Compare commits

..

1 Commits

Author SHA1 Message Date
eric sciple 6ad6b2e620 support action.yml 2025-12-28 01:51:55 +00:00
145 changed files with 2761 additions and 14142 deletions
-3
View File
@@ -1,4 +1 @@
* @actions/actions-vscode-reviewers
# Owners maintaining https://github.com/actions/runner-images
/languageservice/src/value-providers/default.ts @actions/runner-images-writers @actions/actions-vscode-reviewers
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v4
@@ -37,10 +37,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 24.x
- name: Use Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 22.x
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
- run: npm ci
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: "16"
- name: Bump version and push
run: |
+11 -3
View File
@@ -59,7 +59,7 @@ jobs:
permissions:
contents: write
id-token: write
packages: write
env:
PKG_VERSION: "" # will be set in the workflow
@@ -69,8 +69,9 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 22.x
cache: "npm"
scope: '@actions'
- name: Parse version from lerna.json
run: |
@@ -96,6 +97,13 @@ jobs:
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
await core.summary.write();
- name: setup authentication
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish packages
run: |
npx lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
-3
View File
@@ -4,9 +4,6 @@ lerna-debug.log
node_modules
.DS_Store
# Nx cache (generated by Lerna/Nx)
.nx/
# Minified JSON (generated at build time)
*.min.json
+1 -2
View File
@@ -3,5 +3,4 @@ dist
*.md
*.js
*.json
*.d.ts
/.nx/workspace-data
*.d.ts
-33
View File
@@ -1,33 +0,0 @@
# Agents
## Build
```
npx lerna run build
```
## Test
```
npm -w @actions/expressions test
npm -w @actions/workflow-parser test
npm -w @actions/languageservice test
```
## Format
Always run formatting before committing:
```
npx prettier --write <changed files>
```
Verify with:
```
npm run format-check -ws
```
## Feature flags
Feature flags are defined in `expressions/src/features.ts` (`ExperimentalFeatures` interface + `allFeatureKeys` array). They are plumbed through `ConvertOptions`, `CompletionConfig`, `ValidationConfig`, and `initializationOptions`. When a feature graduates to stable, remove its flag and make the behavior unconditional.
+6 -26
View File
@@ -4,27 +4,6 @@
This document outlines the plan to migrate from TypeScript's deprecated `"moduleResolution": "node"` (node10) to `"moduleResolution": "node16"` or `"nodenext"`. This change is necessary because the published ESM packages have extensionless imports that don't work correctly in modern ESM environments.
## TL;DR - Remaining Work
- [x] expressions - Migrated ✅
- [x] workflow-parser - Migrated ✅
- [x] languageservice - Migrated ✅
- [x] languageserver - Add `.js` extensions to imports ✅
- [ ] languageserver - Update `tsconfig.build.json` to `moduleResolution: "node16"` (blocked by vscode-languageserver)
- [ ] languageserver - Upgrade `vscode-languageserver` to stable v10+ when released
**Blocker:** `vscode-languageserver@8.0.2` lacks ESM exports. Stable v10 with `exports` field needed.
### ⚠️ Important: `skipLibCheck: true` Required
All migrated packages use `skipLibCheck: true` in their `tsconfig.build.json`. This works around a TS2386 "Overload signatures must all be optional or required" error in `@types/node/module.d.ts`.
**Why can't we just fix the error?** The error is in `@types/node`, a third-party package maintained by DefinitelyTyped. We can't modify `node_modules`, and upstream fixes take time.
**Is `skipLibCheck` safe?** Yes. It only skips type checking of `.d.ts` files (declaration files from dependencies). Our own `.ts` source files are still fully type-checked. This is a common and recommended workaround for issues in third-party type definitions.
---
## Issues Fixed
This migration will resolve the following issues:
@@ -220,13 +199,14 @@ src/connection.ts(1,43): error TS2307: Cannot find module 'vscode-languageserver
With `moduleResolution: "node16"`, TypeScript follows Node.js ESM resolution rules which require explicit `exports` for subpath imports like `vscode-languageserver/browser` and `vscode-languageserver/node`.
**Status:** Partial - `.js` extensions added, waiting for stable `vscode-languageserver` release with ESM exports to complete migration.
**Status:** Verified December 2025. Version 9.0.1 is available but ESM export support is not confirmed.
**Completed:** All relative imports in languageserver source files have been updated to use `.js` extensions. This is compatible with the current `moduleResolution: "node"` and will enable a seamless migration once a stable vscode-languageserver version with ESM exports is available.
**Current Decision:** The languageserver package is **deferred** from this migration until the upstream `vscode-languageserver` package adds proper ESM exports. It will continue using the old `moduleResolution: "node"` configuration.
**Options to resolve:**
- Wait for stable vscode-languageserver v10+ with ESM exports
- Use pre-release `vscode-languageserver@10.0.0-next.16` (has proper exports but is unstable)
- Wait for vscode-languageserver to add ESM exports
- Try upgrading to vscode-languageserver v9.x to see if exports were added
- Use a bundler to work around the module resolution
- Fork or patch the dependency
---
@@ -238,7 +218,7 @@ With `moduleResolution: "node16"`, TypeScript follows Node.js ESM resolution rul
| expressions | 1068 | ✅ Migrated |
| workflow-parser | 292 | ✅ Migrated |
| languageservice | 452 | ✅ Migrated |
| languageserver | 31 | 🔶 Partial (`.js` extensions added, awaiting stable vscode-languageserver) |
| languageserver | 6 files | ⏸️ Deferred (vscode-languageserver lacks ESM exports) |
---
+52
View File
@@ -0,0 +1,52 @@
# Future Breaking Changes
This document tracks cleanup changes we want to make in a future major version bump. These are architectural improvements that would break existing import paths or APIs.
**Current version:** 0.x (pre-1.0)
---
## `@actions/workflow-parser`
### Move shared utilities from `workflows/` to `templates/`
Several files in `workflows/` are actually generic and should live in `templates/`:
| File | Current Location | Proposed Location | Notes |
|------|------------------|-------------------|-------|
| `yaml-object-reader.ts` | `workflows/` | `templates/` | Generic YAML parsing, no workflow dependencies |
| `file.ts` | `workflows/` | `templates/` | Generic `{ name, content }` interface |
| `file-provider.ts` | `workflows/` | `templates/` | Generic interface |
**Impact:** Import paths change for consumers using deep imports.
### Consolidate export strategy
Currently:
- `index.ts` exports the "public API"
- `package.json` has `"./*"` allowing deep imports to anything
Consider:
- Explicitly define which subpaths are stable API
- Document internal vs public paths
- Or: export everything needed from `index.ts` subpath exports
---
## `@actions/languageservice`
### Rename `action.ts` for clarity
`languageservice/src/action.ts` contains types for **consuming** actions (validating `uses:` in workflows). The name is ambiguous now that we have action.yml **authoring** support.
Consider renaming to:
- `action-metadata.ts` — clearer that it's about fetched metadata
- `action-consumer.ts` — clearer about the use case
---
## Notes
- Add items here as we discover them during development
- Group by package
- Include impact assessment for each change
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.54",
"version": "0.3.28",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -36,7 +36,7 @@
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"prepublishOnly": "npm run build && npm run test",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
@@ -44,7 +44,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"engines": {
"node": ">= 20"
"node": ">= 18"
},
"files": [
"dist/**/*"
+3 -11
View File
@@ -2,7 +2,6 @@ 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";
@@ -27,15 +26,13 @@ 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>,
featureFlags?: FeatureFlags
functions?: Map<string, FunctionDefinition>
): CompletionItem[] {
// Lex
const lexer = new Lexer(input);
@@ -66,7 +63,7 @@ export function complete(
const result = contextKeys(context);
// Merge with functions
result.push(...functionItems(extensionFunctions, featureFlags));
result.push(...functionItems(extensionFunctions));
return result;
}
@@ -91,15 +88,10 @@ export function complete(
return contextKeys(result);
}
function functionItems(extensionFunctions: FunctionInfo[], featureFlags?: FeatureFlags): CompletionItem[] {
function functionItems(extensionFunctions: FunctionInfo[]): 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,7 +12,6 @@ export enum ErrorType {
ErrorExceededMaxLength,
ErrorTooFewParameters,
ErrorTooManyParameters,
ErrorEvenParameters,
ErrorUnrecognizedContext,
ErrorUnrecognizedFunction
}
@@ -43,8 +42,6 @@ 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:
-64
View File
@@ -1,64 +0,0 @@
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);
expect(flags.isEnabled("allowConcurrencyQueue")).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",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCopilotRequestsPermission",
"allowConcurrencyQueue"
]);
});
});
});
-97
View File
@@ -1,97 +0,0 @@
/**
* 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;
/**
* Warn when block scalars (| or >) use implicit clip chomping,
* which adds a trailing newline that may be unintentional.
* @default false
*/
blockScalarChompingWarning?: boolean;
/**
* Enable the case() function in expressions.
* @default false
*/
allowCaseFunction?: boolean;
/**
* Enable the copilot-requests permission in workflow permissions.
* @default false
*/
allowCopilotRequestsPermission?: boolean;
/**
* Enable the queue property in workflow concurrency settings.
* @default false
*/
allowConcurrencyQueue?: boolean;
}
/**
* Keys of ExperimentalFeatures that represent actual features (excludes 'all')
*/
export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
/**
* All known experimental feature keys.
* This list must be kept in sync with the ExperimentalFeatures interface.
*/
const allFeatureKeys: ExperimentalFeatureKey[] = [
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCopilotRequestsPermission",
"allowConcurrencyQueue"
];
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));
}
}
-7
View File
@@ -1,5 +1,4 @@
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";
@@ -17,7 +16,6 @@ export type ParseContext = {
};
export const wellKnownFunctions: {[name: string]: FunctionDefinition} = {
case: caseFunc,
contains: contains,
endswith: endswith,
format: format,
@@ -55,9 +53,4 @@ 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
@@ -1,29 +0,0 @@
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];
}
};
-1
View File
@@ -4,7 +4,6 @@ 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";
-157
View File
@@ -1,157 +0,0 @@
{
"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')"
}
}
]
}
-33
View File
@@ -84,11 +84,6 @@ export interface InitializationOptions {
* Desired log level
*/
logLevel?: LogLevel;
/**
* Experimental features that are opt-in
*/
experimentalFeatures?: ExperimentalFeatures;
}
```
@@ -105,34 +100,6 @@ 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 |
| `blockScalarChompingWarning` | Warn when block scalars (`\|` or `>`) use implicit clip chomping, which adds a trailing newline that may be unintentional |
| `allowConcurrencyQueue` | Enable the `concurrency.queue` workflow property |
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:
+6 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.54",
"version": "0.3.28",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -36,7 +36,7 @@
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"prepublishOnly": "npm run build && npm run test",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
@@ -48,8 +48,8 @@
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.54",
"@actions/workflow-parser": "^0.3.54",
"@actions/languageservice": "^0.3.28",
"@actions/workflow-parser": "^0.3.28",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -57,7 +57,7 @@
"yaml": "^2.1.3"
},
"engines": {
"node": ">= 20"
"node": ">= 18"
},
"files": [
"dist/**/*",
@@ -73,10 +73,9 @@
"eslint-plugin-prettier": "^4.2.1",
"fetch-mock": "^9.11.0",
"jest": "^29.0.3",
"node-fetch": "^2.6.7",
"prettier": "^2.8.3",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"typescript": "^5.8.3"
"typescript": "^4.8.4"
}
}
+15 -59
View File
@@ -1,18 +1,8 @@
import {
documentLinks,
getCodeActions,
getInlayHints,
hover,
validate,
ValidationConfig
} from "@actions/languageservice";
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest";
import {
CodeAction,
CodeActionKind,
CodeActionParams,
CompletionItem,
Connection,
DocumentLink,
@@ -22,27 +12,24 @@ import {
HoverParams,
InitializeParams,
InitializeResult,
InlayHint,
InlayHintParams,
TextDocumentIdentifier,
TextDocumentPositionParams,
TextDocuments,
TextDocumentSyncKind
} from "vscode-languageserver";
import {TextDocument} from "vscode-languageserver-textdocument";
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";
import {ReadFileRequest, Requests} from "./request.js";
import {getActionsMetadataProvider} from "./utils/action-metadata.js";
import {TTLCache} from "./utils/cache.js";
import {timeOperation} from "./utils/timer.js";
import {valueProviders} from "./value-providers.js";
import {getClient} from "./client";
import {Commands} from "./commands";
import {contextProviders} from "./context-providers";
import {descriptionProvider} from "./description-provider";
import {getFileProvider} from "./file-provider";
import {InitializationOptions, RepositoryContext} from "./initializationOptions";
import {onCompletion} from "./on-completion";
import {ReadFileRequest, Requests} from "./request";
import {getActionsMetadataProvider} from "./utils/action-metadata";
import {TTLCache} from "./utils/cache";
import {timeOperation} from "./utils/timer";
import {valueProviders} from "./value-providers";
export function initConnection(connection: Connection) {
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
@@ -52,7 +39,6 @@ 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);
@@ -76,8 +62,6 @@ export function initConnection(connection: Connection) {
setLogLevel(options.logLevel);
}
featureFlags = new FeatureFlags(options.experimentalFeatures);
const result: InitializeResult = {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Full,
@@ -88,10 +72,6 @@ export function initConnection(connection: Connection) {
hoverProvider: true,
documentLinkProvider: {
resolveProvider: false
},
inlayHintProvider: true,
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix]
}
}
};
@@ -108,11 +88,6 @@ 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();
@@ -136,8 +111,7 @@ export function initConnection(connection: Connection) {
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
}),
featureFlags
})
};
const result = await validate(textDocument, config);
@@ -154,8 +128,7 @@ export function initConnection(connection: Connection) {
getDocument(documents, textDocument),
client,
repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)),
cache,
featureFlags
cache
)
);
});
@@ -185,23 +158,6 @@ export function initConnection(connection: Connection) {
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
});
connection.languages.inlayHint.on(async ({textDocument}: InlayHintParams): Promise<InlayHint[] | null> => {
return timeOperation("inlayHints", () => {
return getInlayHints(getDocument(documents, textDocument));
});
});
connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
const document = getDocument(documents, params.textDocument);
return getCodeActions({
uri: params.textDocument.uri,
documentContent: document.getText(),
diagnostics: params.context.diagnostics,
only: params.context.only,
featureFlags
});
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
+3 -3
View File
@@ -1,9 +1,9 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {Mode} from "@actions/languageservice/context-providers/default";
import {contextProviders} from "./context-providers.js";
import {RepositoryContext} from "./initializationOptions.js";
import {TTLCache} from "./utils/cache.js";
import {contextProviders} from "./context-providers";
import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache";
describe("contextProviders", () => {
const mockCache = new TTLCache();
+5 -5
View File
@@ -3,11 +3,11 @@ import {ContextProviderConfig} from "@actions/languageservice";
import {Mode} from "@actions/languageservice/context-providers/default";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {Octokit} from "@octokit/rest";
import {getSecrets} from "./context-providers/secrets.js";
import {getStepsContext} from "./context-providers/steps.js";
import {getVariables} from "./context-providers/variables.js";
import {RepositoryContext} from "./initializationOptions.js";
import {TTLCache} from "./utils/cache.js";
import {getSecrets} from "./context-providers/secrets";
import {getStepsContext} from "./context-providers/steps";
import {getVariables} from "./context-providers/variables";
import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache";
export function contextProviders(
client: Octokit | undefined,
@@ -1,7 +1,7 @@
import {ActionOutputs, ActionReference} from "@actions/languageservice/action";
import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata.js";
import {TTLCache} from "../utils/cache.js";
import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache";
export async function getActionOutputs(
octokit: Octokit,
@@ -6,10 +6,10 @@ import {warn} from "@actions/languageservice/log";
import {isMapping, isString} from "@actions/workflow-parser";
import {Octokit} from "@octokit/rest";
import {RepositoryContext} from "../initializationOptions.js";
import {TTLCache} from "../utils/cache.js";
import {errorStatus} from "../utils/error.js";
import {getRepoPermission} from "../utils/repo-permission.js";
import {RepositoryContext} from "../initializationOptions";
import {TTLCache} from "../utils/cache";
import {errorStatus} from "../utils/error";
import {getRepoPermission} from "../utils/repo-permission";
export async function getSecrets(
workflowContext: WorkflowContext,
@@ -3,9 +3,9 @@ import {getStepsContext as getDefaultStepsContext} from "@actions/languageservic
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
import {createWorkflowContext} from "../test-utils/workflow-context.js";
import {TTLCache} from "../utils/cache.js";
import {getStepsContext} from "./steps.js";
import {createWorkflowContext} from "../test-utils/workflow-context";
import {TTLCache} from "../utils/cache";
import {getStepsContext} from "./steps";
const workflow = `
name: Caching Primes
@@ -84,17 +84,13 @@ it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
// Get the step context
const stepContext = stepsContext?.get("cache-primes");
if (!stepContext) {
throw new Error("Expected stepContext to be defined");
}
expect(isDescriptionDictionary(stepContext)).toBe(true);
expect(stepContext).toBeDefined();
expect(isDescriptionDictionary(stepContext!)).toBe(true);
// Get the outputs - should be a dictionary, not null
const outputs = (stepContext as DescriptionDictionary).get("outputs");
if (!outputs) {
throw new Error("Expected outputs to be defined");
}
expect(isDescriptionDictionary(outputs)).toBe(true);
expect(outputs).toBeDefined();
expect(isDescriptionDictionary(outputs!)).toBe(true);
// Outputs should be marked incomplete to allow dynamic outputs
const outputsDict = outputs as DescriptionDictionary;
@@ -3,8 +3,8 @@ import {parseActionReference} from "@actions/languageservice/action";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Octokit} from "@octokit/rest";
import {TTLCache} from "../utils/cache.js";
import {getActionOutputs} from "./action-outputs.js";
import {TTLCache} from "../utils/cache";
import {getActionOutputs} from "./action-outputs";
export async function getStepsContext(
octokit: Octokit,
@@ -7,10 +7,10 @@ import {isMapping, isString} from "@actions/workflow-parser";
import {Octokit} from "@octokit/rest";
import {RequestError} from "@octokit/request-error";
import {RepositoryContext} from "../initializationOptions.js";
import {TTLCache} from "../utils/cache.js";
import {errorStatus} from "../utils/error.js";
import {getRepoPermission} from "../utils/repo-permission.js";
import {RepositoryContext} from "../initializationOptions";
import {TTLCache} from "../utils/cache";
import {errorStatus} from "../utils/error";
import {getRepoPermission} from "../utils/repo-permission";
export async function getVariables(
workflowContext: WorkflowContext,
+3 -3
View File
@@ -1,8 +1,8 @@
import {DescriptionProvider} from "@actions/languageservice/hover";
import {Octokit} from "@octokit/rest";
import {getActionDescription} from "./description-providers/action-description.js";
import {getActionInputDescription} from "./description-providers/action-input.js";
import {TTLCache} from "./utils/cache.js";
import {getActionDescription} from "./description-providers/action-description";
import {getActionInputDescription} from "./description-providers/action-input";
import {TTLCache} from "./utils/cache";
export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider {
const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => {
@@ -1,9 +1,9 @@
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
import {createWorkflowContext} from "../test-utils/workflow-context.js";
import {TTLCache} from "../utils/cache.js";
import {getActionDescription} from "./action-description.js";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
import {createWorkflowContext} from "../test-utils/workflow-context";
import {TTLCache} from "../utils/cache";
import {getActionDescription} from "./action-description";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
const workflow = `
name: Hello World
@@ -2,8 +2,8 @@ import {actionUrl, parseActionReference} from "@actions/languageservice/action";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata.js";
import {TTLCache} from "../utils/cache.js";
import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache";
export async function getActionDescription(client: Octokit, cache: TTLCache, step: Step): Promise<string | undefined> {
if (!isActionStep(step)) {
@@ -2,10 +2,10 @@ import {StringToken} from "@actions/workflow-parser/templates/tokens/string-toke
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
import {createWorkflowContext} from "../test-utils/workflow-context.js";
import {TTLCache} from "../utils/cache.js";
import {getActionInputDescription} from "./action-input.js";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
import {createWorkflowContext} from "../test-utils/workflow-context";
import {TTLCache} from "../utils/cache";
import {getActionInputDescription} from "./action-input";
const workflow = `
name: Hello World
@@ -4,8 +4,8 @@ import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata.js";
import {TTLCache} from "../utils/cache.js";
import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache";
export async function getActionInputDescription(
client: Octokit,
+1 -1
View File
@@ -2,7 +2,7 @@ import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
import {Octokit} from "@octokit/rest";
import {TTLCache} from "./utils/cache.js";
import {TTLCache} from "./utils/cache";
import * as vscodeURI from "vscode-uri";
export function getFileProvider(
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from "vscode-languageserver/browser";
import {createConnection as createNodeConnection} from "vscode-languageserver/node";
import {initConnection} from "./connection.js";
import {initConnection} from "./connection";
/** Helper function determining whether we are executing with node runtime */
function isNode(): boolean {
@@ -1,4 +1,3 @@
import {ExperimentalFeatures} from "@actions/expressions";
import {LogLevel} from "@actions/languageservice/log";
export {LogLevel} from "@actions/languageservice/log";
@@ -29,12 +28,6 @@ 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 {
+7 -10
View File
@@ -1,14 +1,13 @@
import {complete} from "@actions/languageservice/complete";
import type {FeatureFlags} from "@actions/expressions";
import {Octokit} from "@octokit/rest";
import {CompletionItem, Connection, Position} from "vscode-languageserver";
import {TextDocument} from "vscode-languageserver-textdocument";
import {contextProviders} from "./context-providers.js";
import {getFileProvider} from "./file-provider.js";
import {RepositoryContext} from "./initializationOptions.js";
import {Requests} from "./request.js";
import {TTLCache} from "./utils/cache.js";
import {valueProviders} from "./value-providers.js";
import {contextProviders} from "./context-providers";
import {getFileProvider} from "./file-provider";
import {RepositoryContext} from "./initializationOptions";
import {Requests} from "./request";
import {TTLCache} from "./utils/cache";
import {valueProviders} from "./value-providers";
export async function onCompletion(
connection: Connection,
@@ -16,13 +15,11 @@ export async function onCompletion(
document: TextDocument,
client: Octokit | undefined,
repoContext: RepositoryContext | undefined,
cache: TTLCache,
featureFlags?: FeatureFlags
cache: TTLCache
): Promise<CompletionItem[]> {
return await complete(document, position, {
valueProviderConfig: repoContext && valueProviders(client, repoContext, cache),
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
featureFlags,
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
return await connection.sendRequest(Requests.ReadFile, {path});
})
@@ -1,7 +1,7 @@
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
import {fetchActionMetadata} from "./action-metadata.js";
import {TTLCache} from "./cache.js";
import {fetchActionMetadata} from "./action-metadata";
import {TTLCache} from "./cache";
// A simplified version of the action.yml file from actions/checkout
const actionMetadataContent = `
+2 -2
View File
@@ -3,8 +3,8 @@ import {ActionsMetadataProvider} from "@actions/languageservice";
import {error} from "@actions/languageservice/log";
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
import {parse} from "yaml";
import {TTLCache} from "./cache.js";
import {errorMessage, errorStatus} from "./error.js";
import {TTLCache} from "./cache";
import {errorMessage, errorStatus} from "./error";
export function getActionsMetadataProvider(
client: Octokit | undefined,
+4 -4
View File
@@ -1,9 +1,9 @@
import {error} from "@actions/languageservice/log";
import {Octokit} from "@octokit/rest";
import {RepositoryContext} from "../initializationOptions.js";
import {TTLCache} from "./cache.js";
import {errorStatus} from "./error.js";
import {getUsername} from "./username.js";
import {RepositoryContext} from "../initializationOptions";
import {TTLCache} from "./cache";
import {errorStatus} from "./error";
import {getUsername} from "./username";
export type RepoPermission = "admin" | "write" | "read" | "none";
+1 -1
View File
@@ -1,5 +1,5 @@
import {Octokit} from "@octokit/rest";
import {TTLCache} from "./cache.js";
import {TTLCache} from "./cache";
export async function getUsername(octokit: Octokit, cache: TTLCache): Promise<string> {
return await cache.get(`/username`, undefined, () => fetchUsername(octokit));
+5 -5
View File
@@ -2,11 +2,11 @@ import {ValueProviderConfig} from "@actions/languageservice";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {ValueProviderKind} from "@actions/languageservice/value-providers/config";
import {Octokit} from "@octokit/rest";
import {RepositoryContext} from "./initializationOptions.js";
import {TTLCache} from "./utils/cache.js";
import {getActionInputValues} from "./value-providers/action-inputs.js";
import {getEnvironments} from "./value-providers/job-environment.js";
import {getRunnerLabels} from "./value-providers/runs-on.js";
import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache";
import {getActionInputValues} from "./value-providers/action-inputs";
import {getEnvironments} from "./value-providers/job-environment";
import {getRunnerLabels} from "./value-providers/runs-on";
export function valueProviders(
client: Octokit | undefined,
@@ -3,8 +3,8 @@ import {WorkflowContext} from "@actions/languageservice/context/workflow-context
import {Value} from "@actions/languageservice/value-providers/config";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata.js";
import {TTLCache} from "../utils/cache.js";
import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache";
export async function getActionInputs(
client: Octokit,
@@ -1,6 +1,6 @@
import {Value} from "@actions/languageservice/value-providers/config";
import {Octokit} from "@octokit/rest";
import {TTLCache} from "../utils/cache.js";
import {TTLCache} from "../utils/cache";
export async function getEnvironments(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> {
const environments = await cache.get(`${owner}/${name}/environments`, undefined, () =>
@@ -2,8 +2,8 @@ import {log} from "@actions/languageservice/log";
import {Value} from "@actions/languageservice/value-providers/config";
import {DEFAULT_RUNNER_LABELS} from "@actions/languageservice/value-providers/default";
import {Octokit} from "@octokit/rest";
import {TTLCache} from "../utils/cache.js";
import {errorMessage} from "../utils/error.js";
import {TTLCache} from "../utils/cache";
import {errorMessage} from "../utils/error";
// Limitation: getRunnerLabels returns default hosted labels and labels for repository self-hosted runners.
// It doesn't return labels for organization runners visible to the repository.
+1 -2
View File
@@ -5,7 +5,6 @@
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "./dist",
"skipLibCheck": true
"outDir": "./dist"
}
}
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.54",
"version": "0.3.28",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -35,7 +35,7 @@
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
"prebuild": "npm run minify-json",
@@ -47,15 +47,15 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.54",
"@actions/workflow-parser": "^0.3.54",
"@actions/expressions": "^0.3.28",
"@actions/workflow-parser": "^0.3.28",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
"yaml": "^2.1.1"
},
"engines": {
"node": ">= 20"
"node": ">= 18"
},
"files": [
"dist/**/*"
@@ -74,6 +74,6 @@
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1",
"typescript": "^5.8.3"
"typescript": "^4.8.4"
}
}
@@ -1,55 +0,0 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
import {CodeActionContext, CodeActionProvider} from "./types.js";
import {getQuickfixProviders} from "./quickfix/quickfix-providers.js";
export interface CodeActionParams {
uri: string;
documentContent: string;
diagnostics: Diagnostic[];
only?: string[];
featureFlags?: FeatureFlags;
}
export function getCodeActions(params: CodeActionParams): CodeAction[] {
const actions: CodeAction[] = [];
const context: CodeActionContext = {
uri: params.uri,
documentContent: params.documentContent,
featureFlags: params.featureFlags
};
// Build providers map based on feature flags
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
[CodeActionKind.QuickFix, getQuickfixProviders(params.featureFlags)]
// [CodeActionKind.Refactor, getRefactorProviders(params.featureFlags)],
// [CodeActionKind.Source, getSourceProviders(params.featureFlags)],
// etc
]);
// Filter to requested kinds, or use all if none specified
const requestedKinds = params.only;
const kindsToCheck = requestedKinds
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
: [...providersByKind.keys()];
for (const diagnostic of params.diagnostics) {
for (const kind of kindsToCheck) {
const providers = providersByKind.get(kind) ?? [];
for (const provider of providers) {
if (provider.diagnosticCodes.includes(diagnostic.code)) {
const action = provider.createCodeAction(context, diagnostic);
if (action) {
action.kind = kind;
action.diagnostics = [diagnostic];
actions.push(action);
}
}
}
}
}
return actions;
}
export type {CodeActionContext, CodeActionProvider} from "./types.js";
@@ -1,245 +0,0 @@
import {isMapping} from "@actions/workflow-parser";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {CodeAction, Position, TextEdit} from "vscode-languageserver-types";
import {error} from "../../log.js";
import {findToken} from "../../utils/find-token.js";
import {getOrParseWorkflow} from "../../utils/workflow-cache.js";
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action-reference.js";
import {CodeActionContext, CodeActionProvider} from "../types.js";
/**
* Information extracted from a step token needed to generate edits
*/
interface StepInfo {
/** Column where step keys start (1-indexed), e.g., the column of "uses:" */
stepKeyColumn: number;
/** End line of the step (1-indexed) */
stepEndLine: number;
/** Detected indent size (spaces per level) */
indentSize: number;
/** Information about existing with: block, if present */
withInfo?: {
keyColumn: number;
keyEndLine: number;
valueEndLine: number;
hasChildren: boolean;
/** Column of first child input (1-indexed), for indentation detection */
firstChildColumn?: number;
};
}
export const addMissingInputsProvider: CodeActionProvider = {
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
createCodeAction(context: CodeActionContext, diagnostic): CodeAction | undefined {
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
if (!data) {
return undefined;
}
// Parse the document to get the step token
const stepInfo = getStepInfo(context, diagnostic.range.start);
if (!stepInfo) {
return undefined;
}
const edits = createInputEdits(data.missingInputs, stepInfo);
if (!edits || edits.length === 0) {
return undefined;
}
const inputNames = data.missingInputs.map(i => i.name).join(", ");
return {
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
edit: {
changes: {
[context.uri]: edits
}
}
};
}
};
/**
* Parse the document and extract step information needed for generating edits.
* Returns undefined if parsing fails or the step token cannot be found.
*/
function getStepInfo(context: CodeActionContext, diagnosticPosition: Position): StepInfo | undefined {
// Parse the document (uses cache if available from validation)
const file = {name: context.uri, content: context.documentContent};
const parseResult = getOrParseWorkflow(file, context.uri);
if (!parseResult.value) {
error("Failed to parse workflow for missing inputs quickfix");
return undefined;
}
// Find the token at the diagnostic position
const {path} = findToken(diagnosticPosition, parseResult.value);
// Walk up the path to find the step token (regular-step)
const stepToken = findStepInPath(path);
if (!stepToken) {
error("Could not find step token for missing inputs quickfix");
return undefined;
}
return extractStepInfo(stepToken);
}
/**
* Find the step token (regular-step) in the token path
*/
function findStepInPath(path: TemplateToken[]): MappingToken | undefined {
// Walk backwards through path to find the step
for (let i = path.length - 1; i >= 0; i--) {
if (path[i].definition?.key === "regular-step" && isMapping(path[i])) {
return path[i] as MappingToken;
}
}
return undefined;
}
/**
* Extract position and indentation info from a step token
*/
function extractStepInfo(stepToken: MappingToken): StepInfo | undefined {
if (!stepToken.range) {
return undefined;
}
// Get the column of the first key in the step
let stepKeyColumn = stepToken.range.start.column;
if (stepToken.count > 0) {
const firstEntry = stepToken.get(0);
if (firstEntry?.key.range) {
stepKeyColumn = firstEntry.key.range.start.column;
}
}
// Find the with: block if present
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const {key, value} of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
break;
}
}
// Calculate indent size
let indentSize = 2; // Default
let withInfo: StepInfo["withInfo"];
if (withKey?.range && withToken?.range) {
// Has with: block - extract its info
const hasChildren = isMapping(withToken) && withToken.count > 0;
let firstChildColumn: number | undefined;
if (hasChildren) {
const firstChild = (withToken as MappingToken).get(0);
if (firstChild?.key.range) {
firstChildColumn = firstChild.key.range.start.column;
// Detect indent size from with: children
indentSize = firstChildColumn - withKey.range.start.column;
}
}
withInfo = {
keyColumn: withKey.range.start.column,
keyEndLine: withKey.range.end.line,
valueEndLine: withToken.range.end.line,
hasChildren,
firstChildColumn
};
} else {
// No with: block - detect indent size using heuristics
// Based on the step key column position, estimate indent size
// 2-space indent files typically have step keys at column 7
// 4-space indent files typically have step keys at column 15
const zeroIndexedCol = stepKeyColumn - 1;
if (zeroIndexedCol >= 10) {
indentSize = 4;
}
}
return {
stepKeyColumn,
stepEndLine: stepToken.range.end.line,
indentSize,
withInfo
};
}
/**
* Generate text edits to add missing inputs
*/
function createInputEdits(missingInputs: MissingInputsDiagnosticData["missingInputs"], stepInfo: StepInfo): TextEdit[] {
const formatInputLines = (indent: string) =>
missingInputs.map(input => {
const value = input.default ?? '""';
return `${indent}${input.name}: ${value}`;
});
if (stepInfo.withInfo) {
// `with:` exists - add inputs to existing block
const withIndent = stepInfo.withInfo.keyColumn - 1; // 0-indexed
const inputIndentSize = stepInfo.withInfo.firstChildColumn
? stepInfo.withInfo.firstChildColumn - stepInfo.withInfo.keyColumn
: stepInfo.indentSize;
const inputIndent = " ".repeat(withIndent + inputIndentSize);
const inputLines = formatInputLines(inputIndent);
// Calculate insert position
let insertLine: number;
if (stepInfo.withInfo.hasChildren) {
// Insert after the last child (at end of with: block)
// valueEndLine is 1-indexed, we want 0-indexed for Position
insertLine = stepInfo.withInfo.valueEndLine - 1;
} else {
// Empty with: block - insert on the next line after with:
// keyEndLine is 1-indexed, convert to 0-indexed and go to next line
insertLine = stepInfo.withInfo.keyEndLine;
}
const insertPosition: Position = {
line: insertLine,
character: 0
};
return [
{
range: {start: insertPosition, end: insertPosition},
newText: inputLines.map(line => line + "\n").join("")
}
];
} else {
// No `with:` key - add `with:` at the same level as other step keys
const withKeyIndent = stepInfo.stepKeyColumn - 1; // 0-indexed (columns are 1-based)
const withIndent = " ".repeat(withKeyIndent);
const inputIndent = " ".repeat(withKeyIndent + stepInfo.indentSize);
const inputLines = formatInputLines(inputIndent);
const newText = `${withIndent}with:\n` + inputLines.map(line => `${line}\n`).join("");
// Insert at end of step
// stepEndLine is 1-indexed, we want 0-indexed and insert before the line after
const insertPosition: Position = {
line: stepInfo.stepEndLine - 1,
character: 0
};
return [
{
range: {start: insertPosition, end: insertPosition},
newText
}
];
}
}
@@ -1,13 +0,0 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeActionProvider} from "../types.js";
import {addMissingInputsProvider} from "./add-missing-inputs.js";
export function getQuickfixProviders(featureFlags?: FeatureFlags): CodeActionProvider[] {
const providers: CodeActionProvider[] = [];
if (featureFlags?.isEnabled("missingInputsQuickfix")) {
providers.push(addMissingInputsProvider);
}
return providers;
}
@@ -1,90 +0,0 @@
import * as path from "path";
import {fileURLToPath} from "url";
import {loadTestCases, runTestCase} from "./runner.js";
import {ValidationConfig} from "../../validate.js";
import {ActionMetadata, ActionReference} from "../../action.js";
import {clearCache} from "../../utils/workflow-cache.js";
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Mock action metadata provider for tests
const validationConfig: ValidationConfig = {
actionsMetadataProvider: {
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
const key = `${ref.owner}/${ref.name}@${ref.ref}`;
const metadata: Record<string, ActionMetadata> = {
"actions/cache@v1": {
name: "Cache",
description: "Cache dependencies",
inputs: {
path: {
description: "A list of files to cache",
required: true
},
key: {
description: "Cache key",
required: true
},
"restore-keys": {
description: "Restore keys",
required: false
}
}
},
"actions/setup-node@v3": {
name: "Setup Node",
description: "Setup Node.js",
inputs: {
"node-version": {
description: "Node version",
required: true,
default: "16"
}
}
}
};
return Promise.resolve(metadata[key]);
}
}
};
// Point to the source testdata directory
const testdataDir = path.join(__dirname, "testdata");
beforeEach(() => {
clearCache();
});
describe("code action golden tests", () => {
const testCases = loadTestCases(testdataDir);
if (testCases.length === 0) {
it.todo("no test cases found - add .yml files to testdata/");
return;
}
for (const testCase of testCases) {
it(testCase.name, async () => {
const result = await runTestCase(testCase, validationConfig);
if (!result.passed) {
let errorMessage = result.error || "Test failed";
if (result.expected !== undefined && result.actual !== undefined) {
errorMessage += "\n\n";
errorMessage += "=== EXPECTED (golden file) ===\n";
errorMessage += result.expected;
errorMessage += "\n\n";
errorMessage += "=== ACTUAL ===\n";
errorMessage += result.actual;
}
throw new Error(errorMessage);
}
});
}
});
@@ -1,231 +0,0 @@
import * as fs from "fs";
import * as path from "path";
import {TextEdit} from "vscode-languageserver-types";
import {TextDocument} from "vscode-languageserver-textdocument";
import {FeatureFlags} from "@actions/expressions";
import {validate, ValidationConfig} from "../../validate.js";
import {getCodeActions, CodeActionParams} from "../code-actions.js";
// Marker pattern: # want "diagnostic message" fix="code-action-name"
const MARKER_PATTERN = /#\s*want\s+"([^"]+)"(?:\s+fix="([^"]+)")?/;
export interface TestCase {
name: string;
inputPath: string;
goldenPath: string;
input: string;
golden: string;
markers: Marker[];
}
export interface Marker {
line: number;
message: string;
fix?: string;
}
export interface TestResult {
name: string;
passed: boolean;
error?: string;
expected?: string;
actual?: string;
}
/**
* Parse markers from input file content
*/
export function parseMarkers(content: string): Marker[] {
const lines = content.split("\n");
const markers: Marker[] = [];
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(MARKER_PATTERN);
if (match) {
markers.push({
line: i,
message: match[1],
fix: match[2]
});
}
}
return markers;
}
/**
* Strip markers from content (for processing)
*/
export function stripMarkers(content: string): string {
return content
.split("\n")
.map(line => line.replace(MARKER_PATTERN, "").trimEnd())
.join("\n");
}
/**
* Load all test cases from a testdata directory
*/
export function loadTestCases(testdataDir: string): TestCase[] {
const testCases: TestCase[] = [];
function walkDir(dir: string) {
const entries = fs.readdirSync(dir, {withFileTypes: true});
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".yml") && !entry.name.endsWith(".golden.yml")) {
const goldenPath = fullPath.replace(".yml", ".golden.yml");
if (fs.existsSync(goldenPath)) {
const input = fs.readFileSync(fullPath, "utf-8");
const golden = fs.readFileSync(goldenPath, "utf-8");
testCases.push({
name: path.relative(testdataDir, fullPath),
inputPath: fullPath,
goldenPath,
input,
golden,
markers: parseMarkers(input)
});
}
}
}
}
walkDir(testdataDir);
return testCases;
}
/**
* Apply text edits to a document
*/
export function applyEdits(content: string, edits: TextEdit[]): string {
// Sort edits in reverse order by position to apply from bottom to top
const sortedEdits = [...edits].sort((a, b) => {
if (b.range.start.line !== a.range.start.line) {
return b.range.start.line - a.range.start.line;
}
return b.range.start.character - a.range.start.character;
});
const lines = content.split("\n");
for (const edit of sortedEdits) {
const startLine = edit.range.start.line;
const startChar = edit.range.start.character;
const endLine = edit.range.end.line;
const endChar = edit.range.end.character;
const before = lines[startLine].slice(0, startChar);
const after = lines[endLine].slice(endChar);
const newLines = edit.newText.split("\n");
newLines[0] = before + newLines[0];
newLines[newLines.length - 1] = newLines[newLines.length - 1] + after;
lines.splice(startLine, endLine - startLine + 1, ...newLines);
}
return lines.join("\n");
}
/**
* Run a single test case
*/
export async function runTestCase(testCase: TestCase, validationConfig: ValidationConfig): Promise<TestResult> {
const strippedInput = stripMarkers(testCase.input);
const document = TextDocument.create("file:///test.yml", "yaml", 1, strippedInput);
// 1. Validate and get diagnostics
const diagnostics = await validate(document, validationConfig);
// 2. Verify all expected diagnostics are present
const missingDiagnostics: string[] = [];
for (const marker of testCase.markers) {
const found = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
if (!found) {
missingDiagnostics.push(`line ${marker.line}: "${marker.message}"`);
}
}
if (missingDiagnostics.length > 0) {
return {
name: testCase.name,
passed: false,
error: `Missing expected diagnostics:\n ${missingDiagnostics.join(
"\n "
)}\n\nActual diagnostics:\n ${diagnostics.map(d => `line ${d.range.start.line}: "${d.message}"`).join("\n ")}`
};
}
// 3. Collect all edits from all matching code actions
const allEdits: TextEdit[] = [];
for (const marker of testCase.markers) {
if (!marker.fix) {
continue;
}
const diagnostic = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
if (!diagnostic) {
continue; // Already reported above
}
const params: CodeActionParams = {
uri: document.uri,
documentContent: strippedInput,
diagnostics: [diagnostic],
featureFlags: new FeatureFlags({all: true})
};
const actions = getCodeActions(params);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- marker.fix is checked at the start of the loop
const matchingAction = actions.find(a => a.title.toLowerCase().includes(marker.fix!.toLowerCase()));
if (!matchingAction) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" not found for diagnostic on line ${marker.line}.\nAvailable actions: ${
actions.map(a => a.title).join(", ") || "(none)"
}`
};
}
if (!matchingAction.edit?.changes) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" has no edits`
};
}
const edits = matchingAction.edit.changes[document.uri] || [];
allEdits.push(...edits);
}
// 4. Apply all edits and compare to golden file
const actualOutput = applyEdits(strippedInput, allEdits);
const expectedOutput = testCase.golden;
if (actualOutput.trim() !== expectedOutput.trim()) {
return {
name: testCase.name,
passed: false,
error: "Output does not match golden file",
expected: expectedOutput,
actual: actualOutput
};
}
return {
name: testCase.name,
passed: true
};
}
@@ -1,9 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -1,7 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
@@ -1,10 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: ${{ runner.os }}-
path: ""
key: ""
@@ -1,8 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
restore-keys: ${{ runner.os }}-
@@ -1,9 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -1,6 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
@@ -1,9 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -1,6 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
-23
View File
@@ -1,23 +0,0 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeAction, Diagnostic} from "vscode-languageserver-types";
export interface CodeActionContext {
uri: string;
documentContent: string;
featureFlags?: FeatureFlags;
}
/**
* A provider that can produce a code action for a given diagnostic
*/
export interface CodeActionProvider {
/**
* The diagnostic codes this provider handles
*/
diagnosticCodes: (string | number | undefined)[];
/**
* Create a code action for the diagnostic, if applicable
*/
createCodeAction(context: CodeActionContext, diagnostic: Diagnostic): CodeAction | undefined;
}
-689
View File
@@ -1,689 +0,0 @@
import {TextDocument} from "vscode-languageserver-textdocument";
import {complete} from "./complete";
import {clearCache} from "./utils/workflow-cache";
beforeEach(() => {
clearCache();
});
describe("complete action files", () => {
function createActionDocument(
content: string,
uri = "file:///test/action.yml"
): [TextDocument, {line: number; character: number}] {
// Parse cursor position and remove the | character
const cursorIndex = content.indexOf("|");
if (cursorIndex === -1) {
throw new Error("No cursor (|) found in content");
}
const newContent = content.substring(0, cursorIndex) + content.substring(cursorIndex + 1);
const doc = TextDocument.create(uri, "yaml", 1, newContent);
const position = doc.positionAt(cursorIndex);
return [doc, position];
}
describe("expression completion in composite actions", () => {
it("completes inputs context", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
inputs:
name:
description: The name
greeting:
description: The greeting
default: Hello
runs:
using: composite
steps:
- run: echo "\${{ inputs.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("name");
expect(labels).toContain("greeting");
});
it("completes steps context with prior step IDs", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- id: step1
run: echo "hello"
shell: bash
- id: step2
run: echo "\${{ steps.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("step1");
expect(labels).not.toContain("step2"); // Current step should not be included
});
it("completes step properties", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- id: greet
run: echo "hello"
shell: bash
- run: echo "\${{ steps.greet.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("outputs");
expect(labels).toContain("outcome");
expect(labels).toContain("conclusion");
});
it("does not include steps from after cursor position", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- id: first
run: echo "first"
shell: bash
- run: echo "\${{ steps.| }}"
shell: bash
- id: last
run: echo "last"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("first");
expect(labels).not.toContain("last");
});
it("completes github context in actions", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- run: echo "\${{ github.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("actor");
expect(labels).toContain("repository");
expect(labels).toContain("ref");
});
it("completes runner context in actions", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- run: echo "\${{ runner.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("os");
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", () => {
it("completes top-level keys", async () => {
const [doc, position] = createActionDocument(`n|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("name");
});
it("completes at empty line", async () => {
const [doc, position] = createActionDocument(`name: My Action
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("description");
expect(labels).toContain("runs");
expect(labels).toContain("inputs");
expect(labels).toContain("outputs");
expect(labels).toContain("branding");
expect(labels).toContain("author");
});
});
describe("runs completions", () => {
it("completes runs.using values", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("composite");
expect(labels).toContain("node20");
expect(labels).toContain("docker");
});
it("completes runs keys", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("using");
});
it("filters runs keys for node20 actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node20
|`);
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("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
runs:
using: composite
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show composite action keys
expect(labels).toContain("steps");
// Should NOT show Node.js or docker keys
expect(labels).not.toContain("main");
expect(labels).not.toContain("pre");
expect(labels).not.toContain("post");
expect(labels).not.toContain("image");
});
it("filters runs keys for docker actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: docker
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show Docker action keys
expect(labels).toContain("image");
expect(labels).toContain("args");
expect(labels).toContain("env");
expect(labels).toContain("entrypoint");
expect(labels).toContain("pre-entrypoint");
expect(labels).toContain("post-entrypoint");
// Should NOT show Node.js or composite keys
expect(labels).not.toContain("main");
expect(labels).not.toContain("steps");
});
it("prioritizes using when not set", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
|`);
const completions = await complete(doc, position);
// Find the using completion
const usingCompletion = completions.find(c => c.label === "using");
expect(usingCompletion).toBeDefined();
// It should have a sortText that makes it sort after snippets
expect(usingCompletion?.sortText).toBe("9_using");
});
it("completes step keys inside composite action steps", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: composite
steps:
- run: echo hello
shell: bash
- |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show step keys, not filtered by runs-level logic
expect(labels).toContain("run");
expect(labels).toContain("uses");
expect(labels).toContain("shell");
expect(labels).toContain("id");
expect(labels).toContain("name");
expect(labels).toContain("if");
expect(labels).toContain("env");
expect(labels).toContain("working-directory");
});
});
describe("branding completions", () => {
it("completes branding keys", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node20
main: index.js
branding:
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("icon");
expect(labels).toContain("color");
});
it("completes branding color values", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node20
main: index.js
branding:
color: |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("blue");
expect(labels).toContain("green");
expect(labels).toContain("red");
});
});
describe("inputs completions", () => {
it("completes input property keys", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
inputs:
my-input:
|
runs:
using: node20
main: index.js`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("description");
expect(labels).toContain("required");
expect(labels).toContain("default");
expect(labels).toContain("deprecationMessage");
});
});
describe("document type routing", () => {
it("routes action.yml to action completion", async () => {
const [doc, position] = createActionDocument(`n|`, "file:///my-repo/action.yml");
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("name");
// Should NOT contain workflow-specific keys
expect(labels).not.toContain("on");
expect(labels).not.toContain("jobs");
});
it("includes descriptions from schema for completion items", async () => {
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
const completions = await complete(doc, position);
const authorCompletion = completions.find(c => c.label === "author");
expect(authorCompletion).toBeDefined();
expect(authorCompletion?.documentation).toBeDefined();
expect((authorCompletion?.documentation as {value: string})?.value).toContain("author");
});
it("includes descriptions for branding completion", async () => {
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
const completions = await complete(doc, position);
const brandingCompletion = completions.find(c => c.label === "branding");
expect(brandingCompletion).toBeDefined();
expect(brandingCompletion?.documentation).toBeDefined();
expect((brandingCompletion?.documentation as {value: string})?.value).toContain("branding");
});
it("falls back to type description when property has no description", async () => {
// `inputs` uses shorthand form in schema: "inputs": "inputs-strict"
// So the property has no description, but the type `inputs-strict` does
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
const completions = await complete(doc, position);
const inputsCompletion = completions.find(c => c.label === "inputs");
expect(inputsCompletion).toBeDefined();
expect(inputsCompletion?.documentation).toBeDefined();
expect((inputsCompletion?.documentation as {value: string})?.value).toContain("Input parameters");
});
it("does not route workflow files to action completion", async () => {
const doc = TextDocument.create("file:///repo/.github/workflows/ci.yml", "yaml", 1, `o`);
const completions = await complete(doc, {line: 0, character: 1});
const labels = completions.map(c => c.label);
expect(labels).toContain("on");
expect(labels).toContain("jobs");
});
});
describe("action scaffolding snippets", () => {
it("offers full scaffolding snippets in empty file", async () => {
const [doc, position] = createActionDocument(`|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("Node.js Action");
expect(labels).toContain("Composite Action");
expect(labels).toContain("Docker Action");
// Verify they are snippets
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
expect(nodeSnippet?.kind).toBe(15); // CompletionItemKind.Snippet
expect(nodeSnippet?.insertTextFormat).toBe(2); // InsertTextFormat.Snippet
});
it("offers full scaffolding snippets when no name or description exists", async () => {
const [doc, position] = createActionDocument(`author: me
|`);
const completions = await complete(doc, position);
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
expect(nodeSnippet).toBeDefined();
// Full snippet should include name:
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toContain("name:");
});
it("offers runs-only snippets when name exists", async () => {
const [doc, position] = createActionDocument(`name: My Action
|`);
const completions = await complete(doc, position);
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
expect(nodeSnippet).toBeDefined();
// Runs-only snippet should start with inputs:, not name:
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toMatch(/^inputs:/);
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toContain("runs:");
});
it("offers runs-only snippets when description exists", async () => {
const [doc, position] = createActionDocument(`description: Does something
|`);
const completions = await complete(doc, position);
const compositeSnippet = completions.find(c => c.label === "Composite Action");
expect(compositeSnippet).toBeDefined();
// Runs-only snippet should start with inputs:, not description:
expect((compositeSnippet?.textEdit as {newText: string})?.newText).toMatch(/^inputs:/);
expect((compositeSnippet?.textEdit as {newText: string})?.newText).toContain("runs:");
});
it("does not offer snippets when runs.using already exists", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
using: composite
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).not.toContain("Node.js Action");
expect(labels).not.toContain("Composite Action");
expect(labels).not.toContain("Docker Action");
});
it("offers snippets inside runs when using is not set", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("Node.js Action");
expect(labels).toContain("Composite Action");
expect(labels).toContain("Docker Action");
});
it("does not offer snippets at root level when runs exists", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
steps: []
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).not.toContain("Node.js Action");
expect(labels).not.toContain("Composite Action");
expect(labels).not.toContain("Docker Action");
});
it("does not offer snippets when nested inside runs steps", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
using: composite
steps:
- |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).not.toContain("Node.js Action");
expect(labels).not.toContain("Composite Action");
expect(labels).not.toContain("Docker Action");
});
it("Node.js snippet contains expected content", async () => {
const [doc, position] = createActionDocument(`|`);
const completions = await complete(doc, position);
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
const text = (nodeSnippet?.textEdit as {newText: string})?.newText;
expect(text).toContain("using: node24");
expect(text).toContain("main:");
expect(text).toContain("inputs:");
expect(text).toContain("outputs:");
});
it("Composite snippet contains expected content", async () => {
const [doc, position] = createActionDocument(`|`);
const completions = await complete(doc, position);
const compositeSnippet = completions.find(c => c.label === "Composite Action");
const text = (compositeSnippet?.textEdit as {newText: string})?.newText;
expect(text).toContain("using: composite");
expect(text).toContain("steps:");
expect(text).toContain("shell: bash");
});
it("Docker snippet contains expected content", async () => {
const [doc, position] = createActionDocument(`|`);
const completions = await complete(doc, position);
const dockerSnippet = completions.find(c => c.label === "Docker Action");
const text = (dockerSnippet?.textEdit as {newText: string})?.newText;
expect(text).toContain("using: docker");
expect(text).toContain("image:");
expect(text).toContain("entrypoint:");
});
it("replaces typed text when selecting scaffolding snippet", async () => {
// User typed "compo" and then triggered completion
const [doc, position] = createActionDocument(`compo|`);
const completions = await complete(doc, position);
const compositeSnippet = completions.find(c => c.label === "Composite Action");
expect(compositeSnippet).toBeDefined();
// The textEdit should replace "compo", not insert after it
const textEdit = compositeSnippet?.textEdit as {range: {start: {character: number}; end: {character: number}}};
expect(textEdit.range.start.character).toBe(0); // Start of "compo"
expect(textEdit.range.end.character).toBe(5); // End of "compo"
});
it("handles empty file with no typed text", async () => {
const [doc, position] = createActionDocument(`|`);
const completions = await complete(doc, position);
const compositeSnippet = completions.find(c => c.label === "Composite Action");
const textEdit = compositeSnippet?.textEdit as {range: {start: {character: number}; end: {character: number}}};
// Zero-length range is fine when there's nothing to replace
expect(textEdit.range.start.character).toBe(0);
expect(textEdit.range.end.character).toBe(0);
});
});
});
-480
View File
@@ -1,480 +0,0 @@
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {Position} from "vscode-languageserver-textdocument";
import {CompletionItem, CompletionItemKind, InsertTextFormat, Range, TextEdit} from "vscode-languageserver-types";
import {Value} from "./value-providers/config.js";
/**
* Valid keys for each action type under the `runs:` section.
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
*/
const ACTION_NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
const ACTION_COMPOSITE_KEYS = new Set(["using", "steps"]);
const ACTION_DOCKER_KEYS = new Set([
"using",
"image",
"args",
"env",
"entrypoint",
"pre-entrypoint",
"pre-if",
"post-entrypoint",
"post-if"
]);
/**
* Action scaffolding snippets.
*
* Full variants include name, description, inputs, outputs, and runs.
* Runs-only variants include just the runs block.
*/
const ACTION_SNIPPET_NODEJS_FULL = `name: '\${1:Action Name}'
description: '\${2:What this action does}'
inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
runs:
# For more on JavaScript actions (including @actions/toolkit), see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
using: node24
main: index.js
# Sample index.js (vanilla JS, no build required):
#
# const fs = require('fs');
# const name = process.env.INPUT_NAME || 'World';
# const greeting = \\\`Hello \\\${name}\\\`;
# console.log(greeting);
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
`;
const ACTION_SNIPPET_NODEJS_RUNS = `inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
runs:
# For more on JavaScript actions (including @actions/toolkit), see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
using: node24
main: index.js
# Sample index.js (vanilla JS, no build required):
#
# const fs = require('fs');
# const name = process.env.INPUT_NAME || 'World';
# const greeting = \\\`Hello \\\${name}\\\`;
# console.log(greeting);
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
`;
const ACTION_SNIPPET_NODEJS_USING = `# For more on JavaScript actions (including @actions/toolkit), see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
using: node24
main: index.js
# Sample index.js (vanilla JS, no build required):
#
# console.log('Hello World');
`;
const ACTION_SNIPPET_COMPOSITE_FULL = `name: '\${1:Action Name}'
description: '\${2:What this action does}'
inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
value: \\\${{ steps.greet.outputs.greeting }}
runs:
# For more on composite actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
using: composite
steps:
- id: greet
shell: bash
env:
INPUT_NAME: \\\${{ inputs.name }}
run: |
GREETING="Hello \\$INPUT_NAME"
echo "\\$GREETING"
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
`;
const ACTION_SNIPPET_COMPOSITE_RUNS = `inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
value: \\\${{ steps.greet.outputs.greeting }}
runs:
# For more on composite actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
using: composite
steps:
- id: greet
shell: bash
env:
INPUT_NAME: \\\${{ inputs.name }}
run: |
GREETING="Hello \\$INPUT_NAME"
echo "\\$GREETING"
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
`;
const ACTION_SNIPPET_COMPOSITE_USING = `# For more on composite actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
using: composite
steps:
- shell: bash
run: echo "Hello World"
`;
const ACTION_SNIPPET_DOCKER_FULL = `name: '\${1:Action Name}'
description: '\${2:What this action does}'
inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
runs:
# For more on Docker actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
using: docker
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
image: '\${3:docker://alpine:3.20}'
env:
INPUT_NAME: \\\${{ inputs.name }}
entrypoint: '\${4:sh}'
args:
- -c
- |
GREETING="Hello \\$INPUT_NAME"
echo "\\$GREETING"
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
`;
const ACTION_SNIPPET_DOCKER_RUNS = `inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
runs:
# For more on Docker actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
using: docker
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
image: '\${1:docker://alpine:3.20}'
env:
INPUT_NAME: \\\${{ inputs.name }}
entrypoint: '\${2:sh}'
args:
- -c
- |
GREETING="Hello \\$INPUT_NAME"
echo "\\$GREETING"
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
`;
const ACTION_SNIPPET_DOCKER_USING = `# For more on Docker actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
using: docker
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
image: '\${1:docker://alpine:3.20}'
entrypoint: '\${2:sh}'
args:
- -c
- echo "Hello World"
`;
/**
* Filters action.yml `runs:` completions based on the `using:` value.
*
* When the user is completing keys under `runs:`:
* - If `using: node20` is set, only show Node.js action keys
* - If `using: composite` is set, only show composite action keys
* - If `using: docker` is set, only show Docker action keys
* - If `using:` is not set, show all keys but prioritize `using` first
*/
export function filterActionRunsCompletions(values: Value[], path: TemplateToken[], root: TemplateToken): Value[] {
// Find the runs mapping from the root
let runsMapping: MappingToken | undefined;
if (root instanceof MappingToken) {
for (let i = 0; i < root.count; i++) {
const {key, value} = root.get(i);
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
runsMapping = value;
break;
}
}
}
if (!runsMapping) {
return values;
}
// Check if the runs mapping is in our path (meaning we're completing inside it)
const isInsideRuns = path.some(token => token === runsMapping);
if (!isInsideRuns) {
return values;
}
// Find where runsMapping is in the path
const runsMappingIndex = path.indexOf(runsMapping);
if (runsMappingIndex === -1) {
return values;
}
// Check if there's anything after runsMapping in the path
// If so, we're nested deeper (e.g., inside steps sequence or a step mapping)
if (runsMappingIndex < path.length - 1) {
return values;
}
// Get the using value from the runs mapping
let usingValue: string | undefined;
for (let i = 0; i < runsMapping.count; i++) {
const {key, value} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
usingValue = value.toString();
break;
}
}
// Determine which keys to allow
let allowedKeys: Set<string>;
if (!usingValue) {
// No using value set - show all keys but prioritize "using"
return values.map(v => {
if (v.label.toLowerCase() === "using") {
return {...v, sortText: "9_using"}; // Sort after snippets (0_, 1_, 2_)
}
return v;
});
} else if (usingValue.match(/^node\d+$/i)) {
allowedKeys = ACTION_NODE_KEYS;
} else if (usingValue.toLowerCase() === "composite") {
allowedKeys = ACTION_COMPOSITE_KEYS;
} else if (usingValue.toLowerCase() === "docker") {
allowedKeys = ACTION_DOCKER_KEYS;
} else {
// Unknown using value - show all
return values;
}
// Filter to only allowed keys
return values.filter(v => allowedKeys.has(v.label.toLowerCase()));
}
/**
* Gets action scaffolding snippet completions for action.yml files.
*
* Returns snippet completions when `runs.using` is not present, offering
* three action types: Node.js, Composite, and Docker.
*
* Three variants per type:
* - "_FULL": Full scaffold with name, description, inputs, outputs, and runs
* - "_RUNS": Inputs, outputs, and runs (when name/description already exists)
* - "_USING": Minimal runs content (when inside `runs:` mapping)
*
* Which variant is shown depends on context:
* - Inside `runs:` mapping → "_USING" variants
* - At root with name/description → "_RUNS" variants
* - At root without name/description → "_FULL" variants
*/
export function getActionScaffoldingSnippets(
root: TemplateToken | undefined,
path: TemplateToken[],
position: Position,
replaceRange?: Range
): CompletionItem[] {
// Get the runs mapping from the root, if it exists
let runsMapping: MappingToken | undefined;
if (root instanceof MappingToken) {
for (let i = 0; i < root.count; i++) {
const {key, value} = root.get(i);
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
runsMapping = value;
break;
}
}
}
// Check if runs.using already exists - if so, no scaffolding needed
if (runsMapping) {
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
return [];
}
}
}
// Show "_USING" variants directly inside `runs`
const runsMappingIndex = runsMapping ? path.indexOf(runsMapping) : -1;
const isDirectlyInsideRuns = runsMappingIndex !== -1 && runsMappingIndex === path.length - 1;
if (isDirectlyInsideRuns) {
return [
createSnippetCompletion(
"Node.js Action",
"Scaffold a Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
ACTION_SNIPPET_NODEJS_USING,
position,
"0_nodejs",
replaceRange
),
createSnippetCompletion(
"Composite Action",
"Scaffold a composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
ACTION_SNIPPET_COMPOSITE_USING,
position,
"1_composite",
replaceRange
),
createSnippetCompletion(
"Docker Action",
"Scaffold a Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
ACTION_SNIPPET_DOCKER_USING,
position,
"2_docker",
replaceRange
)
];
}
// Not at root or `runs` already exists?
const isAtRoot = path.length === 0 || (path.length === 1 && path[0] === root);
if (!isAtRoot || runsMapping) {
return [];
}
// Determine which variant to show based on existing root keys
let hasNameOrDescription = false;
if (root instanceof MappingToken) {
for (let i = 0; i < root.count; i++) {
const keyStr = root.get(i).key.toString().toLowerCase();
if (keyStr === "name" || keyStr === "description") {
hasNameOrDescription = true;
break;
}
}
}
// Show "_RUNS" variants (inputs, outputs, and runs block)
if (hasNameOrDescription) {
return [
createSnippetCompletion(
"Node.js Action",
"Scaffold a Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
ACTION_SNIPPET_NODEJS_RUNS,
position,
"1_nodejs",
replaceRange
),
createSnippetCompletion(
"Composite Action",
"Scaffold a composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
ACTION_SNIPPET_COMPOSITE_RUNS,
position,
"2_composite",
replaceRange
),
createSnippetCompletion(
"Docker Action",
"Scaffold a Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
ACTION_SNIPPET_DOCKER_RUNS,
position,
"3_docker",
replaceRange
)
];
}
// Show "_FULL" variants (complete scaffold)
return [
createSnippetCompletion(
"Node.js Action",
"Scaffold a complete Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
ACTION_SNIPPET_NODEJS_FULL,
position,
"1_nodejs",
replaceRange
),
createSnippetCompletion(
"Composite Action",
"Scaffold a complete composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
ACTION_SNIPPET_COMPOSITE_FULL,
position,
"2_composite",
replaceRange
),
createSnippetCompletion(
"Docker Action",
"Scaffold a complete Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
ACTION_SNIPPET_DOCKER_FULL,
position,
"3_docker",
replaceRange
)
];
}
/**
* Creates a snippet completion item.
*/
function createSnippetCompletion(
label: string,
description: string,
snippetText: string,
position: Position,
sortText: string,
replaceRange?: Range
): CompletionItem {
// Use replace if we have a range, otherwise insert at position
const textEdit = replaceRange ? TextEdit.replace(replaceRange, snippetText) : TextEdit.insert(position, snippetText);
return {
label,
labelDetails: {description: "snippet"},
kind: CompletionItemKind.Snippet,
documentation: {
kind: "markdown",
value: description
},
insertTextFormat: InsertTextFormat.Snippet,
sortText,
textEdit
};
}
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {data, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
import {CompletionItem, CompletionItemKind, MarkupContent} from "vscode-languageserver-types";
import {data, DescriptionDictionary} from "@actions/expressions";
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
import {complete, getExpressionInput} from "./complete.js";
import {ContextProviderConfig} from "./context-providers/config.js";
import {registerLogger} from "./log.js";
@@ -68,16 +68,12 @@ describe("expressions", () => {
describe("top-level auto-complete", () => {
it("single region", async () => {
const input = "run-name: ${{ | }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
const result = await complete(...getPositionFromCursor(input));
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -112,16 +108,12 @@ describe("expressions", () => {
it("single region with existing input", async () => {
const input = "run-name: ${{ g| }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -134,16 +126,12 @@ describe("expressions", () => {
it("single region with existing condition", async () => {
const input = "run-name: ${{ g| == 'test' }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -156,16 +144,12 @@ 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,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -178,16 +162,12 @@ describe("expressions", () => {
it("multiple regions - first region", async () => {
const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -200,16 +180,12 @@ describe("expressions", () => {
it("multiple regions", async () => {
const input = "run-name: test-${{ github }}-${{ | }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -419,36 +395,6 @@ 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");
});
});
});
@@ -1164,16 +1110,7 @@ jobs:
`;
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"check_run_id",
"container",
"services",
"status",
"workflow_file_path",
"workflow_ref",
"workflow_repository",
"workflow_sha"
]);
expect(result.map(x => x.label)).toEqual(["check_run_id", "container", "services", "status"]);
});
it("job context is suggested within a job output", async () => {
@@ -1189,10 +1126,7 @@ jobs:
run: echo hi
`;
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"env",
"github",
@@ -1205,7 +1139,6 @@ jobs:
"steps",
"strategy",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -1317,7 +1250,6 @@ 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");
+67 -444
View File
@@ -6,7 +6,6 @@ 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());
@@ -20,12 +19,9 @@ describe("completion", () => {
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// 28 runner labels + 2 escape hatches (switch to list, switch to full syntax)
expect(result.length).toEqual(30);
expect(result.length).toEqual(12);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
expect(labels).toContain("(switch to list)");
expect(labels).toContain("(switch to mapping)");
});
it("needs", async () => {
@@ -60,7 +56,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(27);
expect(result.length).toEqual(11);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
@@ -99,7 +95,6 @@ jobs:
release:
types: |`;
const result = await complete(...getPositionFromCursor(input));
// Expect string values plus escape hatch to switch to list form
expect(result.map(x => x.label)).toEqual([
"created",
"deleted",
@@ -107,8 +102,7 @@ jobs:
"prereleased",
"published",
"released",
"unpublished",
"(switch to list)"
"unpublished"
]);
});
@@ -196,11 +190,8 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
expect(result).not.toBeUndefined();
// Custom value plus escape hatches for list and full syntax
expect(result.length).toEqual(3);
expect(result.length).toEqual(1);
expect(result[0].label).toEqual("my-custom-label");
expect(result.map(x => x.label)).toContain("(switch to list)");
expect(result.map(x => x.label)).toContain("(switch to mapping)");
});
it("custom value providers for sequences", async () => {
@@ -221,9 +212,7 @@ jobs:
expect(result[0].label).toEqual("my-custom-label");
});
it("does not show mapping keys or parent sibling keys in Key mode", async () => {
// At `container: |`, the scalar form is a string with no constants.
// Mapping keys should NOT be shown inline - but escape hatch to full syntax IS shown.
it("does not show parent mapping sibling keys", async () => {
const input = `on: push
jobs:
build:
@@ -231,21 +220,20 @@ jobs:
runs-on: ubuntu-latest`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// Only escape hatch to full syntax (container has mapping form but no sequence)
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
expect(result.length).toEqual(6);
// Should not contain other top-level job keys like `if` and `runs-on`
expect(result.map(x => x.label)).not.toContain("if");
expect(result.map(x => x.label)).not.toContain("runs-on");
});
it("does not show mapping keys in Key mode when structure is uncommitted", async () => {
// At `concurrency: |`, user is in Key mode but hasn't committed to a structure.
// The scalar form is a string with no constants, so no scalar completions.
// But escape hatch to full syntax IS shown as a way out.
it("shows mapping keys within a new map ", async () => {
const input = `on: push
jobs:
build:
concurrency: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
expect(result.map(x => x.label).sort()).toEqual(["cancel-in-progress", "group"]);
});
it("job key", async () => {
@@ -278,10 +266,7 @@ jobs:
concurrency: 'group-name'`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// Verify we get job-level completions, but concurrency is already present so excluded
expect(result.length).toBeGreaterThan(20);
expect(result.some(x => x.label === "runs-on")).toBe(true);
expect(result.some(x => x.label === "concurrency")).toBe(false);
expect(result).toHaveLength(29);
});
it("step key without space after colon", async () => {
@@ -350,9 +335,7 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
// Verify we get job-level completions including runs-on variants
expect(result.length).toBeGreaterThan(20);
expect(result.some(x => x.label === "steps")).toBe(true);
expect(result).toHaveLength(25);
});
it("complete from behind a colon will replace it", async () => {
@@ -365,8 +348,7 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
// Verify we get job-level completions
expect(result.length).toBeGreaterThan(20);
expect(result).toHaveLength(25);
const textEdit = result[0].textEdit as TextEdit;
expect(textEdit.range).toEqual({
start: {line: 5, character: 4},
@@ -465,9 +447,8 @@ jobs:
"timeout-minutes: "
]);
// One-of (scalar variant)
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
// One-of
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
});
it("custom indentation", async () => {
@@ -489,21 +470,20 @@ jobs:
"timeout-minutes: "
]);
// One-of (scalar variant)
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
// One-of
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
});
});
it("does not show mapping keys in Key mode for one-of with mapping variant", async () => {
// At `concurrency: |`, mapping keys should NOT be shown.
// Users who want the mapping form should use `concurrency (full syntax)` at parent level.
it("adds a new line and indentation for mapping keys when the key is given", async () => {
const input = "concurrency: |";
const result = await complete(...getPositionFromCursor(input));
expect(result.filter(x => x.label === "cancel-in-progress")).toEqual([]);
expect(result.filter(x => x.label === "group")).toEqual([]);
expect(result.filter(x => x.label === "cancel-in-progress").map(x => x.textEdit?.newText)).toEqual([
"\n cancel-in-progress: "
]);
expect(result.filter(x => x.label === "group").map(x => x.textEdit?.newText)).toEqual(["\n group: "]);
});
it("does not add new line if no key in line", async () => {
@@ -530,9 +510,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
// Scalar variant inserts "types: "
const scalarVariant = result.find(x => x.label === "types" && x.labelDetails === undefined);
expect(scalarVariant?.textEdit?.newText).toEqual("types: ");
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]);
});
it("does not show mapping keys for one-of when user has typed a scalar value", async () => {
@@ -543,14 +521,12 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
// check_run's scalar form only accepts null, so typing anything should show no completions
// (we don't show mapping keys like `types` anymore - user should use check_run with detail "full syntax" instead)
// (we don't show mapping keys like `types` anymore - user should use `check_run (full syntax)` instead)
expect(result.filter(x => x.label === "types")).toEqual([]);
});
it("shows only scalar options for one-of in Key mode when user hasn't committed to a type", async () => {
// At `permissions: |` user hasn't typed anything yet - show only scalar options
// Mapping keys are NOT shown because they would require a newline
// Users who want the mapping form can use `permissions (full syntax)` at the parent level
it("shows all options for one-of when user hasn't committed to a type yet", async () => {
// At `permissions: |` user hasn't typed anything yet - show all options
const input = "on: push\npermissions: |";
const result = await complete(...getPositionFromCursor(input));
@@ -559,9 +535,9 @@ jobs:
expect(result.filter(x => x.label === "read-all").map(x => x.textEdit?.newText)).toEqual(["read-all"]);
expect(result.filter(x => x.label === "write-all").map(x => x.textEdit?.newText)).toEqual(["write-all"]);
// Mapping keys should NOT be shown - they require a newline which is confusing inline
expect(result.filter(x => x.label === "actions")).toEqual([]);
expect(result.filter(x => x.label === "contents")).toEqual([]);
// Mapping keys should also be available (user hasn't committed yet)
expect(result.filter(x => x.label === "actions").map(x => x.textEdit?.newText)).toEqual(["\n actions: "]);
expect(result.filter(x => x.label === "contents").map(x => x.textEdit?.newText)).toEqual(["\n contents: "]);
});
it("filters to scalar options when user has started typing a scalar", async () => {
@@ -577,18 +553,20 @@ jobs:
expect(result.filter(x => x.label === "contents")).toEqual([]);
});
it("shows both simple and full syntax for null+mapping one-of", async () => {
// check_run is a one-of: [null, mapping]. Show both:
// - check_run (simple, just the key with colon)
// - check_run with detail "full syntax" (ready to add mapping keys)
it("shows full syntax for null+mapping one-of (skips null-only scalar)", async () => {
// check_run is a one-of: [null, mapping].
// Since the scalar form is only null (no string constants), we skip it
// to avoid clobbering string constants from elsewhere in the schema.
// User should see check_run (full syntax) for the mapping form.
const input = "on:\n |";
const result = await complete(...getPositionFromCursor(input));
// Should have both check_run (scalar) and check_run with detail "full syntax"
const checkRunVariants = result.filter(x => x.label === "check_run");
expect(checkRunVariants.some(x => x.labelDetails === undefined)).toBe(true);
expect(checkRunVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true);
// Should NOT have plain check_run (null-only scalar is skipped)
// Instead, string constant check_run from on-string-strict is available
expect(result.some(x => x.label === "check_run")).toBe(true);
// Full syntax variant should be available
expect(result.some(x => x.label === "check_run (full syntax)")).toBe(true);
});
it("shows all three variants for scalar+sequence+mapping one-of", async () => {
@@ -600,12 +578,10 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
// Should have runs-on (scalar), runs-on with detail "list", and runs-on with detail "full syntax"
const runsOnVariants = result.filter(x => x.label === "runs-on");
expect(runsOnVariants.length).toBe(3);
expect(runsOnVariants.some(x => x.labelDetails === undefined)).toBe(true);
expect(runsOnVariants.some(x => x.labelDetails?.description === "list")).toBe(true);
expect(runsOnVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true);
// Should have runs-on, runs-on (list), and runs-on (full syntax)
expect(result.some(x => x.label === "runs-on")).toBe(true);
expect(result.some(x => x.label === "runs-on (list)")).toBe(true);
expect(result.some(x => x.label === "runs-on (full syntax)")).toBe(true);
});
it("generates correct insertText for one-of variants in parent mode", async () => {
@@ -617,38 +593,31 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
const runsOnVariants = result.filter(x => x.label === "runs-on");
// Scalar: just key with colon and space
expect(runsOnVariants.find(x => x.labelDetails === undefined)?.textEdit?.newText).toEqual("runs-on: ");
expect(result.find(x => x.label === "runs-on")?.textEdit?.newText).toEqual("runs-on: ");
// Sequence: key with colon, newline, and list item
expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.textEdit?.newText).toEqual(
"runs-on:\n - "
);
expect(result.find(x => x.label === "runs-on (list)")?.textEdit?.newText).toEqual("runs-on:\n - ");
// Mapping: key with colon, newline, and indentation for nested keys
expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.textEdit?.newText).toEqual(
"runs-on:\n "
);
expect(result.find(x => x.label === "runs-on (full syntax)")?.textEdit?.newText).toEqual("runs-on:\n ");
});
it("generates correct insertText for one-of variants in parent mode", async () => {
// concurrency is a one-of: [string, mapping] - testing parent mode (inside mapping)
// At `concurrency:\n |`, user HAS committed to mapping structure, so mapping keys are shown
const input = "concurrency:\n |";
it("generates correct insertText for one-of variants in key mode", async () => {
// concurrency is a one-of: [string, mapping] - testing key mode (after colon on same line)
const input = "concurrency: |";
const result = await complete(...getPositionFromCursor(input));
// In parent mode: just key + colon + space (no leading newline)
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("group: ");
// Scalar in key mode: newline + indented key + colon + space
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("\n group: ");
// Boolean in parent mode (cancel-in-progress): key + colon + space
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("cancel-in-progress: ");
// Boolean in key mode (cancel-in-progress): newline + indented key + colon + space
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("\n cancel-in-progress: ");
});
it("uses sortText for ordering qualified one-of variants", async () => {
// runs-on has multiple structural types, so variants need sorting
it("uses base key as filterText for qualified one-of variants", async () => {
// runs-on has multiple structural types, so variants get qualifiers
const input = `on: push
jobs:
build:
@@ -656,14 +625,12 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
const runsOnVariants = result.filter(x => x.label === "runs-on");
// Scalar: no qualifier, so no filterText needed
expect(result.find(x => x.label === "runs-on")?.filterText).toBeUndefined();
// Scalar: no sortText needed (sorts naturally first)
expect(runsOnVariants.find(x => x.labelDetails === undefined)?.sortText).toBeUndefined();
// Sequence and mapping: sortText controls ordering
expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.sortText).toEqual("runs-on 1");
expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.sortText).toEqual("runs-on 2");
// Sequence and mapping: qualified labels should filter on base key
expect(result.find(x => x.label === "runs-on (list)")?.filterText).toEqual("runs-on");
expect(result.find(x => x.label === "runs-on (full syntax)")?.filterText).toEqual("runs-on");
});
it("scalar event completion inserts inline without newline", async () => {
@@ -677,13 +644,14 @@ jobs:
const push = result.find(x => x.label === "push");
expect(push?.textEdit?.newText).toEqual("push");
const checkRun = result.find(x => x.label === "check_run" && x.labelDetails === undefined);
const checkRun = result.find(x => x.label === "check_run");
expect(checkRun?.textEdit?.newText).toEqual("check_run");
// Full syntax form should NOT be shown in Key mode - it requires a newline
// which is confusing when typing inline. Users who want the mapping form
// can use `on (full syntax)` at the parent level.
expect(result.find(x => x.label === "check_run" && x.labelDetails?.description === "full syntax")).toBeUndefined();
// Full syntax form inserts as a mapping key (with newline in Key mode)
// This is expected behavior - it starts the mapping form
const checkRunFull = result.find(x => x.label === "check_run (full syntax)");
// In Key mode: \n + indent + key + : + \n + indent + indent (for nested content)
expect(checkRunFull?.textEdit?.newText).toEqual("\n check_run:\n ");
});
it("filters to sequence options when user has started a sequence", async () => {
@@ -704,349 +672,4 @@ jobs:
expect(result.filter(x => x.label === "group")).toEqual([]);
expect(result.filter(x => x.label === "labels")).toEqual([]);
});
describe("escape hatch completions", () => {
it("runs-on shows switch to list and full syntax", async () => {
const input = `on: push
jobs:
build:
runs-on: |`;
const result = await complete(...getPositionFromCursor(input));
// Should have escape hatches at the end
const switchToList = result.find(x => x.label === "(switch to list)");
const switchToFull = result.find(x => x.label === "(switch to mapping)");
expect(switchToList).toBeDefined();
expect(switchToFull).toBeDefined();
// Escape hatches should sort last
expect(switchToList!.sortText).toEqual("zzz_switch_1");
expect(switchToFull!.sortText).toEqual("zzz_switch_2");
// Escape hatches should have textEdit at cursor position (for VS Code filtering compatibility)
const listEdit = switchToList!.textEdit as TextEdit;
const fullEdit = switchToFull!.textEdit as TextEdit;
// Main textEdit inserts newline and indented content at cursor position
expect(listEdit.newText).toEqual("\n - ");
expect(fullEdit.newText).toEqual("\n ");
// TextEdit range should be at cursor position (empty range)
expect(listEdit.range.start).toEqual({line: 3, character: 13});
expect(listEdit.range.end).toEqual({line: 3, character: 13});
expect(fullEdit.range.start).toEqual({line: 3, character: 13});
expect(fullEdit.range.end).toEqual({line: 3, character: 13});
// additionalTextEdits should clean up the key portion
expect(switchToList!.additionalTextEdits).toHaveLength(1);
expect(switchToList!.additionalTextEdits![0].range.start).toEqual({line: 3, character: 4});
expect(switchToList!.additionalTextEdits![0].range.end).toEqual({line: 3, character: 13});
expect(switchToList!.additionalTextEdits![0].newText).toEqual("runs-on:");
expect(switchToFull!.additionalTextEdits).toHaveLength(1);
expect(switchToFull!.additionalTextEdits![0].newText).toEqual("runs-on:");
});
it("permissions shows only switch to full syntax (no sequence form)", async () => {
const input = `on: push
permissions: |`;
const result = await complete(...getPositionFromCursor(input));
// Should have full syntax escape hatch but NOT list (permissions has no sequence form)
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
});
it("escape hatches are not shown when value is non-empty", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-|`;
const result = await complete(...getPositionFromCursor(input));
// User has started typing a scalar value, no escape hatches
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
});
it("escape hatches are not shown when inside a sequence", async () => {
const input = `on: push
jobs:
build:
runs-on:
- |`;
const result = await complete(...getPositionFromCursor(input));
// User is already in sequence form, no escape hatches
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
});
it("escape hatches are not shown when inside a mapping", async () => {
const input = `on: push
jobs:
build:
runs-on:
group: |`;
const result = await complete(...getPositionFromCursor(input));
// User is in mapping form completing a value, no escape hatches for the parent
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
});
it("escape hatches ARE shown even when no scalar completions exist", async () => {
// concurrency: | has no scalar constants, but escape hatch provides a way out
const input = `on: push
jobs:
build:
concurrency: |`;
const result = await complete(...getPositionFromCursor(input));
// Escape hatch to mapping should be available even with no scalar completions
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
});
it("pure mapping type (strategy) shows switch to mapping", async () => {
const input = `on: push
jobs:
build:
strategy: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
});
it("pure sequence type (steps) shows switch to list", async () => {
const input = `on: push
jobs:
build:
steps: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result.some(x => x.label === "(switch to list)")).toBe(true);
});
it("selecting switch to list restructures YAML", async () => {
const input = `on: push
jobs:
build:
runs-on: |`;
const result = await complete(...getPositionFromCursor(input));
const switchToList = result.find(x => x.label === "(switch to list)");
const textEdit = switchToList!.textEdit as TextEdit;
const additionalEdits = switchToList!.additionalTextEdits!;
// Main textEdit inserts newline content at cursor
expect(textEdit.newText).toEqual("\n - ");
// additionalTextEdits replaces "runs-on: " with "runs-on:"
expect(additionalEdits).toHaveLength(1);
expect(additionalEdits[0].newText).toEqual("runs-on:");
// Combined result when applied: "runs-on:\n - "
});
});
describe("runs-on mapping syntax", () => {
it("provides label completions for labels as scalar", async () => {
const input = `on: push
jobs:
build:
runs-on:
labels: |`;
const result = await complete(...getPositionFromCursor(input));
// Should show runner labels
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
expect(result.some(x => x.label === "macos-latest")).toBe(true);
expect(result.some(x => x.label === "self-hosted")).toBe(true);
});
it("provides label completions for labels as sequence item", async () => {
const input = `on: push
jobs:
build:
runs-on:
labels:
- |`;
const result = await complete(...getPositionFromCursor(input));
// Should show runner labels
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
expect(result.some(x => x.label === "macos-latest")).toBe(true);
expect(result.some(x => x.label === "self-hosted")).toBe(true);
});
it("excludes already used labels in sequence", async () => {
const input = `on: push
jobs:
build:
runs-on:
labels:
- ubuntu-latest
- |`;
const result = await complete(...getPositionFromCursor(input));
// Should NOT show ubuntu-latest since it's already in the list
expect(result.some(x => x.label === "ubuntu-latest")).toBe(false);
// But should show other labels
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");
});
});
});
describe("schedule timezone completion", () => {
it("includes timezone for schedule", async () => {
const input = `on:
schedule:
- |`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("cron");
expect(labels).toContain("timezone");
});
});
describe("permissions copilot-requests completion", () => {
it("includes copilot-requests when allowCopilotRequestsPermission is enabled", async () => {
const input = `on: push
permissions:
|`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: true})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).toContain("copilot-requests");
});
it("excludes copilot-requests when allowCopilotRequestsPermission is disabled", async () => {
const input = `on: push
permissions:
|`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: false})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).not.toContain("copilot-requests");
});
it("excludes copilot-requests when no feature flags are provided", async () => {
const input = `on: push
permissions:
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).not.toContain("copilot-requests");
});
it("includes copilot-requests in job-level permissions when allowCopilotRequestsPermission is enabled", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
permissions:
|`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: true})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).toContain("copilot-requests");
});
it("excludes copilot-requests from job-level permissions when allowCopilotRequestsPermission is disabled", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
permissions:
|`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: false})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).not.toContain("copilot-requests");
});
});
describe("service container command/entrypoint completion", () => {
it("suggests entrypoint and command in service container", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
services:
redis:
image: redis
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("entrypoint");
expect(labels).toContain("command");
});
it("does not suggest entrypoint and command in job container", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
container:
image: node:20
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).not.toContain("entrypoint");
expect(labels).not.toContain("command");
});
});
+47 -287
View File
@@ -1,13 +1,7 @@
import {complete as completeExpression, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
import {complete as completeExpression, DescriptionDictionary} 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";
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
@@ -15,29 +9,19 @@ import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range"
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
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 {getContext, Mode} from "./context-providers/default.js";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
import {validatorFunctions} from "./expression-validation/functions.js";
import {error} from "./log.js";
import {detectDocumentType} from "./utils/document-type.js";
import {isPotentiallyExpression} from "./utils/expression-detection.js";
import {findToken} from "./utils/find-token.js";
import {guessIndentation} from "./utils/indentation-guesser.js";
import {mapRange} from "./utils/range.js";
import {isPlaceholder, transform} from "./utils/transform.js";
import {
getOrConvertActionTemplate,
getOrConvertWorkflowTemplate,
getOrParseAction,
getOrParseWorkflow
} from "./utils/workflow-cache.js";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
import {Value, ValueProviderConfig} from "./value-providers/config.js";
import {defaultValueProviders} from "./value-providers/default.js";
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
@@ -58,7 +42,6 @@ export type CompletionConfig = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
fileProvider?: FileProvider;
featureFlags?: FeatureFlags;
};
export async function complete(
@@ -82,105 +65,43 @@ export async function complete(
content: newDoc.getText()
};
// Determine document type - unknown defaults to workflow (backwards compatibility)
const isAction = detectDocumentType(textDocument.uri) === "action";
// Parse the document
const parsedTemplate = isAction
? getOrParseAction(file, textDocument.uri, true)
: getOrParseWorkflow(file, textDocument.uri, true);
if (!parsedTemplate.value) {
const parsedWorkflow = fetchOrParseWorkflow(file, textDocument.uri, true);
if (!parsedWorkflow.value) {
return [];
}
const schema = isAction ? getActionSchema() : getWorkflowSchema();
const {token, keyToken, parent, path} = findToken(newPos, parsedTemplate.value);
// Build context for position-aware completions (e.g., steps.*, needs.*, inputs.*)
let workflowContext: WorkflowContext | undefined;
let actionContext: ActionContext | undefined;
if (isAction) {
const actionTemplate = getOrConvertActionTemplate(
parsedTemplate.context,
parsedTemplate.value,
textDocument.uri,
{errorPolicy: ErrorPolicy.TryConversion},
true
);
actionContext = getActionContext(textDocument.uri, actionTemplate, path);
} else {
const workflowTemplate = await getOrConvertWorkflowTemplate(
parsedTemplate.context,
parsedTemplate.value,
textDocument.uri,
config,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion,
featureFlags: config?.featureFlags
},
true
);
workflowContext = workflowTemplate ? getWorkflowContext(textDocument.uri, workflowTemplate, path) : undefined;
}
// Expression completions
if (token && (isBasicExpression(token) || isPotentiallyExpression(token, isAction))) {
const allowedContext = token.definitionInfo?.allowedContext || [];
const {namedContexts, functions: extensionFunctions} = splitAllowedContext(allowedContext);
const context = isAction
? getActionExpressionContext(namedContexts, config?.contextProviderConfig, actionContext, Mode.Completion)
: await getWorkflowExpressionContext(
namedContexts,
config?.contextProviderConfig,
workflowContext,
Mode.Completion
);
// Populate function descriptions for completion display
for (const func of extensionFunctions) {
func.description = getFunctionDescription(func.name);
const template = await fetchOrConvertWorkflowTemplate(
parsedWorkflow.context,
parsedWorkflow.value,
textDocument.uri,
config,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion
}
);
return getExpressionCompletionItems(token, context, extensionFunctions, newPos, config?.featureFlags);
const {token, keyToken, parent, path} = findToken(newPos, parsedWorkflow.value);
const workflowContext = getWorkflowContext(textDocument.uri, template, path);
// If we are inside an expression, take a different code-path. The workflow parser does not correctly create
// expression nodes for invalid expressions and during editing expressions are invalid most of the time.
if (token) {
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
const allowedContext = token.definitionInfo?.allowedContext || [];
const context = await getContext(allowedContext, config?.contextProviderConfig, workflowContext, Mode.Completion);
return getExpressionCompletionItems(token, context, newPos);
}
}
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
const indentString = " ".repeat(indentation.tabSize);
// YAML key/value completions
let values = await getValues(
token,
keyToken,
parent,
config?.valueProviderConfig,
workflowContext,
indentString,
schema
);
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
// Filter action.yml `runs:` completions based on `using:` value
if (isAction && parsedTemplate.value) {
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
}
// Filter `copilot-requests` from permissions completions when the feature flag is disabled
if (
!config?.featureFlags?.isEnabled("allowCopilotRequestsPermission") &&
parent?.definition?.key === "permissions-mapping"
) {
values = values.filter(v => v.label !== "copilot-requests");
}
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
values.push(...escapeHatches);
// Figure out what text to replace when the user picks a completion.
// For example, if they typed `runs-|` and pick `runs-on`, we need to replace `runs-`.
let replaceRange: Range | undefined;
if (token?.range) {
// Prefer the token's range since it accounts for YAML syntax like quotes
replaceRange = mapRange(token.range);
} else if (!token) {
// Not a valid token, create a range from the current position
@@ -203,35 +124,11 @@ export async function complete(
}
}
// Get action scaffolding snippets if applicable
let actionSnippets: CompletionItem[] = [];
if (isAction) {
actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position, replaceRange);
}
// Convert values to LSP CompletionItems
const completionItems = values.map(value => {
return values.map(value => {
const newText = value.insertText || value.label;
// Escape hatches provide their own textEdit to restructure the YAML
let textEdit: TextEdit;
if (value.textEdit) {
textEdit = TextEdit.replace(value.textEdit.range, value.textEdit.newText);
} else if (replaceRange) {
textEdit = TextEdit.replace(replaceRange, newText);
} else {
textEdit = TextEdit.insert(position, newText);
}
// Convert additionalTextEdits if present
let additionalTextEdits: TextEdit[] | undefined;
if (value.additionalTextEdits) {
additionalTextEdits = value.additionalTextEdits.map(edit => TextEdit.replace(edit.range, edit.newText));
}
const item: CompletionItem = {
label: value.label,
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
filterText: value.filterText,
sortText: value.sortText,
documentation: value.description && {
@@ -239,15 +136,11 @@ export async function complete(
value: value.description
},
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
textEdit,
additionalTextEdits
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
};
return item;
});
// Add action scaffolding snippets if available
return [...completionItems, ...actionSnippets];
}
/**
@@ -266,9 +159,8 @@ async function getValues(
keyToken: TemplateToken | null,
parent: TemplateToken | null,
valueProviderConfig: ValueProviderConfig | undefined,
workflowContext: WorkflowContext | undefined,
indentation: string,
schema: TemplateSchema
workflowContext: WorkflowContext,
indentation: string
): Promise<Value[]> {
if (!parent) {
return [];
@@ -279,23 +171,20 @@ async function getValues(
// Use the value providers from the parent if the current key is null
const valueProviderToken = keyToken || parent;
// Value providers require workflow context - only use them for workflows
if (workflowContext) {
const customValueProvider =
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
if (customValueProvider) {
const customValues = await customValueProvider.get(workflowContext, existingValues);
if (customValues) {
return filterAndSortCompletionOptions(customValues, existingValues);
}
const customValueProvider =
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
if (customValueProvider) {
const customValues = await customValueProvider.get(workflowContext, existingValues);
if (customValues) {
return filterAndSortCompletionOptions(customValues, existingValues);
}
}
const defaultValueProvider =
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
if (defaultValueProvider) {
const values = await defaultValueProvider.get(workflowContext, existingValues);
return filterAndSortCompletionOptions(values, existingValues);
}
const defaultValueProvider =
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
if (defaultValueProvider) {
const values = await defaultValueProvider.get(workflowContext, existingValues);
return filterAndSortCompletionOptions(values, existingValues);
}
// Use the definition if there are no value providers
@@ -312,8 +201,7 @@ async function getValues(
def,
indentation,
keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent,
tokenStructure,
schema
tokenStructure
);
return filterAndSortCompletionOptions(values, existingValues);
}
@@ -357,132 +245,6 @@ function getTokenStructure(token: TemplateToken | null): TokenStructure {
}
}
/**
* Generates escape hatch completions that allow switching from scalar form to
* alternative structural forms (sequence or mapping) when the value is empty.
*
* For example, at `runs-on: |`, this adds "(switch to list)" and "(switch to full syntax)"
* completions that restructure the YAML to `runs-on:\n - |` or `runs-on:\n |`.
*
* Only shown when:
* - Completing in value position (keyToken exists)
* - Value is empty (user hasn't committed to a structure yet)
* - Definition allows sequence or mapping structure
*/
function getEscapeHatchCompletions(
token: TemplateToken | null,
keyToken: TemplateToken | null,
indentation: string,
position: Position,
schema: TemplateSchema
): Value[] {
// Only show escape hatches when value is empty
const tokenStructure = getTokenStructure(token);
if (tokenStructure !== undefined) {
return [];
}
// Need a key token with a definition
if (!keyToken?.definition) {
return [];
}
// Determine which structural types are available from the definition
const def = keyToken.definition;
const buckets = {
sequence: false,
mapping: false
};
if (def instanceof OneOfDefinition) {
// OneOf: check each variant
for (const variantKey of def.oneOf) {
const variantDef = schema.definitions[variantKey];
if (variantDef) {
switch (variantDef.definitionType) {
case DefinitionType.Sequence:
buckets.sequence = true;
break;
case DefinitionType.Mapping:
buckets.mapping = true;
break;
}
}
}
} else {
// Single definition type
switch (def.definitionType) {
case DefinitionType.Sequence:
buckets.sequence = true;
break;
case DefinitionType.Mapping:
buckets.mapping = true;
break;
}
}
const results: Value[] = [];
const keyName = isString(keyToken) ? keyToken.value : "";
const keyRange = keyToken.range;
if (!keyRange || !keyName) {
return [];
}
// For VS Code compatibility, we use a cursor-position range for the main textEdit
// and additionalTextEdits to clean up the key portion. This prevents VS Code from
// filtering out escape hatches based on the key text (e.g., "runs-on: ").
//
// Main textEdit: insert at cursor position (newline + indented content)
// additionalTextEdits: replace "key: " with "key:" (removes trailing space)
const cursorRange = {
start: {line: position.line, character: position.character},
end: {line: position.line, character: position.character}
};
// Range from key start to cursor - used to replace "key: " with "key:" in additionalTextEdits
const keyToCursorRange = {
start: {line: keyRange.start.line - 1, character: keyRange.start.column - 1},
end: {line: position.line, character: position.character}
};
if (buckets.sequence) {
results.push({
label: "(switch to list)",
sortText: "zzz_switch_1",
textEdit: {
range: cursorRange,
newText: `\n${indentation}- `
},
additionalTextEdits: [
{
range: keyToCursorRange,
newText: `${keyName}:`
}
]
});
}
if (buckets.mapping) {
results.push({
label: "(switch to mapping)",
sortText: "zzz_switch_2",
textEdit: {
range: cursorRange,
newText: `\n${indentation}`
},
additionalTextEdits: [
{
range: keyToCursorRange,
newText: `${keyName}:`
}
]
});
}
return results;
}
/**
* Collects values that are already present in the current context, so they can be
* excluded from completion suggestions.
@@ -539,9 +301,7 @@ export function getExistingValues(token: TemplateToken | null, parent: TemplateT
function getExpressionCompletionItems(
token: TemplateToken,
context: DescriptionDictionary,
extensionFunctions: FunctionInfo[],
pos: Position,
featureFlags?: FeatureFlags
pos: Position
): CompletionItem[] {
if (!token.range) {
return [];
@@ -560,8 +320,8 @@ function getExpressionCompletionItems(
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
try {
return completeExpression(expressionInput, context, extensionFunctions, validatorFunctions, featureFlags).map(
item => mapExpressionCompletionItem(item, currentInput[cursorOffset])
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
mapExpressionCompletionItem(item, currentInput[cursorOffset])
);
} catch (e) {
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
@@ -1,8 +1,8 @@
import {DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "../context/workflow-context.js";
import {getWorkflowExpressionContext, Mode} from "./default.js";
import {getContext, Mode} from "./default.js";
describe("getWorkflowExpressionContext", () => {
describe("getContext", () => {
const emptyWorkflowContext: WorkflowContext = {
uri: "test.yaml",
template: undefined
@@ -10,7 +10,7 @@ describe("getWorkflowExpressionContext", () => {
describe("when no contextProviderConfig is provided", () => {
it("should mark secrets context as incomplete", async () => {
const result = await getWorkflowExpressionContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets") as DescriptionDictionary;
expect(secretsContext).toBeDefined();
@@ -18,7 +18,7 @@ describe("getWorkflowExpressionContext", () => {
});
it("should mark vars context as incomplete", async () => {
const result = await getWorkflowExpressionContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars") as DescriptionDictionary;
expect(varsContext).toBeDefined();
@@ -26,12 +26,7 @@ describe("getWorkflowExpressionContext", () => {
});
it("should not mark other contexts as incomplete", async () => {
const result = await getWorkflowExpressionContext(
["env", "github"],
undefined,
emptyWorkflowContext,
Mode.Validation
);
const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation);
const envContext = result.get("env") as DescriptionDictionary;
const githubContext = result.get("github") as DescriptionDictionary;
@@ -53,7 +48,7 @@ describe("getWorkflowExpressionContext", () => {
getContext: () => Promise.resolve(providedContext)
};
const result = await getWorkflowExpressionContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets");
expect(secretsContext).toBe(providedContext);
@@ -68,7 +63,7 @@ describe("getWorkflowExpressionContext", () => {
getContext: () => Promise.resolve(providedContext)
};
const result = await getWorkflowExpressionContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars");
expect(varsContext).toBe(providedContext);
@@ -82,7 +77,7 @@ describe("getWorkflowExpressionContext", () => {
getContext: () => Promise.resolve(undefined)
};
const result = await getWorkflowExpressionContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets") as DescriptionDictionary;
expect(secretsContext.complete).toBe(false);
@@ -93,7 +88,7 @@ describe("getWorkflowExpressionContext", () => {
getContext: () => Promise.resolve(undefined)
};
const result = await getWorkflowExpressionContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars") as DescriptionDictionary;
expect(varsContext.complete).toBe(false);
+29 -179
View File
@@ -1,6 +1,5 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {Kind} from "@actions/expressions/data/expressiondata";
import {ActionContext, getActionInputs, getActionStepIdsBefore} from "../context/action-context.js";
import {WorkflowContext} from "../context/workflow-context.js";
import {ContextProviderConfig} from "./config.js";
import {getDescription, RootContext} from "./descriptions.js";
@@ -13,6 +12,7 @@ import {getMatrixContext} from "./matrix.js";
import {getNeedsContext} from "./needs.js";
import {getSecretsContext} from "./secrets.js";
import {getStepsContext} from "./steps.js";
import {getStrategyContext} from "./strategy.js";
// ContextValue is the type of the value returned by a context provider
// Null indicates that the context provider doesn't have any value to provide
@@ -24,13 +24,10 @@ export enum Mode {
Hover
}
/**
* Build expression context for workflow files (e.g., github.*, steps.*, needs.*)
*/
export async function getWorkflowExpressionContext(
export async function getContext(
names: string[],
config: ContextProviderConfig | undefined,
workflowContext: WorkflowContext | undefined,
workflowContext: WorkflowContext,
mode: Mode
): Promise<DescriptionDictionary> {
const context = new DescriptionDictionary();
@@ -44,9 +41,7 @@ export async function getWorkflowExpressionContext(
continue;
}
const remoteValue = workflowContext
? await config?.getContext(contextName, value, workflowContext, mode)
: undefined;
const remoteValue = await config?.getContext(contextName, value, workflowContext, mode);
if (remoteValue) {
value = remoteValue;
} else if (contextName === "secrets" || contextName === "vars") {
@@ -62,206 +57,61 @@ export async function getWorkflowExpressionContext(
return context;
}
/**
* Maps context name to its provider (e.g., "steps" -> getStepsContext)
*/
function getDefaultContext(
name: string,
workflowContext: WorkflowContext | undefined,
mode: Mode
): ContextValue | undefined {
function getDefaultContext(name: string, workflowContext: WorkflowContext, mode: Mode): ContextValue | undefined {
switch (name) {
case "env":
return workflowContext ? getEnvContext(workflowContext) : new DescriptionDictionary();
return getEnvContext(workflowContext);
case "github":
return getGithubContext(workflowContext, mode);
case "inputs":
return workflowContext ? getInputsContext(workflowContext) : new DescriptionDictionary();
return getInputsContext(workflowContext);
case "reusableWorkflowJob":
case "job":
return workflowContext ? getJobContext(workflowContext) : new DescriptionDictionary();
return getJobContext(workflowContext);
case "jobs":
return workflowContext ? getJobsContext(workflowContext) : new DescriptionDictionary();
return getJobsContext(workflowContext);
case "matrix":
return workflowContext ? getMatrixContext(workflowContext, mode) : new DescriptionDictionary();
return getMatrixContext(workflowContext, mode);
case "needs":
return workflowContext ? getNeedsContext(workflowContext) : new DescriptionDictionary();
return getNeedsContext(workflowContext);
case "runner":
return getRunnerContext();
return objectToDictionary({
arch: "X64",
debug: "1",
environment: "github-hosted",
name: "GitHub Actions 2",
os: "Linux",
temp: "/home/runner/work/_temp",
tool_cache: "/opt/hostedtoolcache",
workspace: "/home/runner/work/repo"
});
case "secrets":
return workflowContext ? getSecretsContext(workflowContext, mode) : new DescriptionDictionary();
return getSecretsContext(workflowContext, mode);
case "steps":
return workflowContext ? getStepsContext(workflowContext) : new DescriptionDictionary();
return getStepsContext(workflowContext);
case "strategy":
return getStrategyContext();
return getStrategyContext(workflowContext);
}
return undefined;
}
/**
* Returns the strategy context with default values (fail-fast, job-index, etc.)
*/
function getStrategyContext(): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
return new DescriptionDictionary(
{key: "fail-fast", value: new data.BooleanData(true), description: getDescription("strategy", "fail-fast")},
{key: "job-index", value: new data.NumberData(0), description: getDescription("strategy", "job-index")},
{key: "job-total", value: new data.NumberData(1), description: getDescription("strategy", "job-total")},
{key: "max-parallel", value: new data.NumberData(1), description: getDescription("strategy", "max-parallel")}
);
}
function objectToDictionary(object: {[key: string]: string}): DescriptionDictionary {
const dictionary = new DescriptionDictionary();
/**
* Returns the runner context with environment info (arch, os, temp, workspace, etc.)
*/
function getRunnerContext(): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
return new DescriptionDictionary(
{key: "arch", value: new data.StringData("X64"), description: getDescription("runner", "arch")},
{key: "debug", value: new data.StringData("1"), description: getDescription("runner", "debug")},
{
key: "environment",
value: new data.StringData("github-hosted"),
description: getDescription("runner", "environment")
},
{key: "name", value: new data.StringData("GitHub Actions 2"), description: getDescription("runner", "name")},
{key: "os", value: new data.StringData("Linux"), description: getDescription("runner", "os")},
{key: "temp", value: new data.StringData("/home/runner/work/_temp"), description: getDescription("runner", "temp")},
{
key: "tool_cache",
value: new data.StringData("/opt/hostedtoolcache"),
description: getDescription("runner", "tool_cache")
},
{
key: "workspace",
value: new data.StringData("/home/runner/work/repo"),
description: getDescription("runner", "workspace")
}
);
}
/**
* Get context for expression completion in action.yml files.
* Actions have a more limited set of contexts available compared to workflows.
*/
export function getActionExpressionContext(
names: string[],
config: ContextProviderConfig | undefined,
actionContext: ActionContext | undefined,
mode: Mode
): DescriptionDictionary {
const context = new DescriptionDictionary();
for (const contextName of names) {
const value = getDefaultActionContext(contextName, actionContext, mode);
if (value) {
context.add(contextName, value, getDescription(RootContext, contextName));
}
for (const key in object) {
dictionary.add(key, new data.StringData(object[key]));
}
return context;
}
/**
* Maps context name to its provider for action.yml files (e.g., "inputs" -> getActionInputsContext)
*/
function getDefaultActionContext(
name: string,
actionContext: ActionContext | undefined,
mode: Mode
): ContextValue | undefined {
switch (name) {
case "inputs":
// Return empty dictionary if no context - still allows completion, just without specific input names
return actionContext ? getActionInputsContext(actionContext) : new DescriptionDictionary();
case "steps":
// Return empty dictionary if no context - still allows completion, just without specific step IDs
return actionContext ? getActionStepsContext(actionContext) : new DescriptionDictionary();
case "github":
// Use the same github context but without workflow-specific event info
// Actions inherit the event context from the calling workflow at runtime
return getGithubContext(undefined, mode);
case "runner":
return getRunnerContext();
case "env": {
// Actions can access env but we don't know what env vars the calling workflow defines
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
const envContext = new DescriptionDictionary();
envContext.complete = false;
return envContext;
}
case "job": {
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
const jobContext = new DescriptionDictionary();
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
const containerContext = new DescriptionDictionary();
containerContext.add("id", new data.StringData(""), getDescription("job", "container.id"));
containerContext.add("network", new data.StringData(""), getDescription("job", "container.network"));
jobContext.add("container", containerContext, getDescription("job", "container"));
jobContext.add("services", new DescriptionDictionary(), getDescription("job", "services"));
return jobContext;
}
case "strategy":
return getStrategyContext();
case "matrix": {
// Actions can access matrix context at runtime but we don't know the calling workflow's matrix
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
const matrixContext = new DescriptionDictionary();
matrixContext.complete = false;
return matrixContext;
}
}
return undefined;
}
/**
* Get inputs context for action files based on defined inputs
*/
function getActionInputsContext(actionContext: ActionContext): DescriptionDictionary {
const dict = new DescriptionDictionary();
const inputs = getActionInputs(actionContext.template);
for (const input of inputs) {
dict.add(input.id, new data.StringData(""), input.description || "");
}
return dict;
}
/**
* Get steps context for composite action files based on step IDs
*/
function getActionStepsContext(actionContext: ActionContext): DescriptionDictionary {
const dict = new DescriptionDictionary();
const stepIds = getActionStepIdsBefore(actionContext);
for (const stepId of stepIds) {
const stepDict = new DescriptionDictionary();
stepDict.add("outputs", new DescriptionDictionary(), getDescription("steps", "outputs"));
stepDict.add("outcome", new data.StringData("success"), getDescription("steps", "outcome"));
stepDict.add("conclusion", new data.StringData("success"), getDescription("steps", "conclusion"));
dict.add(stepId, stepDict, `Step: ${stepId}`);
}
return dict;
return dictionary;
}
@@ -105,6 +105,13 @@
"job": {
"description": "The [`job_id`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_id) of the current job.\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
},
"job_workflow_sha": {
"description": "For jobs using a reusable workflow, the commit SHA for the reusable workflow file.",
"versions": {
"ghes": ">=3.9",
"ghae": ">=3.9"
}
},
"path": {
"description": "Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"[Workflow commands for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-system-path).\""
},
@@ -191,47 +198,6 @@
"description": "The default working directory on the runner for steps, and the default location of your repository when using the [`checkout`](https://github.com/actions/checkout) action."
}
},
"job": {
"container": {
"description": "Information about the job's container. For more information about containers, see \"[Running jobs in a container](https://docs.github.com/actions/using-jobs/running-jobs-in-a-container).\""
},
"container.id": {
"description": "The ID of the container."
},
"container.network": {
"description": "The ID of the container network. The runner creates the network used by all containers in a job."
},
"services": {
"description": "The service containers created for a job. For more information about service containers, see \"[Using service containers](https://docs.github.com/actions/using-containerized-services/about-service-containers).\""
},
"services.<service_id>.id": {
"description": "The ID of the service container."
},
"services.<service_id>.network": {
"description": "The ID of the service container network. The runner creates the network used by all containers in a job."
},
"services.<service_id>.ports": {
"description": "The exposed ports of the service container."
},
"status": {
"description": "The current status of the job. Possible values are `success`, `failure`, or `cancelled`."
},
"check_run_id": {
"description": "The unique identifier of the check run for this job."
},
"workflow_file_path": {
"description": "The path of the workflow file that contains the job. For example, `.github/workflows/my-workflow.yml`."
},
"workflow_ref": {
"description": "The ref path to the workflow file that contains the job. For example, `octocat/hello-world/.github/workflows/my-workflow.yml@refs/heads/my_branch`."
},
"workflow_repository": {
"description": "The owner and repository name of the workflow file that contains the job. For example, `octocat/Hello-World`."
},
"workflow_sha": {
"description": "The commit SHA of the workflow file that contains the job."
}
},
"secrets": {
"GITHUB_TOKEN": {
"description": "Automatically created token for each workflow run. For more information, see \"[Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication).\""
@@ -7,10 +7,7 @@ import {getDescription} from "./descriptions.js";
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads.js";
import {getInputsContext} from "./inputs.js";
/**
* Returns the github context with properties like actor, ref, sha, event, etc.
*/
export function getGithubContext(workflowContext: WorkflowContext | undefined, mode: Mode): DescriptionDictionary {
export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
const keys = [
"action",
@@ -29,6 +26,7 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
"graphql_url",
"head_ref",
"job",
"job_workflow_sha",
"path",
"ref",
"ref_name",
@@ -75,10 +73,7 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
);
}
/**
* Builds the github.event context based on workflow trigger configuration.
*/
function getEventContext(workflowContext: WorkflowContext | undefined, mode: Mode): ExpressionData {
function getEventContext(workflowContext: WorkflowContext, mode: Mode): ExpressionData {
const d = new DescriptionDictionary();
const eventsConfig = workflowContext?.template?.events;
@@ -1,197 +0,0 @@
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {getJobContext} from "./job.js";
function stringToToken(value: string): StringToken {
return new StringToken(undefined, undefined, value, undefined);
}
describe("job context", () => {
it("returns empty context when no job", () => {
const workflowContext = {} as WorkflowContext;
const context = getJobContext(workflowContext);
// When there's no job, context is empty
expect(context.pairs().length).toBe(0);
});
it("returns status, check_run_id, and workflow fields when job has no container or services", () => {
const workflowContext = {job: {}} as WorkflowContext;
const context = getJobContext(workflowContext);
expect(context.get("status")).toBeDefined();
expect(context.get("check_run_id")).toBeDefined();
expect(context.get("workflow_ref")).toBeDefined();
expect(context.get("workflow_sha")).toBeDefined();
expect(context.get("workflow_repository")).toBeDefined();
expect(context.get("workflow_file_path")).toBeDefined();
expect(context.get("container")).toBeUndefined();
expect(context.get("services")).toBeUndefined();
});
describe("container context", () => {
it("includes container with id and network when container is defined", () => {
const containerToken = new MappingToken(undefined, undefined, undefined);
containerToken.add(stringToToken("image"), stringToToken("node:18"));
const workflowContext = {
job: {container: containerToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const container = context.get("container");
expect(container).toBeDefined();
if (!container) return;
expect(isDescriptionDictionary(container)).toBe(true);
const containerDict = container as DescriptionDictionary;
expect(containerDict.get("id")).toBeDefined();
expect(containerDict.get("network")).toBeDefined();
expect(containerDict.get("ports")).toBeUndefined(); // job container has no ports
});
it("container has descriptions", () => {
const containerToken = new MappingToken(undefined, undefined, undefined);
containerToken.add(stringToToken("image"), stringToToken("node:18"));
const workflowContext = {
job: {container: containerToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const containerDescription = context.getDescription("container");
expect(containerDescription).toBeDefined();
const containerDict = context.get("container") as DescriptionDictionary;
expect(containerDict.getDescription("id")).toBeDefined();
expect(containerDict.getDescription("network")).toBeDefined();
});
});
describe("services context", () => {
it("includes services with id, network, and ports", () => {
const redisToken = new MappingToken(undefined, undefined, undefined);
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
const servicesToken = new MappingToken(undefined, undefined, undefined);
servicesToken.add(stringToToken("redis"), redisToken);
const workflowContext = {
job: {services: servicesToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const services = context.get("services");
expect(services).toBeDefined();
if (!services) return;
expect(isDescriptionDictionary(services)).toBe(true);
const servicesDict = services as DescriptionDictionary;
const redis = servicesDict.get("redis");
expect(redis).toBeDefined();
if (!redis) return;
expect(isDescriptionDictionary(redis)).toBe(true);
const redisDict = redis as DescriptionDictionary;
expect(redisDict.get("id")).toBeDefined();
expect(redisDict.get("network")).toBeDefined();
expect(redisDict.get("ports")).toBeDefined(); // services have ports
});
it("parses service ports in host:container format", () => {
const portsSequence = new SequenceToken(undefined, undefined, undefined);
portsSequence.add(stringToToken("6379:6379"));
portsSequence.add(stringToToken("8080:80"));
const redisToken = new MappingToken(undefined, undefined, undefined);
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
redisToken.add(stringToToken("ports"), portsSequence);
const servicesToken = new MappingToken(undefined, undefined, undefined);
servicesToken.add(stringToToken("redis"), redisToken);
const workflowContext = {
job: {services: servicesToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const services = context.get("services") as DescriptionDictionary;
const redis = services.get("redis") as DescriptionDictionary;
const ports = redis.get("ports") as DescriptionDictionary;
// Container ports should be the keys (second part of host:container)
expect(ports.get("6379")).toBeDefined();
expect(ports.get("80")).toBeDefined();
});
it("parses service ports in single port format", () => {
const portsSequence = new SequenceToken(undefined, undefined, undefined);
portsSequence.add(stringToToken("6379"));
const redisToken = new MappingToken(undefined, undefined, undefined);
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
redisToken.add(stringToToken("ports"), portsSequence);
const servicesToken = new MappingToken(undefined, undefined, undefined);
servicesToken.add(stringToToken("redis"), redisToken);
const workflowContext = {
job: {services: servicesToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const services = context.get("services") as DescriptionDictionary;
const redis = services.get("redis") as DescriptionDictionary;
const ports = redis.get("ports") as DescriptionDictionary;
// Single port format uses the port as the key
expect(ports.get("6379")).toBeDefined();
});
it("services have descriptions", () => {
const redisToken = new MappingToken(undefined, undefined, undefined);
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
const servicesToken = new MappingToken(undefined, undefined, undefined);
servicesToken.add(stringToToken("redis"), redisToken);
const workflowContext = {
job: {services: servicesToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const servicesDescription = context.getDescription("services");
expect(servicesDescription).toBeDefined();
const services = context.get("services") as DescriptionDictionary;
const redis = services.get("redis") as DescriptionDictionary;
expect(redis.getDescription("id")).toBeDefined();
expect(redis.getDescription("network")).toBeDefined();
expect(redis.getDescription("ports")).toBeDefined();
});
});
describe("workflow context fields", () => {
it("includes workflow context fields with descriptions", () => {
const workflowContext = {job: {}} as WorkflowContext;
const context = getJobContext(workflowContext);
expect(context.get("workflow_ref")).toBeDefined();
expect(context.get("workflow_sha")).toBeDefined();
expect(context.get("workflow_repository")).toBeDefined();
expect(context.get("workflow_file_path")).toBeDefined();
expect(context.getDescription("workflow_ref")).toBeDefined();
expect(context.getDescription("workflow_sha")).toBeDefined();
expect(context.getDescription("workflow_repository")).toBeDefined();
expect(context.getDescription("workflow_file_path")).toBeDefined();
});
});
});
+25 -41
View File
@@ -2,11 +2,7 @@ import {data, DescriptionDictionary} from "@actions/expressions";
import {isMapping, isSequence} from "@actions/workflow-parser";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {getDescription} from "./descriptions.js";
/**
* Returns the job context with container, services, status, check_run_id, and workflow identity fields.
*/
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
const jobContext = new DescriptionDictionary();
@@ -19,7 +15,7 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
const jobContainer = job.container;
if (jobContainer && isMapping(jobContainer)) {
const containerContext = createContainerContext(jobContainer, false);
jobContext.add("container", containerContext, getDescription("job", "container"));
jobContext.add("container", containerContext);
}
// Services
@@ -33,54 +29,42 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
const serviceContext = createContainerContext(service.value, true);
servicesContext.add(service.key.toString(), serviceContext);
}
jobContext.add("services", servicesContext, getDescription("job", "services"));
jobContext.add("services", servicesContext);
}
// Status
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
jobContext.add("status", new data.Null());
// Check run ID
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
// Workflow context fields (populated at runtime for reusable workflow jobs)
jobContext.add("workflow_file_path", new data.StringData(""), getDescription("job", "workflow_file_path"));
jobContext.add("workflow_ref", new data.StringData(""), getDescription("job", "workflow_ref"));
jobContext.add("workflow_repository", new data.StringData(""), getDescription("job", "workflow_repository"));
jobContext.add("workflow_sha", new data.StringData(""), getDescription("job", "workflow_sha"));
jobContext.add("check_run_id", new data.Null());
return jobContext;
}
function createContainerContext(container: MappingToken, isServices: boolean): DescriptionDictionary {
const containerContext = new DescriptionDictionary();
// id and network are always available
containerContext.add(
"id",
new data.StringData(""),
getDescription("job", isServices ? "services.<service_id>.id" : "container.id")
);
containerContext.add(
"network",
new data.StringData(""),
getDescription("job", isServices ? "services.<service_id>.network" : "container.network")
);
// ports are only available for service containers (not job container)
if (isServices) {
const ports = new DescriptionDictionary();
for (const {key, value} of container) {
if (key.toString() === "ports" && isSequence(value)) {
for (const item of value) {
const portParts = item.toString().split(":");
// The key is the container port (second part if host:container format)
const containerPort = portParts.length === 2 ? portParts[1] : portParts[0];
ports.add(containerPort, new data.StringData(""));
function createContainerContext(container: MappingToken, isServices: boolean): data.Dictionary {
const containerContext = new data.Dictionary();
for (const {key, value} of container) {
if (isSequence(value)) {
// service ports are the only thing that is part of the job context
if (key.toString() !== "ports") {
continue;
}
const ports = new data.Dictionary();
for (const item of value) {
// We can determine the context mapping fully only if the port is defined
// as a mapping (i.e. <port1>:<port2>), single ports are assigned randomly
const portParts = item.toString().split(":");
if (isServices && portParts.length === 2) {
ports.add(portParts[1], new data.StringData(portParts[0]));
} else {
// If the port isn't a mapping, just use null
ports.add(portParts[0], new data.Null());
}
}
containerContext.add(key.toString(), ports);
}
containerContext.add("ports", ports, getDescription("job", "services.<service_id>.ports"));
}
containerContext.add("id", new data.Null());
containerContext.add("network", new data.Null());
return containerContext;
}
@@ -0,0 +1,126 @@
import {data} from "@actions/expressions";
import {Job} from "@actions/workflow-parser/model/workflow-template";
import {BooleanToken} from "@actions/workflow-parser/templates/tokens/boolean-token";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {NumberToken} from "@actions/workflow-parser/templates/tokens/number-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {getStrategyContext} from "./strategy.js";
function stringToToken(value: string) {
return new StringToken(undefined, undefined, value, undefined);
}
function boolToToken(value: boolean) {
return new BooleanToken(undefined, undefined, value, undefined);
}
function numberToToken(value: number) {
return new NumberToken(undefined, undefined, value, undefined);
}
function contextFromStrategy(strategy?: TemplateToken) {
return {
job: {
strategy: strategy
}
} as WorkflowContext;
}
describe("strategy context", () => {
describe("no strategy defined", () => {
it("returns defaults when job is undefined", () => {
const workflowContext = {} as WorkflowContext;
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
it("returns defaults when strategy is undefined", () => {
const job = {} as Job;
const workflowContext = {job} as WorkflowContext;
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
it("returns defaults when strategy is not a mapping", () => {
const workflowContext = contextFromStrategy(stringToToken("hello"));
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
});
describe("strategy defined with partial properties", () => {
it("uses specified fail-fast, defaults for others", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("fail-fast"), boolToToken(false));
const workflowContext = contextFromStrategy(strategy);
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
it("uses specified max-parallel, defaults for others", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("max-parallel"), numberToToken(5));
const workflowContext = contextFromStrategy(strategy);
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(5));
});
it("only has matrix defined, all strategy properties use defaults", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
const matrix = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), matrix);
const workflowContext = contextFromStrategy(strategy);
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
});
describe("strategy with all properties defined", () => {
it("uses all specified values", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("fail-fast"), boolToToken(false));
strategy.add(stringToToken("max-parallel"), numberToToken(3));
const workflowContext = contextFromStrategy(strategy);
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
// job-index and job-total are runtime values, not specified in YAML
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(3));
});
});
});
@@ -0,0 +1,49 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {isMapping, isScalar, isString} from "@actions/workflow-parser";
import {WorkflowContext} from "../context/workflow-context.js";
import {scalarToData} from "../utils/scalar-to-data.js";
// Default strategy values when no strategy block is defined
const DEFAULT_STRATEGY = {
"fail-fast": new data.BooleanData(true),
"job-index": new data.NumberData(0),
"job-total": new data.NumberData(1),
"max-parallel": new data.NumberData(1)
};
export function getStrategyContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
const keys = ["fail-fast", "job-index", "job-total", "max-parallel"];
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
if (!strategy || !isMapping(strategy)) {
// No strategy defined - return defaults that match runtime behavior
return new DescriptionDictionary(
...keys.map(key => {
return {key, value: DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]};
})
);
}
const strategyContext = new DescriptionDictionary();
for (const pair of strategy) {
if (!isString(pair.key)) {
continue;
}
if (!keys.includes(pair.key.value)) {
continue;
}
const value = isScalar(pair.value) ? scalarToData(pair.value) : new data.Null();
strategyContext.add(pair.key.value, value);
}
for (const key of keys) {
if (!strategyContext.get(key)) {
// Use default value for missing properties
strategyContext.add(key, DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]);
}
}
return strategyContext;
}
@@ -1,122 +0,0 @@
import {isMapping} from "@actions/workflow-parser";
import {ActionInputDefinition, ActionTemplate} from "@actions/workflow-parser/actions/action-template";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
/**
* Context information for an action.yml file, used to provide
* expression completion with action-specific values.
*/
export interface ActionContext {
uri: string;
/** The converted action template */
template: ActionTemplate | undefined;
/** If the context is for a position within a composite step, this will be the step */
step?: Step;
}
/**
* Build context from a converted action template and token path.
* Similar to getWorkflowContext but for action files.
*/
export function getActionContext(
uri: string,
template: ActionTemplate | undefined,
tokenPath: TemplateToken[]
): ActionContext {
const context: ActionContext = {uri, template};
if (!template) {
return context;
}
// Only composite actions have steps
if (template.runs?.using !== "composite") {
return context;
}
const compositeRuns = template.runs;
if (!compositeRuns.steps?.length) {
return context;
}
// Find the current step from the token path
let stepsSequence: SequenceToken | undefined;
let stepToken: MappingToken | undefined;
for (const token of tokenPath) {
const defKey = token.definition?.key;
if (defKey === "composite-steps" && token instanceof SequenceToken) {
stepsSequence = token;
} else if ((defKey === "run-step" || defKey === "uses-step") && isMapping(token)) {
stepToken = token;
}
}
if (stepsSequence && stepToken) {
context.step = findStep(compositeRuns.steps, stepsSequence, stepToken);
}
return context;
}
/**
* Find the Step that corresponds to the given step token.
*/
function findStep(steps: Step[], stepsSequence: SequenceToken, stepToken: MappingToken): Step | undefined {
// Find the step by matching index in the sequence
let stepIndex = -1;
for (let i = 0; i < stepsSequence.count; i++) {
if (stepsSequence.get(i) === stepToken) {
stepIndex = i;
break;
}
}
if (stepIndex === -1 || stepIndex >= steps.length) {
return undefined;
}
return steps[stepIndex];
}
/**
* Get input definitions from the action template.
*/
export function getActionInputs(template: ActionTemplate | undefined): ActionInputDefinition[] {
return template?.inputs ?? [];
}
/**
* Get step IDs from composite action steps that appear before the current step.
* This is used for `steps.<id>` context completion - you can only reference
* steps that have already run.
*/
export function getActionStepIdsBefore(context: ActionContext): string[] {
const template = context.template;
if (!template || template.runs?.using !== "composite") {
return [];
}
const compositeRuns = template.runs;
const steps = compositeRuns.steps ?? [];
const currentStep = context.step;
const stepIds: string[] = [];
for (const step of steps) {
// Stop when we reach the current step
if (currentStep && step === currentStep) {
break;
}
// Only include steps with explicit IDs
if (step.id) {
stepIds.push(step.id);
}
}
return stepIds;
}
@@ -6,10 +6,6 @@ import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
/**
* Represents the contextual position within a workflow file.
* Used to determine which expression contexts are available at a given location.
*/
export interface WorkflowContext {
uri: string;
@@ -25,12 +21,6 @@ export interface WorkflowContext {
step?: Step;
}
/**
* Builds a WorkflowContext by walking the token path to identify the current job and step.
* @param uri - The URI of the workflow file
* @param template - The parsed workflow template
* @param tokenPath - The path of tokens from root to the current position
*/
export function getWorkflowContext(
uri: string,
template: WorkflowTemplate | undefined,
@@ -83,10 +73,6 @@ export function getWorkflowContext(
return context;
}
/**
* Finds a Step by matching the step token's position in the steps sequence.
* Steps may not have IDs, so we locate them by index rather than by identifier.
*/
function findStep(steps?: Step[], stepSequence?: SequenceToken, stepToken?: MappingToken): Step | undefined {
if (!steps || !stepSequence || !stepToken) {
return undefined;
@@ -3,9 +3,6 @@ import {DESCRIPTION} from "@actions/workflow-parser/templates/template-constants
import {WorkflowContext} from "../context/workflow-context.js";
import {TokenResult} from "../utils/find-token.js";
/**
* Checks if the token is an input value in a reusable workflow job's `with:` block.
*/
export function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean {
return (
tokenResult.parent?.definition?.key === "workflow-job-with" &&
@@ -14,11 +11,6 @@ export function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean {
);
}
/**
* Gets the description of an input from a called reusable workflow.
* When a workflow calls another workflow with `uses:`, this fetches the input's
* description from the called workflow's `workflow_call.inputs` definitions.
*/
export function getReusableWorkflowInputDescription(
workflowContext: WorkflowContext,
tokenResult: TokenResult
@@ -129,31 +129,4 @@ jobs:
}
]);
});
it("links for actions in composite action", async () => {
const input = `name: My Composite Action
description: A composite action with nested actions
runs:
using: composite
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: echo "Hello"
shell: bash`;
const result = await documentLinks(createDocument("action.yml", input), undefined);
expect(result).toHaveLength(2);
expect(result[0].target).toBe("https://www.github.com/actions/checkout/tree/v4/");
expect(result[0].tooltip).toBe("Open action on GitHub");
expect(result[1].target).toBe("https://www.github.com/actions/setup-node/tree/v4/");
});
it("no links for non-composite action", async () => {
const input = `name: My Node Action
description: A node action
runs:
using: node20
main: index.js`;
const result = await documentLinks(createDocument("action.yml", input), undefined);
expect(result).toHaveLength(0);
});
});
+11 -64
View File
@@ -6,82 +6,29 @@ import {TextDocument} from "vscode-languageserver-textdocument";
import {DocumentLink} from "vscode-languageserver-types";
import * as vscodeURI from "vscode-uri";
import {actionUrl, parseActionReference} from "./action.js";
import {isActionDocument} from "./utils/document-type.js";
import {mapRange} from "./utils/range.js";
import {
getOrConvertActionTemplate,
getOrConvertWorkflowTemplate,
getOrParseAction,
getOrParseWorkflow
} from "./utils/workflow-cache.js";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
/**
* Generates clickable links for action references and reusable workflows.
*/
export async function documentLinks(document: TextDocument, workspace: string | undefined): Promise<DocumentLink[]> {
const file: File = {
name: document.uri,
content: document.getText()
};
return isActionDocument(document.uri)
? actionDocumentLinks(file, document.uri)
: workflowDocumentLinks(file, document.uri, workspace);
}
/**
* Generates clickable links for action references in action.yml files.
*/
function actionDocumentLinks(file: File, uri: string): DocumentLink[] {
const parsedAction = getOrParseAction(file, uri);
if (!parsedAction?.value) {
return [];
}
const template = getOrConvertActionTemplate(parsedAction.context, parsedAction.value, uri, {
errorPolicy: ErrorPolicy.TryConversion
});
const links: DocumentLink[] = [];
// Only composite actions have steps
if (template?.runs?.using !== "composite") {
return links;
}
const steps = template.runs.steps ?? [];
for (const step of steps) {
if ("uses" in step) {
const actionRef = parseActionReference(step.uses.value);
if (!actionRef) {
continue;
}
const url = actionUrl(actionRef);
links.push({
range: mapRange(step.uses.range),
target: url,
tooltip: `Open action on GitHub`
});
}
}
return links;
}
/**
* Generates clickable links for action references and reusable workflows in workflow files.
*/
async function workflowDocumentLinks(file: File, uri: string, workspace: string | undefined): Promise<DocumentLink[]> {
const parsedWorkflow = getOrParseWorkflow(file, uri);
const parsedWorkflow = fetchOrParseWorkflow(file, document.uri);
if (!parsedWorkflow?.value) {
return [];
}
const template = await getOrConvertWorkflowTemplate(parsedWorkflow.context, parsedWorkflow.value, uri, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
const template = await fetchOrConvertWorkflowTemplate(
parsedWorkflow.context,
parsedWorkflow.value,
document.uri,
undefined,
{
errorPolicy: ErrorPolicy.TryConversion
}
);
const links: DocumentLink[] = [];
+2 -4
View File
@@ -22,10 +22,8 @@ describe("end-to-end", () => {
expect(result).not.toBeUndefined();
expect(result.length).toEqual(13);
const labelsWithDetails = result.map(x =>
x.labelDetails?.description ? `${x.label} (${x.labelDetails.description})` : x.label
);
expect(labelsWithDetails).toEqual([
const labels = result.map(x => x.label);
expect(labels).toEqual([
"concurrency",
"concurrency (full syntax)",
"defaults",
@@ -3,7 +3,7 @@ import {convertWorkflowTemplate, parseWorkflow} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {File} from "@actions/workflow-parser/workflows/file";
import {ContextProviderConfig} from "../context-providers/config.js";
import {getWorkflowExpressionContext, Mode} from "../context-providers/default.js";
import {getContext, Mode} from "../context-providers/default.js";
import {getWorkflowContext} from "../context/workflow-context.js";
import {validatorFunctions} from "../expression-validation/functions.js";
import {nullTrace} from "../nulltrace.js";
@@ -116,12 +116,7 @@ async function hoverExpression(input: string) {
errorPolicy: ErrorPolicy.TryConversion
});
const workflowContext = getWorkflowContext(td.uri, template, []);
const context = await getWorkflowExpressionContext(
allowedContext,
contextProviderConfig,
workflowContext,
Mode.Completion
);
const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion);
const l = new Lexer(td.getText());
const lr = l.lex();
-217
View File
@@ -1,217 +0,0 @@
import {TextDocument} from "vscode-languageserver-textdocument";
import {hover} from "./hover";
describe("hover action files", () => {
function createActionDocument(
content: string,
uri = "file:///test/action.yml"
): [TextDocument, {line: number; character: number}] {
// Parse cursor position and remove the | character
const cursorIndex = content.indexOf("|");
if (cursorIndex === -1) {
throw new Error("No cursor (|) found in content");
}
const newContent = content.substring(0, cursorIndex) + content.substring(cursorIndex + 1);
const doc = TextDocument.create(uri, "yaml", 1, newContent);
const position = doc.positionAt(cursorIndex);
return [doc, position];
}
describe("top-level keys", () => {
it("shows description for name key", async () => {
const [doc, position] = createActionDocument(`na|me: My Action
description: Test
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("name");
});
it("shows description for description key", async () => {
const [doc, position] = createActionDocument(`name: My Action
descrip|tion: Test
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("description");
});
it("shows description for runs key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
ru|ns:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("runs");
});
it("shows description for author key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
au|thor: Me
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("author");
expect(result?.contents).toContain("Documentation");
});
});
describe("runs properties", () => {
it("shows description for using key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
us|ing: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("runtime");
});
it("shows description for main key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
using: node20
ma|in: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("main");
});
});
describe("inputs", () => {
it("shows description for inputs section", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
inp|uts:
my-input:
description: A test input
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("input");
});
it("shows description for required key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
inputs:
my-input:
description: A test input
requ|ired: true
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("required");
});
it("shows allowed context for default value", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
inputs:
my-input:
description: A test input
def|ault: foo
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
// Input defaults can use expressions with github, strategy, matrix, job, runner contexts
expect(result?.contents).toContain("github");
});
});
describe("branding", () => {
it("shows description for branding section", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
using: node20
main: index.js
brand|ing:
icon: activity
color: blue`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("brand");
expect(result?.contents).toContain("Documentation");
});
it("shows description for icon key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
using: node20
main: index.js
branding:
ic|on: activity
color: blue`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("icon");
});
});
describe("document type routing", () => {
it("routes action.yml to action hover", async () => {
const [doc, position] = createActionDocument(
`na|me: My Action
description: Test
runs:
using: node20
main: index.js`,
"file:///my-repo/action.yml"
);
const result = await hover(doc, position);
expect(result).not.toBeNull();
});
it("does not route workflow files to action hover", async () => {
const doc = TextDocument.create(
"file:///repo/.github/workflows/ci.yml",
"yaml",
1,
`name: CI
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hello`
);
// Hovering over 'name' in a workflow file should give workflow-specific info
const result = await hover(doc, {line: 0, character: 2});
// The workflow hover might not have description for workflow name,
// but it should not crash
expect(result === null || result.contents !== undefined).toBe(true);
});
});
});
+5 -8
View File
@@ -110,7 +110,8 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual("Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00");
// Cron description is now shown via diagnostics, not hover
expect(result?.contents).toEqual("");
});
it("on a cron mapping key", async () => {
@@ -120,9 +121,7 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
);
expect(result?.contents).toEqual("");
});
it("on an invalid cron schedule", async () => {
@@ -132,9 +131,7 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
);
expect(result?.contents).toEqual("");
});
it("shows context inherited from parent nodes", async () => {
@@ -199,7 +196,7 @@ jobs:
const result = await hover(...getPositionFromCursor(input), testHoverConfig("uses", "step-uses", undefined));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, a [private repository with access enabled](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository), or in a published Docker container image."
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image."
);
});
});
+58 -127
View File
@@ -1,9 +1,6 @@
import {data, DescriptionDictionary, Parser} from "@actions/expressions";
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
import {Lexer} from "@actions/expressions/lexer";
import {parseAction} from "@actions/workflow-parser/actions/action-parser";
import {isString} from "@actions/workflow-parser";
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
@@ -13,9 +10,8 @@ import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {Hover} from "vscode-languageserver-types";
import {ContextProviderConfig} from "./context-providers/config.js";
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
import {getContext, 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 {
getReusableWorkflowInputDescription,
@@ -24,12 +20,10 @@ import {
import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-pos.js";
import {HoverVisitor} from "./expression-hover/visitor.js";
import {info} from "./log.js";
import {nullTrace} from "./nulltrace.js";
import {isActionDocument} from "./utils/document-type.js";
import {isPotentiallyExpression} from "./utils/expression-detection.js";
import {findToken} from "./utils/find-token.js";
import {mapRange} from "./utils/range.js";
import {getOrConvertActionTemplate, getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
export type HoverConfig = {
descriptionProvider?: DescriptionProvider;
@@ -38,136 +32,79 @@ export type HoverConfig = {
};
export type DescriptionProvider = {
getDescription(
context: WorkflowContext | ActionContext,
token: TemplateToken,
path: TemplateToken[]
): Promise<string | undefined>;
getDescription(context: WorkflowContext, token: TemplateToken, path: TemplateToken[]): Promise<string | undefined>;
};
/**
* Returns hover information for the token at the given position.
*/
export async function hover(document: TextDocument, position: Position, config?: HoverConfig): Promise<Hover | null> {
const file: File = {
name: document.uri,
content: document.getText()
};
// Determine document type based on file path (action.yml vs workflow file)
const isAction = isActionDocument(document.uri);
// Parse document
const parsedTemplate = isAction ? parseAction(file, nullTrace) : getOrParseWorkflow(file, document.uri);
if (!parsedTemplate?.value) {
const parsedWorkflow = fetchOrParseWorkflow(file, document.uri);
if (!parsedWorkflow?.value) {
return null;
}
// Find the token at the cursor position
const tokenResult = findToken(position, parsedTemplate.value);
const template = await fetchOrConvertWorkflowTemplate(
parsedWorkflow.context,
parsedWorkflow.value,
document.uri,
config,
{
errorPolicy: ErrorPolicy.TryConversion,
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
}
);
const tokenResult = findToken(position, parsedWorkflow.value);
const {token, keyToken, parent} = tokenResult;
const tokenDefinitionInfo = (keyToken || parent || token)?.definitionInfo;
// Early exit if there's nothing to provide hover for
const hoverToken = token || keyToken;
const isExpressionHover =
token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token, isAction));
if (!isExpressionHover && !hoverToken?.definition) {
const workflowContext = getWorkflowContext(document.uri, template, tokenResult.path);
if (token && tokenDefinitionInfo) {
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
info(`Calculating expression hover for token with definition ${tokenDefinitionInfo.definition.key}`);
const allowedContext = tokenDefinitionInfo.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
const context = await getContext(namedContexts, config?.contextProviderConfig, workflowContext, Mode.Completion);
for (const func of functions) {
func.description = getFunctionDescription(func.name);
}
const exprPos = mapToExpressionPos(token, position);
if (exprPos) {
return expressionHover(exprPos, context, namedContexts, functions);
}
}
}
if (!token?.definition) {
return null;
}
// Build document context (jobs, steps, inputs, etc.) from the parsed template
const documentContext = isAction
? getActionContext(
document.uri,
getOrConvertActionTemplate(parsedTemplate.context, parsedTemplate.value, document.uri, {
errorPolicy: ErrorPolicy.TryConversion
}),
tokenResult.path
)
: getWorkflowContext(
document.uri,
await getOrConvertWorkflowTemplate(parsedTemplate.context, parsedTemplate.value, document.uri, config, {
errorPolicy: ErrorPolicy.TryConversion,
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
}),
tokenResult.path
);
info(`Calculating hover for token with definition ${token.definition.key}`);
// Expression hover
if (isExpressionHover) {
info(`Calculating expression hover for token with definition ${tokenDefinitionInfo.definition.key}`);
const allowedContext = tokenDefinitionInfo.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
// Build expression context with named contexts (github, env, etc.) and their descriptions
const expressionContext = isAction
? getActionExpressionContext(
namedContexts,
config?.contextProviderConfig,
documentContext as ActionContext,
Mode.Hover
)
: await getWorkflowExpressionContext(
namedContexts,
config?.contextProviderConfig,
documentContext as WorkflowContext,
Mode.Hover
);
// Populate function descriptions for hover display
for (const func of functions) {
func.description = getFunctionDescription(func.name);
}
// Convert document position to expression-relative position
const exprPos = mapToExpressionPos(token, position);
if (exprPos) {
// Find the expression element at the cursor and return its description
return expressionHover(exprPos, expressionContext, namedContexts, functions);
}
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
description = appendContext(description, token.definitionInfo?.allowedContext);
return {
contents: description,
range: mapRange(token.range)
} satisfies Hover;
}
if (!hoverToken?.definition) {
return null;
}
let description = await getDescription(config, workflowContext, token, tokenResult.path);
description = appendContext(description, token.definitionInfo?.allowedContext);
// Non-expression hover: show the schema description for the YAML key or value
info(`Calculating hover for token with definition ${hoverToken.definition.key}`);
// Check for cron expression hover
if (isString(hoverToken) && hoverToken.definition.key === "cron-pattern") {
const cronDescription = getCronDescription(hoverToken.value);
if (cronDescription) {
return {
contents: cronDescription,
range: mapRange(hoverToken.range)
};
}
}
let description: string;
if (!isAction && tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
// Reusable workflow call: fetch the called workflow's input descriptions
description = getReusableWorkflowInputDescription(documentContext as WorkflowContext, tokenResult);
} else {
// Default: use custom provider or token's schema description
description =
(await getDescription(config, documentContext, hoverToken, tokenResult.path)) || hoverToken.description || "";
}
// Return hover with description and available expression contexts
return {
contents: appendContext(description, hoverToken.definitionInfo?.allowedContext),
range: mapRange(hoverToken.range)
contents: description,
range: mapRange(token.range)
} satisfies Hover;
}
/**
* Appends available expression contexts and functions to a hover description.
* For example: "Available expression contexts: `github`, `env`"
*/
function appendContext(description: string, allowedContext?: string[]) {
if (!allowedContext || allowedContext.length == 0) {
return description;
@@ -191,30 +128,24 @@ function appendContext(description: string, allowedContext?: string[]) {
return `${description}${namedContextsString}${functionsString}`;
}
/**
* Gets a custom description from the configured description provider.
* Used to fetch rich descriptions like action input docs from GitHub repos.
*/
async function getDescription(
config: HoverConfig | undefined,
documentContext: WorkflowContext | ActionContext,
workflowContext: WorkflowContext,
token: TemplateToken,
path: TemplateToken[]
): Promise<string | undefined> {
) {
const defaultDescription = token.description || "";
if (!config?.descriptionProvider) {
return undefined;
return defaultDescription;
}
return await config.descriptionProvider.getDescription(documentContext, token, path);
const description = await config.descriptionProvider.getDescription(workflowContext, token, path);
return description || defaultDescription;
}
/**
* Parses an expression and finds the element at the cursor position to show its description.
* For example, hovering over `github.actor` shows "The login of the user that triggered the workflow".
*/
function expressionHover(
exprPos: ExpressionPos,
expressionContext: DescriptionDictionary,
context: DescriptionDictionary,
namedContexts: string[],
functions: FunctionInfo[]
): Hover | null {
@@ -234,7 +165,7 @@ function expressionHover(
call: () => new data.Null()
});
}
const hv = new HoverVisitor(position, expressionContext, functionMap);
const hv = new HoverVisitor(position, context, functionMap);
const hoverResult = hv.hover(expr);
if (!hoverResult) {
return null;
+1 -3
View File
@@ -1,9 +1,7 @@
export {complete, CompletionConfig} from "./complete.js";
export {complete} from "./complete.js";
export {ContextProviderConfig} from "./context-providers/config.js";
export {documentLinks} from "./document-links.js";
export {hover} from "./hover.js";
export {getInlayHints} from "./inlay-hints.js";
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log.js";
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate.js";
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
export {getCodeActions, CodeActionParams} from "./code-actions/code-actions.js";
-116
View File
@@ -1,116 +0,0 @@
import {InlayHintKind} from "vscode-languageserver-types";
import {getInlayHints} from "./inlay-hints.js";
import {registerLogger} from "./log.js";
import {createDocument} from "./test-utils/document.js";
import {TestLogger} from "./test-utils/logger.js";
import {clearCache} from "./utils/workflow-cache.js";
registerLogger(new TestLogger());
beforeEach(() => {
clearCache();
});
describe("inlay-hints", () => {
describe("cron expressions", () => {
it("returns inlay hint for valid cron expression", () => {
const input = `on:
schedule:
- cron: '0 * * * *'
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(1);
expect(hints[0].label).toBe("→ Runs every hour");
expect(hints[0].kind).toBe(InlayHintKind.Parameter);
expect(hints[0].paddingLeft).toBe(true);
});
it("returns correct position at end of cron value", () => {
const input = `on:
schedule:
- cron: '0 3 * * 1'
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(1);
// Position should be at the end of the cron string value (after the closing quote)
// Line 3 (0-indexed: 2), end of '0 3 * * 1'
expect(hints[0].position.line).toBe(2);
});
it("returns no hint for invalid cron expression", () => {
const input = `on:
schedule:
- cron: 'invalid cron'
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(0);
});
it("returns multiple hints for multiple cron expressions", () => {
const input = `on:
schedule:
- cron: '0 * * * *'
- cron: '0 0 * * *'
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(2);
expect(hints[0].label).toBe("→ Runs every hour");
expect(hints[1].label).toBe("→ Runs at 00:00");
});
it("returns hint with descriptive label for weekly cron", () => {
const input = `on:
schedule:
- cron: '0 3 * * 1'
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(1);
expect(hints[0].label).toContain("Monday");
});
it("returns no hints for empty workflow", () => {
const input = ``;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(0);
});
it("returns no hints for workflow without schedule", () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hello
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(0);
});
it("returns hint for frequent cron that triggers warning", () => {
// Even crons that trigger the <5min warning should still get inlay hints
const input = `on:
schedule:
- cron: '* * * * *'
`;
const document = createDocument("test.yaml", input);
const hints = getInlayHints(document);
expect(hints).toHaveLength(1);
expect(hints[0].label).toBe("→ Runs every minute");
});
});
});
-62
View File
@@ -1,62 +0,0 @@
import {isString} from "@actions/workflow-parser";
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {File} from "@actions/workflow-parser/workflows/file";
import {TextDocument} from "vscode-languageserver-textdocument";
import {InlayHint, InlayHintKind} from "vscode-languageserver-types";
import {isActionDocument} from "./utils/document-type.js";
import {getOrParseWorkflow} from "./utils/workflow-cache.js";
/**
* Returns inlay hints for a workflow document.
* Currently supports cron expressions, showing a human-readable description
* of the schedule inline after the cron value.
*
* @param document Text document to get inlay hints for
* @returns Array of inlay hints
*/
export function getInlayHints(document: TextDocument): InlayHint[] {
// Inlay hints are only supported for workflow files (cron expressions)
if (isActionDocument(document.uri)) {
return [];
}
const file: File = {
name: document.uri,
content: document.getText()
};
const result = getOrParseWorkflow(file, document.uri);
if (!result?.value) {
return [];
}
const hints: InlayHint[] = [];
// Traverse the workflow AST to find cron expressions
for (const [parent, token, key] of TemplateToken.traverse(result.value)) {
const validationToken = key || parent || token;
const validationDefinition = validationToken.definition;
// Check for cron-pattern tokens
if (isString(token) && token.range && validationDefinition?.key === "cron-pattern") {
const cronValue = token.value;
const description = getCronDescription(cronValue);
if (description) {
// Position the hint at the end of the cron value
hints.push({
position: {
line: token.range.end.line - 1, // Convert from 1-based to 0-based
character: token.range.end.column - 1 // Convert from 1-based to 0-based
},
label: `${description}`,
kind: InlayHintKind.Parameter,
paddingLeft: true
});
}
}
}
return hints;
}
@@ -1,98 +0,0 @@
import {detectDocumentType, isActionDocument, isWorkflowDocument} from "./document-type";
describe("detectDocumentType", () => {
describe("action files", () => {
it("detects action.yml", () => {
expect(detectDocumentType("/path/to/action.yml")).toBe("action");
});
it("detects action.yaml", () => {
expect(detectDocumentType("/path/to/action.yaml")).toBe("action");
});
it("detects action.yml with case insensitivity", () => {
expect(detectDocumentType("/path/to/ACTION.YML")).toBe("action");
expect(detectDocumentType("/path/to/Action.Yaml")).toBe("action");
});
it("detects nested action.yml", () => {
expect(detectDocumentType("/repo/.github/actions/my-action/action.yml")).toBe("action");
});
it("detects bare action.yml", () => {
expect(detectDocumentType("action.yml")).toBe("action");
});
it("handles Windows paths", () => {
expect(detectDocumentType("C:\\Users\\me\\action.yml")).toBe("action");
expect(detectDocumentType("C:\\repo\\.github\\actions\\my-action\\action.yml")).toBe("action");
});
});
describe("workflow files", () => {
it("detects workflow files in .github/workflows", () => {
expect(detectDocumentType("/repo/.github/workflows/ci.yml")).toBe("workflow");
expect(detectDocumentType("/repo/.github/workflows/build.yaml")).toBe("workflow");
});
it("detects workflow files in .github/workflows-lab", () => {
expect(detectDocumentType("/repo/.github/workflows-lab/ci.yml")).toBe("workflow");
expect(detectDocumentType("/repo/.github/workflows-lab/build.yaml")).toBe("workflow");
});
it("detects workflow files case insensitively", () => {
expect(detectDocumentType("/repo/.github/workflows/CI.YML")).toBe("workflow");
});
it("handles Windows paths for workflows", () => {
expect(detectDocumentType("C:\\repo\\.github\\workflows\\ci.yml")).toBe("workflow");
expect(detectDocumentType("C:\\repo\\.github\\workflows-lab\\ci.yml")).toBe("workflow");
});
it("workflow path takes precedence over action filename", () => {
// Edge case: action.yml inside .github/workflows should be treated as workflow
expect(detectDocumentType("/repo/.github/workflows/action.yml")).toBe("workflow");
expect(detectDocumentType("/repo/.github/workflows/action.yaml")).toBe("workflow");
expect(detectDocumentType("/repo/.github/workflows-lab/action.yml")).toBe("workflow");
});
});
describe("unknown files", () => {
it("returns unknown for other yaml files", () => {
expect(detectDocumentType("/path/to/config.yml")).toBe("unknown");
expect(detectDocumentType("/path/to/docker-compose.yaml")).toBe("unknown");
});
it("returns unknown for non-yaml files", () => {
expect(detectDocumentType("/path/to/file.txt")).toBe("unknown");
});
});
});
describe("isActionDocument", () => {
it("returns true for action files", () => {
expect(isActionDocument("/path/to/action.yml")).toBe(true);
});
it("returns false for workflow files", () => {
expect(isActionDocument("/repo/.github/workflows/ci.yml")).toBe(false);
});
it("returns false for unknown files", () => {
expect(isActionDocument("/path/to/config.yml")).toBe(false);
});
});
describe("isWorkflowDocument", () => {
it("returns true for workflow files", () => {
expect(isWorkflowDocument("/repo/.github/workflows/ci.yml")).toBe(true);
});
it("returns false for action files", () => {
expect(isWorkflowDocument("/path/to/action.yml")).toBe(false);
});
it("returns false for unknown files", () => {
expect(isWorkflowDocument("/path/to/config.yml")).toBe(false);
});
});
@@ -1,48 +0,0 @@
/**
* Document type detection for workflow and action files.
* Detection is based on file path/name only - content heuristics are not used
* because files in non-standard locations wouldn't work as workflows/actions anyway.
*/
export type DocumentType = "workflow" | "action" | "unknown";
/**
* Detects whether a document is a workflow file, action file, or unknown based on its URI.
*
* @param uri The document URI or file path
* @returns The detected document type
*/
export function detectDocumentType(uri: string): DocumentType {
// Normalize path separators
const normalizedUri = uri.replace(/\\/g, "/");
// Check for workflow file patterns FIRST (more specific path takes precedence)
// Matches: .github/workflows/*.yml or .github/workflows/*.yaml
// Also matches: .github/workflows-lab/*.yml or .github/workflows-lab/*.yaml
// This ensures .github/workflows/action.yml is treated as a workflow, not an action
if (/\.github\/workflows(-lab)?\/[^/]+\.ya?ml$/i.test(normalizedUri)) {
return "workflow";
}
// Check for action.yml/action.yaml patterns
// Matches: action.yml, action.yaml, .github/actions/my-action/action.yml, etc.
if (/\/action\.ya?ml$/i.test(normalizedUri) || /^action\.ya?ml$/i.test(normalizedUri)) {
return "action";
}
return "unknown";
}
/**
* Check if a document is an action file
*/
export function isActionDocument(uri: string): boolean {
return detectDocumentType(uri) === "action";
}
/**
* Check if a document is a workflow file
*/
export function isWorkflowDocument(uri: string): boolean {
return detectDocumentType(uri) === "workflow";
}
@@ -1,170 +0,0 @@
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);
});
});
});
@@ -2,36 +2,10 @@ 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";
/**
* 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);
}
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;
}
+2 -18
View File
@@ -6,24 +6,8 @@ import {Range} from "vscode-languageserver-types";
const PLACEHOLDER_KEY = "key";
/**
* Transforms a document to make it valid YAML so the parser can understand
* the cursor position during auto-completion.
*
* When typing in an IDE, the document is usually invalid YAML:
* - `runs-on` without `:` isn't a valid key
* - Empty lines don't parse as anything
* - `- ` without a value isn't complete
*
* This function inserts placeholders to make the document parseable:
* - Empty line → inserts `key:` placeholder
* - Line without colon → appends `:`
* - Sequence item `- ` → inserts `key` after the dash
*
* Lines containing `${{` are skipped to avoid breaking multi-line strings.
*
* The `isPlaceholder()` helper filters out the fake entries from completions.
*/
// Transform a document to work around YAML parsing issues
// Based on `_transform` in https://github.com/cschleiden/github-actions-parser/blob/main/src/lib/parser/complete.ts#L311
export function transform(doc: TextDocument, pos: Position): [TextDocument, Position] {
let offset = doc.offsetAt(pos);
-65
View File
@@ -1,65 +0,0 @@
/**
* Shared validation utilities for `if` condition literal text detection.
* Used by both workflow and action validation.
*/
import {data} from "@actions/expressions";
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
/**
* Checks if a format function contains literal text in its format string.
* This indicates user confusion about how expressions work.
*
* Example: format('push == {0}', github.event_name)
* The literal text "push == " will always evaluate to truthy.
*
* @param expr The expression to check
* @returns true if the expression is a format() call with literal text
*/
export function hasFormatWithLiteralText(expr: Expr): boolean {
// If this is a logical AND expression (from ensureStatusFunction wrapping)
// check the right side for the format call
if (expr instanceof Logical && expr.operator.lexeme === "&&" && expr.args.length === 2) {
return hasFormatWithLiteralText(expr.args[1]);
}
if (!(expr instanceof FunctionCall)) {
return false;
}
// Check if this is a format function
if (expr.functionName.lexeme.toLowerCase() !== "format") {
return false;
}
// Check if the first argument is a string literal
if (expr.args.length < 1) {
return false;
}
const firstArg = expr.args[0];
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== data.Kind.String) {
return false;
}
// Get the format string and trim whitespace
const formatString = firstArg.literal.coerceString();
const trimmed = formatString.trim();
// Check if there's literal text (non-replacement tokens) after trimming
let inToken = false;
for (let i = 0; i < trimmed.length; i++) {
if (!inToken && trimmed[i] === "{") {
inToken = true;
} else if (inToken && trimmed[i] === "}") {
inToken = false;
} else if (inToken && trimmed[i] >= "0" && trimmed[i] <= "9") {
// OK - this is a replacement token like {0}, {1}, etc.
} else {
// Found literal text
return true;
}
}
return false;
}
-118
View File
@@ -1,118 +0,0 @@
/**
* Shared validation utilities for step `uses` field format.
* Used by both workflow and action validation.
*/
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {mapRange} from "./range.js";
// Matches a short SHA (7-8 hex characters) that looks like it should be a full SHA
const SHORT_SHA_PATTERN = /^[0-9a-f]{7,8}$/i;
const SHORT_SHA_DOCS_URL =
"https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions";
/**
* Checks if a ref looks like a short SHA and adds a warning if so.
* Returns true if a warning was added.
*/
export function warnIfShortSha(diagnostics: Diagnostic[], token: StringToken, ref: string): boolean {
if (SHORT_SHA_PATTERN.test(ref)) {
diagnostics.push({
message: `The provided ref '${ref}' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.`,
severity: DiagnosticSeverity.Warning,
range: mapRange(token.range),
code: "short-sha-ref",
codeDescription: {
href: SHORT_SHA_DOCS_URL
}
});
return true;
}
return false;
}
/**
* Validates the format of a step's `uses` field.
*
* Valid formats:
* - docker://image:tag
* - ./local/path
* - .\local\path (Windows)
* - {owner}/{repo}@{ref}
* - {owner}/{repo}/{path}@{ref}
*/
export function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
const uses = token.value;
// Empty uses value
if (!uses) {
diagnostics.push({
message: "'uses' value in action cannot be blank",
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
return;
}
// Docker image reference - always valid format
if (uses.startsWith("docker://")) {
return;
}
// Local action path - always valid format
if (uses.startsWith("./") || uses.startsWith(".\\")) {
return;
}
// Remote action: must be {owner}/{repo}[/path]@{ref}
const atSegments = uses.split("@");
// Must have exactly one @
if (atSegments.length !== 2) {
addStepUsesFormatError(diagnostics, token);
return;
}
const [repoPath, gitRef] = atSegments;
// Ref cannot be empty
if (!gitRef) {
addStepUsesFormatError(diagnostics, token);
return;
}
// Split by / or \ to get path segments
const pathSegments = repoPath.split(/[\\/]/);
// Must have at least owner and repo (both non-empty)
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
addStepUsesFormatError(diagnostics, token);
return;
}
// Check if this is a reusable workflow reference (should be at job level, not step)
// Path would be like: owner/repo/.github/workflows/file.yml
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
diagnostics.push({
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
return;
}
// Warn if ref looks like a short SHA
warnIfShortSha(diagnostics, token, gitRef);
}
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
diagnostics.push({
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
}
+13 -65
View File
@@ -1,10 +1,4 @@
import {convertWorkflowTemplate, parseWorkflow, TemplateParseResult, WorkflowTemplate} from "@actions/workflow-parser";
import {parseAction} from "@actions/workflow-parser/actions/action-parser";
import {
ActionTemplate,
ActionTemplateConverterOptions,
convertActionTemplate
} from "@actions/workflow-parser/actions/action-template";
import {convertWorkflowTemplate, parseWorkflow, ParseWorkflowResult, WorkflowTemplate} from "@actions/workflow-parser";
import {WorkflowTemplateConverterOptions} from "@actions/workflow-parser/model/convert";
import {TemplateContext} from "@actions/workflow-parser/templates/template-context";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
@@ -13,36 +7,28 @@ import {File} from "@actions/workflow-parser/workflows/file";
import {CompletionConfig} from "../complete.js";
import {nullTrace} from "../nulltrace.js";
const parsedWorkflowCache = new Map<string, TemplateParseResult>();
const parsedActionCache = new Map<string, TemplateParseResult>();
const parsedWorkflowCache = new Map<string, ParseWorkflowResult>();
const workflowTemplateCache = new Map<string, WorkflowTemplate>();
const actionTemplateCache = new Map<string, ActionTemplate>();
export function clearCacheEntry(uri: string) {
parsedWorkflowCache.delete(uri);
parsedWorkflowCache.delete(cacheKey(uri, true));
parsedActionCache.delete(uri);
parsedActionCache.delete(cacheKey(uri, true));
parsedWorkflowCache.delete(workflowKey(uri, true));
workflowTemplateCache.delete(uri);
workflowTemplateCache.delete(cacheKey(uri, true));
actionTemplateCache.delete(uri);
actionTemplateCache.delete(cacheKey(uri, true));
workflowTemplateCache.delete(workflowKey(uri, true));
}
export function clearCache() {
parsedWorkflowCache.clear();
parsedActionCache.clear();
workflowTemplateCache.clear();
actionTemplateCache.clear();
}
/**
* Parses a workflow file, returning cached result if available
* Parses a workflow file and caches the result
* @param transformed Indicates whether the workflow has been transformed before parsing
* @returns the {@link TemplateParseResult}
* @returns the {@link ParseWorkflowResult}
*/
export function getOrParseWorkflow(file: File, uri: string, transformed = false): TemplateParseResult {
const key = cacheKey(uri, transformed);
export function fetchOrParseWorkflow(file: File, uri: string, transformed = false): ParseWorkflowResult {
const key = workflowKey(uri, transformed);
const cachedResult = parsedWorkflowCache.get(key);
if (cachedResult) {
return cachedResult;
@@ -53,27 +39,11 @@ export function getOrParseWorkflow(file: File, uri: string, transformed = false)
}
/**
* Parses an action file, returning cached result if available
* @param transformed Indicates whether the action has been transformed before parsing
* @returns the {@link TemplateParseResult}
*/
export function getOrParseAction(file: File, uri: string, transformed = false): TemplateParseResult {
const key = cacheKey(uri, transformed);
const cachedResult = parsedActionCache.get(key);
if (cachedResult) {
return cachedResult;
}
const result = parseAction(file, nullTrace);
parsedActionCache.set(key, result);
return result;
}
/**
* Converts a workflow template, returning cached result if available
* Converts a workflow template and caches the result
* @param transformed Indicates whether the workflow has been transformed before parsing
* @returns the converted {@link WorkflowTemplate}
*/
export async function getOrConvertWorkflowTemplate(
export async function fetchOrConvertWorkflowTemplate(
context: TemplateContext,
template: TemplateToken,
uri: string,
@@ -81,7 +51,7 @@ export async function getOrConvertWorkflowTemplate(
options?: WorkflowTemplateConverterOptions,
transformed = false
): Promise<WorkflowTemplate> {
const key = cacheKey(uri, transformed);
const key = workflowKey(uri, transformed);
const cachedTemplate = workflowTemplateCache.get(key);
if (cachedTemplate) {
return cachedTemplate;
@@ -91,30 +61,8 @@ export async function getOrConvertWorkflowTemplate(
return workflowTemplate;
}
/**
* Converts an action template, returning cached result if available
* @param transformed Indicates whether the action has been transformed before parsing
* @returns the converted {@link ActionTemplate}
*/
export function getOrConvertActionTemplate(
context: TemplateContext,
template: TemplateToken,
uri: string,
options?: ActionTemplateConverterOptions,
transformed = false
): ActionTemplate {
const key = cacheKey(uri, transformed);
const cachedTemplate = actionTemplateCache.get(key);
if (cachedTemplate) {
return cachedTemplate;
}
const actionTemplate = convertActionTemplate(context, template, options);
actionTemplateCache.set(key, actionTemplate);
return actionTemplate;
}
// Use a separate cache key for transformed documents
function cacheKey(uri: string, transformed: boolean): string {
// Use a separate cache key for transformed workflows
function workflowKey(uri: string, transformed: boolean): string {
if (transformed) {
return `transformed-${uri}`;
}
@@ -1,127 +0,0 @@
import {isMapping} from "@actions/workflow-parser";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {ActionReference, parseActionReference} from "./action.js";
import {mapRange} from "./utils/range.js";
import {ValidationConfig} from "./validate.js";
export const DiagnosticCode = {
MissingRequiredInputs: "missing-required-inputs"
} as const;
export interface MissingInputsDiagnosticData {
action: ActionReference;
missingInputs: Array<{
name: string;
default?: string;
}>;
}
/**
* Validates action references in workflow steps, checking for valid inputs and required inputs.
*/
export async function validateActionReference(
diagnostics: Diagnostic[],
stepToken: TemplateToken,
step: Step | undefined,
config: ValidationConfig | undefined
): Promise<void> {
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.actionsMetadataProvider) {
return;
}
// Parse the action reference (e.g., "actions/checkout@v4" -> {owner, name, ref})
const action = parseActionReference(step.uses.value);
if (!action) {
return;
}
// Fetch the action's metadata (action.yml) to get input definitions
const actionMetadata = await config.actionsMetadataProvider.fetchActionMetadata(action);
if (actionMetadata === undefined) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(step.uses.range),
message: `Unable to resolve action \`${step.uses.value}\`, repository or version not found`
});
return;
}
// Find the "with" key in the step token to get the inputs passed to the action
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const {key, value} of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
break;
}
}
// Collect the inputs provided in the step's "with" block
const stepInputs = new Map<string, ScalarToken>();
if (withToken && isMapping(withToken)) {
for (const {key} of withToken) {
stepInputs.set(key.toString(), key);
}
}
// Skip validation if the action doesn't define any inputs
const actionInputs = actionMetadata.inputs;
if (actionInputs === undefined) {
return;
}
// Check each provided input is valid and not deprecated
for (const [input, inputToken] of stepInputs) {
if (!actionInputs[input]) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(inputToken.range),
message: `Invalid action input '${input}'`
});
}
const deprecationMessage = actionInputs[input]?.deprecationMessage;
if (deprecationMessage) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
range: mapRange(inputToken.range),
message: deprecationMessage
});
}
}
// Check for required inputs that weren't provided and don't have defaults
const missingRequiredInputs = Object.entries(actionInputs).filter(
([inputName, input]) => input.required && !stepInputs.has(inputName) && input.default === undefined
);
// Report missing required inputs
if (missingRequiredInputs.length > 0) {
const message =
missingRequiredInputs.length === 1
? `Missing required input \`${missingRequiredInputs[0][0]}\``
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
// Build minimal diagnostic data - position calculation happens in the quickfix
const diagnosticData: MissingInputsDiagnosticData = {
action,
missingInputs: missingRequiredInputs.map(([name, input]) => ({
name,
default: input.default
}))
};
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange((withKey || stepToken).range),
message: message,
code: DiagnosticCode.MissingRequiredInputs,
data: diagnosticData
});
}
}
File diff suppressed because it is too large Load Diff
+65 -455
View File
@@ -1,482 +1,92 @@
/**
* Validation for action.yml / action.yaml manifest files
*/
import {Lexer, Parser} from "@actions/expressions";
import {Expr} from "@actions/expressions/ast";
import {isMapping, isString} from "@actions/workflow-parser";
import {isMapping} from "@actions/workflow-parser";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {ActionTemplate} from "@actions/workflow-parser/actions/action-template";
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
import {TemplateValidationError} from "@actions/workflow-parser/templates/template-validation-error";
import {File} from "@actions/workflow-parser/workflows/file";
import {TextDocument} from "vscode-languageserver-textdocument";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {error} from "./log.js";
import {parseActionReference} from "./action.js";
import {mapRange} from "./utils/range.js";
import {hasFormatWithLiteralText} from "./utils/validate-if.js";
import {validateStepUsesFormat} from "./utils/validate-uses.js";
import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cache.js";
import {validateActionReference} from "./validate-action-reference.js";
import {validateFormatCalls} from "./validate-format-string.js";
import {ValidationConfig} from "./validate.js";
/**
* Valid keys for each action type under the `runs:` section.
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
*/
const NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
const COMPOSITE_KEYS = new Set(["using", "steps"]);
const DOCKER_KEYS = new Set([
"using",
"image",
"args",
"env",
"entrypoint",
"pre-entrypoint",
"pre-if",
"post-entrypoint",
"post-if"
]);
/**
* Required keys for each action type (besides 'using').
*/
const NODE_REQUIRED_KEYS = ["main"];
const COMPOSITE_REQUIRED_KEYS = ["steps"];
const DOCKER_REQUIRED_KEYS = ["image"];
/**
* Validates an action.yml file
*
* @param textDocument Document to validate
* @param config Optional validation configuration for action metadata provider
* @returns Array of diagnostics
*/
export async function validateAction(textDocument: TextDocument, config?: ValidationConfig): Promise<Diagnostic[]> {
const file: File = {
name: textDocument.uri,
content: textDocument.getText()
};
const diagnostics: Diagnostic[] = [];
try {
// Parse and validate the action.yml against the schema
const result = getOrParseAction(file, textDocument.uri);
if (!result) {
return [];
}
// Convert the action template (this may add validation errors for pre-if/post-if)
let template: ActionTemplate | undefined;
if (result.value) {
template = getOrConvertActionTemplate(result.context, result.value, textDocument.uri, {
errorPolicy: ErrorPolicy.TryConversion
});
}
// Get schema and conversion errors (must be after conversion to include conversion errors)
const schemaErrors = result.context.errors.getErrors();
// Run custom runs key validation, which also filters redundant schema errors in place
if (result.value) {
diagnostics.push(...validateRunsKeysAndFilterErrors(result.value, schemaErrors));
}
// Map remaining schema errors to diagnostics
for (const err of schemaErrors) {
const range = mapRange(err.range);
// Determine severity based on error type
let severity: DiagnosticSeverity = DiagnosticSeverity.Error;
// Treat deprecation warnings as warnings
if (err.rawMessage.includes("deprecated")) {
severity = DiagnosticSeverity.Warning;
}
diagnostics.push({
message: err.rawMessage,
range,
severity
});
}
// Validate composite action steps if we have a parsed result
if (result.value && template) {
// Only composite actions have steps to validate
if (template.runs?.using === "composite") {
const steps = template.runs.steps ?? [];
// Find the steps sequence token from the raw parsed result
const stepsSequence = findStepsSequence(result.value);
if (stepsSequence) {
// Validate each action step
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const stepToken = stepsSequence.get(i);
// Validate action references (inputs, required fields) for uses steps
if (isActionStep(step) && isMapping(stepToken)) {
await validateActionReference(diagnostics, stepToken, step, config);
}
// Validate step uses format
if (isMapping(stepToken)) {
validateStepUsesField(diagnostics, stepToken);
}
}
}
}
// Single traversal for all expression validation (like workflow's additionalValidations)
validateAllTokens(diagnostics, result.value);
}
} catch (e) {
error(`Unhandled error while validating action file: ${(e as Error).message}`);
}
return diagnostics;
}
/**
* Validates the `uses` field format in a composite action step.
*/
function validateStepUsesField(diagnostics: Diagnostic[], stepToken: MappingToken): void {
for (let i = 0; i < stepToken.count; i++) {
const {key, value} = stepToken.get(i);
const keyStr = isString(key) ? key.value.toLowerCase() : "";
if (keyStr === "uses" && isString(value)) {
validateStepUsesFormat(diagnostics, value);
}
}
}
/**
* Single traversal validation for all tokens in the action template.
* This follows the same pattern as workflow validation's additionalValidations:
* - For BasicExpressionToken: validate format() calls
* - For StringToken on if conditions: validate literal text detection and format() calls
* - For pre-if/post-if with explicit ${{ }}: report error (not supported by runner)
*
* Context validation (unknown named values) is handled by workflow-parser during conversion.
*/
function validateAllTokens(diagnostics: Diagnostic[], root: TemplateToken): void {
for (const [parent, token] of TemplateToken.traverse(root)) {
const definitionKey = token.definition?.key;
// Validate all BasicExpressionToken instances for format() calls
if (token instanceof BasicExpressionToken && token.range) {
// Check for literal text in if conditions (format with literal text)
if (definitionKey === "step-if") {
validateIfLiteralText(diagnostics, token);
}
// Validate format() calls for all expressions
for (const expression of token.originalExpressions || [token]) {
validateExpressionFormatCalls(diagnostics, expression);
}
// Check for explicit ${{ }} in pre-if/post-if (not supported by runner)
if (definitionKey === "runs-if" && parent instanceof MappingToken) {
// Resolve the key name (pre-if or post-if) from parent mapping
let keyName: string | undefined;
for (let i = 0; i < parent.count; i++) {
const {key, value} = parent.get(i);
if (value === token) {
keyName = key.toString().toLowerCase();
break;
}
}
if (keyName) {
diagnostics.push({
message: `Explicit expression syntax \${{ }} is not supported for '${keyName}'. Remove the \${{ }} markers and use the expression directly.`,
range: mapRange(token.range),
severity: DiagnosticSeverity.Error,
code: "explicit-expression-not-allowed"
});
}
}
}
// Handle implicit if conditions (StringToken without ${{ }})
// These allow expression syntax without the markers
if (isString(token) && token.range) {
if (definitionKey === "step-if" || definitionKey === "runs-if") {
validateImplicitIfCondition(diagnostics, token);
}
}
}
}
const LITERAL_TEXT_IN_CONDITION_MESSAGE =
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?";
const LITERAL_TEXT_IN_CONDITION_CODE = "expression-literal-text-in-condition";
/**
* Validates an implicit if condition (StringToken without ${{ }}).
* Checks for literal text detection and validates format() calls.
*/
function validateImplicitIfCondition(diagnostics: Diagnostic[], token: StringToken): void {
const condition = token.value.trim();
if (!condition) {
export async function validateAction(
diagnostics: Diagnostic[],
stepToken: TemplateToken,
step: Step | undefined,
config: ValidationConfig | undefined
): Promise<void> {
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.actionsMetadataProvider) {
return;
}
const allowedContext = token.definitionInfo?.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
// Ensure the condition has a status function, wrapping if needed
const finalCondition = ensureStatusFunction(condition, token.definitionInfo);
try {
const l = new Lexer(finalCondition);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
const expr = p.parse();
// Check for literal text in the expression (format with literal text)
if (hasFormatWithLiteralText(expr)) {
diagnostics.push({
message: LITERAL_TEXT_IN_CONDITION_MESSAGE,
range: mapRange(token.range),
severity: DiagnosticSeverity.Error,
code: LITERAL_TEXT_IN_CONDITION_CODE
});
}
// Validate format() function calls
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
} catch {
// Ignore parse errors - they'll be caught by schema validation or workflow-parser
}
}
/**
* Validates a BasicExpressionToken for literal text in if conditions.
*/
function validateIfLiteralText(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
const allowedContext = token.definitionInfo?.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
try {
const l = new Lexer(token.expression);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
const expr = p.parse();
if (hasFormatWithLiteralText(expr)) {
diagnostics.push({
message: LITERAL_TEXT_IN_CONDITION_MESSAGE,
range: mapRange(token.range),
severity: DiagnosticSeverity.Error,
code: LITERAL_TEXT_IN_CONDITION_CODE
});
}
} catch {
// Ignore parse errors - they'll be caught by schema validation or workflow-parser
}
}
/**
* Validates format() function calls in an expression token.
*/
function validateExpressionFormatCalls(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
const allowedContext = token.definitionInfo?.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
try {
const l = new Lexer(token.expression);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
const expr = p.parse();
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
} catch {
// Ignore parse errors - they'll be caught by schema validation
}
}
/**
* Helper to validate format() function calls and add diagnostics.
*/
function validateFormatCallsAndAddDiagnostics(
diagnostics: Diagnostic[],
expr: Expr,
range: TokenRange | undefined
): void {
const formatErrors = validateFormatCalls(expr);
for (const formatError of formatErrors) {
if (formatError.type === "invalid-syntax") {
diagnostics.push({
message: `Invalid format string: ${formatError.message}`,
range: mapRange(range),
severity: DiagnosticSeverity.Error,
code: "invalid-format-string"
});
} else if (formatError.type === "arg-count-mismatch") {
diagnostics.push({
message: `Format string references argument {${formatError.expected - 1}} but only ${
formatError.provided
} argument(s) provided`,
range: mapRange(range),
severity: DiagnosticSeverity.Error,
code: "format-arg-count-mismatch"
});
}
}
}
/**
* Find the steps sequence token from the raw action template.
* Traverses the token tree looking for the "composite-steps" definition.
*/
function findStepsSequence(root: TemplateToken): SequenceToken | undefined {
for (const [, token] of TemplateToken.traverse(root)) {
if (token.definition?.key === "composite-steps" && token instanceof SequenceToken) {
return token;
}
}
return undefined;
}
/**
* Validates that the keys under `runs:` are valid for the specified `using:` type.
* Also filters out schema errors (in place) that this validation replaces with more specific messages.
*/
function validateRunsKeysAndFilterErrors(
root: TemplateToken,
schemaErrors: TemplateValidationError[] // mutated: redundant errors are removed
): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
// Find the runs mapping from the root
let runsMapping: MappingToken | undefined;
if (root instanceof MappingToken) {
for (let i = 0; i < root.count; i++) {
const {key, value} = root.get(i);
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
runsMapping = value;
break;
}
}
}
if (!runsMapping) {
return diagnostics;
const action = parseActionReference(step.uses.value);
if (!action) {
return;
}
// Get the using value from the runs mapping
let usingValue: string | undefined;
for (let i = 0; i < runsMapping.count; i++) {
const {key, value} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
usingValue = value.toString();
const actionMetadata = await config.actionsMetadataProvider.fetchActionMetadata(action);
if (actionMetadata === undefined) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(step.uses.range),
message: `Unable to resolve action \`${step.uses.value}\`, repository or version not found`
});
return;
}
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const {key, value} of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
break;
}
}
if (!usingValue) {
return diagnostics; // No using value, let schema validation handle it
const stepInputs = new Map<string, ScalarToken>();
if (withToken && isMapping(withToken)) {
for (const {key} of withToken) {
stepInputs.set(key.toString(), key);
}
}
// Determine allowed keys, required keys, and action type name
let allowedKeys: Set<string>;
let requiredKeys: string[];
let actionType: string;
if (usingValue.match(/^node\d+$/i)) {
allowedKeys = NODE_KEYS;
requiredKeys = NODE_REQUIRED_KEYS;
actionType = "Node.js";
} else if (usingValue.toLowerCase() === "composite") {
allowedKeys = COMPOSITE_KEYS;
requiredKeys = COMPOSITE_REQUIRED_KEYS;
actionType = "composite";
} else if (usingValue.toLowerCase() === "docker") {
allowedKeys = DOCKER_KEYS;
requiredKeys = DOCKER_REQUIRED_KEYS;
actionType = "Docker";
} else {
return diagnostics; // Unknown type, let schema validation handle it
const actionInputs = actionMetadata.inputs;
if (actionInputs === undefined) {
return;
}
// Get all present keys
const presentKeys = new Set<string>();
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
presentKeys.add(key.toString().toLowerCase());
}
// Check for invalid keys
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
const keyStr = key.toString().toLowerCase();
if (!allowedKeys.has(keyStr)) {
for (const [input, inputToken] of stepInputs) {
if (!actionInputs[input]) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(key.range),
message: `'${key.toString()}' is not valid for ${actionType} actions (using: ${usingValue})`
range: mapRange(inputToken.range),
message: `Invalid action input '${input}'`
});
}
const deprecationMessage = actionInputs[input]?.deprecationMessage;
if (deprecationMessage) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
range: mapRange(inputToken.range),
message: deprecationMessage
});
}
}
// Check for missing required keys
for (const requiredKey of requiredKeys) {
if (!presentKeys.has(requiredKey)) {
// Find the 'using' key to report the error location
let usingKeyRange = runsMapping.range;
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
usingKeyRange = key.range;
break;
}
}
const missingRequiredInputs = Object.entries(actionInputs).filter(
([inputName, input]) => input.required && !stepInputs.has(inputName) && input.default === undefined
);
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(usingKeyRange),
message: `'${requiredKey}' is required for ${actionType} actions (using: ${usingValue})`
});
}
if (missingRequiredInputs.length > 0) {
const message =
missingRequiredInputs.length === 1
? `Missing required input \`${missingRequiredInputs[0][0]}\``
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
message: message
});
}
// Remove schema errors that we're replacing with more specific messages (mutate in place)
for (let i = schemaErrors.length - 1; i >= 0; i--) {
const err = schemaErrors[i];
// Keep errors not at the runs section start
if (
err.range?.start.line !== runsMapping.range?.start.line ||
err.range?.start.column !== runsMapping.range?.start.column
) {
continue;
}
// Check if this is an error we're replacing
const isOneOfAmbiguity = err.rawMessage.startsWith("There's not enough info to determine");
const isRequiredKey = /^Required property is missing: (main|steps|image)$/.test(err.rawMessage);
if (!isOneOfAmbiguity && !isRequiredKey) {
continue; // Keep errors we're not replacing
}
// Remove only if we have custom diagnostics for this
if (diagnostics.length > 0) {
schemaErrors.splice(i, 1);
}
}
return diagnostics;
}
@@ -1,199 +0,0 @@
/**
* Format string validation for format() function calls.
* Port of Go's format_validator.go from actions-workflow-parser.
*/
import {Expr, FunctionCall, Literal, Binary, Unary, Logical, Grouping, IndexAccess} from "@actions/expressions/ast";
import {Kind} from "@actions/expressions/data/expressiondata";
/**
* Error types for format string validation
*/
export type FormatStringError =
| {type: "invalid-syntax"; message: string}
| {type: "arg-count-mismatch"; expected: number; provided: number};
/**
* Validates a format string and returns the maximum placeholder index.
* Port of Go's validateFormatString from format_validator.go.
*
* @param formatString The format string to validate
* @returns { valid: boolean, maxArgIndex: number } where maxArgIndex is -1 if no placeholders
*/
export function validateFormatString(formatString: string): {valid: boolean; maxArgIndex: number} {
let maxIndex = -1;
let i = 0;
while (i < formatString.length) {
// Find next left brace
let lbrace = -1;
for (let j = i; j < formatString.length; j++) {
if (formatString[j] === "{") {
lbrace = j;
break;
}
}
// Find next right brace
let rbrace = -1;
for (let j = i; j < formatString.length; j++) {
if (formatString[j] === "}") {
rbrace = j;
break;
}
}
// No more braces
if (lbrace < 0 && rbrace < 0) {
break;
}
// Left brace comes first (or only left brace exists)
if (lbrace >= 0 && (rbrace < 0 || lbrace < rbrace)) {
// Check if it's escaped
if (lbrace + 1 < formatString.length && formatString[lbrace + 1] === "{") {
// Escaped left brace
i = lbrace + 2;
continue;
}
// This is a placeholder opening - find the closing brace
rbrace = -1;
for (let j = lbrace + 1; j < formatString.length; j++) {
if (formatString[j] === "}") {
rbrace = j;
break;
}
}
if (rbrace < 0) {
// Missing closing brace
return {valid: false, maxArgIndex: -1};
}
// Validate placeholder content (must be digits only)
if (rbrace === lbrace + 1) {
// Empty placeholder {}
return {valid: false, maxArgIndex: -1};
}
// Parse the index and validate it's all digits
let index = 0;
for (let j = lbrace + 1; j < rbrace; j++) {
const c = formatString[j];
if (c < "0" || c > "9") {
// Non-numeric character
return {valid: false, maxArgIndex: -1};
}
index = index * 10 + (c.charCodeAt(0) - "0".charCodeAt(0));
}
if (index > maxIndex) {
maxIndex = index;
}
i = rbrace + 1;
continue;
}
// Right brace comes first (or only right brace exists)
// Check if it's escaped
if (rbrace + 1 < formatString.length && formatString[rbrace + 1] === "}") {
// Escaped right brace
i = rbrace + 2;
continue;
}
// Unescaped right brace outside of placeholder
return {valid: false, maxArgIndex: -1};
}
return {valid: true, maxArgIndex: maxIndex};
}
/**
* Walks an expression AST to find and validate all format() function calls.
*
* @param expr The expression AST to validate
* @returns Array of validation errors found
*/
export function validateFormatCalls(expr: Expr): FormatStringError[] {
const errors: FormatStringError[] = [];
const stack: Expr[] = [expr];
while (stack.length > 0) {
const node = stack.pop();
if (!node) {
continue;
}
if (node instanceof FunctionCall) {
if (node.functionName.lexeme.toLowerCase() === "format") {
const error = validateSingleFormatCall(node);
if (error) {
errors.push(error);
}
}
// Push args for further processing (to find nested format calls)
for (const arg of node.args) {
stack.push(arg);
}
} else if (node instanceof Binary) {
stack.push(node.left, node.right);
} else if (node instanceof Unary) {
stack.push(node.expr);
} else if (node instanceof Logical) {
for (const arg of node.args) {
stack.push(arg);
}
} else if (node instanceof Grouping) {
stack.push(node.group);
} else if (node instanceof IndexAccess) {
stack.push(node.expr, node.index);
}
// Literal, ContextAccess - no children to process
}
return errors;
}
/**
* Validates a single format() function call.
*
* @param fc The FunctionCall AST node
* @returns Validation error if found, undefined if valid
*/
function validateSingleFormatCall(fc: FunctionCall): FormatStringError | undefined {
// Must have at least one argument (the format string)
if (fc.args.length < 1) {
return undefined;
}
// First argument must be a string literal
const firstArg = fc.args[0];
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== Kind.String) {
return undefined; // Can't validate dynamic format strings
}
const formatString = firstArg.literal.coerceString();
const numArgs = fc.args.length - 1; // Subtract 1 for format string itself
const {valid, maxArgIndex} = validateFormatString(formatString);
if (!valid) {
return {
type: "invalid-syntax",
message: "Format string has invalid syntax (missing closing brace, unescaped braces, or invalid placeholder)"
};
}
if (maxArgIndex >= numArgs) {
return {
type: "arg-count-mismatch",
expected: maxArgIndex + 1, // Convert 0-based index to count
provided: numArgs
};
}
return undefined;
}
@@ -249,21 +249,7 @@ jobs:
line: 7
}
},
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
missingInputs: [
{
default: undefined,
name: "path"
}
]
}
severity: DiagnosticSeverity.Error
}
]);
});
@@ -308,25 +294,7 @@ jobs:
line: 7
}
},
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
]
}
severity: DiagnosticSeverity.Error
}
]);
});
@@ -355,25 +323,7 @@ jobs:
line: 6
}
},
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
]
}
severity: DiagnosticSeverity.Error
}
]);
});

Some files were not shown because too many files have changed in this diff Show More