diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index d2c1040b4..96fd7f265 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -709,9 +709,11 @@ fn complete_params(ctx: &mut CompletionContext) -> bool { return true; } - // Parameters: "func(|)", "func(hi|)", "func(12,|)". + // Parameters: "func(|)", "func(hi|)", "func(12, |)", "func(12,|)" [explicit mode only] if let SyntaxKind::LeftParen | SyntaxKind::Comma = deciding.kind() - && (deciding.kind() != SyntaxKind::Comma || deciding.range().end < ctx.cursor) + && (deciding.kind() != SyntaxKind::Comma + || deciding.range().end < ctx.cursor + || ctx.explicit) { if let Some(next) = deciding.next_leaf() { ctx.from = ctx.cursor.min(next.offset()); @@ -891,7 +893,10 @@ fn complete_code(ctx: &mut CompletionContext) -> bool { } // An existing identifier: "{ pa| }". - if ctx.leaf.kind() == SyntaxKind::Ident { + // Ignores named pair keys as they are not variables (as in "(pa|: 23)"). + 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; @@ -904,11 +909,19 @@ fn complete_code(ctx: &mut CompletionContext) -> bool { return true; } - // Anywhere: "{ | }". - // But not within or after an expression. + // Anywhere: "{ | }", "(|)", "(1,|)", "(a:|)". + // But not within or after an expression, and also not part of a dictionary + // key (as in "(pa: |,)") 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); @@ -1560,6 +1573,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>) @@ -1578,6 +1592,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(); @@ -1622,7 +1646,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] @@ -1635,7 +1667,7 @@ mod tests { let doc = typst::compile(&world).output.ok(); let end = world.main.text().len(); world.main.edit(end..end, addition); - test_with_doc(&world, pos, doc.as_ref()) + test_with_doc(&world, pos, doc.as_ref(), true) } #[track_caller] @@ -1643,11 +1675,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] @@ -1698,7 +1731,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"]); } @@ -1853,26 +1886,105 @@ 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(); + } + + #[test] + fn test_autocomplete_in_destructuring() { + let document = "#let value = 20\n#let (va: value) = (va: 10)"; + + // At destructuring rename pattern source + test(document, 24).must_be_empty(); + test_implicit(document, 24).must_be_empty(); } }