Skip to content

Commit 007bb19

Browse files
Dunqingclaude
andcommitted
feat(formatter): add snapshot-based test infrastructure
Implement a flexible snapshot-based testing framework for oxc_formatter using `insta` and build-time test generation. - **Auto-discovery**: Tests are automatically discovered from `tests/fixtures/` directory - **Individual test functions**: Each fixture file gets its own test function for easy identification - **Hierarchical options**: Support for `options.json` files at any directory level - **Multiple option sets**: Test the same input with multiple formatting configurations - **Co-located snapshots**: Snapshot files are stored next to test files (e.g., `foo.js.snap`) - **Comprehensive README**: Detailed documentation for adding and running tests - **build.rs**: Scans `tests/fixtures/` and generates test functions at build time - **tests/fixtures/mod.rs**: Core test infrastructure with option resolution and snapshot generation - **tests/README.md**: Complete guide for using the test framework - **Sample tests**: Example tests demonstrating JS, JSX, TS, and nested configurations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d776a17 commit 007bb19

File tree

5 files changed

+637
-0
lines changed

5 files changed

+637
-0
lines changed

crates/oxc_formatter/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,7 @@ insta = { workspace = true }
3939
oxc_parser = { workspace = true }
4040
pico-args = { workspace = true }
4141
project-root = { workspace = true }
42+
serde_json = { workspace = true }
43+
44+
[build-dependencies]
45+
oxc_span = { workspace = true }

