Compare commits

..

2 Commits

Author SHA1 Message Date
Salman Chishti 5abd234cbf Merge branch 'main' into node24 2025-12-12 21:27:29 +00:00
Salman Muin Kayser Chishti 751cb5a940 Update action metadata to use node24 runtime
Changed the 'using' field in action metadata YAML from 'node16' to 'node24' in test utilities and tests to reflect the updated Node.js runtime environment.
2025-07-30 15:19:44 +01:00
268 changed files with 1654 additions and 13102 deletions
-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
-319
View File
@@ -1,319 +0,0 @@
# ESM Migration Plan: Add File Extensions to Imports
## Overview
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:
- **#154** - Upgrade `moduleResolution` from `node` to `node16` or `nodenext` in tsconfig
- **#110** - Published ESM code has imports without file extensions
- **#64** - expressions: ERR_MODULE_NOT_FOUND attempting to run example demo script
- **#146** - Can not import `@actions/workflow-parser`
## Problem Statement
### Current State
All packages use `"moduleResolution": "node"`:
| Package | moduleResolution | TypeScript |
|---------|------------------|------------|
| expressions | `"node"` | ^4.7.4 |
| workflow-parser | `"node"` | ^4.8.4 |
| languageservice | `"node"` | ^4.8.4 |
| languageserver | `"node"` | ^4.8.4 |
| browser-playground | `"Node16"` ✅ | ^4.9.4 |
This causes TypeScript to emit code like:
```javascript
// Published to npm - INVALID ESM
export { Expr } from "./ast"; // Missing .js extension!
```
### Why This Fails
ESM in Node.js 12+ **requires** explicit file extensions. When users try to import these packages:
```javascript
// User's code
import { Expr } from "@actions/expressions";
```
Node.js fails with:
```
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/@actions/expressions/dist/ast'
```
## Migration Strategy
### Option A: TypeScript 5.7+ with `rewriteRelativeImportExtensions` (Recommended)
TypeScript 5.7 introduced a new compiler option that automatically rewrites `.ts` extensions to `.js` in output:
```jsonc
{
"compilerOptions": {
"moduleResolution": "node16", // or "nodenext"
"rewriteRelativeImportExtensions": true
}
}
```
**Source code:**
```typescript
import { Expr } from "./ast.ts";
```
**Compiled output:**
```javascript
export { Expr } from "./ast.js";
```
**Pros:**
- Source uses `.ts` extensions (matches actual files)
- Works with Deno (which requires `.ts` extensions)
- TypeScript automatically transforms to `.js`
- Modern, forward-looking approach
**Cons:**
- Requires TypeScript 5.7+
- Relatively new feature
- **BUG:** See "Known Issues" section below
### Option B: Manual `.js` Extensions
Use `.js` extensions in source TypeScript files:
```typescript
import { Expr } from "./ast.js"; // Points to .ts file, but use .js extension
```
**Pros:**
- Works with TypeScript 4.7+ (with node16 moduleResolution)
- Well-established pattern
- No post-processing needed
- Works with ts-jest without extra configuration
**Cons:**
- Confusing - `.js` files don't exist at write time
- Doesn't work with Deno out of the box
### Recommendation
**Use Option B** (manual `.js` extensions). Option A with `rewriteRelativeImportExtensions` has compatibility issues with ts-jest and requires additional workarounds.
---
## Known Issues and Workarounds (December 2025)
### 1. TypeScript Version Conflicts in Monorepo
**Problem:** The root `node_modules/typescript` was version 4.9.5 (pulled in by `ts-node` and `tsutils` dependencies), while workspace packages specified `^5.8.3`.
**Symptoms:**
- `npx tsc --version` showed 4.9.5
- `require('typescript').version` in ts-jest showed 5.8.3
- Confusing build failures
**Solution:** Add npm overrides in root `package.json`:
```json
{
"overrides": {
"typescript": "5.8.3"
}
}
```
### 2. ts-jest Compatibility with TypeScript 5.9+
**Problem:** ts-jest 29.4.6 uses `typescript.JSDocParsingMode.ParseAll` which doesn't exist in TypeScript's ES module exports.
**Error:**
```
TypeError: Cannot read properties of undefined (reading 'ParseAll')
at Object.<anonymous> (node_modules/ts-jest/dist/compiler/ts-compiler.js:43:123)
```
**Root Cause:** ts-jest accesses `typescript_1.default.JSDocParsingMode.ParseAll` but TypeScript has no default export in ESM.
**Solution:**
- Use ts-jest 29.0.3 (older version that doesn't use this API)
- OR wait for ts-jest fix
- **Stay on TypeScript 5.8.3, not 5.9+**
### 3. TypeScript `rewriteRelativeImportExtensions` Bug with .d.ts Files
**Problem:** TypeScript's `rewriteRelativeImportExtensions: true` correctly rewrites `.ts``.js` in `.js` output files, but **incorrectly keeps `.ts` extensions in `.d.ts` declaration files**.
**Example:**
- Source: `export { Expr } from "./ast.ts";`
- Output `index.js`: `export { Expr } from "./ast.js";` ✅ Correct
- Output `index.d.ts`: `export { Expr } from "./ast.ts";` ❌ Wrong (should be `.js`)
**Upstream Issue:** https://github.com/microsoft/TypeScript/issues/61037 (marked "Help Wanted", in Backlog, NOT FIXED as of Dec 2025)
**Workaround:** Post-process `.d.ts` files with a script. See `script/fix-dts-extensions.cjs`.
**Note:** Since we use Option B (manual `.js` extensions), this bug does not affect our migration.
### 4. yaml Package Internal Types Not Exported
**Problem:** The `yaml` package does not export internal types like `LinePos` and `NodeBase` that are used in `workflow-parser/src/workflows/yaml-object-reader.ts`.
**Error:**
```
error TS2305: Module '"yaml"' has no exported member 'LinePos'.
error TS2305: Module '"yaml"' has no exported member 'NodeBase'.
```
**Solution:** Define local type aliases in the file that uses them:
```typescript
// Local type definitions to replace yaml internal imports
type LinePos = { line: number; col: number };
type NodeBase = { range?: [number, number, number] };
```
### 5. languageserver Blocked by vscode-languageserver Dependency
**Problem:** The `vscode-languageserver` package (v8.0.2) does not have proper ESM exports. When using `moduleResolution: "node16"`, TypeScript requires packages to have an `exports` field in `package.json` for subpath imports to work.
**Error:**
```
src/index.ts(6,8): error TS2307: Cannot find module 'vscode-languageserver/browser' or its corresponding type declarations.
src/connection.ts(1,43): error TS2307: Cannot find module 'vscode-languageserver/node' or its corresponding type declarations.
```
**Root Cause:** The `vscode-languageserver` package.json only has `main` and `browser` fields, but no `exports` field:
```json
{
"main": "./lib/node/main.js",
"browser": {
"./lib/node/main.js": "./lib/browser/main.js"
}
// No "exports" field!
}
```
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.
**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.
**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)
- Fork or patch the dependency
---
## Migration Status
| Package | Tests | ESM Status |
|---------|-------|------------|
| expressions | 1068 | ✅ Migrated |
| workflow-parser | 292 | ✅ Migrated |
| languageservice | 452 | ✅ Migrated |
| languageserver | 31 | 🔶 Partial (`.js` extensions added, awaiting stable vscode-languageserver) |
---
## Required Configuration Changes
### tsconfig.build.json (each migrated package)
**Note:** We use **Option B** (manual `.js` extensions in source files) rather than `rewriteRelativeImportExtensions` because Option A caused ts-jest compatibility issues (tests would hang indefinitely).
```json
{
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"skipLibCheck": true,
"lib": ["ES2022"],
"target": "ES2022"
}
}
```
The `skipLibCheck: true` is needed to work around @types/node compatibility issues with TypeScript 5.x (TS2386 overload signature errors).
```
### jest.config.js (each migrated package)
```javascript
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: "ts-jest/presets/default-esm",
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
"^(\\.{1,2}/.*)\\.ts$": "$1",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
isolatedModules: true,
},
],
},
moduleFileExtensions: ["ts", "js"],
};
```
### Root package.json
```json
{
"overrides": {
"typescript": "5.8.3"
}
}
```
### Each workspace package.json
```json
{
"devDependencies": {
"typescript": "^5.8.3",
"ts-jest": "^29.0.3"
}
}
```
---
## References
- [TypeScript moduleResolution reference](https://www.typescriptlang.org/docs/handbook/modules/reference.html)
- [TypeScript 5.7 rewriteRelativeImportExtensions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-7.html#path-rewriting-for-relative-paths)
- [TypeScript .d.ts extension bug #61037](https://github.com/microsoft/TypeScript/issues/61037)
- [Node.js ESM mandatory extensions](https://nodejs.org/api/esm.html#mandatory-file-extensions)
- [ts-jest ESM support](https://kulshekhar.github.io/ts-jest/docs/guides/esm-support)
- [Community fork that works](https://github.com/boxbuild-io/actions-languageservices/commit/077fb2b58dfd2cca3d6e3df1fdf9e26e75db24ae)
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.44",
"version": "0.3.25",
"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",
@@ -60,6 +60,6 @@
"prettier": "^2.8.3",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"typescript": "^5.8.3"
"typescript": "^4.7.4"
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import {ExpressionData} from "./data/index.js";
import {Token} from "./lexer.js";
import {ExpressionData} from "./data";
import {Token} from "./lexer";
export interface ExprVisitor<R> {
visitLiteral(literal: Literal): R;
+8 -8
View File
@@ -1,11 +1,11 @@
import {complete, CompletionItem, trimTokenVector} from "./completion.js";
import {DescriptionDictionary} from "./completion/descriptionDictionary.js";
import {BooleanData} from "./data/boolean.js";
import {Dictionary} from "./data/dictionary.js";
import {StringData} from "./data/string.js";
import {wellKnownFunctions} from "./funcs.js";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
import {Lexer, TokenType} from "./lexer.js";
import {complete, CompletionItem, trimTokenVector} from "./completion";
import {DescriptionDictionary} from "./completion/descriptionDictionary";
import {BooleanData} from "./data/boolean";
import {Dictionary} from "./data/dictionary";
import {StringData} from "./data/string";
import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {Lexer, TokenType} from "./lexer";
const testContext = new Dictionary(
{
+9 -13
View File
@@ -1,12 +1,11 @@
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";
import {Parser} from "./parser.js";
import {DescriptionPair} from "./completion/descriptionDictionary";
import {Dictionary, isDictionary} from "./data/dictionary";
import {ExpressionData} from "./data/expressiondata";
import {Evaluator} from "./evaluator";
import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {Lexer, Token, TokenType} from "./lexer";
import {Parser} from "./parser";
export type CompletionItem = {
label: string;
@@ -27,16 +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>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
featureFlags?: FeatureFlags
functions?: Map<string, FunctionDefinition>
): CompletionItem[] {
// Lex
const lexer = new Lexer(input);
@@ -1,5 +1,5 @@
import {StringData} from "../data/index.js";
import {DescriptionDictionary} from "./descriptionDictionary.js";
import {StringData} from "../data";
import {DescriptionDictionary} from "./descriptionDictionary";
describe("description dictionary", () => {
it("pairs contains all values", () => {
@@ -1,5 +1,5 @@
import {Dictionary} from "../data/dictionary.js";
import {ExpressionData, Kind, Pair} from "../data/expressiondata.js";
import {Dictionary} from "../data/dictionary";
import {ExpressionData, Kind, Pair} from "../data/expressiondata";
export type DescriptionPair = Pair & {description?: string};
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata.js";
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata";
export class Array implements ExpressionDataInterface {
private v: ExpressionData[] = [];
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
import {ExpressionDataInterface, Kind} from "./expressiondata";
export class BooleanData implements ExpressionDataInterface {
constructor(public readonly value: boolean) {}
+2 -2
View File
@@ -1,5 +1,5 @@
import {Dictionary} from "./dictionary.js";
import {StringData} from "./string.js";
import {Dictionary} from "./dictionary";
import {StringData} from "./string";
describe("dictionary", () => {
it("pairs contains all values", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata.js";
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata";
export class Dictionary implements ExpressionDataInterface {
private keys: string[] = [];
+6 -6
View File
@@ -1,9 +1,9 @@
import {Dictionary} from "./dictionary.js";
import {Null} from "./null.js";
import {Array} from "./array.js";
import {StringData} from "./string.js";
import {NumberData} from "./number.js";
import {BooleanData} from "./boolean.js";
import {Dictionary} from "./dictionary";
import {Null} from "./null";
import {Array} from "./array";
import {StringData} from "./string";
import {NumberData} from "./number";
import {BooleanData} from "./boolean";
export enum Kind {
String = 0,
+9 -9
View File
@@ -1,9 +1,9 @@
export {Array} from "./array.js";
export {BooleanData} from "./boolean.js";
export {Dictionary} from "./dictionary.js";
export {ExpressionData, Kind} from "./expressiondata.js";
export {Null} from "./null.js";
export {NumberData} from "./number.js";
export {replacer} from "./replacer.js";
export {reviver} from "./reviver.js";
export {StringData} from "./string.js";
export {Array} from "./array";
export {BooleanData} from "./boolean";
export {Dictionary} from "./dictionary";
export {ExpressionData, Kind} from "./expressiondata";
export {Null} from "./null";
export {NumberData} from "./number";
export {replacer} from "./replacer";
export {reviver} from "./reviver";
export {StringData} from "./string";
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
import {ExpressionDataInterface, Kind} from "./expressiondata";
export class Null implements ExpressionDataInterface {
public readonly kind = Kind.Null;
+1 -1
View File
@@ -1,4 +1,4 @@
import {NumberData} from "./number.js";
import {NumberData} from "./number";
describe("number", () => {
it("coerces to string", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
import {ExpressionDataInterface, Kind} from "./expressiondata";
export class NumberData implements ExpressionDataInterface {
constructor(public readonly value: number) {}
+6 -6
View File
@@ -1,9 +1,9 @@
import {Array} from "./array.js";
import {Dictionary} from "./dictionary.js";
import {Null} from "./null.js";
import {NumberData} from "./number.js";
import {replacer} from "./replacer.js";
import {StringData} from "./string.js";
import {Array} from "./array";
import {Dictionary} from "./dictionary";
import {Null} from "./null";
import {NumberData} from "./number";
import {replacer} from "./replacer";
import {StringData} from "./string";
describe("replacer", () => {
it("null", () => {
+6 -6
View File
@@ -1,9 +1,9 @@
import {Array} from "./array.js";
import {BooleanData} from "./boolean.js";
import {Dictionary} from "./dictionary.js";
import {Null} from "./null.js";
import {NumberData} from "./number.js";
import {StringData} from "./string.js";
import {Array} from "./array";
import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary";
import {Null} from "./null";
import {NumberData} from "./number";
import {StringData} from "./string";
/**
* Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON
+8 -8
View File
@@ -1,11 +1,11 @@
import {Array} from "./array.js";
import {BooleanData} from "./boolean.js";
import {Dictionary} from "./dictionary.js";
import {ExpressionData} from "./expressiondata.js";
import {Null} from "./null.js";
import {NumberData} from "./number.js";
import {reviver} from "./reviver.js";
import {StringData} from "./string.js";
import {Array} from "./array";
import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary";
import {ExpressionData} from "./expressiondata";
import {Null} from "./null";
import {NumberData} from "./number";
import {reviver} from "./reviver";
import {StringData} from "./string";
describe("reviver", () => {
const tests: {
+7 -7
View File
@@ -1,10 +1,10 @@
import {Array as dArray} from "./array.js";
import {BooleanData} from "./boolean.js";
import {Dictionary} from "./dictionary.js";
import {ExpressionData} from "./expressiondata.js";
import {Null} from "./null.js";
import {NumberData} from "./number.js";
import {StringData} from "./string.js";
import {Array as dArray} from "./array";
import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary";
import {ExpressionData} from "./expressiondata";
import {Null} from "./null";
import {NumberData} from "./number";
import {StringData} from "./string";
/**
* Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object.
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
import {ExpressionDataInterface, Kind} from "./expressiondata";
export class StringData implements ExpressionDataInterface {
constructor(public readonly value: string) {}
+1 -4
View File
@@ -1,4 +1,4 @@
import {Pos, Token, tokenString} from "./lexer.js";
import {Pos, Token, tokenString} from "./lexer";
export const MAX_PARSER_DEPTH = 50;
export const MAX_EXPRESSION_LENGTH = 21000;
@@ -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:
+5 -5
View File
@@ -1,8 +1,8 @@
import * as data from "./data/index.js";
import {ExpressionEvaluationError} from "./errors.js";
import {Evaluator} from "./evaluator.js";
import {Lexer} from "./lexer.js";
import {Parser} from "./parser.js";
import * as data from "./data";
import {ExpressionEvaluationError} from "./errors";
import {Evaluator} from "./evaluator";
import {Lexer} from "./lexer";
import {Parser} from "./parser";
describe("evaluator", () => {
const lexAndParse = (input: string) => {
+8 -8
View File
@@ -10,14 +10,14 @@ import {
Logical,
Star,
Unary
} from "./ast.js";
import * as data from "./data/index.js";
import {FilteredArray} from "./filtered_array.js";
import {wellKnownFunctions} from "./funcs.js";
import {FunctionDefinition} from "./funcs/info.js";
import {idxHelper} from "./idxHelper.js";
import {TokenType} from "./lexer.js";
import {equals, falsy, greaterThan, lessThan, truthy} from "./result.js";
} from "./ast";
import * as data from "./data";
import {FilteredArray} from "./filtered_array";
import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition} from "./funcs/info";
import {idxHelper} from "./idxHelper";
import {TokenType} from "./lexer";
import {equals, falsy, greaterThan, lessThan, truthy} from "./result";
export class Evaluator implements ExprVisitor<data.ExpressionData> {
/**
-57
View File
@@ -1,57 +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);
});
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"]);
});
});
});
-73
View File
@@ -1,73 +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;
}
/**
* 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"];
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));
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
import * as data from "./data/index.js";
import * as data from "./data";
export class FilteredArray extends data.Array {}
+10 -17
View File
@@ -1,14 +1,13 @@
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";
import {fromjson} from "./funcs/fromjson.js";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
import {join} from "./funcs/join.js";
import {startswith} from "./funcs/startswith.js";
import {tojson} from "./funcs/tojson.js";
import {Token} from "./lexer.js";
import {ErrorType, ExpressionError} from "./errors";
import {contains} from "./funcs/contains";
import {endswith} from "./funcs/endswith";
import {format} from "./funcs/format";
import {fromjson} from "./funcs/fromjson";
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {join} from "./funcs/join";
import {startswith} from "./funcs/startswith";
import {tojson} from "./funcs/tojson";
import {Token} from "./lexer";
export type ParseContext = {
allowUnknownKeywords: boolean;
@@ -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];
}
};
+3 -3
View File
@@ -1,6 +1,6 @@
import {BooleanData, ExpressionData, Kind} from "../data/index.js";
import {equals} from "../result.js";
import {FunctionDefinition} from "./info.js";
import {BooleanData, ExpressionData, Kind} from "../data";
import {equals} from "../result";
import {FunctionDefinition} from "./info";
export const contains: FunctionDefinition = {
name: "contains",
+3 -3
View File
@@ -1,6 +1,6 @@
import {BooleanData, ExpressionData} from "../data/index.js";
import {toUpperSpecial} from "../result.js";
import {FunctionDefinition} from "./info.js";
import {BooleanData, ExpressionData} from "../data";
import {toUpperSpecial} from "../result";
import {FunctionDefinition} from "./info";
export const endswith: FunctionDefinition = {
name: "endsWith",
+2 -2
View File
@@ -1,5 +1,5 @@
import {Null, NumberData, StringData} from "../data/index.js";
import {format} from "./format.js";
import {Null, NumberData, StringData} from "../data";
import {format} from "./format";
describe("format", () => {
it("null", () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import {ExpressionData, StringData} from "../data/index.js";
import {FunctionDefinition} from "./info.js";
import {ExpressionData, StringData} from "../data";
import {FunctionDefinition} from "./info";
export const format: FunctionDefinition = {
name: "format",
+4 -4
View File
@@ -1,7 +1,7 @@
import {ExpressionData} from "../data/index.js";
import {reviver} from "../data/reviver.js";
import {ExpressionEvaluationError} from "../errors.js";
import {FunctionDefinition} from "./info.js";
import {ExpressionData} from "../data";
import {reviver} from "../data/reviver";
import {ExpressionEvaluationError} from "../errors";
import {FunctionDefinition} from "./info";
export const fromjson: FunctionDefinition = {
name: "fromJson",
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData} from "../data/index.js";
import {ExpressionData} from "../data";
export interface FunctionInfo {
name: string;
+2 -2
View File
@@ -1,5 +1,5 @@
import {ExpressionData, Kind, StringData} from "../data/index.js";
import {FunctionDefinition} from "./info.js";
import {ExpressionData, Kind, StringData} from "../data";
import {FunctionDefinition} from "./info";
export const join: FunctionDefinition = {
name: "join",
+3 -3
View File
@@ -1,6 +1,6 @@
import {BooleanData, ExpressionData} from "../data/index.js";
import {toUpperSpecial} from "../result.js";
import {FunctionDefinition} from "./info.js";
import {BooleanData, ExpressionData} from "../data";
import {toUpperSpecial} from "../result";
import {FunctionDefinition} from "./info";
export const startswith: FunctionDefinition = {
name: "startsWith",
+3 -3
View File
@@ -1,6 +1,6 @@
import {ExpressionData, StringData} from "../data/index.js";
import {replacer} from "../data/replacer.js";
import {FunctionDefinition} from "./info.js";
import {ExpressionData, StringData} from "../data";
import {replacer} from "../data/replacer";
import {FunctionDefinition} from "./info";
export const tojson: FunctionDefinition = {
name: "toJson",
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData} from "./data/index.js";
import {ExpressionData} from "./data";
export class idxHelper {
public readonly str: string | undefined;
+9 -10
View File
@@ -1,10 +1,9 @@
export {Expr} from "./ast.js";
export {complete, CompletionItem} from "./completion.js";
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary.js";
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";
export {Expr} from "./ast";
export {complete, CompletionItem} from "./completion";
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary";
export * as data from "./data";
export {ExpressionError, ExpressionEvaluationError} from "./errors";
export {Evaluator} from "./evaluator";
export {wellKnownFunctions} from "./funcs";
export {Lexer, Result} from "./lexer";
export {Parser} from "./parser";
+1 -1
View File
@@ -1,4 +1,4 @@
import {Lexer, Token, TokenType} from "./lexer.js";
import {Lexer, Token, TokenType} from "./lexer";
describe("lexer", () => {
const tests: {
+2 -2
View File
@@ -1,5 +1,5 @@
import {StringData} from "./data/index.js";
import {MAX_EXPRESSION_LENGTH} from "./errors.js";
import {StringData} from "./data";
import {MAX_EXPRESSION_LENGTH} from "./errors";
export enum TokenType {
UNKNOWN,
+6 -17
View File
@@ -1,20 +1,9 @@
import {
Binary,
ContextAccess,
Expr,
FunctionCall,
Grouping,
IndexAccess,
Literal,
Logical,
Star,
Unary
} from "./ast.js";
import * as data from "./data/index.js";
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors.js";
import {ParseContext, validateFunction} from "./funcs.js";
import {FunctionInfo} from "./funcs/info.js";
import {Token, TokenType} from "./lexer.js";
import {Binary, ContextAccess, Expr, FunctionCall, Grouping, IndexAccess, Literal, Logical, Star, Unary} from "./ast";
import * as data from "./data";
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors";
import {ParseContext, validateFunction} from "./funcs";
import {FunctionInfo} from "./funcs/info";
import {Token, TokenType} from "./lexer";
export class Parser {
private extContexts: Map<string, boolean>;
+2 -2
View File
@@ -1,5 +1,5 @@
import {BooleanData, ExpressionData, NumberData, StringData} from "./data/index.js";
import {coerceTypes, toUpperSpecial} from "./result.js";
import {BooleanData, ExpressionData, NumberData, StringData} from "./data";
import {coerceTypes, toUpperSpecial} from "./result";
describe("coerceTypes", () => {
const tests: {
+1 -1
View File
@@ -1,4 +1,4 @@
import * as data from "./data/index.js";
import * as data from "./data";
export function falsy(d: data.ExpressionData): boolean {
switch (d.kind) {
+9 -9
View File
@@ -1,14 +1,14 @@
import * as fs from "fs";
import * as path from "path";
import {Expr} from "./ast.js";
import * as data from "./data/index.js";
import {kindStr} from "./data/expressiondata.js";
import {replacer} from "./data/replacer.js";
import {reviver} from "./data/reviver.js";
import {ExpressionError} from "./errors.js";
import {Evaluator} from "./evaluator.js";
import {Lexer, Result} from "./lexer.js";
import {Parser} from "./parser.js";
import {Expr} from "./ast";
import * as data from "./data";
import {kindStr} from "./data/expressiondata";
import {replacer} from "./data/replacer";
import {reviver} from "./data/reviver";
import {ExpressionError} from "./errors";
import {Evaluator} from "./evaluator";
import {Lexer, Result} from "./lexer";
import {Parser} from "./parser";
interface TestResult {
value: data.ExpressionData;
-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')"
}
}
]
}
+1 -4
View File
@@ -2,12 +2,9 @@
"exclude": ["./src/**/*.test.ts"],
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "./dist",
"skipLibCheck": true
"outDir": "./dist"
}
}
-205
View File
@@ -10,14 +10,6 @@ The [package](https://www.npmjs.com/package/@actions/languageserver) contains Ty
npm install @actions/languageserver
```
To install the language server as a standalone CLI:
```bash
npm install -g @actions/languageserver
```
This makes the `actions-languageserver` command available globally.
## Usage
### Basic usage using `vscode-languageserver-node`
@@ -84,11 +76,6 @@ export interface InitializationOptions {
* Desired log level
*/
logLevel?: LogLevel;
/**
* Experimental features that are opt-in
*/
experimentalFeatures?: ExperimentalFeatures;
}
```
@@ -105,177 +92,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 |
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:
```bash
actions-languageserver --stdio
```
This starts the language server using stdio transport, which is the standard way for editors to communicate with language servers.
### In Neovim
#### 1. Install the language server
```bash
npm install -g @actions/languageserver
```
#### 2. Set up filetype detection
Add this to your `init.lua` to detect GitHub Actions workflow files:
```lua
vim.filetype.add({
pattern = {
[".*/%.github/workflows/.*%.ya?ml"] = "yaml.ghactions",
},
})
```
This sets the filetype to `yaml.ghactions` for YAML files in `.github/workflows/`, allowing you to keep separate YAML LSP configurations if needed.
#### 3. Create the LSP configuration
As of Neovim 0.11+ you can add this configuration in `~/.config/nvim/lsp/actionsls.lua`:
```lua
local function get_github_token()
local handle = io.popen("gh auth token 2>/dev/null")
if not handle then return nil end
local token = handle: read("*a"):gsub("%s+", "")
handle:close()
return token ~= "" and token or nil
end
local function parse_github_remote(url)
if not url or url == "" then return nil end
-- SSH format: git@github.com:owner/repo.git
local owner, repo = url:match("git@github%.com:([^/]+)/([^/%.]+)")
if owner and repo then
return owner, repo: gsub("%.git$", "")
end
-- HTTPS format: https://github.com/owner/repo.git
owner, repo = url:match("github%.com/([^/]+)/([^/%.]+)")
if owner and repo then
return owner, repo:gsub("%.git$", "")
end
return nil
end
local function get_repo_info(owner, repo)
local cmd = string.format(
"gh repo view %s/%s --json id,owner --template '{{.id}}\t{{.owner.type}}' 2>/dev/null",
owner,
repo
)
local handle = io.popen(cmd)
if not handle then return nil end
local result = handle: read("*a"):gsub("%s+$", "")
handle:close()
local id, owner_type = result:match("^(%d+)\t(.+)$")
if id then
return {
id = tonumber(id),
organizationOwned = owner_type == "Organization",
}
end
return nil
end
local function get_repos_config()
local handle = io.popen("git rev-parse --show-toplevel 2>/dev/null")
if not handle then return nil end
local git_root = handle: read("*a"):gsub("%s+", "")
handle:close()
if git_root == "" then return nil end
handle = io.popen("git remote get-url origin 2>/dev/null")
if not handle then return nil end
local remote_url = handle:read("*a"):gsub("%s+", "")
handle:close()
local owner, name = parse_github_remote(remote_url)
if not owner or not name then return nil end
local info = get_repo_info(owner, name)
return {
{
id = info and info.id or 0,
owner = owner,
name = name,
organizationOwned = info and info.organizationOwned or false,
workspaceUri = "file://" .. git_root,
},
}
end
return {
cmd = { "actions-languageserver", "--stdio" },
filetypes = { "yaml.ghactions" },
root_markers = { ".git" },
init_options = {
-- Optional: provide a GitHub token and repo context for added functionality
-- (e.g., repository-specific completions)
sessionToken = get_github_token(),
repos = get_repos_config(),
},
}
```
#### 4. Enable the LSP
Add to your `init.lua`:
```lua
vim.lsp.enable('actionsls')
```
#### 5. Verify it's working
Open any `.github/workflows/*.yml` file and run:
```vim
:checkhealth vim.lsp
```
You should see `actionsls` in the list of attached clients.
## Contributing
See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations.
@@ -294,27 +110,6 @@ or to watch for changes
npm run watch
```
### Running the language server locally
After running
```bash
npm run build:cli
npm link
```
`actions-languageserver` will be available globally. You can start it with:
```bash
actions-languageserver --stdio
```
Once linked you can also watch for changes and rebuild automatically:
```bash
npm run watch:cli
```
### Test
```bash
@@ -1,2 +0,0 @@
#!/usr/bin/env node
import "../dist/cli.bundle.cjs";
+7 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.44",
"version": "0.3.25",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -31,25 +31,20 @@
"url": "https://github.com/actions/languageservices"
},
"scripts": {
"build": "tsc --build tsconfig.build.json && npm run build:cli",
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
"build": "tsc --build tsconfig.build.json",
"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",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"watch": "tsc --build tsconfig.build.json --watch",
"watch:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs --watch"
},
"bin": {
"actions-languageserver": "./bin/actions-languageserver"
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/languageservice": "^0.3.44",
"@actions/workflow-parser": "^0.3.44",
"@actions/languageservice": "^0.3.25",
"@actions/workflow-parser": "^0.3.25",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -60,14 +55,12 @@
"node": ">= 18"
},
"files": [
"dist/**/*",
"bin/**/*"
"dist/**/*"
],
"devDependencies": {
"@types/jest": "^29.0.3",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"esbuild": "^0.27.1",
"eslint": "^8.36.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
+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});
})
@@ -16,7 +16,7 @@ inputs:
description: 'Repository name with owner. For example, actions/checkout'
deprecationMessage: 'Use repository instead'
runs:
using: node16
using: node24
main: dist/index.js
post: dist/index.js
`;
@@ -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 = `
@@ -12,7 +12,7 @@ inputs:
description: Repository name with owner. For example, actions/checkout
default: \${{ github.repository }}
runs:
using: node16
using: node24
main: dist/index.js
post: dist/index.js
`;
@@ -231,7 +231,7 @@ inputs:
description: 📦 Repository 📦 name with owner. For example, actions/checkout
default: \${{ github.repository }}
runs:
using: node16
using: node24
main: dist/index.js
post: dist/index.js
`;
+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"
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.44",
"version": "0.3.25",
"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,8 +47,8 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.44",
"@actions/workflow-parser": "^0.3.44",
"@actions/expressions": "^0.3.25",
"@actions/workflow-parser": "^0.3.25",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
+1 -1
View File
@@ -1,4 +1,4 @@
import {actionIdentifier, parseActionReference as parse} from "./action.js";
import {actionIdentifier, parseActionReference as parse} from "./action";
describe("parseActionReference", () => {
it("basic action", () => {
@@ -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,13 +1,13 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {data, DescriptionDictionary} from "@actions/expressions";
import {CompletionItem, CompletionItemKind, MarkupContent} from "vscode-languageserver-types";
import {complete, getExpressionInput} from "./complete.js";
import {ContextProviderConfig} from "./context-providers/config.js";
import {registerLogger} from "./log.js";
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
import {TestLogger} from "./test-utils/logger.js";
import {testFileProvider} from "./test-utils/test-file-provider.js";
import {clearCache} from "./utils/workflow-cache.js";
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
import {complete, getExpressionInput} from "./complete";
import {ContextProviderConfig} from "./context-providers/config";
import {registerLogger} from "./log";
import {getPositionFromCursor} from "./test-utils/cursor-position";
import {TestLogger} from "./test-utils/logger";
import {testFileProvider} from "./test-utils/test-file-provider";
import {clearCache} from "./utils/workflow-cache";
const contextProviderConfig: ContextProviderConfig = {
getContext: (context: string) => {
@@ -68,15 +68,12 @@ describe("expressions", () => {
describe("top-level auto-complete", () => {
it("single region", async () => {
const input = "run-name: ${{ | }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
});
const result = await complete(...getPositionFromCursor(input));
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -111,15 +108,12 @@ describe("expressions", () => {
it("single region with existing input", async () => {
const input = "run-name: ${{ g| }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
});
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -132,15 +126,12 @@ describe("expressions", () => {
it("single region with existing condition", async () => {
const input = "run-name: ${{ g| == 'test' }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
});
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -153,15 +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
});
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -174,15 +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
});
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -195,15 +180,12 @@ describe("expressions", () => {
it("multiple regions", async () => {
const input = "run-name: test-${{ github }}-${{ | }}";
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
});
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -413,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");
});
});
});
@@ -1158,7 +1110,7 @@ jobs:
`;
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual(["check_run_id", "container", "services", "status"]);
expect(result.map(x => x.label)).toEqual(["container", "services", "status"]);
});
it("job context is suggested within a job output", async () => {
@@ -1174,9 +1126,7 @@ jobs:
run: echo hi
`;
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
});
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"env",
"github",
@@ -1189,7 +1139,6 @@ jobs:
"steps",
"strategy",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -1301,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");
@@ -1,7 +1,7 @@
import {complete} from "./complete.js";
import {complete} from "./complete";
import {TextDocument} from "vscode-languageserver-textdocument";
import {clearCache} from "./utils/workflow-cache.js";
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
import {clearCache} from "./utils/workflow-cache";
import {getPositionFromCursor} from "./test-utils/cursor-position";
beforeEach(() => {
clearCache();
@@ -1,8 +1,8 @@
import {CompletionItem, MarkupContent} from "vscode-languageserver-types";
import {complete} from "./complete.js";
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
import {testFileProvider} from "./test-utils/test-file-provider.js";
import {clearCache} from "./utils/workflow-cache.js";
import {complete} from "./complete";
import {getPositionFromCursor} from "./test-utils/cursor-position";
import {testFileProvider} from "./test-utils/test-file-provider";
import {clearCache} from "./utils/workflow-cache";
function mapResult(result: CompletionItem[]) {
return result.map(x => {

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