diff --git a/.chronus/changes/partial-interface-2026-9-9-19-30-0.md b/.chronus/changes/partial-interface-2026-9-9-19-30-0.md new file mode 100644 index 00000000000..ae5bd726a9b --- /dev/null +++ b/.chronus/changes/partial-interface-2026-9-9-19-30-0.md @@ -0,0 +1,19 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add support for `partial` interfaces. A `partial interface` can be declared multiple times, including across different files, and every matching declaration must be marked `partial`. All operations, decorators, and `extends` clauses from each declaration are combined into a single interface. + +```typespec +// a.tsp +partial interface Widgets { + list(): void; +} + +// b.tsp +partial interface Widgets { + read(id: string): void; +} +``` diff --git a/packages/compiler/src/core/binder.ts b/packages/compiler/src/core/binder.ts index 68a74d5479f..4a93f390b56 100644 --- a/packages/compiler/src/core/binder.ts +++ b/packages/compiler/src/core/binder.ts @@ -2,6 +2,7 @@ import { mutate } from "../utils/misc.js"; import { compilerAssert } from "./diagnostics.js"; import { isCompilerFeatureEnabled } from "./features.js"; import { getLocationContext } from "./helpers/location-context.js"; +import { createDiagnostic } from "./messages.js"; import { visitChildren } from "./parser.js"; import type { Program } from "./program.js"; import type { @@ -637,6 +638,12 @@ export function createBinder(program: Program): Binder { ) { return; } + if ( + flags & SymbolFlags.Interface && + mergePartialInterfaceDeclarations(node as InterfaceStatementNode, scope) + ) { + return; + } const key = name ?? node.id.sv; const symbol = createSymbol(node, key, flags, scope.symbol); mutate(node).symbol = symbol; @@ -656,6 +663,12 @@ export function createBinder(program: Program): Binder { ) { return; } + if ( + flags & SymbolFlags.Interface && + mergePartialInterfaceDeclarations(node as InterfaceStatementNode, effectiveScope) + ) { + return; + } const key = name ?? node.id.sv; const symbol = createSymbol(node, key, flags, fileNamespace?.symbol); mutate(node).symbol = symbol; @@ -685,6 +698,48 @@ export function createBinder(program: Program): Binder { return symbol; } + /** + * Merge a `partial interface` declaration into the symbol of a previously bound + * declaration of the same name, provided every declaration sharing that name is + * marked `partial`. Once merged, the declarations share a single symbol so that + * their members (operations) end up in the same symbol table, and the checker can + * later combine all of the declarations into a single `Interface` type. + * + * @returns `true` if the node was merged into an existing declaration (in which case + * the caller should not create a new symbol for it), `false` otherwise. + */ + function mergePartialInterfaceDeclarations(node: InterfaceStatementNode, scope: ScopeNode) { + const isPartial = (node.modifierFlags & ModifierFlags.Partial) !== 0; + const existingBinding = scope.symbol.exports!.get(node.id.sv); + if (!existingBinding || !(existingBinding.flags & SymbolFlags.Interface)) { + // No prior declaration with this name: nothing to merge with yet. Even if this + // declaration is `partial`, it becomes the first declaration and is bound normally. + return false; + } + + const existingIsPartial = existingBinding.declarations.every( + (decl) => ((decl as InterfaceStatementNode).modifierFlags & ModifierFlags.Partial) !== 0, + ); + + if (!isPartial || !existingIsPartial) { + program.reportDiagnostic( + createDiagnostic({ + code: "partial-interface-mismatch", + format: { name: node.id.sv }, + target: node, + }), + ); + // Fall through to normal declaration handling, which will register this as a + // duplicate symbol and produce a `duplicate-symbol` diagnostic as well. + return false; + } + + // we have an existing binding, so just push this node to its declarations + mutate(existingBinding.declarations).push(node); + mutate(node).symbol = existingBinding; + return true; + } + function mergeNamespaceDeclarations(node: NamespaceStatementNode, scope: ScopeNode) { // we are declaring a namespace in either global scope, or a blockless namespace. const existingBinding = scope.symbol.exports!.get(node.id.sv); diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index dd1badc9c9b..17fcc9060b2 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -7397,13 +7397,17 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ctx: CheckContext, targetType: Type, node: Node & { decorators: readonly DecoratorExpressionNode[] }, + options: { includeAugmentDecorators?: boolean } = {}, ) { + const { includeAugmentDecorators = true } = options; const sym = isMemberNode(node) ? (getSymbolForMember(node) ?? node.symbol) : getMergedSymbol(node.symbol); const decorators: DecoratorApplication[] = []; - const augmentDecoratorNodes = resolver.getAugmentDecoratorsForSym(sym); + const augmentDecoratorNodes = includeAugmentDecorators + ? resolver.getAugmentDecoratorsForSym(sym) + : []; const decoratorNodes = [ ...augmentDecoratorNodes, // the first decorator will be executed at last, so augmented decorator should be placed at first. ...node.decorators, @@ -7416,19 +7420,23 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } // Doc comment should always be the first decorator in case an explicit @doc must override it. - const docComment = extractMainDoc(targetType); - if (docComment) { - decorators.unshift(createDocFromCommentDecorator("self", docComment)); - } - if (targetType.kind === "Operation") { - const returnTypesDocs = extractReturnsDocs(targetType); - if (returnTypesDocs.returns) { - decorators.unshift(createDocFromCommentDecorator("returns", returnTypesDocs.returns)); + // Like augment decorators, this is derived from `targetType.node` which is shared across all + // partial declarations, so only add it once to avoid duplicate applications. + if (includeAugmentDecorators) { + const docComment = extractMainDoc(targetType); + if (docComment) { + decorators.unshift(createDocFromCommentDecorator("self", docComment)); } - if (returnTypesDocs.errors) { - decorators.unshift(createDocFromCommentDecorator("errors", returnTypesDocs.errors)); + if (targetType.kind === "Operation") { + const returnTypesDocs = extractReturnsDocs(targetType); + if (returnTypesDocs.returns) { + decorators.unshift(createDocFromCommentDecorator("returns", returnTypesDocs.returns)); + } + if (returnTypesDocs.errors) { + decorators.unshift(createDocFromCommentDecorator("errors", returnTypesDocs.errors)); + } + } else if (targetType.kind === "ModelProperty") { } - } else if (targetType.kind === "ModelProperty") { } return decorators; } @@ -7783,7 +7791,8 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } function checkInterface(ctx: CheckContext, node: InterfaceStatementNode): Interface { - const links = getSymbolLinks(node.symbol); + const mergedSymbol = getMergedSymbol(node.symbol); + const links = getSymbolLinks(mergedSymbol); if (ctx.mapper === undefined && node.templateParameters.length > 0) { // This is a templated declaration and we are not instantiating it, so we need to update the flags. @@ -7794,15 +7803,42 @@ export function createChecker(program: Program, resolver: NameResolver): Checker // we're not instantiating this interface and we've already checked it return links.declaredType as Interface; } + + // All of the declarations that make up this interface. For a normal (non-partial) + // interface this is just `[node]`; for a `partial interface` this includes every + // declaration sharing the same symbol, potentially spread across multiple files. + const declarations = mergedSymbol.declarations as InterfaceStatementNode[]; + if (ctx.mapper === undefined) { - checkModifiers(program, node); + for (const declNode of declarations) { + checkModifiers(program, declNode); + if ( + declNode.modifierFlags & ModifierFlags.Partial && + declNode.templateParameters.length > 0 + ) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "partial-interface-template", + format: { name: declNode.id.sv }, + target: declNode, + }), + ); + } + } } checkTemplateDeclaration(ctx, node); + // Use the canonical (first-bound) declaration as the type's `.node` rather than + // whichever declaration happened to trigger this check. This keeps `interfaceType.node` + // stable and, critically, ensures `interfaceType.node!.symbol` is always the fully + // merged symbol (whose `.declarations` includes every partial declaration, same-file + // or cross-file) rather than a non-canonical per-file symbol for cross-file merges. + const canonicalNode = declarations[0]; + const interfaceType: Interface = createType({ kind: "Interface", decorators: [], - node, + node: canonicalNode, namespace: getParentNamespaceType(node), sourceInterfaces: [], operations: createRekeyableMap(), @@ -7811,44 +7847,59 @@ export function createChecker(program: Program, resolver: NameResolver): Checker linkType(ctx, links, interfaceType); - interfaceType.decorators = checkDecorators(ctx, interfaceType, node); - - const ownMembers = checkInterfaceMembers(ctx, node, interfaceType); + for (const [index, declNode] of declarations.entries()) { + // Augment decorators (`@@dec(Foo, ...)`) target the merged symbol shared by every + // partial declaration, so only resolve/apply them once (on the first declaration) + // to avoid re-running the same augment decorator once per partial declaration. + interfaceType.decorators = interfaceType.decorators.concat( + checkDecorators(ctx, interfaceType, declNode, { + includeAugmentDecorators: index === 0, + }), + ); + } - for (const extendsNode of node.extends) { - const extendsType = getTypeForNode(extendsNode, ctx); - if (extendsType.kind !== "Interface") { - reportCheckerDiagnostic( - createDiagnostic({ code: "extends-interface", target: extendsNode }), - ); - continue; - } + const ownMembers = checkInterfaceMembers(ctx, declarations, interfaceType); - for (const member of extendsType.operations.values()) { - if (interfaceType.operations.has(member.name)) { + for (const declNode of declarations) { + for (const extendsNode of declNode.extends) { + const extendsType = getTypeForNode(extendsNode, ctx); + if (extendsType.kind !== "Interface") { reportCheckerDiagnostic( - createDiagnostic({ - code: "extends-interface-duplicate", - format: { name: member.name }, - target: extendsNode, - }), + createDiagnostic({ code: "extends-interface", target: extendsNode }), ); + continue; } - const newMember = cloneTypeForSymbol(getMemberSymbol(node.symbol, member.name)!, member, { - interface: interfaceType, - }); - // Don't link it it is overritten - if (!ownMembers.has(member.name)) { - linkIndirectMember(ctx, node, newMember); - } + for (const member of extendsType.operations.values()) { + if (interfaceType.operations.has(member.name)) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "extends-interface-duplicate", + format: { name: member.name }, + target: extendsNode, + }), + ); + } - // Clone deprecation information - copyDeprecation(member, newMember); + const newMember = cloneTypeForSymbol( + getMemberSymbol(mergedSymbol, member.name)!, + member, + { + interface: interfaceType, + }, + ); + // Don't link it it is overritten + if (!ownMembers.has(member.name)) { + linkIndirectMember(ctx, declNode, newMember); + } - interfaceType.operations.set(newMember.name, newMember); + // Clone deprecation information + copyDeprecation(member, newMember); + + interfaceType.operations.set(newMember.name, newMember); + } + interfaceType.sourceInterfaces.push(extendsType); } - interfaceType.sourceInterfaces.push(extendsType); } for (const [key, value] of ownMembers) { @@ -7870,32 +7921,36 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkInterfaceMembers( ctx: CheckContext, - node: InterfaceStatementNode, + declarations: readonly InterfaceStatementNode[], interfaceType: Interface, ): Map { const ownMembers = new Map(); // Preregister each operation sym links instantiation to make sure there is no race condition when instantiating templated interface - for (const opNode of node.operations) { - const symbol = getSymbolForMember(opNode); - const links = symbol && getSymbolLinks(symbol); - if (links) { - links.instantiations = new TypeInstantiationMap(); + for (const declNode of declarations) { + for (const opNode of declNode.operations) { + const symbol = getSymbolForMember(opNode); + const links = symbol && getSymbolLinks(symbol); + if (links) { + links.instantiations = new TypeInstantiationMap(); + } } } - for (const opNode of node.operations) { - const opType = checkOperation(ctx, opNode, interfaceType); - if (ownMembers.has(opType.name)) { - reportCheckerDiagnostic( - createDiagnostic({ - code: "interface-duplicate", - format: { name: opType.name }, - target: opNode, - }), - ); - continue; + for (const declNode of declarations) { + for (const opNode of declNode.operations) { + const opType = checkOperation(ctx, opNode, interfaceType); + if (ownMembers.has(opType.name)) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "interface-duplicate", + format: { name: opType.name }, + target: opNode, + }), + ); + continue; + } + ownMembers.set(opType.name, opType); } - ownMembers.set(opType.name, opType); } return ownMembers; } @@ -8169,7 +8224,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return undefined; } const name = node.id.sv; - const parentSym = node.parent?.symbol; + const parentSym = node.parent?.symbol && getMergedSymbol(node.parent.symbol); return parentSym ? getMemberSymbol(parentSym, name) : undefined; } diff --git a/packages/compiler/src/core/decorator-utils.ts b/packages/compiler/src/core/decorator-utils.ts index 4c1042f8a1c..315932c5e65 100644 --- a/packages/compiler/src/core/decorator-utils.ts +++ b/packages/compiler/src/core/decorator-utils.ts @@ -10,6 +10,7 @@ import type { IntrinsicScalarName, Model, ModelProperty, + Node, Scalar, Type, } from "./types.js"; @@ -308,11 +309,22 @@ export function validateDecoratorUniqueOnNode( ) { compilerAssert("decorators" in type, "Type should have decorators"); + // A declaration can be split across multiple nodes when it is declared `partial` + // (currently only interfaces). In that case `type.node` is just the canonical + // declaration, but the same decorator applied once on each partial declaration + // should still be flagged as a duplicate, so check against every node that + // contributes to the underlying symbol rather than only `type.node`. + const ownerNodes: readonly Node[] = type.node?.symbol + ? type.node.symbol.declarations + : type.node + ? [type.node] + : []; + const sameDecorators = type.decorators.filter( (x) => x.decorator === decorator && x.node?.kind === SyntaxKind.DecoratorExpression && - x.node?.parent === type.node, + ownerNodes.includes(x.node?.parent as Node), ); if (sameDecorators.length > 1) { diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 9da4608067f..36868d3904b 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -558,6 +558,18 @@ const diagnostics = { default: paramMessage`Interface already has a member named ${"name"}`, }, }, + "partial-interface-mismatch": { + severity: "error", + messages: { + default: paramMessage`Interface '${"name"}' is declared multiple times but not all declarations are marked 'partial'. Add the 'partial' modifier to every declaration of '${"name"}'.`, + }, + }, + "partial-interface-template": { + severity: "error", + messages: { + default: paramMessage`Partial interface '${"name"}' cannot have template parameters.`, + }, + }, "union-duplicate": { severity: "error", messages: { diff --git a/packages/compiler/src/core/modifiers.ts b/packages/compiler/src/core/modifiers.ts index 0fac51a7e32..8b6ebdaaeb9 100644 --- a/packages/compiler/src/core/modifiers.ts +++ b/packages/compiler/src/core/modifiers.ts @@ -42,6 +42,11 @@ const NO_MODIFIERS: ModifierCompatibility = { required: ModifierFlags.None, }; +const INTERFACE_COMPATIBILITY: ModifierCompatibility = { + allowed: ModifierFlags.Internal | ModifierFlags.Partial, + required: ModifierFlags.None, +}; + /** * Declaration nodes whose modifiers can be checked. Includes the statement * declarations as well as the declaration-expression nodes (which never carry @@ -59,7 +64,7 @@ const SYNTAX_MODIFIERS: Readonly, + ) { + const targetBinding = target.get(key); + if (!targetBinding || !(targetBinding.flags & SymbolFlags.Interface)) { + target.set(key, sourceBinding); + return; + } + + const allDeclarationsArePartial = (sym: Sym) => + sym.declarations.every( + (decl) => ((decl as InterfaceStatementNode).modifierFlags & ModifierFlags.Partial) !== 0, + ); + + if (allDeclarationsArePartial(sourceBinding) && allDeclarationsArePartial(targetBinding)) { + mergedSymbols.set(sourceBinding, targetBinding); + mutate(targetBinding.declarations).push(...sourceBinding.declarations); + // Combine the operations declared in each partial declaration into a single + // member symbol table so member lookups (e.g. `Foo.op`) see every operation + // regardless of which file declared it. + const targetMembers = mutate(targetBinding.members!); + for (const [memberKey, memberSym] of sourceBinding.members!) { + targetMembers.set(memberKey, memberSym); + } + } else { + program.reportDiagnostic( + createDiagnostic({ + code: "partial-interface-mismatch", + format: { name: key }, + target: sourceBinding.declarations[0] ?? getSymNode(sourceBinding), + }), + ); + // this will set a duplicate error too + target.set(key, sourceBinding); + } + } + function setUsingsForFile(file: TypeSpecScriptNode) { const usedUsing = new Map>(); function isAlreadyAddedIn(sym: Sym, target: Sym) { diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index 988ac4a73e3..108aa68372c 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -85,6 +85,7 @@ import type { OperationStatementNode, ParenthesizedExpression, ParseOptions, + PartialKeywordNode, PositionDetail, ScalarConstructorNode, ScalarDeclarationExpressionNode, @@ -477,6 +478,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa case Token.ExternKeyword: case Token.InternalKeyword: case Token.AutoKeyword: + case Token.PartialKeyword: case Token.FnKeyword: case Token.DecKeyword: item = parseDeclaration(pos, decorators, docs, directives); @@ -549,6 +551,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa case Token.ExternKeyword: case Token.InternalKeyword: case Token.AutoKeyword: + case Token.PartialKeyword: case Token.FnKeyword: case Token.DecKeyword: item = parseDeclaration(pos, decorators, docs, directives); @@ -2004,6 +2007,15 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } + function parsePartialKeyword(): PartialKeywordNode { + const pos = tokenPos(); + parseExpected(Token.PartialKeyword); + return { + kind: SyntaxKind.PartialKeyword, + ...finishNode(pos), + }; + } + function parseVoidKeyword(): VoidKeywordNode { const pos = tokenPos(); parseExpected(Token.VoidKeyword); @@ -2334,6 +2346,8 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return parseInternalKeyword(); case Token.AutoKeyword: return parseAutoKeyword(); + case Token.PartialKeyword: + return parsePartialKeyword(); default: return undefined; } @@ -3498,6 +3512,7 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined case SyntaxKind.ExternKeyword: case SyntaxKind.InternalKeyword: case SyntaxKind.AutoKeyword: + case SyntaxKind.PartialKeyword: case SyntaxKind.UnknownKeyword: case SyntaxKind.JsSourceFile: case SyntaxKind.JsNamespaceDeclaration: diff --git a/packages/compiler/src/core/scanner.ts b/packages/compiler/src/core/scanner.ts index 4f81a2fa382..c56faf4f23a 100644 --- a/packages/compiler/src/core/scanner.ts +++ b/packages/compiler/src/core/scanner.ts @@ -142,6 +142,7 @@ export enum Token { ExternKeyword = __StartModifierKeyword, InternalKeyword, AutoKeyword, + PartialKeyword, /** @internal */ __EndModifierKeyword, /////////////////////////////////////////////////////////////// @@ -197,7 +198,6 @@ export enum Token { ImplKeyword, SatisfiesKeyword, FlagKeyword, - PartialKeyword, PrivateKeyword, PublicKeyword, ProtectedKeyword, @@ -311,6 +311,7 @@ export const TokenDisplay = getTokenDisplayTable([ [Token.UnknownKeyword, "'unknown'"], [Token.ExternKeyword, "'extern'"], [Token.AutoKeyword, "'auto'"], + [Token.PartialKeyword, "'partial'"], // Reserved keywords [Token.StatemachineKeyword, "'statemachine'"], @@ -343,7 +344,6 @@ export const TokenDisplay = getTokenDisplayTable([ [Token.ImplKeyword, "'impl'"], [Token.SatisfiesKeyword, "'satisfies'"], [Token.FlagKeyword, "'flag'"], - [Token.PartialKeyword, "'partial'"], [Token.PrivateKeyword, "'private'"], [Token.PublicKeyword, "'public'"], [Token.ProtectedKeyword, "'protected'"], @@ -385,6 +385,7 @@ export const Keywords: ReadonlyMap = new Map([ ["extern", Token.ExternKeyword], ["auto", Token.AutoKeyword], ["internal", Token.InternalKeyword], + ["partial", Token.PartialKeyword], // Reserved keywords ["statemachine", Token.StatemachineKeyword], @@ -417,7 +418,6 @@ export const Keywords: ReadonlyMap = new Map([ ["impl", Token.ImplKeyword], ["satisfies", Token.SatisfiesKeyword], ["flag", Token.FlagKeyword], - ["partial", Token.PartialKeyword], ["private", Token.PrivateKeyword], ["public", Token.PublicKeyword], ["protected", Token.ProtectedKeyword], @@ -455,7 +455,6 @@ export const ReservedKeywords: ReadonlyMap = new Map([ ["impl", Token.ImplKeyword], ["satisfies", Token.SatisfiesKeyword], ["flag", Token.FlagKeyword], - ["partial", Token.PartialKeyword], ["private", Token.PrivateKeyword], ["public", Token.PublicKeyword], ["protected", Token.ProtectedKeyword], diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 47cf7c05d62..b9a0bfd8105 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -1251,6 +1251,7 @@ export enum SyntaxKind { InternalKeyword, AutoKeyword, FunctionTypeExpression, + PartialKeyword, ModelDeclarationExpression, ScalarDeclarationExpression, UnionDeclarationExpression, @@ -1936,6 +1937,18 @@ export interface AutoKeywordNode extends BaseNode { readonly kind: SyntaxKind.AutoKeyword; } +/** + * The `partial` modifier keyword. + * + * Marks a declaration (currently only interfaces) as being one of possibly + * several declarations that will be combined into a single declaration once + * the program is fully bound. Every declaration sharing the same name must + * be marked `partial`. + */ +export interface PartialKeywordNode extends BaseNode { + readonly kind: SyntaxKind.PartialKeyword; +} + export interface VoidKeywordNode extends BaseNode { readonly kind: SyntaxKind.VoidKeyword; } @@ -1993,11 +2006,13 @@ export const enum ModifierFlags { Extern = 1 << 1, Internal = 1 << 2, Auto = 1 << 3, + Partial = 1 << 4, - All = Extern | Internal | Auto, + All = Extern | Internal | Auto | Partial, } -export type Modifier = ExternKeywordNode | InternalKeywordNode | AutoKeywordNode; +export type Modifier = + ExternKeywordNode | InternalKeywordNode | AutoKeywordNode | PartialKeywordNode; /** * Represent a decorator declaration diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index dee4b222902..8e8d35b2553 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -330,6 +330,8 @@ export function printNode( return "internal"; case SyntaxKind.AutoKeyword: return "auto"; + case SyntaxKind.PartialKeyword: + return "partial"; case SyntaxKind.VoidKeyword: return "void"; case SyntaxKind.NeverKeyword: diff --git a/packages/compiler/src/server/completion.ts b/packages/compiler/src/server/completion.ts index 3b349ea7a0f..0fda2873d7b 100644 --- a/packages/compiler/src/server/completion.ts +++ b/packages/compiler/src/server/completion.ts @@ -250,6 +250,7 @@ const keywords = [ // Modifiers ["extern", { root: true, namespace: true }], ["internal", { root: true, namespace: true }], + ["partial", { root: true, namespace: true }], // Scalars ["init", { scalarBody: true }], diff --git a/packages/compiler/test/checker/interface.test.ts b/packages/compiler/test/checker/interface.test.ts index 7f051bcc2d7..bd1d15c0f3c 100644 --- a/packages/compiler/test/checker/interface.test.ts +++ b/packages/compiler/test/checker/interface.test.ts @@ -1,9 +1,10 @@ import { deepStrictEqual, notStrictEqual, ok, strictEqual } from "assert"; import { describe, expect, it, vi } from "vitest"; +import { validateDecoratorUniqueOnNode } from "../../src/core/decorator-utils.js"; import { isTemplateDeclaration } from "../../src/core/type-utils.js"; import type { Interface, Model, Type } from "../../src/core/types.js"; import { getDoc } from "../../src/index.js"; -import { expectDiagnostics, mockFile, t } from "../../src/testing/index.js"; +import { expectDiagnosticEmpty, expectDiagnostics, mockFile, t } from "../../src/testing/index.js"; import { Tester } from "../tester.js"; it("works", async () => { @@ -445,3 +446,398 @@ it("can decorate extended operations independently", async () => { strictEqual(getDoc(program, Extending.operations.get("one")!), "override for spread"); strictEqual(getDoc(program, Base.operations.get("one")!), "base doc"); }); + +describe("partial interfaces", () => { + it("combines operations from multiple partial declarations in the same file", async () => { + const { Foo } = await Tester.compile(t.code` + partial interface ${t.interface("Foo")} { + a(): void; + } + + partial interface Foo { + b(): void; + } + `); + deepStrictEqual([...Foo.operations.keys()].sort(), ["a", "b"]); + }); + + it("combines operations from multiple partial declarations across files", async () => { + const [{ Foo }, diagnostics] = await Tester.files({ + "other.tsp": ` + partial interface Foo { + b(): void; + } + `, + }).compileAndDiagnose(t.code` + import "./other.tsp"; + + partial interface ${t.interface("Foo")} { + a(): void; + } + `); + expectDiagnosticEmpty(diagnostics); + deepStrictEqual([...Foo.operations.keys()].sort(), ["a", "b"]); + }); + + it("reports duplicate-decorator for @doc repeated across partial declarations, consistent with a single declaration", async () => { + // @doc self-validates uniqueness via validateDecoratorUniqueOnNode. Since each partial + // declaration is conceptually part of the same interface declaration, repeating @doc + // across partial declarations should be flagged just like repeating it within a single + // (non-partial) declaration is. + const [{ Foo, program }, diagnostics] = await Tester.compileAndDiagnose(t.code` + @doc("first") + partial interface ${t.interface("Foo")} { + a(): void; + } + + @doc("second") + partial interface Foo { + b(): void; + } + `); + expectDiagnostics(diagnostics, [ + { code: "duplicate-decorator" }, + { code: "duplicate-decorator" }, + ]); + strictEqual(getDoc(program, Foo), "second"); + }); + + it("applies distinct decorators contributed by different partial declarations", async () => { + const tracked: unknown[] = []; + const { Foo } = await Tester.files({ + "test.js": mockFile.js({ + $mark(_p: any, _target: Interface, label: { value: string }) { + tracked.push(label.value); + }, + }), + }).import("./test.js").compile(t.code` + @mark("from-a") + partial interface ${t.interface("Foo")} { + a(): void; + } + + @mark("from-b") + partial interface Foo { + b(): void; + } + `); + // Each partial declaration's own inline decorator is independent (not deduplicated, + // unlike augment decorators/doc comments which target the shared merged symbol) and + // should be applied exactly once per occurrence, regardless of declaration order. + deepStrictEqual((tracked as string[]).sort(), ["from-a", "from-b"]); + strictEqual(Foo.decorators.length, 2); + }); + + it("reports duplicate-decorator when a self-validating unique decorator is repeated across partial declarations", async () => { + function $unique(context: any, target: Interface) { + validateDecoratorUniqueOnNode(context, target, $unique); + } + const diagnostics = await Tester.files({ + "test.js": mockFile.js({ $unique }), + }).import("./test.js").diagnose(` + @unique + partial interface Foo { + a(): void; + } + + @unique + partial interface Foo { + b(): void; + } + `); + // Decorators like @service opt into this check via validateDecoratorUniqueOnNode to + // self-report a duplicate-decorator diagnostic. Applying the same decorator once on + // each of two partial declarations of the same interface should be caught just like + // applying it twice on a single non-partial declaration is. + expectDiagnostics(diagnostics, [ + { code: "duplicate-decorator" }, + { code: "duplicate-decorator" }, + ]); + }); + + it("applies an augment decorator targeting a partial interface exactly once", async () => { + const calls: unknown[] = []; + const { Foo } = await Tester.files({ + "test.js": mockFile.js({ + $track(_p: any, target: Interface) { + calls.push(target); + }, + }), + }).import("./test.js").compile(t.code` + partial interface ${t.interface("Foo")} { + a(): void; + } + + partial interface Foo { + b(): void; + } + + partial interface Foo { + c(): void; + } + + @@track(Foo); + `); + strictEqual(calls.length, 1, "augment decorator should only be applied once"); + strictEqual(calls[0], Foo); + }); + + it("does not duplicate the doc-comment-derived decorator across partial declarations", async () => { + const { Foo, program } = await Tester.compile(t.code` + /** shared doc */ + partial interface ${t.interface("Foo")} { + a(): void; + } + + partial interface Foo { + b(): void; + } + + partial interface Foo { + c(): void; + } + `); + strictEqual(getDoc(program, Foo), "shared doc"); + strictEqual( + Foo.decorators.length, + 1, + "doc decorator derived from doc comment should only be applied once", + ); + }); + + it("applies an augment decorator targeting a partial interface exactly once across files", async () => { + const calls: unknown[] = []; + const [{ Foo }, diagnostics] = await Tester.files({ + "test.js": mockFile.js({ + $track(_p: any, target: Interface) { + calls.push(target); + }, + }), + "other.tsp": ` + import "./test.js"; + partial interface Foo { + b(): void; + } + `, + }).import("./test.js").compileAndDiagnose(t.code` + import "./other.tsp"; + + partial interface ${t.interface("Foo")} { + a(): void; + } + + @@track(Foo); + `); + expectDiagnosticEmpty(diagnostics); + strictEqual(calls.length, 1, "augment decorator should only be applied once"); + strictEqual(calls[0], Foo); + }); + + it("combines extends from multiple partial declarations", async () => { + const { Foo } = await Tester.compile(t.code` + interface Base { + base(): void; + } + + partial interface ${t.interface("Foo")} extends Base { + a(): void; + } + + partial interface Foo { + b(): void; + } + `); + deepStrictEqual([...Foo.operations.keys()].sort(), ["a", "b", "base"]); + }); + + it("emits diagnostic if operation names conflict across partial declarations", async () => { + const diagnostics = await Tester.diagnose(` + partial interface Foo { + a(): void; + } + + partial interface Foo { + a(): int32; + } + `); + + expectDiagnostics(diagnostics, { + code: "interface-duplicate", + message: "Interface already has a member named a", + }); + }); + + it("emits diagnostic if only some declarations are marked partial", async () => { + const diagnostics = await Tester.diagnose(` + partial interface Foo { + a(): void; + } + + interface Foo { + b(): void; + } + `); + + expectDiagnostics(diagnostics, [ + { + code: "partial-interface-mismatch", + message: + "Interface 'Foo' is declared multiple times but not all declarations are marked 'partial'. Add the 'partial' modifier to every declaration of 'Foo'.", + }, + { code: "duplicate-symbol" }, + { code: "duplicate-symbol" }, + ]); + }); + + it("emits diagnostic if only some declarations are marked partial across files", async () => { + const [, diagnostics] = await Tester.files({ + "other.tsp": ` + interface Foo { + b(): void; + } + `, + }).compileAndDiagnose(` + import "./other.tsp"; + + partial interface Foo { + a(): void; + } + `); + + expectDiagnostics(diagnostics, [ + { + code: "partial-interface-mismatch", + message: + "Interface 'Foo' is declared multiple times but not all declarations are marked 'partial'. Add the 'partial' modifier to every declaration of 'Foo'.", + }, + { code: "duplicate-symbol" }, + { code: "duplicate-symbol" }, + ]); + }); + + it("emits diagnostic for a partial interface with template parameters", async () => { + const diagnostics = await Tester.diagnose(` + partial interface Foo { + a(): T; + } + `); + + expectDiagnostics(diagnostics, { + code: "partial-interface-template", + message: "Partial interface 'Foo' cannot have template parameters.", + }); + }); + + it("allows a single partial interface declaration on its own", async () => { + const { Foo } = await Tester.compile(t.code` + partial interface ${t.interface("Foo")} { + a(): void; + } + `); + deepStrictEqual([...Foo.operations.keys()], ["a"]); + }); + + it("does not allow 'partial' on other declaration kinds", async () => { + const diagnostics = await Tester.diagnose(`partial model Foo {}`); + expectDiagnostics(diagnostics, [ + { + code: "invalid-modifier", + message: "Modifier 'partial' cannot be used on declarations of type 'model'.", + }, + ]); + }); + + it("does not allow 'partial' on a decorator declaration", async () => { + // `ModifierFlags.All` (used as the allowed set for decorator declarations) includes + // `ModifierFlags.Partial`, but `partial` is only meaningful for interfaces. + const diagnostics = await Tester.diagnose(`partial extern dec foo(target: unknown);`); + expectDiagnostics(diagnostics, [ + { + code: "invalid-modifier", + message: "Modifier 'partial' cannot be used on declarations of type 'dec'.", + }, + // Unrelated to `partial`: an `extern dec` with no JS implementation still errors. + { code: "missing-implementation" }, + ]); + }); + + it("combines extends across partial declarations even when 'extends' is only on a later declaration", async () => { + // Regression test: `bindInterfaceMembers` used to only process the `extends` clause + // of whichever single partial declaration happened to trigger member binding first, + // silently dropping inherited members contributed by any other partial declaration. + const { program, Foo } = await Tester.compile(t.code` + interface Base { + base(): void; + } + + partial interface ${t.interface("Foo")} { + a(): void; + } + + partial interface Foo extends Base { + b(): void; + } + + alias T = Foo.base; + `); + expectDiagnosticEmpty(program.diagnostics); + deepStrictEqual([...Foo.operations.keys()].sort(), ["a", "b", "base"]); + }); + + it("combines extends across partial declarations split across files, even when 'extends' is only on a non-canonical declaration", async () => { + const { Foo, program } = await Tester.files({ + "base.tsp": ` + interface Base { + base(): void; + } + `, + "other.tsp": ` + import "./base.tsp"; + partial interface Foo extends Base { + b(): void; + } + `, + }).compile(t.code` + import "./other.tsp"; + + partial interface ${t.interface("Foo")} { + a(): void; + } + + alias T = Foo.base; + `); + expectDiagnosticEmpty(program.diagnostics); + deepStrictEqual([...Foo.operations.keys()].sort(), ["a", "b", "base"]); + }); + + it("reports duplicate-decorator for a self-validating unique decorator applied once in each of two files", async () => { + // Regression test: `validateDecoratorUniqueOnNode` compared against `type.node`, but + // for cross-file partial declarations `type.node` could be a non-canonical declaration + // whose `.symbol` was never updated to the fully-merged symbol, so the check would miss + // decorators split one-per-file. + function $unique(context: any, target: Interface) { + validateDecoratorUniqueOnNode(context, target, $unique); + } + const [, diagnostics] = await Tester.files({ + "test.js": mockFile.js({ $unique }), + "other.tsp": ` + import "./test.js"; + @unique + partial interface Foo { + b(): void; + } + `, + }).import("./test.js").compileAndDiagnose(` + import "./other.tsp"; + + @unique + partial interface Foo { + a(): void; + } + `); + expectDiagnostics(diagnostics, [ + { code: "duplicate-decorator" }, + { code: "duplicate-decorator" }, + ]); + }); +}); diff --git a/packages/compiler/test/formatter/scenarios/inputs/interface.tsp b/packages/compiler/test/formatter/scenarios/inputs/interface.tsp index 9232172cc48..64a9aefe669 100644 --- a/packages/compiler/test/formatter/scenarios/inputs/interface.tsp +++ b/packages/compiler/test/formatter/scenarios/inputs/interface.tsp @@ -28,3 +28,7 @@ interface WithMultipleOperation { keepSeperation2(): string; } + +partial interface Partial1 { + a(): string; +} diff --git a/packages/compiler/test/formatter/scenarios/outputs/interface.tsp b/packages/compiler/test/formatter/scenarios/outputs/interface.tsp index 46737d8dbfe..c39700eb69e 100644 --- a/packages/compiler/test/formatter/scenarios/outputs/interface.tsp +++ b/packages/compiler/test/formatter/scenarios/outputs/interface.tsp @@ -19,3 +19,7 @@ interface WithMultipleOperation { keepSeperation2(): string; } + +partial interface Partial1 { + a(): string; +} diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index d442d2f4b35..0839a8d8532 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -49,7 +49,7 @@ describe("future reserved keywords", () => { }); describe("modifier keywords as identifiers", () => { - const modifiers = ["internal", "extern"]; + const modifiers = ["internal", "extern", "partial"]; // Allowed as members parseEach(modifiers.map((x) => `model Foo { ${x}: string }`)); @@ -230,6 +230,8 @@ describe("interface statements", () => { "interface Foo { foo(): int32; }", "interface Foo { foo(): int32; bar(): int32; }", "interface Foo { op foo(): int32; op bar(): int32; baz(): int32; }", + "partial interface Foo { }", + "partial interface Foo { foo(): int32; }", ]); parseErrorEach([ diff --git a/packages/compiler/test/scanner.test.ts b/packages/compiler/test/scanner.test.ts index be15bd6f05e..f19ab9d6315 100644 --- a/packages/compiler/test/scanner.test.ts +++ b/packages/compiler/test/scanner.test.ts @@ -398,6 +398,7 @@ it("provides friendly token display and classification", () => { Token.ExternKeyword, Token.InternalKeyword, Token.AutoKeyword, + Token.PartialKeyword, Token.ValueOfKeyword, Token.TypeOfKeyword, // `fn` can be either a statement or the start of an expr depending on context. diff --git a/packages/compiler/test/server/completion.test.ts b/packages/compiler/test/server/completion.test.ts index 99968bc08fb..06a90a0b648 100644 --- a/packages/compiler/test/server/completion.test.ts +++ b/packages/compiler/test/server/completion.test.ts @@ -17,6 +17,7 @@ describe("complete statement keywords", () => { ["op", true], ["extern", true], ["internal", true], + ["partial", true], ["dec", true], ["fn", true], ["alias", true], diff --git a/packages/monarch/src/typespec-monarch.ts b/packages/monarch/src/typespec-monarch.ts index 60de98e468e..05a7b7ae56b 100644 --- a/packages/monarch/src/typespec-monarch.ts +++ b/packages/monarch/src/typespec-monarch.ts @@ -30,6 +30,7 @@ const keywords = [ "dec", "extern", "internal", + "partial", "fn", ]; const namedLiterals = ["true", "false", "null", "unknown", "never"]; diff --git a/packages/monarch/test/typespec-monarch.test.ts b/packages/monarch/test/typespec-monarch.test.ts index 8127e5ac72b..d24231c6b42 100644 --- a/packages/monarch/test/typespec-monarch.test.ts +++ b/packages/monarch/test/typespec-monarch.test.ts @@ -148,6 +148,16 @@ it( tokenizeTo([Token.keyword("interface"), Token.identifier("Foo"), Token.default("{}")]), ); +it( + "partial interface Foo {}", + tokenizeTo([ + Token.keyword("partial"), + Token.keyword("interface"), + Token.identifier("Foo"), + Token.default("{}"), + ]), +); + it( "union Foo {}", tokenizeTo([Token.keyword("union"), Token.identifier("Foo"), Token.default("{}")]),