use crate::config::{find_project_root, get_mlf_cache_dir, ConfigError, MlfConfig}; use crate::workspace_ext::workspace_with_std_and_cache; use miette::Diagnostic; use mlf_diagnostics::{ParseDiagnostic, ValidationDiagnostic}; use std::path::PathBuf; use thiserror::Error; #[derive(Error, Debug, Diagnostic)] pub enum CheckError { #[error("Failed to read file: {path}")] #[diagnostic(code(mlf::check::read_file))] ReadFile { path: String, #[source] source: std::io::Error, }, #[error("Failed to parse lexicon: {path}")] #[diagnostic(code(mlf::check::parse_lexicon))] ParseLexicon { path: String, #[help] help: Option, }, #[error("Failed to parse JSON record")] #[diagnostic(code(mlf::check::parse_json))] ParseJson { #[source] source: serde_json::Error, }, #[error("Validation errors in lexicon")] #[diagnostic(code(mlf::check::validation_errors))] ValidationErrors { #[help] help: Option, }, #[error("Record validation failed")] #[diagnostic(code(mlf::check::record_validation))] RecordValidation { errors: Vec, }, #[error("Failed to load config: {0}")] #[diagnostic(code(mlf::check::config_error))] ConfigError(#[from] ConfigError), } pub fn run_check(input_paths: Vec, explicit_root: Option) -> Result<(), CheckError> { let current_dir = std::env::current_dir() .map_err(|e| CheckError::ReadFile { path: ".".to_string(), source: e, })?; // Determine root directory and input paths let (root_dir, file_paths) = if input_paths.is_empty() { // No input provided: must use mlf.toml match find_project_root(¤t_dir) { Ok(project_root) => { let config_path = project_root.join("mlf.toml"); let config = MlfConfig::load(&config_path)?; let source_dir = project_root.join(&config.source.directory); let root = explicit_root.unwrap_or_else(|| source_dir.clone()); println!("Using source directory from mlf.toml: {}", config.source.directory); // Collect all .mlf files from source directory let files = collect_mlf_files(&source_dir)?; (root, files) } Err(ConfigError::NotFound) => { return Err(CheckError::ValidationErrors { help: Some("No input files provided and no mlf.toml found. Please provide input files or create a mlf.toml configuration.".to_string()), }); } Err(e) => return Err(CheckError::ConfigError(e)), } } else { // Input provided: determine root let root = if let Some(explicit) = explicit_root { // --root flag takes precedence explicit } else if let Ok(project_root) = find_project_root(¤t_dir) { // Try to use mlf.toml source directory let config_path = project_root.join("mlf.toml"); if let Ok(config) = MlfConfig::load(&config_path) { project_root.join(&config.source.directory) } else { current_dir.clone() } } else { // Fall back to current directory current_dir.clone() }; // Collect files from input paths let mut files = Vec::new(); for input_path in input_paths { let path = if input_path.is_absolute() { input_path } else { current_dir.join(input_path) }; if path.is_dir() { files.extend(collect_mlf_files(&path)?); } else if path.is_file() { files.push(path); } else { return Err(CheckError::ReadFile { path: path.display().to_string(), source: std::io::Error::new(std::io::ErrorKind::NotFound, "Path not found"), }); } } (root, files) }; // Try to load cached lexicons from .mlf directory let current_dir = std::env::current_dir() .map_err(|e| CheckError::ReadFile { path: ".".to_string(), source: e, })?; let mlf_cache_dir = find_project_root(¤t_dir) .ok() .map(|root| get_mlf_cache_dir(&root)); let mut workspace = workspace_with_std_and_cache(mlf_cache_dir.as_deref()).map_err(|e| { eprintln!("Error loading workspace: {}", e); CheckError::ValidationErrors { help: Some(format!("Failed to load workspace: {}", e)), } })?; let mut source_files = Vec::new(); let mut had_parse_errors = false; for file_path in &file_paths { let source = std::fs::read_to_string(file_path).map_err(|source| { CheckError::ReadFile { path: file_path.display().to_string(), source, } })?; let filename = file_path.display().to_string(); let lexicon = match mlf_lang::parse_lexicon(&source) { Ok(lex) => lex, Err(e) => { let diagnostic = ParseDiagnostic::new(filename.clone(), source.clone(), e); eprintln!("{:?}", miette::Report::new(diagnostic)); had_parse_errors = true; continue; } }; let namespace = extract_namespace(&file_path, &root_dir)?; if let Err(e) = workspace.add_module(namespace.clone(), lexicon.clone()) { let diagnostic = ValidationDiagnostic::new(filename.clone(), source.clone(), namespace.clone(), e); eprintln!("{:?}", miette::Report::new(diagnostic)); had_parse_errors = true; continue; } source_files.push((filename.clone(), namespace.clone(), source)); println!("✓ {}: Parsed successfully", file_path.display()); } if had_parse_errors { return Err(CheckError::ValidationErrors { help: Some("Some lexicons had parse errors".to_string()), }); } if let Err(e) = workspace.resolve() { // Collect all modules that have errors let mut modules_with_errors: std::collections::BTreeMap, String)> = std::collections::BTreeMap::new(); // First, add all explicitly checked files for (filename, namespace, source) in &source_files { modules_with_errors.insert(namespace.clone(), (Some(filename.clone()), source.clone())); } // Then, find any cached modules with errors and try to load their source for error in &e.errors { let error_namespace = mlf_diagnostics::get_error_module_namespace_str(error); if !modules_with_errors.contains_key(error_namespace) { let namespace_path = error_namespace.replace('.', "/"); let mut source_loaded = false; // Try multiple locations for the source file let mut possible_paths = vec![ // Check in lexicons/ directory (common structure) current_dir.join("lexicons").join(format!("{}.mlf", namespace_path)), // Check in source directory from config current_dir.join("src").join(format!("{}.mlf", namespace_path)), // Check relative to current directory current_dir.join(format!("{}.mlf", namespace_path)), ]; // Add cache directory if available (lexicons are in lexicons/mlf/ subdirectory) if let Some(cache_dir) = &mlf_cache_dir { possible_paths.push(cache_dir.join("lexicons").join("mlf").join(format!("{}.mlf", namespace_path))); } for path in possible_paths { if let Ok(source) = std::fs::read_to_string(&path) { modules_with_errors.insert( error_namespace.to_string(), (Some(path.display().to_string()), source) ); source_loaded = true; break; } } if !source_loaded { // Couldn't load source, add placeholder modules_with_errors.insert( error_namespace.to_string(), (None, String::new()) ); } } } // Show diagnostics for all modules with errors for (namespace, (filename_opt, source)) in &modules_with_errors { // Only show diagnostic if this module has errors let has_errors = e.errors.iter().any(|error| { mlf_diagnostics::get_error_module_namespace_str(error) == namespace }); if has_errors { if let Some(filename) = filename_opt { // Have source file, show full diagnostic let diagnostic = ValidationDiagnostic::new(filename.clone(), source.clone(), namespace.clone(), e.clone()); eprintln!("{:?}", miette::Report::new(diagnostic)); } else { // No source available, just list the errors let error_count = e.errors.iter() .filter(|err| mlf_diagnostics::get_error_module_namespace_str(err) == namespace) .count(); eprintln!("\n{}: {} error(s) (source not available)", namespace, error_count); } } } return Err(CheckError::ValidationErrors { help: Some("Workspace validation failed".to_string()), }); } println!("\n✓ All lexicons are valid"); Ok(()) } pub fn validate(lexicon_path: PathBuf, record_path: PathBuf) -> Result<(), CheckError> { let lexicon_source = std::fs::read_to_string(&lexicon_path).map_err(|source| { CheckError::ReadFile { path: lexicon_path.display().to_string(), source, } })?; let record_source = std::fs::read_to_string(&record_path).map_err(|source| { CheckError::ReadFile { path: record_path.display().to_string(), source, } })?; let lexicon = mlf_lang::parse_lexicon(&lexicon_source).map_err(|e| { let diagnostic = ParseDiagnostic::new( lexicon_path.display().to_string(), lexicon_source.clone(), e, ); eprintln!("{:?}", miette::Report::new(diagnostic)); CheckError::ParseLexicon { path: lexicon_path.display().to_string(), help: Some("Failed to parse lexicon".to_string()), } })?; let record: serde_json::Value = serde_json::from_str(&record_source) .map_err(|source| CheckError::ParseJson { source })?; println!("✓ Lexicon parsed successfully"); println!("✓ JSON record parsed successfully"); let validator = mlf_validation::RecordValidator::new(&lexicon); match validator.validate_record(&record) { Ok(()) => { println!("✓ Record is valid according to the lexicon schema"); Ok(()) } Err(errors) => { eprintln!("✗ Record validation failed with {} error(s):", errors.len()); for error in &errors { eprintln!(" • {}", error); } Err(CheckError::RecordValidation { errors }) } } } /// Recursively collect all .mlf files from a directory fn collect_mlf_files(dir: &std::path::Path) -> Result, CheckError> { let mut files = Vec::new(); if !dir.exists() { return Err(CheckError::ReadFile { path: dir.display().to_string(), source: std::io::Error::new(std::io::ErrorKind::NotFound, "Directory not found"), }); } fn visit_dirs(dir: &std::path::Path, files: &mut Vec) -> std::io::Result<()> { if dir.is_dir() { for entry in std::fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { visit_dirs(&path, files)?; } else if path.extension().and_then(|s| s.to_str()) == Some("mlf") { files.push(path); } } } Ok(()) } visit_dirs(dir, &mut files).map_err(|source| CheckError::ReadFile { path: dir.display().to_string(), source, })?; Ok(files) } /// Extract namespace from file path relative to root directory /// e.g., root=/project/lexicons, file=/project/lexicons/com/example/foo.mlf -> com.example.foo fn extract_namespace(file_path: &std::path::Path, root_dir: &std::path::Path) -> Result { // Get the canonical paths to handle . and .. correctly let file_canonical = file_path.canonicalize().map_err(|source| CheckError::ReadFile { path: file_path.display().to_string(), source, })?; let root_canonical = root_dir.canonicalize().map_err(|source| CheckError::ReadFile { path: root_dir.display().to_string(), source, })?; // Get relative path from root to file let relative_path = file_canonical.strip_prefix(&root_canonical) .map_err(|_| CheckError::ValidationErrors { help: Some(format!( "File {} is not within root directory {}", file_path.display(), root_dir.display() )), })?; // Convert path to namespace let mut components = Vec::new(); for component in relative_path.components() { if let std::path::Component::Normal(os_str) = component { if let Some(s) = os_str.to_str() { components.push(s); } } } // Remove .mlf extension from last component if let Some(last) = components.last_mut() { if let Some(stem) = last.strip_suffix(".mlf") { *last = stem; } } if components.is_empty() { return Err(CheckError::ValidationErrors { help: Some(format!("Could not extract namespace from path: {}", file_path.display())), }); } Ok(components.join(".")) }