diff --git a/expressions/src/completion.ts b/expressions/src/completion.ts index 4ebafc3..846ebe6 100644 --- a/expressions/src/completion.ts +++ b/expressions/src/completion.ts @@ -2,6 +2,7 @@ import {DescriptionPair} from "./completion/descriptionDictionary.js"; import {Dictionary, isDictionary} from "./data/dictionary.js"; import {ExpressionData} from "./data/expressiondata.js"; import {Evaluator} from "./evaluator.js"; +import {FeatureFlags} from "./features.js"; import {wellKnownFunctions} from "./funcs.js"; import {FunctionDefinition, FunctionInfo} from "./funcs/info.js"; import {Lexer, Token, TokenType} from "./lexer.js"; @@ -26,13 +27,15 @@ export type CompletionItem = { * @param context Context available for the expression * @param extensionFunctions List of functions available * @param functions Optional map of functions to use during evaluation + * @param featureFlags Optional feature flags to control which features are enabled * @returns Array of completion items */ export function complete( input: string, context: Dictionary, extensionFunctions: FunctionInfo[], - functions?: Map + functions?: Map, + featureFlags?: FeatureFlags ): CompletionItem[] { // Lex const lexer = new Lexer(input); @@ -63,7 +66,7 @@ export function complete( const result = contextKeys(context); // Merge with functions - result.push(...functionItems(extensionFunctions)); + result.push(...functionItems(extensionFunctions, featureFlags)); return result; } @@ -88,10 +91,15 @@ export function complete( return contextKeys(result); } -function functionItems(extensionFunctions: FunctionInfo[]): CompletionItem[] { +function functionItems(extensionFunctions: FunctionInfo[], featureFlags?: FeatureFlags): CompletionItem[] { const result: CompletionItem[] = []; + const flags = featureFlags ?? new FeatureFlags(); for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) { + // Filter out case function if feature is disabled + if (fdef.name === "case" && !flags.isEnabled("allowCaseFunction")) { + continue; + } result.push({ label: fdef.name, description: fdef.description, diff --git a/expressions/src/errors.ts b/expressions/src/errors.ts index 967113f..cfccfe7 100644 --- a/expressions/src/errors.ts +++ b/expressions/src/errors.ts @@ -12,6 +12,7 @@ export enum ErrorType { ErrorExceededMaxLength, ErrorTooFewParameters, ErrorTooManyParameters, + ErrorEvenParameters, ErrorUnrecognizedContext, ErrorUnrecognizedFunction } @@ -42,6 +43,8 @@ function errorDescription(typ: ErrorType): string { return "Too few parameters supplied"; case ErrorType.ErrorTooManyParameters: return "Too many parameters supplied"; + case ErrorType.ErrorEvenParameters: + return "Even number of parameters supplied, requires an odd number of parameters"; case ErrorType.ErrorUnrecognizedContext: return "Unrecognized named-value"; case ErrorType.ErrorUnrecognizedFunction: diff --git a/expressions/src/features.test.ts b/expressions/src/features.test.ts index 425c72a..4ccbd59 100644 --- a/expressions/src/features.test.ts +++ b/expressions/src/features.test.ts @@ -54,7 +54,8 @@ describe("FeatureFlags", () => { expect(flags.getEnabledFeatures()).toEqual([ "missingInputsQuickfix", "blockScalarChompingWarning", - "actionScaffoldingSnippets" + "actionScaffoldingSnippets", + "allowCaseFunction" ]); }); }); diff --git a/expressions/src/features.ts b/expressions/src/features.ts index 159fbd1..fee0135 100644 --- a/expressions/src/features.ts +++ b/expressions/src/features.ts @@ -35,6 +35,12 @@ export interface ExperimentalFeatures { * @default false */ actionScaffoldingSnippets?: boolean; + + /** + * Enable the case() function in expressions. + * @default false + */ + allowCaseFunction?: boolean; } /** @@ -49,7 +55,8 @@ export type ExperimentalFeatureKey = Exclude; const allFeatureKeys: ExperimentalFeatureKey[] = [ "missingInputsQuickfix", "blockScalarChompingWarning", - "actionScaffoldingSnippets" + "actionScaffoldingSnippets", + "allowCaseFunction" ]; export class FeatureFlags { diff --git a/expressions/src/funcs.ts b/expressions/src/funcs.ts index 1adad75..a0df579 100644 --- a/expressions/src/funcs.ts +++ b/expressions/src/funcs.ts @@ -1,4 +1,5 @@ import {ErrorType, ExpressionError} from "./errors.js"; +import {caseFunc} from "./funcs/case.js"; import {contains} from "./funcs/contains.js"; import {endswith} from "./funcs/endswith.js"; import {format} from "./funcs/format.js"; @@ -16,6 +17,7 @@ export type ParseContext = { }; export const wellKnownFunctions: {[name: string]: FunctionDefinition} = { + case: caseFunc, contains: contains, endswith: endswith, format: format, @@ -53,4 +55,9 @@ export function validateFunction(context: ParseContext, identifier: Token, argCo if (argCount > f.maxArgs) { throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier); } + + // case function requires an odd number of arguments + if (name === "case" && argCount % 2 === 0) { + throw new ExpressionError(ErrorType.ErrorEvenParameters, identifier); + } } diff --git a/expressions/src/funcs/case.ts b/expressions/src/funcs/case.ts new file mode 100644 index 0000000..eb96595 --- /dev/null +++ b/expressions/src/funcs/case.ts @@ -0,0 +1,29 @@ +import {ExpressionData, Kind} from "../data/index.js"; +import {FunctionDefinition} from "./info.js"; + +export const caseFunc: FunctionDefinition = { + name: "case", + description: + "`case( pred1, val1, pred2, val2, ..., default )`\n\nEvaluates predicates in order and returns the value corresponding to the first predicate that evaluates to `true`. If no predicate matches, it returns the last argument as the default value.", + minArgs: 3, + maxArgs: Number.MAX_SAFE_INTEGER, + call: (...args: ExpressionData[]): ExpressionData => { + // Evaluate predicate-result pairs + for (let i = 0; i < args.length - 1; i += 2) { + const predicate = args[i]; + + // Predicate must be a boolean + if (predicate.kind !== Kind.Boolean) { + throw new Error("case predicate must evaluate to a boolean value"); + } + + // If predicate is true, return the corresponding result + if (predicate.value) { + return args[i + 1]; + } + } + + // No predicate matched, return default (last argument) + return args[args.length - 1]; + } +}; diff --git a/expressions/testdata/case.json b/expressions/testdata/case.json new file mode 100644 index 0000000..1dc12ff --- /dev/null +++ b/expressions/testdata/case.json @@ -0,0 +1,157 @@ +{ + "case": [ + { + "expr": "case(true, 'first', 'default')", + "result": { "kind": "String", "value": "first" } + }, + { + "expr": "case(false, 'first', 'default')", + "result": { "kind": "String", "value": "default" } + }, + { + "expr": "case(true, 'first', false, 'second', 'default')", + "result": { "kind": "String", "value": "first" } + }, + { + "expr": "case(false, 'first', true, 'second', 'default')", + "result": { "kind": "String", "value": "second" } + }, + { + "expr": "case(false, 'first', false, 'second', 'default')", + "result": { "kind": "String", "value": "default" } + }, + { + "expr": "case(1 == 1, 'equal', 'not equal')", + "result": { "kind": "String", "value": "equal" } + }, + { + "expr": "case(1 == 2, 'equal', 'not equal')", + "result": { "kind": "String", "value": "not equal" } + }, + { + "expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')", + "contexts": { + "github": { + "ref": "refs/heads/main", + "event_name": "push" + } + }, + "result": { "kind": "String", "value": "main" } + }, + { + "expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')", + "contexts": { + "github": { + "ref": "refs/heads/develop", + "event_name": "pull_request" + } + }, + "result": { "kind": "String", "value": "pr" } + }, + { + "expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')", + "contexts": { + "github": { + "ref": "refs/heads/develop", + "event_name": "push" + } + }, + "result": { "kind": "String", "value": "other" } + }, + { + "expr": "case(true, 123, 456)", + "result": { "kind": "Number", "value": 123 } + }, + { + "expr": "case(false, 123, 456)", + "result": { "kind": "Number", "value": 456 } + }, + { + "expr": "case(github.event == 'pull_request', 0, 1)", + "contexts": { + "github": { + "event": "pull_request" + } + }, + "result": { "kind": "Number", "value": 0 } + }, + { + "expr": "case(false, 0, 1)", + "result": { "kind": "Number", "value": 1 } + }, + { + "expr": "case(true, false, true)", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "case(false, false, true)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "case(true, '', 'default')", + "result": { "kind": "String", "value": "" } + }, + { + "expr": "case(false, 'first', '')", + "result": { "kind": "String", "value": "" } + }, + { + "expr": "case(true, fromJSON('[1,2,3]'), 'default')", + "result": { "kind": "Array", "value": [1, 2, 3] } + }, + { + "expr": "case(true, fromJSON('{\"key\":\"value\"}'), 'default')", + "result": { "kind": "Object", "value": { "key": "value" } } + }, + { + "expr": "case(false, 'first', false, 'second', false, 'third', false, 'fourth', 'default')", + "result": { "kind": "String", "value": "default" } + }, + { + "expr": "case(false, 'first', false, 'second', true, 'third', false, 'fourth', 'default')", + "result": { "kind": "String", "value": "third" } + }, + { + "expr": "case('not a boolean', 'first', 'default')", + "err": { + "kind": "evaluation", + "value": "case predicate must evaluate to a boolean value" + } + }, + { + "expr": "case(1, 'first', 'default')", + "err": { + "kind": "evaluation", + "value": "case predicate must evaluate to a boolean value" + } + }, + { + "expr": "case(null, 'first', 'default')", + "err": { + "kind": "evaluation", + "value": "case predicate must evaluate to a boolean value" + } + }, + { + "expr": "case(fromJSON('[]'), 'first', 'default')", + "err": { + "kind": "evaluation", + "value": "case predicate must evaluate to a boolean value" + } + }, + { + "expr": "case(fromJSON('{}'), 'first', 'default')", + "err": { + "kind": "evaluation", + "value": "case predicate must evaluate to a boolean value" + } + }, + { + "expr": "case(true, 'first', false, 'second')", + "err": { + "kind": "parsing", + "value": "Even number of parameters supplied, requires an odd number of parameters: 'case'. Located at position 1 within expression: case(true, 'first', false, 'second')" + } + } + ] +} diff --git a/languageservice/src/complete.expressions.test.ts b/languageservice/src/complete.expressions.test.ts index 3496b1e..f0b4348 100644 --- a/languageservice/src/complete.expressions.test.ts +++ b/languageservice/src/complete.expressions.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import {data, DescriptionDictionary} from "@actions/expressions"; +import {data, DescriptionDictionary, FeatureFlags} from "@actions/expressions"; import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types"; import {complete, getExpressionInput} from "./complete.js"; import {ContextProviderConfig} from "./context-providers/config.js"; @@ -68,12 +68,16 @@ describe("expressions", () => { describe("top-level auto-complete", () => { it("single region", async () => { const input = "run-name: ${{ | }}"; - const result = await complete(...getPositionFromCursor(input)); + const result = await complete(...getPositionFromCursor(input), { + contextProviderConfig, + featureFlags: new FeatureFlags({allowCaseFunction: true}) + }); expect(result.map(x => x.label)).toEqual([ "github", "inputs", "vars", + "case", "contains", "endsWith", "format", @@ -108,12 +112,16 @@ describe("expressions", () => { it("single region with existing input", async () => { const input = "run-name: ${{ g| }}"; - const result = await complete(...getPositionFromCursor(input), {contextProviderConfig}); + const result = await complete(...getPositionFromCursor(input), { + contextProviderConfig, + featureFlags: new FeatureFlags({allowCaseFunction: true}) + }); expect(result.map(x => x.label)).toEqual([ "github", "inputs", "vars", + "case", "contains", "endsWith", "format", @@ -126,12 +134,16 @@ describe("expressions", () => { it("single region with existing condition", async () => { const input = "run-name: ${{ g| == 'test' }}"; - const result = await complete(...getPositionFromCursor(input), {contextProviderConfig}); + const result = await complete(...getPositionFromCursor(input), { + contextProviderConfig, + featureFlags: new FeatureFlags({allowCaseFunction: true}) + }); expect(result.map(x => x.label)).toEqual([ "github", "inputs", "vars", + "case", "contains", "endsWith", "format", @@ -144,12 +156,16 @@ describe("expressions", () => { it("multiple regions with partial function", async () => { const input = "run-name: Run a ${{ inputs.test }} one-line script ${{ from|('test') == inputs.name }}"; - const result = await complete(...getPositionFromCursor(input), {contextProviderConfig}); + const result = await complete(...getPositionFromCursor(input), { + contextProviderConfig, + featureFlags: new FeatureFlags({allowCaseFunction: true}) + }); expect(result.map(x => x.label)).toEqual([ "github", "inputs", "vars", + "case", "contains", "endsWith", "format", @@ -162,12 +178,16 @@ describe("expressions", () => { it("multiple regions - first region", async () => { const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}"; - const result = await complete(...getPositionFromCursor(input), {contextProviderConfig}); + const result = await complete(...getPositionFromCursor(input), { + contextProviderConfig, + featureFlags: new FeatureFlags({allowCaseFunction: true}) + }); expect(result.map(x => x.label)).toEqual([ "github", "inputs", "vars", + "case", "contains", "endsWith", "format", @@ -180,12 +200,16 @@ describe("expressions", () => { it("multiple regions", async () => { const input = "run-name: test-${{ github }}-${{ | }}"; - const result = await complete(...getPositionFromCursor(input), {contextProviderConfig}); + const result = await complete(...getPositionFromCursor(input), { + contextProviderConfig, + featureFlags: new FeatureFlags({allowCaseFunction: true}) + }); expect(result.map(x => x.label)).toEqual([ "github", "inputs", "vars", + "case", "contains", "endsWith", "format", @@ -1126,7 +1150,10 @@ jobs: run: echo hi `; - const result = await complete(...getPositionFromCursor(input), {contextProviderConfig}); + const result = await complete(...getPositionFromCursor(input), { + contextProviderConfig, + featureFlags: new FeatureFlags({allowCaseFunction: true}) + }); expect(result.map(x => x.label)).toEqual([ "env", "github", @@ -1139,6 +1166,7 @@ jobs: "steps", "strategy", "vars", + "case", "contains", "endsWith", "format", diff --git a/languageservice/src/complete.test.ts b/languageservice/src/complete.test.ts index 1ba56a9..5026283 100644 --- a/languageservice/src/complete.test.ts +++ b/languageservice/src/complete.test.ts @@ -6,6 +6,7 @@ import {getPositionFromCursor} from "./test-utils/cursor-position.js"; import {TestLogger} from "./test-utils/logger.js"; import {clearCache} from "./utils/workflow-cache.js"; import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js"; +import {FeatureFlags} from "@actions/expressions/features"; registerLogger(new TestLogger()); @@ -895,4 +896,32 @@ jobs: expect(result.some(x => x.label === "macos-latest")).toBe(true); }); }); + + describe("expression completions", () => { + it("include case function when enabled", async () => { + const input = "on: push\njobs:\n build:\n runs-on: ${{ c|"; + const result = await complete(...getPositionFromCursor(input), { + featureFlags: new FeatureFlags({allowCaseFunction: true}) + }); + + expect(result).not.toBeUndefined(); + // Expression completions starting with 'c': case, contains + const labels = result.map(x => x.label); + expect(labels).toContain("case"); + expect(labels).toContain("contains"); + }); + + it("exclude case function when disabled", async () => { + const input = "on: push\njobs:\n build:\n runs-on: ${{ c|"; + const result = await complete(...getPositionFromCursor(input), { + featureFlags: new FeatureFlags({allowCaseFunction: false}) + }); + + expect(result).not.toBeUndefined(); + // Expression completions starting with 'c': contains + const labels = result.map(x => x.label); + expect(labels).not.toContain("case"); + expect(labels).toContain("contains"); + }); + }); }); diff --git a/languageservice/src/complete.ts b/languageservice/src/complete.ts index a760ac7..079ae01 100644 --- a/languageservice/src/complete.ts +++ b/languageservice/src/complete.ts @@ -132,7 +132,7 @@ export async function complete( Mode.Completion ); - return getExpressionCompletionItems(token, context, newPos); + return getExpressionCompletionItems(token, context, newPos, config?.featureFlags); } const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML @@ -521,7 +521,8 @@ export function getExistingValues(token: TemplateToken | null, parent: TemplateT function getExpressionCompletionItems( token: TemplateToken, context: DescriptionDictionary, - pos: Position + pos: Position, + featureFlags?: FeatureFlags ): CompletionItem[] { if (!token.range) { return []; @@ -540,7 +541,7 @@ function getExpressionCompletionItems( const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim(); try { - return completeExpression(expressionInput, context, [], validatorFunctions).map(item => + return completeExpression(expressionInput, context, [], validatorFunctions, featureFlags).map(item => mapExpressionCompletionItem(item, currentInput[cursorOffset]) ); } catch (e) {