Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: feat: code action to add a misspelling to the config file #72

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
50 changes: 50 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/typos-lsp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ matchit = "0.8.4"
shellexpand = "3.1.0"
regex = "1.10.6"
once_cell = "1.19.0"
toml_edit = "0.22.14"

[dev-dependencies]
test-log = { version = "0.2.16", features = ["trace"] }
httparse = "1.9"
similar-asserts = "1.4"
tempfile = "3.10.1"
113 changes: 107 additions & 6 deletions crates/typos-lsp/src/lsp.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use ignore_typo_action::IGNORE_IN_PROJECT;
use matchit::Match;

use std::borrow::Cow;
Expand All @@ -18,9 +19,19 @@ pub struct Backend<'s, 'p> {
default_policy: policy::Policy<'p, 'p, 'p>,
}

mod ignore_typo_action;

#[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 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,10 @@ impl LanguageServer for Backend<'static, 'static> {
resolve_provider: None,
},
)),
execute_command_provider: Some(ExecuteCommandOptions {
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 +165,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 +180,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 +208,55 @@ 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 config_files = value.config_files_in_project();

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(),
config_file_path: config_files
.project_root
.to_string_lossy()
.to_string(),
})
.unwrap()]
.into(),
),
}));

if let Some(explicit_config) = &config_files.explicit {
suggestions.push(CodeActionOrCommand::Command(Command {
title: format!("Ignore `{}` in the configuration file", typo),
command: IGNORE_IN_PROJECT.to_string(),
arguments: Some(
[serde_json::to_value(IgnoreInProjectCommandArguments {
typo: typo.to_string(),
config_file_path: explicit_config.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 +275,41 @@ 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()
);

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)
{
ignore_typo_action::ignore_typo_in_config_file(
PathBuf::from(config_file_path),
typo,
)
.unwrap();
self.state.lock().unwrap().update_router().unwrap();
};
}

Ok(None)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI probably the easiest way to trigger reloading the config file would be to call update_router which resets the router with new Instances (this approach does have a memory leak but that could be solved independently see #11)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks really good, thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reloading seems to work for me now!

}

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
111 changes: 111 additions & 0 deletions crates/typos-lsp/src/lsp/ignore_typo_action.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
use std::{
fs::read_to_string,
path::{Path, PathBuf},
};

use anyhow::{anyhow, Context};
use toml_edit::DocumentMut;

pub(super) const IGNORE_IN_PROJECT: &str = "ignore-in-project";

pub(super) fn ignore_typo_in_config_file(config_file: PathBuf, typo: String) -> anyhow::Result<()> {
let input = read_to_string(&config_file)
.with_context(|| anyhow!("Cannot read config file at {}", config_file.display()))
.unwrap_or("".to_string());

let document = add_typo(input, typo, &config_file)?;

std::fs::write(&config_file, document.to_string())
.with_context(|| anyhow!("Cannot write config file to {}", config_file.display()))?;

Ok(())
}

fn add_typo(
input: String,
typo: String,
config_file_path: &Path,
) -> Result<DocumentMut, anyhow::Error> {
// preserve comments and formatting
let mut document = input
.parse::<DocumentMut>()
.with_context(|| anyhow!("Cannot parse config file at {}", config_file_path.display()))?;
let extend_words = document
.entry("default")
.or_insert(toml_edit::table())
.as_table_mut()
.context("Cannot get 'default' table")?
.entry("extend-words")
.or_insert(toml_edit::table())
.as_table_mut()
.context("Cannot get 'extend-words' table")?;
extend_words[typo.as_str()] = toml_edit::value(typo.clone());
Ok(document)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_add_typo_to_empty_file() {
let empty_file = "";
let document = add_typo(
empty_file.to_string(),
"typo".to_string(),
PathBuf::from("test.toml").as_path(),
)
.unwrap();

similar_asserts::assert_eq!(
document.to_string(),
[
"[default]",
"",
"[default.extend-words]",
"typo = \"typo\"",
""
]
.join("\n")
);
}

#[test]
fn test_add_typo_to_existing_file() -> anyhow::Result<()> {
// should preserve comments and formatting

let existing_file = [
"[files] # comment",
"# comment",
"extend-exclude = [\"CHANGELOG.md\", \"crates/typos-lsp/tests/integration_test.rs\"]",
]
.join("\n");

// make sure the config is valid (so the test makes sense)
let _ = typos_cli::config::Config::from_toml(&existing_file)?;

let document = add_typo(
existing_file.to_string(),
"typo".to_string(),
PathBuf::from("test.toml").as_path(),
)?;

similar_asserts::assert_eq!(
document.to_string(),
[
"[files] # comment",
"# comment",
"extend-exclude = [\"CHANGELOG.md\", \"crates/typos-lsp/tests/integration_test.rs\"]",
"",
"[default]",
"",
"[default.extend-words]",
"typo = \"typo\"",
""
]
.join("\n")
);

Ok(())
}
}
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
Loading
Loading