mod rules;
use crate::helpers::{replace_by_line_column, InlinedTemplateFinder};
use crate::{parse_pax_err, Pair, Rule};
use eyre::{Report, WrapErr};
use std::fs;
use std::path::{Path, PathBuf};
use syn::parse_file;
use syn::visit::Visit;
#[derive(Debug, Default, PartialEq, Eq)]
pub struct FormatSummary {
pub files_checked: usize,
pub changed_files: Vec<PathBuf>,
}
pub fn format_pax_template(code: String) -> Result<String, eyre::Report> {
let pax_component_definition = parse_pax_err(Rule::pax_component_definition, code.as_str())?;
reject_duplicate_class_attributes(&pax_component_definition)?;
Ok(rules::format(pax_component_definition))
}
fn reject_duplicate_class_attributes(pair: &Pair<Rule>) -> Result<(), Report> {
if matches!(pair.as_rule(), Rule::open_tag | Rule::self_closing_tag) {
let class_attribute_count = pair
.clone()
.into_inner()
.filter(|child| child.as_rule() == Rule::attribute_key_value_pair)
.filter(|attribute| {
attribute
.clone()
.into_inner()
.any(|child| child.as_rule() == Rule::class_attribute)
})
.count();
if class_attribute_count > 1 {
return Err(Report::msg(
"a tag may have only one `class` attribute; use a string list for multiple classes",
));
}
}
for child in pair.clone().into_inner() {
reject_duplicate_class_attributes(&child)?;
}
Ok(())
}
pub fn format_file(file_path: &str) -> Result<(), Report> {
format_path(Path::new(file_path), false).map(|_| ())
}
pub fn format_path(path: &Path, check: bool) -> Result<FormatSummary, Report> {
if !path.exists() {
return Err(Report::msg(format!(
"format path does not exist: {}",
path.display()
)));
}
let mut files = Vec::new();
collect_format_files(path, &mut files)?;
files.sort();
if files.is_empty() && path.is_file() {
return Err(Report::msg("Unsupported file extension"));
}
let mut summary = FormatSummary::default();
for file in files {
let content = fs::read_to_string(&file)
.wrap_err_with(|| format!("failed to read {}", file.display()))?;
if file.extension().and_then(|extension| extension.to_str()) == Some("rs")
&& !content.contains("#[inlined")
{
continue;
}
summary.files_checked += 1;
let formatted_content = format_source(&file, &content)
.wrap_err_with(|| format!("failed to format {}", file.display()))?;
if content != formatted_content {
summary.changed_files.push(file.clone());
if !check {
fs::write(file, formatted_content)?;
}
}
}
Ok(summary)
}
fn collect_format_files(path: &Path, files: &mut Vec<PathBuf>) -> Result<(), Report> {
if path.is_file() {
if matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("pax" | "rs")
) {
files.push(path.to_path_buf());
}
return Ok(());
}
for entry in fs::read_dir(path)? {
let entry = entry?;
let child = entry.path();
if child.is_dir() {
let directory_name = child.file_name().and_then(|name| name.to_str());
if matches!(
directory_name,
Some(".git" | ".pax" | "target" | "node_modules")
) {
continue;
}
collect_format_files(&child, files)?;
} else if matches!(
child.extension().and_then(|extension| extension.to_str()),
Some("pax" | "rs")
) {
files.push(child);
}
}
Ok(())
}
fn format_source(path: &Path, content: &str) -> Result<String, Report> {
let formatted = match path.extension().and_then(|extension| extension.to_str()) {
Some("pax") => format_pax_template(content.to_string())?,
Some("rs") => format_pax_in_rust_source(content)?,
_ => return Err(Report::msg("Unsupported file extension")),
};
Ok(with_final_newline(formatted))
}
fn with_final_newline(mut content: String) -> String {
while content.ends_with('\n') {
content.pop();
if content.ends_with('\r') {
content.pop();
}
}
content.push('\n');
content
}
fn format_pax_in_rust_source(content: &str) -> Result<String, Report> {
let ast = parse_file(&content)?;
let mut finder = InlinedTemplateFinder::new(content.to_string());
finder.visit_file(&ast);
let mut modified_content = content.to_string();
for template in finder.templates {
let formatted_template = format_pax_template(template.template)?;
let new_content = format!("(\n{}\n)", formatted_template);
modified_content =
replace_by_line_column(&modified_content, template.start, template.end, new_content)
.unwrap();
}
Ok(modified_content)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_directory() -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path =
std::env::temp_dir().join(format!("pax-format-tests-{}-{unique}", std::process::id()));
fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn canonicalizes_static_classes_and_final_newline() {
let directory = temp_directory();
let path = directory.join("component.pax");
fs::write(&path, r#"<Rectangle class=["foo"]/>"#).unwrap();
let check = format_path(&directory, true).unwrap();
assert_eq!(check.changed_files, vec![path.clone()]);
assert_eq!(
fs::read_to_string(&path).unwrap(),
r#"<Rectangle class=["foo"]/>"#
);
format_path(&directory, false).unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"<Rectangle class=\"foo\"/>\n"
);
assert!(format_path(&directory, true)
.unwrap()
.changed_files
.is_empty());
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn formats_literal_and_expression_class_bindings() {
assert_eq!(
format_pax_template(r#"<Text text="Hello" class="heading" id=title/>"#.to_string())
.unwrap(),
r#"<Text class="heading" text="Hello" id=title/>"#
);
assert_eq!(
format_pax_template(r#"<Rectangle class=["base", "selected"]/>"#.to_string()).unwrap(),
r#"<Rectangle class=["base", "selected"]/>"#
);
assert_eq!(
format_pax_template("<Rectangle class={self.classes}/>".to_string()).unwrap(),
"<Rectangle class={self.classes}/>"
);
assert_eq!(
format_pax_template(r#"<Rectangle class={"temporary"}/>"#.to_string()).unwrap(),
r#"<Rectangle class={"temporary"}/>"#
);
assert!(format_pax_template("<Rectangle class=legacy/>".to_string()).is_err());
assert!(format_pax_template("<Rectangle class=[legacy, selected]/>".to_string()).is_err());
assert!(
format_pax_template(r#"<Rectangle class="base" class="selected"/>"#.to_string())
.is_err()
);
}
#[test]
fn recursively_formats_sources_but_skips_generated_directories() {
let directory = temp_directory();
let source = directory.join("src");
let generated = directory.join("target");
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&generated).unwrap();
fs::write(source.join("component.pax"), "<Rectangle/>").unwrap();
fs::write(source.join("plain.rs"), "fn main() {}").unwrap();
fs::write(generated.join("generated.pax"), "<Rectangle/>").unwrap();
let summary = format_path(&directory, false).unwrap();
assert_eq!(summary.files_checked, 1);
assert_eq!(
fs::read_to_string(source.join("component.pax")).unwrap(),
"<Rectangle />\n"
);
assert_eq!(
fs::read_to_string(generated.join("generated.pax")).unwrap(),
"<Rectangle/>"
);
assert_eq!(
fs::read_to_string(source.join("plain.rs")).unwrap(),
"fn main() {}"
);
fs::remove_dir_all(directory).unwrap();
}
}