docs: split and documenting code action worker (#977)

* docs: split and documenting code action worker

* dev: update prelude
This commit is contained in:
Myriad-Dreamin 2024-12-11 11:03:47 +08:00 committed by GitHub
parent 0d4cd77d2c
commit 678b2d2111
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 265 additions and 259 deletions

View file

@ -6,6 +6,8 @@ use std::path::Path;
pub(crate) use bib::*;
pub mod call;
pub use call::*;
pub mod code_action;
pub use code_action::*;
pub mod color_exprs;
pub use color_exprs::*;
pub mod link_exprs;

View file

@ -0,0 +1,259 @@
//! Provides code actions for the document.
use regex::Regex;
use crate::prelude::*;
use crate::syntax::{interpret_mode_at, InterpretMode};
/// Analyzes the document and provides code actions.
pub struct CodeActionWorker<'a> {
/// The local analysis context to work with.
ctx: &'a mut LocalContext,
/// The source document to analyze.
source: Source,
/// The code actions to provide.
pub actions: Vec<CodeActionOrCommand>,
/// The lazily calculated local URL to [`Self::source`].
local_url: OnceLock<Option<Url>>,
}
impl<'a> CodeActionWorker<'a> {
/// Creates a new color action worker.
pub fn new(ctx: &'a mut LocalContext, source: Source) -> Self {
Self {
ctx,
source,
actions: Vec::new(),
local_url: OnceLock::new(),
}
}
fn local_url(&self) -> Option<&Url> {
self.local_url
.get_or_init(|| self.ctx.uri_for_id(self.source.id()).ok())
.as_ref()
}
#[must_use]
fn local_edits(&self, edits: Vec<TextEdit>) -> Option<WorkspaceEdit> {
Some(WorkspaceEdit {
changes: Some(HashMap::from_iter([(self.local_url()?.clone(), edits)])),
..Default::default()
})
}
#[must_use]
fn local_edit(&self, edit: TextEdit) -> Option<WorkspaceEdit> {
self.local_edits(vec![edit])
}
/// Starts to work.
pub fn work(&mut self, root: LinkedNode, range: Range<usize>) -> Option<()> {
let cursor = (range.start + 1).min(self.source.text().len());
let node = root.leaf_at_compat(cursor)?;
let mut node = &node;
let mut heading_resolved = false;
let mut equation_resolved = false;
self.wrap_actions(node, range);
loop {
match node.kind() {
// Only the deepest heading is considered
SyntaxKind::Heading if !heading_resolved => {
heading_resolved = true;
self.heading_actions(node);
}
// Only the deepest equation is considered
SyntaxKind::Equation if !equation_resolved => {
equation_resolved = true;
self.equation_actions(node);
}
_ => {}
}
node = node.parent()?;
}
}
fn wrap_actions(&mut self, node: &LinkedNode, range: Range<usize>) -> Option<()> {
if range.is_empty() {
return None;
}
let start_mode = interpret_mode_at(Some(node));
if !matches!(start_mode, InterpretMode::Markup | InterpretMode::Math) {
return None;
}
let edit = self.local_edits(vec![
TextEdit {
range: self
.ctx
.to_lsp_range(range.start..range.start, &self.source),
new_text: "#[".into(),
},
TextEdit {
range: self.ctx.to_lsp_range(range.end..range.end, &self.source),
new_text: "]".into(),
},
])?;
let action = CodeActionOrCommand::CodeAction(CodeAction {
title: "Wrap with content block".to_string(),
kind: Some(CodeActionKind::REFACTOR_REWRITE),
edit: Some(edit),
..CodeAction::default()
});
self.actions.push(action);
Some(())
}
fn heading_actions(&mut self, node: &LinkedNode) -> Option<()> {
let h = node.cast::<ast::Heading>()?;
let depth = h.depth().get();
// Only the marker is replaced, for minimal text change
let marker = node
.children()
.find(|e| e.kind() == SyntaxKind::HeadingMarker)?;
let marker_range = marker.range();
if depth > 1 {
// Decrease depth of heading
let action = CodeActionOrCommand::CodeAction(CodeAction {
title: "Decrease depth of heading".to_string(),
kind: Some(CodeActionKind::REFACTOR_REWRITE),
edit: Some(self.local_edit(TextEdit {
range: self.ctx.to_lsp_range(marker_range.clone(), &self.source),
new_text: "=".repeat(depth - 1),
})?),
..CodeAction::default()
});
self.actions.push(action);
}
// Increase depth of heading
let action = CodeActionOrCommand::CodeAction(CodeAction {
title: "Increase depth of heading".to_string(),
kind: Some(CodeActionKind::REFACTOR_REWRITE),
edit: Some(self.local_edit(TextEdit {
range: self.ctx.to_lsp_range(marker_range, &self.source),
new_text: "=".repeat(depth + 1),
})?),
..CodeAction::default()
});
self.actions.push(action);
Some(())
}
fn equation_actions(&mut self, node: &LinkedNode) -> Option<()> {
let equation = node.cast::<ast::Equation>()?;
let body = equation.body();
let is_block = equation.block();
let body = node.find(body.span())?;
let body_range = body.range();
let node_end = node.range().end;
let mut chs = node.children();
let chs = chs.by_ref();
let first_dollar = chs.take(1).find(|e| e.kind() == SyntaxKind::Dollar)?;
let last_dollar = chs.rev().take(1).find(|e| e.kind() == SyntaxKind::Dollar)?;
// Erroneous equation is skipped.
// For example, some unclosed equation.
if first_dollar.offset() == last_dollar.offset() {
return None;
}
let front_range = self
.ctx
.to_lsp_range(first_dollar.range().end..body_range.start, &self.source);
let back_range = self
.ctx
.to_lsp_range(body_range.end..last_dollar.range().start, &self.source);
// Retrieve punctuation to move
let mark_after_equation = self
.source
.text()
.get(node_end..)
.and_then(|text| {
let mut ch = text.chars();
let nx = ch.next()?;
Some((nx, ch.next()))
})
.filter(|(ch, ch_next)| {
static IS_PUNCTUATION: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\p{Punctuation}").unwrap());
(ch.is_ascii_punctuation()
&& ch_next.map_or(true, |ch_next| !ch_next.is_ascii_punctuation()))
|| (!ch.is_ascii_punctuation() && IS_PUNCTUATION.is_match(&ch.to_string()))
});
let punc_modify = if let Some((nx, _)) = mark_after_equation {
let ch_range = self
.ctx
.to_lsp_range(node_end..node_end + nx.len_utf8(), &self.source);
let remove_edit = TextEdit {
range: ch_range,
new_text: "".to_owned(),
};
Some((nx, remove_edit))
} else {
None
};
let rewrite_action = |title: &str, new_text: &str| {
let mut edits = vec![
TextEdit {
range: front_range,
new_text: new_text.to_owned(),
},
TextEdit {
range: back_range,
new_text: if !new_text.is_empty() {
if let Some((ch, _)) = &punc_modify {
ch.to_string() + new_text
} else {
new_text.to_owned()
}
} else {
"".to_owned()
},
},
];
if !new_text.is_empty() {
if let Some((_, edit)) = &punc_modify {
edits.push(edit.clone());
}
}
Some(CodeActionOrCommand::CodeAction(CodeAction {
title: title.to_owned(),
kind: Some(CodeActionKind::REFACTOR_REWRITE),
edit: Some(self.local_edits(edits)?),
..CodeAction::default()
}))
};
// Prepare actions
let a1 = if is_block {
rewrite_action("Convert to inline equation", "")?
} else {
rewrite_action("Convert to block equation", " ")?
};
let a2 = rewrite_action("Convert to multiple-line block equation", "\n");
self.actions.push(a1);
if let Some(a2) = a2 {
self.actions.push(a2);
}
Some(())
}
}

