diff --git a/common/changes/@typespec/compiler/compiler-named-template-params_2023-10-10-02-34.json b/common/changes/@typespec/compiler/compiler-named-template-params_2023-10-10-02-34.json new file mode 100644 index 0000000000..6379db70ec --- /dev/null +++ b/common/changes/@typespec/compiler/compiler-named-template-params_2023-10-10-02-34.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@typespec/compiler", + "comment": "Added support for named template arguments (#2340)", + "type": "none" + } + ], + "packageName": "@typespec/compiler" +} \ No newline at end of file diff --git a/docs/introduction/style-guide.md b/docs/introduction/style-guide.md index 3d5a548d95..fe202c0f10 100644 --- a/docs/introduction/style-guide.md +++ b/docs/introduction/style-guide.md @@ -12,23 +12,28 @@ The guidelines in this article are used in TypeSpec Core libraries. You can use ## Naming convention -| Type | Naming | Example | -| ---------------- | -------------------------------------------- | ------------------------------------------------ | -| scalar | camelCase | `scalar uuid extends string;` | -| model | PascalCase | `model Pet {}` | -| model property | camelCase | `model Pet {furColor: string}` | -| enum | PascalCase | `enum Direction {}` | -| enum member | camelCase | `enum Direction {up, down}` | -| namespace | PascalCase | `namespace Org.PetStore` | -| interface | PascalCase | `interface Stores {}` | -| operation | camelCase | `op listPets(): Pet[];` | -| operation params | camelCase | `op getPet(petId: string): Pet;` | -| unions | PascalCase | `union Pet {cat: Cat, dog: Dog}` | -| unions variants | camelCase | `union Pet {cat: Cat, dog: Dog}` | -| alias | camelCase or PascalCase depending on context | `alias myString = string` or `alias MyPet = Pet` | -| decorators | camelCase | `@format`, `@resourceCollection` | -| functions | camelCase | `addedAfter` | -| file name | kebab-case | `my-lib.tsp` | +| Type | Naming | Example | +| ------------------ | -------------------------------------------- | ------------------------------------------------ | +| scalar | camelCase | `scalar uuid extends string;` | +| model | PascalCase | `model Pet {}` | +| model property | camelCase | `model Pet {furColor: string}` | +| enum | PascalCase | `enum Direction {}` | +| enum member | camelCase | `enum Direction {up, down}` | +| namespace | PascalCase | `namespace Org.PetStore` | +| interface | PascalCase | `interface Stores {}` | +| operation | camelCase | `op listPets(): Pet[];` | +| operation params | camelCase | `op getPet(petId: string): Pet;` | +| unions | PascalCase | `union Pet {cat: Cat, dog: Dog}` | +| unions variants | camelCase | `union Pet {cat: Cat, dog: Dog}` | +| alias | camelCase or PascalCase depending on context | `alias myString = string` or `alias MyPet = Pet` | +| decorators | camelCase | `@format`, `@resourceCollection` | +| functions | camelCase | `addedAfter` | +| file name | kebab-case | `my-lib.tsp` | +| template parameter | PascalCase | `` | + +:::note +In some languages, particularly object-oriented programming languages, it's conventional to prefix certain names with a letter to indicate what kind of thing they are. For example, prefixing interface names with `I` (as in `IPet`) or prefixing template parameter names with `T` (as in `TResponse`). **This is not conventional in TypeSpec**. +::: ## Layout convention diff --git a/docs/language-basics/templates.md b/docs/language-basics/templates.md index c909b72cf3..93df782755 100644 --- a/docs/language-basics/templates.md +++ b/docs/language-basics/templates.md @@ -9,7 +9,7 @@ It is often useful to let the users of a model fill in certain details. Template Templates can be used on: -- [alias](./alias.md) +- [aliases](./alias.md) - [models](./models.md) - [operations](./operations.md) - [interfaces](./interfaces.md) @@ -27,7 +27,7 @@ model DogPage { ## Default values -A template parameter can be given a default value with `= `. +A template parameter can be given a default argument value with `= `. ```typespec model Page { @@ -38,27 +38,27 @@ model Page { ## Parameter constraints -Template parameter can provide a constraint using the `extends` keyword. See [type relations](./type-relations.md) documentation for details on how validation works. +Template parameters can specify a constraint using the `extends` keyword. See the [type relations](./type-relations.md) documentation for details on how validation works. ```typespec alias Foo = Type; ``` -now instantiating Foo with the wrong type will result in an error +Now, instantiating Foo with an argument that does not satisfy the constraint `string` will result in an error: ```typespec alias Bar = Foo<123>; ^ Type '123' is not assignable to type 'TypeSpec.string' ``` -Template constraints can be a model expression +A template parameter constraint can also be a model expression: ```typespec // Expect Type to be a model with property name: string alias Foo = Type; ``` -Template parameter default also need to respect the constraint +Template parameter defaults also need to respect the constraint: ```typespec alias Foo = Type @@ -66,3 +66,45 @@ alias Foo = Type alias Bar = Type ^ Type '123' is not assignable to type 'TypeSpec.string' ``` + +Furthermore, all optional arguments must come at the end of the template. A required argument cannot follow an optional argument: + +```typespec +// Invalid +alias Foo = ...; + ^ Required template arguments must not follow optional template arguments +``` + +## Named template arguments + +Template arguments may also be specified by name. In that case, they can be specified out of order and optional arguments may be omitted. This can be useful when dealing with templates that have many defaultable arguments: + +```typespec +alias Test = ...; + +// Specify the argument V by name to skip argument U, since U is optional and we +// are okay with its default +alias Example1 = Test; + +// Even all three arguments can be specified out of order +alias Example2 = Test< + V = "example2", + T = unknown, + U = uint64 +>; +``` + +However, once a template argument is specified by name, all subsequent arguments must also be specified by name: + +```typespec +// Invalid +alias Example3 = Test< + V = "example3", + unknown, + ^^^^^^^ Positional template arguments cannot follow named arguments in the same argument list. +>; +``` + +Since template arguments may be specified by name, the names of template parameters are part of the public API of a template. **Changing the name of a template parameter may break existing specifications that use the template.** + +**Note**: Template arguments are evaluated in the order the parameters are defined in the template _definition_, not the order in which they are written in the template _instance_. Most of the time, this should not matter, but may be important in some cases where evaluating a template argument may invoke decorators with side effects. diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 0aad7216e1..ca2de2e530 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -11,7 +11,12 @@ import { } from "./helpers/index.js"; import { isStringTemplateSerializable } from "./helpers/string-template-utils.js"; import { createDiagnostic } from "./messages.js"; -import { getIdentifierContext, hasParseError, visitChildren } from "./parser.js"; +import { + exprIsBareIdentifier, + getIdentifierContext, + hasParseError, + visitChildren, +} from "./parser.js"; import { Program, ProjectedProgram } from "./program.js"; import { createProjectionMembers } from "./projection-members.js"; import { @@ -121,6 +126,7 @@ import { SymbolLinks, SymbolTable, SyntaxKind, + TemplateArgumentNode, TemplateDeclarationNode, TemplateParameter, TemplateParameterDeclarationNode, @@ -260,6 +266,11 @@ export interface TypeSpecCompletionItem { * Optional label if different from the text to complete. */ label?: string; + + /** + * Optional text to be appended to the completion if accepted. + */ + suffix?: string; } /** @@ -487,7 +498,7 @@ export function createChecker(program: Program): Checker { for (const decNode of augmentDecorators) { const ref = resolveTypeReferenceSym(decNode.targetType, undefined); if (ref) { - let args: readonly Expression[] = []; + let args: readonly TemplateArgumentNode[] = []; if (ref.declarations[0].kind === SyntaxKind.AliasStatement) { const aliasNode = ref.declarations[0] as AliasStatementNode; if (aliasNode.value.kind === SyntaxKind.TypeReference) { @@ -669,6 +680,8 @@ export function createChecker(program: Program): Checker { return checkFunctionDeclaration(node, mapper); case SyntaxKind.TypeReference: return checkTypeReference(node, mapper); + case SyntaxKind.TemplateArgument: + return checkTemplateArgument(node, mapper); case SyntaxKind.TemplateParameterDeclaration: return checkTemplateParameterDeclaration(node, mapper); case SyntaxKind.ProjectionStatement: @@ -846,6 +859,10 @@ export function createChecker(program: Program): Checker { return type; } + function checkTemplateArgument(node: TemplateArgumentNode, mapper: TypeMapper | undefined): Type { + return getTypeForNode(node.argument, mapper); + } + function resolveTypeReference( node: TypeReferenceNode ): [Type | undefined, readonly Diagnostic[]] { @@ -921,85 +938,159 @@ export function createChecker(program: Program): Checker { } } - function checkTypeReferenceArgs( - node: TypeReferenceNode | MemberExpressionNode | IdentifierNode, + function checkTemplateInstantiationArgs( + node: Node, + args: readonly TemplateArgumentNode[], + decls: readonly TemplateParameterDeclarationNode[], mapper: TypeMapper | undefined - ): [Node, Type][] { - const args: [Node, Type][] = []; - if (node.kind !== SyntaxKind.TypeReference) { - return args; + ): Map { + const params = new Map(); + const positional: TemplateParameter[] = []; + interface TemplateParameterInit { + decl: TemplateParameterDeclarationNode; + // Deferred initializer so that we evaluate the param arguments in definition order. + checkArgument: (() => [Node, Type]) | null; } + const initMap = new Map( + decls.map(function (decl) { + const declaredType = getTypeForNode(decl)! as TemplateParameter; - for (const arg of node.arguments) { - const value = getTypeForNode(arg, mapper); - args.push([arg, value]); - } - return args; - } + positional.push(declaredType); + params.set(decl.id.sv, declaredType); - function checkTemplateInstantiationArgs( - node: Node, - args: [Node, Type][], - declarations: readonly TemplateParameterDeclarationNode[] - ): [TemplateParameter[], Type[]] { - if (args.length > declarations.length) { - reportCheckerDiagnostic( - createDiagnostic({ - code: "invalid-template-args", - messageId: "tooMany", - target: node, - }) - ); - // Too many args shouldn't matter for instantiating we can still go ahead - } + return [ + declaredType, + { + decl, + checkArgument: null, + }, + ]; + }) + ); - const values: Type[] = []; - const params: TemplateParameter[] = []; - let tooFew = false; + let named = false; - for (let i = 0; i < declarations.length; i++) { - const declaration = declarations[i]; - const declaredType = getTypeForNode(declaration)! as TemplateParameter; - params.push(declaredType); + for (const [arg, idx] of args.map((v, i) => [v, i] as const)) { + function deferredCheck(): [Node, Type] { + return [arg, getTypeForNode(arg.argument, mapper)]; + } - if (i < args.length) { - let [valueNode, value] = args[i]; - if (declaredType.constraint) { - if (!checkTypeAssignable(value, declaredType.constraint, valueNode)) { - // TODO-TIM check if we expose this below - value = - declaredType.constraint?.kind === "Value" ? unknownType : declaredType.constraint; - } + if (arg.name) { + named = true; + + const param = params.get(arg.name.sv); + + if (!param) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "invalid-template-args", + messageId: "unknownName", + format: { + name: arg.name.sv, + }, + target: arg, + }) + ); + continue; } - values.push(value); + + if (initMap.get(param)!.checkArgument !== null) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "invalid-template-args", + messageId: "specifiedAgain", + format: { + name: arg.name.sv, + }, + target: arg, + }) + ); + continue; + } + + initMap.get(param)!.checkArgument = deferredCheck; } else { - const mapper = createTypeMapper(params, values); - const defaultValue = getResolvedTypeParameterDefault(declaredType, declaration, mapper); + if (named) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "invalid-template-args", + messageId: "positionalAfterNamed", + target: arg, + }) + ); + // we just throw this arg away. any missing args will be filled with ErrorType + } + + if (idx >= positional.length) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "invalid-template-args", + messageId: "tooMany", + target: node, + }) + ); + continue; + } + + const param = positional[idx]; + + initMap.get(param)!.checkArgument ??= deferredCheck; + } + } + + const finalMap = initMap as unknown as Map; + const mapperParams: TemplateParameter[] = []; + const mapperArgs: Type[] = []; + for (const [param, { decl, checkArgument: init }] of [...initMap]) { + function commit(param: TemplateParameter, type: Type): void { + finalMap.set(param, type); + mapperParams.push(param); + mapperArgs.push(type); + } + + if (init === null) { + const argumentMapper = createTypeMapper(mapperParams, mapperArgs); + const defaultValue = getResolvedTypeParameterDefault(param, decl, argumentMapper); if (defaultValue) { - values.push(defaultValue); + commit(param, defaultValue); } else { - tooFew = true; - values.push( - // TODO-TIM check if we expose this below - declaredType.constraint?.kind === "Value" - ? unknownType - : declaredType.constraint ?? unknownType + reportCheckerDiagnostic( + createDiagnostic({ + code: "invalid-template-args", + messageId: "missing", + format: { + name: decl.id.sv, + }, + target: node, + }) + ); + + // TODO-TIM check if we expose this below + commit( + param, + param.constraint?.kind === "Value" ? unknownType : param.constraint ?? unknownType ); } + + continue; } - } - if (tooFew) { - reportCheckerDiagnostic( - createDiagnostic({ - code: "invalid-template-args", - messageId: "tooFew", - target: node, - }) - ); + const [argNode, type] = init(); + + if (param.constraint) { + if (!checkTypeAssignable(type, param.constraint, argNode)) { + // TODO-TIM check if we expose this below + const effectiveType = param.constraint?.kind === "Value" ? unknownType : param.constraint; + + commit(param, effectiveType); + continue; + } + } + + commit(param, type); } - return [params, values]; + return finalMap; } /** @@ -1032,9 +1123,9 @@ export function createChecker(program: Program): Checker { return errorType; } + const argumentNodes = node.kind === SyntaxKind.TypeReference ? node.arguments : []; const symbolLinks = getSymbolLinks(sym); let baseType; - const args = checkTypeReferenceArgs(node, mapper); if ( sym.flags & (SymbolFlags.Model | @@ -1046,7 +1137,7 @@ export function createChecker(program: Program): Checker { ) { const decl = sym.declarations[0] as TemplateableNode; if (!isTemplatedNode(decl)) { - if (args.length > 0) { + if (argumentNodes.length > 0) { reportCheckerDiagnostic( createDiagnostic({ code: "invalid-template-args", @@ -1070,22 +1161,24 @@ export function createChecker(program: Program): Checker { const declaredType = getOrCheckDeclaredType(sym, decl, mapper); const templateParameters = decl.templateParameters; - const [params, instantiationArgs] = checkTemplateInstantiationArgs( + const instantiation = checkTemplateInstantiationArgs( node, - args, - templateParameters + argumentNodes, + templateParameters, + mapper ); + baseType = getOrInstantiateTemplate( decl, - params, - instantiationArgs, + [...instantiation.keys()], + [...instantiation.values()], declaredType.templateMapper, instantiateTemplates ); } } else { // some other kind of reference - if (args.length > 0) { + if (argumentNodes.length > 0) { reportCheckerDiagnostic( createDiagnostic({ code: "invalid-template-args", @@ -1964,7 +2057,18 @@ export function createChecker(program: Program): Checker { } sym = resolveTypeReferenceSym(ref, mapper, resolveDecorator); break; + case IdentifierKind.TemplateArgument: + const templates = getTemplateDeclarationsForArgument(node as TemplateArgumentNode, mapper); + + const firstMatchingParameter = templates + .flatMap((t) => t.templateParameters) + .find((p) => p.id.sv === id.sv); + if (firstMatchingParameter) { + sym = getMergedSymbol(firstMatchingParameter.symbol); + } + + break; default: const _assertNever: never = kind; compilerAssert(false, "Unreachable"); @@ -1973,9 +2077,17 @@ export function createChecker(program: Program): Checker { return sym?.symbolSource ?? sym; } + function getTemplateDeclarationsForArgument( + node: TemplateArgumentNode, + mapper: TypeMapper | undefined + ) { + const resolved = resolveTypeReferenceSym(node.parent as TypeReferenceNode, mapper, false); + return (resolved?.declarations.filter((n) => isTemplatedNode(n)) ?? []) as TemplateableNode[]; + } + function resolveCompletions(identifier: IdentifierNode): Map { const completions = new Map(); - const { kind } = getIdentifierContext(identifier); + const { kind, node: ancestor } = getIdentifierContext(identifier); switch (kind) { case IdentifierKind.Using: @@ -1987,6 +2099,20 @@ export function createChecker(program: Program): Checker { return completions; // not implemented case IdentifierKind.Declaration: return completions; // cannot complete, name can be chosen arbitrarily + case IdentifierKind.TemplateArgument: { + const templates = getTemplateDeclarationsForArgument( + ancestor as TemplateArgumentNode, + undefined + ); + + for (const template of templates) { + for (const param of template.templateParameters) { + addCompletion(param.id.sv, param.symbol); + } + } + + return completions; + } default: const _assertNever: never = kind; compilerAssert(false, "Unreachable"); @@ -2010,6 +2136,26 @@ export function createChecker(program: Program): Checker { } } } else { + // We will only add template arguments if the template isn't already named + // to avoid completing the name of the argument again. + if ( + kind === IdentifierKind.TypeReference && + exprIsBareIdentifier(ancestor as TypeReferenceNode) && + ancestor.parent?.kind === SyntaxKind.TemplateArgument && + ancestor.parent.name === undefined + ) { + const templates = getTemplateDeclarationsForArgument( + ancestor.parent as TemplateArgumentNode, + undefined + ); + + for (const template of templates) { + for (const param of template.templateParameters) { + addCompletion(param.id.sv, param.symbol, { suffix: " = " }); + } + } + } + let scope: Node | undefined = identifier.parent; while (scope && scope.kind !== SyntaxKind.TypeSpecScript) { if (scope.symbol && scope.symbol.exports) { @@ -2060,7 +2206,7 @@ export function createChecker(program: Program): Checker { } } - function addCompletion(key: string, sym: Sym) { + function addCompletion(key: string, sym: Sym, options: { suffix?: string } = {}) { if (sym.symbolSource) { sym = sym.symbolSource; } @@ -2071,7 +2217,7 @@ export function createChecker(program: Program): Checker { key = key.slice(1); } if (!completions.has(key)) { - completions.set(key, { sym }); + completions.set(key, { ...options, sym }); } } @@ -2086,6 +2232,8 @@ export function createChecker(program: Program): Checker { case IdentifierKind.TypeReference: // Do not return functions or decorators when completing types return !(sym.flags & (SymbolFlags.Function | SymbolFlags.Decorator)); + case IdentifierKind.TemplateArgument: + return !!(sym.flags & SymbolFlags.TemplateParameter); default: compilerAssert(false, "We should have bailed up-front on other kinds."); } @@ -2835,7 +2983,6 @@ export function createChecker(program: Program): Checker { switch (node.value.kind) { case SyntaxKind.MemberExpression: case SyntaxKind.TypeReference: - case SyntaxKind.Identifier: const resolvedSym = resolveTypeReferenceSym(node.value, undefined); if (resolvedSym && resolvedSym.flags & SymbolFlags.Alias) { return resolveAliasedSymbol(resolvedSym); diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 1a73c3b383..ccb2cc78e2 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -211,6 +211,12 @@ const diagnostics = { default: "Required template parameters must not follow optional template parameters", }, }, + "invalid-template-argument-name": { + severity: "error", + messages: { + default: "Template parameter argument names must be valid, bare identifiers.", + }, + }, "invalid-template-default": { severity: "error", messages: { @@ -271,8 +277,12 @@ const diagnostics = { messages: { default: "Invalid template arguments.", notTemplate: "Can't pass template arguments to non-templated type", - tooFew: "Too few template arguments provided.", tooMany: "Too many template arguments provided.", + unknownName: paramMessage`No parameter named '${"name"}' exists in the target template.`, + positionalAfterNamed: + "Positional template arguments cannot follow named arguments in the same argument list.", + missing: paramMessage`Template argument '${"name"}' is required and not specified.`, + specifiedAgain: paramMessage`Cannot specify template argument '${"name"}' again.`, }, }, "intersect-non-model": { diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index a968da5f87..a9f30fa964 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -97,6 +97,7 @@ import { StringTemplateTailNode, Sym, SyntaxKind, + TemplateArgumentNode, TemplateParameterDeclarationNode, TextRange, TupleExpressionNode, @@ -1150,12 +1151,13 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa ...finishNode(pos), }; } + function parseReferenceExpression( message?: keyof CompilerDiagnostics["token-expected"] ): TypeReferenceNode { const pos = tokenPos(); const target = parseIdentifierOrMemberExpression(message); - const args = parseOptionalList(ListKind.TemplateArguments, parseExpression); + const args = parseOptionalList(ListKind.TemplateArguments, parseTemplateArgument); return { kind: SyntaxKind.TypeReference, @@ -1165,6 +1167,47 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } + function parseTemplateArgument(): TemplateArgumentNode { + const pos = tokenPos(); + + // Early error recovery for missing identifier followed by eq + if (token() === Token.Equals) { + error({ code: "token-expected", messageId: "identifier" }); + nextToken(); + return { + kind: SyntaxKind.TemplateArgument, + name: createMissingIdentifier(), + argument: parseExpression(), + ...finishNode(pos), + }; + } + + const expr: Expression = parseExpression(); + + const eq = parseOptional(Token.Equals); + + if (eq) { + const isBareIdentifier = exprIsBareIdentifier(expr); + + if (!isBareIdentifier) { + error({ code: "invalid-template-argument-name", target: expr }); + } + + return { + kind: SyntaxKind.TemplateArgument, + name: isBareIdentifier ? expr.target : createMissingIdentifier(), + argument: parseExpression(), + ...finishNode(pos), + }; + } else { + return { + kind: SyntaxKind.TemplateArgument, + argument: expr, + ...finishNode(pos), + }; + } + } + function parseAugmentDecorator(): AugmentDecoratorStatementNode { const pos = tokenPos(); parseExpected(Token.AtAt); @@ -1669,7 +1712,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa target = { kind: SyntaxKind.FunctionParameter, id: createMissingIdentifier(), - type: createMissingIdentifier(), + type: createMissingTypeReference(), optional: false, rest: false, ...finishNode(pos), @@ -2717,6 +2760,17 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } + function createMissingTypeReference(): TypeReferenceNode { + const pos = tokenPos(); + + return { + kind: SyntaxKind.TypeReference, + target: createMissingIdentifier(), + arguments: [], + ...finishNode(pos), + }; + } + function finishNode(pos: number): TextRange & { flags: NodeFlags; symbol: Sym } { const flags = parseErrorInNextFinishedNode ? NodeFlags.ThisNodeHasError : NodeFlags.None; parseErrorInNextFinishedNode = false; @@ -3087,6 +3141,16 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa export type NodeCallback = (c: Node) => T; +export function exprIsBareIdentifier( + expr: Expression +): expr is TypeReferenceNode & { target: IdentifierNode; arguments: [] } { + return ( + expr.kind === SyntaxKind.TypeReference && + expr.target.kind === SyntaxKind.Identifier && + expr.arguments.length === 0 + ); +} + export function visitChildren(node: Node, cb: NodeCallback): T | undefined { if (node.directives) { const result = visitEach(cb, node.directives); @@ -3276,6 +3340,8 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined return ( visitNode(cb, node.id) || visitNode(cb, node.constraint) || visitNode(cb, node.default) ); + case SyntaxKind.TemplateArgument: + return (node.name && visitNode(cb, node.name)) || visitNode(cb, node.argument); case SyntaxKind.ProjectionLambdaParameterDeclaration: return visitNode(cb, node.id); case SyntaxKind.ProjectionParameterDeclaration: @@ -3479,6 +3545,9 @@ export function getIdentifierContext(id: IdentifierNode): IdentifierContext { case SyntaxKind.UsingStatement: kind = IdentifierKind.Using; break; + case SyntaxKind.TemplateArgument: + kind = IdentifierKind.TemplateArgument; + break; default: kind = (id.parent as DeclarationNode).id === id diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index e86207de73..f932183714 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -816,6 +816,7 @@ export enum SyntaxKind { ProjectionDecoratorReferenceExpression, Return, JsNamespaceDeclaration, + TemplateArgument, } export const enum NodeFlags { @@ -872,6 +873,7 @@ export type Node = | TypeSpecScriptNode | JsSourceFileNode | JsNamespaceDeclarationNode + | TemplateArgumentNode | TemplateParameterDeclarationNode | ProjectionParameterDeclarationNode | ProjectionLambdaParameterDeclarationNode @@ -1070,7 +1072,6 @@ export type Expression = | IntersectionExpressionNode | TypeReferenceNode | ValueOfExpressionNode - | IdentifierNode | StringLiteralNode | NumericLiteralNode | BooleanLiteralNode @@ -1352,7 +1353,13 @@ export interface ValueOfExpressionNode extends BaseNode { export interface TypeReferenceNode extends BaseNode { readonly kind: SyntaxKind.TypeReference; readonly target: MemberExpressionNode | IdentifierNode; - readonly arguments: readonly Expression[]; + readonly arguments: readonly TemplateArgumentNode[]; +} + +export interface TemplateArgumentNode extends BaseNode { + readonly kind: SyntaxKind.TemplateArgument; + readonly name?: IdentifierNode; + readonly argument: Expression; } export interface ProjectionReferenceNode extends BaseNode { @@ -1613,6 +1620,7 @@ export interface IdentifierContext { export enum IdentifierKind { TypeReference, + TemplateArgument, Decorator, Function, Using, diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index dff54328a6..56c7565e1e 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -63,6 +63,7 @@ import { StringTemplateExpressionNode, StringTemplateSpanNode, SyntaxKind, + TemplateArgumentNode, TemplateParameterDeclarationNode, TextRange, TupleExpressionNode, @@ -212,6 +213,8 @@ export function printNode( return printUnionVariant(path as AstPath, options, print); case SyntaxKind.TypeReference: return printTypeReference(path as AstPath, options, print); + case SyntaxKind.TemplateArgument: + return printTemplateArgument(path as AstPath, options, print); case SyntaxKind.ValueOfExpression: return printValueOfExpression(path as AstPath, options, print); case SyntaxKind.TemplateParameterDeclaration: @@ -1347,6 +1350,21 @@ export function printTypeReference( return [type, template]; } +export function printTemplateArgument( + path: AstPath, + _options: TypeSpecPrettierOptions, + print: PrettierChildPrint +): Doc { + if (path.getValue().name !== undefined) { + const name = path.call(print, "name"); + const argument = path.call(print, "argument"); + + return group([name, " = ", argument]); + } else { + return path.call(print, "argument"); + } +} + export function printValueOfExpression( path: AstPath, options: TypeSpecPrettierOptions, diff --git a/packages/compiler/src/server/completion.ts b/packages/compiler/src/server/completion.ts index 3196741f14..b5177953d8 100644 --- a/packages/compiler/src/server/completion.ts +++ b/packages/compiler/src/server/completion.ts @@ -213,7 +213,7 @@ function addIdentifierCompletion( if (result.size === 0) { return; } - for (const [key, { sym, label }] of result) { + for (const [key, { sym, label, suffix }] of result) { let kind: CompletionItemKind; let deprecated = false; const type = sym.type ?? program.checker.getTypeForNode(sym.declarations[0]); @@ -241,7 +241,7 @@ function addIdentifierCompletion( } : undefined, kind, - insertText: printId(key), + insertText: printId(key) + (suffix ?? ""), }; if (deprecated) { item.tags = [CompletionItemTag.Deprecated]; diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index 14930247c0..5ab8c5776a 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -1192,6 +1192,9 @@ export function createServer(host: ServerHost): Server { case SyntaxKind.DocUnknownTag: classifyDocTag(node.tagName, SemanticTokenKind.Macro); break; + case SyntaxKind.TemplateArgument: + if (node.name) classify(node.name, SemanticTokenKind.TypeParameter); + break; default: break; } diff --git a/packages/compiler/src/server/tmlanguage.ts b/packages/compiler/src/server/tmlanguage.ts index d94cc38313..ee9b38427a 100644 --- a/packages/compiler/src/server/tmlanguage.ts +++ b/packages/compiler/src/server/tmlanguage.ts @@ -315,7 +315,7 @@ const typeArguments: BeginEndRule = { endCaptures: { "0": { scope: "punctuation.definition.typeparameters.end.tsp" }, }, - patterns: [expression, punctuationComma], + patterns: [identifierExpression, operatorAssignment, expression, punctuationComma], }; const typeParameterConstraint: BeginEndRule = { diff --git a/packages/compiler/test/checker/templates.test.ts b/packages/compiler/test/checker/templates.test.ts index 47a6c24288..271189303d 100644 --- a/packages/compiler/test/checker/templates.test.ts +++ b/packages/compiler/test/checker/templates.test.ts @@ -1,6 +1,6 @@ import { deepStrictEqual, fail, strictEqual } from "assert"; import { getSourceLocation } from "../../src/core/diagnostics.js"; -import { Diagnostic, Model, StringLiteral } from "../../src/core/types.js"; +import { Diagnostic, Model, StringLiteral, Type } from "../../src/core/types.js"; import { BasicTestRunner, TestHost, @@ -60,7 +60,7 @@ describe("compiler: templates", () => { const diagnostics = await testHost.diagnose("main.tsp"); strictEqual(diagnostics.length, 1); strictEqual(diagnostics[0].code, "invalid-template-args"); - strictEqual(diagnostics[0].message, "Too few template arguments provided."); + strictEqual(diagnostics[0].message, "Template argument 'T' is required and not specified."); // Should point to the start of A deepStrictEqual(getLineAndCharOfDiagnostic(diagnostics[0]), { line: 3, @@ -162,7 +162,7 @@ describe("compiler: templates", () => { const diagnostics = await testHost.diagnose("main.tsp"); strictEqual(diagnostics.length, 1); strictEqual(diagnostics[0].code, "invalid-template-args"); - strictEqual(diagnostics[0].message, "Too few template arguments provided."); + strictEqual(diagnostics[0].message, "Template argument 'U' is required and not specified."); }); it("emits diagnostics when non-defaulted template parameter comes after defaulted one", async () => { @@ -220,7 +220,7 @@ describe("compiler: templates", () => { const diagnostics = await testHost.diagnose("main.tsp"); expectDiagnostics(diagnostics, { code: "invalid-template-args", - message: "Too few template arguments provided.", + message: "Template argument 'Element' is required and not specified.", }); }); @@ -304,7 +304,7 @@ describe("compiler: templates", () => { "main.tsp", ` @test model A> { b: X } - model B { + model B { foo: A<"bye"> }; @@ -515,4 +515,363 @@ describe("compiler: templates", () => { `); }); }); + + describe("named template argument instantiations", async () => { + it("with named arguments", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + model A { a: T } + @test model B { + foo: A + }; + ` + ); + + const { B } = (await testHost.compile("main.tsp")) as { B: Model }; + const foo = B.properties.get("foo")!.type; + strictEqual(foo.kind, "Model"); + const a = foo.properties.get("a")!; + strictEqual(a.type.kind, "Scalar"); + strictEqual(a.type.name, "string"); + }); + + it("with named arguments out of order", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + model A { a: T, b: U } + @test model B { + foo: A + }; + ` + ); + + const { B } = (await testHost.compile("main.tsp")) as { B: Model }; + const foo = B.properties.get("foo")!.type; + strictEqual(foo.kind, "Model"); + const a = foo.properties.get("a")!; + const b = foo.properties.get("b")!; + strictEqual(a.type.kind, "Scalar"); + strictEqual(a.type.name, "string"); + strictEqual(b.type.kind, "Scalar"); + strictEqual(b.type.name, "int32"); + }); + + it("with named arguments and defaults", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + model A { a: T, b: U } + @test model B { + foo: A + }; + ` + ); + + const { B } = (await testHost.compile("main.tsp")) as { B: Model }; + const foo = B.properties.get("foo")!.type; + strictEqual(foo.kind, "Model"); + const a = foo.properties.get("a")!; + const b = foo.properties.get("b")!; + strictEqual(a.type.kind, "Scalar"); + strictEqual(a.type.name, "int32"); + strictEqual(b.type.kind, "String"); + strictEqual(b.type.value, "bar"); + }); + + it("with named arguments and defaults bound to other parameters", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + model A { a: T, b: U } + @test model B { + foo: A + }; + ` + ); + + const { B } = (await testHost.compile("main.tsp")) as { B: Model }; + const foo = B.properties.get("foo")!.type; + strictEqual(foo.kind, "Model"); + const a = foo.properties.get("a")!; + const b = foo.properties.get("b")!; + strictEqual(a.type.kind, "Scalar"); + strictEqual(a.type.name, "string"); + strictEqual(b.type.kind, "Scalar"); + strictEqual(b.type.name, "string"); + }); + + it("with named and positional arguments", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + model A { a: T, b: U, c: V } + + @test model B { + foo: A + } + + @test model C { + foo: A + } + ` + ); + + const { B, C } = (await testHost.compile("main.tsp")) as { B: Model; C: Model }; + + for (const M of [B, C]) { + const foo = M.properties.get("foo")!.type; + strictEqual(foo.kind, "Model"); + const a = foo.properties.get("a")!; + const b = foo.properties.get("b")!; + const c = foo.properties.get("c")!; + strictEqual(a.type.kind, "Scalar"); + strictEqual(a.type.name, "boolean"); + strictEqual(b.type.kind, "Scalar"); + strictEqual(b.type.name, "int32"); + strictEqual(c.type.kind, "String"); + strictEqual(c.type.value, "bar"); + } + }); + + it("cannot specify name of nonexistent parameter", async () => { + const { pos, end, source } = extractSquiggles(` + model A { a: T } + + @test model B { + foo: A + } + `); + + testHost.addTypeSpecFile("main.tsp", source); + + const [{ B }, diagnostics] = (await testHost.compileAndDiagnose("main.tsp")) as [ + { B: Model }, + Diagnostic[], + ]; + + const foo = B.properties.get("foo")!.type; + strictEqual(foo.kind, "Model"); + const a = foo.properties.get("a")!; + strictEqual(a.type.kind, "Scalar"); + strictEqual(a.type.name, "string"); + + expectDiagnostics(diagnostics, { + code: "invalid-template-args", + message: "No parameter named 'U' exists in the target template.", + pos, + end, + }); + }); + + it("cannot specify argument twice", async () => { + const { pos, end, source } = extractSquiggles(` + model A { a: T } + + @test model B { + foo: A + } + `); + + testHost.addTypeSpecFile("main.tsp", source); + + const [{ B }, diagnostics] = (await testHost.compileAndDiagnose("main.tsp")) as [ + { B: Model }, + Diagnostic[], + ]; + + const foo = B.properties.get("foo")!.type; + strictEqual(foo.kind, "Model"); + const a = foo.properties.get("a")!; + strictEqual(a.type.kind, "Scalar"); + strictEqual(a.type.name, "string"); + + expectDiagnostics(diagnostics, { + code: "invalid-template-args", + message: "Cannot specify template argument 'T' again.", + pos, + end, + }); + }); + + it("cannot specify argument twice by name", async () => { + const { pos, end, source } = extractSquiggles(` + model A { a: T } + + @test model B { + foo: A + } + `); + + testHost.addTypeSpecFile("main.tsp", source); + + const [{ B }, diagnostics] = (await testHost.compileAndDiagnose("main.tsp")) as [ + { B: Model }, + Diagnostic[], + ]; + + const foo = B.properties.get("foo")!.type; + strictEqual(foo.kind, "Model"); + const a = foo.properties.get("a")!; + strictEqual(a.type.kind, "Scalar"); + strictEqual(a.type.name, "string"); + + expectDiagnostics(diagnostics, { + code: "invalid-template-args", + message: "Cannot specify template argument 'T' again.", + pos, + end, + }); + }); + + it("cannot specify positional argument after named argument", async () => { + const { pos, end, source } = extractSquiggles(` + model A { a: T, b: U, c: V } + + @test model B { + foo: ~~~A~~~ + } + `); + + testHost.addTypeSpecFile("main.tsp", source); + + const [{ B }, diagnostics] = (await testHost.compileAndDiagnose("main.tsp")) as [ + { B: Model }, + Diagnostic[], + ]; + + const foo = B.properties.get("foo")!.type; + strictEqual(foo.kind, "Model"); + const a = foo.properties.get("a")!; + const b = foo.properties.get("b")!; + const c = foo.properties.get("c")!; + strictEqual(a.type.kind, "Scalar"); + strictEqual(a.type.name, "boolean"); + strictEqual(b.type.kind, "Intrinsic"); + strictEqual(b.type.name, "unknown"); + strictEqual(c.type.kind, "String"); + strictEqual(c.type.value, "bar"); + + expectDiagnostics(diagnostics, [ + { + code: "invalid-template-args", + message: + "Positional template arguments cannot follow named arguments in the same argument list.", + pos: pos + 22, + end: end - 1, + }, + { + code: "invalid-template-args", + message: "Template argument 'U' is required and not specified.", + pos, + end, + }, + ]); + }); + + it("cannot specify positional argument after named argument with default omitted", async () => { + const { pos, end, source } = extractSquiggles(` + model A { a: T, b: U, c: V } + + @test model B { + foo: A + } + `); + + testHost.addTypeSpecFile("main.tsp", source); + + const [{ B }, diagnostics] = (await testHost.compileAndDiagnose("main.tsp")) as [ + { B: Model }, + Diagnostic[], + ]; + + const foo = B.properties.get("foo")!.type; + strictEqual(foo.kind, "Model"); + const a = foo.properties.get("a")!; + const b = foo.properties.get("b")!; + const c = foo.properties.get("c")!; + strictEqual(a.type.kind, "Scalar"); + strictEqual(a.type.name, "boolean"); + strictEqual(b.type.kind, "Scalar"); + strictEqual(b.type.name, "int32"); + strictEqual(c.type.kind, "String"); + strictEqual(c.type.value, "bar"); + + expectDiagnostics(diagnostics, { + code: "invalid-template-args", + message: + "Positional template arguments cannot follow named arguments in the same argument list.", + pos: pos, + end: end, + }); + }); + + it("cannot specify a typereference with args as a parameter name", async () => { + const { pos, end, source } = extractSquiggles(` + model A { a: T } + + @test model B { + foo: A<~~~T~~~ = string> + } + `); + + testHost.addTypeSpecFile("main.tsp", source); + + const diagnostics = await testHost.diagnose("main.tsp"); + + expectDiagnostics(diagnostics, { + code: "invalid-template-argument-name", + message: "Template parameter argument names must be valid, bare identifiers.", + pos, + end, + }); + }); + + it("template arguments are evaluated in the correct order", async () => { + const members: [Type, Type][] = []; + + testHost.addJsFile("effect.js", { + $effect: (_: DecoratorContext, target: Type, value: Type) => { + members.push([target, value]); + }, + }); + + testHost.addTypeSpecFile( + "main.tsp", + ` + import "./effect.js"; + + @effect(T) + model Dec { t: T } + + model A { a: T, b: U } + + @test model B { + bar: A, T = Dec> + } + ` + ); + + const { B } = (await testHost.compile("main.tsp")) as { B: Model }; + const bar = B.properties.get("bar")!.type; + strictEqual(bar.kind, "Model"); + const a = bar.properties.get("a")!; + const b = bar.properties.get("b")!; + strictEqual(a.type.kind, "Model"); + strictEqual(a.type.name, "Dec"); + strictEqual(b.type.kind, "Model"); + strictEqual(b.type.name, "Dec"); + + // Assert that the members are added (decorators executed) in _declaration_ order + // rather than in the order they appear in the template instantiation. + strictEqual(members.length, 2); + strictEqual(members[0][0], a.type); + strictEqual(members[0][1].kind, "Scalar"); + strictEqual(members[0][1].name, "int32"); + strictEqual(members[1][0], b.type); + strictEqual(members[1][1].kind, "Scalar"); + strictEqual(members[1][1].name, "string"); + }); + }); }); diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index 0bca93e85c..18d48a9423 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -2292,6 +2292,18 @@ alias Foo = Bar< alias Foo = Bar< "very long string that is overflowing the max column allowed", "very long string that is overflowing the max column allowed" +>;`, + }); + }); + + it("handles nested named template args", async () => { + await assertFormat({ + code: 'alias F=Foo,U=Foo>;', + expected: ` +alias F = Foo< + int32, + V = Foo, + U = Foo >;`, }); }); diff --git a/packages/compiler/test/server/colorization.test.ts b/packages/compiler/test/server/colorization.test.ts index a7a74d65a6..3d5eb0d9ed 100644 --- a/packages/compiler/test/server/colorization.test.ts +++ b/packages/compiler/test/server/colorization.test.ts @@ -755,6 +755,28 @@ function testColorization(description: string, tokenize: Tokenize) { }); }); + it("named template argument list", async () => { + const tokens = await tokenize("alias X = Foo;"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("X"), + Token.operators.assignment, + Token.identifiers.type("Foo"), + Token.punctuation.typeParameters.begin, + Token.identifiers.type("boolean"), + Token.punctuation.comma, + Token.identifiers.type("T"), + Token.operators.assignment, + Token.identifiers.type("string"), + Token.punctuation.comma, + Token.identifiers.type("U"), + Token.operators.assignment, + Token.identifiers.type("int32"), + Token.punctuation.typeParameters.end, + Token.punctuation.semicolon, + ]); + }); + describe("namespaces", () => { it("simple global namespace", async () => { const tokens = await tokenize("namespace Foo;"); diff --git a/packages/compiler/test/server/completion.test.ts b/packages/compiler/test/server/completion.test.ts index 4777d56ddd..1d4683eeba 100644 --- a/packages/compiler/test/server/completion.test.ts +++ b/packages/compiler/test/server/completion.test.ts @@ -596,6 +596,54 @@ describe("compiler: server: completion", () => { ]); }); + it("completes template parameter names in arguments", async () => { + const completions = await complete(` + model Template { + prop: Param; + } + + model M { + prop: Template; + } + `); + + check(completions, [ + { + label: "Param", + insertText: "Param = ", + kind: CompletionItemKind.Struct, + documentation: { + kind: MarkupKind.Markdown, + value: "(template parameter)\n```typespec\nParam\n```", + }, + }, + ]); + }); + + it("completes template parameter names in arguments with equals sign already in place", async () => { + const completions = await complete(` + model Template { + prop: Param; + } + + model M { + prop: Template; + } + `); + + check(completions, [ + { + label: "Param", + insertText: "Param", + kind: CompletionItemKind.Struct, + documentation: { + kind: MarkupKind.Markdown, + value: "(template parameter)\n```typespec\nParam\n```", + }, + }, + ]); + }); + it("completes sibling in namespace", async () => { const completions = await complete( `