From c8be1cdc3620538c491f7331515f116a17f2231b Mon Sep 17 00:00:00 2001 From: Igor Khanin Date: Thu, 3 Jul 2025 00:07:13 +0300 Subject: [PATCH] Allow explicit autocomplete immediately after comma and colon --- crates/typst-ide/src/complete.rs | 133 +++++++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 17 deletions(-) diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index bc5b3e10e..fa97a29f6 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -725,10 +725,10 @@ fn complete_params(ctx: &mut CompletionContext) -> bool { } } - // Parameters: "func(|)", "func(hi|)", "func(12,|)". + // Parameters: "func(|)", "func(hi|)", "func(12, |)", "func(12,|)" [explicit mode only] if_chain! { if matches!(deciding.kind(), SyntaxKind::LeftParen | SyntaxKind::Comma); - if deciding.kind() != SyntaxKind::Comma || deciding.range().end < ctx.cursor; + if deciding.kind() != SyntaxKind::Comma || deciding.range().end < ctx.cursor || ctx.explicit; then { if let Some(next) = deciding.next_leaf() { ctx.from = ctx.cursor.min(next.offset()); @@ -908,8 +908,10 @@ fn complete_code(ctx: &mut CompletionContext) -> bool { return false; } - // An existing identifier: "{ pa| }". - if ctx.leaf.kind() == SyntaxKind::Ident { + // An existing identifier (which is not the key of a named pair): "{ pa| }". + if ctx.leaf.kind() == SyntaxKind::Ident + && (ctx.leaf.index() > 0 || ctx.leaf.parent_kind() != Some(SyntaxKind::Named)) + { ctx.from = ctx.leaf.offset(); code_completions(ctx, false); return true; @@ -923,10 +925,17 @@ fn complete_code(ctx: &mut CompletionContext) -> bool { } // Anywhere: "{ | }". - // But not within or after an expression. + // But not within or after an expression, and not a dictionary key if ctx.explicit + && ctx.leaf.parent_kind() != Some(SyntaxKind::Dict) && (ctx.leaf.kind().is_trivia() - || matches!(ctx.leaf.kind(), SyntaxKind::LeftParen | SyntaxKind::LeftBrace)) + || matches!( + ctx.leaf.kind(), + SyntaxKind::LeftParen + | SyntaxKind::LeftBrace + | SyntaxKind::Comma + | SyntaxKind::Colon + )) { ctx.from = ctx.cursor; code_completions(ctx, false); @@ -1579,6 +1588,7 @@ mod tests { trait ResponseExt { fn completions(&self) -> &[Completion]; fn labels(&self) -> BTreeSet<&str>; + fn must_be_empty(&self) -> &Self; fn must_include<'a>(&self, includes: impl IntoIterator) -> &Self; fn must_exclude<'a>(&self, excludes: impl IntoIterator) -> &Self; fn must_apply<'a>(&self, label: &str, apply: impl Into>) @@ -1597,6 +1607,16 @@ mod tests { self.completions().iter().map(|c| c.label.as_str()).collect() } + #[track_caller] + fn must_be_empty(&self) -> &Self { + let labels = self.labels(); + assert!( + labels.is_empty(), + "expected no suggestions (got {labels:?} instead)" + ); + self + } + #[track_caller] fn must_include<'a>(&self, includes: impl IntoIterator) -> &Self { let labels = self.labels(); @@ -1641,7 +1661,15 @@ mod tests { let world = world.acquire(); let world = world.borrow(); let doc = typst::compile(world).output.ok(); - test_with_doc(world, pos, doc.as_ref()) + test_with_doc(world, pos, doc.as_ref(), true) + } + + #[track_caller] + fn test_implicit(world: impl WorldLike, pos: impl FilePos) -> Response { + let world = world.acquire(); + let world = world.borrow(); + let doc = typst::compile(world).output.ok(); + test_with_doc(world, pos, doc.as_ref(), false) } #[track_caller] @@ -1649,11 +1677,12 @@ mod tests { world: impl WorldLike, pos: impl FilePos, doc: Option<&PagedDocument>, + explicit: bool, ) -> Response { let world = world.acquire(); let world = world.borrow(); let (source, cursor) = pos.resolve(world); - autocomplete(world, doc, &source, cursor, true) + autocomplete(world, doc, &source, cursor, explicit) } #[test] @@ -1704,7 +1733,7 @@ mod tests { let end = world.main.text().len(); world.main.edit(end..end, " #cite()"); - test_with_doc(&world, -2, doc.as_ref()) + test_with_doc(&world, -2, doc.as_ref(), true) .must_include(["netwok", "glacier-melt", "supplement"]) .must_exclude(["bib"]); } @@ -1835,26 +1864,96 @@ mod tests { #[test] fn test_autocomplete_fonts() { test("#text(font:)", -2) - .must_include(["\"Libertinus Serif\"", "\"New Computer Modern Math\""]); + .must_include([q!("Libertinus Serif"), q!("New Computer Modern Math")]); test("#show link: set text(font: )", -2) - .must_include(["\"Libertinus Serif\"", "\"New Computer Modern Math\""]); + .must_include([q!("Libertinus Serif"), q!("New Computer Modern Math")]); test("#show math.equation: set text(font: )", -2) - .must_include(["\"New Computer Modern Math\""]) - .must_exclude(["\"Libertinus Serif\""]); + .must_include([q!("New Computer Modern Math")]) + .must_exclude([q!("Libertinus Serif")]); test("#show math.equation: it => { set text(font: )\nit }", -7) - .must_include(["\"New Computer Modern Math\""]) - .must_exclude(["\"Libertinus Serif\""]); + .must_include([q!("New Computer Modern Math")]) + .must_exclude([q!("Libertinus Serif")]); } #[test] fn test_autocomplete_typed_html() { test("#html.div(translate: )", -2) .must_include(["true", "false"]) - .must_exclude(["\"yes\"", "\"no\""]); + .must_exclude([q!("yes"), q!("no")]); test("#html.input(value: )", -2).must_include(["float", "string", "red", "blue"]); - test("#html.div(role: )", -2).must_include(["\"alertdialog\""]); + test("#html.div(role: )", -2).must_include([q!("alertdialog")]); + } + + #[test] + fn test_autocomplete_in_function_params_after_comma_and_colon() { + let document = "#text(size: 12pt, [])"; + + // After colon + test(document, 11).must_include(["length"]); + test_implicit(document, 11).must_include(["length"]); + + test(document, 12).must_include(["length"]); + test_implicit(document, 12).must_include(["length"]); + + // After comma + test(document, 17).must_include(["font"]); + test_implicit(document, 17).must_be_empty(); + + test(document, 18).must_include(["font"]); + test_implicit(document, 18).must_include(["font"]); + } + + #[test] + fn test_autocomplete_in_list_literal() { + let document = "#let val = 0\n#(1, \"one\")"; + + // After opening paren + test(document, 15).must_include(["color", "val"]); + test_implicit(document, 15).must_be_empty(); + + // After first element + test(document, 16).must_be_empty(); + test_implicit(document, 16).must_be_empty(); + + // After comma + test(document, 17).must_include(["color", "val"]); + test_implicit(document, 17).must_be_empty(); + + test(document, 18).must_include(["color", "val"]); + test_implicit(document, 18).must_be_empty(); + } + + #[test] + fn test_autocomplete_in_dict_literal() { + let document = "#let first = 0\n#(first: 1, second: one)"; + + // After opening paren + test(document, 17).must_be_empty(); + test_implicit(document, 17).must_be_empty(); + + // After first key + test(document, 22).must_be_empty(); + test_implicit(document, 22).must_be_empty(); + + // After colon + test(document, 23).must_include(["align", "first"]); + test_implicit(document, 23).must_be_empty(); + + test(document, 24).must_include(["align", "first"]); + test_implicit(document, 24).must_be_empty(); + + // After first value + test(document, 25).must_be_empty(); + test_implicit(document, 25).must_be_empty(); + + // After comma + test(document, 26).must_be_empty(); + test_implicit(document, 26).must_be_empty(); + + test(document, 27).must_be_empty(); + test_implicit(document, 27).must_be_empty(); } }