diff --git a/expressions/src/features.test.ts b/expressions/src/features.test.ts new file mode 100644 index 00000000..eb690997 --- /dev/null +++ b/expressions/src/features.test.ts @@ -0,0 +1,57 @@ +import {FeatureFlags} from "./features.js"; + +describe("FeatureFlags", () => { + describe("isEnabled", () => { + it("returns false by default when no options provided", () => { + const flags = new FeatureFlags(); + expect(flags.isEnabled("missingInputsQuickfix")).toBe(false); + }); + + it("returns false by default when empty options provided", () => { + const flags = new FeatureFlags({}); + expect(flags.isEnabled("missingInputsQuickfix")).toBe(false); + }); + + it("returns true when feature is explicitly enabled", () => { + const flags = new FeatureFlags({missingInputsQuickfix: true}); + expect(flags.isEnabled("missingInputsQuickfix")).toBe(true); + }); + + it("returns false when feature is explicitly disabled", () => { + const flags = new FeatureFlags({missingInputsQuickfix: false}); + expect(flags.isEnabled("missingInputsQuickfix")).toBe(false); + }); + + it("returns true when all is enabled", () => { + const flags = new FeatureFlags({all: true}); + expect(flags.isEnabled("missingInputsQuickfix")).toBe(true); + }); + + it("explicit feature flag takes precedence over all:true", () => { + const flags = new FeatureFlags({all: true, missingInputsQuickfix: false}); + expect(flags.isEnabled("missingInputsQuickfix")).toBe(false); + }); + + it("explicit feature flag takes precedence over all:false", () => { + const flags = new FeatureFlags({all: false, missingInputsQuickfix: true}); + expect(flags.isEnabled("missingInputsQuickfix")).toBe(true); + }); + }); + + describe("getEnabledFeatures", () => { + it("returns empty array when no features enabled", () => { + const flags = new FeatureFlags(); + expect(flags.getEnabledFeatures()).toEqual([]); + }); + + it("returns enabled features", () => { + const flags = new FeatureFlags({missingInputsQuickfix: true}); + expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]); + }); + + it("returns all features when all is enabled", () => { + const flags = new FeatureFlags({all: true}); + expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]); + }); + }); +}); diff --git a/expressions/src/features.ts b/expressions/src/features.ts new file mode 100644 index 00000000..ab1b7fdf --- /dev/null +++ b/expressions/src/features.ts @@ -0,0 +1,66 @@ +/** + * Experimental feature flags. + * + * Individual feature flags take precedence over `all`. + * Example: { all: true, missingInputsQuickfix: false } enables all + * experimental features EXCEPT missingInputsQuickfix. + * + * When a feature graduates to stable, its flag becomes a no-op + * (the feature will be enabled regardless of the configuration value). + */ +export interface ExperimentalFeatures { + /** + * Enable all experimental features. + * Individual feature flags take precedence over this setting. + * @default false + */ + all?: boolean; + + /** + * Enable quickfix code action for missing required action inputs. + * @default false + */ + missingInputsQuickfix?: boolean; +} + +/** + * Keys of ExperimentalFeatures that represent actual features (excludes 'all') + */ +export type ExperimentalFeatureKey = Exclude; + +/** + * All known experimental feature keys. + * This list must be kept in sync with the ExperimentalFeatures interface. + */ +const allFeatureKeys: ExperimentalFeatureKey[] = ["missingInputsQuickfix"]; + +export class FeatureFlags { + private readonly features: ExperimentalFeatures; + + constructor(features?: ExperimentalFeatures) { + this.features = features ?? {}; + } + + /** + * Check if an experimental feature is enabled. + * + * Resolution order: + * 1. Explicit feature flag (if set) + * 2. `all` flag (if set) + * 3. false (default) + */ + isEnabled(feature: ExperimentalFeatureKey): boolean { + const explicit = this.features[feature]; + if (explicit !== undefined) { + return explicit; + } + return this.features.all ?? false; + } + + /** + * Returns list of all enabled experimental features. + */ + getEnabledFeatures(): ExperimentalFeatureKey[] { + return allFeatureKeys.filter(key => this.isEnabled(key)); + } +} diff --git a/expressions/src/index.ts b/expressions/src/index.ts index effcbf48..4b91e1db 100644 --- a/expressions/src/index.ts +++ b/expressions/src/index.ts @@ -4,6 +4,7 @@ export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from ". export * as data from "./data/index.js"; export {ExpressionError, ExpressionEvaluationError} from "./errors.js"; export {Evaluator} from "./evaluator.js"; +export {ExperimentalFeatureKey, ExperimentalFeatures, FeatureFlags} from "./features.js"; export {wellKnownFunctions} from "./funcs.js"; export {Lexer, Result} from "./lexer.js"; export {Parser} from "./parser.js"; diff --git a/languageserver/README.md b/languageserver/README.md index 4aaade8d..e8633e38 100644 --- a/languageserver/README.md +++ b/languageserver/README.md @@ -84,6 +84,11 @@ export interface InitializationOptions { * Desired log level */ logLevel?: LogLevel; + + /** + * Experimental features that are opt-in + */ + experimentalFeatures?: ExperimentalFeatures; } ``` @@ -100,6 +105,32 @@ const clientOptions: LanguageClientOptions = { const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions); ``` +### Experimental Features + +The language server supports opt-in experimental features via the `experimentalFeatures` initialization option. These features may change or be removed in between releases. + +```typescript +initializationOptions: { + experimentalFeatures: { + // Enable all experimental features + all: true, + + // Or enable specific features + missingInputsQuickfix: true, + } +} +``` + +**Available experimental features:** + +| Feature | Description | +|---------|-------------| +| `missingInputsQuickfix` | Code action to add missing required inputs for actions | + +Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`. + +When a feature graduates to stable, its flag becomes a no-op and the feature will be enabled regardless of the configuration value. + ### Standalone CLI After installing globally, you can run the language server directly: diff --git a/languageserver/src/connection.ts b/languageserver/src/connection.ts index 8424980c..d0a7ede3 100644 --- a/languageserver/src/connection.ts +++ b/languageserver/src/connection.ts @@ -24,6 +24,7 @@ import {getClient} from "./client.js"; import {Commands} from "./commands.js"; import {contextProviders} from "./context-providers.js"; import {descriptionProvider} from "./description-provider.js"; +import {FeatureFlags} from "@actions/expressions"; import {getFileProvider} from "./file-provider.js"; import {InitializationOptions, RepositoryContext} from "./initializationOptions.js"; import {onCompletion} from "./on-completion.js"; @@ -41,6 +42,7 @@ export function initConnection(connection: Connection) { const cache = new TTLCache(); let hasWorkspaceFolderCapability = false; + let featureFlags = new FeatureFlags(); // Register remote console logger with language service registerLogger(connection.console); @@ -64,6 +66,8 @@ export function initConnection(connection: Connection) { setLogLevel(options.logLevel); } + featureFlags = new FeatureFlags(options.experimentalFeatures); + const result: InitializeResult = { capabilities: { textDocumentSync: TextDocumentSyncKind.Full, @@ -91,6 +95,11 @@ export function initConnection(connection: Connection) { }); connection.onInitialized(() => { + const enabledFeatures = featureFlags.getEnabledFeatures(); + if (enabledFeatures.length > 0) { + connection.console.info(`Experimental features enabled: ${enabledFeatures.join(", ")}`); + } + if (hasWorkspaceFolderCapability) { connection.workspace.onDidChangeWorkspaceFolders(() => { clearCache(); diff --git a/languageserver/src/initializationOptions.ts b/languageserver/src/initializationOptions.ts index 59ef4623..d3059714 100644 --- a/languageserver/src/initializationOptions.ts +++ b/languageserver/src/initializationOptions.ts @@ -1,3 +1,4 @@ +import {ExperimentalFeatures} from "@actions/expressions"; import {LogLevel} from "@actions/languageservice/log"; export {LogLevel} from "@actions/languageservice/log"; @@ -28,6 +29,12 @@ export interface InitializationOptions { * If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3" */ gitHubApiUrl?: string; + + /** + * Experimental features that are opt-in. + * Features listed here may change or be removed without notice. + */ + experimentalFeatures?: ExperimentalFeatures; } export interface RepositoryContext {