tinymist_query/
on_enter.rs

1//! <https://github.com/rust-lang/rust-analyzer/blob/master/docs/dev/lsp-extensions.md#on-enter>
2
3use typst_shim::syntax::LinkedNodeExt;
4
5use crate::{SyntaxRequest, prelude::*, syntax::node_ancestors};
6
7/// The [`experimental/onEnter`] request is sent from client to server to handle
8/// the <kbd>Enter</kbd> key press.
9///
10/// - `kbd:Enter` inside triple-slash comments automatically inserts `///`
11/// - `kbd:Enter` in the middle or after a trailing space in `//` inserts `//`
12/// - `kbd:Enter` inside `//!` doc comments automatically inserts `//!`
13/// - `kbd:Enter` inside block math automatically inserts a newline and indents
14/// - `kbd:Enter` inside `list` or `enum` items automatically automatically
15///   inserts `-` or `+` and indents
16///
17/// [`experimental/onEnter`]: https://github.com/rust-lang/rust-analyzer/blob/master/docs/dev/lsp-extensions.md#on-enter
18///
19/// # Compatibility
20///
21/// This request was introduced in specification version 3.10.0.
22#[derive(Debug, Clone)]
23pub struct OnEnterRequest {
24    /// The path of the document to get folding ranges for.
25    pub path: PathBuf,
26    /// The source code range to request for.
27    pub range: LspRange,
28    /// Whether to handle list and enum items.
29    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        // Continuing single-line non-doc comments (like this one :) ) is annoying
129        if comment_group_cnt <= 1 && comment_prefix == "//" {
130            return None;
131        }
132
133        let indent = self.indent_of(leaf.offset());
134        // todo: remove_trailing_whitespace
135
136        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            // todo: read indent configuration
165            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}