Skip to content

Commit 5af841f

Browse files
authored
feat: implement grit format command (#595)
1 parent 62e55e8 commit 5af841f

30 files changed

+1249
-203
lines changed

Cargo.lock

+653-144
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cli/Cargo.toml

+3-2
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ path = "src/lib.rs"
1919
anyhow = { version = "1.0.70" }
2020
clap = { version = "4.1.13", features = ["derive"] }
2121
indicatif = { version = "0.17.5" }
22-
# Do *NOT* upgrade beyond 1.0.171 until https://github.com/serde-rs/serde/issues/2538 is fixed
23-
serde = { version = "1.0.164", features = ["derive"] }
22+
serde = { version = "1.0.217", features = ["derive"] }
2423
serde_json = { version = "1.0.96" }
2524
uuid = { version = "1.1", features = ["v4", "serde"] }
2625
tokio = { version = "1", features = ["full"] }
@@ -88,6 +87,8 @@ tracing-subscriber = { version = "0.3", default-features = false, optional = tru
8887
tracing-log = { version = "0.2.0", optional = true }
8988

9089
fs-err = { version = "2.11.0" }
90+
biome_grit_parser = { git = "https://github.com/biomejs/biome" }
91+
biome_grit_formatter = { git = "https://github.com/biomejs/biome" }
9192

9293
[target.'cfg(not(windows))'.dependencies]
9394
openssl = { version = "0.10", features = ["vendored"] }

crates/cli/src/commands/format.rs

+238
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
use crate::{
2+
resolver::{resolve_from_cwd, GritModuleResolver, Source},
3+
ux::{format_diff, DiffString},
4+
};
5+
use anyhow::{anyhow, bail, ensure, Context, Result};
6+
use biome_grit_formatter::context::GritFormatOptions;
7+
use clap::Args;
8+
use colored::Colorize;
9+
use marzano_core::api::MatchResult;
10+
use marzano_gritmodule::{config::ResolvedGritDefinition, parser::PatternFileExt};
11+
use marzano_util::{rich_path::RichFile, runtime::ExecutionContext};
12+
use rayon::iter::{IntoParallelIterator, ParallelIterator};
13+
use serde::Serialize;
14+
use std::collections::BTreeMap;
15+
16+
#[derive(Args, Debug, Serialize, Clone)]
17+
pub struct FormatArgs {
18+
/// Write formats to file instead of just showing them
19+
#[clap(long)]
20+
pub write: bool,
21+
}
22+
23+
pub async fn run_format(arg: &FormatArgs) -> Result<()> {
24+
let (resolved, _) = resolve_from_cwd(&Source::Local).await?;
25+
26+
let file_path_to_resolved = group_resolved_patterns_by_group(resolved);
27+
let mut results = file_path_to_resolved
28+
.into_par_iter()
29+
.map(|(file_path, resolved_patterns)| {
30+
let result = format_file_resolved_patterns(&file_path, resolved_patterns, arg.clone());
31+
(file_path, result)
32+
})
33+
.collect::<Vec<_>>();
34+
35+
// sort outputs to ensure consistent stdout output
36+
// also avoid using sort_by_key to prevent additional cloning of file_path
37+
results.sort_by(|(file_path, _), (other_file_path, _)| file_path.cmp(other_file_path));
38+
39+
for (file_path, result) in results {
40+
match result {
41+
Err(error) => eprintln!("couldn't format '{}': {error:?}", file_path),
42+
Ok(Some(diff)) => println!("{}:\n{}", file_path.bold(), diff),
43+
Ok(None) => (), // `args.write` is true or file is already formated
44+
}
45+
}
46+
Ok(())
47+
}
48+
49+
fn group_resolved_patterns_by_group(
50+
resolved: Vec<ResolvedGritDefinition>,
51+
) -> Vec<(String, Vec<ResolvedGritDefinition>)> {
52+
resolved.into_iter().fold(Vec::new(), |mut acc, resolved| {
53+
let file_path = &resolved.config.path;
54+
if let Some((_, resolved_patterns)) = acc
55+
.iter_mut()
56+
.find(|(resolv_file_path, _)| resolv_file_path == file_path)
57+
{
58+
resolved_patterns.push(resolved);
59+
} else {
60+
acc.push((file_path.clone(), vec![resolved]));
61+
}
62+
acc
63+
})
64+
}
65+
66+
fn format_file_resolved_patterns(
67+
file_path: &str,
68+
patterns: Vec<ResolvedGritDefinition>,
69+
arg: FormatArgs,
70+
) -> Result<Option<DiffString>> {
71+
let first_pattern = patterns
72+
.first()
73+
.ok_or_else(|| anyhow!("patterns is empty"))?;
74+
let first_pattern_raw_data = first_pattern
75+
.config
76+
.raw
77+
.as_ref()
78+
.ok_or_else(|| anyhow!("pattern doesn't have raw data"))?;
79+
let old_file_content = &first_pattern_raw_data.content;
80+
81+
let new_file_content = match first_pattern_raw_data.format {
82+
PatternFileExt::Yaml => format_yaml_file(&patterns, old_file_content)?,
83+
PatternFileExt::Grit => format_grit_code(old_file_content)?,
84+
PatternFileExt::Md => {
85+
let hunks = patterns
86+
.iter()
87+
.map(format_pattern_as_hunk_changes)
88+
.collect::<Result<Vec<HunkChange>>>()?;
89+
apply_hunk_changes(old_file_content, hunks)
90+
}
91+
};
92+
93+
if &new_file_content == old_file_content {
94+
return Ok(None);
95+
}
96+
97+
if arg.write {
98+
std::fs::write(file_path, new_file_content).with_context(|| "could not write to file")?;
99+
Ok(None)
100+
} else {
101+
Ok(Some(format_diff(old_file_content, &new_file_content)))
102+
}
103+
}
104+
105+
/// bubble clause that finds a grit pattern with name "\<pattern_name\>" in yaml and
106+
/// replaces it's body to "\<new_body\>", `format_yaml_file` uses this pattern to replace
107+
/// pattern bodies with formatted ones
108+
const YAML_REPLACE_BODY_PATERN: &str = r#"
109+
bubble file($body) where {
110+
$body <: contains block_mapping(items=$items) where {
111+
$items <: within `patterns: $_`,
112+
$items <: contains `name: $name`,
113+
$name <: "<pattern_name>",
114+
$items <: contains `body: $yaml_body`,
115+
$new_body = "<new_body>",
116+
$yaml_body => $new_body
117+
},
118+
}
119+
"#;
120+
121+
/// format each pattern and use gritql pattern to match and rewrite
122+
fn format_yaml_file(patterns: &[ResolvedGritDefinition], file_content: &str) -> Result<String> {
123+
let bubbles = patterns
124+
.iter()
125+
.map(|pattern| {
126+
let formatted_body = format_grit_code(&pattern.body)
127+
.with_context(|| format!("could not format '{}'", pattern.name()))?;
128+
let bubble = YAML_REPLACE_BODY_PATERN
129+
.replace("<pattern_name>", pattern.name())
130+
.replace("<new_body>", &format_yaml_body_code(&formatted_body));
131+
Ok(bubble)
132+
})
133+
.collect::<Result<Vec<_>>>()?
134+
.join(",\n");
135+
let pattern_body = format!("language yaml\nsequential{{ {bubbles} }}");
136+
apply_grit_rewrite(file_content, &pattern_body)
137+
}
138+
139+
fn format_yaml_body_code(input: &str) -> String {
140+
// yaml body still needs two indentation to look good
141+
let body_with_prefix = prefix_lines(input, &" ".repeat(2));
142+
let escaped_body = body_with_prefix.replace("\"", "\\\"");
143+
// body: |
144+
// escaped_body
145+
format!("|\n{escaped_body}")
146+
}
147+
148+
fn prefix_lines(input: &str, prefix: &str) -> String {
149+
input
150+
.lines()
151+
.map(|line| {
152+
if line.is_empty() {
153+
line.to_owned()
154+
} else {
155+
format!("{prefix}{line}")
156+
}
157+
})
158+
.collect::<Vec<_>>()
159+
.join("\n")
160+
}
161+
162+
fn apply_grit_rewrite(input: &str, pattern: &str) -> Result<String> {
163+
let resolver = GritModuleResolver::new();
164+
let rich_pattern = resolver.make_pattern(pattern, None)?;
165+
166+
let compiled = rich_pattern
167+
.compile(&BTreeMap::new(), None, None, None)
168+
.map(|cr| cr.problem)
169+
.with_context(|| "could not compile pattern")?;
170+
171+
let rich_file = RichFile::new(String::new(), input.to_owned());
172+
let runtime = ExecutionContext::default();
173+
for result in compiled.execute_file(&rich_file, &runtime) {
174+
if let MatchResult::Rewrite(rewrite) = result {
175+
let content = rewrite
176+
.rewritten
177+
.content
178+
.ok_or_else(|| anyhow!("rewritten content is empty"))?;
179+
return Ok(content);
180+
}
181+
}
182+
bail!("no rewrite result after applying grit pattern")
183+
}
184+
185+
fn format_pattern_as_hunk_changes(pattern: &ResolvedGritDefinition) -> Result<HunkChange> {
186+
let formatted_grit_code = format_grit_code(&pattern.body)?;
187+
let body_range = pattern
188+
.config
189+
.range
190+
.ok_or_else(|| anyhow!("pattern doesn't have config range"))?;
191+
Ok(HunkChange {
192+
starting_byte: body_range.start_byte as usize,
193+
ending_byte: body_range.end_byte as usize,
194+
new_content: formatted_grit_code,
195+
})
196+
}
197+
198+
/// format grit code using `biome`
199+
fn format_grit_code(source: &str) -> Result<String> {
200+
let parsed = biome_grit_parser::parse_grit(source);
201+
ensure!(
202+
parsed.diagnostics().is_empty(),
203+
"biome couldn't parse: {}",
204+
parsed
205+
.diagnostics()
206+
.iter()
207+
.map(|diag| diag.message.to_string())
208+
.collect::<Vec<_>>()
209+
.join("\n")
210+
);
211+
212+
let options = GritFormatOptions::default();
213+
let doc = biome_grit_formatter::format_node(options, &parsed.syntax())
214+
.with_context(|| "biome couldn't format")?;
215+
Ok(doc.print()?.into_code())
216+
}
217+
218+
/// Represent a hunk of text that needs to be changed
219+
#[derive(Debug)]
220+
struct HunkChange {
221+
starting_byte: usize,
222+
ending_byte: usize,
223+
new_content: String,
224+
}
225+
226+
/// returns a new string that applies hunk changes
227+
fn apply_hunk_changes(input: &str, mut hunks: Vec<HunkChange>) -> String {
228+
if hunks.is_empty() {
229+
return input.to_string();
230+
}
231+
hunks.sort_by_key(|hunk| -(hunk.starting_byte as isize));
232+
let mut buffer = input.to_owned();
233+
for hunk in hunks {
234+
let hunk_range = hunk.starting_byte..hunk.ending_byte;
235+
buffer.replace_range(hunk_range, &hunk.new_content);
236+
}
237+
buffer
238+
}

crates/cli/src/commands/mod.rs

+6
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub(crate) mod lsp;
1616

1717
pub(crate) mod check;
1818

19+
pub(crate) mod format;
1920
pub(crate) mod parse;
2021
pub(crate) mod patterns;
2122
pub(crate) mod patterns_list;
@@ -82,6 +83,7 @@ use check::CheckArg;
8283
use clap::Parser;
8384
use clap::Subcommand;
8485
use doctor::DoctorArgs;
86+
use format::{run_format, FormatArgs};
8587
use indicatif::MultiProgress;
8688
use indicatif_log_bridge::LogWrapper;
8789
use init::InitArgs;
@@ -171,6 +173,8 @@ pub enum Commands {
171173
Plumbing(PlumbingArgs),
172174
/// Display version information about the CLI and agents
173175
Version(VersionArgs),
176+
/// Format grit files under current directory
177+
Format(FormatArgs),
174178
/// Generate documentation for the Grit CLI (internal use only)
175179
#[cfg(feature = "docgen")]
176180
#[clap(hide = true)]
@@ -217,6 +221,7 @@ impl fmt::Display for Commands {
217221
},
218222
Commands::Plumbing(_) => write!(f, "plumbing"),
219223
Commands::Version(_) => write!(f, "version"),
224+
Commands::Format(_) => write!(f, "format"),
220225
#[cfg(feature = "docgen")]
221226
Commands::Docgen(_) => write!(f, "docgen"),
222227
#[cfg(feature = "server")]
@@ -458,6 +463,7 @@ async fn run_command(_use_tracing: bool) -> Result<()> {
458463
run_plumbing(arg, multi, &mut apply_details, app.format_flags).await
459464
}
460465
Commands::Version(arg) => run_version(arg).await,
466+
Commands::Format(arg) => run_format(&arg).await,
461467
#[cfg(feature = "docgen")]
462468
Commands::Docgen(arg) => run_docgen(arg).await,
463469
#[cfg(feature = "server")]

crates/cli/src/ux.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ impl fmt::Display for DiffString {
199199
}
200200
}
201201

202-
fn format_diff(expected: &str, actual: &str) -> DiffString {
202+
pub fn format_diff(expected: &str, actual: &str) -> DiffString {
203203
let mut output = String::new();
204204
let diff = TextDiff::from_lines(expected, actual);
205205

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
version: 0.0.1
2+
patterns:
3+
- name: aspect_ratio_yaml
4+
description: Yaml version of aspect_ratio.md
5+
body: |
6+
language css
7+
8+
`a { $props }` where {
9+
$props <: contains `aspect-ratio: $x`
10+
}
11+
12+
- file: ./others/test_move_import.md
13+
14+
- name: some_json_pattern
15+
body: |
16+
language json
17+
18+
`account: $val` where {
19+
$val <: contains `password: $password`,
20+
$password => raw`hidden`
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
private: true
3+
tags: [private]
4+
---
5+
```grit
6+
language js
7+
8+
`sanitizeFilePath` as $s where {
9+
move_import(`sanitizeFilePath`, `'@getgrit/universal'`)
10+
}
11+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
title: Aspect ratio
3+
---
4+
5+
```grit
6+
language css
7+
8+
`a { $props }` where {
9+
$props <: contains `aspect-ratio: $x`
10+
}
11+
```
12+
13+
## Matches the right selector and declaration block
14+
15+
```css
16+
a {
17+
width: calc(100% - 80px);
18+
aspect-ratio: 1/2;
19+
font-size: calc(10px + (56 - 10) * ((100vw - 320px) / (1920 - 320)));
20+
}
21+
22+
#some-id {
23+
some-property: 5px;
24+
}
25+
26+
a.b ~ c.d {
27+
}
28+
.e.f + .g.h {
29+
}
30+
31+
@font-face {
32+
font-family: 'Open Sans';
33+
src: url('/a') format('woff2'), url('/b/c') format('woff');
34+
}
35+
```
36+
37+
```css
38+
a {
39+
width: calc(100% - 80px);
40+
aspect-ratio: 1/2;
41+
font-size: calc(10px + (56 - 10) * ((100vw - 320px) / (1920 - 320)));
42+
}
43+
44+
#some-id {
45+
some-property: 5px;
46+
}
47+
48+
a.b ~ c.d {
49+
}
50+
.e.f + .g.h {
51+
}
52+
53+
@font-face {
54+
font-family: 'Open Sans';
55+
src: url('/a') format('woff2'), url('/b/c') format('woff');
56+
}
57+
```

0 commit comments

Comments
 (0)