Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(cargo test:*)",
"Bash(cargo clippy:*)"
]
}
}
2 changes: 1 addition & 1 deletion codebook.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ flag_words = [
"fixme",
]
ignore_paths = [
"target/**/*",
"**/*.json",
".git/**/*",
"target/**/*",
]
ignore_patterns = ['\b[ATCG]+\b']
193 changes: 191 additions & 2 deletions crates/codebook-lsp/src/lsp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,39 @@ use crate::lsp_logger;

const SOURCE_NAME: &str = "Codebook";

/// Computes the relative path of a file from a workspace directory.
/// Returns the relative path if the file is within the workspace, otherwise returns the absolute path.
/// If `workspace_dir_canonical` is provided, skips canonicalizing the workspace directory (optimization).
fn compute_relative_path(
workspace_dir: &Path,
workspace_dir_canonical: Option<&Path>,
file_path: &Path,
) -> String {
let workspace_canonical = match workspace_dir_canonical {
Some(dir) => dir.to_path_buf(),
None => match workspace_dir.canonicalize() {
Ok(dir) => dir,
Err(err) => {
info!("Could not canonicalize workspace directory. Error: {err}.");
return file_path.to_string_lossy().to_string();
}
},
};

match file_path.canonicalize() {
Ok(canon_file_path) => match canon_file_path.strip_prefix(&workspace_canonical) {
Ok(relative) => relative.to_string_lossy().to_string(),
Err(_) => file_path.to_string_lossy().to_string(),
},
Err(_) => file_path.to_string_lossy().to_string(),
}
}

pub struct Backend {
client: Client,
workspace_dir: PathBuf,
/// Cached canonicalized workspace directory for efficient relative path computation
workspace_dir_canonical: Option<PathBuf>,
codebook: OnceLock<Arc<Codebook>>,
config: OnceLock<Arc<CodebookConfigFile>>,
document_cache: TextDocumentCache,
Expand All @@ -38,6 +68,7 @@ pub struct Backend {
enum CodebookCommand {
AddWord,
AddWordGlobal,
IgnoreFile,
Unknown,
}

Expand All @@ -46,6 +77,7 @@ impl From<&str> for CodebookCommand {
match command {
"codebook.addWord" => CodebookCommand::AddWord,
"codebook.addWordGlobal" => CodebookCommand::AddWordGlobal,
"codebook.ignoreFile" => CodebookCommand::IgnoreFile,
_ => CodebookCommand::Unknown,
}
}
Expand All @@ -56,6 +88,7 @@ impl From<CodebookCommand> for String {
match command {
CodebookCommand::AddWord => "codebook.addWord".to_string(),
CodebookCommand::AddWordGlobal => "codebook.addWordGlobal".to_string(),
CodebookCommand::IgnoreFile => "codebook.ignoreFile".to_string(),
CodebookCommand::Unknown => "codebook.unknown".to_string(),
}
}
Expand Down Expand Up @@ -93,6 +126,7 @@ impl LanguageServer for Backend {
commands: vec![
CodebookCommand::AddWord.into(),
CodebookCommand::AddWordGlobal.into(),
CodebookCommand::IgnoreFile.into(),
],
work_done_progress_options: Default::default(),
}),
Expand Down Expand Up @@ -182,11 +216,13 @@ impl LanguageServer for Backend {
None => return Ok(None),
};

let mut has_codebook_diagnostic = false;
for diag in params.context.diagnostics {
// Only process our own diagnostics
if diag.source.as_deref() != Some(SOURCE_NAME) {
continue;
}
has_codebook_diagnostic = true;
let line = doc
.text
.lines()
Expand Down Expand Up @@ -256,6 +292,22 @@ impl LanguageServer for Backend {
data: None,
}));
}
if has_codebook_diagnostic {
actions.push(CodeActionOrCommand::CodeAction(CodeAction {
title: "Add current file to ignore list".to_string(),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: None,
edit: None,
command: Some(Command {
title: "Add current file to ignore list".to_string(),
command: CodebookCommand::IgnoreFile.into(),
arguments: Some(vec![params.text_document.uri.to_string().into()]),
}),
is_preferred: None,
disabled: None,
data: None,
}));
}
match actions.is_empty() {
true => Ok(None),
false => Ok(Some(actions)),
Expand Down Expand Up @@ -294,16 +346,35 @@ impl LanguageServer for Backend {
}
Ok(None)
}
CodebookCommand::IgnoreFile => {
let Some(file_uri) = params
.arguments
.first()
.and_then(|arg| arg.as_str())
else {
error!("IgnoreFile command missing or invalid file URI argument");
return Ok(None);
};
let config = self.config_handle();
let updated = self.add_ignore_file(config.as_ref(), file_uri);
if updated {
let _ = config.save();
self.recheck_all().await;
}
Ok(None)
}
CodebookCommand::Unknown => Ok(None),
}
}
}

