tinymist-query 0.14.18

Language queries for tinymist.
use crate::analysis::{CompletionCursor, CompletionWorker};
use crate::prelude::*;

pub(crate) mod proto;
pub use proto::*;
pub(crate) mod snippet;
pub use snippet::*;

/// The [`textDocument/completion`] request is sent from the client to the
/// server to compute completion items at a given cursor position.
///
/// If computing full completion items is expensive, servers can additionally
/// provide a handler for the completion item resolve request
/// (`completionItem/resolve`). This request is sent when a completion item is
/// selected in the user interface.
///
/// [`textDocument/completion`]: https://microsoft.github.io/language-server-protocol/specification#textDocument_completion
///
/// # Compatibility
///
/// Since 3.16.0, the client can signal that it can resolve more properties
/// lazily. This is done using the `completion_item.resolve_support` client
/// capability which lists all properties that can be filled in during a
/// `completionItem/resolve` request.
///
/// All other properties (usually `sort_text`, `filter_text`, `insert_text`, and
/// `text_edit`) must be provided in the `textDocument/completion` response and
/// must not be changed during resolve.
#[derive(Debug, Clone)]
pub struct CompletionRequest {
    /// The path of the document to compute completions.
    pub path: PathBuf,
    /// The position in the document at which to compute completions.
    pub position: LspPosition,
    /// Whether the completion is triggered explicitly.
    pub explicit: bool,
    /// The character that triggered the completion, if any.
    pub trigger_character: Option<char>,
}

impl SemanticRequest for CompletionRequest {
    type Response = CompletionList;

