tinymist_query/
on_enter.rs1use typst_shim::syntax::LinkedNodeExt;
4
5use crate::{SyntaxRequest, prelude::*, syntax::node_ancestors};
6
7#[derive(Debug, Clone)]
23pub struct OnEnterRequest {
24 pub path: PathBuf,
26 pub range: LspRange,
28 pub handle_list: bool,
30}
31
32impl SyntaxRequest for OnEnterRequest {
33 type Response = Vec<TextEdit>;
34
35 fn request(
36 self,
37 source: &Source,
38 position_encoding: PositionEncoding,
39 ) -> Option<Self::Response> {
40 let root = LinkedNode::new(source.root());
41 let rng = to_typst_range(self.range, position_encoding, source)?;
42 let cursor = rng.start;
43 let leaf = root.leaf_at_compat(cursor)?;
44
45 let worker = OnEnterWorker {
46 source,
47 position_encoding,
48 };
49
50 enum Cases<'a> {
51 LineComment(LinkedNode<'a>),
52 Equation(LinkedNode<'a>),
53 ListOrEnum(LinkedNode<'a>),
54 }
55
56 let case = node_ancestors(&leaf).find_map(|node| match node.kind() {
57 SyntaxKind::LineComment => Some(Cases::LineComment(node.clone())),
58 SyntaxKind::Equation => Some(Cases::Equation(node.clone())),
59 SyntaxKind::ListItem | SyntaxKind::EnumItem if self.handle_list => {
60 Some(Cases::ListOrEnum(node.clone()))
61 }
62 SyntaxKind::Space | SyntaxKind::Parbreak if self.handle_list => {
63 let prev_leaf = node.prev_sibling()?;
64
65 let inter_space = node.offset()..rng.start;
66 if !inter_space.is_empty() && source.text()[inter_space].contains(['\r', '\n']) {
67 return None;
68 }
69
70 match prev_leaf.kind() {
71 SyntaxKind::ListItem | SyntaxKind::EnumItem => {
72 return Some(Cases::ListOrEnum(prev_leaf));
73 }
74 _ => {}
75 }
76
77 None
78 }
79 _ => None,
80 });
81
82 match case {
83 Some(Cases::LineComment(node)) => worker.enter_line_doc_comment(node, rng),
84 Some(Cases::Equation(node)) => worker.enter_block_math(node, rng),
85 Some(Cases::ListOrEnum(node)) => worker.enter_list_or_enum(node, rng),
86 _ => None,
87 }
88 }
89}
90
91struct OnEnterWorker<'a> {
92 source: &'a Source,
93 position_encoding: PositionEncoding,
94}
95
96impl OnEnterWorker<'_> {
97 fn indent_of(&self, of: usize) -> String {
98 let all_text = self.source.text();
99 let start = all_text[..of].rfind('\n').map(|lf_offset| lf_offset + 1);
100 let indent_size = all_text[start.unwrap_or_default()..of].chars().count();
101 " ".repeat(indent_size)
102 }
103
104 fn enter_line_doc_comment(&self, leaf: LinkedNode, rng: Range<usize>) -> Option<Vec<TextEdit>> {
105 let skipper = |n: &LinkedNode| {
106 matches!(
107 n.kind(),
108 SyntaxKind::Space | SyntaxKind::Linebreak | SyntaxKind::LineComment
109 )
110 };
111 let parent = leaf.parent()?;
112 let till_curr = parent.children().take(leaf.index());
113 let first_index = till_curr.rev().take_while(skipper).count();
114 let comment_group_cnt = parent
115 .children()
116 .skip(leaf.index().saturating_sub(first_index))
117 .take_while(skipper)
118 .filter(|child| matches!(child.kind(), SyntaxKind::LineComment))
119 .count();
120
121 let comment_prefix = {
122 let mut scanner = unscanny::Scanner::new(leaf.text());
123 scanner.eat_while('/');
124 scanner.eat_if('!');
125 scanner.before()
126 };
127
128 if comment_group_cnt <= 1 && comment_prefix == "//" {
130 return None;
131 }
132
133 let indent = self.indent_of(leaf.offset());
134 let edit = TextEdit {
137 range: to_lsp_range(rng, self.source, self.position_encoding),
138 new_text: format!("\n{indent}{comment_prefix} $0"),
139 };
140
141 Some(vec![edit])
142 }
143
144 fn enter_block_math(
145 &self,
146 math_node: LinkedNode<'_>,
147 rng: Range<usize>,
148 ) -> Option<Vec<TextEdit>> {
149 let o = math_node.range();
150 if !o.contains(&rng.end) {
151 return None;
152 }
153
154 let all_text = self.source.text();
155 let math_text = &all_text[o.clone()];
156 let content = math_text.trim_start_matches('$').trim_end_matches('$');
157 if !content.trim().is_empty() {
158 return None;
159 }
160
161 let indent = self.indent_of(o.start);
162 let edit = TextEdit {
163 range: to_lsp_range(rng, self.source, self.position_encoding),
164 new_text: if !content.contains('\n') {
166 format!("\n{indent} $0\n{indent}")
167 } else {
168 format!("\n{indent} $0")
169 },
170 };
171
172 Some(vec![edit])
173 }
174
175 fn enter_list_or_enum(&self, node: LinkedNode<'_>, rng: Range<usize>) -> Option<Vec<TextEdit>> {
176 let rng_end = rng.end;
177 let node_end = node.range().end;
178 let in_middle_of_node = rng_end < node_end
179 && self.source.text()[rng_end..node_end].contains(|c: char| !c.is_whitespace());
180
181 if in_middle_of_node {
182 return None;
183 }
184
185 let indent = self.indent_of(node.range().start);
186
187 let is_list = matches!(node.kind(), SyntaxKind::ListItem);
188 let marker = if is_list { "-" } else { "+" };
189
190 let edit = TextEdit {
191 range: to_lsp_range(rng, self.source, self.position_encoding),
192 new_text: format!("\n{indent}{marker} $0"),
193 };
194
195 Some(vec![edit])
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::tests::*;
203
204 #[test]
205 fn prepare() {
206 snapshot_testing("on_enter", &|world, path| {
207 let source = world.source_by_path(&path).unwrap();
208
209 let request = OnEnterRequest {
210 path: path.clone(),
211 range: find_test_range(&source),
212 handle_list: true,
213 };
214
215 let result = request.request(&source, PositionEncoding::Utf16);
216
217 let annotated = {
218 let range = find_test_range_(&source);
219 let range_before = range.start.saturating_sub(10)..range.start;
220 let range_window = range.clone();
221 let range_after = range.end..range.end.saturating_add(10).min(source.text().len());
222
223 let window_before = &source.text()[range_before];
224 let window_line = &source.text()[range_window];
225 let window_after = &source.text()[range_after];
226 format!("{window_before}|{window_line}|{window_after}")
227 };
228
229 with_settings!({
230 description => format!("On Enter on {annotated})"),
231 }, {
232 assert_snapshot!(JsonRepr::new_redacted(result, &REDACT_LOC));
233 })
234 });
235 }
236}