Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions crates/codebook-lsp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
222 changes: 222 additions & 0 deletions crates/codebook-lsp/src/lint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
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);
}

/// 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);

Comment thread
niekdomi marked this conversation as resolved.
Outdated
let mut seen_words: HashSet<String> = HashSet::new();
let mut total_errors = 0usize;
let mut files_with_errors = 0usize;

for path in &resolved {
let error_count = check_file(path, &codebook, &mut seen_words, unique, suggest);
if error_count > 0 {
total_errors += error_count;
files_with_errors += 1;
}
}

if total_errors > 0 {
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
Comment thread
niekdomi marked this conversation as resolved.
Outdated
}
Comment thread
niekdomi marked this conversation as resolved.
Outdated

Comment thread
niekdomi marked this conversation as resolved.
/// 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).
fn check_file(
path: &Path,
codebook: &Codebook,
seen_words: &mut HashSet<String>,
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;
}
Comment thread
niekdomi marked this conversation as resolved.
};
Comment thread
niekdomi marked this conversation as resolved.

let raw = path.to_string_lossy();
let display = raw.strip_prefix("./").unwrap_or(&raw);

let mut locations = codebook.spell_check(&text, None, path.to_str());
Comment thread
niekdomi marked this conversation as resolved.
Outdated
// 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<String>>)> = 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
};

for range in &wl.locations {
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.
fn resolve_paths(patterns: &[String]) -> Vec<PathBuf> {
let mut paths = Vec::new();
for pattern in patterns {
let p = PathBuf::from(pattern);
if p.is_dir() {
collect_dir(&p, &mut paths);
} else {
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}"),
Comment thread
niekdomi marked this conversation as resolved.
Outdated
}
}
}
paths.sort();
paths.dedup();
paths
}
Comment thread
niekdomi marked this conversation as resolved.

fn collect_dir(dir: &Path, out: &mut Vec<PathBuf>) {
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()));
Comment thread
niekdomi marked this conversation as resolved.
Outdated
}
27 changes: 26 additions & 1 deletion crates/codebook-lsp/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod file_cache;
mod init_options;
mod lint;
mod lsp;
mod lsp_logger;

Expand Down Expand Up @@ -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<String>,
/// 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,
Comment thread
niekdomi marked this conversation as resolved.
Outdated
};
LspLogger::init_early(log_level).expect("Failed to initialize early logger");
Expand All @@ -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 => {}
}
}
Expand Down
Loading