diff --git a/.vscode/settings.json b/.vscode/settings.json index 30bf8c2..6a564ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,9 @@ "out": true // set this to false to include "out" folder in search results }, // Turn off tsc task auto detection since we have the necessary tasks as npm scripts - "typescript.tsc.autoDetect": "off" -} \ No newline at end of file + "typescript.tsc.autoDetect": "off", + "rust-analyzer.linkedProjects": [ + "./crates/typos-lsp/Cargo.toml" + ], + "rust-analyzer.runnables.extraEnv": { "RUST_LOG": "warn"} +} diff --git a/Cargo.lock b/Cargo.lock index 803bbbb..11a1e6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -781,6 +781,12 @@ dependencies = [ "regex-automata 0.1.10", ] +[[package]] +name = "matchit" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67827e6ea8ee8a7c4a72227ef4fc08957040acffdb5f122733b24fa12daff41b" + [[package]] name = "memchr" version = "2.5.0" @@ -1519,7 +1525,9 @@ dependencies = [ "bstr 1.6.0", "env_logger", "httparse", + "ignore", "itertools", + "matchit", "serde", "serde_json", "similar-asserts", diff --git a/README.md b/README.md index ca2d298..ce5b387 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > **Source code spell checker for Visual Studio Code** -A Visual Studio Code Extension and LSP server for [typos](https://github.com/crate-ci/typos) a low false-positive source code spell checker. +[typos](https://github.com/crate-ci/typos) is a low false-positive source code spell checker. This Visual Studio Code extension provides a fast, low memory, in-editor spell checker by integrating with typos through the Language Server Protocol (LSP). ## Features @@ -16,6 +16,17 @@ Once installed `typos` will automatically execute when you open or edit any file To disable `typos` per workspace, see [disable this extension](https://code.visualstudio.com/docs/editor/extension-marketplace#_disable-an-extension). +## Config file support + +Supports [config fields](https://github.com/crate-ci/typos/blob/master/docs/reference.md) in `typos.toml`, `_typos.toml`, or `.typos.toml`, except: + +- `files.ignore*` - have no effect. +- `default.check-filename` - file names are never spell checked. +- `default.check-file` - files are always checked. +- `*.binary` - binary files are always checked. + +Config files will be read from the workspace folder or its parents. If there is no workspace folder, then no config file will be read and the typos defaults will be used. + ## Settings This extension contributes the following settings: @@ -29,3 +40,8 @@ This extension contributes the following settings: | Command | Description | | -------------- | ------------------- | | Typos: Restart | Restart the server. | + +## Caveats + +- File names are not spell checked. +- Server must be restarted after changing the config files (ie: typos.toml). diff --git a/crates/typos-lsp/Cargo.toml b/crates/typos-lsp/Cargo.toml index a0eef63..ce5fc71 100644 --- a/crates/typos-lsp/Cargo.toml +++ b/crates/typos-lsp/Cargo.toml @@ -16,6 +16,8 @@ env_logger = "0.10.0" typos = "0.10" typos-cli = "1.16" serde = { version = "1.0", features = ["derive"] } +ignore = "0.4.20" +matchit = "0.7.1" [dev-dependencies] test-log = { version = "0.2.11", features = ["trace"] } diff --git a/crates/typos-lsp/src/lsp.rs b/crates/typos-lsp/src/lsp.rs index 9c8e3a3..c38958f 100644 --- a/crates/typos-lsp/src/lsp.rs +++ b/crates/typos-lsp/src/lsp.rs @@ -1,17 +1,101 @@ +use anyhow::anyhow; +use matchit::{Match, Router}; + use std::borrow::Cow; use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Mutex; use bstr::ByteSlice; -use serde_json::json; +use serde_json::{json, to_string}; use tower_lsp::lsp_types::*; use tower_lsp::*; use tower_lsp::{Client, LanguageServer}; +use ignore::overrides::{Override, OverrideBuilder}; use typos_cli::policy; - -pub struct Backend<'a> { +pub struct Backend<'s, 'p> { client: Client, - policy: policy::Policy<'a, 'a, 'a>, + state: Mutex>, + default_policy: policy::Policy<'p, 'p, 'p>, +} + +#[derive(Default)] +struct BackendState<'s> { + workspace_folders: Vec, + router: Router>, +} + +struct TyposCli<'s> { + overrides: Override, + engine: policy::ConfigEngine<'s>, +} + +impl<'s> TryFrom<&PathBuf> for TyposCli<'s> { + type Error = anyhow::Error; + + // initialise an engine and overrides using the config file from path or its parent + fn try_from(path: &PathBuf) -> anyhow::Result { + // leak to get a 'static which is needed to satisfy the 's lifetime + // but does mean memory will grow unbounded + let storage = Box::leak(Box::new(policy::ConfigStorage::new())); + let mut engine = typos_cli::policy::ConfigEngine::new(storage); + engine.init_dir(path)?; + + let walk_policy = engine.walk(path); + + // add any explicit excludes + let mut overrides = OverrideBuilder::new(path); + for pattern in walk_policy.extend_exclude.iter() { + overrides.add(&format!("!{}", pattern))?; + } + let overrides = overrides.build()?; + + Ok(TyposCli { overrides, engine }) + } +} + +impl<'s> BackendState<'s> { + fn set_workspace_folders( + &mut self, + workspace_folders: Vec, + ) -> anyhow::Result<(), anyhow::Error> { + self.workspace_folders = workspace_folders; + self.update_router()?; + Ok(()) + } + + fn update_workspace_folders( + &mut self, + added: Vec, + removed: Vec, + ) -> anyhow::Result<(), anyhow::Error> { + self.workspace_folders.extend(added); + if !removed.is_empty() { + self.workspace_folders.retain(|x| !removed.contains(x)); + } + self.update_router()?; + Ok(()) + } + + fn update_router(&mut self) -> anyhow::Result<(), anyhow::Error> { + self.router = Router::new(); + for folder in self.workspace_folders.iter() { + let path = folder + .uri + .to_file_path() + .map_err(|_| anyhow!("Cannot convert uri {} to file path", folder.uri))?; + let path_wildcard = format!( + "{}{}", + path.to_str() + .ok_or_else(|| anyhow!("Invalid unicode in path {:?}", path))?, + "/*p" + ); + let config = TyposCli::try_from(&path)?; + self.router.insert(path_wildcard, config)?; + } + Ok(()) + } } #[derive(Debug, serde::Serialize, serde::Deserialize)] @@ -20,9 +104,9 @@ struct DiagnosticData<'c> { } #[tower_lsp::async_trait] -impl LanguageServer for Backend<'static> { +impl LanguageServer for Backend<'static, 'static> { async fn initialize(&self, params: InitializeParams) -> jsonrpc::Result { - tracing::debug!("initialize: {:?}", params); + tracing::debug!("initialize: {}", to_string(¶ms).unwrap_or_default()); if let Some(TextDocumentClientCapabilities { publish_diagnostics: @@ -33,13 +117,18 @@ impl LanguageServer for Backend<'static> { .. }) = params.capabilities.text_document { - tracing::debug!("client supports diagnostics data") + tracing::debug!("Client supports diagnostics data") } else { tracing::warn!( - "client does not support diagnostics data.. code actions will not be available" + "Client does not support diagnostics data.. code actions will not be available" ) } + let mut state = self.state.lock().unwrap(); + if let Err(e) = state.set_workspace_folders(params.workspace_folders.unwrap_or_default()) { + tracing::warn!("Cannot set workspace folders: {}", e); + } + Ok(InitializeResult { capabilities: ServerCapabilities { text_document_sync: Some(TextDocumentSyncCapability::Kind( @@ -55,7 +144,14 @@ impl LanguageServer for Backend<'static> { resolve_provider: None, }, )), - ..ServerCapabilities::default() + workspace: Some(WorkspaceServerCapabilities { + workspace_folders: Some(WorkspaceFoldersServerCapabilities { + supported: Some(true), + change_notifications: Some(OneOf::Left(true)), + }), + ..Default::default() + }), + ..Default::default() }, server_info: Some(ServerInfo { name: "typos".to_string(), @@ -71,12 +167,12 @@ impl LanguageServer for Backend<'static> { } async fn did_open(&self, params: DidOpenTextDocumentParams) { - tracing::debug!("did_open: {:?}", params); + tracing::debug!("did_open: {:?}", to_string(¶ms).unwrap_or_default()); self.report_diagnostics(params.text_document).await; } async fn did_change(&self, mut params: DidChangeTextDocumentParams) { - tracing::debug!("did_change: {:?}", params); + tracing::debug!("did_change: {:?}", to_string(¶ms).unwrap_or_default()); self.report_diagnostics(TextDocumentItem { language_id: "FOOBAR".to_string(), uri: params.text_document.uri, @@ -87,12 +183,12 @@ impl LanguageServer for Backend<'static> { } async fn did_save(&self, params: DidSaveTextDocumentParams) { - tracing::debug!("did_save: {:?}", params); + tracing::debug!("did_save: {:?}", to_string(¶ms).unwrap_or_default()); // noop to avoid unimplemented warning log line } async fn did_close(&self, params: DidCloseTextDocumentParams) { - tracing::debug!("did_close: {:?}", params); + tracing::debug!("did_close: {:?}", to_string(¶ms).unwrap_or_default()); // clear diagnostics to avoid a stale diagnostics flash on open // if the file has typos fixed outside of vscode // see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_publishDiagnostics @@ -105,7 +201,7 @@ impl LanguageServer for Backend<'static> { &self, params: CodeActionParams, ) -> jsonrpc::Result> { - tracing::debug!("code_action: {:?}", params); + tracing::debug!("code_action: {:?}", to_string(¶ms).unwrap_or_default()); let actions = params .context @@ -152,7 +248,7 @@ impl LanguageServer for Backend<'static> { } } None => { - tracing::warn!("client doesn't support diagnostic data"); + tracing::warn!("Client doesn't support diagnostic data"); vec![] } }) @@ -161,19 +257,40 @@ impl LanguageServer for Backend<'static> { Ok(Some(actions)) } + async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) { + tracing::debug!( + "did_change_workspace_folders: {:?}", + to_string(¶ms).unwrap_or_default() + ); + + let mut state = self.state.lock().unwrap(); + if let Err(e) = state.update_workspace_folders(params.event.added, params.event.removed) { + tracing::warn!("Cannot update workspace folders {}", e); + } + } + async fn shutdown(&self) -> jsonrpc::Result<()> { Ok(()) } } -impl Backend<'static> { +impl<'s, 'p> Backend<'s, 'p> { pub fn new(client: Client) -> Self { - let policy = policy::Policy::new(); - Self { client, policy } + Self { + client, + state: Mutex::new(BackendState::default()), + default_policy: policy::Policy::default(), + } } async fn report_diagnostics(&self, params: TextDocumentItem) { - let diagnostics = self.check_text(¶ms.text); + let diagnostics = match self.check_text(¶ms.text, ¶ms.uri) { + Err(e) => { + tracing::warn!("{}", e); + Vec::new() + } + Ok(diagnostics) => diagnostics, + }; self.client .publish_diagnostics(params.uri, diagnostics, Some(params.version)) @@ -181,12 +298,51 @@ impl Backend<'static> { } // mimics typos_cli::file::FileChecker::check_file - fn check_text(&self, buffer: &str) -> Vec { - let mut accum = AccumulatePosition::new(); + fn check_text( + &self, + buffer: &str, + uri: &Url, + ) -> anyhow::Result, anyhow::Error> { + let path = uri + .to_file_path() + .map_err(|_| anyhow!("Cannot convert uri {} to file path", uri))?; + + let path_str = path + .to_str() + .ok_or_else(|| anyhow!("Invalid unicode in path {:?}", path))?; + + let state = self.state.lock().unwrap(); + + // find relevant overrides and engine for the workspace folder + let (overrides, tokenizer, dict) = match state.router.at(path_str) { + Err(_) => { + tracing::debug!( + "Using default policy because no workspace folder found for {}", + uri + ); + ( + None, + self.default_policy.tokenizer, + self.default_policy.dict, + ) + } + Ok(Match { value, params: _ }) => { + let policy = value.engine.policy(&path); + (Some(&value.overrides), policy.tokenizer, policy.dict) + } + }; + + // skip file if matches extend-exclude + if let Some(overrides) = overrides { + if overrides.matched(path_str, false).is_ignore() { + tracing::debug!("Ignoring {} because it matches extend-exclude.", uri); + return Ok(Vec::default()); + } + } - // TODO: support ignores & typos.toml + let mut accum = AccumulatePosition::new(); - typos::check_str(buffer, self.policy.tokenizer, self.policy.dict) + Ok(typos::check_str(buffer, tokenizer, dict) .map(|typo| { tracing::debug!("typo: {:?}", typo); @@ -218,9 +374,10 @@ impl Backend<'static> { ..Diagnostic::default() } }) - .collect() + .collect()) } } + struct AccumulatePosition { line_num: usize, line_pos: usize, @@ -288,7 +445,7 @@ mod tests { similar_asserts::assert_eq!( body(&output).unwrap(), format!( - r#"{{"jsonrpc":"2.0","result":{{"capabilities":{{"codeActionProvider":{{"codeActionKinds":["quickfix"],"workDoneProgress":false}},"textDocumentSync":1}},"serverInfo":{{"name":"typos","version":"{}"}}}},"id":1}}"#, + r#"{{"jsonrpc":"2.0","result":{{"capabilities":{{"codeActionProvider":{{"codeActionKinds":["quickfix"],"workDoneProgress":false}},"textDocumentSync":1,"workspace":{{"workspaceFolders":{{"changeNotifications":true,"supported":true}}}}}},"serverInfo":{{"name":"typos","version":"{}"}}}},"id":1}}"#, env!("CARGO_PKG_VERSION") ) ) @@ -462,6 +619,108 @@ mod tests { ); } + #[test_log::test(tokio::test)] + async fn test_config_file_e2e() { + let workspace_folder_uri = + Url::from_file_path(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests")).unwrap(); + + let initialize = format!( + r#"{{ + "jsonrpc": "2.0", + "method": "initialize", + "params": {{ + "capabilities": {{ + "textDocument": {{ "publishDiagnostics": {{ "dataSupport": true }} }} + }}, + "workspaceFolders": [ + {{ + "uri": "{}", + "name": "tests" + }} + ] + }}, + "id": 1 + }} + "#, + workspace_folder_uri + ); + + let did_open_diag_txt = format!( + r#"{{ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": {{ + "textDocument": {{ + "uri": "{}/diagnostics.txt", + "languageId": "plaintext", + "version": 1, + "text": "this is an apropriate test\nfo typos\n" + }} + }} + }} + "#, + workspace_folder_uri + ); + + let did_open_changelog = format!( + r#"{{ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": {{ + "textDocument": {{ + "uri": "{}/CHANGELOG.md", + "languageId": "plaintext", + "version": 1, + "text": "this is an apropriate test\nfo typos\n" + }} + }} + }} + "#, + workspace_folder_uri + ); + + let (mut req_client, mut resp_client) = start_server(); + let mut buf = vec![0; 1024]; + + req_client + .write_all(req(initialize).as_bytes()) + .await + .unwrap(); + let _ = resp_client.read(&mut buf).await.unwrap(); + + // check "fo" is corrected to "of" because of default.extend-words + tracing::debug!("{}", did_open_diag_txt); + req_client + .write_all(req(did_open_diag_txt).as_bytes()) + .await + .unwrap(); + let n = resp_client.read(&mut buf).await.unwrap(); + + similar_asserts::assert_eq!( + body(&buf[..n]).unwrap(), + format!( + r#"{{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{{"diagnostics":[{{"data":{{"corrections":["appropriate"]}},"message":"`apropriate` should be `appropriate`","range":{{"end":{{"character":21,"line":0}},"start":{{"character":11,"line":0}}}},"severity":2,"source":"typos"}},{{"data":{{"corrections":["of"]}},"message":"`fo` should be `of`","range":{{"end":{{"character":2,"line":1}},"start":{{"character":0,"line":1}}}},"severity":2,"source":"typos"}}],"uri":"{}/diagnostics.txt","version":1}}}}"#, + workspace_folder_uri + ), + ); + + // check changelog is excluded because of files.extend-exclude + tracing::debug!("{}", did_open_changelog); + req_client + .write_all(req(did_open_changelog).as_bytes()) + .await + .unwrap(); + let n = resp_client.read(&mut buf).await.unwrap(); + + similar_asserts::assert_eq!( + body(&buf[..n]).unwrap(), + format!( + r#"{{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{{"diagnostics":[],"uri":"{}/CHANGELOG.md","version":1}}}}"#, + workspace_folder_uri + ), + ); + } + fn start_server() -> (tokio::io::DuplexStream, tokio::io::DuplexStream) { let (req_client, req_server) = tokio::io::duplex(1024); let (resp_server, resp_client) = tokio::io::duplex(1024); @@ -474,8 +733,12 @@ mod tests { (req_client, resp_client) } - fn req(msg: &str) -> String { - format!("Content-Length: {}\r\n\r\n{}", msg.len(), msg) + fn req>(msg: T) -> String { + format!( + "Content-Length: {}\r\n\r\n{}", + msg.as_ref().len(), + msg.as_ref() + ) } fn body(src: &[u8]) -> Result<&str, anyhow::Error> { diff --git a/crates/typos-lsp/src/main.rs b/crates/typos-lsp/src/main.rs index 5bc6318..0236f11 100644 --- a/crates/typos-lsp/src/main.rs +++ b/crates/typos-lsp/src/main.rs @@ -1,5 +1,4 @@ mod lsp; - use tower_lsp::LspService; use tower_lsp::Server; diff --git a/crates/typos-lsp/tests/typos.toml b/crates/typos-lsp/tests/typos.toml new file mode 100644 index 0000000..5bd974f --- /dev/null +++ b/crates/typos-lsp/tests/typos.toml @@ -0,0 +1,7 @@ +[default.extend-words] +# tell typos which of the several possible corrections to use +fo = "of" + +[files] +# ignore typos in CHANGELOG.md +extend-exclude = ["CHANGELOG.md"]