diff --git a/languageservice/src/complete-action.test.ts b/languageservice/src/complete-action.test.ts index 87e24ef..2e163c6 100644 --- a/languageservice/src/complete-action.test.ts +++ b/languageservice/src/complete-action.test.ts @@ -134,6 +134,49 @@ runs: expect(labels).toContain("arch"); expect(labels).toContain("temp"); }); + + it("completes if expression value for composite run step", async () => { + const [doc, position] = createActionDocument(`name: My Action +description: Test action +runs: + using: composite + steps: + - if: | + run: echo "hello" + shell: bash`); + const completions = await complete(doc, position); + const labels = completions.map(c => c.label); + + // Should show expression-related completions (status functions and contexts) + expect(labels).toContain("always"); + expect(labels).toContain("success"); + expect(labels).toContain("failure"); + expect(labels).toContain("cancelled"); + expect(labels).toContain("runner"); + expect(labels).toContain("github"); + expect(labels).toContain("inputs"); + expect(labels).toContain("steps"); + }); + + it("completes if expression value for composite uses step", async () => { + const [doc, position] = createActionDocument(`name: My Action +description: Test action +runs: + using: composite + steps: + - if: | + uses: actions/checkout@v4`); + const completions = await complete(doc, position); + const labels = completions.map(c => c.label); + + // Should show expression-related completions + expect(labels).toContain("always"); + expect(labels).toContain("success"); + expect(labels).toContain("failure"); + expect(labels).toContain("cancelled"); + expect(labels).toContain("runner"); + expect(labels).toContain("github"); + }); }); describe("top-level completions", () => { @@ -207,6 +250,85 @@ runs: expect(labels).not.toContain("entrypoint"); }); + it("filters runs keys for node24 actions", async () => { + const [doc, position] = createActionDocument(`name: Test +description: Test +runs: + using: node24 + |`); + const completions = await complete(doc, position); + const labels = completions.map(c => c.label); + + // Should show Node.js action keys + expect(labels).toContain("main"); + expect(labels).toContain("pre"); + expect(labels).toContain("post"); + expect(labels).toContain("pre-if"); + expect(labels).toContain("post-if"); + + // Should NOT show composite or docker keys + expect(labels).not.toContain("steps"); + expect(labels).not.toContain("image"); + expect(labels).not.toContain("entrypoint"); + }); + + it("completes pre-if expression value for node actions", async () => { + const [doc, position] = createActionDocument(`name: Test +description: Test +runs: + using: node24 + main: index.js + pre: setup.js + pre-if: |`); + const completions = await complete(doc, position); + const labels = completions.map(c => c.label); + + // Should show expression-related completions (context functions and namespaces) + expect(labels).toContain("always"); + expect(labels).toContain("success"); + expect(labels).toContain("failure"); + expect(labels).toContain("cancelled"); + expect(labels).toContain("runner"); + expect(labels).toContain("github"); + expect(labels).toContain("inputs"); + expect(labels).toContain("hashFiles"); + }); + + it("completes post-if expression value for node actions", async () => { + const [doc, position] = createActionDocument(`name: Test +description: Test +runs: + using: node24 + main: index.js + post: cleanup.js + post-if: |`); + const completions = await complete(doc, position); + const labels = completions.map(c => c.label); + + // Should show expression-related completions + expect(labels).toContain("always"); + expect(labels).toContain("runner"); + expect(labels).toContain("hashFiles"); + }); + + it("completes pre-if expression value for docker actions", async () => { + const [doc, position] = createActionDocument(`name: Test +description: Test +runs: + using: docker + image: docker://alpine + pre-entrypoint: setup.sh + pre-if: |`); + const completions = await complete(doc, position); + const labels = completions.map(c => c.label); + + // Should show expression-related completions + expect(labels).toContain("always"); + expect(labels).toContain("runner"); + expect(labels).toContain("github"); + expect(labels).toContain("hashFiles"); + }); + it("filters runs keys for composite actions", async () => { const [doc, position] = createActionDocument(`name: Test description: Test diff --git a/languageservice/src/complete.expressions.test.ts b/languageservice/src/complete.expressions.test.ts index f0b4348..0a2f276 100644 --- a/languageservice/src/complete.expressions.test.ts +++ b/languageservice/src/complete.expressions.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import {data, DescriptionDictionary, FeatureFlags} from "@actions/expressions"; -import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types"; +import {CompletionItem, CompletionItemKind, MarkupContent} from "vscode-languageserver-types"; import {complete, getExpressionInput} from "./complete.js"; import {ContextProviderConfig} from "./context-providers/config.js"; import {registerLogger} from "./log.js"; @@ -419,6 +419,36 @@ jobs: expect(result.map(x => x.label)).toEqual(["event"]); }); + + it("includes both contexts and extension functions", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo + if: |`; + const result = await complete(...getPositionFromCursor(input), {contextProviderConfig}); + const labels = result.map(x => x.label); + + // Context namespaces should be present + expect(labels).toContain("github"); + expect(labels).toContain("runner"); + expect(labels).toContain("env"); + expect(labels).toContain("steps"); + + // Extension functions should be present (from schema context array) + expect(labels).toContain("hashFiles"); + expect(labels).toContain("always"); + expect(labels).toContain("success"); + expect(labels).toContain("failure"); + expect(labels).toContain("cancelled"); + + // Built-in functions should be present + expect(labels).toContain("toJson"); + expect(labels).toContain("fromJson"); + expect(labels).toContain("contains"); + }); }); }); @@ -1278,6 +1308,7 @@ jobs: expect(hashFiles).toBeDefined(); expect(hashFiles!.kind).toBe(CompletionItemKind.Function); expect(hashFiles!.insertText).toBe("hashFiles()"); + expect((hashFiles!.documentation as MarkupContent)?.value).toContain("Returns a single hash for the set of files"); // Not a function const github = result.find(x => x.label === "github"); diff --git a/languageservice/src/complete.ts b/languageservice/src/complete.ts index 41ce5ab..32c2e5e 100644 --- a/languageservice/src/complete.ts +++ b/languageservice/src/complete.ts @@ -1,8 +1,10 @@ import {complete as completeExpression, DescriptionDictionary, FeatureFlags} from "@actions/expressions"; import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion"; +import {FunctionInfo} from "@actions/expressions/funcs/info"; import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser"; import {getActionSchema} from "@actions/workflow-parser/actions/action-schema"; import {ErrorPolicy} from "@actions/workflow-parser/model/convert"; +import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context"; import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type"; import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition"; import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema"; @@ -19,6 +21,7 @@ import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} import {filterActionRunsCompletions, getActionScaffoldingSnippets} from "./complete-action.js"; import {ContextProviderConfig} from "./context-providers/config.js"; import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js"; +import {getFunctionDescription} from "./context-providers/descriptions.js"; import {ActionContext, getActionContext} from "./context/action-context.js"; import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js"; import {validatorFunctions} from "./expression-validation/functions.js"; @@ -121,18 +124,24 @@ export async function complete( } // Expression completions - if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) { + if (token && (isBasicExpression(token) || isPotentiallyExpression(token, isAction))) { const allowedContext = token.definitionInfo?.allowedContext || []; + const {namedContexts, functions: extensionFunctions} = splitAllowedContext(allowedContext); const context = isAction - ? getActionExpressionContext(allowedContext, config?.contextProviderConfig, actionContext, Mode.Completion) + ? getActionExpressionContext(namedContexts, config?.contextProviderConfig, actionContext, Mode.Completion) : await getWorkflowExpressionContext( - allowedContext, + namedContexts, config?.contextProviderConfig, workflowContext, Mode.Completion ); - return getExpressionCompletionItems(token, context, newPos, config?.featureFlags); + // Populate function descriptions for completion display + for (const func of extensionFunctions) { + func.description = getFunctionDescription(func.name); + } + + return getExpressionCompletionItems(token, context, extensionFunctions, newPos, config?.featureFlags); } const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML @@ -521,6 +530,7 @@ export function getExistingValues(token: TemplateToken | null, parent: TemplateT function getExpressionCompletionItems( token: TemplateToken, context: DescriptionDictionary, + extensionFunctions: FunctionInfo[], pos: Position, featureFlags?: FeatureFlags ): CompletionItem[] { @@ -541,8 +551,8 @@ function getExpressionCompletionItems( const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim(); try { - return completeExpression(expressionInput, context, [], validatorFunctions, featureFlags).map(item => - mapExpressionCompletionItem(item, currentInput[cursorOffset]) + return completeExpression(expressionInput, context, extensionFunctions, validatorFunctions, featureFlags).map( + item => mapExpressionCompletionItem(item, currentInput[cursorOffset]) ); } catch (e) { error(`Error while completing expression: '${(e as Error)?.message || ""}'`); diff --git a/languageservice/src/hover.ts b/languageservice/src/hover.ts index 873abea..b9f0fd7 100644 --- a/languageservice/src/hover.ts +++ b/languageservice/src/hover.ts @@ -71,7 +71,7 @@ export async function hover(document: TextDocument, position: Position, config?: // Early exit if there's nothing to provide hover for const hoverToken = token || keyToken; const isExpressionHover = - token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token)); + token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token, isAction)); if (!isExpressionHover && !hoverToken?.definition) { return null; } diff --git a/languageservice/src/utils/expression-detection.test.ts b/languageservice/src/utils/expression-detection.test.ts new file mode 100644 index 0000000..f53445e --- /dev/null +++ b/languageservice/src/utils/expression-detection.test.ts @@ -0,0 +1,170 @@ +import {isPotentiallyExpression} from "./expression-detection.js"; +import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token"; +import {TokenType} from "@actions/workflow-parser/templates/tokens/types"; +import {Definition} from "@actions/workflow-parser/templates/schema/definition"; + +// Helper to create a mock TemplateToken with the properties we need to test +function createMockToken(options: {value?: string; definitionKey?: string; isString?: boolean}): TemplateToken { + const {value = "", definitionKey, isString = true} = options; + + const mockDefinition = definitionKey ? ({key: definitionKey} as Definition) : undefined; + + return { + value: isString ? value : undefined, + definition: mockDefinition, + templateTokenType: isString ? TokenType.String : TokenType.Mapping, + // Required by isString type guard (isLiteral checks isLiteral property) + isLiteral: isString, + isScalar: isString + } as unknown as TemplateToken; +} + +describe("isPotentiallyExpression", () => { + describe("expression markers", () => { + it("returns true when token value contains ${{", () => { + const token = createMockToken({value: "${{ github.actor }}"}); + expect(isPotentiallyExpression(token, false)).toBe(true); + expect(isPotentiallyExpression(token, true)).toBe(true); + }); + + it("returns true when token value contains embedded ${{", () => { + const token = createMockToken({value: "Hello ${{ github.actor }}!"}); + expect(isPotentiallyExpression(token, false)).toBe(true); + expect(isPotentiallyExpression(token, true)).toBe(true); + }); + + it("returns false when token value does not contain ${{", () => { + const token = createMockToken({value: "plain text"}); + expect(isPotentiallyExpression(token, false)).toBe(false); + expect(isPotentiallyExpression(token, true)).toBe(false); + }); + + it("returns false for non-string tokens without expression marker", () => { + const token = createMockToken({isString: false}); + expect(isPotentiallyExpression(token, false)).toBe(false); + expect(isPotentiallyExpression(token, true)).toBe(false); + }); + }); + + describe("workflow schema if-conditions", () => { + it("returns true for job-if definition in workflow", () => { + const token = createMockToken({value: "success()", definitionKey: "job-if"}); + expect(isPotentiallyExpression(token, false)).toBe(true); + }); + + it("returns false for job-if definition in action (not valid in action schema)", () => { + const token = createMockToken({value: "success()", definitionKey: "job-if"}); + expect(isPotentiallyExpression(token, true)).toBe(false); + }); + + it("returns true for step-if definition in workflow", () => { + const token = createMockToken({value: "failure()", definitionKey: "step-if"}); + expect(isPotentiallyExpression(token, false)).toBe(true); + }); + + it("returns true for snapshot-if definition in workflow", () => { + const token = createMockToken({value: "always()", definitionKey: "snapshot-if"}); + expect(isPotentiallyExpression(token, false)).toBe(true); + }); + + it("returns false for snapshot-if definition in action (not valid in action schema)", () => { + const token = createMockToken({value: "always()", definitionKey: "snapshot-if"}); + expect(isPotentiallyExpression(token, true)).toBe(false); + }); + }); + + describe("action schema if-conditions", () => { + describe("composite action step if (run and uses)", () => { + it("returns true for step-if definition in action", () => { + const token = createMockToken({value: "success()", definitionKey: "step-if"}); + expect(isPotentiallyExpression(token, true)).toBe(true); + }); + + it("returns true for step-if with run step condition", () => { + // Composite action run step: if condition + const token = createMockToken({value: "github.event_name == 'push'", definitionKey: "step-if"}); + expect(isPotentiallyExpression(token, true)).toBe(true); + }); + + it("returns true for step-if with uses step condition", () => { + // Composite action uses step: if condition + const token = createMockToken({value: "runner.os == 'Linux'", definitionKey: "step-if"}); + expect(isPotentiallyExpression(token, true)).toBe(true); + }); + }); + + describe("pre-if and post-if (node/docker actions)", () => { + it("returns true for runs-if definition in action (pre-if)", () => { + const token = createMockToken({value: "runner.os == 'Linux'", definitionKey: "runs-if"}); + expect(isPotentiallyExpression(token, true)).toBe(true); + }); + + it("returns true for runs-if definition in action (post-if)", () => { + const token = createMockToken({value: "always()", definitionKey: "runs-if"}); + expect(isPotentiallyExpression(token, true)).toBe(true); + }); + + it("returns false for runs-if definition in workflow (not valid in workflow schema)", () => { + const token = createMockToken({value: "always()", definitionKey: "runs-if"}); + expect(isPotentiallyExpression(token, false)).toBe(false); + }); + }); + }); + + describe("mixed scenarios", () => { + it("returns true when expression marker present even if definition is not if-related", () => { + const token = createMockToken({value: "${{ github.actor }}", definitionKey: "some-other-definition"}); + expect(isPotentiallyExpression(token, false)).toBe(true); + expect(isPotentiallyExpression(token, true)).toBe(true); + }); + + it("returns true when both expression marker and if definition present", () => { + const token = createMockToken({value: "${{ success() }}", definitionKey: "step-if"}); + expect(isPotentiallyExpression(token, false)).toBe(true); + expect(isPotentiallyExpression(token, true)).toBe(true); + }); + + it("returns false for plain text with non-if definition", () => { + const token = createMockToken({value: "plain text", definitionKey: "string"}); + expect(isPotentiallyExpression(token, false)).toBe(false); + expect(isPotentiallyExpression(token, true)).toBe(false); + }); + + it("returns false when token has no definition and no expression marker", () => { + const token = createMockToken({value: "plain text"}); + expect(isPotentiallyExpression(token, false)).toBe(false); + expect(isPotentiallyExpression(token, true)).toBe(false); + }); + }); + + describe("edge cases", () => { + it("handles empty string value", () => { + const token = createMockToken({value: ""}); + expect(isPotentiallyExpression(token, false)).toBe(false); + expect(isPotentiallyExpression(token, true)).toBe(false); + }); + + it("handles expression marker as if-condition value", () => { + const token = createMockToken({value: "${{ always() }}", definitionKey: "job-if"}); + expect(isPotentiallyExpression(token, false)).toBe(true); + // For action, job-if is not valid, but ${{ is present + expect(isPotentiallyExpression(token, true)).toBe(true); + }); + + it("handles partial expression marker", () => { + const token = createMockToken({value: "${incomplete"}); + expect(isPotentiallyExpression(token, false)).toBe(false); + expect(isPotentiallyExpression(token, true)).toBe(false); + }); + + it("handles ${{ at different positions", () => { + const startToken = createMockToken({value: "${{ foo }} bar"}); + const middleToken = createMockToken({value: "bar ${{ foo }} baz"}); + const endToken = createMockToken({value: "bar ${{ foo }}"}); + + expect(isPotentiallyExpression(startToken, false)).toBe(true); + expect(isPotentiallyExpression(middleToken, false)).toBe(true); + expect(isPotentiallyExpression(endToken, false)).toBe(true); + }); + }); +}); diff --git a/languageservice/src/utils/expression-detection.ts b/languageservice/src/utils/expression-detection.ts index e52de25..9d63717 100644 --- a/languageservice/src/utils/expression-detection.ts +++ b/languageservice/src/utils/expression-detection.ts @@ -2,10 +2,36 @@ import {isString} from "@actions/workflow-parser"; import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants"; import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index"; -export function isPotentiallyExpression(token: TemplateToken): boolean { - const containsExpression = isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0; - // If conditions are always expressions (job-if, step-if, snapshot-if) - const definitionKey = token.definition?.key; - const isIfCondition = definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if"; - return containsExpression || isIfCondition; +/** + * Workflow schema if-condition definition keys. + * - job-if: job level if condition + * - step-if: step level if condition + * - snapshot-if: snapshot if condition + */ +const WORKFLOW_IF_DEFINITIONS = new Set(["job-if", "step-if", "snapshot-if"]); + +/** + * Action schema if-condition definition keys. + * - step-if: composite action step if condition (run-step and uses-step) + * - runs-if: pre-if and post-if at the runs level (node/docker actions) + */ +const ACTION_IF_DEFINITIONS = new Set(["step-if", "runs-if"]); + +export function isPotentiallyExpression(token: TemplateToken, isAction: boolean): boolean { + // Check if token contains expression syntax + if (isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0) { + return true; + } + + // Check if token is an if-condition (always treated as expressions) + if (!token.definition?.key) { + return false; + } + + // Definition keys differ between workflow and action schemas + if (isAction) { + return ACTION_IF_DEFINITIONS.has(token.definition.key); + } else { + return WORKFLOW_IF_DEFINITIONS.has(token.definition.key); + } } diff --git a/workflow-parser/src/action-v1.0.json b/workflow-parser/src/action-v1.0.json index 368daeb..3feebe7 100644 --- a/workflow-parser/src/action-v1.0.json +++ b/workflow-parser/src/action-v1.0.json @@ -150,7 +150,8 @@ "always(0,0)", "success(0,0)", "failure(0,0)", - "cancelled(0,0)" + "cancelled(0,0)", + "hashFiles(1,255)" ], "string": {} },