forked from
stavola.xyz/mlf
A human-friendly DSL for ATProto Lexicons
1use crate::config::{find_project_root, get_mlf_cache_dir, ConfigError, MlfConfig};
2use crate::workspace_ext::workspace_with_std_and_cache;
3use miette::Diagnostic;
4use mlf_diagnostics::{ParseDiagnostic, ValidationDiagnostic};
5use std::path::PathBuf;
6use thiserror::Error;
7
8#[derive(Error, Debug, Diagnostic)]
9pub enum CheckError {
10 #[error("Failed to read file: {path}")]
11 #[diagnostic(code(mlf::check::read_file))]
12 ReadFile {
13 path: String,
14 #[source]
15 source: std::io::Error,
16 },
17
18 #[error("Failed to parse lexicon: {path}")]
19 #[diagnostic(code(mlf::check::parse_lexicon))]
20 ParseLexicon {
21 path: String,
22 #[help]
23 help: Option<String>,
24 },
25
26 #[error("Failed to parse JSON record")]
27 #[diagnostic(code(mlf::check::parse_json))]
28 ParseJson {
29 #[source]
30 source: serde_json::Error,
31 },
32
33 #[error("Validation errors in lexicon")]
34 #[diagnostic(code(mlf::check::validation_errors))]
35 ValidationErrors {
36 #[help]
37 help: Option<String>,
38 },
39
40
41 #[error("Record validation failed")]
42 #[diagnostic(code(mlf::check::record_validation))]
43 RecordValidation {
44 errors: Vec<mlf_validation::ValidationError>,
45 },
46
47 #[error("Failed to load config: {0}")]
48 #[diagnostic(code(mlf::check::config_error))]
49 ConfigError(#[from] ConfigError),
50}
51
52pub fn run_check(input_paths: Vec<PathBuf>, explicit_root: Option<PathBuf>) -> Result<(), CheckError> {
53 let current_dir = std::env::current_dir()
54 .map_err(|e| CheckError::ReadFile {
55 path: ".".to_string(),
56 source: e,
57 })?;
58
59 // Determine root directory and input paths
60 let (root_dir, file_paths) = if input_paths.is_empty() {
61 // No input provided: must use mlf.toml
62 match find_project_root(¤t_dir) {
63 Ok(project_root) => {
64 let config_path = project_root.join("mlf.toml");
65 let config = MlfConfig::load(&config_path)?;
66 let source_dir = project_root.join(&config.source.directory);
67 let root = explicit_root.unwrap_or_else(|| source_dir.clone());
68 println!("Using source directory from mlf.toml: {}", config.source.directory);
69
70 // Collect all .mlf files from source directory
71 let files = collect_mlf_files(&source_dir)?;
72 (root, files)
73 }
74 Err(ConfigError::NotFound) => {
75 return Err(CheckError::ValidationErrors {
76 help: Some("No input files provided and no mlf.toml found. Please provide input files or create a mlf.toml configuration.".to_string()),
77 });
78 }
79 Err(e) => return Err(CheckError::ConfigError(e)),
80 }
81 } else {
82 // Input provided: determine root
83 let root = if let Some(explicit) = explicit_root {
84 // --root flag takes precedence
85 explicit
86 } else if let Ok(project_root) = find_project_root(¤t_dir) {
87 // Try to use mlf.toml source directory
88 let config_path = project_root.join("mlf.toml");
89 if let Ok(config) = MlfConfig::load(&config_path) {
90 project_root.join(&config.source.directory)
91 } else {
92 current_dir.clone()
93 }
94 } else {
95 // Fall back to current directory
96 current_dir.clone()
97 };
98
99 // Collect files from input paths
100 let mut files = Vec::new();
101 for input_path in input_paths {
102 let path = if input_path.is_absolute() {
103 input_path
104 } else {
105 current_dir.join(input_path)
106 };
107
108 if path.is_dir() {
109 files.extend(collect_mlf_files(&path)?);
110 } else if path.is_file() {
111 files.push(path);
112 } else {
113 return Err(CheckError::ReadFile {
114 path: path.display().to_string(),
115 source: std::io::Error::new(std::io::ErrorKind::NotFound, "Path not found"),
116 });
117 }
118 }
119 (root, files)
120 };
121
122 // Try to load cached lexicons from .mlf directory
123 let current_dir = std::env::current_dir()
124 .map_err(|e| CheckError::ReadFile {
125 path: ".".to_string(),
126 source: e,
127 })?;
128
129 let mlf_cache_dir = find_project_root(¤t_dir)
130 .ok()
131 .map(|root| get_mlf_cache_dir(&root));
132
133 let mut workspace = workspace_with_std_and_cache(mlf_cache_dir.as_deref()).map_err(|e| {
134 eprintln!("Error loading workspace: {}", e);
135 CheckError::ValidationErrors {
136 help: Some(format!("Failed to load workspace: {}", e)),
137 }
138 })?;
139
140 let mut source_files = Vec::new();
141 let mut had_parse_errors = false;
142
143 for file_path in &file_paths {
144 let source = std::fs::read_to_string(file_path).map_err(|source| {
145 CheckError::ReadFile {
146 path: file_path.display().to_string(),
147 source,
148 }
149 })?;
150
151 let filename = file_path.display().to_string();
152
153 let lexicon = match mlf_lang::parse_lexicon(&source) {
154 Ok(lex) => lex,
155 Err(e) => {
156 let diagnostic = ParseDiagnostic::new(filename.clone(), source.clone(), e);
157 eprintln!("{:?}", miette::Report::new(diagnostic));
158 had_parse_errors = true;
159 continue;
160 }
161 };
162
163 let namespace = extract_namespace(&file_path, &root_dir)?;
164
165 if let Err(e) = workspace.add_module(namespace.clone(), lexicon.clone()) {
166 let diagnostic = ValidationDiagnostic::new(filename.clone(), source.clone(), namespace.clone(), e);
167 eprintln!("{:?}", miette::Report::new(diagnostic));
168 had_parse_errors = true;
169 continue;
170 }
171
172 source_files.push((filename.clone(), namespace.clone(), source));
173 println!("✓ {}: Parsed successfully", file_path.display());
174 }
175
176 if had_parse_errors {
177 return Err(CheckError::ValidationErrors {
178 help: Some("Some lexicons had parse errors".to_string()),
179 });
180 }
181
182 if let Err(e) = workspace.resolve() {
183 // Collect all modules that have errors
184 let mut modules_with_errors: std::collections::BTreeMap<String, (Option<String>, String)> = std::collections::BTreeMap::new();
185
186 // First, add all explicitly checked files
187 for (filename, namespace, source) in &source_files {
188 modules_with_errors.insert(namespace.clone(), (Some(filename.clone()), source.clone()));
189 }
190
191 // Then, find any cached modules with errors and try to load their source
192 for error in &e.errors {
193 let error_namespace = mlf_diagnostics::get_error_module_namespace_str(error);
194 if !modules_with_errors.contains_key(error_namespace) {
195 let namespace_path = error_namespace.replace('.', "/");
196 let mut source_loaded = false;
197
198 // Try multiple locations for the source file
199 let mut possible_paths = vec![
200 // Check in lexicons/ directory (common structure)
201 current_dir.join("lexicons").join(format!("{}.mlf", namespace_path)),
202 // Check in source directory from config
203 current_dir.join("src").join(format!("{}.mlf", namespace_path)),
204 // Check relative to current directory
205 current_dir.join(format!("{}.mlf", namespace_path)),
206 ];
207
208 // Add cache directory if available (lexicons are in lexicons/mlf/ subdirectory)
209 if let Some(cache_dir) = &mlf_cache_dir {
210 possible_paths.push(cache_dir.join("lexicons").join("mlf").join(format!("{}.mlf", namespace_path)));
211 }
212
213 for path in possible_paths {
214 if let Ok(source) = std::fs::read_to_string(&path) {
215 modules_with_errors.insert(
216 error_namespace.to_string(),
217 (Some(path.display().to_string()), source)
218 );
219 source_loaded = true;
220 break;
221 }
222 }
223
224 if !source_loaded {
225 // Couldn't load source, add placeholder
226 modules_with_errors.insert(
227 error_namespace.to_string(),
228 (None, String::new())
229 );
230 }
231 }
232 }
233
234 // Show diagnostics for all modules with errors
235 for (namespace, (filename_opt, source)) in &modules_with_errors {
236 // Only show diagnostic if this module has errors
237 let has_errors = e.errors.iter().any(|error| {
238 mlf_diagnostics::get_error_module_namespace_str(error) == namespace
239 });
240
241 if has_errors {
242 if let Some(filename) = filename_opt {
243 // Have source file, show full diagnostic
244 let diagnostic = ValidationDiagnostic::new(filename.clone(), source.clone(), namespace.clone(), e.clone());
245 eprintln!("{:?}", miette::Report::new(diagnostic));
246 } else {
247 // No source available, just list the errors
248 let error_count = e.errors.iter()
249 .filter(|err| mlf_diagnostics::get_error_module_namespace_str(err) == namespace)
250 .count();
251 eprintln!("\n{}: {} error(s) (source not available)", namespace, error_count);
252 }
253 }
254 }
255
256 return Err(CheckError::ValidationErrors {
257 help: Some("Workspace validation failed".to_string()),
258 });
259 }
260
261 println!("\n✓ All lexicons are valid");
262 Ok(())
263}
264
265pub fn validate(lexicon_path: PathBuf, record_path: PathBuf) -> Result<(), CheckError> {
266 let lexicon_source = std::fs::read_to_string(&lexicon_path).map_err(|source| {
267 CheckError::ReadFile {
268 path: lexicon_path.display().to_string(),
269 source,
270 }
271 })?;
272
273 let record_source = std::fs::read_to_string(&record_path).map_err(|source| {
274 CheckError::ReadFile {
275 path: record_path.display().to_string(),
276 source,
277 }
278 })?;
279
280 let lexicon = mlf_lang::parse_lexicon(&lexicon_source).map_err(|e| {
281 let diagnostic = ParseDiagnostic::new(
282 lexicon_path.display().to_string(),
283 lexicon_source.clone(),
284 e,
285 );
286 eprintln!("{:?}", miette::Report::new(diagnostic));
287 CheckError::ParseLexicon {
288 path: lexicon_path.display().to_string(),
289 help: Some("Failed to parse lexicon".to_string()),
290 }
291 })?;
292
293 let record: serde_json::Value = serde_json::from_str(&record_source)
294 .map_err(|source| CheckError::ParseJson { source })?;
295
296 println!("✓ Lexicon parsed successfully");
297 println!("✓ JSON record parsed successfully");
298
299 let validator = mlf_validation::RecordValidator::new(&lexicon);
300 match validator.validate_record(&record) {
301 Ok(()) => {
302 println!("✓ Record is valid according to the lexicon schema");
303 Ok(())
304 }
305 Err(errors) => {
306 eprintln!("✗ Record validation failed with {} error(s):", errors.len());
307 for error in &errors {
308 eprintln!(" • {}", error);
309 }
310 Err(CheckError::RecordValidation { errors })
311 }
312 }
313}
314
315/// Recursively collect all .mlf files from a directory
316fn collect_mlf_files(dir: &std::path::Path) -> Result<Vec<PathBuf>, CheckError> {
317 let mut files = Vec::new();
318
319 if !dir.exists() {
320 return Err(CheckError::ReadFile {
321 path: dir.display().to_string(),
322 source: std::io::Error::new(std::io::ErrorKind::NotFound, "Directory not found"),
323 });
324 }
325
326 fn visit_dirs(dir: &std::path::Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
327 if dir.is_dir() {
328 for entry in std::fs::read_dir(dir)? {
329 let entry = entry?;
330 let path = entry.path();
331 if path.is_dir() {
332 visit_dirs(&path, files)?;
333 } else if path.extension().and_then(|s| s.to_str()) == Some("mlf") {
334 files.push(path);
335 }
336 }
337 }
338 Ok(())
339 }
340
341 visit_dirs(dir, &mut files).map_err(|source| CheckError::ReadFile {
342 path: dir.display().to_string(),
343 source,
344 })?;
345
346 Ok(files)
347}
348
349/// Extract namespace from file path relative to root directory
350/// e.g., root=/project/lexicons, file=/project/lexicons/com/example/foo.mlf -> com.example.foo
351fn extract_namespace(file_path: &std::path::Path, root_dir: &std::path::Path) -> Result<String, CheckError> {
352 // Get the canonical paths to handle . and .. correctly
353 let file_canonical = file_path.canonicalize().map_err(|source| CheckError::ReadFile {
354 path: file_path.display().to_string(),
355 source,
356 })?;
357
358 let root_canonical = root_dir.canonicalize().map_err(|source| CheckError::ReadFile {
359 path: root_dir.display().to_string(),
360 source,
361 })?;
362
363 // Get relative path from root to file
364 let relative_path = file_canonical.strip_prefix(&root_canonical)
365 .map_err(|_| CheckError::ValidationErrors {
366 help: Some(format!(
367 "File {} is not within root directory {}",
368 file_path.display(),
369 root_dir.display()
370 )),
371 })?;
372
373 // Convert path to namespace
374 let mut components = Vec::new();
375 for component in relative_path.components() {
376 if let std::path::Component::Normal(os_str) = component {
377 if let Some(s) = os_str.to_str() {
378 components.push(s);
379 }
380 }
381 }
382
383 // Remove .mlf extension from last component
384 if let Some(last) = components.last_mut() {
385 if let Some(stem) = last.strip_suffix(".mlf") {
386 *last = stem;
387 }
388 }
389
390 if components.is_empty() {
391 return Err(CheckError::ValidationErrors {
392 help: Some(format!("Could not extract namespace from path: {}", file_path.display())),
393 });
394 }
395
396 Ok(components.join("."))
397}