diff --git a/.chronus/changes/ide-support-completion-for-extends-is-2024-4-24-11-15-1.md b/.chronus/changes/ide-support-completion-for-extends-is-2024-4-24-11-15-1.md new file mode 100644 index 0000000000..e3c10d8967 --- /dev/null +++ b/.chronus/changes/ide-support-completion-for-extends-is-2024-4-24-11-15-1.md @@ -0,0 +1,27 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Support completion for keyword 'extends' and 'is' + + Example + ```tsp + model Dog ┆ {} + | [extends] + | [is] + + scalar Addresss ┆ + | [extends] + + op jump ┆ + | [is] + + interface ResourceA ┆ {} + | [extends] + + model Cat {} + | [extends] + ``` + diff --git a/packages/compiler/src/core/charcode.ts b/packages/compiler/src/core/charcode.ts index c493a08078..3a06f2e32a 100644 --- a/packages/compiler/src/core/charcode.ts +++ b/packages/compiler/src/core/charcode.ts @@ -247,17 +247,20 @@ export function isNonAsciiIdentifierCharacter(codePoint: number) { return lookupInNonAsciiMap(codePoint, nonAsciiIdentifierMap); } -export function codePointBefore(text: string, pos: number): number | undefined { - if (pos <= 0 || pos >= text.length) { - return undefined; +export function codePointBefore( + text: string, + pos: number +): { char: number | undefined; size: number } { + if (pos <= 0 || pos > text.length) { + return { char: undefined, size: 0 }; } const ch = text.charCodeAt(pos - 1); if (!isLowSurrogate(ch) || !isHighSurrogate(text.charCodeAt(pos - 2))) { - return ch; + return { char: ch, size: 1 }; } - return text.codePointAt(pos - 2); + return { char: text.codePointAt(pos - 2), size: 2 }; } function lookupInNonAsciiMap(codePoint: number, map: readonly number[]) { diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index bafd9f6d4c..2917635708 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -1,5 +1,5 @@ import { isArray, mutate } from "../utils/misc.js"; -import { trim } from "./charcode.js"; +import { codePointBefore, isIdentifierContinue, trim } from "./charcode.js"; import { compilerAssert } from "./diagnostics.js"; import { CompilerDiagnostics, createDiagnostic } from "./messages.js"; import { @@ -12,6 +12,9 @@ import { isPunctuation, isStatementKeyword, isTrivia, + skipContinuousIdentifier, + skipTrivia, + skipTriviaBackward, } from "./scanner.js"; import { AliasStatementNode, @@ -136,6 +139,17 @@ type ParseListItem = K extends UnannotatedListKind ? () => T : (pos: number, decorators: DecoratorExpressionNode[]) => T; +type ListDetail = { + items: T[]; + /** + * The range of the list items as below as an example + * model Foo { a: string; b: string; } + * + * remark: if the start/end token (i.e. { } ) not found, pos/end will be -1 + */ + range: TextRange; +}; + type OpenToken = | Token.OpenBrace | Token.OpenParen @@ -702,9 +716,10 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa ): InterfaceStatementNode { parseExpected(Token.InterfaceKeyword); const id = parseIdentifier(); - const templateParameters = parseTemplateParameterList(); + const { items: templateParameters, range: templateParametersRange } = + parseTemplateParameterList(); - let extendList: TypeReferenceNode[] = []; + let extendList: ListDetail = createEmptyList(); if (token() === Token.ExtendsKeyword) { nextToken(); extendList = parseList(ListKind.Heritage, parseReferenceExpression); @@ -713,25 +728,28 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa nextToken(); } - const operations = parseList(ListKind.InterfaceMembers, (pos, decorators) => - parseOperationStatement(pos, decorators, true) + const { items: operations, range: bodyRange } = parseList( + ListKind.InterfaceMembers, + (pos, decorators) => parseOperationStatement(pos, decorators, true) ); return { kind: SyntaxKind.InterfaceStatement, id, templateParameters, + templateParametersRange, operations, - extends: extendList, + bodyRange, + extends: extendList.items, decorators, ...finishNode(pos), }; } - function parseTemplateParameterList(): TemplateParameterDeclarationNode[] { - const list = parseOptionalList(ListKind.TemplateParameters, parseTemplateParameter); + function parseTemplateParameterList(): ListDetail { + const detail = parseOptionalList(ListKind.TemplateParameters, parseTemplateParameter); let setDefault = false; - for (const item of list) { + for (const item of detail.items) { if (!item.default && setDefault) { error({ code: "default-required", target: item }); continue; @@ -742,7 +760,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa } } - return list; + return detail; } function parseUnionStatement( @@ -751,14 +769,16 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa ): UnionStatementNode { parseExpected(Token.UnionKeyword); const id = parseIdentifier(); - const templateParameters = parseTemplateParameterList(); + const { items: templateParameters, range: templateParametersRange } = + parseTemplateParameterList(); - const options = parseList(ListKind.UnionVariants, parseUnionVariant); + const { items: options } = parseList(ListKind.UnionVariants, parseUnionVariant); return { kind: SyntaxKind.UnionStatement, id, templateParameters, + templateParametersRange, decorators, options, ...finishNode(pos), @@ -835,7 +855,8 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa } const id = parseIdentifier(); - const templateParameters = parseTemplateParameterList(); + const { items: templateParameters, range: templateParametersRange } = + parseTemplateParameterList(); // Make sure the next token is one that is expected const token = expectTokenIsOneOf(Token.OpenParen, Token.IsKeyword); @@ -874,6 +895,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa kind: SyntaxKind.OperationStatement, id, templateParameters, + templateParametersRange, signature, decorators, ...finishNode(pos), @@ -882,10 +904,14 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa function parseOperationParameters(): ModelExpressionNode { const pos = tokenPos(); - const properties = parseList(ListKind.OperationParameters, parseModelPropertyOrSpread); + const { items: properties, range: bodyRange } = parseList( + ListKind.OperationParameters, + parseModelPropertyOrSpread + ); const parameters: ModelExpressionNode = { kind: SyntaxKind.ModelExpression, properties, + bodyRange, ...finishNode(pos), }; return parameters; @@ -897,23 +923,26 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa ): ModelStatementNode { parseExpected(Token.ModelKeyword); const id = parseIdentifier(); - const templateParameters = parseTemplateParameterList(); + const { items: templateParameters, range: templateParametersRange } = + parseTemplateParameterList(); expectTokenIsOneOf(Token.OpenBrace, Token.Equals, Token.ExtendsKeyword, Token.IsKeyword); const optionalExtends = parseOptionalModelExtends(); const optionalIs = optionalExtends ? undefined : parseOptionalModelIs(); - let properties: (ModelPropertyNode | ModelSpreadPropertyNode)[] = []; + let propDetail: ListDetail = createEmptyList< + ModelPropertyNode | ModelSpreadPropertyNode + >(); if (optionalIs) { const tok = expectTokenIsOneOf(Token.Semicolon, Token.OpenBrace); if (tok === Token.Semicolon) { nextToken(); } else { - properties = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); + propDetail = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); } } else { - properties = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); + propDetail = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); } return { @@ -922,8 +951,10 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa extends: optionalExtends, is: optionalIs, templateParameters, + templateParametersRange, decorators, - properties, + properties: propDetail.items, + bodyRange: propDetail.range, ...finishNode(pos), }; } @@ -1092,17 +1123,20 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa ): ScalarStatementNode { parseExpected(Token.ScalarKeyword); const id = parseIdentifier(); - const templateParameters = parseTemplateParameterList(); + const { items: templateParameters, range: templateParametersRange } = + parseTemplateParameterList(); const optionalExtends = parseOptionalScalarExtends(); - const members = parseScalarMembers(); + const { items: members, range: bodyRange } = parseScalarMembers(); return { kind: SyntaxKind.ScalarStatement, id, templateParameters, + templateParametersRange, extends: optionalExtends, members, + bodyRange, decorators, ...finishNode(pos), }; @@ -1115,10 +1149,10 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return undefined; } - function parseScalarMembers(): readonly ScalarConstructorNode[] { + function parseScalarMembers(): ListDetail { if (token() === Token.Semicolon) { nextToken(); - return []; + return createEmptyList(); } else { return parseList(ListKind.ScalarMembers, parseScalarMember); } @@ -1132,7 +1166,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa parseExpected(Token.InitKeyword); const id = parseIdentifier(); - const parameters = parseFunctionParameters(); + const { items: parameters } = parseFunctionParameters(); return { kind: SyntaxKind.ScalarConstructor, id, @@ -1147,7 +1181,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa ): EnumStatementNode { parseExpected(Token.EnumKeyword); const id = parseIdentifier(); - const members = parseList(ListKind.EnumMembers, parseEnumMemberOrSpread); + const { items: members } = parseList(ListKind.EnumMembers, parseEnumMemberOrSpread); return { kind: SyntaxKind.EnumStatement, id, @@ -1214,7 +1248,8 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa function parseAliasStatement(pos: number): AliasStatementNode { parseExpected(Token.AliasKeyword); const id = parseIdentifier(); - const templateParameters = parseTemplateParameterList(); + const { items: templateParameters, range: templateParametersRange } = + parseTemplateParameterList(); parseExpected(Token.Equals); const value = parseExpression(); parseExpected(Token.Semicolon); @@ -1222,6 +1257,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa kind: SyntaxKind.AliasStatement, id, templateParameters, + templateParametersRange, value, ...finishNode(pos), }; @@ -1388,10 +1424,11 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa const pos = tokenPos(); const target = parseIdentifierOrMemberExpression(message); if (token() === Token.OpenParen) { + const { items: args } = parseList(ListKind.FunctionArguments, parseExpression); return { kind: SyntaxKind.CallExpression, target, - arguments: parseList(ListKind.FunctionArguments, parseExpression), + arguments: args, ...finishNode(pos), }; } @@ -1403,7 +1440,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa target: IdentifierNode | MemberExpressionNode, pos: number ): TypeReferenceNode { - const args = parseOptionalList(ListKind.TemplateArguments, parseTemplateArgument); + const { items: args } = parseOptionalList(ListKind.TemplateArguments, parseTemplateArgument); return { kind: SyntaxKind.TypeReference, @@ -1463,29 +1500,31 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa // `@` applied to `model Foo`, and not as `@model` // applied to invalid statement `Foo`. const target = parseIdentifierOrMemberExpression(undefined, false); - const args = parseOptionalList(ListKind.DecoratorArguments, parseExpression); + const { items: args } = parseOptionalList(ListKind.DecoratorArguments, parseExpression); if (args.length === 0) { error({ code: "augment-decorator-target" }); + const emptyList = createEmptyList(); return { kind: SyntaxKind.AugmentDecoratorStatement, target, targetType: { kind: SyntaxKind.TypeReference, target: createMissingIdentifier(), - arguments: [], + arguments: emptyList.items, ...finishNode(pos), }, - arguments: [], + arguments: args, ...finishNode(pos), }; } let [targetEntity, ...decoratorArgs] = args; if (targetEntity.kind !== SyntaxKind.TypeReference) { error({ code: "augment-decorator-target", target: targetEntity }); + const emptyList = createEmptyList(); targetEntity = { kind: SyntaxKind.TypeReference, target: createMissingIdentifier(), - arguments: [], + arguments: emptyList.items, ...finishNode(pos), }; } @@ -1523,7 +1562,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa // `@` applied to `model Foo`, and not as `@model` // applied to invalid statement `Foo`. const target = parseIdentifierOrMemberExpression(undefined, false); - const args = parseOptionalList(ListKind.DecoratorArguments, parseExpression); + const { items: args } = parseOptionalList(ListKind.DecoratorArguments, parseExpression); return { kind: SyntaxKind.DecoratorExpression, arguments: args, @@ -1723,7 +1762,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa function parseTupleExpression(): TupleExpressionNode { const pos = tokenPos(); - const values = parseList(ListKind.Tuple, parseExpression); + const { items: values } = parseList(ListKind.Tuple, parseExpression); return { kind: SyntaxKind.TupleExpression, values, @@ -1733,30 +1772,35 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa function parseModelExpression(): ModelExpressionNode { const pos = tokenPos(); - const properties = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); + const { items: properties, range: bodyRange } = parseList( + ListKind.ModelProperties, + parseModelPropertyOrSpread + ); return { kind: SyntaxKind.ModelExpression, properties, + bodyRange, ...finishNode(pos), }; } function parseObjectLiteral(): ObjectLiteralNode { const pos = tokenPos(); - const properties = parseList( + const { items: properties, range: bodyRange } = parseList( ListKind.ObjectLiteralProperties, parseObjectLiteralPropertyOrSpread ); return { kind: SyntaxKind.ObjectLiteral, properties, + bodyRange, ...finishNode(pos), }; } function parseArrayLiteral(): ArrayLiteralNode { const pos = tokenPos(); - const values = parseList(ListKind.ArrayLiteral, parseExpression); + const { items: values } = parseList(ListKind.ArrayLiteral, parseExpression); return { kind: SyntaxKind.ArrayLiteral, values, @@ -1979,7 +2023,8 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa const modifierFlags = modifiersToFlags(modifiers); parseExpected(Token.DecKeyword); const id = parseIdentifier(); - let [target, ...parameters] = parseFunctionParameters(); + const allParamListDetail = parseFunctionParameters(); + let [target, ...parameters] = allParamListDetail.items; if (target === undefined) { error({ code: "decorator-decl-target", target: { pos, end: previousTokenEnd } }); target = { @@ -2013,7 +2058,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa const modifierFlags = modifiersToFlags(modifiers); parseExpected(Token.FnKeyword); const id = parseIdentifier(); - const parameters = parseFunctionParameters(); + const { items: parameters } = parseFunctionParameters(); let returnType; if (parseOptional(Token.Colon)) { returnType = parseExpression(); @@ -2030,14 +2075,14 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } - function parseFunctionParameters(): FunctionParameterNode[] { + function parseFunctionParameters(): ListDetail { const parameters = parseList( ListKind.FunctionParameters, parseFunctionParameter ); let foundOptional = false; - for (const [index, item] of parameters.entries()) { + for (const [index, item] of parameters.items.entries()) { if (!item.optional && foundOptional) { error({ code: "required-parameter-first", target: item }); continue; @@ -2050,7 +2095,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa if (item.rest && item.optional) { error({ code: "rest-parameter-required", target: item }); } - if (item.rest && index !== parameters.length - 1) { + if (item.rest && index !== parameters.items.length - 1) { error({ code: "rest-parameter-last", target: item }); } } @@ -2150,7 +2195,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa let parameters: ProjectionParameterDeclarationNode[]; if (token() === Token.OpenParen) { - parameters = parseList(ListKind.ProjectionParameter, parseProjectionParameter); + parameters = parseList(ListKind.ProjectionParameter, parseProjectionParameter).items; } else { parameters = []; } @@ -2395,7 +2440,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa kind: SyntaxKind.ProjectionCallExpression, callKind: "method", target: expr, - arguments: parseList(ListKind.CallArguments, parseProjectionExpression), + arguments: parseList(ListKind.CallArguments, parseProjectionExpression).items, ...finishNode(pos), }; } else { @@ -2486,7 +2531,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa function parseProjectionLambdaOrParenthesizedExpression(): ProjectionExpression { const pos = tokenPos(); - const exprs = parseList(ListKind.ProjectionExpression, parseProjectionExpression); + const exprs = parseList(ListKind.ProjectionExpression, parseProjectionExpression).items; if (token() === Token.EqualsGreaterThan) { // unpack the exprs (which should be just identifiers) into a param list const params: ProjectionLambdaParameterDeclarationNode[] = []; @@ -2544,7 +2589,10 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa function parseProjectionModelExpression(): ProjectionModelExpressionNode { const pos = tokenPos(); - const properties = parseList(ListKind.ModelProperties, parseProjectionModelPropertyOrSpread); + const { items: properties } = parseList( + ListKind.ModelProperties, + parseProjectionModelPropertyOrSpread + ); return { kind: SyntaxKind.ProjectionModelExpression, properties, @@ -2638,7 +2686,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa function parseProjectionTupleExpression(): ProjectionTupleExpressionNode { const pos = tokenPos(); - const values = parseList(ListKind.Tuple, parseProjectionExpression); + const { items: values } = parseList(ListKind.Tuple, parseProjectionExpression); return { kind: SyntaxKind.ProjectionTupleExpression, values, @@ -3091,11 +3139,12 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa function createMissingTypeReference(): TypeReferenceNode { const pos = tokenPos(); + const { items: args } = createEmptyList(); return { kind: SyntaxKind.TypeReference, target: createMissingIdentifier(), - arguments: [], + arguments: args, ...finishNode(pos), }; } @@ -3111,6 +3160,13 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return obj as any; } + function createEmptyList(range: TextRange = { pos: -1, end: -1 }): ListDetail { + return { + items: [], + range, + }; + } + /** * Parse a delimited list of elements, including the surrounding open and * close punctuation @@ -3129,16 +3185,20 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa function parseList( kind: K, parseItem: ParseListItem - ): T[] { + ): ListDetail { + const r: ListDetail = createEmptyList(); if (kind.open !== Token.None) { - parseExpected(kind.open); + const t = tokenPos(); + if (parseExpected(kind.open)) { + mutate(r.range).pos = t; + } } if (kind.allowEmpty && parseOptional(kind.close)) { - return []; + mutate(r.range).end = previousTokenEnd; + return r; } - const items: T[] = []; while (true) { const startingPos = tokenPos(); const { pos, docs, directives, decorators } = parseAnnotations({ @@ -3154,7 +3214,9 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa // of file. Note, however, that we must parse a missing element if // there were directives or decorators as we cannot drop those from // the tree. - parseExpected(kind.close); + if (parseExpected(kind.close)) { + mutate(r.range).end = previousTokenEnd; + } break; } @@ -3167,13 +3229,14 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa mutate(item).directives = directives; } - items.push(item); + r.items.push(item); const delimiter = token(); const delimiterPos = tokenPos(); if (parseOptionalDelimiter(kind)) { // Delimiter found: check if it's trailing. if (parseOptional(kind.close)) { + mutate(r.range).end = previousTokenEnd; if (!kind.trailingDelimiterIsValid) { error({ code: "trailing-token", @@ -3195,6 +3258,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa // there's no delimiter after an item. break; } else if (parseOptional(kind.close)) { + mutate(r.range).end = previousTokenEnd; // If a list *is* surrounded by punctuation, then the list ends when we // reach the close token. break; @@ -3204,7 +3268,9 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa // assumption that the closing delimiter is missing. This check is // duplicated from above to preempt the parseExpected(delimeter) // below. - parseExpected(kind.close); + if (parseExpected(kind.close)) { + mutate(r.range).end = previousTokenEnd; + } break; } else { // Error recovery: if a list kind *is* surrounded by punctuation and we @@ -3223,16 +3289,17 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa // // Simple repro: `model M { ]` would loop forever without this check. // - parseExpected(kind.close); + if (parseExpected(kind.close)) { + mutate(r.range).end = previousTokenEnd; + } nextToken(); // remove the item that was entirely inserted by error recovery. - items.pop(); + r.items.pop(); break; } } - - return items; + return r; } /** @@ -3242,8 +3309,8 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa function parseOptionalList( kind: K, parseItem: ParseListItem - ): T[] { - return token() === kind.open ? parseList(kind, parseItem) : []; + ): ListDetail { + return token() === kind.open ? parseList(kind, parseItem) : createEmptyList(); } function parseOptionalDelimiter(kind: ListKind) { @@ -3767,25 +3834,68 @@ function visitEach(cb: NodeCallback, nodes: readonly Node[] | undefined): return; } +/** + * check whether a position belongs to a range (excluding the start and end pos) + * i.e. {...} + * + * remark: if range.pos is -1 means no start point found, so return false + * if range.end is -1 means no end point found, so return true if position is greater than range.pos + */ +export function positionInRange(position: number, range: TextRange) { + return range.pos >= 0 && position > range.pos && (range.end === -1 || position < range.end); +} + export function getNodeAtPositionDetail( script: TypeSpecScriptNode, position: number, - filter?: (node: Node) => boolean -): PositionDetail | undefined { - const node = getNodeAtPosition(script, position, filter); - if (!node) return undefined; + filter: (node: Node, flag: "cur" | "pre" | "post") => boolean = () => true +): PositionDetail { + const cur = getNodeAtPosition(script, position, (n) => filter(n, "cur")); - const char = script.file.text.charCodeAt(position); - const preChar = position >= 0 ? script.file.text.charCodeAt(position - 1) : NaN; - const nextChar = - position < script.file.text.length ? script.file.text.charCodeAt(position + 1) : NaN; + const input = script.file.text; + const char = input.charCodeAt(position); + const preChar = position >= 0 ? input.charCodeAt(position - 1) : NaN; + const nextChar = position < input.length ? input.charCodeAt(position + 1) : NaN; + + let inTrivia = false; + let triviaStart: number | undefined; + let triviaEnd: number | undefined; + if (!cur || cur.kind !== SyntaxKind.StringLiteral) { + const { char: cp } = codePointBefore(input, position); + if (!cp || !isIdentifierContinue(cp)) { + triviaEnd = skipTrivia(input, position); + triviaStart = skipTriviaBackward(script, position) + 1; + inTrivia = triviaEnd !== position; + } + } + + if (!inTrivia) { + const beforeId = skipContinuousIdentifier(input, position, true /*isBackward*/); + triviaStart = skipTriviaBackward(script, beforeId) + 1; + const afterId = skipContinuousIdentifier(input, position, false /*isBackward*/); + triviaEnd = skipTrivia(input, afterId); + } + + if (triviaStart === undefined || triviaEnd === undefined) { + compilerAssert(false, "unexpected, triviaStart and triviaEnd should be defined"); + } return { - node, - position, + node: cur, + char, preChar, nextChar, - char, + position, + inTrivia, + triviaStartPosition: triviaStart, + triviaEndPosition: triviaEnd, + getPositionDetailBeforeTrivia: () => { + // getNodeAtPosition will also include the 'node.end' position which is the triviaStart pos + return getNodeAtPositionDetail(script, triviaStart, (n) => filter(n, "pre")); + }, + getPositionDetailAfterTrivia: () => { + return getNodeAtPositionDetail(script, triviaEnd, (n) => filter(n, "post")); + }, }; } @@ -3890,7 +4000,14 @@ function isBlocklessNamespace(node: Node) { return node.statements === undefined; } -export function getFirstAncestor(node: Node, test: NodeCallback): Node | undefined { +export function getFirstAncestor( + node: Node, + test: NodeCallback, + includeSelf: boolean = false +): Node | undefined { + if (includeSelf && test(node)) { + return node; + } for (let n = node.parent; n; n = n.parent) { if (test(n)) { return n; diff --git a/packages/compiler/src/core/scanner.ts b/packages/compiler/src/core/scanner.ts index eb8e252810..6e96a5afc1 100644 --- a/packages/compiler/src/core/scanner.ts +++ b/packages/compiler/src/core/scanner.ts @@ -1,5 +1,6 @@ import { CharCode, + codePointBefore, isAsciiIdentifierContinue, isAsciiIdentifierStart, isBinaryDigit, @@ -17,8 +18,9 @@ import { } from "./charcode.js"; import { DiagnosticHandler, compilerAssert } from "./diagnostics.js"; import { CompilerDiagnostics, createDiagnostic } from "./messages.js"; +import { getCommentAtPosition } from "./parser-utils.js"; import { createSourceFile } from "./source-file.js"; -import { DiagnosticReport, SourceFile, TextRange } from "./types.js"; +import { DiagnosticReport, SourceFile, TextRange, TypeSpecScriptNode } from "./types.js"; // All conflict markers consist of the same character repeated seven times. If it is // a <<<<<<< or >>>>>>> marker then it is also followed by a space. @@ -1418,7 +1420,54 @@ export function createScanner( } } +/** + * + * @param script + * @param position + * @param endPosition exclude + * @returns return === endPosition (or -1) means not found non-trivia until endPosition + 1 + */ +export function skipTriviaBackward( + script: TypeSpecScriptNode, + position: number, + endPosition = -1 +): number { + endPosition = endPosition < -1 ? -1 : endPosition; + const input = script.file.text; + if (position === input.length) { + // it's possible if the pos is at the end of the file, just treat it as trivia + position--; + } else if (position > input.length) { + compilerAssert(false, "position out of range"); + } + + while (position > endPosition) { + const ch = input.charCodeAt(position); + + if (isWhiteSpace(ch)) { + position--; + } else { + const comment = getCommentAtPosition(script, position); + if (comment) { + position = comment.pos - 1; + } else { + break; + } + } + } + + return position; +} + +/** + * + * @param input + * @param position + * @param endPosition exclude + * @returns return === endPosition (or input.length) means not found non-trivia until endPosition - 1 + */ export function skipTrivia(input: string, position: number, endPosition = input.length): number { + endPosition = endPosition > input.length ? input.length : endPosition; while (position < endPosition) { const ch = input.charCodeAt(position); @@ -1496,6 +1545,20 @@ function skipMultiLineComment( return [position, false]; } +export function skipContinuousIdentifier(input: string, position: number, isBackward = false) { + let cur = position; + const direction = isBackward ? -1 : 1; + const bar = isBackward ? (p: number) => p >= 0 : (p: number) => p < input.length; + while (bar(cur)) { + const { char: cp, size } = codePointBefore(input, cur); + cur += direction * size; + if (!cp || !isIdentifierContinue(cp)) { + break; + } + } + return cur; +} + function isConflictMarker(input: string, position: number, endPosition = input.length): boolean { // Conflict markers must be at the start of a line. const ch = input.charCodeAt(position); diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 44fe2cc30e..52f6876abb 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -1044,6 +1044,7 @@ export interface BaseNode extends TextRange { export interface TemplateDeclarationNode { readonly templateParameters: readonly TemplateParameterDeclarationNode[]; + readonly templateParametersRange: TextRange; readonly locals?: SymbolTable; } @@ -1051,11 +1052,35 @@ export interface TemplateDeclarationNode { * owner node and other related information according to the position */ export interface PositionDetail { - readonly node: Node; + readonly node: Node | undefined; readonly position: number; readonly char: number; readonly preChar: number; readonly nextChar: number; + readonly inTrivia: boolean; + + /** + * if the position is in a trivia, return the start position of the trivia containing the position + * if the position is not a trivia, return the start position of the trivia before the text(identifier code) containing the position + * + * Please be aware that this may not be the pre node in the tree because some non-trivia char is ignored in the tree but will counted here + * + * also comments are considered as trivia + */ + readonly triviaStartPosition: number; + /** + * if the position is in a trivia, return the end position (exclude as other 'end' means) of the trivia containing the position + * if the position is not a trivia, return the end position (exclude as other 'end' means) of the trivia after the node containing the position + * + * Please be aware that this may not be the next node in the tree because some non-trivia char is ignored in the tree but will considered here + * + * also comments are considered as trivia + */ + readonly triviaEndPosition: number; + /** get the PositionDetail of positionBeforeTrivia */ + readonly getPositionDetailBeforeTrivia: () => PositionDetail; + /** get the PositionDetail of positionAfterTrivia */ + readonly getPositionDetailAfterTrivia: () => PositionDetail; } export type Node = @@ -1360,6 +1385,7 @@ export interface OperationStatementNode extends BaseNode, DeclarationNode, Templ export interface ModelStatementNode extends BaseNode, DeclarationNode, TemplateDeclarationNode { readonly kind: SyntaxKind.ModelStatement; readonly properties: readonly (ModelPropertyNode | ModelSpreadPropertyNode)[]; + readonly bodyRange: TextRange; readonly extends?: Expression; readonly is?: Expression; readonly decorators: readonly DecoratorExpressionNode[]; @@ -1371,6 +1397,7 @@ export interface ScalarStatementNode extends BaseNode, DeclarationNode, Template readonly extends?: TypeReferenceNode; readonly decorators: readonly DecoratorExpressionNode[]; readonly members: readonly ScalarConstructorNode[]; + readonly bodyRange: TextRange; readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } @@ -1384,6 +1411,7 @@ export interface ScalarConstructorNode extends BaseNode { export interface InterfaceStatementNode extends BaseNode, DeclarationNode, TemplateDeclarationNode { readonly kind: SyntaxKind.InterfaceStatement; readonly operations: readonly OperationStatementNode[]; + readonly bodyRange: TextRange; readonly extends: readonly TypeReferenceNode[]; readonly decorators: readonly DecoratorExpressionNode[]; readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; @@ -1454,6 +1482,7 @@ export interface EmptyStatementNode extends BaseNode { export interface ModelExpressionNode extends BaseNode { readonly kind: SyntaxKind.ModelExpression; readonly properties: (ModelPropertyNode | ModelSpreadPropertyNode)[]; + readonly bodyRange: TextRange; } export interface ArrayExpressionNode extends BaseNode { @@ -1484,6 +1513,7 @@ export interface ModelSpreadPropertyNode extends BaseNode { export interface ObjectLiteralNode extends BaseNode { readonly kind: SyntaxKind.ObjectLiteral; readonly properties: (ObjectLiteralPropertyNode | ObjectLiteralSpreadPropertyNode)[]; + readonly bodyRange: TextRange; } export interface ObjectLiteralPropertyNode extends BaseNode { diff --git a/packages/compiler/src/server/completion.ts b/packages/compiler/src/server/completion.ts index 5bea884dc3..73a7903c67 100644 --- a/packages/compiler/src/server/completion.ts +++ b/packages/compiler/src/server/completion.ts @@ -10,6 +10,7 @@ import { getDeprecationDetails } from "../core/deprecation.js"; import { CompilerHost, IdentifierNode, + Node, NodeFlags, NodePackage, PositionDetail, @@ -19,6 +20,9 @@ import { SyntaxKind, Type, TypeSpecScriptNode, + compilerAssert, + getFirstAncestor, + positionInRange, } from "../core/index.js"; import { getAnyExtensionFromPath, @@ -40,11 +44,116 @@ export type CompletionContext = { export async function resolveCompletion( context: CompletionContext, - posDetail: PositionDetail | undefined + posDetail: PositionDetail ): Promise { - const node = posDetail?.node; + let node: Node | undefined = posDetail.node; + + if (!node) { + if ( + posDetail.triviaStartPosition === 0 || + !addCompletionByLookingBackward(posDetail, context) + ) { + addKeywordCompletion("root", context.completions); + } + } else { + // look back first to see whether we can get some completion from the previous statement, e.g. `model Foo |` + if (!addCompletionByLookingBackward(posDetail, context)) { + if (posDetail.inTrivia) { + // If we're not immediately after an identifier character, then advance + // the position past any trivia. This is done because a zero-width + // inserted missing identifier that the user is now trying to complete + // starts after the trivia following the cursor. + node = posDetail.getPositionDetailAfterTrivia().node; + } + await AddCompletionNonTrivia(node, context, posDetail); + } else { + if (!posDetail.inTrivia) { + await AddCompletionNonTrivia(node, context, posDetail); + } + } + } + + return context.completions; +} + +function addCompletionByLookingBackward( + posDetail: PositionDetail, + context: CompletionContext +): boolean { + if (posDetail.triviaStartPosition === 0) { + return false; + } + const preDetail = posDetail.getPositionDetailBeforeTrivia(); + if (!preDetail.node) { + return false; + } + + const node = getFirstAncestor( + preDetail.node, + (n) => + n.kind === SyntaxKind.ModelStatement || + n.kind === SyntaxKind.ScalarStatement || + n.kind === SyntaxKind.OperationStatement || + n.kind === SyntaxKind.InterfaceStatement || + n.kind === SyntaxKind.TemplateParameterDeclaration, + true /*includeSelf*/ + ); + + return node !== undefined && addCompletionByLookingBackwardNode(node, posDetail, context); +} + +function addCompletionByLookingBackwardNode( + preNode: Node, + posDetail: PositionDetail, + context: CompletionContext +): boolean { + const getIdentifierEndPos = (n: IdentifierNode) => { + // n.pos === n.end, it means it's a missing identifier, just return -1; + return n.pos === n.end ? -1 : n.end; + }; + const map: { [key in SyntaxKind]?: keyof KeywordArea } = { + [SyntaxKind.ModelStatement]: "modelHeader", + [SyntaxKind.ScalarStatement]: "scalarHeader", + [SyntaxKind.OperationStatement]: "operationHeader", + [SyntaxKind.InterfaceStatement]: "interfaceHeader", + }; + switch (preNode?.kind) { + case SyntaxKind.ModelStatement: + case SyntaxKind.ScalarStatement: + case SyntaxKind.OperationStatement: + case SyntaxKind.InterfaceStatement: + const idEndPos = + preNode.templateParametersRange.end >= 0 + ? preNode.templateParametersRange.end + : getIdentifierEndPos(preNode.id); + if (posDetail.triviaStartPosition === idEndPos) { + const key = map[preNode.kind]; + if (!key) { + compilerAssert(false, "KeywordArea missing in keyarea map."); + } + addKeywordCompletion(key, context.completions); + return true; + } + break; + case SyntaxKind.TemplateParameterDeclaration: + if (posDetail.triviaStartPosition === getIdentifierEndPos(preNode.id)) { + addKeywordCompletion("templateParameter", context.completions); + return true; + } else if (preNode.parent?.templateParametersRange.end === posDetail.triviaStartPosition) { + return addCompletionByLookingBackwardNode(preNode.parent, posDetail, context); + } + break; + } + return false; +} + +async function AddCompletionNonTrivia( + node: Node | undefined, + context: CompletionContext, + posDetail: PositionDetail, + lookBackward: boolean = true +) { if ( - posDetail === undefined || node === undefined || node.kind === SyntaxKind.InvalidStatement || (node.kind === SyntaxKind.Identifier && @@ -58,7 +167,9 @@ export async function resolveCompletion( addKeywordCompletion("namespace", context.completions); break; case SyntaxKind.ScalarStatement: - addKeywordCompletion("scalar", context.completions); + if (positionInRange(posDetail.position, node.bodyRange)) { + addKeywordCompletion("scalarBody", context.completions); + } break; case SyntaxKind.Identifier: addDirectiveCompletion(context, node); @@ -76,16 +187,18 @@ export async function resolveCompletion( break; } } - - return context.completions; } interface KeywordArea { root?: boolean; namespace?: boolean; - model?: boolean; + modelHeader?: boolean; identifier?: boolean; - scalar?: boolean; + scalarHeader?: boolean; + scalarBody?: boolean; + templateParameter?: boolean; + operationHeader?: boolean; + interfaceHeader?: boolean; } const keywords = [ @@ -107,8 +220,11 @@ const keywords = [ ["const", { root: true, namespace: true }], // On model `model Foo ...` - ["extends", { model: true }], - ["is", { model: true }], + [ + "extends", + { modelHeader: true, scalarHeader: true, templateParameter: true, interfaceHeader: true }, + ], + ["is", { modelHeader: true, operationHeader: true }], // On identifier ["true", { identifier: true }], @@ -121,7 +237,7 @@ const keywords = [ ["extern", { root: true, namespace: true }], // Scalars - ["init", { scalar: true }], + ["init", { scalarBody: true }], ] as const; function addKeywordCompletion(area: keyof KeywordArea, completions: CompletionList) { @@ -247,34 +363,37 @@ async function addRelativePathCompletion( function addModelCompletion(context: CompletionContext, posDetail: PositionDetail) { const node = posDetail.node; if ( - node.kind !== SyntaxKind.ModelStatement && - node.kind !== SyntaxKind.ModelExpression && - node.kind !== SyntaxKind.ObjectLiteral + !node || + (node.kind !== SyntaxKind.ModelStatement && + node.kind !== SyntaxKind.ModelExpression && + node.kind !== SyntaxKind.ObjectLiteral) ) { return; } - // skip the scenario like `{ ... }|` - if (node.end === posDetail.position) { + + if (posDetail.position === node.bodyRange.end) { + // skip the scenario like `{ ... }|` return; + } else { + // create a fake identifier node to further resolve the completions for the model/object + // it's a little tricky but can help to keep things clean and simple while the cons. is limited + // TODO: consider adding support in resolveCompletions for non-identifier-node directly when we find more scenario and worth the cost + const fakeProp = { + kind: + node.kind === SyntaxKind.ObjectLiteral + ? SyntaxKind.ObjectLiteralProperty + : SyntaxKind.ModelProperty, + flags: NodeFlags.None, + parent: node, + }; + const fakeId = { + kind: SyntaxKind.Identifier, + sv: "", + flags: NodeFlags.None, + parent: fakeProp, + }; + addIdentifierCompletion(context, fakeId as IdentifierNode); } - // create a fake identifier node to further resolve the completions for the model/object - // it's a little tricky but can help to keep things clean and simple while the cons. is limited - // TODO: consider adding support in resolveCompletions for non-identifier-node directly when we find more scenario and worth the cost - const fakeProp = { - kind: - node.kind === SyntaxKind.ObjectLiteral - ? SyntaxKind.ObjectLiteralProperty - : SyntaxKind.ModelProperty, - flags: NodeFlags.None, - parent: node, - }; - const fakeId = { - kind: SyntaxKind.Identifier, - sv: "", - flags: NodeFlags.None, - parent: fakeProp, - }; - addIdentifierCompletion(context, fakeId as IdentifierNode); } /** diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index 7ea1a69349..4dc89f625e 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -45,7 +45,7 @@ import { WorkspaceEdit, WorkspaceFoldersChangeEvent, } from "vscode-languageserver/node.js"; -import { CharCode, codePointBefore, isIdentifierContinue } from "../core/charcode.js"; +import { CharCode } from "../core/charcode.js"; import { resolveCodeFix } from "../core/code-fixes.js"; import { compilerAssert, getSourceLocation } from "../core/diagnostics.js"; import { formatTypeSpec } from "../core/formatter.js"; @@ -1075,21 +1075,6 @@ export function getCompletionNodeAtPosition( script: TypeSpecScriptNode, position: number, filter: (node: Node) => boolean = (node: Node) => true -): PositionDetail | undefined { - const detail = getNodeAtPositionDetail(script, position, filter); - if (detail?.node.kind === SyntaxKind.StringLiteral) { - return detail; - } - // If we're not immediately after an identifier character, then advance - // the position past any trivia. This is done because a zero-width - // inserted missing identifier that the user is now trying to complete - // starts after the trivia following the cursor. - const cp = codePointBefore(script.file.text, position); - if (!cp || !isIdentifierContinue(cp)) { - const newPosition = skipTrivia(script.file.text, position); - if (newPosition !== position) { - return getNodeAtPositionDetail(script, newPosition, filter); - } - } - return detail; +): PositionDetail { + return getNodeAtPositionDetail(script, position, filter); } diff --git a/packages/compiler/test/server/completion.test.ts b/packages/compiler/test/server/completion.test.ts index 8dd65e8715..093839c2a0 100644 --- a/packages/compiler/test/server/completion.test.ts +++ b/packages/compiler/test/server/completion.test.ts @@ -1,4 +1,4 @@ -import { deepStrictEqual, ok, strictEqual } from "assert"; +import { deepStrictEqual, equal, ok, strictEqual } from "assert"; import { describe, it } from "vitest"; import { CompletionItem, @@ -59,6 +59,119 @@ describe("complete statement keywords", () => { }); }); +describe("completes for keywords", () => { + describe.each([ + [`scalar S ┆`, ["extends"]], + [`scalar S ┆ `, ["extends"]], + [`scalar S \n┆\n`, ["extends"]], + [`scalar S ┆;`, ["extends"]], + [`scalar S ┆ ;`, ["extends"]], + [`scalar S /*comment*/ ┆{}`, ["extends"]], + [`scalar S ┆ {}`, ["extends"]], + [`scalar S ┆ \nscalar S2`, ["extends"]], + [`scalar S1;\nscalar S2 ┆ S1`, ["extends"]], + [`scalar S1;\nscalar S2 e┆x S1`, ["extends"]], + [`scalar S1;\nscalar S2 ┆ex S1`, ["extends"]], + [`scalar S ┆\n`, ["extends"]], + [`scalar S┆ \n`, ["extends"]], + [`scalar S ┆ {}`, ["extends"]], + [`scalar S ex┆`, ["extends"]], + [`scalar S ex┆tends`, ["extends"]], + [`scalar S ex ┆ {}`, []], + [`scalar S ex ex┆`, []], + [`scalar S {┆}`, ["init"]], + [`scalar S`, []], + + [`model M ┆`, ["extends", "is"]], + [`model M ┆ `, ["extends", "is"]], + [`model M \n┆\n`, ["extends", "is"]], + [`model M ┆;`, ["extends", "is"]], + [`model M ┆ ;`, ["extends", "is"]], + [`model M ┆{}`, ["extends", "is"]], + [`model M ┆ {}`, ["extends", "is"]], + [`model M ┆ \nscalar S2`, ["extends", "is"]], + [`model M1{}; model M2 ┆ M1`, ["extends", "is"]], + [`model M1{}; model M2 e┆x M1`, ["extends", "is"]], + [`model M1{}; model M2 ┆ex M1`, ["extends", "is"]], + [`model M ┆\n`, ["extends", "is"]], + [`model M┆ \n`, ["extends", "is"]], + [`model M ┆ {}`, ["extends", "is"]], + [`model M ex┆`, ["extends", "is"]], + [`model M i┆s`, ["extends", "is"]], + [`model M {┆}`, []], + [`model M {}`, []], + + [`op o ┆`, ["is"]], + [`op o ┆ `, ["is"]], + [`op o \n┆\n`, ["is"]], + [`op o ┆;`, ["is"]], + [`op o ┆ ;`, ["is"]], + [`op o ┆{}`, ["is"]], + [`op o ┆ {}`, ["is"]], + [`op o ┆ ()`, ["is"]], + [`op o ┆()`, ["is"]], + [`op o ┆ \nscalar S2`, ["is"]], + [`op o1{}; op o2 \n//comment\n ┆ M1`, ["is"]], + [`op o1{}; op o2 i┆s M1`, ["is"]], + [`op o1{}; op o2 ┆is M1`, ["is"]], + [`op o ┆\n`, ["is"]], + [`op o┆ \n`, ["is"]], + [`op o ┆ {}`, ["is"]], + [`op o is┆`, ["is"]], + [`op o (┆)`, []], + [`op o {}`, []], + [`interface I {o ┆}`, ["is"]], + [`interface I {o ┆ ()}`, ["is"]], + [`interface I {o (┆)}`, []], + + [`interface I ┆`, ["extends"]], + [`interface I //comment\n ┆ `, ["extends"]], + [`interface I \n┆\n`, ["extends"]], + [`interface I ┆;`, ["extends"]], + [`interface I ┆ ;`, ["extends"]], + [`interface I ┆{}`, ["extends"]], + [`interface I ┆ {}`, ["extends"]], + [`interface I ┆ \nscalar S2`, ["extends"]], + [`interface I1;\ninterface I2 ┆ I1`, ["extends"]], + [`interface I1;\ninterface I2 e┆x I1`, ["extends"]], + [`interface I1;\ninterface I2 ┆ex I1`, ["extends"]], + [`interface I ┆\n`, ["extends"]], + [`interface I┆ \n`, ["extends"]], + [`interface I ┆ {}`, ["extends"]], + [`interface I ex┆`, ["extends"]], + [`interface I ex┆tends`, ["extends"]], + [`interface I ex ┆ {}`, []], + [`interface I ex ex┆`, []], + [`interface I {┆}`, []], + [`interface I`, []], + + [`scalar S`, ["extends"]], + [`scalar S`, ["extends"]], + [`model M`, ["extends"]], + [`model M`, ["extends"]], + [`model M`, ["extends"]], + [`op o`, ["extends"]], + [`op o`, ["extends"]], + [`interface I┆`, []], + [`interface I<┆, T, Q>`, []], + [`interface I`, ["extends"]], + [`model M{};alias a = M`, []], + [`model M{};model M2 extends M`, []], + ] as const)("%s", (code, keywords) => { + it("completes extends keyword", async () => { + const completions = await complete(code); + if (keywords.length > 0) { + check( + completions, + keywords.map((w) => ({ label: w, kind: CompletionItemKind.Keyword })) + ); + } else { + equal(completions.items.length, 0, "No completions expected"); + } + }); + }); +}); + describe("imports", () => { describe("library imports", () => { async function testCompleteLibrary(code: string) { diff --git a/packages/compiler/test/server/misc.test.ts b/packages/compiler/test/server/misc.test.ts index 8ff5218b52..176d8f7137 100644 --- a/packages/compiler/test/server/misc.test.ts +++ b/packages/compiler/test/server/misc.test.ts @@ -1,6 +1,6 @@ import { ok, strictEqual } from "assert"; import { describe, it } from "vitest"; -import { Node, SyntaxKind, TypeSpecScriptNode, parse } from "../../src/core/index.js"; +import { PositionDetail, SyntaxKind, TypeSpecScriptNode, parse } from "../../src/core/index.js"; import { getCompletionNodeAtPosition } from "../../src/server/serverlib.js"; import { extractCursor } from "../../src/testing/test-server-host.js"; import { dumpAST } from "../ast-test-utils.js"; @@ -9,57 +9,62 @@ describe("compiler: server: misc", () => { describe("getCompletionNodeAtPosition", () => { async function getNodeAtCursor( sourceWithCursor: string - ): Promise<{ root: TypeSpecScriptNode; node: Node | undefined }> { + ): Promise<{ root: TypeSpecScriptNode; detail: PositionDetail | undefined }> { const { source, pos } = extractCursor(sourceWithCursor); - const root = parse(source); + const root = parse(source, { comments: true, docs: true }); dumpAST(root); - return { node: getCompletionNodeAtPosition(root, pos)?.node, root }; + return { detail: getCompletionNodeAtPosition(root, pos), root }; } it("return identifier for property return type", async () => { - const { node } = await getNodeAtCursor(` + const { detail } = await getNodeAtCursor(` model Foo { prop: stri┆ng } `); + const node = detail?.node; ok(node); strictEqual(node.kind, SyntaxKind.Identifier as const); strictEqual(node.sv, "string"); }); it("return missing identifier node when at the position for model property type", async () => { - const { node } = await getNodeAtCursor(` + const { detail } = await getNodeAtCursor(` model Foo { prop: ┆ } `); + const node = detail?.getPositionDetailAfterTrivia()?.node; ok(node); strictEqual(node.kind, SyntaxKind.Identifier as const); strictEqual(node.sv, "1"); }); it("return string literal when in non completed string", async () => { - const { node } = await getNodeAtCursor(` + const { detail } = await getNodeAtCursor(` import "┆ `); + const node = detail?.node; ok(node); strictEqual(node.kind, SyntaxKind.StringLiteral); }); it("return string literal when in non completed multi line string", async () => { - const { node } = await getNodeAtCursor(` + const { detail } = await getNodeAtCursor(` model Foo { prop: """┆ } `); + const node = detail?.node; ok(node); strictEqual(node.kind, SyntaxKind.StringLiteral); }); it("return missing identifier between dot and close paren", async () => { - const { node } = await getNodeAtCursor(` + const { detail } = await getNodeAtCursor(` @myDecN.┆) `); + const node = detail?.node; ok(node); strictEqual(node.kind, SyntaxKind.Identifier as const); strictEqual(node.sv, "1"); @@ -67,11 +72,12 @@ describe("compiler: server: misc", () => { describe("resolve real node when no potential identifier", () => { it("return namespace when in namespace body", async () => { - const { node } = await getNodeAtCursor(` + const { detail } = await getNodeAtCursor(` namespace Foo { ┆ } `); + const node = detail?.node; ok(node); strictEqual(node.kind, SyntaxKind.NamespaceStatement as const); strictEqual(node.id.sv, "Foo");