1use ecow::eco_format;
4use lsp_types::{ChangeAnnotation, CreateFile, CreateFileOptions};
5use regex::Regex;
6use tinymist_analysis::syntax::{
7 PreviousItem, SyntaxClass, adjust_expr, node_ancestors, previous_items,
8};
9use tinymist_std::path::{diff, unix_slash};
10use typst::syntax::Side;
11
12use super::get_link_exprs_in;
13use crate::analysis::LinkTarget;
14use crate::prelude::*;
15use crate::syntax::{InterpretMode, interpret_mode_at};
16
17pub struct CodeActionWorker<'a> {
19 ctx: &'a mut LocalContext,
21 source: Source,
23 pub actions: Vec<CodeAction>,
25 local_url: OnceLock<Option<Url>>,
27}
28
29impl<'a> CodeActionWorker<'a> {
30 pub fn new(ctx: &'a mut LocalContext, source: Source) -> Self {
32 Self {
33 ctx,
34 source,
35 actions: Vec::new(),
36 local_url: OnceLock::new(),
37 }
38 }
39
40 fn local_url(&self) -> Option<&Url> {
41 self.local_url
42 .get_or_init(|| self.ctx.uri_for_id(self.source.id()).ok())
43 .as_ref()
44 }
45
46 #[must_use]
47 fn local_edits(&self, edits: Vec<EcoSnippetTextEdit>) -> Option<EcoWorkspaceEdit> {
48 Some(EcoWorkspaceEdit {
49 changes: Some(HashMap::from_iter([(self.local_url()?.clone(), edits)])),
50 ..Default::default()
51 })
52 }
53
54 #[must_use]
55 fn local_edit(&self, edit: EcoSnippetTextEdit) -> Option<EcoWorkspaceEdit> {
56 self.local_edits(vec![edit])
57 }
58
59 pub(crate) fn autofix(
60 &mut self,
61 root: &LinkedNode<'_>,
62 range: &Range<usize>,
63 context: &lsp_types::CodeActionContext,
64 ) -> Option<()> {
65 if let Some(only) = &context.only
66 && !only.is_empty()
67 && !only
68 .iter()
69 .any(|kind| *kind == CodeActionKind::EMPTY || *kind == CodeActionKind::QUICKFIX)
70 {
71 return None;
72 }
73
74 for diag in &context.diagnostics {
75 if diag.source.as_ref().is_none_or(|t| t != "typst") {
76 continue;
77 }
78
79 match match_autofix_kind(diag.message.as_str()) {
80 Some(AutofixKind::UnknownVariable) => {
81 self.autofix_unknown_variable(root, range);
82 }
83 Some(AutofixKind::FileNotFound) => {
84 self.autofix_file_not_found(root, range);
85 }
86 _ => {}
87 }
88 }
89
90 Some(())
91 }
92
93 pub fn autofix_unknown_variable(
95 &mut self,
96 root: &LinkedNode,
97 range: &Range<usize>,
98 ) -> Option<()> {
99 let cursor = (range.start + 1).min(self.source.text().len());
100 let node = root.leaf_at_compat(cursor)?;
101 self.create_missing_variable(root, &node);
102 self.add_spaces_to_math_unknown_variable(&node);
103 Some(())
104 }
105
106 fn create_missing_variable(
107 &mut self,
108 root: &LinkedNode<'_>,
109 node: &LinkedNode<'_>,
110 ) -> Option<()> {
111 let ident = 'determine_ident: {
112 if let Some(ident) = node.cast::<ast::Ident>() {
113 break 'determine_ident ident.get().clone();
114 }
115 if let Some(ident) = node.cast::<ast::MathIdent>() {
116 break 'determine_ident ident.get().clone();
117 }
118
119 return None;
120 };
121
122 enum CreatePosition {
123 Before(usize),
124 After(usize),
125 Bad,
126 }
127
128 let previous_decl = previous_items(node.clone(), |item| {
129 match item {
130 PreviousItem::Parent(parent, ..) => match parent.kind() {
131 SyntaxKind::LetBinding => {
132 let mut create_before = parent.clone();
133 while let Some(before) = create_before.prev_sibling() {
134 if matches!(before.kind(), SyntaxKind::Hash) {
135 create_before = before;
136 continue;
137 }
138
139 break;
140 }
141
142 return Some(CreatePosition::Before(create_before.range().start));
143 }
144 SyntaxKind::CodeBlock | SyntaxKind::ContentBlock => {
145 let child = parent.children().find(|child| {
146 matches!(
147 child.kind(),
148 SyntaxKind::LeftBrace | SyntaxKind::LeftBracket
149 )
150 })?;
151
152 return Some(CreatePosition::After(child.range().end));
153 }
154 SyntaxKind::ModuleImport | SyntaxKind::ModuleInclude => {
155 return Some(CreatePosition::Bad);
156 }
157 _ => {}
158 },
159 PreviousItem::Sibling(node) => {
160 if matches!(
161 node.kind(),
162 SyntaxKind::ModuleImport | SyntaxKind::ModuleInclude
163 ) {
164 return Some(CreatePosition::After(node.range().end));
166 }
167 }
168 }
169
170 None
171 });
172
173 let (create_pos, side) = match previous_decl {
174 Some(CreatePosition::Before(pos)) => (pos, Side::Before),
175 Some(CreatePosition::After(pos)) => (pos, Side::After),
176 None => (0, Side::After),
177 Some(CreatePosition::Bad) => return None,
178 };
179
180 let pos_node = root.leaf_at(create_pos, side.clone());
181 let mode = match interpret_mode_at(pos_node.as_ref()) {
182 InterpretMode::Markup => "#",
183 _ => "",
184 };
185
186 let extend_assign = if self.ctx.analysis.extended_code_action {
187 " = ${1:none}$0"
188 } else {
189 ""
190 };
191 let new_text = if matches!(side, Side::Before) {
192 eco_format!("{mode}let {ident}{extend_assign}\n\n")
193 } else {
194 eco_format!("\n\n{mode}let {ident}{extend_assign}")
195 };
196
197 let range = self.ctx.to_lsp_range(create_pos..create_pos, &self.source);
198 let edit = self.local_edit(EcoSnippetTextEdit::new(range, new_text))?;
199 let action = CodeAction {
200 title: "Create missing variable".to_string(),
201 kind: Some(CodeActionKind::QUICKFIX),
202 edit: Some(edit),
203 ..CodeAction::default()
204 };
205 self.actions.push(action);
206 Some(())
207 }
208
209 fn add_spaces_to_math_unknown_variable(&mut self, node: &LinkedNode<'_>) -> Option<()> {
212 let ident = node.cast::<ast::MathIdent>()?.get();
213
214 let needs_parens = matches!(
217 node.parent_kind(),
218 Some(SyntaxKind::MathAttach | SyntaxKind::MathFrac)
219 );
220 let new_text = if needs_parens {
221 eco_format!("({})", ident.chars().join(" "))
222 } else {
223 ident.chars().join(" ").into()
224 };
225
226 let range = self.ctx.to_lsp_range(node.range(), &self.source);
227 let edit = self.local_edit(EcoSnippetTextEdit::new(range, new_text))?;
228 let action = CodeAction {
229 title: "Add spaces between letters".to_string(),
230 kind: Some(CodeActionKind::QUICKFIX),
231 edit: Some(edit),
232 ..CodeAction::default()
233 };
234 self.actions.push(action);
235 Some(())
236 }
237
238 pub fn autofix_file_not_found(
240 &mut self,
241 root: &LinkedNode,
242 range: &Range<usize>,
243 ) -> Option<()> {
244 let cursor = (range.start + 1).min(self.source.text().len());
245 let node = root.leaf_at_compat(cursor)?;
246
247 let importing = node.cast::<ast::Str>()?.get();
248 if importing.starts_with('@') {
249 return None;
254 }
255
256 let file_id = node.span().id()?;
257 let root_path = self.ctx.path_for_id(file_id.join("/")).ok()?;
258 let path_in_workspace = file_id.vpath().join(importing.as_str());
259 let new_path = path_in_workspace.resolve(root_path.as_path())?;
260 let new_file_url = path_to_url(&new_path).ok()?;
261
262 let edit = self.create_file(new_file_url, false);
263
264 let file_to_create = unix_slash(path_in_workspace.as_rooted_path());
265 let action = CodeAction {
266 title: format!("Create missing file at `{file_to_create}`"),
267 kind: Some(CodeActionKind::QUICKFIX),
268 edit: Some(edit),
269 ..CodeAction::default()
270 };
271 self.actions.push(action);
272
273 Some(())
274 }
275
276 pub fn scoped(&mut self, root: &LinkedNode, range: &Range<usize>) -> Option<()> {
278 let cursor = (range.start + 1).min(self.source.text().len());
279 let node = root.leaf_at_compat(cursor)?;
280 let mut node = &node;
281
282 let mut heading_resolved = false;
283 let mut equation_resolved = false;
284 let mut path_resolved = false;
285
286 self.wrap_actions(node, range);
287
288 loop {
289 match node.kind() {
290 SyntaxKind::Heading if !heading_resolved => {
292 heading_resolved = true;
293 self.heading_actions(node);
294 }
295 SyntaxKind::Equation if !equation_resolved => {
297 equation_resolved = true;
298 self.equation_actions(node);
299 }
300 SyntaxKind::Str if !path_resolved => {
301 path_resolved = true;
302 self.path_actions(node, cursor);
303 }
304 _ => {}
305 }
306
307 node = node.parent()?;
308 }
309 }
310
311 fn path_actions(&mut self, node: &LinkedNode, cursor: usize) -> Option<()> {
312 if let Some(SyntaxClass::IncludePath(path_node) | SyntaxClass::ImportPath(path_node)) =
314 classify_syntax(node.clone(), cursor)
315 {
316 let str_node = adjust_expr(path_node)?;
317 let str_ast = str_node.cast::<ast::Str>()?;
318 return self.path_rewrite(self.source.id(), &str_ast.get(), &str_node);
319 }
320
321 let link_parent = node_ancestors(node)
322 .find(|node| matches!(node.kind(), SyntaxKind::FuncCall))
323 .unwrap_or(node);
324
325 let link_info = get_link_exprs_in(link_parent);
327 let objects = link_info.objects.into_iter();
328 let object_under_node = objects.filter(|link| link.range.contains(&cursor));
329
330 let mut resolved = false;
331 for link in object_under_node {
332 if let LinkTarget::Path(id, path) = link.target {
333 resolved = self.path_rewrite(id, &path, node).is_some() || resolved;
335 }
336 }
337
338 resolved.then_some(())
339 }
340
341 fn path_rewrite(&mut self, id: TypstFileId, path: &str, node: &LinkedNode) -> Option<()> {
343 if !matches!(node.kind(), SyntaxKind::Str) {
344 log::warn!("bad path node kind on code action: {:?}", node.kind());
345 return None;
346 }
347
348 let path = Path::new(path);
349
350 if path.starts_with("/") {
351 let cur_path = id.vpath().as_rooted_path().parent().unwrap();
353 let new_path = diff(path, cur_path)?;
354 let edit = self.edit_str(node, unix_slash(&new_path))?;
355 let action = CodeAction {
356 title: "Convert to relative path".to_string(),
357 kind: Some(CodeActionKind::REFACTOR_REWRITE),
358 edit: Some(edit),
359 ..CodeAction::default()
360 };
361 self.actions.push(action);
362 } else {
363 let mut new_path = id.vpath().as_rooted_path().parent().unwrap().to_path_buf();
365 for i in path.components() {
366 match i {
367 std::path::Component::ParentDir => {
368 new_path.pop().then_some(())?;
369 }
370 std::path::Component::Normal(name) => {
371 new_path.push(name);
372 }
373 _ => {}
374 }
375 }
376 let edit = self.edit_str(node, unix_slash(&new_path))?;
377 let action = CodeAction {
378 title: "Convert to absolute path".to_string(),
379 kind: Some(CodeActionKind::REFACTOR_REWRITE),
380 edit: Some(edit),
381 ..CodeAction::default()
382 };
383 self.actions.push(action);
384 }
385
386 Some(())
387 }
388
389 fn edit_str(&mut self, node: &LinkedNode, new_content: String) -> Option<EcoWorkspaceEdit> {
390 if !matches!(node.kind(), SyntaxKind::Str) {
391 log::warn!("edit_str only works on string AST nodes: {:?}", node.kind());
392 return None;
393 }
394
395 self.local_edit(EcoSnippetTextEdit::new_plain(
396 self.ctx.to_lsp_range(node.range(), &self.source),
397 eco_format!("{new_content:?}"),
399 ))
400 }
401
402 fn wrap_actions(&mut self, node: &LinkedNode, range: &Range<usize>) -> Option<()> {
403 if range.is_empty() {
404 return None;
405 }
406
407 let start_mode = interpret_mode_at(Some(node));
408 if !matches!(start_mode, InterpretMode::Markup | InterpretMode::Math) {
409 return None;
410 }
411
412 let edit = self.local_edits(vec![
413 EcoSnippetTextEdit::new_plain(
414 self.ctx
415 .to_lsp_range(range.start..range.start, &self.source),
416 EcoString::inline("#["),
417 ),
418 EcoSnippetTextEdit::new_plain(
419 self.ctx.to_lsp_range(range.end..range.end, &self.source),
420 EcoString::inline("]"),
421 ),
422 ])?;
423
424 let action = CodeAction {
425 title: "Wrap with content block".to_string(),
426 kind: Some(CodeActionKind::REFACTOR_REWRITE),
427 edit: Some(edit),
428 ..CodeAction::default()
429 };
430 self.actions.push(action);
431
432 Some(())
433 }
434
435 fn heading_actions(&mut self, node: &LinkedNode) -> Option<()> {
436 let heading = node.cast::<ast::Heading>()?;
437 let depth = heading.depth().get();
438
439 let marker = node
441 .children()
442 .find(|child| child.kind() == SyntaxKind::HeadingMarker)?;
443 let marker_range = marker.range();
444
445 if depth > 1 {
446 let action = CodeAction {
448 title: "Decrease depth of heading".to_string(),
449 kind: Some(CodeActionKind::REFACTOR_REWRITE),
450 edit: Some(self.local_edit(EcoSnippetTextEdit::new_plain(
451 self.ctx.to_lsp_range(marker_range.clone(), &self.source),
452 EcoString::inline("=").repeat(depth - 1),
453 ))?),
454 ..CodeAction::default()
455 };
456 self.actions.push(action);
457 }
458
459 let action = CodeAction {
461 title: "Increase depth of heading".to_string(),
462 kind: Some(CodeActionKind::REFACTOR_REWRITE),
463 edit: Some(self.local_edit(EcoSnippetTextEdit::new_plain(
464 self.ctx.to_lsp_range(marker_range, &self.source),
465 EcoString::inline("=").repeat(depth + 1),
466 ))?),
467 ..CodeAction::default()
468 };
469 self.actions.push(action);
470
471 Some(())
472 }
473
474 fn equation_actions(&mut self, node: &LinkedNode) -> Option<()> {
475 let equation = node.cast::<ast::Equation>()?;
476 let body = equation.body();
477 let is_block = equation.block();
478
479 let body = node.find(body.span())?;
480 let body_range = body.range();
481 let node_end = node.range().end;
482
483 let mut chs = node.children();
484 let chs = chs.by_ref();
485 let is_dollar = |node: &LinkedNode| node.kind() == SyntaxKind::Dollar;
486 let first_dollar = chs.take(1).find(is_dollar)?;
487 let last_dollar = chs.rev().take(1).find(is_dollar)?;
488
489 if first_dollar.offset() == last_dollar.offset() {
492 return None;
493 }
494
495 let front_range = self
496 .ctx
497 .to_lsp_range(first_dollar.range().end..body_range.start, &self.source);
498 let back_range = self
499 .ctx
500 .to_lsp_range(body_range.end..last_dollar.range().start, &self.source);
501
502 let mark_after_equation = self
504 .source
505 .text()
506 .get(node_end..)
507 .and_then(|text| {
508 let mut ch = text.chars();
509 let nx = ch.next()?;
510 Some((nx, ch.next()))
511 })
512 .filter(|(ch, ch_next)| {
513 static IS_PUNCTUATION: LazyLock<Regex> =
514 LazyLock::new(|| Regex::new(r"\p{Punctuation}").unwrap());
515 (ch.is_ascii_punctuation()
516 && ch_next.is_none_or(|ch_next| !ch_next.is_ascii_punctuation()))
517 || (!ch.is_ascii_punctuation() && IS_PUNCTUATION.is_match(&ch.to_string()))
518 });
519 let punc_modify = if let Some((nx, _)) = mark_after_equation {
520 let ch_range = self
521 .ctx
522 .to_lsp_range(node_end..node_end + nx.len_utf8(), &self.source);
523 let remove_edit = EcoSnippetTextEdit::new_plain(ch_range, EcoString::new());
524 Some((nx, remove_edit))
525 } else {
526 None
527 };
528
529 let rewrite_action = |title: &str, new_text: &str| {
530 let mut edits = vec![
531 EcoSnippetTextEdit::new_plain(front_range, new_text.into()),
532 EcoSnippetTextEdit::new_plain(
533 back_range,
534 if !new_text.is_empty() {
535 if let Some((ch, _)) = &punc_modify {
536 EcoString::from(*ch) + new_text
537 } else {
538 new_text.into()
539 }
540 } else {
541 EcoString::new()
542 },
543 ),
544 ];
545
546 if !new_text.is_empty()
547 && let Some((_, edit)) = &punc_modify
548 {
549 edits.push(edit.clone());
550 }
551
552 Some(CodeAction {
553 title: title.to_owned(),
554 kind: Some(CodeActionKind::REFACTOR_REWRITE),
555 edit: Some(self.local_edits(edits)?),
556 ..CodeAction::default()
557 })
558 };
559
560 let toggle_action = if is_block {
562 rewrite_action("Convert to inline equation", "")?
563 } else {
564 rewrite_action("Convert to block equation", " ")?
565 };
566 let block_action = rewrite_action("Convert to multiple-line block equation", "\n");
567
568 self.actions.push(toggle_action);
569 if let Some(a2) = block_action {
570 self.actions.push(a2);
571 }
572
573 Some(())
574 }
575
576 fn create_file(&self, uri: Url, needs_confirmation: bool) -> EcoWorkspaceEdit {
577 let change_id = "Typst Create Missing Files".to_string();
578
579 let create_op = EcoDocumentChangeOperation::Op(lsp_types::ResourceOp::Create(CreateFile {
580 uri,
581 options: Some(CreateFileOptions {
582 overwrite: Some(false),
583 ignore_if_exists: None,
584 }),
585 annotation_id: Some(change_id.clone()),
586 }));
587
588 let mut change_annotations = HashMap::new();
589 change_annotations.insert(
590 change_id.clone(),
591 ChangeAnnotation {
592 label: change_id,
593 needs_confirmation: Some(needs_confirmation),
594 description: Some("The file is missing but required by code".to_string()),
595 },
596 );
597
598 EcoWorkspaceEdit {
599 changes: None,
600 document_changes: Some(EcoDocumentChanges::Operations(vec![create_op])),
601 change_annotations: Some(change_annotations),
602 }
603 }
604}
605
606#[derive(Debug, Clone, Copy)]
607enum AutofixKind {
608 UnknownVariable,
609 FileNotFound,
610}
611
612fn match_autofix_kind(msg: &str) -> Option<AutofixKind> {
613 static PATTERNS: &[(&str, AutofixKind)] = &[
614 ("unknown variable", AutofixKind::UnknownVariable), ("file not found", AutofixKind::FileNotFound),
616 ];
617
618 for (pattern, kind) in PATTERNS {
619 if msg.starts_with(pattern) {
620 return Some(*kind);
621 }
622 }
623
624 None
625}