From 1121061753d542d7753b8a41024f7bf99fcf657f Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 3 Mar 2026 13:23:33 -0500 Subject: [PATCH] Enable feature via directive --- packages/compiler/src/core/checker.ts | 3 + packages/compiler/src/core/features.ts | 59 ++++++++++++ packages/compiler/src/core/messages.ts | 21 +++++ packages/compiler/src/core/parser.ts | 70 +++++++++++++- packages/compiler/src/core/program.ts | 94 +++++++++++++++++++ packages/compiler/src/core/types.ts | 13 ++- packages/compiler/src/index.ts | 2 + packages/compiler/src/server/completion.ts | 2 +- packages/compiler/test/core/features.test.ts | 89 ++++++++++++++++++ packages/compiler/test/parser.test.ts | 51 ++++++++++ .../compiler/test/server/completion.test.ts | 8 ++ 11 files changed, 405 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/src/core/features.ts create mode 100644 packages/compiler/test/core/features.test.ts diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 0d46a8aa6f9..3fbd9d1ebc3 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -1075,6 +1075,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return checkAugmentDecorator(ctx, node); case SyntaxKind.UsingStatement: return checkUsings(ctx, node); + case SyntaxKind.DirectiveExpression: + // #enable/#disable directives are handled during program loading, nothing to check here + return voidType; default: return errorType; } diff --git a/packages/compiler/src/core/features.ts b/packages/compiler/src/core/features.ts new file mode 100644 index 00000000000..5707319067b --- /dev/null +++ b/packages/compiler/src/core/features.ts @@ -0,0 +1,59 @@ +/** + * Feature registry for TypeSpec language features that can be enabled/disabled + * via `#enable` / `#disable` directives. + * + * Features go through a lifecycle: + * 1. "opt-in" — Available but off by default. Use `#enable "featureName"` to activate. + * 2. "default" — On by default. Use `#disable "featureName"` to temporarily opt-out. + * 3. "mandatory" — Always on. `#enable` is a no-op, `#disable` produces a diagnostic. + */ + +export type FeatureStatus = "opt-in" | "default" | "mandatory"; + +export interface FeatureDefinition { + /** The feature name used in `#enable "name"` directives. */ + readonly name: string; + /** Human-readable description of the feature. */ + readonly description: string; + /** Current lifecycle status of the feature. */ + readonly status: FeatureStatus; + /** Compiler version that introduced the feature. */ + readonly addedIn: string; + /** Compiler version where it became default (undefined if still opt-in). */ + readonly defaultIn?: string; + /** Compiler version where disable was removed (undefined if not yet mandatory). */ + readonly mandatoryIn?: string; +} + +/** + * Registry of all known TypeSpec language features. + * Add new features here as they are introduced. + */ +const featureDefinitions: readonly FeatureDefinition[] = [ + // Placeholder feature for testing the feature system + // { + // name: "example-feature", + // description: "An example feature for testing the feature opt-in system.", + // status: "opt-in", + // addedIn: "0.65.0", + // }, +]; + +const featureMap = new Map( + featureDefinitions.map((f) => [f.name, f]), +); + +/** Get the definition for a feature by name, or undefined if not found. */ +export function getFeatureDefinition(name: string): FeatureDefinition | undefined { + return featureMap.get(name); +} + +/** Get all known feature definitions. */ +export function getAllFeatureDefinitions(): readonly FeatureDefinition[] { + return featureDefinitions; +} + +/** Check whether a feature name is known to the compiler. */ +export function isKnownFeature(name: string): boolean { + return featureMap.has(name); +} diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 71e73369579..68daabf4543 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -1090,6 +1090,27 @@ const diagnostics = { }, }, // #endregion CLI + + // #region Features + "unknown-feature": { + severity: "error", + messages: { + default: paramMessage`Unknown feature '${"feature"}'.`, + }, + }, + "feature-conflict": { + severity: "error", + messages: { + default: paramMessage`Feature '${"feature"}' is both enabled and disabled.`, + }, + }, + "feature-mandatory": { + severity: "warning", + messages: { + default: paramMessage`Feature '${"feature"}' is mandatory and cannot be disabled.`, + }, + }, + // #endregion Features } as const; export type CompilerDiagnostics = TypeOfDiagnostics; diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index 63ed0076f13..5ac64fcbc6b 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -430,7 +430,33 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa let seenUsing = false; while (token() !== Token.EndOfFile) { const { pos, docs, directives, decorators } = parseAnnotations(); + + // Extract #enable/#disable directives as standalone statements + const featureDirectives: DirectiveExpressionNode[] = []; + const attachDirectives: DirectiveExpressionNode[] = []; + for (const d of directives) { + if (d.target.sv === "enable" || d.target.sv === "disable") { + featureDirectives.push(d); + } else { + attachDirectives.push(d); + } + } + for (const fd of featureDirectives) { + stmts.push(fd); + } + const tok = token(); + + // If only feature directives remain and nothing else follows, continue + if ( + tok === Token.EndOfFile && + attachDirectives.length === 0 && + decorators.length === 0 && + docs.length === 0 + ) { + break; + } + let item: Statement; switch (tok) { case Token.AtAt: @@ -463,7 +489,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa case Token.InternalKeyword: case Token.FnKeyword: case Token.DecKeyword: - item = parseDeclaration(pos, decorators, docs, directives); + item = parseDeclaration(pos, decorators, docs, attachDirectives); break; default: item = parseInvalidStatement(pos, decorators); @@ -471,7 +497,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa } if (tok !== Token.NamespaceKeyword) { - mutate(item).directives = directives; + mutate(item).directives = attachDirectives; mutate(item).docs = docs; } @@ -504,8 +530,37 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa while (token() !== Token.CloseBrace) { const { pos, docs, directives, decorators } = parseAnnotations(); + + // Extract #enable/#disable directives as standalone statements + const featureDirectives: DirectiveExpressionNode[] = []; + const attachDirectives: DirectiveExpressionNode[] = []; + for (const d of directives) { + if (d.target.sv === "enable" || d.target.sv === "disable") { + featureDirectives.push(d); + } else { + attachDirectives.push(d); + } + } + for (const fd of featureDirectives) { + stmts.push(fd); + } + const tok = token(); + // If only feature directives remain and nothing else follows, continue + if ( + (tok === Token.CloseBrace || tok === Token.EndOfFile) && + attachDirectives.length === 0 && + decorators.length === 0 && + docs.length === 0 + ) { + if (tok === Token.EndOfFile) { + parseExpected(Token.CloseBrace); + return stmts; + } + break; + } + let item: Statement; switch (tok) { case Token.AtAt: @@ -534,7 +589,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa case Token.InternalKeyword: case Token.FnKeyword: case Token.DecKeyword: - item = parseDeclaration(pos, decorators, docs, directives); + item = parseDeclaration(pos, decorators, docs, attachDirectives); break; case Token.EndOfFile: parseExpected(Token.CloseBrace); @@ -552,7 +607,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa error({ code: "blockless-namespace-first", messageId: "topLevel", target: item }); } - mutate(item).directives = directives; + mutate(item).directives = attachDirectives; if (tok !== Token.NamespaceKeyword) { mutate(item).docs = docs; } @@ -1608,7 +1663,12 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa parseExpected(Token.Hash); const target = parseIdentifier(); - if (target.sv !== "suppress" && target.sv !== "deprecated") { + if ( + target.sv !== "suppress" && + target.sv !== "deprecated" && + target.sv !== "enable" && + target.sv !== "disable" + ) { error({ code: "unknown-directive", format: { id: target.sv }, diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 19699c1d779..35852a91a1e 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -14,6 +14,7 @@ import { createSuppressCodeFix } from "./compiler-code-fixes/suppress.codefix.js import { compilerAssert } from "./diagnostics.js"; import { getEmittedFilesForProgram } from "./emitter-utils.js"; import { resolveTypeSpecEntrypoint } from "./entrypoint-resolution.js"; +import { getAllFeatureDefinitions, getFeatureDefinition, isKnownFeature } from "./features.js"; import { ExternalError } from "./external-error.js"; import { getLibraryUrlsLoaded } from "./library.js"; import { @@ -129,6 +130,9 @@ export interface Program { /** Return location context of the given source file. */ getSourceFileLocationContext(sourceFile: SourceFile): LocationContext; + /** Check if a language feature is enabled for this project. */ + isFeatureEnabled(featureName: string): boolean; + /** * Project root. If a tsconfig was found/specified this is the directory for the tsconfig.json. Otherwise directory where the entrypoint is located. */ @@ -218,6 +222,7 @@ async function createProgram( const emitters: EmitterRef[] = []; const requireImports = new Map(); const complexityStats: ComplexityStats = {} as any; + const enabledFeatures = new Set(); let sourceResolution: SourceResolution; let error = false; let continueToNextStage = true; @@ -261,6 +266,7 @@ async function createProgram( /** @internal */ resolveTypeOrValueReference, getSourceFileLocationContext, + isFeatureEnabled: (name) => enabledFeatures.has(name), projectRoot: getDirectoryPath(options.config ?? resolvedMain ?? ""), }; @@ -279,6 +285,8 @@ async function createProgram( runtimeStats.loader = await perf.timeAsync(() => loadSources(resolvedMain)); + resolveFeatures(); + const emit = options.noEmit ? [] : (options.emit ?? []); const emitterOptions = options.options; @@ -471,6 +479,92 @@ async function createProgram( return locationContext; } + /** + * Collect #enable/#disable directives from project source files and resolve the enabled feature set. + */ + function resolveFeatures() { + const enables = new Map(); + const disables = new Map(); + + for (const [_, sourceFile] of program.sourceFiles) { + const locationContext = sourceResolution.locationContexts.get(sourceFile.file); + if (locationContext?.type !== "project") { + continue; + } + + for (const stmt of sourceFile.statements) { + if (stmt.kind !== SyntaxKind.DirectiveExpression) continue; + const directiveName = stmt.target.sv; + if (directiveName !== "enable" && directiveName !== "disable") continue; + + const featureArg = stmt.arguments[0]; + if (!featureArg || featureArg.kind !== SyntaxKind.StringLiteral) continue; + const featureName = featureArg.value; + + if (!isKnownFeature(featureName)) { + reportDiagnostic( + createDiagnostic({ + code: "unknown-feature", + format: { feature: featureName }, + target: featureArg, + }), + ); + continue; + } + + if (directiveName === "enable") { + enables.set(featureName, stmt); + } else { + disables.set(featureName, stmt); + } + } + } + + // Check for conflicts (both enable and disable for same feature) + for (const [name, node] of enables) { + if (disables.has(name)) { + reportDiagnostic( + createDiagnostic({ + code: "feature-conflict", + format: { feature: name }, + target: node, + }), + ); + } + } + + // Resolve effective feature set + for (const [name] of enables) { + if (!disables.has(name)) { + enabledFeatures.add(name); + } + } + + // Features that are "default" status are on unless explicitly disabled + for (const def of getAllFeatureDefinitions()) { + if (def.status === "default" || def.status === "mandatory") { + if (!disables.has(def.name)) { + enabledFeatures.add(def.name); + } + } + } + + // Check for disable on mandatory features + for (const [name, node] of disables) { + const def = getFeatureDefinition(name); + if (def?.status === "mandatory") { + reportDiagnostic( + createDiagnostic({ + code: "feature-mandatory", + format: { feature: name }, + target: node, + }), + ); + enabledFeatures.add(name); + } + } + } + async function loadEmitters( basedir: string, emitterNameOrPaths: string[], diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 29560bd3609..197b47c66c3 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -1387,6 +1387,7 @@ export type Statement = | AugmentDecoratorStatementNode | ConstStatementNode | CallExpressionNode + | DirectiveExpressionNode | EmptyStatementNode | InvalidStatementNode; @@ -2182,7 +2183,7 @@ export interface DirectiveBase { node: DirectiveExpressionNode; } -export type Directive = SuppressDirective | DeprecatedDirective; +export type Directive = SuppressDirective | DeprecatedDirective | EnableDirective | DisableDirective; export interface SuppressDirective extends DirectiveBase { name: "suppress"; @@ -2195,6 +2196,16 @@ export interface DeprecatedDirective extends DirectiveBase { message: string; } +export interface EnableDirective extends DirectiveBase { + name: "enable"; + feature: string; +} + +export interface DisableDirective extends DirectiveBase { + name: "disable"; + feature: string; +} + export interface RmOptions { /** * If `true`, perform a recursive directory removal. In diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 1600e2aa043..e64f23614ae 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -344,10 +344,12 @@ export type { Directive, DirectiveArgument, DirectiveBase, + DisableDirective, DocContent, EmitContext, EmitOptionsFor, EmitterFunc, + EnableDirective, Entity, Enum, EnumMember, diff --git a/packages/compiler/src/server/completion.ts b/packages/compiler/src/server/completion.ts index 03a8278e679..be2d60c8c36 100644 --- a/packages/compiler/src/server/completion.ts +++ b/packages/compiler/src/server/completion.ts @@ -480,7 +480,7 @@ async function addIdentifierCompletion( } } -const directiveNames = ["suppress", "deprecated"]; +const directiveNames = ["suppress", "deprecated", "enable", "disable"]; function addDirectiveCompletion({ completions }: CompletionContext, node: IdentifierNode) { if (!(node.parent?.kind === SyntaxKind.DirectiveExpression && node.parent.target === node)) { return; diff --git a/packages/compiler/test/core/features.test.ts b/packages/compiler/test/core/features.test.ts new file mode 100644 index 00000000000..cf6bbaa8238 --- /dev/null +++ b/packages/compiler/test/core/features.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { parse } from "../../src/core/parser.js"; +import { SyntaxKind } from "../../src/core/types.js"; +import { + TestHost, + createTestHost, + expectDiagnosticEmpty, + expectDiagnostics, +} from "../../src/testing/index.js"; + +describe("compiler: features", () => { + describe("parser", () => { + it("#enable parses as standalone statement", () => { + const script = parse(`#enable "someFeature"\nmodel Foo {}`); + expect(script.statements.length).toBe(2); + expect(script.statements[0].kind).toBe(SyntaxKind.DirectiveExpression); + expect(script.statements[1].kind).toBe(SyntaxKind.ModelStatement); + }); + + it("#disable parses as standalone statement", () => { + const script = parse(`#disable "someFeature"\nmodel Foo {}`); + expect(script.statements.length).toBe(2); + expect(script.statements[0].kind).toBe(SyntaxKind.DirectiveExpression); + }); + + it("#enable is not attached to the following declaration", () => { + const script = parse(`#enable "someFeature"\nmodel Foo {}`); + const model = script.statements[1]; + expect(model.directives?.length ?? 0).toBe(0); + }); + + it("#enable alone in a file parses correctly", () => { + const script = parse(`#enable "someFeature"`); + expect(script.statements.length).toBe(1); + expect(script.statements[0].kind).toBe(SyntaxKind.DirectiveExpression); + }); + + it("multiple #enable directives parse as separate statements", () => { + const script = parse(`#enable "feat1"\n#enable "feat2"\nmodel Foo {}`); + expect(script.statements.length).toBe(3); + expect(script.statements[0].kind).toBe(SyntaxKind.DirectiveExpression); + expect(script.statements[1].kind).toBe(SyntaxKind.DirectiveExpression); + expect(script.statements[2].kind).toBe(SyntaxKind.ModelStatement); + }); + + it("#suppress still attaches to the next declaration", () => { + const script = parse(`#suppress "code"\nmodel Foo {}`); + expect(script.statements.length).toBe(1); + expect(script.statements[0].kind).toBe(SyntaxKind.ModelStatement); + expect(script.statements[0].directives?.length).toBe(1); + }); + + it("#enable and #suppress can coexist", () => { + const script = parse(`#enable "someFeature"\n#suppress "code"\nmodel Foo {}`); + expect(script.statements.length).toBe(2); + // First statement is the #enable directive + expect(script.statements[0].kind).toBe(SyntaxKind.DirectiveExpression); + // Second statement is the model with #suppress attached + expect(script.statements[1].kind).toBe(SyntaxKind.ModelStatement); + expect(script.statements[1].directives?.length).toBe(1); + }); + }); + + describe("feature resolution", () => { + let host: TestHost; + + beforeEach(async () => { + host = await createTestHost(); + }); + + it("unknown feature produces diagnostic", async () => { + host.addTypeSpecFile("main.tsp", `#enable "nonexistent-feature"`); + const diagnostics = await host.diagnose("main.tsp", { nostdlib: true }); + expectDiagnostics(diagnostics, { code: "unknown-feature" }); + }); + + it("unknown feature in #disable produces diagnostic", async () => { + host.addTypeSpecFile("main.tsp", `#disable "nonexistent-feature"`); + const diagnostics = await host.diagnose("main.tsp", { nostdlib: true }); + expectDiagnostics(diagnostics, { code: "unknown-feature" }); + }); + + it("compiles without errors when no features are used", async () => { + host.addTypeSpecFile("main.tsp", `model Foo {}`); + const diagnostics = await host.diagnose("main.tsp", { nostdlib: true }); + expectDiagnosticEmpty(diagnostics); + }); + }); +}); diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index e3d78e94607..c9b50b5d733 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -843,6 +843,57 @@ describe("compiler: parser", () => { ], ]); }); + + describe("#enable and #disable are parsed as standalone statements", () => { + parseEach([ + [ + `#enable "someFeature"\nmodel Foo {}`, + (node) => { + strictEqual(node.statements.length, 2); + const directive = node.statements[0]; + strictEqual(directive.kind, SyntaxKind.DirectiveExpression); + if (directive.kind === SyntaxKind.DirectiveExpression) { + strictEqual(directive.target.sv, "enable"); + strictEqual(directive.arguments.length, 1); + strictEqual(directive.arguments[0].kind, SyntaxKind.StringLiteral); + if (directive.arguments[0].kind === SyntaxKind.StringLiteral) { + strictEqual(directive.arguments[0].value, "someFeature"); + } + } + strictEqual(node.statements[1].kind, SyntaxKind.ModelStatement); + // #enable should NOT be attached to the model + strictEqual(node.statements[1].directives?.length ?? 0, 0); + }, + ], + [ + `#disable "someFeature"\nmodel Foo {}`, + (node) => { + strictEqual(node.statements.length, 2); + const directive = node.statements[0]; + strictEqual(directive.kind, SyntaxKind.DirectiveExpression); + if (directive.kind === SyntaxKind.DirectiveExpression) { + strictEqual(directive.target.sv, "disable"); + } + }, + ], + [ + `#enable "feature1"\n#enable "feature2"\nmodel Foo {}`, + (node) => { + strictEqual(node.statements.length, 3); + strictEqual(node.statements[0].kind, SyntaxKind.DirectiveExpression); + strictEqual(node.statements[1].kind, SyntaxKind.DirectiveExpression); + strictEqual(node.statements[2].kind, SyntaxKind.ModelStatement); + }, + ], + [ + `#enable "someFeature"`, + (node) => { + strictEqual(node.statements.length, 1); + strictEqual(node.statements[0].kind, SyntaxKind.DirectiveExpression); + }, + ], + ]); + }); }); describe("augment decorator statements", () => { diff --git a/packages/compiler/test/server/completion.test.ts b/packages/compiler/test/server/completion.test.ts index d12643b8c3b..df290dc7c0d 100644 --- a/packages/compiler/test/server/completion.test.ts +++ b/packages/compiler/test/server/completion.test.ts @@ -2614,6 +2614,14 @@ describe("identifiers", () => { label: "deprecated", kind: CompletionItemKind.Keyword, }, + { + label: "enable", + kind: CompletionItemKind.Keyword, + }, + { + label: "disable", + kind: CompletionItemKind.Keyword, + }, ]); });