diff --git a/Cargo.lock b/Cargo.lock index fae5cd2..15e8589 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1711,6 +1711,7 @@ dependencies = [ "similar-asserts", "test-log", "tokio", + "toml", "tower-lsp", "tracing", "tracing-subscriber", diff --git a/crates/typos-lsp/Cargo.toml b/crates/typos-lsp/Cargo.toml index ae9e2cf..1cf3c2d 100644 --- a/crates/typos-lsp/Cargo.toml +++ b/crates/typos-lsp/Cargo.toml @@ -21,6 +21,7 @@ matchit = "0.8.2" shellexpand = "3.1.0" regex = "1.10.4" once_cell = "1.19.0" +toml = "0.8.12" [dev-dependencies] test-log = { version = "0.2.16", features = ["trace"] } diff --git a/crates/typos-lsp/src/lsp.rs b/crates/typos-lsp/src/lsp.rs index 7151797..25dc13a 100644 --- a/crates/typos-lsp/src/lsp.rs +++ b/crates/typos-lsp/src/lsp.rs @@ -2,13 +2,14 @@ use matchit::Match; use std::borrow::Cow; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use serde_json::{json, to_string}; use tower_lsp::lsp_types::*; use tower_lsp::*; use tower_lsp::{Client, LanguageServer}; +use typos_cli::config::DictConfig; use typos_cli::policy; use crate::state::{url_path_sanitised, BackendState}; @@ -21,6 +22,16 @@ pub struct Backend<'s, 'p> { #[derive(Debug, serde::Serialize, serde::Deserialize)] struct DiagnosticData<'c> { corrections: Vec>, + typo: Cow<'c, str>, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct IgnoreInProjectCommandArguments { + typo: String, + /// The file that contains the typo to ignore + typo_file_path: String, + /// The configuration file that should be modified to ignore the typo + config_file_path: String, } #[tower_lsp::async_trait] @@ -97,6 +108,11 @@ impl LanguageServer for Backend<'static, 'static> { resolve_provider: None, }, )), + execute_command_provider: Some(ExecuteCommandOptions { + // TODO this magic string should be a constant + commands: vec!["ignore-in-project".to_string()], + work_done_progress_options: WorkDoneProgressOptions::default(), + }), workspace: Some(WorkspaceServerCapabilities { workspace_folders: Some(WorkspaceFoldersServerCapabilities { supported: Some(true), @@ -150,6 +166,8 @@ impl LanguageServer for Backend<'static, 'static> { .await; } + /// Called by the editor to request displaying a list of code actions and commands for a given + /// position in the current file. async fn code_action( &self, params: CodeActionParams, @@ -163,10 +181,10 @@ impl LanguageServer for Backend<'static, 'static> { .filter(|diag| diag.source == Some("typos".to_string())) .flat_map(|diag| match &diag.data { Some(data) => { - if let Ok(DiagnosticData { corrections }) = + if let Ok(DiagnosticData { corrections, typo }) = serde_json::from_value::(data.clone()) { - corrections + let mut suggestions: Vec<_> = corrections .iter() .map(|c| { CodeActionOrCommand::CodeAction(CodeAction { @@ -191,7 +209,44 @@ impl LanguageServer for Backend<'static, 'static> { ..CodeAction::default() }) }) - .collect() + .collect(); + + if let Ok(Match { value, .. }) = self + .state + .lock() + .unwrap() + .router + .at(params.text_document.uri.to_file_path().unwrap().to_str().unwrap()) + { + let typo_file: &Url = ¶ms.text_document.uri; + let config_files = + value.config_files_in_project(Path::new(typo_file.as_str())); + + suggestions.push(CodeActionOrCommand::Command(Command { + title: format!("Ignore `{}` in the project", typo), + command: "ignore-in-project".to_string(), + arguments: Some( + [serde_json::to_value(IgnoreInProjectCommandArguments { + typo: typo.to_string(), + typo_file_path: typo_file.to_string(), + config_file_path: config_files + .project_root + .path + .to_string_lossy() + .to_string(), + }) + .unwrap()] + .into(), + ), + })); + } else { + tracing::warn!( + "code_action: Cannot create a code action for ignoring a typo in the project. Reason: No route found for file '{}'", + params.text_document.uri + ); + } + + suggestions } else { tracing::error!( "Deserialization failed: received {:?} as diagnostic data", @@ -210,6 +265,51 @@ impl LanguageServer for Backend<'static, 'static> { Ok(Some(actions)) } + /// Called by the editor to execute a server side command, such as ignoring a typo. + async fn execute_command( + &self, + raw_params: ExecuteCommandParams, + ) -> jsonrpc::Result> { + tracing::debug!( + "execute_command: {:?}", + to_string(&raw_params).unwrap_or_default() + ); + + // TODO reduce the nesting + if raw_params.command == "ignore-in-project" { + let argument = raw_params + .arguments + .into_iter() + .next() + .expect("no arguments for ignore-in-project command"); + + if let Ok(IgnoreInProjectCommandArguments { + typo, + config_file_path, + .. + }) = serde_json::from_value::(argument) + { + let mut config = typos_cli::config::Config::from_file(Path::new(&config_file_path)) + .ok() + .flatten() + .unwrap_or_default(); + + config.default.dict.update(&DictConfig { + extend_words: HashMap::from([(typo.clone().into(), typo.into())]), + ..Default::default() + }); + + std::fs::write( + &config_file_path, + toml::to_string_pretty(&config).expect("cannot serialize config"), + ) + .unwrap_or_else(|_| panic!("Cannot write to {}", config_file_path)); + }; + } + + Ok(None) + } + async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) { tracing::debug!( "did_change_workspace_folders: {:?}", @@ -271,9 +371,10 @@ impl<'s, 'p> Backend<'s, 'p> { }, // store corrections for retrieval during code_action data: match typo.corrections { - typos::Status::Corrections(corrections) => { - Some(json!(DiagnosticData { corrections })) - } + typos::Status::Corrections(corrections) => Some(json!(DiagnosticData { + corrections, + typo: typo.typo + })), _ => None, }, ..Diagnostic::default() diff --git a/crates/typos-lsp/src/state.rs b/crates/typos-lsp/src/state.rs index 3d5ec69..de71022 100644 --- a/crates/typos-lsp/src/state.rs +++ b/crates/typos-lsp/src/state.rs @@ -8,8 +8,13 @@ use crate::typos::Instance; #[derive(Default)] pub(crate) struct BackendState<'s> { pub severity: Option, + /// The path to the configuration file given to the LSP server. Settings in this configuration + /// file override the typos.toml settings. pub config: Option, pub workspace_folders: Vec, + + /// Maps routes (file system paths) to TyposCli instances, so that we can quickly find the + /// correct instance for a given file path pub router: Router>, } diff --git a/crates/typos-lsp/src/typos.rs b/crates/typos-lsp/src/typos.rs index 1eeaf1e..ac40272 100644 --- a/crates/typos-lsp/src/typos.rs +++ b/crates/typos-lsp/src/typos.rs @@ -1,11 +1,18 @@ -use std::path::Path; +mod config_file_location; +mod config_file_suggestions; + +use std::path::{Path, PathBuf}; use bstr::ByteSlice; use ignore::overrides::{Override, OverrideBuilder}; use typos_cli::policy; pub struct Instance<'s> { + /// File path rules to ignore pub ignores: Override, pub engine: policy::ConfigEngine<'s>, + + /// The path where the LSP server was started + pub project_root: PathBuf, } impl Instance<'_> { @@ -46,10 +53,41 @@ impl Instance<'_> { let ignore = ignores.build()?; Ok(Instance { + project_root: path.to_path_buf(), ignores: ignore, engine, }) } + + /// Returns the typos_cli configuration files that are relevant for the given path. Note that + /// all config files are read by typos_cli, and the settings are applied in precedence order: + /// + /// + pub fn config_files_in_project( + &self, + starting_path: &Path, + ) -> config_file_suggestions::ConfigFileSuggestions { + // limit the search to the project root, never search above it + let project_path = self.project_root.as_path(); + + let mut suggestions = config_file_suggestions::ConfigFileSuggestions { + project_root: config_file_location::ConfigFileLocation::from_dir_or_default( + self.project_root.as_path(), + ), + config_files: vec![], + }; + starting_path + .ancestors() + .filter(|path| path.starts_with(project_path)) + .filter(|path| *path != self.project_root.as_path()) + .for_each(|path| { + let config_location = + config_file_location::ConfigFileLocation::from_dir_or_default(path); + suggestions.config_files.push(config_location); + }); + + suggestions + } } // mimics typos_cli::file::FileChecker::check_file diff --git a/crates/typos-lsp/src/typos/config_file_location.rs b/crates/typos-lsp/src/typos/config_file_location.rs new file mode 100644 index 0000000..643fab9 --- /dev/null +++ b/crates/typos-lsp/src/typos/config_file_location.rs @@ -0,0 +1,49 @@ +use std::path::Path; + +use std::path::PathBuf; + +/// Represents a path to a typos_cli config file and, if it contains a configuration file, the file +/// contents +pub struct ConfigFileLocation { + pub path: PathBuf, + pub config: Option, +} + +impl ConfigFileLocation { + pub fn from_dir_or_default(path: &Path) -> ConfigFileLocation { + let directory = if path.is_dir() { + path + } else { + path.parent().unwrap() + }; + ConfigFileLocation::from_dir(directory).unwrap_or_else(|_| ConfigFileLocation { + path: path.to_path_buf(), + config: None, + }) + } + + // copied from typos_cli::config::Config::from_dir with the difference that it shows which + // config file was found of the supported ones. This information is useful when we want to + // modify the config file later on. + pub fn from_dir(dir: &Path) -> anyhow::Result { + assert!( + dir.is_dir(), + "Expected a directory that might contain a configuration file" + ); + + for file in typos_cli::config::SUPPORTED_FILE_NAMES { + let path = dir.join(file); + if let Ok(Some(config)) = typos_cli::config::Config::from_file(path.as_path()) { + return Ok(ConfigFileLocation { + path, + config: Some(config), + }); + } + } + + Err(anyhow::anyhow!( + "No typos_cli config file found starting from {:?}", + dir + )) + } +} diff --git a/crates/typos-lsp/src/typos/config_file_suggestions.rs b/crates/typos-lsp/src/typos/config_file_suggestions.rs new file mode 100644 index 0000000..09abbfd --- /dev/null +++ b/crates/typos-lsp/src/typos/config_file_suggestions.rs @@ -0,0 +1,15 @@ +use config_file_location::ConfigFileLocation; + +use super::config_file_location; + +/// Represents the paths to typos_cli config files that could be used when adding a new ignore +/// rule. The config files may or may not exist. +pub struct ConfigFileSuggestions { + /// The path to a (possible) configuration file in the directory where the LSP server was + /// started. This is always included as the default suggestion. + pub project_root: ConfigFileLocation, + + /// Other configuration files that currently exist in the project. The order is from the closest + /// to the currently open file to the project root. Only existing files are included. + pub config_files: Vec, +}