diff --git a/Cargo.lock b/Cargo.lock index 1972400..6db3a14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -427,8 +427,10 @@ dependencies = [ "codebook_config", "env_logger", "fs2", + "glob", "log", "lru", + "owo-colors", "serde", "serde_json", "streaming-iterator", @@ -436,6 +438,7 @@ dependencies = [ "tempfile", "tokio", "tower-lsp", + "walkdir", ] [[package]] @@ -1372,6 +1375,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1703,6 +1712,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2449,6 +2468,25 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + [[package]] name = "symbolic-common" version = "12.17.2" diff --git a/Cargo.toml b/Cargo.toml index 0922004..0ad3137 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ env_logger = "0.11.6" fs2 = "0.4" git2 = "0.20.0" glob = "0.3" +owo-colors = { version = "4", features = ["supports-colors"] } httpmock = "<0.9.0" lazy_static = "1.5.0" log = "0.4.22" diff --git a/crates/codebook-lsp/Cargo.toml b/crates/codebook-lsp/Cargo.toml index a85100b..114e3fd 100644 --- a/crates/codebook-lsp/Cargo.toml +++ b/crates/codebook-lsp/Cargo.toml @@ -28,6 +28,9 @@ env_logger.workspace = true fs2.workspace = true log.workspace = true lru.workspace = true +glob.workspace = true +owo-colors.workspace = true +walkdir.workspace = true serde.workspace = true serde_json.workspace = true string-offsets.workspace = true diff --git a/crates/codebook-lsp/src/lint.rs b/crates/codebook-lsp/src/lint.rs new file mode 100644 index 0000000..565e4ae --- /dev/null +++ b/crates/codebook-lsp/src/lint.rs @@ -0,0 +1,247 @@ +use codebook::Codebook; +use codebook_config::CodebookConfigFile; +use owo_colors::{OwoColorize, Stream, Style}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +const BOLD: Style = Style::new().bold(); +const DIM: Style = Style::new().dimmed(); +const YELLOW: Style = Style::new().yellow(); +const BOLD_RED: Style = Style::new().bold().red(); + +fn fatal(msg: impl std::fmt::Display) -> ! { + eprintln!( + "{} {msg}", + "error:".if_supports_color(Stream::Stderr, |t| t.style(BOLD_RED)) + ); + std::process::exit(2); +} + +/// Computes a workspace-relative path string for a given file. Falls back to +/// the absolute path if the file is outside the workspace or canonicalization fails. +fn relative_to_root(root: &Path, path: &Path) -> String { + let root_canonical = match root.canonicalize() { + Ok(r) => r, + Err(_) => return path.to_string_lossy().to_string(), + }; + match path.canonicalize() { + Ok(canon) => match canon.strip_prefix(&root_canonical) { + Ok(rel) => rel.to_string_lossy().to_string(), + Err(_) => path.to_string_lossy().to_string(), + }, + Err(_) => path.to_string_lossy().to_string(), + } +} + +/// Returns `true` if any spelling errors were found. +pub fn run_lint(files: &[String], root: &Path, unique: bool, suggest: bool) -> bool { + let config = Arc::new( + CodebookConfigFile::load(Some(root)) + .unwrap_or_else(|e| fatal(format!("failed to load config: {e}"))), + ); + + print_config_source(&config); + eprintln!(); + + let codebook = Codebook::new(config.clone()) + .unwrap_or_else(|e| fatal(format!("failed to initialize: {e}"))); + + let resolved = resolve_paths(files, root); + + let mut seen_words: HashSet = HashSet::new(); + let mut total_errors = 0usize; + let mut files_with_errors = 0usize; + + for path in &resolved { + let relative = relative_to_root(root, path); + let error_count = check_file(path, &relative, &codebook, &mut seen_words, unique, suggest); + if error_count > 0 { + total_errors += error_count; + files_with_errors += 1; + } + } + + let unique_label = if unique { "unique " } else { "" }; + eprintln!( + "Found {} {unique_label}spelling error(s) in {} file(s).", + total_errors.if_supports_color(Stream::Stderr, |t| t.style(BOLD)), + files_with_errors.if_supports_color(Stream::Stderr, |t| t.style(BOLD)), + ); + + total_errors > 0 +} + +/// Spell-checks a single file and prints any diagnostics to stdout. +/// +/// Returns the number of errors found (0 if the file was clean or unreadable). +/// `relative` is the workspace-relative path used for display and ignore matching. +fn check_file( + path: &Path, + relative: &str, + codebook: &Codebook, + seen_words: &mut HashSet, + unique: bool, + suggest: bool, +) -> usize { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) => { + eprintln!( + "{} {}: {e}", + "error:".if_supports_color(Stream::Stderr, |t| t.style(BOLD_RED)), + path.display() + ); + return 0; + } + }; + + let display = relative.strip_prefix("./").unwrap_or(relative); + + let mut locations = codebook.spell_check(&text, None, Some(relative)); + // Sort by first occurrence in the file. + locations.sort_by_key(|l| l.locations.first().map(|r| r.start_byte).unwrap_or(0)); + + // Collect (linecol, word, suggestions) first so we can compute pad_len for alignment. + // unique check is per-word (outer loop) so all ranges of a word are included or skipped together. + let mut hits: Vec<(String, &str, Option>)> = Vec::new(); + for wl in &locations { + if unique && !seen_words.insert(wl.word.to_lowercase()) { + continue; + } + + let suggestions = if suggest { + codebook.get_suggestions(wl.word.as_str()) + } else { + None + }; + + // In unique mode only emit the first occurrence of each word + let ranges = if unique { + &wl.locations[..1] + } else { + &wl.locations[..] + }; + + for range in ranges { + let before = &text[..range.start_byte.min(text.len())]; + let line = before.bytes().filter(|&b| b == b'\n').count() + 1; + let col = before + .rfind('\n') + .map(|p| before.len() - p) + .unwrap_or(before.len() + 1); + hits.push(( + format!("{line}:{col}"), + wl.word.as_str(), + suggestions.clone(), + )); + } + } + + if hits.is_empty() { + return 0; + } + + let pad_len = hits + .iter() + .map(|(linecol, _, _)| linecol.len()) + .max() + .unwrap_or(0); + + println!( + "{}", + display.if_supports_color(Stream::Stdout, |t| t.style(BOLD)) + ); + for (linecol, word, suggestions) in &hits { + let pad = " ".repeat(pad_len - linecol.len()); + print!( + " {}:{}{} {}", + display.if_supports_color(Stream::Stdout, |t| t.style(DIM)), + linecol.if_supports_color(Stream::Stdout, |t| t.style(YELLOW)), + pad, + word.if_supports_color(Stream::Stdout, |t| t.style(BOLD_RED)), + ); + if let Some(suggestions) = suggestions { + println!( + " {}", + format!("→ {}", suggestions.join(", ")) + .if_supports_color(Stream::Stdout, |t| t.style(DIM)), + ); + } else { + println!(); + } + } + println!(); + + hits.len() +} + +/// Prints which config file is being used, or notes that the default is active. +fn print_config_source(config: &CodebookConfigFile) { + let cwd = std::env::current_dir().unwrap_or_default(); + match ( + config.project_config_path().filter(|p| p.is_file()), + config.global_config_path().filter(|p| p.is_file()), + ) { + (Some(p), _) => { + let path = p.strip_prefix(&cwd).unwrap_or(&p).display().to_string(); + eprintln!( + "using config {}", + path.if_supports_color(Stream::Stderr, |t| t.style(DIM)) + ); + } + (None, Some(g)) => { + let path = g.strip_prefix(&cwd).unwrap_or(&g).display().to_string(); + eprintln!( + "using global config {}", + path.if_supports_color(Stream::Stderr, |t| t.style(DIM)) + ); + } + (None, None) => eprintln!("No config found, using default config"), + } +} + +/// Resolves a mix of file paths, directories, and glob patterns into a sorted, +/// deduplicated list of file paths. Non-absolute patterns are resolved relative to root. +fn resolve_paths(patterns: &[String], root: &Path) -> Vec { + let mut paths = Vec::new(); + for pattern in patterns { + let p = PathBuf::from(pattern); + let p = if p.is_absolute() { p } else { root.join(&p) }; + if p.is_dir() { + collect_dir(&p, &mut paths); + } else { + let pattern = p.to_string_lossy(); + match glob::glob(&pattern) { + Ok(entries) => { + let mut matched = false; + for entry in entries.flatten() { + if entry.is_file() { + paths.push(entry); + matched = true; + } else if entry.is_dir() { + collect_dir(&entry, &mut paths); + matched = true; + } + } + if !matched { + eprintln!("codebook: no match for '{pattern}'"); + } + } + Err(e) => eprintln!("codebook: invalid pattern '{pattern}': {e}"), + } + } + } + paths.sort(); + paths.dedup(); + paths +} + +fn collect_dir(dir: &Path, out: &mut Vec) { + walkdir::WalkDir::new(dir) + .follow_links(false) + .into_iter() + .flatten() + .filter(|e| e.file_type().is_file()) + .for_each(|e| out.push(e.into_path())); +} diff --git a/crates/codebook-lsp/src/main.rs b/crates/codebook-lsp/src/main.rs index bc055ee..449c174 100644 --- a/crates/codebook-lsp/src/main.rs +++ b/crates/codebook-lsp/src/main.rs @@ -1,5 +1,6 @@ mod file_cache; mod init_options; +mod lint; mod lsp; mod lsp_logger; @@ -30,14 +31,29 @@ enum Commands { Serve {}, /// Remove server cache Clean {}, + /// Check files for spelling errors + Lint { + /// Files or glob patterns to spell-check + #[arg(required = true)] + files: Vec, + /// Only report each misspelled word once, ignoring duplicates across files + #[arg(short = 'u', long)] + unique: bool, + /// Show spelling suggestions for each misspelled word + #[arg(short = 's', long)] + suggest: bool, + }, } #[tokio::main(flavor = "current_thread")] async fn main() { // Initialize logger early with stderr output and buffering - // Default to INFO level, will be adjusted when LSP client connects + // Default to INFO for LSP, WARN for lint (to suppress LSP-oriented noise) + let is_lint = std::env::args().nth(1).as_deref() == Some("lint"); let log_level = match env::var("RUST_LOG").as_deref() { Ok("debug") => LevelFilter::Debug, + Ok("info") => LevelFilter::Info, + _ if is_lint => LevelFilter::Warn, _ => LevelFilter::Info, }; LspLogger::init_early(log_level).expect("Failed to initialize early logger"); @@ -58,6 +74,15 @@ async fn main() { info!("Cleaning: {:?}", config.cache_dir); config.clean_cache() } + Some(Commands::Lint { + files, + unique, + suggest, + }) => { + if lint::run_lint(files, root, *unique, *suggest) { + std::process::exit(1); + } + } None => {} } }