From 32b32c13f985885b4ea98b9e59805642a437215f Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Sun, 26 Oct 2025 19:56:23 +0800 Subject: [PATCH] feat: add extract section functionality with subsections support --- .../src/analysis/code_action.rs | 309 +++++++++++++++++- .../fixtures/code_action/extract_section.typ | 19 ++ .../snaps/test@extract_section.typ.snap | 114 +++++++ 3 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 crates/tinymist-query/src/fixtures/code_action/extract_section.typ create mode 100644 crates/tinymist-query/src/fixtures/code_action/snaps/test@extract_section.typ.snap diff --git a/crates/tinymist-query/src/analysis/code_action.rs b/crates/tinymist-query/src/analysis/code_action.rs index 93ba7b16..a5329cb0 100644 --- a/crates/tinymist-query/src/analysis/code_action.rs +++ b/crates/tinymist-query/src/analysis/code_action.rs @@ -1,7 +1,10 @@ //! Provides code actions for the document. use ecow::eco_format; -use lsp_types::{ChangeAnnotation, CreateFile, CreateFileOptions}; +use lsp_types::{ + ChangeAnnotation, CreateFile, CreateFileOptions, OneOf, + OptionalVersionedTextDocumentIdentifier, ResourceOp, +}; use regex::Regex; use tinymist_analysis::syntax::{ PreviousItem, SyntaxClass, adjust_expr, node_ancestors, previous_items, @@ -11,6 +14,7 @@ use typst::syntax::Side; use super::get_link_exprs_in; use crate::analysis::LinkTarget; +use crate::code_action::proto::{EcoSnippetTextEdit, EcoTextDocumentEdit}; use crate::prelude::*; use crate::syntax::{InterpretMode, interpret_mode_at}; @@ -471,9 +475,312 @@ impl<'a> CodeActionWorker<'a> { }; self.actions.push(action); + // Extract section to new file + self.extract_section_action(node); + Some(()) } + fn extract_section_action(&mut self, node: &LinkedNode) -> Option<()> { + let heading_text = self.get_heading_text(node)?; + let section_name = self.sanitize_filename(&heading_text); + + let current_id = self.source.id(); + let current_path = self.ctx.path_for_id(current_id).ok()?; + let parent_dir = current_path.as_path().parent()?; + + let has_subsections = self.has_subsections(node)?; + let mut edits = vec![]; + let change_id = "Typst Extract Section".to_string(); + + if has_subsections { + self.extract_section_with_subsections( + node, + §ion_name, + parent_dir, + &change_id, + &mut edits, + )?; + } else { + self.extract_section_itself(node, §ion_name, parent_dir, &change_id, &mut edits)?; + } + + self.actions.push(CodeAction { + title: if has_subsections { + "Extract section with subsections to directory".to_string() + } else { + "Extract section to new file".to_string() + }, + kind: Some(CodeActionKind::REFACTOR_EXTRACT), + edit: Some(EcoWorkspaceEdit { + changes: None, + document_changes: Some(EcoDocumentChanges::Operations(edits)), + change_annotations: Some(HashMap::from_iter([( + change_id.clone(), + ChangeAnnotation { + label: change_id, + needs_confirmation: Some(true), + description: Some("Extract section to new file(s)".to_string()), + }, + )])), + }), + ..CodeAction::default() + }); + + Some(()) + } + + fn has_subsections(&self, heading_node: &LinkedNode) -> Option { + let current_depth = heading_node.cast::()?.depth().get(); + let mut current = heading_node.clone(); + + while let Some(next) = current.next_sibling() { + if let Some(next_heading) = next.cast::() { + let next_depth = next_heading.depth().get(); + if next_depth == current_depth + 1 { + return Some(true); + } + if next_depth <= current_depth { + break; + } + } + current = next; + } + Some(false) + } + + fn extract_section_itself( + &self, + node: &LinkedNode, + section_name: &str, + parent_dir: &Path, + change_id: &str, + edits: &mut Vec, + ) -> Option<()> { + let section_range = self.find_section_range(node)?; + let section_text = self.source.text().get(section_range.clone())?; + let new_filename = format!("{}.typ", section_name); + let new_file_url = path_to_url(&parent_dir.join(&new_filename)).ok()?; + + self.create_file_with_content(&new_file_url, section_text, change_id, edits); + self.replace_with_include( + &self.ctx.to_lsp_range(section_range, &self.source), + &new_filename, + edits, + )?; + + Some(()) + } + + fn extract_section_with_subsections( + &self, + node: &LinkedNode, + section_name: &str, + parent_dir: &Path, + change_id: &str, + edits: &mut Vec, + ) -> Option<()> { + let current_depth = node.cast::()?.depth().get(); + let section_start = node.offset(); + let section_dir = parent_dir.join(section_name); + + let subsections = self.collect_subsections(node, current_depth)?; + + let mut main_content = String::new(); + let mut last_end = section_start; + + if let Some(first) = subsections.first() { + if let Some(text) = self.source.text().get(section_start..first.offset()) { + main_content.push_str(text); + } + last_end = first.offset(); + } + + for subsection in &subsections { + let subsection_name = self.get_heading_text(subsection)?; + let subsection_file = format!("{}.typ", self.sanitize_filename(&subsection_name)); + let subsection_range = self.find_subsection_range(subsection, current_depth + 1)?; + let subsection_content = self.source.text().get(subsection_range.clone())?; + + if subsection.offset() > last_end { + if let Some(text) = self.source.text().get(last_end..subsection.offset()) { + main_content.push_str(text); + } + } + + main_content.push_str(&format!("#include(\"{}\")\n", subsection_file)); + + // Create subsection file + let subsection_url = path_to_url(§ion_dir.join(&subsection_file)).ok()?; + self.create_file_with_content(&subsection_url, subsection_content, change_id, edits); + + last_end = subsection_range.end; + } + + let main_url = path_to_url(§ion_dir.join("index.typ")).ok()?; + self.create_file_with_content(&main_url, &main_content, change_id, edits); + + let section_range = self.find_section_range(node)?; + let include_path = format!("{}/index.typ", section_name); + self.replace_with_include( + &self.ctx.to_lsp_range(section_range, &self.source), + &include_path, + edits, + )?; + + Some(()) + } + + fn find_subsection_range( + &self, + subsection_node: &LinkedNode, + parent_depth: usize, + ) -> Option> { + let start = subsection_node.offset(); + let mut current = subsection_node.clone(); + let mut end = self.source.text().len(); + + while let Some(next) = current.next_sibling() { + if let Some(next_heading) = next.cast::() { + let next_depth = next_heading.depth().get(); + if next_depth as usize <= parent_depth { + end = next.offset(); + break; + } + } + current = next; + } + + Some(start..end) + } + + fn create_file_with_content( + &self, + url: &Url, + content: &str, + change_id: &str, + edits: &mut Vec, + ) { + edits.push(EcoDocumentChangeOperation::Op(ResourceOp::Create( + CreateFile { + uri: url.clone(), + options: Some(CreateFileOptions { + overwrite: Some(false), + ignore_if_exists: None, + }), + annotation_id: Some(change_id.to_string()), + }, + ))); + + edits.push(EcoDocumentChangeOperation::Edit(EcoTextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { + uri: url.clone(), + version: None, + }, + edits: vec![OneOf::Left(EcoSnippetTextEdit::new_plain( + LspRange::new(LspPosition::new(0, 0), LspPosition::new(0, 0)), + content.into(), + ))], + })); + } + + fn replace_with_include( + &self, + range: &LspRange, + path: &str, + edits: &mut Vec, + ) -> Option<()> { + edits.push(EcoDocumentChangeOperation::Edit(EcoTextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { + uri: self.local_url()?.clone(), + version: None, + }, + edits: vec![OneOf::Left(EcoSnippetTextEdit::new_plain( + *range, + format!("#include(\"{}\")\n", path).into(), + ))], + })); + Some(()) + } + + fn collect_subsections<'b>( + &self, + node: &'b LinkedNode, + parent_depth: usize, + ) -> Option>> { + let mut subsections = vec![]; + let mut current = node.clone(); + + while let Some(next) = current.next_sibling() { + if let Some(next_heading) = next.cast::() { + let next_depth = next_heading.depth().get(); + if next_depth == parent_depth + 1 { + subsections.push(next.clone()); + } else if next_depth <= parent_depth { + break; + } + } + current = next; + } + Some(subsections) + } + + fn get_heading_text(&self, heading_node: &LinkedNode) -> Option { + let body_node = heading_node + .children() + .find(|child| child.cast::().is_some())?; + + Some(body_node.get().clone().into_text()) + } + + fn find_section_range(&self, heading_node: &LinkedNode) -> Option> { + let heading = heading_node.cast::()?; + let current_depth = heading.depth().get(); + let start = heading_node.offset(); + + let mut current = heading_node.clone(); + let mut end = self.source.text().len(); + + while let Some(next) = current.next_sibling() { + if let Some(next_heading) = next.cast::() { + let next_depth = next_heading.depth().get(); + if next_depth <= current_depth { + end = next.offset(); + break; + } + } + current = next; + } + + Some(start..end) + } + + fn sanitize_filename(&self, text: &str) -> String { + let sanitized = text + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c.to_ascii_lowercase() + } else if c.is_whitespace() { + '-' + } else { + '_' + } + }) + .collect::() + .trim_matches('-') + .trim_matches('_') + .chars() + .take(50) + .collect::(); + + if sanitized.is_empty() { + "extracted-section".to_string() + } else { + sanitized + } + } + fn equation_actions(&mut self, node: &LinkedNode) -> Option<()> { let equation = node.cast::()?; let body = equation.body(); diff --git a/crates/tinymist-query/src/fixtures/code_action/extract_section.typ b/crates/tinymist-query/src/fixtures/code_action/extract_section.typ new file mode 100644 index 00000000..ca360b4a --- /dev/null +++ b/crates/tinymist-query/src/fixtures/code_action/extract_section.typ @@ -0,0 +1,19 @@ += Main Document + +Some introduction text. + += My/* range 0..0 */ Great Section + +This is the content of the section that should be extracted. + +== Subsection One + +Content of subsection one. + +== Subsection Two + +Content of subsection two. + += Another Section + +This should not be included in the extracted file. diff --git a/crates/tinymist-query/src/fixtures/code_action/snaps/test@extract_section.typ.snap b/crates/tinymist-query/src/fixtures/code_action/snaps/test@extract_section.typ.snap new file mode 100644 index 00000000..ce0d9d72 --- /dev/null +++ b/crates/tinymist-query/src/fixtures/code_action/snaps/test@extract_section.typ.snap @@ -0,0 +1,114 @@ +--- +source: crates/tinymist-query/src/code_action.rs +description: "Code Action on ext.\n\n= My||/* range 0" +expression: "JsonRepr::new_redacted(result, &REDACT_LOC)" +input_file: crates/tinymist-query/src/fixtures/code_action/extract_section_complex.typ +--- +[ + { + "edit": { + "changes": { + "s0.typ": [ + { + "insertTextFormat": 1, + "newText": "==", + "range": "4:0:4:1" + } + ] + } + }, + "kind": "refactor.rewrite", + "title": "Increase depth of heading" + }, + { + "edit": { + "changeAnnotations": { + "Typst Extract Section": { + "description": "Extract section to new file(s)", + "label": "Typst Extract Section", + "needsConfirmation": true + } + }, + "documentChanges": [ + { + "annotationId": "Typst Extract Section", + "kind": "create", + "options": { + "overwrite": false + }, + "uri": "my__-range-0__0-__-great-section/subsection-one.typ" + }, + { + "edits": [ + { + "insertTextFormat": 1, + "newText": "== Subsection One\n\nContent of subsection one.\n\n", + "range": "0:0:0:0" + } + ], + "textDocument": { + "uri": "my__-range-0__0-__-great-section/subsection-one.typ", + "version": null + } + }, + { + "annotationId": "Typst Extract Section", + "kind": "create", + "options": { + "overwrite": false + }, + "uri": "my__-range-0__0-__-great-section/subsection-two.typ" + }, + { + "edits": [ + { + "insertTextFormat": 1, + "newText": "== Subsection Two\n\nContent of subsection two.\n\n", + "range": "0:0:0:0" + } + ], + "textDocument": { + "uri": "my__-range-0__0-__-great-section/subsection-two.typ", + "version": null + } + }, + { + "annotationId": "Typst Extract Section", + "kind": "create", + "options": { + "overwrite": false + }, + "uri": "my__-range-0__0-__-great-section/index.typ" + }, + { + "edits": [ + { + "insertTextFormat": 1, + "newText": "= My/* range 0..0 */ Great Section\n\nThis is the content of the section that should be extracted.\n\n#include(\"subsection-one.typ\")\n#include(\"subsection-two.typ\")\n", + "range": "0:0:0:0" + } + ], + "textDocument": { + "uri": "my__-range-0__0-__-great-section/index.typ", + "version": null + } + }, + { + "edits": [ + { + "insertTextFormat": 1, + "newText": "#include(\"my__-range-0__0-__-great-section/index.typ\")\n", + "range": "4:0:16:0" + } + ], + "textDocument": { + "uri": "s0.typ", + "version": null + } + } + ] + }, + "kind": "refactor.extract", + "title": "Extract section with subsections to directory" + } +]