crates/oxc_formatter/build.rs

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
use std::{
2+
collections::BTreeMap,
3+
env,
4+
fs::{self, File},
5+
io::Write,
6+
path::{Path, PathBuf},
7+
};
8+
9+
use oxc_span::SourceType;
10+
11+
fn main() {
12+
let out_dir = env::var("OUT_DIR").unwrap();
13+
let dest_path = Path::new(&out_dir).join("generated_tests.rs");
14+
let mut f = File::create(&dest_path).unwrap();
15+
16+
let fixtures_dir = Path::new("tests/fixtures");
17+
18+
if !fixtures_dir.exists() {
19+
// If no fixtures directory exists, create an empty file
20+
writeln!(f, "// No test fixtures found").unwrap();
21+
return;
22+
}
23+
24+
writeln!(f, "// Auto-generated test modules and functions").unwrap();
25+
writeln!(f).unwrap();
26+
27+
// Collect all test files organized by directory
28+
let mut dir_structure = DirStructure::new();
29+
collect_tests(fixtures_dir, fixtures_dir, &mut dir_structure).unwrap();
30+
31+
// Generate nested modules
32+
generate_modules(&mut f, &dir_structure, 0).unwrap();
33+
34+
println!("cargo:rerun-if-changed=tests/fixtures");
35+
}
36+
37+
#[derive(Default)]
38+
struct DirStructure {
39+
/// Test files in this directory (relative paths from fixtures root)
40+
test_files: Vec<PathBuf>,
41+
/// Subdirectories
42+
subdirs: BTreeMap<String, DirStructure>,
43+
}
44+
45+
impl DirStructure {
46+
fn new() -> Self {
47+
Self::default()
48+
}
49+
}
50+
51+
/// Collect all test files and organize them by directory
52+
fn collect_tests(dir: &Path, base_dir: &Path, structure: &mut DirStructure) -> std::io::Result<()> {
53+
let entries = fs::read_dir(dir)?;
54+
55+
for entry in entries {
56+
let entry = entry?;
57+
let path = entry.path();
58+
59+
if path.is_dir() {
60+
let dir_name = path.file_name().unwrap().to_string_lossy().to_string();
61+
let subdir = structure.subdirs.entry(dir_name).or_default();
62+
collect_tests(&path, base_dir, subdir)?;
63+
} else if is_test_file(&path) {
64+
let relative_path = path.strip_prefix(base_dir).unwrap().to_path_buf();
65+
structure.test_files.push(relative_path);
66+
}
67+
}
68+
69+
Ok(())
70+
}
71+
72+
/// Generate nested modules for the directory structure
73+
fn generate_modules(
74+
f: &mut File,
75+
structure: &DirStructure,
76+
indent_level: usize,
77+
) -> std::io::Result<()> {
78+
let indent = " ".repeat(indent_level);
79+
80+
// Generate test functions for files in this directory
81+
for test_file in &structure.test_files {
82+
generate_test_function(f, test_file, indent_level)?;
83+
}
84+
85+
// Generate submodules
86+
for (dir_name, subdir) in &structure.subdirs {
87+
let module_name = sanitize_module_name(dir_name);
88+
89+
writeln!(f, "{indent}#[cfg(test)]")?;
90+
writeln!(f, "{indent}mod {module_name} {{")?;
91+
writeln!(f, "{indent} use super::test_file;")?;
92+
writeln!(f)?;
93+
94+
generate_modules(f, subdir, indent_level + 1)?;
95+
96+
writeln!(f, "{indent}}}")?;
97+
writeln!(f)?;
98+
}
99+
100+
Ok(())
101+
}
102+
103+
fn is_test_file(path: &Path) -> bool {
104+
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
105+
SourceType::from_extension(ext).is_ok()
106+
} else {
107+
false
108+
}
109+
}
110+
111+
fn generate_test_function(
112+
f: &mut File,
113+
relative_path: &Path,
114+
indent_level: usize,
115+
) -> std::io::Result<()> {
116+
let indent = " ".repeat(indent_level);
117+
// Use only the filename for the test name (directories are handled by modules)
118+
let test_name = file_to_test_name(relative_path.file_name().unwrap().to_str().unwrap());
119+
120+
writeln!(f, "{indent}#[test]")?;
121+
writeln!(f, "{indent}fn {test_name}() {{")?;
122+
writeln!(
123+
f,
124+
"{} let path = std::path::Path::new(\"tests/fixtures/{}\");",
125+
indent,
126+
relative_path.display()
127+
)?;
128+
writeln!(f, "{indent} test_file(path);")?;
129+
writeln!(f, "{indent}}}")?;
130+
writeln!(f)?;
131+
132+
Ok(())
133+
}
134+
135+
/// Convert filename to a valid Rust test function name
136+
fn file_to_test_name(filename: &str) -> String {
137+
let mut name = String::new();
138+
139+
// Replace non-alphanumeric characters with underscores
140+
for c in filename.chars() {
141+
if c.is_alphanumeric() {
142+
name.push(c.to_ascii_lowercase());
143+
} else {
144+
name.push('_');
145+
}
146+
}
147+
148+
// Remove file extension
149+
if let Some(pos) = name.rfind('_') {
150+
let after_underscore = &name[pos + 1..];
151+
if SourceType::from_extension(after_underscore).is_ok() {
152+
name.truncate(pos);
153+
}
154+
}
155+
156+
sanitize_identifier(name, "test")
157+
}
158+
159+
/// Sanitize directory name to be a valid Rust module name
160+
fn sanitize_module_name(name: &str) -> String {
161+
let mut result = String::new();
162+
163+
for c in name.chars() {
164+
if c.is_alphanumeric() {
165+
result.push(c.to_ascii_lowercase());
166+
} else {
167+
result.push('_');
168+
}
169+
}
170+
171+
sanitize_identifier(result, "")
172+
}
173+
174+
/// Sanitize a string to be a valid Rust identifier
175+
/// - prefix: prefix to add if identifier is empty or starts with digit (e.g., "test" or "")
176+
fn sanitize_identifier(mut name: String, prefix: &str) -> String {
177+
// Ensure it starts with a letter or underscore
178+
if name.is_empty() || name.chars().next().unwrap().is_numeric() {
179+
name = if prefix.is_empty() {
180+
format!("_{name}")
181+
} else {
182+
format!("{prefix}_{name}")
183+
};
184+
}
185+
186+
// Handle reserved keywords
187+
if is_reserved_keyword(&name) {
188+
return format!("r#{name}");
189+
}
190+
191+
name
192+
}
193+
194+
/// Check if a string is a Rust reserved keyword
195+
fn is_reserved_keyword(s: &str) -> bool {
196+
matches!(
197+
s,
198+
"mod" | "fn"
199+
| "let"
200+
| "mut"
201+
| "const"
202+
| "static"
203+
| "type"
204+
| "use"
205+
| "as"
206+
| "async"
207+
| "await"
208+
| "break"
209+
| "continue"
210+
| "crate"
211+
| "dyn"
212+
| "else"
213+
| "enum"
214+
| "extern"
215+
| "false"
216+
| "for"
217+
| "if"
218+
| "impl"
219+
| "in"
220+
| "loop"
221+
| "match"
222+
| "move"
223+
| "pub"
224+
| "ref"
225+
| "return"
226+
| "self"
227+
| "Self"
228+
| "struct"
229+
| "super"
230+
| "trait"
231+
| "true"
232+
| "unsafe"
233+
| "where"
234+
| "while"
235+
| "abstract"
236+
| "become"
237+
| "box"
238+
| "do"
239+
| "final"
240+
| "macro"
241+
| "override"
242+
| "priv"
243+
| "typeof"
244+
| "unsized"
245+
| "virtual"
246+
| "yield"
247+
| "try"
248+
)
249+
}

0 commit comments

Comments
 (0)