Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/compiler/src/core/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
59 changes: 59 additions & 0 deletions packages/compiler/src/core/features.ts
Original file line number Diff line number Diff line change
@@ -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<string, FeatureDefinition>(
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);
}
21 changes: 21 additions & 0 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof diagnostics>;
Expand Down
70 changes: 65 additions & 5 deletions packages/compiler/src/core/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -463,15 +489,15 @@ 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);
break;
}

if (tok !== Token.NamespaceKeyword) {
mutate(item).directives = directives;
mutate(item).directives = attachDirectives;
mutate(item).docs = docs;
}

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 },
Expand Down
94 changes: 94 additions & 0 deletions packages/compiler/src/core/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -218,6 +222,7 @@ async function createProgram(
const emitters: EmitterRef[] = [];
const requireImports = new Map<string, string>();
const complexityStats: ComplexityStats = {} as any;
const enabledFeatures = new Set<string>();
let sourceResolution: SourceResolution;
let error = false;
let continueToNextStage = true;
Expand Down Expand Up @@ -261,6 +266,7 @@ async function createProgram(
/** @internal */
resolveTypeOrValueReference,
getSourceFileLocationContext,
isFeatureEnabled: (name) => enabledFeatures.has(name),
projectRoot: getDirectoryPath(options.config ?? resolvedMain ?? ""),
};

Expand All @@ -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;

Expand Down Expand Up @@ -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<string, DirectiveExpressionNode>();
const disables = new Map<string, DirectiveExpressionNode>();

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[],
Expand Down
13 changes: 12 additions & 1 deletion packages/compiler/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,7 @@ export type Statement =
| AugmentDecoratorStatementNode
| ConstStatementNode
| CallExpressionNode
| DirectiveExpressionNode
| EmptyStatementNode
| InvalidStatementNode;

Expand Down Expand Up @@ -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";
Expand All @@ -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
Expand Down
Loading
Loading