Compare commits

..

14 Commits

Author SHA1 Message Date
Allan Guigou 67dd4fbd61 Merge pull request #298 from actions/release/0.3.36
Release version 0.3.36
2026-01-14 10:26:27 -05:00
GitHub Actions 4a7e08774d Release extension version 0.3.36 2026-01-14 15:24:00 +00:00
Allan Guigou 9ec1c123a8 Merge pull request #294 from actions/allanguigou/case
Add support for case function
2026-01-14 10:13:17 -05:00
Allan Guigou aad3bcd291 Fix tests 2026-01-14 14:48:52 +00:00
Allan Guigou 248934d513 Fix formatting 2026-01-14 14:17:49 +00:00
Allan Guigou b605cb6582 Merge branch 'main' into allanguigou/case 2026-01-14 13:51:13 +00:00
Allan Guigou 05debf64b0 Add experimental flag for case function 2026-01-14 13:17:15 +00:00
Allan Guigou 228acc3cd9 Update case.ts 2026-01-13 09:53:34 -05:00
Allan Guigou 9f30846fde Merge branch 'main' into allanguigou/case 2026-01-13 09:46:34 -05:00
Allan Guigou 0ebe1262ee Add case to completion tests 2026-01-08 15:01:43 +00:00
Allan Guigou 94d7f7b124 Remove unncessary type conversion 2026-01-08 14:33:59 +00:00
Allan Guigou f439272f69 Update import paths to include file extensions 2026-01-08 09:21:12 -05:00
Allan Guigou 161574adac Merge branch 'main' into allanguigou/case 2026-01-08 09:12:05 -05:00
Allan Guigou 44900feff7 Add support for case function 2026-01-06 14:42:01 +00:00
16 changed files with 305 additions and 35 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.35",
"version": "0.3.36",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+11 -3
View File
@@ -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<string, FunctionDefinition>
functions?: Map<string, FunctionDefinition>,
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,
+3
View File
@@ -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:
+2 -1
View File
@@ -54,7 +54,8 @@ describe("FeatureFlags", () => {
expect(flags.getEnabledFeatures()).toEqual([
"missingInputsQuickfix",
"blockScalarChompingWarning",
"actionScaffoldingSnippets"
"actionScaffoldingSnippets",
"allowCaseFunction"
]);
});
});
+8 -1
View File
@@ -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<keyof ExperimentalFeatures, "all">;
const allFeatureKeys: ExperimentalFeatureKey[] = [
"missingInputsQuickfix",
"blockScalarChompingWarning",
"actionScaffoldingSnippets"
"actionScaffoldingSnippets",
"allowCaseFunction"
];
export class FeatureFlags {
+7
View File
@@ -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);
}
}
+29
View File
@@ -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];
}
};
+157
View File
@@ -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')"
}
}
]
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.35",
"version": "0.3.36",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -48,8 +48,8 @@
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.35",
"@actions/workflow-parser": "^0.3.35",
"@actions/languageservice": "^0.3.36",
"@actions/workflow-parser": "^0.3.36",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.35",
"version": "0.3.36",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -47,8 +47,8 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.35",
"@actions/workflow-parser": "^0.3.35",
"@actions/expressions": "^0.3.36",
"@actions/workflow-parser": "^0.3.36",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
@@ -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",
+29
View File
@@ -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");
});
});
});
+4 -3
View File
@@ -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) {
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.35"
"version": "0.3.36"
}
+9 -9
View File
@@ -136,7 +136,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.35",
"version": "0.3.36",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -396,11 +396,11 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.35",
"version": "0.3.36",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.35",
"@actions/workflow-parser": "^0.3.35",
"@actions/languageservice": "^0.3.36",
"@actions/workflow-parser": "^0.3.36",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -940,11 +940,11 @@
},
"languageservice": {
"name": "@actions/languageservice",
"version": "0.3.35",
"version": "0.3.36",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.35",
"@actions/workflow-parser": "^0.3.35",
"@actions/expressions": "^0.3.36",
"@actions/workflow-parser": "^0.3.36",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
@@ -13345,10 +13345,10 @@
},
"workflow-parser": {
"name": "@actions/workflow-parser",
"version": "0.3.35",
"version": "0.3.36",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.35",
"@actions/expressions": "^0.3.36",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.35",
"version": "0.3.36",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -48,7 +48,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.35",
"@actions/expressions": "^0.3.36",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},