Skip to main content

pax_language/formatting/
mod.rs

1mod rules;
2
3use crate::helpers::{replace_by_line_column, InlinedTemplateFinder};
4use crate::{parse_pax_err, Pair, Rule};
5use eyre::{Report, WrapErr};
6use std::fs;
7use std::path::{Path, PathBuf};
8use syn::parse_file;
9use syn::visit::Visit;
10
11/// Result of formatting a file or directory tree.
12#[derive(Debug, Default, PartialEq, Eq)]
13pub struct FormatSummary {
14    /// Pax-bearing source files inspected.
15    pub files_checked: usize,
16    /// Files whose canonical representation differs from disk.
17    pub changed_files: Vec<PathBuf>,
18}
19
20/// Format one Pax template string.
21pub fn format_pax_template(code: String) -> Result<String, eyre::Report> {
22    let pax_component_definition = parse_pax_err(Rule::pax_component_definition, code.as_str())?;
23    reject_duplicate_class_attributes(&pax_component_definition)?;
24    Ok(rules::format(pax_component_definition))
25}
26
27fn reject_duplicate_class_attributes(pair: &Pair<Rule>) -> Result<(), Report> {
28    if matches!(pair.as_rule(), Rule::open_tag | Rule::self_closing_tag) {
29        let class_attribute_count = pair
30            .clone()
31            .into_inner()
32            .filter(|child| child.as_rule() == Rule::attribute_key_value_pair)
33            .filter(|attribute| {
34                attribute
35                    .clone()
36                    .into_inner()
37                    .any(|child| child.as_rule() == Rule::class_attribute)
38            })
39            .count();
40        if class_attribute_count > 1 {
41            return Err(Report::msg(
42                "a tag may have only one `class` attribute; use a string list for multiple classes",
43            ));
44        }
45    }
46
47    for child in pair.clone().into_inner() {
48        reject_duplicate_class_attributes(&child)?;
49    }
50    Ok(())
51}
52
53/// Format either a `.pax` file or an inlined Pax template inside a Rust file.
54pub fn format_file(file_path: &str) -> Result<(), Report> {
55    format_path(Path::new(file_path), false).map(|_| ())
56}
57
58/// Format a `.pax`/`.rs` file or every Pax-bearing source file below a directory.
59///
60/// Directory traversal skips generated and dependency directories (`.git`, `.pax`,
61/// `target`, and `node_modules`). When `check` is true, files are inspected but not
62/// written, and changed paths are returned in [`FormatSummary::changed_files`].
63pub fn format_path(path: &Path, check: bool) -> Result<FormatSummary, Report> {
64    if !path.exists() {
65        return Err(Report::msg(format!(
66            "format path does not exist: {}",
67            path.display()
68        )));
69    }
70
71    let mut files = Vec::new();
72    collect_format_files(path, &mut files)?;
73    files.sort();
74
75    if files.is_empty() && path.is_file() {
76        return Err(Report::msg("Unsupported file extension"));
77    }
78
79    let mut summary = FormatSummary::default();
80    for file in files {
81        let content = fs::read_to_string(&file)
82            .wrap_err_with(|| format!("failed to read {}", file.display()))?;
83        if file.extension().and_then(|extension| extension.to_str()) == Some("rs")
84            && !content.contains("#[inlined")
85        {
86            continue;
87        }
88
89        summary.files_checked += 1;
90        let formatted_content = format_source(&file, &content)
91            .wrap_err_with(|| format!("failed to format {}", file.display()))?;
92        if content != formatted_content {
93            summary.changed_files.push(file.clone());
94            if !check {
95                fs::write(file, formatted_content)?;
96            }
97        }
98    }
99
100    Ok(summary)
101}
102
103fn collect_format_files(path: &Path, files: &mut Vec<PathBuf>) -> Result<(), Report> {
104    if path.is_file() {
105        if matches!(
106            path.extension().and_then(|extension| extension.to_str()),
107            Some("pax" | "rs")
108        ) {
109            files.push(path.to_path_buf());
110        }
111        return Ok(());
112    }
113
114    for entry in fs::read_dir(path)? {
115        let entry = entry?;
116        let child = entry.path();
117        if child.is_dir() {
118            let directory_name = child.file_name().and_then(|name| name.to_str());
119            if matches!(
120                directory_name,
121                Some(".git" | ".pax" | "target" | "node_modules")
122            ) {
123                continue;
124            }
125            collect_format_files(&child, files)?;
126        } else if matches!(
127            child.extension().and_then(|extension| extension.to_str()),
128            Some("pax" | "rs")
129        ) {
130            files.push(child);
131        }
132    }
133
134    Ok(())
135}
136
137fn format_source(path: &Path, content: &str) -> Result<String, Report> {
138    let formatted = match path.extension().and_then(|extension| extension.to_str()) {
139        Some("pax") => format_pax_template(content.to_string())?,
140        Some("rs") => format_pax_in_rust_source(content)?,
141        _ => return Err(Report::msg("Unsupported file extension")),
142    };
143
144    Ok(with_final_newline(formatted))
145}
146
147fn with_final_newline(mut content: String) -> String {
148    while content.ends_with('\n') {
149        content.pop();
150        if content.ends_with('\r') {
151            content.pop();
152        }
153    }
154    content.push('\n');
155    content
156}
157
158fn format_pax_in_rust_source(content: &str) -> Result<String, Report> {
159    let ast = parse_file(&content)?;
160
161    let mut finder = InlinedTemplateFinder::new(content.to_string());
162    finder.visit_file(&ast);
163
164    let mut modified_content = content.to_string();
165    for template in finder.templates {
166        let formatted_template = format_pax_template(template.template)?;
167        let new_content = format!("(\n{}\n)", formatted_template);
168        modified_content =
169            replace_by_line_column(&modified_content, template.start, template.end, new_content)
170                .unwrap();
171    }
172    Ok(modified_content)
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use std::time::{SystemTime, UNIX_EPOCH};
179
180    fn temp_directory() -> PathBuf {
181        let unique = SystemTime::now()
182            .duration_since(UNIX_EPOCH)
183            .unwrap()
184            .as_nanos();
185        let path =
186            std::env::temp_dir().join(format!("pax-format-tests-{}-{unique}", std::process::id()));
187        fs::create_dir_all(&path).unwrap();
188        path
189    }
190
191    #[test]
192    fn canonicalizes_static_classes_and_final_newline() {
193        let directory = temp_directory();
194        let path = directory.join("component.pax");
195        fs::write(&path, r#"<Rectangle class=["foo"]/>"#).unwrap();
196
197        let check = format_path(&directory, true).unwrap();
198        assert_eq!(check.changed_files, vec![path.clone()]);
199        assert_eq!(
200            fs::read_to_string(&path).unwrap(),
201            r#"<Rectangle class=["foo"]/>"#
202        );
203
204        format_path(&directory, false).unwrap();
205        assert_eq!(
206            fs::read_to_string(&path).unwrap(),
207            "<Rectangle class=\"foo\"/>\n"
208        );
209        assert!(format_path(&directory, true)
210            .unwrap()
211            .changed_files
212            .is_empty());
213
214        fs::remove_dir_all(directory).unwrap();
215    }
216
217    #[test]
218    fn formats_literal_and_expression_class_bindings() {
219        assert_eq!(
220            format_pax_template(r#"<Text text="Hello" class="heading" id=title/>"#.to_string())
221                .unwrap(),
222            r#"<Text class="heading" text="Hello" id=title/>"#
223        );
224        assert_eq!(
225            format_pax_template(r#"<Rectangle class=["base", "selected"]/>"#.to_string()).unwrap(),
226            r#"<Rectangle class=["base", "selected"]/>"#
227        );
228        assert_eq!(
229            format_pax_template("<Rectangle class={self.classes}/>".to_string()).unwrap(),
230            "<Rectangle class={self.classes}/>"
231        );
232        assert_eq!(
233            format_pax_template(r#"<Rectangle class={"temporary"}/>"#.to_string()).unwrap(),
234            r#"<Rectangle class={"temporary"}/>"#
235        );
236        assert!(format_pax_template("<Rectangle class=legacy/>".to_string()).is_err());
237        assert!(format_pax_template("<Rectangle class=[legacy, selected]/>".to_string()).is_err());
238        assert!(
239            format_pax_template(r#"<Rectangle class="base" class="selected"/>"#.to_string())
240                .is_err()
241        );
242    }
243
244    #[test]
245    fn recursively_formats_sources_but_skips_generated_directories() {
246        let directory = temp_directory();
247        let source = directory.join("src");
248        let generated = directory.join("target");
249        fs::create_dir_all(&source).unwrap();
250        fs::create_dir_all(&generated).unwrap();
251        fs::write(source.join("component.pax"), "<Rectangle/>").unwrap();
252        fs::write(source.join("plain.rs"), "fn main() {}").unwrap();
253        fs::write(generated.join("generated.pax"), "<Rectangle/>").unwrap();
254
255        let summary = format_path(&directory, false).unwrap();
256        assert_eq!(summary.files_checked, 1);
257        assert_eq!(
258            fs::read_to_string(source.join("component.pax")).unwrap(),
259            "<Rectangle />\n"
260        );
261        assert_eq!(
262            fs::read_to_string(generated.join("generated.pax")).unwrap(),
263            "<Rectangle/>"
264        );
265        assert_eq!(
266            fs::read_to_string(source.join("plain.rs")).unwrap(),
267            "fn main() {}"
268        );
269
270        fs::remove_dir_all(directory).unwrap();
271    }
272}