tinymist_query/
inlay_hint.rs

1use lsp_types::{InlayHintKind, InlayHintLabel};
2use typst::syntax::{LinkedNode, SyntaxKind, ast};
3
4use crate::{
5    analysis::{ParamKind, analyze_call, call_parts},
6    package::{find_package_and_latest, parse_package_import},
7    prelude::*,
8};
9
10/// Configuration for inlay hints.
11pub struct InlayHintConfig {
12    // positional arguments group
13    /// Show inlay hints for positional arguments.
14    pub on_pos_args: bool,
15    /// Disable inlay hints for single positional arguments.
16    pub off_single_pos_arg: bool,
17
18    // variadic arguments group
19    /// Show inlay hints for variadic arguments.
20    pub on_variadic_args: bool,
21    /// Disable inlay hints for all variadic arguments but the first variadic
22    /// argument.
23    pub only_first_variadic_args: bool,
24
25    // The typst sugar grammar
26    /// Show inlay hints for content block arguments.
27    pub on_content_block_args: bool,
28
29    // package version status
30    /// Show package version status decorations.
31    pub on_package_version_status: bool,
32}
33
34impl InlayHintConfig {
35    /// A smart configuration that enables most useful inlay hints.
36    pub const fn smart() -> Self {
37        Self {
38            on_pos_args: true,
39            off_single_pos_arg: true,
40
41            on_variadic_args: true,
42            only_first_variadic_args: true,
43
44            on_content_block_args: false,
45
46            on_package_version_status: true,
47        }
48    }
49}
50
51/// The [`textDocument/inlayHint`] request is sent from the client to the server
52/// to compute inlay hints for a given `(text document, range)` tuple that may
53/// be rendered in the editor in place with other text.
54///
55/// [`textDocument/inlayHint`]: https://microsoft.github.io/language-server-protocol/specification#textDocument_inlayHint
56///
57/// # Compatibility
58///
59/// This request was introduced in specification version 3.17.0
60#[derive(Debug, Clone)]
61pub struct InlayHintRequest {
62    /// The path of the document to get inlay hints for.
63    pub path: PathBuf,
64    /// The range of the document to get inlay hints for.
65    pub range: LspRange,
66}
67
68impl SemanticRequest for InlayHintRequest {
69    type Response = Vec<InlayHint>;
70
71    fn request(self, ctx: &mut LocalContext) -> Option<Self::Response> {
72        let source = ctx.source_by_path(&self.path).ok()?;
73        let range = ctx.to_typst_range(self.range, &source)?;
74
75        let root = LinkedNode::new(source.root());
76        let mut worker = InlayHintWorker {
77            ctx,
78            source: &source,
79            range,
80            hints: vec![],
81        };
82        worker.work(root);
83
84        (!worker.hints.is_empty()).then_some(worker.hints)
85    }
86}
87
88const SMART: InlayHintConfig = InlayHintConfig::smart();
89
90struct InlayHintWorker<'a> {
91    ctx: &'a mut LocalContext,
92    source: &'a Source,
93    range: Range<usize>,
94    hints: Vec<InlayHint>,
95}
96
97impl InlayHintWorker<'_> {
98    fn work(&mut self, node: LinkedNode) {
99        let rng = node.range();
100        if rng.start >= self.range.end || rng.end <= self.range.start {
101            return;
102        }
103
104        self.analyze_node(&node);
105
106        if node.get().children().len() == 0 {
107            return;
108        }
109
110        // todo: survey bad performance children?
111        for child in node.children() {
112            self.work(child);
113        }
114    }
115
116    fn analyze_node(&mut self, node: &LinkedNode) -> Option<()> {
117        // analyze node self
118        match node.kind() {
119            // Type inlay hints
120            SyntaxKind::LetBinding => {
121                log::trace!("let binding found: {node:?}");
122            }
123            // Assignment inlay hints
124            SyntaxKind::Eq => {
125                log::trace!("assignment found: {node:?}");
126            }
127            SyntaxKind::DestructAssignment => {
128                log::trace!("destruct assignment found: {node:?}");
129            }
130            // Package import version status
131            SyntaxKind::Str if SMART.on_package_version_status => {
132                self.check_package_import(node);
133            }
134            // Parameter inlay hints
135            SyntaxKind::FuncCall | SyntaxKind::MathCall => {
136                log::trace!("func call found: {node:?}");
137                let call_info = analyze_call(self.ctx, self.source.clone(), node.clone())?;
138                crate::log_debug_ct!("got call_info {call_info:?}");
139
140                let (_callee_node, args_node) = call_parts(node)?;
141
142                let check_single_pos_arg = || {
143                    let mut pos = 0;
144                    let mut has_rest = false;
145                    let mut content_pos = 0;
146
147                    for arg_node in args_node.children() {
148                        if arg_node.cast::<ast::Arg>().is_none() {
149                            continue;
150                        }
151
152                        let Some(info) = call_info.arg_mapping.get(&arg_node) else {
153                            continue;
154                        };
155
156                        if info.kind != ParamKind::Named {
157                            if info.kind == ParamKind::Rest {
158                                has_rest = true;
159                                continue;
160                            }
161                            if info.is_content_block {
162                                content_pos += 1;
163                            } else {
164                                pos += 1;
165                            };
166
167                            if pos > 1 && content_pos > 1 {
168                                break;
169                            }
170                        }
171                    }
172
173                    (pos <= if has_rest { 0 } else { 1 }, content_pos <= 1)
174                };
175
176                let (disable_by_single_pos_arg, disable_by_single_content_pos_arg) =
177                    if SMART.on_pos_args && SMART.off_single_pos_arg {
178                        check_single_pos_arg()
179                    } else {
180                        (false, false)
181                    };
182
183                let disable_by_single_line_content_block = !SMART.on_content_block_args
184                    || 'one_line: {
185                        for arg_node in args_node.children() {
186                            if arg_node.cast::<ast::Arg>().is_none() {
187                                continue;
188                            }
189
190                            let Some(info) = call_info.arg_mapping.get(&arg_node) else {
191                                continue;
192                            };
193
194                            if info.kind != ParamKind::Named
195                                && info.is_content_block
196                                && !is_one_line(self.source, &arg_node)
197                            {
198                                break 'one_line false;
199                            }
200                        }
201
202                        true
203                    };
204
205                let mut is_first_variadic_arg = true;
206
207                for arg_node in args_node.children() {
208                    if arg_node.cast::<ast::Arg>().is_none() {
209                        continue;
210                    }
211
212                    let Some(info) = call_info.arg_mapping.get(&arg_node) else {
213                        continue;
214                    };
215
216                    let name = &info.param_name;
217                    if name.is_empty() {
218                        continue;
219                    }
220
221                    match info.kind {
222                        ParamKind::Named => {
223                            continue;
224                        }
225                        ParamKind::Positional
226                            if call_info.signature.primary().has_fill_or_size_or_stroke =>
227                        {
228                            continue;
229                        }
230                        ParamKind::Positional
231                            if !SMART.on_pos_args
232                                || (info.is_content_block
233                                    && (disable_by_single_content_pos_arg
234                                        || disable_by_single_line_content_block))
235                                || (!info.is_content_block && disable_by_single_pos_arg) =>
236                        {
237                            continue;
238                        }
239                        ParamKind::Rest
240                            if (!SMART.on_variadic_args
241                                || disable_by_single_pos_arg
242                                || (!is_first_variadic_arg && SMART.only_first_variadic_args)) =>
243                        {
244                            is_first_variadic_arg = false;
245                            continue;
246                        }
247                        ParamKind::Rest => {
248                            is_first_variadic_arg = false;
249                        }
250                        ParamKind::Positional => {}
251                    }
252
253                    let pos = arg_node.range().start;
254                    let lsp_pos = self.ctx.to_lsp_pos(pos, self.source);
255
256                    let label = InlayHintLabel::String(if info.kind == ParamKind::Rest {
257                        format!("..{name}:")
258                    } else {
259                        format!("{name}:")
260                    });
261
262                    self.hints.push(InlayHint {
263                        position: lsp_pos,
264                        label,
265                        kind: Some(InlayHintKind::PARAMETER),
266                        text_edits: None,
267                        tooltip: None,
268                        padding_left: None,
269                        padding_right: Some(true),
270                        data: None,
271                    });
272                }
273
274                // todo: union signatures
275            }
276            SyntaxKind::Set => {
277                log::trace!("set rule found: {node:?}");
278            }
279            _ => {}
280        }
281
282        None
283    }
284
285    fn check_package_import(&mut self, node: &LinkedNode) -> Option<()> {
286        let package_spec = parse_package_import(node)?;
287
288        let (current_entry, latest) = find_package_and_latest(self.ctx.shared(), &package_spec);
289
290        let (label, tooltip) = if current_entry.is_none() {
291            // Version not found - invalid
292            let version_str = package_spec.version.to_string();
293            (
294                tinymist_l10n::t!("inlay-hint.package.version-not-found", "version not found"),
295                Some(tinymist_l10n::t!(
296                    "inlay-hint.package.version-not-found-tooltip",
297                    "Version {version} not found",
298                    version = version_str.as_str().into()
299                )),
300            )
301        } else if let Some(latest) = latest
302            && let latest_version = latest.package.version
303            && latest_version != package_spec.version
304        {
305            // Upgradable - newer version available
306            let latest_str = latest_version.to_string();
307            (
308                tinymist_l10n::t!(
309                    "inlay-hint.package.upgradable",
310                    "→ {version} available",
311                    version = latest_str.as_str().into()
312                ),
313                Some(tinymist_l10n::t!(
314                    "inlay-hint.package.upgradable-tooltip",
315                    "Newer version available: {version}",
316                    version = latest_str.as_str().into()
317                )),
318            )
319        } else {
320            // Up to date - latest version
321            (
322                tinymist_l10n::t!("inlay-hint.package.up-to-date", "√ latest"),
323                Some(tinymist_l10n::t!(
324                    "inlay-hint.package.up-to-date-tooltip",
325                    "Up to date (latest version)"
326                )),
327            )
328        };
329
330        // Position for the hint - at the end of the string node
331        let pos = node.range().end;
332        let lsp_pos = self.ctx.to_lsp_pos(pos, self.source);
333
334        self.hints.push(InlayHint {
335            position: lsp_pos,
336            label: InlayHintLabel::String(label.to_string()),
337            kind: Some(InlayHintKind::TYPE),
338            text_edits: None,
339            tooltip: tooltip.map(|t| lsp_types::InlayHintTooltip::String(t.to_string())),
340            padding_left: Some(true),
341            padding_right: None,
342            data: None,
343        });
344
345        Some(())
346    }
347}
348
349fn is_one_line(src: &Source, arg_node: &LinkedNode<'_>) -> bool {
350    is_one_line_(src, arg_node).unwrap_or(true)
351}
352
353fn is_one_line_(src: &Source, arg_node: &LinkedNode<'_>) -> Option<bool> {
354    let lb = arg_node.children().next()?;
355    let rb = arg_node.children().next_back()?;
356    let ll = src.lines().byte_to_line(lb.offset())?;
357    let rl = src.lines().byte_to_line(rb.offset())?;
358    Some(ll == rl)
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::tests::*;
365
366    #[test]
367    fn smart() {
368        snapshot_testing("inlay_hints", &|ctx, path| {
369            let source = ctx.source_by_path(&path).unwrap();
370
371            let request = InlayHintRequest {
372                path: path.clone(),
373                range: to_lsp_range(0..source.text().len(), &source, PositionEncoding::Utf16),
374            };
375
376            let result = request.request(ctx);
377            assert_snapshot!(JsonRepr::new_redacted(result, &REDACT_LOC));
378        });
379    }
380}