diff --git a/Cargo.lock b/Cargo.lock index 03dc840..86f3e3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -406,6 +406,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "either" version = "1.9.0" @@ -743,6 +764,17 @@ version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +[[package]] +name = "libredox" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" +dependencies = [ + "bitflags 2.4.1", + "libc", + "redox_syscall", +] + [[package]] name = "linux-raw-sys" version = "0.4.12" @@ -848,6 +880,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "os_info" version = "3.7.0" @@ -967,6 +1005,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.10.2" @@ -1136,6 +1185,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b" +dependencies = [ + "dirs", +] + [[package]] name = "simdutf8" version = "0.1.4" @@ -1547,6 +1605,7 @@ dependencies = [ "matchit", "serde", "serde_json", + "shellexpand", "similar-asserts", "test-log", "tokio", diff --git a/README.md b/README.md index 35a6e3b..aac7508 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Config files will be read from the workspace folder or its parents. If there is This extension contributes the following settings: +- `typos.config`: Custom config. Used together with any workspace config files, taking precedence for settings declared in both. Equivalent to the typos `--config` [cli argument](https://github.com/crate-ci/typos/blob/master/docs/reference.md). - `typos.diagnosticSeverity`: How typos are rendered in the editor, eg: as errors, warnings, information, or hints. - `typos.logLevel`: Logging level of the language server. Logs appear in the _Output -> Typos_ pane. - `typos.path`: Path to the `typos-lsp` binary. If empty the bundled binary will be used. diff --git a/crates/typos-lsp/Cargo.toml b/crates/typos-lsp/Cargo.toml index fce4b0f..1eb8769 100644 --- a/crates/typos-lsp/Cargo.toml +++ b/crates/typos-lsp/Cargo.toml @@ -18,6 +18,7 @@ typos-cli = "1.16" serde = { version = "1.0", features = ["derive"] } ignore = "0.4.20" matchit = "0.7.1" +shellexpand = "3.1.0" [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 1097af4..cceea2a 100644 --- a/crates/typos-lsp/src/lsp.rs +++ b/crates/typos-lsp/src/lsp.rs @@ -3,7 +3,7 @@ use matchit::{Match, Router}; use std::borrow::Cow; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use bstr::ByteSlice; @@ -23,6 +23,7 @@ pub struct Backend<'s, 'p> { #[derive(Default)] struct BackendState<'s> { severity: Option, + config: Option, workspace_folders: Vec, router: Router>, } @@ -32,28 +33,38 @@ struct TyposCli<'s> { 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))?; +// initialise an engine and overrides using the config file from path or its parent +fn try_new_cli<'s>( + path: &Path, + config: Option<&Path>, +) -> anyhow::Result, anyhow::Error> { + // 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); + + // TODO: currently mimicking typos here but do we need to create and the update + // a default config? + let mut overrides = typos_cli::config::Config::default(); + if let Some(config_path) = config { + let custom = typos_cli::config::Config::from_file(config_path)?; + if let Some(custom) = custom { + overrides.update(&custom); + engine.set_overrides(overrides); } - let overrides = overrides.build()?; + } - Ok(TyposCli { overrides, engine }) + 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> { @@ -95,8 +106,8 @@ impl<'s> BackendState<'s> { "/*p" ); tracing::debug!("Adding route {}", &path_wildcard); - let config = TyposCli::try_from(&path)?; - self.router.insert(path_wildcard, config)?; + let cli = try_new_cli(&path, self.config.as_deref())?; + self.router.insert(path_wildcard, cli)?; } Ok(()) } @@ -146,32 +157,37 @@ impl LanguageServer for Backend<'static, 'static> { let mut state = self.state.lock().unwrap(); if let Some(ops) = params.initialization_options { - if let Some(value) = ops - .as_object() - .and_then(|o| o.get("diagnosticSeverity").cloned()) - { - match value.as_str().unwrap_or("").to_lowercase().as_str() { - "error" => { - state.severity = Some(DiagnosticSeverity::ERROR); - } - "warning" => { - state.severity = Some(DiagnosticSeverity::WARNING); - } - "information" => { - state.severity = Some(DiagnosticSeverity::INFORMATION); - } - "hint" => { - state.severity = Some(DiagnosticSeverity::HINT); + if let Some(values) = ops.as_object() { + if let Some(value) = values.get("diagnosticSeverity").cloned() { + match value.as_str().unwrap_or("").to_lowercase().as_str() { + "error" => { + state.severity = Some(DiagnosticSeverity::ERROR); + } + "warning" => { + state.severity = Some(DiagnosticSeverity::WARNING); + } + "information" => { + state.severity = Some(DiagnosticSeverity::INFORMATION); + } + "hint" => { + state.severity = Some(DiagnosticSeverity::HINT); + } + _ => { + tracing::warn!("Unknown diagnostic severity: {}", value); + } } - _ => { - tracing::warn!("Unknown diagnostic severity: {}", value); + } + if let Some(value) = values.get("config").cloned() { + if let Some(value) = value.as_str() { + let expanded_path = PathBuf::from(shellexpand::tilde(value).to_string()); + state.config = Some(expanded_path); } } } } if let Err(e) = state.set_workspace_folders(params.workspace_folders.unwrap_or_default()) { - tracing::warn!("Cannot set workspace folders: {}", e); + tracing::warn!("Falling back to default config: {}", e); } Ok(InitializeResult { diff --git a/crates/typos-lsp/tests/custom_typos.toml b/crates/typos-lsp/tests/custom_typos.toml new file mode 100644 index 0000000..4d7e1cf --- /dev/null +++ b/crates/typos-lsp/tests/custom_typos.toml @@ -0,0 +1,3 @@ +[default.extend-words] +# tell typos which of the several possible corrections to use +fo = "go" diff --git a/crates/typos-lsp/tests/integration_test.rs b/crates/typos-lsp/tests/integration_test.rs index 43a0539..82e9940 100644 --- a/crates/typos-lsp/tests/integration_test.rs +++ b/crates/typos-lsp/tests/integration_test.rs @@ -300,6 +300,88 @@ async fn test_config_file_e2e() { ); } +// TODO refactor and extract the boilerplate +#[test_log::test(tokio::test)] +async fn test_custom_config_file_e2e() { + let workspace_folder_uri = + Url::from_file_path(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests")).unwrap(); + + let custom_config = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("custom_typos.toml"); + + let initialize = format!( + r#"{{ + "jsonrpc": "2.0", + "method": "initialize", + "params": {{ + "initializationOptions": {{ + "diagnosticSeverity": "Warning", + "config": "{}" + }}, + "capabilities": {{ + "textDocument": {{ "publishDiagnostics": {{ "dataSupport": true }} }} + }}, + "workspaceFolders": [ + {{ + "uri": "{}", + "name": "tests" + }} + ] + }}, + "id": 1 + }} + "#, + custom_config.to_string_lossy().replace("\\", "\\\\"), // escape windows path separators to make valid json + workspace_folder_uri, + ); + + println!("{}", initialize); + + 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 (mut req_client, mut resp_client) = start_server(); + let mut buf = vec![0; 10240]; + + req_client + .write_all(req(initialize).as_bytes()) + .await + .unwrap(); + let _ = resp_client.read(&mut buf).await.unwrap(); + + // check "fo" is corrected to "go" because of default.extend-words + // in custom_typos.toml which overrides typos.toml + 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":["go"]}},"message":"`fo` should be `go`","range":{{"end":{{"character":2,"line":1}},"start":{{"character":0,"line":1}}}},"severity":2,"source":"typos"}}],"uri":"{}/diagnostics.txt","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); diff --git a/package.json b/package.json index 7d8b7a2..c5c0583 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,11 @@ "type": "string", "description": "Path to the `typos-lsp` binary. If empty the bundled binary will be used." }, + "typos.config": { + "scope": "machine-overridable", + "type": "string", + "description": "Path to a custom config file. Used together with any workspace config files, taking precedence for settings declared in both." + }, "typos.diagnosticSeverity": { "scope": "window", "type": "string", diff --git a/src/extension.ts b/src/extension.ts index 561480d..34d0e94 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -25,9 +25,10 @@ export async function activate( vscode.workspace.onDidChangeConfiguration( async (e: vscode.ConfigurationChangeEvent) => { const restartTriggeredBy = [ - "typos.path", - "typos.logLevel", + "typos.config", "typos.diagnosticSeverity", + "typos.logLevel", + "typos.path", ].find((s) => e.affectsConfiguration(s)); if (restartTriggeredBy) { @@ -99,6 +100,7 @@ async function createClient( outputChannel: outputChannel, traceOutputChannel: outputChannel, initializationOptions: { + config: config.get("config") ? config.get("config") : null, diagnosticSeverity: config.get("diagnosticSeverity"), }, };