impl Backend {
pub fn new(client: Client, workspace_dir: &Path) -> Self {
let workspace_dir_canonical = workspace_dir.canonicalize().ok();
Self {
client,
workspace_dir: workspace_dir.to_path_buf(),
workspace_dir_canonical,
codebook: OnceLock::new(),
config: OnceLock::new(),
document_cache: TextDocumentCache::default(),
Expand Down Expand Up @@ -382,6 +453,7 @@ impl Backend {
}
should_save
}

fn add_words_global(
&self,
config: &CodebookConfigFile,
Expand All @@ -404,6 +476,39 @@ impl Backend {
should_save
}

fn get_relative_path(&self, uri: &str) -> Option<String> {
let parsed_uri = match Url::parse(uri) {
Ok(u) => u,
Err(e) => {
error!("Failed to parse URI '{uri}': {e}");
return None;
}
};
let file_path = parsed_uri.to_file_path().unwrap_or_default();
Some(compute_relative_path(
&self.workspace_dir,
self.workspace_dir_canonical.as_deref(),
&file_path,
))
}

fn add_ignore_file(&self, config: &CodebookConfigFile, file_uri: &str) -> bool {
let Some(relative_path) = self.get_relative_path(file_uri) else {
return false;
};
match config.add_ignore(&relative_path) {
Ok(true) => true,
Ok(false) => {
info!("File {file_uri} already exists in the ignored files.");
false
}
Err(e) => {
error!("Failed to add ignore file: {e}");
false
}
}
}

fn make_suggestion(&self, suggestion: &str, range: &Range, uri: &Url) -> CodeAction {
let title = format!("Replace with '{suggestion}'");
let mut map = HashMap::new();
Expand Down Expand Up @@ -468,6 +573,13 @@ impl Backend {
let file_path = doc.uri.to_file_path().unwrap_or_default();
debug!("Spell-checking file: {file_path:?}");

// Compute relative path for ignore pattern matching
let relative_path = compute_relative_path(
&self.workspace_dir,
self.workspace_dir_canonical.as_deref(),
&file_path,
);

// Convert utf8 byte offsets to utf16
let offsets = StringOffsets::<AllConfig>::new(&doc.text);

Expand All @@ -476,9 +588,8 @@ impl Backend {
let lang_type = lang.and_then(|lang| LanguageType::from_str(lang).ok());
debug!("Document identified as type {lang_type:?} from {lang:?}");
let cb = self.codebook_handle();
let fp = file_path.clone();
let spell_results = task::spawn_blocking(move || {
cb.spell_check(&doc.text, lang_type, Some(fp.to_str().unwrap_or_default()))
cb.spell_check(&doc.text, lang_type, Some(&relative_path))
})
.await;

Expand Down Expand Up @@ -514,3 +625,81 @@ impl Backend {
// debug!("Published diagnostics for: {:?}", file_path);
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;

#[test]
fn test_compute_relative_path_within_workspace() {
let workspace = tempdir().unwrap();
let workspace_path = workspace.path();

// Create a file inside the workspace
let subdir = workspace_path.join("src");
fs::create_dir_all(&subdir).unwrap();
let file_path = subdir.join("test.rs");
fs::write(&file_path, "test").unwrap();

let result = compute_relative_path(workspace_path, None, &file_path);
assert_eq!(result, "src/test.rs");
}

#[test]
fn test_compute_relative_path_with_cached_canonical() {
let workspace = tempdir().unwrap();
let workspace_path = workspace.path();
let workspace_canonical = workspace_path.canonicalize().unwrap();

// Create a file inside the workspace
let subdir = workspace_path.join("src");
fs::create_dir_all(&subdir).unwrap();
let file_path = subdir.join("test.rs");
fs::write(&file_path, "test").unwrap();

// Using cached canonical path should produce the same result
let result = compute_relative_path(workspace_path, Some(&workspace_canonical), &file_path);
assert_eq!(result, "src/test.rs");
}

#[test]
fn test_compute_relative_path_outside_workspace() {
let workspace = tempdir().unwrap();
let other_dir = tempdir().unwrap();

// Create a file outside the workspace
let file_path = other_dir.path().join("outside.rs");
fs::write(&file_path, "test").unwrap();

let result = compute_relative_path(workspace.path(), None, &file_path);
// Should return the original path since it's outside workspace
assert!(result.contains("outside.rs"));
}

#[test]
fn test_compute_relative_path_nonexistent_file() {
let workspace = tempdir().unwrap();
let file_path = workspace.path().join("nonexistent.rs");

let result = compute_relative_path(workspace.path(), None, &file_path);
// Should return the original path since file doesn't exist
assert!(result.contains("nonexistent.rs"));
}

#[test]
fn test_compute_relative_path_nested_directory() {
let workspace = tempdir().unwrap();
let workspace_path = workspace.path();

// Create a deeply nested file
let nested_dir = workspace_path.join("src").join("components").join("ui");
fs::create_dir_all(&nested_dir).unwrap();
let file_path = nested_dir.join("button.rs");
fs::write(&file_path, "test").unwrap();

let result = compute_relative_path(workspace_path, None, &file_path);
assert_eq!(result, "src/components/ui/button.rs");
}
}
1 change: 1 addition & 0 deletions crates/codebook/src/dictionaries/combined.gen.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,7 @@ helvetica
here
heroicons
hex
hgroup
hh
hi
hibernate
Expand Down