A human-friendly DSL for ATProto Lexicons
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 397 lines 15 kB view raw
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(&current_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(&current_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(&current_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}