pax_language/formatting/
mod.rs1mod 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#[derive(Debug, Default, PartialEq, Eq)]
13pub struct FormatSummary {
14 pub files_checked: usize,
16 pub changed_files: Vec<PathBuf>,
18}
19
20pub 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
53pub fn format_file(file_path: &str) -> Result<(), Report> {
55 format_path(Path::new(file_path), false).map(|_| ())
56}
57
58pub 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}