View file

@ -1,13 +1,4 @@
use lsp_types::TextEdit;
use once_cell::sync::{Lazy, OnceCell};
use regex::Regex;
use typst_shim::syntax::LinkedNodeExt;
use crate::{
prelude::*,
syntax::{interpret_mode_at, InterpretMode},
SemanticRequest,
};
use crate::{analysis::CodeActionWorker, prelude::*, SemanticRequest};
/// The [`textDocument/codeAction`] request is sent from the client to the
/// server to compute commands for a given text document and range. These
@ -86,253 +77,6 @@ impl SemanticRequest for CodeActionRequest {
let mut worker = CodeActionWorker::new(ctx, source.clone());
worker.work(root, range);
let res = worker.actions;
(!res.is_empty()).then_some(res)
}
}
struct CodeActionWorker<'a> {
ctx: &'a mut LocalContext,
actions: Vec<CodeActionOrCommand>,
local_url: OnceCell<Option<Url>>,
current: Source,
}
impl<'a> CodeActionWorker<'a> {
fn new(ctx: &'a mut LocalContext, current: Source) -> Self {
Self {
ctx,
actions: Vec::new(),
local_url: OnceCell::new(),
current,
}
}
fn local_url(&self) -> Option<&Url> {
self.local_url
.get_or_init(|| self.ctx.uri_for_id(self.current.id()).ok())
.as_ref()
}
#[must_use]
fn local_edits(&self, edits: Vec<TextEdit>) -> Option<WorkspaceEdit> {
Some(WorkspaceEdit {
changes: Some(HashMap::from_iter([(self.local_url()?.clone(), edits)])),
..Default::default()
})
}
#[must_use]
fn local_edit(&self, edit: TextEdit) -> Option<WorkspaceEdit> {
self.local_edits(vec![edit])
}
fn wrap_actions(&mut self, node: &LinkedNode, range: Range<usize>) -> Option<()> {
if range.is_empty() {
return None;
}
let start_mode = interpret_mode_at(Some(node));
if !matches!(start_mode, InterpretMode::Markup | InterpretMode::Math) {
return None;
}
let edit = self.local_edits(vec![
TextEdit {
range: self
.ctx
.to_lsp_range(range.start..range.start, &self.current),
new_text: "#[".into(),
},
TextEdit {
range: self.ctx.to_lsp_range(range.end..range.end, &self.current),
new_text: "]".into(),
},
])?;
let action = CodeActionOrCommand::CodeAction(CodeAction {
title: "Wrap with content block".to_string(),
kind: Some(CodeActionKind::REFACTOR_REWRITE),
edit: Some(edit),
..CodeAction::default()
});
self.actions.push(action);
Some(())
}
fn heading_actions(&mut self, node: &LinkedNode) -> Option<()> {
let h = node.cast::<ast::Heading>()?;
let depth = h.depth().get();
// Only the marker is replaced, for minimal text change
let marker = node
.children()
.find(|e| e.kind() == SyntaxKind::HeadingMarker)?;
let marker_range = marker.range();
if depth > 1 {
// Decrease depth of heading
let action = CodeActionOrCommand::CodeAction(CodeAction {
title: "Decrease depth of heading".to_string(),
kind: Some(CodeActionKind::REFACTOR_REWRITE),
edit: Some(self.local_edit(TextEdit {
range: self.ctx.to_lsp_range(marker_range.clone(), &self.current),
new_text: "=".repeat(depth - 1),
})?),
..CodeAction::default()
});
self.actions.push(action);
}
// Increase depth of heading
let action = CodeActionOrCommand::CodeAction(CodeAction {
title: "Increase depth of heading".to_string(),
kind: Some(CodeActionKind::REFACTOR_REWRITE),
edit: Some(self.local_edit(TextEdit {
range: self.ctx.to_lsp_range(marker_range, &self.current),
new_text: "=".repeat(depth + 1),
})?),
..CodeAction::default()
});
self.actions.push(action);
Some(())
}
fn equation_actions(&mut self, node: &LinkedNode) -> Option<()> {
let equation = node.cast::<ast::Equation>()?;
let body = equation.body();
let is_block = equation.block();
let body = node.find(body.span())?;
let body_range = body.range();
let node_end = node.range().end;
let mut chs = node.children();
let chs = chs.by_ref();
let first_dollar = chs.take(1).find(|e| e.kind() == SyntaxKind::Dollar)?;
let last_dollar = chs.rev().take(1).find(|e| e.kind() == SyntaxKind::Dollar)?;
// Erroneous equation is skipped.
// For example, some unclosed equation.
if first_dollar.offset() == last_dollar.offset() {
return None;
}
let front_range = self
.ctx
.to_lsp_range(first_dollar.range().end..body_range.start, &self.current);
let back_range = self
.ctx
.to_lsp_range(body_range.end..last_dollar.range().start, &self.current);
// Retrieve punctuation to move
let mark_after_equation = self
.current
.text()
.get(node_end..)
.and_then(|text| {
let mut ch = text.chars();
let nx = ch.next()?;
Some((nx, ch.next()))
})
.filter(|(ch, ch_next)| {
static IS_PUNCTUATION: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\p{Punctuation}").unwrap());
(ch.is_ascii_punctuation()
&& ch_next.map_or(true, |ch_next| !ch_next.is_ascii_punctuation()))
|| (!ch.is_ascii_punctuation() && IS_PUNCTUATION.is_match(&ch.to_string()))
});
let punc_modify = if let Some((nx, _)) = mark_after_equation {
let ch_range = self
.ctx
.to_lsp_range(node_end..node_end + nx.len_utf8(), &self.current);
let remove_edit = TextEdit {
range: ch_range,
new_text: "".to_owned(),
};
Some((nx, remove_edit))
} else {
None
};
let rewrite_action = |title: &str, new_text: &str| {
let mut edits = vec![
TextEdit {
range: front_range,
new_text: new_text.to_owned(),
},
TextEdit {
range: back_range,
new_text: if !new_text.is_empty() {
if let Some((ch, _)) = &punc_modify {
ch.to_string() + new_text
} else {
new_text.to_owned()
}
} else {
"".to_owned()
},
},
];
if !new_text.is_empty() {
if let Some((_, edit)) = &punc_modify {
edits.push(edit.clone());
}
}
Some(CodeActionOrCommand::CodeAction(CodeAction {
title: title.to_owned(),
kind: Some(CodeActionKind::REFACTOR_REWRITE),
edit: Some(self.local_edits(edits)?),
..CodeAction::default()
}))
};
// Prepare actions
let a1 = if is_block {
rewrite_action("Convert to inline equation", "")?
} else {
rewrite_action("Convert to block equation", " ")?
};
let a2 = rewrite_action("Convert to multiple-line block equation", "\n");
self.actions.push(a1);
if let Some(a2) = a2 {
self.actions.push(a2);
}
Some(())
}
fn work(&mut self, root: LinkedNode, range: Range<usize>) -> Option<()> {
let cursor = (range.start + 1).min(self.current.text().len());
let node = root.leaf_at_compat(cursor)?;
let mut node = &node;
let mut heading_resolved = false;
let mut equation_resolved = false;
self.wrap_actions(node, range);
loop {
match node.kind() {
// Only the deepest heading is considered
SyntaxKind::Heading if !heading_resolved => {
heading_resolved = true;
self.heading_actions(node);
}
// Only the deepest equation is considered
SyntaxKind::Equation if !equation_resolved => {
equation_resolved = true;
self.equation_actions(node);
}
_ => {}
}
node = node.parent()?;
}
(!worker.actions.is_empty()).then_some(worker.actions)
}
}

View file

@ -2,7 +2,7 @@ pub use std::collections::HashMap;
pub use std::iter;
pub use std::ops::Range;
pub use std::path::{Path, PathBuf};
pub use std::sync::{Arc, LazyLock};
pub use std::sync::{Arc, LazyLock, OnceLock};
pub use ecow::{eco_vec, EcoVec};
pub use itertools::{Format, Itertools};
@ -25,6 +25,7 @@ pub use typst::syntax::{
FileId as TypstFileId, LinkedNode, Source, Spanned, SyntaxKind, SyntaxNode,
};
pub use typst::World;
pub use typst_shim::syntax::LinkedNodeExt;
pub use crate::analysis::{Definition, LocalContext};
pub use crate::docs::DefDocs;