Skip to content

Commit

Permalink
feat: code action to add a misspelling to the config file
Browse files Browse the repository at this point in the history
  • Loading branch information
mikavilpas committed Jun 21, 2024
1 parent df11410 commit 08753d6
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 8 deletions.
1 change: 1 addition & 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 crates/typos-lsp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
115 changes: 108 additions & 7 deletions crates/typos-lsp/src/lsp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -21,6 +22,16 @@ pub struct Backend<'s, 'p> {
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct DiagnosticData<'c> {
corrections: Vec<Cow<'c, str>>,
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]
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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::<DiagnosticData>(data.clone())
{
corrections
let mut suggestions: Vec<_> = corrections
.iter()
.map(|c| {
CodeActionOrCommand::CodeAction(CodeAction {
Expand All @@ -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 = &params.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",
Expand All @@ -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<Option<serde_json::Value>> {
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::<IgnoreInProjectCommandArguments>(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: {:?}",
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions crates/typos-lsp/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ use crate::typos::Instance;
#[derive(Default)]
pub(crate) struct BackendState<'s> {
pub severity: Option<DiagnosticSeverity>,
/// The path to the configuration file given to the LSP server. Settings in this configuration
/// file override the typos.toml settings.
pub config: Option<PathBuf>,
pub workspace_folders: Vec<WorkspaceFolder>,

/// 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<crate::typos::Instance<'s>>,
}

Expand Down
40 changes: 39 additions & 1 deletion crates/typos-lsp/src/typos.rs
Original file line number Diff line number Diff line change
@@ -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<'_> {
Expand Down Expand Up @@ -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:
///
/// <https://github.com/crate-ci/typos/blob/master/docs/reference.md>
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
Expand Down
49 changes: 49 additions & 0 deletions crates/typos-lsp/src/typos/config_file_location.rs
Original file line number Diff line number Diff line change
@@ -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<typos_cli::config::Config>,
}

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<ConfigFileLocation> {
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
))
}
}
15 changes: 15 additions & 0 deletions crates/typos-lsp/src/typos/config_file_suggestions.rs
Original file line number Diff line number Diff line change
@@ -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<ConfigFileLocation>,
}

0 comments on commit 08753d6

Please sign in to comment.