    fn request(self, ctx: &mut LocalContext) -> Option<Self::Response> {
        // These trigger characters are for completion on positional arguments,
        // which follows the configuration item
        // `tinymist.completion.triggerOnSnippetPlaceholders`.
        if matches!(self.trigger_character, Some('(' | ',' | ':'))
            && !ctx.analysis.completion_feat.trigger_on_snippet_placeholders
        {
            return None;
        }

        let document = ctx.success_doc().cloned();
        let source = ctx.source_by_path(&self.path).ok()?;
        let cursor = ctx.to_typst_pos_offset(&source, self.position, 0)?;

        // Please see <https://github.com/nvarner/typst-lsp/commit/2d66f26fb96ceb8e485f492e5b81e9db25c3e8ec>
        //
        // FIXME: correctly identify a completion which is triggered
        // by explicit action, such as by pressing control and space
        // or something similar.
        //
        // See <https://github.com/microsoft/language-server-protocol/issues/1101>
        // > As of LSP 3.16, CompletionTriggerKind takes the value Invoked for
        // > both manually invoked (for ex: ctrl + space in VSCode) completions
        // > and always on (what the spec refers to as 24/7 completions).
        //
        // Hence, we cannot distinguish between the two cases. Conservatively, we
        // assume that the completion is not explicit.
        //
        // Second try: According to VSCode:
        // - <https://github.com/microsoft/vscode/issues/130953>
        // - <https://github.com/microsoft/vscode/commit/0984071fe0d8a3c157a1ba810c244752d69e5689>
        // Checks the previous text to filter out letter explicit completions.
        //
        // Second try is failed.
        let explicit = false;
        let mut cursor = CompletionCursor::new(ctx.shared_(), &source, cursor)?;

        let mut worker =
            CompletionWorker::new(ctx, document.as_ref(), explicit, self.trigger_character)?;
        worker.work(&mut cursor)?;

        // todo: define it well, we were needing it because we wanted to do interactive
        // path completion, but now we've scanned all the paths at the same time.
        // is_incomplete = ic;
        let _ = worker.incomplete;

        // To response completions in fine-grained manner, we need to mark result as
        // incomplete. This follows what rust-analyzer does.
        // https://github.com/rust-lang/rust-analyzer/blob/f5a9250147f6569d8d89334dc9cca79c0322729f/crates/rust-analyzer/src/handlers/request.rs#L940C55-L940C75
        Some(CompletionList {
            is_incomplete: false,
            items: worker.completions,
        })
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashSet;
    use std::path::Path;

    use super::*;
    use crate::{completion::proto::CompletionItem, syntax::find_module_level_docs, tests::*};

    struct TestConfig {
        pkg_mode: bool,
    }

    fn run(config: TestConfig) -> impl Fn(&mut LocalContext, PathBuf) {
        fn test(ctx: &mut LocalContext, id: TypstFileId) {
            let source = ctx.source_by_id(id).unwrap();
            let rng = find_test_range_(&source);
            let text = source.text()[rng.clone()].to_string();

            let docs = find_module_level_docs(&source).unwrap_or_default();
            let properties = get_test_properties(&docs);

            let trigger_character = properties
                .get("trigger_character")
                .map(|v| v.chars().next().unwrap());
            let explicit = match properties.get("explicit").copied().map(str::trim) {
                Some("true") => true,
                Some("false") | None => false,
                Some(v) => panic!("invalid value for 'explicit' property: {v}"),
            };

            let mut includes = HashSet::new();
            let mut excludes = HashSet::new();

            for kk in properties.get("contains").iter().flat_map(|v| v.split(',')) {
                // split first char
                let (kind, item) = kk.split_at(1);
                if kind == "+" {
                    includes.insert(item.trim());
                } else if kind == "-" {
                    excludes.insert(item.trim());
                } else {
                    includes.insert(kk.trim());
                }
            }
            let get_items = |items: Vec<CompletionItem>| {
                let mut res: Vec<_> = items
                    .into_iter()
                    .filter(|item| {
                        if !excludes.is_empty() && excludes.contains(item.label.as_str()) {
                            panic!("{item:?} was excluded in {excludes:?}");
                        }
                        if includes.is_empty() {
                            return true;
                        }
                        includes.contains(item.label.as_str())
                    })
                    .map(|item| CompletionItem {
                        label: item.label,
                        label_details: item.label_details,
                        sort_text: item.sort_text,
                        kind: item.kind,
                        text_edit: item.text_edit,
                        additional_text_edits: item.additional_text_edits,
                        command: item.command,
                        ..Default::default()
                    })
                    .collect();

                res.sort_by(|a, b| {
                    a.sort_text
                        .as_ref()
                        .cmp(&b.sort_text.as_ref())
                        .then_with(|| a.label.cmp(&b.label))
                });
                res
            };

            let mut results = vec![];
            for s in rng.clone() {
                let request = CompletionRequest {
                    path: ctx.path_for_id(id).unwrap().as_path().to_owned(),
                    position: ctx.to_lsp_pos(s, &source),
                    explicit,
                    trigger_character,
                };
                let result = request.request(ctx).map(|list| CompletionList {
                    is_incomplete: list.is_incomplete,
                    items: get_items(list.items),
                });
                results.push(result);
            }
            with_settings!({
                description => format!("Completion on {text} ({rng:?})"),
            }, {
                assert_snapshot!(JsonRepr::new_pure(results));
            })
        }

        move |ctx, path| {
            if config.pkg_mode {
                let files = ctx
                    .source_files()
                    .iter()
                    .filter(|id| !id.vpath().as_rootless_path().ends_with("lib.typ"));
                for id in files.copied().collect::<Vec<_>>() {
                    test(ctx, id);
                }
            } else {
                test(ctx, ctx.file_id_by_path(&path).unwrap());
            }
        }
    }

    #[test]
    fn test_base() {
        snapshot_testing("completion", &run(TestConfig { pkg_mode: false }));
    }

    #[test]
    fn test_pkgs() {
        snapshot_testing("pkgs", &run(TestConfig { pkg_mode: true }));
    }

    #[test]
    fn explicit_citation_label_completion_strips_typed_angle_brackets() {
        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("src/fixtures/completion/complete_half_label_cite_explicit.typ");
        let contents = std::fs::read_to_string(&path).unwrap();

        run_with_sources(&contents, |verse: &mut LspUniverse, path| {
            run_with_ctx(verse, path, &|ctx, path| {
                let source = ctx.source_by_path(&path).unwrap();
                let rng = find_test_range_(&source);
                let request = CompletionRequest {
                    path: path.clone(),
                    position: ctx.to_lsp_pos(rng.start, &source),
                    explicit: false,
                    trigger_character: None,
                };
                let result = request.request(ctx).unwrap();
                let item = result
                    .items
                    .into_iter()
                    .find(|item| item.label == "DBLP:books/lib/Knuth86a")
                    .unwrap();

                assert_eq!(
                    item.text_edit.as_ref().unwrap().new_text.as_str(),
                    "label(\"DBLP:books/lib/Knuth86a\")"
                );

                let cleanup_edits = item.additional_text_edits.unwrap();
                assert_eq!(cleanup_edits.len(), 1);

                let cleanup = &cleanup_edits[0];
                assert_eq!(cleanup.new_text.as_str(), "");
                assert_eq!(cleanup.range.start.line, 3);
                assert_eq!(cleanup.range.start.character, 6);
                assert_eq!(cleanup.range.end.line, 3);
                assert_eq!(cleanup.range.end.character, 7);
            });
        });
    }
}