Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix completion of keywords #3151

Merged
merged 6 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: fix
packages:
- "@typespec/compiler"
---

IDE: Fix completion of statement keywords
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: fix
packages:
- "@typespec/playground"
---

Fix completion of keywords
19 changes: 11 additions & 8 deletions packages/compiler/src/server/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,18 @@ export async function resolveCompletion(
context: CompletionContext,
node: Node | undefined
): Promise<CompletionList> {
const completions: CompletionList = {
isIncomplete: false,
items: [],
};
if (node === undefined) {
addKeywordCompletion("root", completions);
if (
node === undefined ||
node.kind === SyntaxKind.InvalidStatement ||
(node.kind === SyntaxKind.Identifier &&
(node.parent?.kind === SyntaxKind.TypeSpecScript ||
node.parent?.kind === SyntaxKind.NamespaceStatement))
) {
addKeywordCompletion("root", context.completions);
} else {
switch (node.kind) {
case SyntaxKind.NamespaceStatement:
addKeywordCompletion("namespace", completions);
addKeywordCompletion("namespace", context.completions);
break;
case SyntaxKind.Identifier:
addDirectiveCompletion(context, node);
Expand All @@ -64,7 +66,8 @@ export async function resolveCompletion(
break;
}
}
return completions;

return context.completions;
}

interface KeywordArea {
Expand Down
3 changes: 2 additions & 1 deletion packages/compiler/src/server/serverlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,7 @@ export function createServer(host: ServerHost): Server {
const { script, document, program } = result;
const node = getCompletionNodeAtPosition(script, document.offsetAt(params.position));

await resolveCompletion(
return await resolveCompletion(
{
program,
file: script,
Expand All @@ -682,6 +682,7 @@ export function createServer(host: ServerHost): Server {
node
);
}

return completions;
}

Expand Down
249 changes: 145 additions & 104 deletions packages/compiler/test/server/completion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,51 @@ import {

// cspell:ignore 𐌰𐌲𐌰𐌲𐌰𐌲

describe("compiler: server: completion", () => {
it("completes globals", async () => {
const completions = await complete(
`
model M {
s: ┆
}
`
);
check(completions, [
{
label: "int32",
insertText: "int32",
kind: CompletionItemKind.Unit,
documentation: {
kind: MarkupKind.Markdown,
value: "```typespec\nscalar int32\n```",
},
},
{
label: "Record",
insertText: "Record",
kind: CompletionItemKind.Class,
documentation: {
kind: MarkupKind.Markdown,
value: "```typespec\nmodel Record<Element>\n```",
},
},
]);
describe("complete statement keywords", () => {
describe.each([
// Top level only
["import", false],
["using", false],
// Namespace and top level
["model", true],
["op", true],
["extern", true],
["dec", true],
["alias", true],
["namespace", true],
["import", true],
["interface", true],
["scalar", true],
["union", true],
["enum", true],
["fn", true],
])("%s", (keyword, inNamespace) => {
describe.each(inNamespace ? ["top level", "namespace"] : ["top level"])("%s", () => {
it("complete with no text", async () => {
const completions = await complete(`┆`);

check(completions, [
{
label: keyword,
kind: CompletionItemKind.Keyword,
},
]);
});
it("complete with start of keyword", async () => {
const completions = await complete(`${keyword.slice(0, 1)}┆`);

check(completions, [
{
label: keyword,
kind: CompletionItemKind.Keyword,
},
]);
});
});
});
});

describe("imports", () => {
describe("library imports", () => {
async function testCompleteLibrary(code: string) {
const { source, pos, end } = extractSquiggles(code);
Expand Down Expand Up @@ -243,7 +257,38 @@ describe("compiler: server: completion", () => {
});
});
});
});

describe("identifiers", () => {
it("builtin types", async () => {
const completions = await complete(
`
model M {
s: ┆
}
`
);
check(completions, [
{
label: "int32",
insertText: "int32",
kind: CompletionItemKind.Unit,
documentation: {
kind: MarkupKind.Markdown,
value: "```typespec\nscalar int32\n```",
},
},
{
label: "Record",
insertText: "Record",
kind: CompletionItemKind.Class,
documentation: {
kind: MarkupKind.Markdown,
value: "```typespec\nmodel Record<Element>\n```",
},
},
]);
});
it("completes decorators on namespaces", async () => {
const completions = await complete(
`
Expand Down Expand Up @@ -1013,87 +1058,83 @@ describe("compiler: server: completion", () => {
check(completions, []);
});
});
});

function check(
list: CompletionList,
expectedItems: CompletionItem[],
options?: {
allowAdditionalCompletions?: boolean;
fullDocs?: boolean;
function check(
list: CompletionList,
expectedItems: CompletionItem[],
options?: {
allowAdditionalCompletions?: boolean;
fullDocs?: boolean;
}
) {
options = {
allowAdditionalCompletions: true,
fullDocs: false,
...options,
};

ok(!list.isIncomplete, "list should not be incomplete.");

const expectedMap = new Map(expectedItems.map((i) => [i.label, i]));
strictEqual(expectedMap.size, expectedItems.length, "Duplicate labels in expected completions.");

const actualMap = new Map(list.items.map((i) => [i.label, i]));
strictEqual(actualMap.size, list.items.length, "Duplicate labels in actual completions.");

for (const expected of expectedItems) {
const actual = actualMap.get(expected.label);

// Unless given the fullDocs option, tests only give their expectation for the first
// markdown paragraph.
if (
!options.fullDocs &&
typeof actual?.documentation === "object" &&
actual.documentation.value.indexOf("\n\n") > 0
) {
actual.documentation = {
kind: MarkupKind.Markdown,
value: actual.documentation.value.substring(0, actual.documentation.value.indexOf("\n\n")),
};
}
) {
options = {
allowAdditionalCompletions: true,
fullDocs: false,
...options,
};

ok(!list.isIncomplete, "list should not be incomplete.");

const expectedMap = new Map(expectedItems.map((i) => [i.label, i]));
strictEqual(
expectedMap.size,
expectedItems.length,
"Duplicate labels in expected completions."
ok(
actual,
`Expected completion item not found: '${expected.label}'. Available: ${list.items.map((x) => x.label).join(", ")}`
);
deepStrictEqual(actual, expected);
actualMap.delete(actual.label);
expectedMap.delete(expected.label);
}

const actualMap = new Map(list.items.map((i) => [i.label, i]));
strictEqual(actualMap.size, list.items.length, "Duplicate labels in actual completions.");

for (const expected of expectedItems) {
const actual = actualMap.get(expected.label);

// Unless given the fullDocs option, tests only give their expectation for the first
// markdown paragraph.
if (
!options.fullDocs &&
typeof actual?.documentation === "object" &&
actual.documentation.value.indexOf("\n\n") > 0
) {
actual.documentation = {
kind: MarkupKind.Markdown,
value: actual.documentation.value.substring(
0,
actual.documentation.value.indexOf("\n\n")
),
};
}

ok(actual, `Expected completion item not found: '${expected.label}'.`);
deepStrictEqual(actual, expected);
actualMap.delete(actual.label);
expectedMap.delete(expected.label);
}

const expectedRemaining = Array.from(expectedMap.values());
deepStrictEqual(expectedRemaining, [], "Not all expected completions were found.");
const expectedRemaining = Array.from(expectedMap.values());
deepStrictEqual(expectedRemaining, [], "Not all expected completions were found.");

if (!options.allowAdditionalCompletions) {
const actualRemaining = Array.from(actualMap.values());
deepStrictEqual(actualRemaining, [], "Extra completions were found.");
}
if (!options.allowAdditionalCompletions) {
const actualRemaining = Array.from(actualMap.values());
deepStrictEqual(actualRemaining, [], "Extra completions were found.");
}

async function complete(
sourceWithCursor: string,
jsSourceFile?: { name: string; js: Record<string, any> },
additionalFiles?: Record<string, string>
): Promise<CompletionList> {
const { source, pos } = extractCursor(sourceWithCursor);
const testHost = await createTestServerHost();
if (jsSourceFile) {
testHost.addJsFile(jsSourceFile.name, jsSourceFile.js);
}
if (additionalFiles) {
for (const [key, value] of Object.entries(additionalFiles)) {
testHost.addTypeSpecFile(key, value);
}
}

async function complete(
sourceWithCursor: string,
jsSourceFile?: { name: string; js: Record<string, any> },
additionalFiles?: Record<string, string>
): Promise<CompletionList> {
const { source, pos } = extractCursor(sourceWithCursor);
const testHost = await createTestServerHost();
if (jsSourceFile) {
testHost.addJsFile(jsSourceFile.name, jsSourceFile.js);
}
if (additionalFiles) {
for (const [key, value] of Object.entries(additionalFiles)) {
testHost.addTypeSpecFile(key, value);
}
testHost.addTypeSpecFile("main.tsp", 'import "./test/test.tsp";');
const textDocument = testHost.addOrUpdateDocument("test/test.tsp", source);
return await testHost.server.complete({
textDocument,
position: textDocument.positionAt(pos),
});
}
});
testHost.addTypeSpecFile("main.tsp", 'import "./test/test.tsp";');
const textDocument = testHost.addOrUpdateDocument("test/test.tsp", source);
return await testHost.server.complete({
textDocument,
position: textDocument.positionAt(pos),
});
}
2 changes: 1 addition & 1 deletion packages/playground/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ export async function registerMonacoLanguage(host: BrowserHost) {
const suggestions: monaco.languages.CompletionItem[] = [];
for (const item of result.items) {
let itemRange = range;
let insertText = item.insertText!;
let insertText = item.insertText ?? item.label;
if (item.textEdit && "range" in item.textEdit) {
itemRange = LspToMonaco.range(item.textEdit.range);
insertText = item.textEdit.newText;
Expand Down
Loading