Compare commits

..

6 Commits

Author SHA1 Message Date
Francesco Renzi 4efa31459b works 2025-12-09 10:43:21 +00:00
Francesco Renzi f8ea05739d Add more tests 2025-11-28 15:37:32 +00:00
Francesco Renzi 73dd3c33c4 prettier 2025-11-28 14:57:59 +00:00
Francesco Renzi e5800c8843 Setup CodeActions and add quickfix for missing inputs 2025-11-28 14:56:01 +00:00
Francesco Renzi bba2a01c01 docs 2025-11-28 08:59:43 +00:00
Francesco Renzi ec52bd7358 Add instructions on how to run locally and in neovim 2025-11-27 15:37:14 +00:00
232 changed files with 138640 additions and 42578 deletions
+3 -40
View File
@@ -12,55 +12,18 @@ jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
- name: Use Node.js 16.15
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version: 16.15
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
- run: npm ci --engine-strict
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm run format-check -ws
- run: npm run build -ws
- run: npm run lint -ws
- run: npm test -ws
check-generated:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Regenerate JSON files
run: |
cd languageservice && npm run update-webhooks && cd ..
- name: Check for uncommitted changes
run: |
if ! git diff --exit-code; then
echo ""
echo "=========================================="
echo "ERROR: Generated files are out of date!"
echo "=========================================="
echo ""
echo "Please run the following commands locally and commit the changes:"
echo ""
echo " cd languageservice && npm run update-webhooks && cd .."
echo " git add -A && git commit -m 'Regenerate JSON files'"
echo ""
exit 1
fi
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 16.x
cache: "npm"
scope: '@actions'
+1 -9
View File
@@ -2,12 +2,4 @@
*/dist
lerna-debug.log
node_modules
.DS_Store
# Minified JSON (generated at build time)
*.min.json
# Intermediate JSON for size comparison (generated by update-webhooks --all)
*.all.json
*.drop.json
*.strip.json
.DS_Store
+152
View File
@@ -0,0 +1,152 @@
# Using GitHub Actions Language Server in Neovim
## Prerequisites
- Node.js 18+
- Neovim 0.11+ with the new LSP config format
## Setup Options
### Option 1: Install from npm (Recommended)
Once published, you can install globally:
```bash
npm install -g @actions/languageserver
```
Then configure Neovim to use the installed binary:
```lua
-- ~/.config/nvim/lsp/actionsls.lua
return {
cmd = { "actions-languageserver" },
filetypes = { "yaml.ghaction" }, -- GitHub Actions workflow files only
root_markers = { ".git" },
init_options = {
sessionToken = vim.fn.system("gh auth token"):gsub("%s+", ""),
logLevel = "info",
},
}
```
**Note:** This requires the package to be published to npm first.
### Option 2: Local Development Build
For development or if the npm package isn't published yet:
### 1. Clone and build
```bash
git clone https://github.com/actions/languageservices.git
cd languageservices
npm install
npm run build --workspaces --if-present
```
### 2. Bundle the server
The server needs to be bundled into a single file to avoid ESM module resolution issues:
```bash
cd languageserver
npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node18 \
--format=cjs \
--outfile=dist/server-bundled.cjs \
--external:vscode \
--loader:.json=json
```
This creates `dist/server-bundled.cjs` (~5.6MB) that contains the entire server.
### 3. Configure Neovim
Create `~/.config/nvim/lsp/actionsls.lua`:
```lua
return {
cmd = {
"/absolute/path/to/languageservices/languageserver/bin/actions-languageserver",
},
filetypes = { "yaml.ghaction" }, -- GitHub Actions workflow files only
root_markers = { ".git" },
init_options = {
sessionToken = vim.fn.system("gh auth token"):gsub("%s+", ""),
logLevel = "info",
},
}
```
**Important:** Replace `/absolute/path/to/languageservices` with your actual clone path.
## Filetype Detection for GitHub Actions Workflows
To ensure the LSP only runs on GitHub Actions workflow files (not all YAML files), set up filetype detection:
**Option A:** In `~/.config/nvim/init.lua`:
```lua
vim.api.nvim_create_autocmd({"BufRead", "BufNewFile"}, {
pattern = ".github/workflows/*.{yml,yaml}",
callback = function()
vim.bo.filetype = "yaml.ghaction"
end,
})
```
**Option B:** Create `~/.config/nvim/ftdetect/ghaction.vim`:
```vim
au BufRead,BufNewFile .github/workflows/*.yml,*.yaml setfiletype yaml.ghaction
```
This sets the filetype to `yaml.ghaction` for files in `.github/workflows/`, matching the `filetypes` setting in your LSP config.
### 4. Enable the LSP in your init.lua
Add to your Neovim configuration:
```lua
vim.lsp.enable('actionsls')
```
### 5. Restart Neovim
Open any `.github/workflows/*.yml` file. The filetype detection will set it to `yaml.ghaction`, and the language server will attach automatically.
## Files Created
- `languageserver/dist/server-bundled.cjs` - Bundled server (~5.6MB)
- `languageserver/bin/actions-languageserver` - Shell wrapper script
The `dist/` directory is gitignored; you'll need to rebuild after pulling updates.
## Troubleshooting
Check if the server is running:
```vim
:lua =vim.lsp.get_clients()
```
View LSP logs:
```bash
tail -f ~/.local/state/nvim/lsp.log
```
Manually start the server to test:
```vim
:lua vim.lsp.start({name='actionsls', cmd={'/path/to/bin/actions-languageserver'}, root_dir=vim.fn.getcwd(), init_options={sessionToken='', logLevel='info'}})
```
## Notes
- The main code change is in `languageserver/src/index.ts` to use dynamic imports, avoiding loading browser modules in Node.js
- The bundling step is necessary because TypeScript outputs ESM with bare imports that Node.js can't resolve
- Only workflow files in git repositories will activate the LSP (due to `root_markers = { ".git" }`)
-4
View File
@@ -8,10 +8,6 @@ This repository contains multiple npm packages for working with GitHub Actions w
- [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors
- [browser-playground](./browser-playground) - Browser-based playground for the language service
## Documentation
- [JSON Data Files](./docs/json-data-files.md) - How the JSON data files are generated and maintained
### Note
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
-299
View File
@@ -1,299 +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.
## 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:** Verified December 2025. Version 9.0.1 is available but ESM export support is not confirmed.
**Current Decision:** The languageserver package is **deferred** from this migration until the upstream `vscode-languageserver` package adds proper ESM exports. It will continue using the old `moduleResolution: "node"` configuration.
**Options to resolve:**
- Wait for vscode-languageserver to add ESM exports
- Try upgrading to vscode-languageserver v9.x to see if exports were added
- Use a bundler to work around the module resolution
- Fork or patch the dependency
---
## Migration Status
| Package | Tests | ESM Status |
|---------|-------|------------|
| expressions | 1068 | ✅ Migrated |
| workflow-parser | 292 | ✅ Migrated |
| languageservice | 452 | ✅ Migrated |
| languageserver | 6 files | ⏸️ Deferred (vscode-languageserver lacks ESM exports) |
---
## 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)
-197
View File
@@ -1,197 +0,0 @@
# JSON Data Files
This document describes the JSON data files used by the language service packages and how they are maintained.
## Overview
The language service uses several JSON files containing schema definitions, webhook payloads, and other metadata. To reduce bundle size, these files are:
1. **Optimized at generation time** — unused events are dropped, unused fields are stripped
2. **Minified at build time** — whitespace is removed to produce `.min.json` files
The source `.json` files are human-readable and checked into the repository. The `.min.json` files are generated during build and gitignored.
## Files
### languageservice
| File | Description |
|------|-------------|
| `src/context-providers/events/webhooks.json` | Webhook event payload schemas for autocompletion |
| `src/context-providers/events/objects.json` | Deduplicated shared object definitions referenced by webhooks |
| `src/context-providers/events/schedule.json` | Schedule event context data |
| `src/context-providers/events/workflow_call.json` | Reusable workflow call context data |
| `src/context-providers/descriptions.json` | Context variable descriptions for hover |
### workflow-parser
| File | Description |
|------|-------------|
| `src/workflow-v1.0.json` | Workflow YAML schema definition |
## Generation
### Webhooks and Objects
The `webhooks.json` and `objects.json` files are generated from the [GitHub REST API description](https://github.com/github/rest-api-description):
```bash
cd languageservice
npm run update-webhooks
```
This script:
1. Fetches webhook schemas from the GitHub API description
2. **Validates** all events are categorized (fails if new events are found)
3. **Drops** events that aren't valid workflow triggers (see [Dropped Events](#dropped-events))
4. **Strips** unused fields like `description` and `summary` (see [Stripped Fields](#stripped-fields))
5. **Deduplicates** shared object definitions into `objects.json`
6. Writes the optimized, pretty-printed JSON files
### Handling New Webhook Events
When GitHub adds a new webhook event, the script will fail with an error like:
```
ERROR: New webhook event(s) detected!
The following events are not categorized:
- new_event_name
Action required:
1. Check if the event is a valid workflow trigger
2. Add the event to DROPPED_EVENTS or KEPT_EVENTS
```
**To resolve:**
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)
2. Edit `languageservice/script/webhooks/index.ts`:
- Add to `KEPT_EVENTS` if it's a valid workflow trigger
- Add to `DROPPED_EVENTS` if it's GitHub App or API-only
3. Run `npm run update-webhooks` and commit the changes
#### Viewing Full Unprocessed Data
To see all available fields and events before optimization:
```bash
npm run update-webhooks -- --all
```
This generates `webhooks.all.json` and `objects.all.json` (gitignored) containing the complete unprocessed data from the GitHub API.
### Other Files
The other JSON files (`schedule.json`, `workflow_call.json`, `descriptions.json`, `workflow-v1.0.json`) are manually maintained.
## Minification
At build time, all JSON files are minified (whitespace removed) to produce `.min.json` versions:
```bash
npm run minify-json
```
This runs automatically via `prebuild` and `pretest` hooks, so you don't need to run it manually.
The code imports the minified versions:
```ts
import webhooks from "./events/webhooks.min.json"
```
## CI Verification
CI verifies that generated source files are up-to-date:
1. Runs `npm run update-webhooks` to regenerate webhooks.json and objects.json
2. Checks for uncommitted changes with `git diff --exit-code`
The `.min.json` files are generated at build time and are not committed to the repository.
If the build fails, run `cd languageservice && npm run update-webhooks` locally and commit the changes.
## Dropped Events
Webhook events that aren't valid workflow `on:` triggers are dropped (e.g., `installation`, `ping`, `member`, etc.). These are GitHub App or API-only events.
See `DROPPED_EVENTS` in `script/webhooks/index.ts` for the full list.
## Stripped Fields
Unused fields are stripped to reduce bundle size. For example:
```json
// Before (from webhooks.all.json)
{
"type": "object",
"name": "issue",
"in": "body",
"description": "The issue itself.",
"isRequired": true,
"childParamsGroups": [...]
}
// After (webhooks.json)
{
"name": "issue",
"description": "The issue itself.",
"childParamsGroups": [...]
}
```
Only `name`, `description`, and `childParamsGroups` are kept — these are used for autocompletion and hover docs.
To compare all fields vs stripped, run `npm run update-webhooks -- --all` and diff the `.all.json` files against the regular ones.
See `EVENT_ACTION_FIELDS` and `BODY_PARAM_FIELDS` in `script/webhooks/index.ts` to modify what gets stripped.
## Schema Synchronization
The `workflow-v1.0.json` schema defines which activity types are valid for each workflow trigger event. A test in `workflow-parser/src/schema-sync.test.ts` verifies these stay in sync with `webhooks.json`.
### When the Test Fails
If the schema-sync test fails, you'll see an error like:
```
Event "pull_request" is missing activity type "new_activity" in workflow-v1.0.json
```
**To resolve:**
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows) to verify the activity type is a valid workflow trigger:
- Find the event section (e.g., "pull_request")
- Look at the "Activity types" table — it lists which types can be used in `on.<event>.types`
- If the type is listed there, it's a valid workflow trigger
- If the type only appears in webhook docs but NOT in the workflow trigger docs, it's webhook-only
2. If it IS a valid workflow trigger:
- Edit `workflow-parser/src/workflow-v1.0.json`
- Find the `<event>-activity-type` definition (e.g., `pull-request-activity-type`)
- Add the new activity type to `allowed-values`
- Update the `description` in `<event>-activity` to list all types
- Run `npm test` to regenerate the minified JSON
3. If it is NOT a valid workflow trigger (webhook-only):
- Edit `workflow-parser/src/schema-sync.test.ts`
- Add the type to `WEBHOOK_ONLY` for that event
### Known Discrepancies
The test tracks several types of known discrepancies:
| Category | Purpose | Example |
|----------|---------|---------|
| `WEBHOOK_ONLY` | Types in webhooks that aren't valid workflow triggers | `check_suite.requested` |
| `SCHEMA_ONLY` | Types valid for workflows but missing from webhooks | `registry_package.updated` |
| `NAME_MAPPINGS` | Different names for the same concept | `project_column`: webhook uses `edited`, schema uses `updated` |
### Bidirectional Checking
The test checks both directions:
- **webhooks → schema**: Ensures all webhook activity types are in the schema (or listed in `WEBHOOK_ONLY`)
- **schema → webhooks**: Ensures the schema doesn't have types that don't exist in webhooks (or listed in `SCHEMA_ONLY` or `NAME_MAPPINGS`)
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.29",
"version": "0.3.20",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -44,7 +44,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"engines": {
"node": ">= 18"
"node": ">= 16.15"
},
"files": [
"dist/**/*"
@@ -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(
{
+8 -8
View File
@@ -1,11 +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 {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;
@@ -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 -1
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;
+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> {
/**
+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 -10
View File
@@ -1,13 +1,13 @@
import {ErrorType, ExpressionError} from "./errors.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;
+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 -9
View File
@@ -1,9 +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 {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;
+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"
}
}
-173
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`
@@ -100,150 +92,6 @@ const clientOptions: LanguageClientOptions = {
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
```
### 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.
@@ -262,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
+7 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.29",
"version": "0.3.20",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -31,7 +31,7 @@
"url": "https://github.com/actions/languageservices"
},
"scripts": {
"build": "tsc --build tsconfig.build.json && npm run build:cli",
"build": "tsc --build tsconfig.build.json",
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
@@ -41,15 +41,14 @@
"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"
"watch": "tsc --build tsconfig.build.json --watch"
},
"bin": {
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.29",
"@actions/workflow-parser": "^0.3.29",
"@actions/languageservice": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -57,11 +56,10 @@
"yaml": "^2.1.3"
},
"engines": {
"node": ">= 18"
"node": ">= 16.15"
},
"files": [
"dist/**/*",
"bin/**/*"
"dist/**/*"
],
"devDependencies": {
"@types/jest": "^29.0.3",
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node18 \
--format=cjs \
--outfile=dist/server-bundled.cjs \
--external:vscode \
--loader:.json=json
+14 -7
View File
@@ -1,8 +1,11 @@
import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice";
import {documentLinks, getCodeActions, 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,
@@ -12,8 +15,6 @@ import {
HoverParams,
InitializeParams,
InitializeResult,
InlayHint,
InlayHintParams,
TextDocumentIdentifier,
TextDocumentPositionParams,
TextDocuments,
@@ -75,7 +76,9 @@ export function initConnection(connection: Connection) {
documentLinkProvider: {
resolveProvider: false
},
inlayHintProvider: true
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix]
}
}
};
@@ -161,9 +164,13 @@ 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(async (params: CodeActionParams): Promise<CodeAction[]> => {
return timeOperation("codeAction", async () => {
return getCodeActions({
uri: params.textDocument.uri,
diagnostics: params.context.diagnostics,
only: params.context.only
});
});
});
@@ -1,76 +0,0 @@
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";
import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache";
describe("contextProviders", () => {
const mockCache = new TTLCache();
const mockRepo: RepositoryContext = {
id: 123,
owner: "test-owner",
name: "test-repo",
organizationOwned: true,
workspaceUri: "file:///workspace"
};
const mockWorkflowContext: WorkflowContext = {
uri: "test.yaml",
template: undefined
};
describe("when client is undefined", () => {
it("should return incomplete context for secrets", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
it("should return incomplete context for vars", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
it("should preserve defaultContext and mark as incomplete for secrets", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const defaultContext = new DescriptionDictionary();
defaultContext.add("EXISTING_SECRET", new data.StringData("test"));
const result = await config.getContext("secrets", defaultContext, mockWorkflowContext, Mode.Validation);
expect(result).toBe(defaultContext);
expect((result as DescriptionDictionary).complete).toBe(false);
expect((result as DescriptionDictionary).get("EXISTING_SECRET")).toBeDefined();
});
it("should return undefined for other contexts like steps", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const result = await config.getContext("steps", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeUndefined();
});
});
describe("when both client and repo are undefined", () => {
it("should return incomplete context for secrets", async () => {
const config = contextProviders(undefined, undefined, mockCache);
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
it("should return incomplete context for vars", async () => {
const config = contextProviders(undefined, undefined, mockCache);
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
});
});
+1 -12
View File
@@ -15,18 +15,7 @@ export function contextProviders(
cache: TTLCache
): ContextProviderConfig {
if (!repo || !client) {
// When GitHub client/repo is unavailable, return an incomplete dictionary
// to avoid false "Context access might be invalid" warnings
return {
getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => {
if (name === "secrets" || name === "vars") {
const context = defaultContext || new DescriptionDictionary();
context.complete = false;
return Promise.resolve(context);
}
return Promise.resolve(undefined);
}
};
return {getContext: () => Promise.resolve(undefined)};
}
const getContext = async (
@@ -49,7 +49,7 @@ export async function getSecrets(
if (isString(x.value)) {
environmentName = x.value.value;
} else {
// this means we have a dynamic environment, in those situations we
// this means we have a dynamic enviornment, in those situations we
// want to make sure we skip doing secret validation
secretsContext.complete = false;
}
@@ -1,4 +1,4 @@
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {data, DescriptionDictionary} from "@actions/expressions";
import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps";
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
@@ -63,43 +63,6 @@ it("returns default context when job is undefined", async () => {
expect(stepsContext).toEqual(defaultContext);
});
it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
const mock = fetchMock
.sandbox()
.getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata);
const workflowContext = await createWorkflowContext(workflow, "build");
const defaultContext = getDefaultStepsContext(workflowContext);
const stepsContext = await getStepsContext(
new Octokit({
request: {
fetch: mock
}
}),
new TTLCache(),
defaultContext,
workflowContext
);
// Get the step context
const stepContext = stepsContext?.get("cache-primes");
expect(stepContext).toBeDefined();
expect(isDescriptionDictionary(stepContext!)).toBe(true);
// Get the outputs - should be a dictionary, not null
const outputs = (stepContext as DescriptionDictionary).get("outputs");
expect(outputs).toBeDefined();
expect(isDescriptionDictionary(outputs!)).toBe(true);
// Outputs should be marked incomplete to allow dynamic outputs
const outputsDict = outputs as DescriptionDictionary;
expect(outputsDict.complete).toBe(false);
// Known outputs from action.yml should be present
expect(outputsDict.get("cache-hit")).toBeDefined();
});
it("adds action outputs", async () => {
const mock = fetchMock
.sandbox()
@@ -120,22 +83,17 @@ it("adds action outputs", async () => {
);
expect(stepsContext).toBeDefined();
// Create expected outputs dict with complete = false
// (actions can have dynamic outputs beyond what's declared in action.yml)
const expectedOutputs = new DescriptionDictionary({
key: "cache-hit",
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
description: "A boolean value to indicate an exact match was found for the primary key"
});
expectedOutputs.complete = false;
expect(stepsContext).toEqual(
new DescriptionDictionary({
key: "cache-primes",
value: new DescriptionDictionary(
{
key: "outputs",
value: expectedOutputs
value: new DescriptionDictionary({
key: "cache-hit",
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
description: "A boolean value to indicate an exact match was found for the primary key"
})
},
{
key: "conclusion",
@@ -58,8 +58,6 @@ export async function getStepsContext(
continue;
}
const outputsDict = new DescriptionDictionary();
// Actions can have dynamic outputs beyond what's declared in action.yml
outputsDict.complete = false;
for (const [key, value] of Object.entries(outputs)) {
outputsDict.add(key, new data.StringData(value.description), value.description);
}
@@ -26,8 +26,6 @@ export async function getVariables(
return secretsContext;
}
const variablesContext = defaultContext || new DescriptionDictionary();
let environmentName: string | undefined;
if (workflowContext?.job?.environment) {
if (isString(workflowContext.job.environment)) {
@@ -37,19 +35,14 @@ export async function getVariables(
if (isString(x.key) && x.key.value === "name") {
if (isString(x.value)) {
environmentName = x.value.value;
} else {
// this means we have a dynamic environment, in those situations we want to skip validation
variablesContext.complete = false;
}
break;
}
}
} else {
// if the expression is something like environment: ${{ ... }} then we want to skip validation
variablesContext.complete = false;
}
}
const variablesContext = defaultContext || new DescriptionDictionary();
try {
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
+3 -3
View File
@@ -1,12 +1,12 @@
import {Connection} from "vscode-languageserver";
import { Connection } from "vscode-languageserver";
import {
BrowserMessageReader,
BrowserMessageWriter,
createConnection as createBrowserConnection
} from "vscode-languageserver/browser";
import {createConnection as createNodeConnection} from "vscode-languageserver/node";
import { createConnection as createNodeConnection } from "vscode-languageserver/node";
import {initConnection} from "./connection";
import { initConnection } from "./connection";
/** Helper function determining whether we are executing with node runtime */
function isNode(): boolean {
+5 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.29",
"version": "0.3.20",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -37,25 +37,22 @@
"format-check": "prettier --check '**/*.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",
"prepublishOnly": "npm run build && npm run test",
"pretest": "npm run minify-json",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"update-webhooks": "npx tsx script/webhooks/index.ts",
"update-webhooks": "ts-node-esm script/webhooks/index.ts",
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.29",
"@actions/workflow-parser": "^0.3.29",
"@actions/expressions": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
"yaml": "^2.1.1"
},
"engines": {
"node": ">= 18"
"node": ">= 16.15"
},
"files": [
"dist/**/*"
+2 -273
View File
@@ -7,185 +7,6 @@ const schema = schemaImport as any;
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
const OBJECTS_PATH = "./src/context-providers/events/objects.json";
const ALL_OUTPUT_PATH = "./src/context-providers/events/webhooks.all.json";
const ALL_OBJECTS_PATH = "./src/context-providers/events/objects.all.json";
const DROP_OUTPUT_PATH = "./src/context-providers/events/webhooks.drop.json";
const DROP_OBJECTS_PATH = "./src/context-providers/events/objects.drop.json";
const STRIP_OUTPUT_PATH = "./src/context-providers/events/webhooks.strip.json";
const STRIP_OBJECTS_PATH = "./src/context-providers/events/objects.strip.json";
// Parse --all flag
const generateAll = process.argv.includes("--all");
// Events to drop - not valid workflow triggers (GitHub App or API-only events)
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
const DROPPED_EVENTS = new Set([
"branch_protection_configuration",
"code_scanning_alert",
"commit_comment",
"custom_property",
"custom_property_values",
"dependabot_alert",
"deploy_key",
"github_app_authorization",
"installation",
"installation_repositories",
"installation_target",
"marketplace_purchase",
"member",
"membership",
"merge_group",
"meta",
"org_block",
"organization",
"package",
"personal_access_token_request",
"ping",
"repository",
"repository_advisory",
"repository_ruleset",
"secret_scanning_alert",
"secret_scanning_alert_location",
"security_advisory",
"security_and_analysis",
"sponsorship",
"star",
"team",
"team_add"
]);
// Events to keep - valid workflow triggers
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
const KEPT_EVENTS = new Set([
"branch_protection_rule",
"check_run",
"check_suite",
"create",
"delete",
"deployment",
"deployment_status",
"discussion",
"discussion_comment",
"fork",
"gollum",
"issue_comment",
"issues",
"label",
"milestone",
"page_build",
"project",
"project_card",
"project_column",
"projects_v2",
"projects_v2_item",
"public",
"pull_request",
"pull_request_review",
"pull_request_review_comment",
"pull_request_review_thread",
"push",
"registry_package",
"release",
"repository_dispatch",
"repository_import",
"repository_vulnerability_alert",
"status",
"watch",
"workflow_dispatch",
"workflow_job",
"workflow_run"
]);
/**
* Fields to strip from the JSON data.
*
* EVENT_ACTION_FIELDS: stripped from each event action object (top level only)
* Example event action object before stripping:
* {
* "description": "This event is triggered when...", // <-- stripped
* "summary": "A brief summary", // <-- stripped
* "availability": ["repository"], // <-- stripped
* "category": "issues", // <-- stripped
* "action": "opened", // kept
* "bodyParameters": [...] // kept
* }
*
* BODY_PARAM_FIELDS: stripped from every bodyParameters object, recursively through childParamsGroups
* Example bodyParameter object before stripping:
* {
* "type": "object", // <-- stripped
* "name": "changes", // kept (used for property names)
* "in": "body", // <-- stripped
* "description": "The changes that were made.", // kept (used for hover docs)
* "isRequired": true, // <-- stripped
* "enum": ["a", "b"], // <-- stripped
* "default": "a", // <-- stripped
* "childParamsGroups": [ // kept (used for nested properties)
* {
* "type": "string", // <-- stripped (recursive)
* "name": "from", // kept
* "isRequired": true // <-- stripped (recursive)
* }
* ]
* }
*/
const EVENT_ACTION_FIELDS = ["description", "summary", "availability", "category"];
const BODY_PARAM_FIELDS = ["type", "in", "isRequired", "enum", "default"];
/**
* Strip fields from a bodyParameter object and recursively from childParamsGroups.
*/
function stripBodyParam(param: any): any {
if (typeof param !== "object" || param === null) {
return param;
}
const result: any = {};
for (const [key, value] of Object.entries(param)) {
if (BODY_PARAM_FIELDS.includes(key)) {
continue; // Strip this field
}
if (key === "childParamsGroups" && Array.isArray(value)) {
result[key] = value.map(stripBodyParam);
} else {
result[key] = value;
}
}
return result;
}
/**
* Strip unused fields from event action data.
*/
function stripEventActionFields(action: any): any {
const result: any = {};
for (const [key, value] of Object.entries(action)) {
if (EVENT_ACTION_FIELDS.includes(key)) {
continue; // Strip this field
}
if (key === "bodyParameters" && Array.isArray(value)) {
result[key] = value.map((p: any) => (typeof p === "number" ? p : stripBodyParam(p)));
} else {
result[key] = value;
}
}
return result;
}
/**
* Strip unused fields from all webhooks.
* Structure: { eventName: { actionName: { ...fields } } }
*/
function stripFields(webhooks: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
const result: Record<string, Record<string, any>> = {};
for (const [eventName, actions] of Object.entries(webhooks)) {
result[eventName] = {};
for (const [actionName, actionData] of Object.entries(actions)) {
result[eventName][actionName] = stripEventActionFields(actionData);
}
}
return result;
}
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
if (!rawWebhooks) {
@@ -199,51 +20,11 @@ for (const webhook of Object.values(rawWebhooks)) {
await Promise.all(webhooks.map(webhook => webhook.process()));
// Check for unknown events (not in DROPPED_EVENTS or KEPT_EVENTS)
const unknownEvents: string[] = [];
for (const webhook of webhooks) {
if (!DROPPED_EVENTS.has(webhook.category) && !KEPT_EVENTS.has(webhook.category)) {
if (!unknownEvents.includes(webhook.category)) {
unknownEvents.push(webhook.category);
}
}
}
if (unknownEvents.length > 0) {
console.error("");
console.error("══════════════════════════════════════════════════════════════════");
console.error("ERROR: New webhook event(s) detected!");
console.error("══════════════════════════════════════════════════════════════════");
console.error("");
console.error("The following events are not categorized:");
for (const event of unknownEvents.sort()) {
console.error(` - ${event}`);
}
console.error("");
console.error("Action required:");
console.error(" 1. Check if the event is a valid workflow trigger:");
console.error(
" https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows"
);
console.error("");
console.error(" 2. Add the event to DROPPED_EVENTS or KEPT_EVENTS in:");
console.error(" languageservice/script/webhooks/index.ts");
console.error("");
console.error(" 3. See docs/json-data-files.md for more details.");
console.error("");
process.exit(1);
}
// The category is the name of the webhook
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
for (const webhook of webhooks) {
if (!webhook.action) webhook.action = "default";
// Drop unused events
if (DROPPED_EVENTS.has(webhook.category)) {
continue;
}
if (categorizedWebhooks[webhook.category]) {
categorizedWebhooks[webhook.category][webhook.action] = webhook;
} else {
@@ -252,59 +33,7 @@ for (const webhook of webhooks) {
}
}
// Strip fields before deduplication
const strippedWebhooks = stripFields(categorizedWebhooks);
const objectsArray = deduplicateWebhooks(categorizedWebhooks);
// Deduplicate after dropping and stripping
const objectsArray = deduplicateWebhooks(strippedWebhooks);
// Write optimized output
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
await fs.writeFile(OUTPUT_PATH, JSON.stringify(strippedWebhooks, null, 2));
console.log(`Wrote ${OUTPUT_PATH} (${Object.keys(strippedWebhooks).length} events)`);
console.log(`Wrote ${OBJECTS_PATH} (${objectsArray.length} objects)`);
// Optionally generate intermediate versions for size comparison
if (generateAll) {
// Helper to deep clone
function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
// Build full webhooks (no drop, no strip) from fresh data
const fullWebhooks: Record<string, Record<string, any>> = {};
for (const webhook of webhooks) {
const w = clone(webhook);
if (!w.action) w.action = "default";
fullWebhooks[w.category] ||= {};
fullWebhooks[w.category][w.action] = w;
}
// Generate all version (no drop, no strip)
const allWebhooks = clone(fullWebhooks);
const allObjects = deduplicateWebhooks(allWebhooks);
await fs.writeFile(ALL_OUTPUT_PATH, JSON.stringify(allWebhooks, null, 2));
await fs.writeFile(ALL_OBJECTS_PATH, JSON.stringify(allObjects, null, 2));
console.log(`Wrote ${ALL_OUTPUT_PATH} (${Object.keys(allWebhooks).length} events)`);
console.log(`Wrote ${ALL_OBJECTS_PATH} (${allObjects.length} objects)`);
// Generate drop-only version (drop events, no strip)
const dropWebhooks = clone(fullWebhooks);
for (const event of DROPPED_EVENTS) {
delete dropWebhooks[event];
}
const dropObjects = deduplicateWebhooks(dropWebhooks);
await fs.writeFile(DROP_OUTPUT_PATH, JSON.stringify(dropWebhooks, null, 2));
await fs.writeFile(DROP_OBJECTS_PATH, JSON.stringify(dropObjects, null, 2));
console.log(`Wrote ${DROP_OUTPUT_PATH} (${Object.keys(dropWebhooks).length} events)`);
console.log(`Wrote ${DROP_OBJECTS_PATH} (${dropObjects.length} objects)`);
// Generate strip-only version (strip fields, no drop)
const stripWebhooks = stripFields(clone(fullWebhooks));
const stripObjects = deduplicateWebhooks(stripWebhooks);
await fs.writeFile(STRIP_OUTPUT_PATH, JSON.stringify(stripWebhooks, null, 2));
await fs.writeFile(STRIP_OBJECTS_PATH, JSON.stringify(stripObjects, null, 2));
console.log(`Wrote ${STRIP_OUTPUT_PATH} (${Object.keys(stripWebhooks).length} events)`);
console.log(`Wrote ${STRIP_OBJECTS_PATH} (${stripObjects.length} objects)`);
}
await fs.writeFile(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, null, 2));
+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", () => {
+54
View File
@@ -0,0 +1,54 @@
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
import {CodeActionContext, CodeActionProvider} from "./types";
import {quickfixProviders} from "./quickfix";
// Aggregate all providers by kind
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
[CodeActionKind.QuickFix, quickfixProviders]
// [CodeActionKind. Refactor, refactorProviders],
// [CodeActionKind.Source, sourceProviders],
// etc
]);
export interface CodeActionConfig {
// TODO: actionsMetadataProvider, fileProvider, etc.
}
export interface CodeActionParams {
uri: string;
diagnostics: Diagnostic[];
only?: string[];
}
export function getCodeActions(params: CodeActionParams, config?: CodeActionConfig): CodeAction[] {
const actions: CodeAction[] = [];
const context: CodeActionContext = {
uri: params.uri
};
// 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";
@@ -0,0 +1,67 @@
import { CodeAction, TextEdit } from "vscode-languageserver-types";
import { CodeActionContext, CodeActionProvider } from "../types";
import { DiagnosticCode, MissingInputsDiagnosticData } from "../../validate-action";
export const addMissingInputsProvider: CodeActionProvider = {
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
createCodeAction(context, diagnostic): CodeAction | undefined {
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
if (!data) {
return undefined;
}
const edits = createInputEdits(data);
if (!edits) {
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,
},
},
};
},
};
function createInputEdits(data: MissingInputsDiagnosticData): TextEdit[] | undefined {
const edits: TextEdit[] = [];
if (data.hasWithKey && data.withIndent !== undefined) {
// `with:` exists - use its indentation + 2 for inputs
const inputIndent = " ".repeat(data.withIndent + 2);
const inputLines = data.missingInputs.map(input => {
const value = input.default !== undefined ? input.default : '""';
return `${inputIndent}${input.name}: ${value}`;
});
edits.push({
range: { start: data.insertPosition, end: data.insertPosition },
newText: inputLines.map(line => line + "\n").join(""),
});
} else {
// No `with:` key - `with:` at step indentation, inputs at step indentation + 2
const withIndent = " ".repeat(data.stepIndent);
const inputIndent = " ".repeat(data.stepIndent + 2);
const inputLines = data.missingInputs.map(input => {
const value = input.default !== undefined ? input.default : '""';
return `${inputIndent}${input.name}: ${value}`;
});
const newText = [`${withIndent}with:\n`, ...inputLines.map(line => `${line}\n`)].join("");
edits.push({
range: { start: data.insertPosition, end: data.insertPosition },
newText,
});
}
return edits;
}
@@ -0,0 +1,4 @@
import {CodeActionProvider} from "../types";
import {addMissingInputsProvider} from "./add-missing-inputs";
export const quickfixProviders: CodeActionProvider[] = [addMissingInputsProvider];
@@ -0,0 +1,90 @@
import * as path from "path";
import {fileURLToPath} from "url";
import {loadTestCases, runTestCase} from "./runner";
import {ValidationConfig} from "../../validate";
import {ActionMetadata, ActionReference} from "../../action";
import {clearCache} from "../../utils/workflow-cache";
// 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);
}
});
}
});
@@ -0,0 +1,227 @@
import * as fs from "fs";
import * as path from "path";
import { TextEdit } from "vscode-languageserver-types";
import { TextDocument } from "vscode-languageserver-textdocument";
import { validate, ValidationConfig } from "../../validate";
import { getCodeActions, CodeActionParams } from "../index";
// 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));
console.log(found);
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,
diagnostics: [diagnostic]
};
const actions = getCodeActions(params);
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
};
}
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,7 @@
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"
@@ -0,0 +1,10 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: ${{ runner.os }}-
path: ""
key: ""
@@ -0,0 +1,8 @@
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 }}-
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,6 @@
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"
+21
View File
@@ -0,0 +1,21 @@
import {CodeAction, Diagnostic} from "vscode-languageserver-types";
export interface CodeActionContext {
uri: string;
// TODO: add things like workflow template, parsed content, etc.
}
/**
* 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;
}
@@ -1,13 +1,13 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {data, DescriptionDictionary} from "@actions/expressions";
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
import {complete, getExpressionInput} from "./complete.js";
import {ContextProviderConfig} from "./context-providers/config.js";
import {registerLogger} from "./log.js";
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 {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) => {
@@ -299,16 +299,7 @@ jobs:
"on: push\njobs:\n build:\n runs-on: ubuntu-latest\n environment:\n url: ${{ runner.| }}\n steps:\n - run: echo";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"arch",
"debug",
"environment",
"name",
"os",
"temp",
"tool_cache",
"workspace"
]);
expect(result.map(x => x.label)).toEqual(["arch", "name", "os", "temp", "tool_cache"]);
});
describe("job if", () => {
@@ -870,7 +861,7 @@ jobs:
});
describe("strategy context", () => {
it("strategy is suggested even when no strategy defined", async () => {
it("strategy is not suggested when outside of a matrix job", async () => {
const input = `
on: push
@@ -884,7 +875,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toContain("strategy");
expect(result.map(x => x.label)).not.toContain("strategy");
});
it("strategy is suggested within a matrix job", async () => {
@@ -931,7 +922,7 @@ jobs:
});
describe("matrix context", () => {
it("matrix is suggested even when no strategy defined", async () => {
it("matrix is not suggested when outside of a matrix job", async () => {
const input = `
on: push
@@ -945,7 +936,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toContain("matrix");
expect(result.map(x => x.label)).not.toContain("strategy");
});
it("matrix is suggested within a matrix job", async () => {
@@ -1110,7 +1101,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 () => {
@@ -1132,12 +1123,10 @@ jobs:
"github",
"inputs",
"job",
"matrix",
"needs",
"runner",
"secrets",
"steps",
"strategy",
"vars",
"contains",
"endsWith",
@@ -1279,7 +1268,7 @@ jobs:
on: push
jobs:
a:
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
uses: ./reusable-workflow-with-outputs.yaml
b:
needs: [a]
runs-on: ubuntu-latest
@@ -1,169 +0,0 @@
import {complete} from "./complete.js";
import {TextDocument} from "vscode-languageserver-textdocument";
import {clearCache} from "./utils/workflow-cache.js";
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
beforeEach(() => {
clearCache();
});
describe("Issue #81 - multi-line if expression completion", () => {
it("should complete in block scalar if with | (exact position)", async () => {
// Exact reproduction from issue - cursor after "github." in block scalar
const input = `on: push
jobs:
build:
if: |
github.
runs-on: ubuntu-latest
steps:
- run: echo`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
// Line 5 (0-indexed) = " github.", character 13 = after the dot
const pos = {line: 5, character: 13};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
expect(result.map(x => x.label)).toContain("actor");
});
it("should complete in block scalar if with > (exact position)", async () => {
const input = `on: push
jobs:
build:
if: >
github.
runs-on: ubuntu-latest
steps:
- run: echo`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
const pos = {line: 5, character: 13};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete in block scalar with multiple lines", async () => {
const input = `on: push
jobs:
build:
if: |
github.event_name == 'push' &&
github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
// Skip 1 to skip the `|` block scalar indicator (same character as cursor marker)
const result = await complete(...getPositionFromCursor(input, 1), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete step if in block scalar", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo
if: |
github.
`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
// Line 7 = " github.", character 15 = after the dot (8 spaces + 7 chars)
const pos = {line: 7, character: 15};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete in block scalar with ${{ expression markers", async () => {
// This case works because transform() skips lines with ${{
// Note: Using explicit position because | appears in multiple places (block scalar, ||, cursor)
const input = `on: push
jobs:
build:
if: |
\${{
github.ref == 'refs/heads/main' ||
github.
runs-on: ubuntu-latest
steps:
- run: echo`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
// Line 6 = " github." = 8 spaces + 7 chars = 15 chars, cursor after dot is at char 15
const pos = {line: 6, character: 15};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("ref");
expect(result.map(x => x.label)).toContain("ref_name");
});
});
describe("Edge cases for getOffsetInContent", () => {
it("should complete in single-line if (not block scalar)", async () => {
const input = `on: push
jobs:
build:
if: github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
const result = await complete(...getPositionFromCursor(input), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete on third content line of block scalar", async () => {
const input = `on: push
jobs:
build:
if: |
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
const result = await complete(...getPositionFromCursor(input, 1), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete when block scalar has empty first line", async () => {
const input = `on: push
jobs:
build:
if: |
github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
const result = await complete(...getPositionFromCursor(input, 1), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
});
@@ -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 => {
@@ -21,7 +21,7 @@ on: push
jobs:
build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
uses: ./reusable-workflow-with-inputs.yaml
with:
|
`;
@@ -49,7 +49,7 @@ on: push
jobs:
build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
uses: ./reusable-workflow-with-inputs.yaml
with:
username: monalisa
|
@@ -74,7 +74,7 @@ on: push
jobs:
build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
uses: ./reusable-workflow-with-inputs.yaml
secrets:
|
`;
@@ -102,7 +102,7 @@ on: push
jobs:
build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
uses: ./reusable-workflow-with-inputs.yaml
secrets: |
`;
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
@@ -117,7 +117,7 @@ on: push
jobs:
build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
uses: ./reusable-workflow-with-inputs.yaml
secrets:
envPAT: "myPAT"
|
+37 -345
View File
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {MarkupContent, TextEdit} from "vscode-languageserver-types";
import {complete} from "./complete.js";
import {registerLogger} from "./log.js";
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
import {TestLogger} from "./test-utils/logger.js";
import {clearCache} from "./utils/workflow-cache.js";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
import {complete} from "./complete";
import {registerLogger} from "./log";
import {getPositionFromCursor} from "./test-utils/cursor-position";
import {TestLogger} from "./test-utils/logger";
import {clearCache} from "./utils/workflow-cache";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
registerLogger(new TestLogger());
@@ -19,12 +19,9 @@ describe("completion", () => {
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
expect(result.length).toEqual(14);
expect(result.length).toEqual(12);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
expect(labels).toContain("(switch to list)");
expect(labels).toContain("(switch to mapping)");
});
it("needs", async () => {
@@ -47,7 +44,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(13);
expect(result.length).toEqual(9);
expect(result[0].label).toEqual("concurrency");
});
@@ -73,7 +70,7 @@ jobs:
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(30);
expect(result.length).toEqual(21);
});
it("string definition completion in sequence", async () => {
@@ -98,7 +95,6 @@ jobs:
release:
types: |`;
const result = await complete(...getPositionFromCursor(input));
// Expect string values plus escape hatch to switch to list form
expect(result.map(x => x.label)).toEqual([
"created",
"deleted",
@@ -106,8 +102,7 @@ jobs:
"prereleased",
"published",
"released",
"unpublished",
"(switch to list)"
"unpublished"
]);
});
@@ -195,11 +190,8 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
expect(result).not.toBeUndefined();
// Custom value plus escape hatches for list and full syntax
expect(result.length).toEqual(3);
expect(result.length).toEqual(1);
expect(result[0].label).toEqual("my-custom-label");
expect(result.map(x => x.label)).toContain("(switch to list)");
expect(result.map(x => x.label)).toContain("(switch to mapping)");
});
it("custom value providers for sequences", async () => {
@@ -220,9 +212,7 @@ jobs:
expect(result[0].label).toEqual("my-custom-label");
});
it("does not show mapping keys or parent sibling keys in Key mode", async () => {
// At `container: |`, the scalar form is a string with no constants.
// Mapping keys should NOT be shown inline - but escape hatch to full syntax IS shown.
it("does not show parent mapping sibling keys", async () => {
const input = `on: push
jobs:
build:
@@ -230,21 +220,20 @@ jobs:
runs-on: ubuntu-latest`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// Only escape hatch to full syntax (container has mapping form but no sequence)
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
expect(result.length).toEqual(6);
// Should not contain other top-level job keys like `if` and `runs-on`
expect(result.map(x => x.label)).not.toContain("if");
expect(result.map(x => x.label)).not.toContain("runs-on");
});
it("does not show mapping keys in Key mode when structure is uncommitted", async () => {
// At `concurrency: |`, user is in Key mode but hasn't committed to a structure.
// The scalar form is a string with no constants, so no scalar completions.
// But escape hatch to full syntax IS shown as a way out.
it("shows mapping keys within a new map ", async () => {
const input = `on: push
jobs:
build:
concurrency: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
expect(result.map(x => x.label).sort()).toEqual(["cancel-in-progress", "group"]);
});
it("job key", async () => {
@@ -254,7 +243,7 @@ jobs:
runs-|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(30);
expect(result).toHaveLength(21);
});
it("job key with comment afterwards", async () => {
@@ -265,7 +254,7 @@ jobs:
#`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(30);
expect(result).toHaveLength(21);
});
it("job key with other values afterwards", async () => {
@@ -277,10 +266,7 @@ jobs:
concurrency: 'group-name'`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// Verify we get job-level completions, but concurrency is already present so excluded
expect(result.length).toBeGreaterThan(20);
expect(result.some(x => x.label === "runs-on")).toBe(true);
expect(result.some(x => x.label === "concurrency")).toBe(false);
expect(result).toHaveLength(20);
});
it("step key without space after colon", async () => {
@@ -349,9 +335,7 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
// Verify we get job-level completions including runs-on variants
expect(result.length).toBeGreaterThan(20);
expect(result.some(x => x.label === "steps")).toBe(true);
expect(result).toHaveLength(17);
});
it("complete from behind a colon will replace it", async () => {
@@ -364,8 +348,7 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
// Verify we get job-level completions
expect(result.length).toBeGreaterThan(20);
expect(result).toHaveLength(17);
const textEdit = result[0].textEdit as TextEdit;
expect(textEdit.range).toEqual({
start: {line: 5, character: 4},
@@ -464,9 +447,8 @@ jobs:
"timeout-minutes: "
]);
// One-of (scalar variant)
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.detail === undefined);
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
// One-of
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
});
it("custom indentation", async () => {
@@ -488,21 +470,20 @@ jobs:
"timeout-minutes: "
]);
// One-of (scalar variant)
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.detail === undefined);
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
// One-of
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
});
});
it("does not show mapping keys in Key mode for one-of with mapping variant", async () => {
// At `concurrency: |`, mapping keys should NOT be shown.
// Users who want the mapping form should use `concurrency (full syntax)` at parent level.
it("adds a new line and indentation for mapping keys when the key is given", async () => {
const input = "concurrency: |";
const result = await complete(...getPositionFromCursor(input));
expect(result.filter(x => x.label === "cancel-in-progress")).toEqual([]);
expect(result.filter(x => x.label === "group")).toEqual([]);
expect(result.filter(x => x.label === "cancel-in-progress").map(x => x.textEdit?.newText)).toEqual([
"\n cancel-in-progress: "
]);
expect(result.filter(x => x.label === "group").map(x => x.textEdit?.newText)).toEqual(["\n group: "]);
});
it("does not add new line if no key in line", async () => {
@@ -513,15 +494,12 @@ jobs:
expect(result.filter(x => x.label === "run-name").map(x => x.textEdit?.newText)).toEqual(["run-name: "]);
});
it("does not show mapping keys when user has started typing a scalar value", async () => {
// User typed `workflow_dispatch: in` - they've committed to a scalar value
// Should not show mapping keys like `inputs`
it("adds new line for nested mapping", async () => {
const input = "on:\n workflow_dispatch: in|";
const result = await complete(...getPositionFromCursor(input));
// No mapping keys should be shown since user started typing a scalar
expect(result.filter(x => x.label === "inputs")).toEqual([]);
expect(result.filter(x => x.label === "inputs").map(x => x.textEdit?.newText)).toEqual(["\n inputs:\n "]);
});
it("adds : for one-of", async () => {
@@ -529,300 +507,14 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
// Scalar variant inserts "types: "
const scalarVariant = result.find(x => x.label === "types" && x.detail === undefined);
expect(scalarVariant?.textEdit?.newText).toEqual("types: ");
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]);
});
it("does not show mapping keys for one-of when user has typed a scalar value", async () => {
// User typed `check_run: ty` - they've committed to scalar form
// The only valid value for check_run scalar is null, so no completions
it("does not add : for one-of in key mode", async () => {
const input = "on:\n check_run: ty|";
const result = await complete(...getPositionFromCursor(input));
// check_run's scalar form only accepts null, so typing anything should show no completions
// (we don't show mapping keys like `types` anymore - user should use check_run with detail "full syntax" instead)
expect(result.filter(x => x.label === "types")).toEqual([]);
});
it("shows only scalar options for one-of in Key mode when user hasn't committed to a type", async () => {
// At `permissions: |` user hasn't typed anything yet - show only scalar options
// Mapping keys are NOT shown because they would require a newline
// Users who want the mapping form can use `permissions (full syntax)` at the parent level
const input = "on: push\npermissions: |";
const result = await complete(...getPositionFromCursor(input));
// String values (read-all, write-all) should be available
expect(result.filter(x => x.label === "read-all").map(x => x.textEdit?.newText)).toEqual(["read-all"]);
expect(result.filter(x => x.label === "write-all").map(x => x.textEdit?.newText)).toEqual(["write-all"]);
// Mapping keys should NOT be shown - they require a newline which is confusing inline
expect(result.filter(x => x.label === "actions")).toEqual([]);
expect(result.filter(x => x.label === "contents")).toEqual([]);
});
it("filters to scalar options when user has started typing a scalar", async () => {
// User typed `permissions: r` - they've committed to scalar form
const input = "on: push\npermissions: r|";
const result = await complete(...getPositionFromCursor(input));
// Only scalar values should be shown (filtering on 'r')
expect(result.some(x => x.label === "read-all")).toBe(true);
// Mapping keys should NOT be shown
expect(result.filter(x => x.label === "actions")).toEqual([]);
expect(result.filter(x => x.label === "contents")).toEqual([]);
});
it("shows both simple and full syntax for null+mapping one-of", async () => {
// check_run is a one-of: [null, mapping]. Show both:
// - check_run (simple, just the key with colon)
// - check_run with detail "full syntax" (ready to add mapping keys)
const input = "on:\n |";
const result = await complete(...getPositionFromCursor(input));
// Should have both check_run (scalar) and check_run with detail "full syntax"
const checkRunVariants = result.filter(x => x.label === "check_run");
expect(checkRunVariants.some(x => x.detail === undefined)).toBe(true);
expect(checkRunVariants.some(x => x.detail === "full syntax")).toBe(true);
});
it("shows all three variants for scalar+sequence+mapping one-of", async () => {
// runs-on is a one-of: [string, sequence, mapping]
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
// Should have runs-on (scalar), runs-on with detail "list", and runs-on with detail "full syntax"
const runsOnVariants = result.filter(x => x.label === "runs-on");
expect(runsOnVariants.length).toBe(3);
expect(runsOnVariants.some(x => x.detail === undefined)).toBe(true);
expect(runsOnVariants.some(x => x.detail === "list")).toBe(true);
expect(runsOnVariants.some(x => x.detail === "full syntax")).toBe(true);
});
it("generates correct insertText for one-of variants in parent mode", async () => {
// runs-on is a one-of: [string, sequence, mapping]
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
const runsOnVariants = result.filter(x => x.label === "runs-on");
// Scalar: just key with colon and space
expect(runsOnVariants.find(x => x.detail === undefined)?.textEdit?.newText).toEqual("runs-on: ");
// Sequence: key with colon, newline, and list item
expect(runsOnVariants.find(x => x.detail === "list")?.textEdit?.newText).toEqual("runs-on:\n - ");
// Mapping: key with colon, newline, and indentation for nested keys
expect(runsOnVariants.find(x => x.detail === "full syntax")?.textEdit?.newText).toEqual("runs-on:\n ");
});
it("generates correct insertText for one-of variants in parent mode", async () => {
// concurrency is a one-of: [string, mapping] - testing parent mode (inside mapping)
// At `concurrency:\n |`, user HAS committed to mapping structure, so mapping keys are shown
const input = "concurrency:\n |";
const result = await complete(...getPositionFromCursor(input));
// In parent mode: just key + colon + space (no leading newline)
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("group: ");
// Boolean in parent mode (cancel-in-progress): key + colon + space
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("cancel-in-progress: ");
});
it("uses sortText for ordering qualified one-of variants", async () => {
// runs-on has multiple structural types, so variants need sorting
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
const runsOnVariants = result.filter(x => x.label === "runs-on");
// Scalar: no sortText needed (sorts naturally first)
expect(runsOnVariants.find(x => x.detail === undefined)?.sortText).toBeUndefined();
// Sequence and mapping: sortText controls ordering
expect(runsOnVariants.find(x => x.detail === "list")?.sortText).toEqual("runs-on 1");
expect(runsOnVariants.find(x => x.detail === "full syntax")?.sortText).toEqual("runs-on 2");
});
it("scalar event completion inserts inline without newline", async () => {
// At `on: |` user is completing the value for 'on' key
// Scalar events like `push`, `check_run` should insert inline
const input = "on: |";
const result = await complete(...getPositionFromCursor(input));
// Scalar forms should NOT have newline - they insert inline
const push = result.find(x => x.label === "push");
expect(push?.textEdit?.newText).toEqual("push");
const checkRun = result.find(x => x.label === "check_run" && x.detail === undefined);
expect(checkRun?.textEdit?.newText).toEqual("check_run");
// Full syntax form should NOT be shown in Key mode - it requires a newline
// which is confusing when typing inline. Users who want the mapping form
// can use `on (full syntax)` at the parent level.
expect(result.find(x => x.label === "check_run" && x.detail === "full syntax")).toBeUndefined();
});
it("filters to sequence options when user has started a sequence", async () => {
// User started a sequence with `- ` syntax - they've committed to sequence form
const input = `on: push
jobs:
build:
runs-on:
- |`;
const result = await complete(...getPositionFromCursor(input));
// Should show runner labels (sequence item values)
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
expect(result.some(x => x.label === "macos-latest")).toBe(true);
// Should NOT show mapping keys like `group` or `labels` (those are for full syntax)
expect(result.filter(x => x.label === "group")).toEqual([]);
expect(result.filter(x => x.label === "labels")).toEqual([]);
});
describe("escape hatch completions", () => {
it("runs-on shows switch to list and full syntax", async () => {
const input = `on: push
jobs:
build:
runs-on: |`;
const result = await complete(...getPositionFromCursor(input));
// Should have escape hatches at the end
const switchToList = result.find(x => x.label === "(switch to list)");
const switchToFull = result.find(x => x.label === "(switch to mapping)");
expect(switchToList).toBeDefined();
expect(switchToFull).toBeDefined();
// Escape hatches should sort last
expect(switchToList!.sortText).toEqual("zzz_switch_1");
expect(switchToFull!.sortText).toEqual("zzz_switch_2");
// Escape hatches should have textEdit that restructures the YAML
const listEdit = switchToList!.textEdit as TextEdit;
const fullEdit = switchToFull!.textEdit as TextEdit;
expect(listEdit.newText).toEqual("runs-on:\n - ");
expect(fullEdit.newText).toEqual("runs-on:\n ");
// TextEdit range should cover from key start to cursor position
expect(listEdit.range.start).toEqual({line: 3, character: 4});
expect(fullEdit.range.start).toEqual({line: 3, character: 4});
});
it("permissions shows only switch to full syntax (no sequence form)", async () => {
const input = `on: push
permissions: |`;
const result = await complete(...getPositionFromCursor(input));
// Should have full syntax escape hatch but NOT list (permissions has no sequence form)
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
});
it("escape hatches are not shown when value is non-empty", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-|`;
const result = await complete(...getPositionFromCursor(input));
// User has started typing a scalar value, no escape hatches
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
});
it("escape hatches are not shown when inside a sequence", async () => {
const input = `on: push
jobs:
build:
runs-on:
- |`;
const result = await complete(...getPositionFromCursor(input));
// User is already in sequence form, no escape hatches
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
});
it("escape hatches are not shown when inside a mapping", async () => {
const input = `on: push
jobs:
build:
runs-on:
group: |`;
const result = await complete(...getPositionFromCursor(input));
// User is in mapping form completing a value, no escape hatches for the parent
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
});
it("escape hatches ARE shown even when no scalar completions exist", async () => {
// concurrency: | has no scalar constants, but escape hatch provides a way out
const input = `on: push
jobs:
build:
concurrency: |`;
const result = await complete(...getPositionFromCursor(input));
// Escape hatch to mapping should be available even with no scalar completions
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
});
it("pure mapping type (strategy) shows switch to mapping", async () => {
const input = `on: push
jobs:
build:
strategy: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
});
it("pure sequence type (steps) shows switch to list", async () => {
const input = `on: push
jobs:
build:
steps: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result.some(x => x.label === "(switch to list)")).toBe(true);
});
it("selecting switch to list restructures YAML", async () => {
const input = `on: push
jobs:
build:
runs-on: |`;
const result = await complete(...getPositionFromCursor(input));
const switchToList = result.find(x => x.label === "(switch to list)");
const textEdit = switchToList!.textEdit as TextEdit;
// Applying this edit to "runs-on: " should produce "runs-on:\n - "
expect(textEdit.newText).toEqual("runs-on:\n - ");
});
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types"]);
});
});
+21 -275
View File
@@ -2,32 +2,29 @@ import {complete as completeExpression, DescriptionDictionary} from "@actions/ex
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
import {ContextProviderConfig} from "./context-providers/config.js";
import {getContext, Mode} from "./context-providers/default.js";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
import {validatorFunctions} from "./expression-validation/functions.js";
import {error} from "./log.js";
import {isPotentiallyExpression} from "./utils/expression-detection.js";
import {findToken} from "./utils/find-token.js";
import {guessIndentation} from "./utils/indentation-guesser.js";
import {mapRange} from "./utils/range.js";
import {isPlaceholder, transform} from "./utils/transform.js";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
import {Value, ValueProviderConfig} from "./value-providers/config.js";
import {defaultValueProviders} from "./value-providers/default.js";
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
import {ContextProviderConfig} from "./context-providers/config";
import {getContext, Mode} from "./context-providers/default";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
import {validatorFunctions} from "./expression-validation/functions";
import {error} from "./log";
import {isPotentiallyExpression} from "./utils/expression-detection";
import {findToken} from "./utils/find-token";
import {guessIndentation} from "./utils/indentation-guesser";
import {mapRange} from "./utils/range";
import {getRelCharOffset} from "./utils/rel-char-pos";
import {isPlaceholder, transform} from "./utils/transform";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
import {Value, ValueProviderConfig} from "./value-providers/config";
import {defaultValueProviders} from "./value-providers/default";
import {DefinitionValueMode, definitionValues} from "./value-providers/definition";
export function getExpressionInput(input: string, pos: number): string {
// Find start marker around the cursor position
@@ -103,17 +100,8 @@ export async function complete(
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
// Add escape hatch completions when completing an empty scalar value for a one-of field.
// These provide a way out of "dead end" situations where no scalar completions exist
// but alternative structural forms (list, mapping) are available.
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos);
values.push(...escapeHatches);
// Figure out what text to replace when the user picks a completion.
// For example, if they typed `runs-|` and pick `runs-on`, we need to replace `runs-`.
let replaceRange: Range | undefined;
if (token?.range) {
// Prefer the token's range since it accounts for YAML syntax like quotes
replaceRange = mapRange(token.range);
} else if (!token) {
// Not a valid token, create a range from the current position
@@ -139,44 +127,20 @@ export async function complete(
return values.map(value => {
const newText = value.insertText || value.label;
// Escape hatches provide their own textEdit to restructure the YAML
let textEdit: TextEdit;
if (value.textEdit) {
textEdit = TextEdit.replace(value.textEdit.range, value.textEdit.newText);
} else if (replaceRange) {
textEdit = TextEdit.replace(replaceRange, newText);
} else {
textEdit = TextEdit.insert(position, newText);
}
const item: CompletionItem = {
label: value.label,
detail: value.detail,
filterText: value.filterText,
sortText: value.sortText,
documentation: value.description && {
kind: "markdown",
value: value.description
},
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
textEdit
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
};
return item;
});
}
/**
* Retrieves completion values for a token based on value providers and definitions.
*
* This function determines which values to suggest for auto-completion by:
* 1. First checking for custom value providers configured for the token's definition key
* 2. Then checking for default value providers for the token's definition key
* 3. Finally falling back to values derived from the token's schema definition
*
* The results are filtered to exclude duplicates (e.g., keys already defined in a mapping
* or values already present in a sequence) and sorted alphabetically.
*/
async function getValues(
token: TemplateToken | null,
keyToken: TemplateToken | null,
@@ -216,181 +180,10 @@ async function getValues(
return [];
}
// When a schema allows multiple formats (e.g., `runs-on` can be a string OR a mapping),
// only suggest completions that match what the user has already started typing.
// For example, if they've started a mapping, don't suggest string values.
const tokenStructure = getTokenStructure(token);
const values = definitionValues(
def,
indentation,
keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent,
tokenStructure
);
const values = definitionValues(def, indentation, keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent);
return filterAndSortCompletionOptions(values, existingValues);
}
/**
* Determines what YAML structure the user has committed to, if any.
*
* Returns:
* - "mapping" if the user has started a key-value structure (e.g., `runs-on:\n group: |`)
* - "sequence" if the user has started a list (e.g., `runs-on:\n - |`)
* - "scalar" if the user has started typing a plain value (e.g., `runs-on: ubuntu-|`)
* - undefined if the user hasn't committed yet (e.g., `runs-on: |` with nothing typed)
*/
function getTokenStructure(token: TemplateToken | null): TokenStructure {
if (!token) {
return undefined;
}
switch (token.templateTokenType) {
case TokenType.Mapping:
return "mapping";
case TokenType.Sequence:
return "sequence";
case TokenType.Null:
// Null means `key: ` with nothing - user hasn't committed to a type yet
return undefined;
case TokenType.String: {
// Empty string means `key: |` - user hasn't committed yet
// Non-empty string means user has started typing a scalar value
const stringToken = token.assertString("getTokenStructure expected string token");
if (stringToken.value === "") {
return undefined;
}
return "scalar";
}
case TokenType.Boolean:
case TokenType.Number:
return "scalar";
default:
return undefined;
}
}
/**
* Generates escape hatch completions that allow switching from scalar form to
* alternative structural forms (sequence or mapping) when the value is empty.
*
* For example, at `runs-on: |`, this adds "(switch to list)" and "(switch to full syntax)"
* completions that restructure the YAML to `runs-on:\n - |` or `runs-on:\n |`.
*
* Only shown when:
* - Completing in value position (keyToken exists)
* - Value is empty (user hasn't committed to a structure yet)
* - Definition allows sequence or mapping structure
*/
function getEscapeHatchCompletions(
token: TemplateToken | null,
keyToken: TemplateToken | null,
indentation: string,
position: Position
): Value[] {
// Only show escape hatches when value is empty
const tokenStructure = getTokenStructure(token);
if (tokenStructure !== undefined) {
return [];
}
// Need a key token with a definition
if (!keyToken?.definition) {
return [];
}
// Determine which structural types are available from the definition
const def = keyToken.definition;
const schema = getWorkflowSchema();
const buckets = {
sequence: false,
mapping: false
};
if (def instanceof OneOfDefinition) {
// OneOf: check each variant
for (const variantKey of def.oneOf) {
const variantDef = schema.definitions[variantKey];
if (variantDef) {
switch (variantDef.definitionType) {
case DefinitionType.Sequence:
buckets.sequence = true;
break;
case DefinitionType.Mapping:
buckets.mapping = true;
break;
}
}
}
} else {
// Single definition type
switch (def.definitionType) {
case DefinitionType.Sequence:
buckets.sequence = true;
break;
case DefinitionType.Mapping:
buckets.mapping = true;
break;
}
}
const results: Value[] = [];
const keyName = isString(keyToken) ? keyToken.value : "";
const keyRange = keyToken.range;
if (!keyRange || !keyName) {
return [];
}
// Calculate the range from key start to current position
// This covers "key: " so we can replace it with "key:\n - " or "key:\n "
const editRange = {
start: {line: keyRange.start.line - 1, character: keyRange.start.column - 1},
end: {line: position.line, character: position.character}
};
if (buckets.sequence) {
results.push({
label: "(switch to list)",
sortText: "zzz_switch_1",
filterText: keyName, // Allow filtering by key name
textEdit: {
range: editRange,
newText: `${keyName}:\n${indentation}- `
}
});
}
if (buckets.mapping) {
results.push({
label: "(switch to mapping)",
sortText: "zzz_switch_2",
filterText: keyName, // Allow filtering by key name
textEdit: {
range: editRange,
newText: `${keyName}:\n${indentation}`
}
});
}
return results;
}
/**
* Collects values that are already present in the current context, so they can be
* excluded from completion suggestions.
*
* For sequences (lists), returns all existing items. For example, if the user has:
* labels:
* - bug
* - |
* This returns {"bug"} so we don't suggest "bug" again.
*
* For mappings, returns all existing keys. For example, if the user has:
* jobs:
* build:
* runs-on: ubuntu-latest
* |
* This returns {"runs-on"} so we don't suggest "runs-on" again.
*/
export function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
// For incomplete YAML, we may only have a parent token
if (token) {
@@ -445,12 +238,12 @@ function getExpressionCompletionItems(
currentInput = stringToken.source || stringToken.value;
}
const cursorOffset = getOffsetInContent(token.range, currentInput, pos);
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
const relCharOffset = getRelCharOffset(token.range, currentInput, pos);
const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim();
try {
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
mapExpressionCompletionItem(item, currentInput[cursorOffset])
mapExpressionCompletionItem(item, currentInput[relCharOffset])
);
} catch (e) {
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
@@ -460,7 +253,7 @@ function getExpressionCompletionItems(
function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<string>) {
options = options.filter(x => !existingValues?.has(x.label));
options.sort((a, b) => (a.sortText ?? a.label).localeCompare(b.sortText ?? b.label));
options.sort((a, b) => a.label.localeCompare(b.label));
return options;
}
@@ -481,50 +274,3 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
};
}
/**
* Converts a document position to an offset within the token's content string.
*/
function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: Position): number {
const range = mapRange(tokenRange);
if (range.start.line === range.end.line) {
// Single-line example:
// if: github.ref == 'main'
// ^8 ^15 (cursor)
// currentInput = "github.ref == 'main'"
// offset = 15 - 8 = 7
return pos.character - range.start.character;
}
// Multi-line example:
// if: | <- line 3 (range.start.line)
// first line <- line 4, content line 0
// second line <- line 5, content line 1
// github. <- line 6, content line 2, cursor at index 11
// ^11 (cursor)
//
// currentInput = " first line\n second line\n github."
// ^0 ^15 ^32 ^43
// Line index within content.
// From the example:
// lineIndexWithinContent = pos.line - range.start.line - 1
// = 6 - 3 - 1 = 2
const lineIndexWithinContent = pos.line - range.start.line - 1;
// Length of content before current line.
// From the example:
// lengthOfContentBeforeCurrentLine => 14 + 1 = 15 (after first iteration)
// => 31 + 1 = 32 (after second iteration)
let lengthOfContentBeforeCurrentLine = 0;
for (let i = 0; i < lineIndexWithinContent; i++) {
lengthOfContentBeforeCurrentLine = currentInput.indexOf("\n", lengthOfContentBeforeCurrentLine) + 1;
}
// Final offset within content.
// From the example:
// finalOffset = lengthOfContentBeforeCurrentLine + pos.character
// = 32 + 11 = 43
return lengthOfContentBeforeCurrentLine + pos.character;
}
@@ -1,6 +1,6 @@
import {DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "../context/workflow-context.js";
import {Mode} from "./default.js";
import {WorkflowContext} from "../context/workflow-context";
import {Mode} from "./default";
export type ContextProviderConfig = {
getContext: (
@@ -1,97 +0,0 @@
import {DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "../context/workflow-context.js";
import {getContext, Mode} from "./default.js";
describe("getContext", () => {
const emptyWorkflowContext: WorkflowContext = {
uri: "test.yaml",
template: undefined
};
describe("when no contextProviderConfig is provided", () => {
it("should mark secrets context as incomplete", async () => {
const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets") as DescriptionDictionary;
expect(secretsContext).toBeDefined();
expect(secretsContext.complete).toBe(false);
});
it("should mark vars context as incomplete", async () => {
const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars") as DescriptionDictionary;
expect(varsContext).toBeDefined();
expect(varsContext.complete).toBe(false);
});
it("should not mark other contexts as incomplete", async () => {
const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation);
const envContext = result.get("env") as DescriptionDictionary;
const githubContext = result.get("github") as DescriptionDictionary;
// These contexts are derived from the workflow file, so they can be complete
expect(envContext).toBeDefined();
expect(envContext.complete).toBe(true);
expect(githubContext).toBeDefined();
expect(githubContext.complete).toBe(true);
});
});
describe("when contextProviderConfig returns a value", () => {
it("should use the provided context for secrets", async () => {
const providedContext = new DescriptionDictionary();
providedContext.complete = true; // Provider fetched from API, so it's complete
const config = {
getContext: () => Promise.resolve(providedContext)
};
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets");
expect(secretsContext).toBe(providedContext);
expect((secretsContext as DescriptionDictionary).complete).toBe(true);
});
it("should use the provided context for vars", async () => {
const providedContext = new DescriptionDictionary();
providedContext.complete = true;
const config = {
getContext: () => Promise.resolve(providedContext)
};
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars");
expect(varsContext).toBe(providedContext);
expect((varsContext as DescriptionDictionary).complete).toBe(true);
});
});
describe("when contextProviderConfig returns undefined", () => {
it("should mark secrets as incomplete", async () => {
const config = {
getContext: () => Promise.resolve(undefined)
};
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets") as DescriptionDictionary;
expect(secretsContext.complete).toBe(false);
});
it("should mark vars as incomplete", async () => {
const config = {
getContext: () => Promise.resolve(undefined)
};
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars") as DescriptionDictionary;
expect(varsContext.complete).toBe(false);
});
});
});
@@ -1,18 +1,18 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {Kind} from "@actions/expressions/data/expressiondata";
import {WorkflowContext} from "../context/workflow-context.js";
import {ContextProviderConfig} from "./config.js";
import {getDescription, RootContext} from "./descriptions.js";
import {getEnvContext} from "./env.js";
import {getGithubContext} from "./github.js";
import {getInputsContext} from "./inputs.js";
import {getJobContext} from "./job.js";
import {getJobsContext} from "./jobs.js";
import {getMatrixContext} from "./matrix.js";
import {getNeedsContext} from "./needs.js";
import {getSecretsContext} from "./secrets.js";
import {getStepsContext} from "./steps.js";
import {getStrategyContext} from "./strategy.js";
import {WorkflowContext} from "../context/workflow-context";
import {ContextProviderConfig} from "./config";
import {getDescription, RootContext} from "./descriptions";
import {getEnvContext} from "./env";
import {getGithubContext} from "./github";
import {getInputsContext} from "./inputs";
import {getJobContext} from "./job";
import {getJobsContext} from "./jobs";
import {getMatrixContext} from "./matrix";
import {getNeedsContext} from "./needs";
import {getSecretsContext} from "./secrets";
import {getStepsContext} from "./steps";
import {getStrategyContext} from "./strategy";
// ContextValue is the type of the value returned by a context provider
// Null indicates that the context provider doesn't have any value to provide
@@ -32,24 +32,15 @@ export async function getContext(
): Promise<DescriptionDictionary> {
const context = new DescriptionDictionary();
// All context names are valid - strategy and matrix are always available
// (with default values when no strategy block is defined)
for (const contextName of names) {
const filteredNames = filterContextNames(names, workflowContext);
for (const contextName of filteredNames) {
let value = getDefaultContext(contextName, workflowContext, mode) || new DescriptionDictionary();
if (value.kind === Kind.Null) {
context.add(contextName, value);
continue;
}
const remoteValue = await config?.getContext(contextName, value, workflowContext, mode);
if (remoteValue) {
value = remoteValue;
} else if (contextName === "secrets" || contextName === "vars") {
// Without a context provider to fetch remote secrets/vars, we can't know
// what values exist, so mark the context as incomplete to avoid false
// "Context access might be invalid" warnings
value.complete = false;
}
value = (await config?.getContext(contextName, value, workflowContext, mode)) || value;
context.add(contextName, value, getDescription(RootContext, contextName));
}
@@ -83,14 +74,11 @@ function getDefaultContext(name: string, workflowContext: WorkflowContext, mode:
case "runner":
return objectToDictionary({
arch: "X64",
debug: "1",
environment: "github-hosted",
name: "GitHub Actions 2",
os: "Linux",
temp: "/home/runner/work/_temp",
arch: "X64",
name: "GitHub Actions 2",
tool_cache: "/opt/hostedtoolcache",
workspace: "/home/runner/work/repo"
temp: "/home/runner/work/_temp"
});
case "secrets":
@@ -115,3 +103,18 @@ function objectToDictionary(object: {[key: string]: string}): DescriptionDiction
return dictionary;
}
function filterContextNames(contextNames: string[], workflowContext: WorkflowContext): string[] {
return contextNames.filter(name => {
switch (name) {
case "matrix":
case "strategy":
return hasStrategy(workflowContext);
}
return true;
});
}
function hasStrategy(workflowContext: WorkflowContext): boolean {
return workflowContext.job?.strategy !== undefined || workflowContext.reusableWorkflowJob?.strategy !== undefined;
}
@@ -239,13 +239,7 @@
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software).\""
},
"debug": {
"description": "This is set only if [`ACTIONS_STEP_DEBUG`](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `\"1\"`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
},
"environment": {
"description": "The environment of the runner executing the job. Possible values are `github-hosted` for GitHub-hosted runners, or `self-hosted` for self-hosted runners."
},
"workspace": {
"description": "The runner-specific working directory path for the job."
"description": "This is set only if [debug logging](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `1`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
}
},
"strategy": {
@@ -1,4 +1,4 @@
import descriptions from "./descriptions.min.json";
import descriptions from "./descriptions.json";
export const RootContext = "root";
const FunctionContext = "functions";
+1 -1
View File
@@ -1,7 +1,7 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {isScalar, isString} from "@actions/workflow-parser";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {WorkflowContext} from "../context/workflow-context";
export function getEnvContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary();
@@ -1,102 +0,0 @@
import {DescriptionDictionary} from "@actions/expressions";
import {getEventPayload, getSupportedEventTypes} from "./eventPayloads.js";
describe("eventPayloads", () => {
describe("getSupportedEventTypes", () => {
it("returns action types for push event", () => {
const types = getSupportedEventTypes("push");
expect(types).toContain("default");
});
it("returns action types for issues event", () => {
const types = getSupportedEventTypes("issues");
expect(types.length).toBeGreaterThan(1);
expect(types).toContain("opened");
expect(types).toContain("closed");
});
});
describe("getEventPayload", () => {
it("returns payload for push event", () => {
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// Verify common fields exist
expect(payload?.get("ref")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
expect(payload?.get("sender")).toBeDefined();
});
it("returns payload for issues event", () => {
const payload = getEventPayload("issues", "opened");
expect(payload).toBeDefined();
expect(payload?.get("action")).toBeDefined();
expect(payload?.get("issue")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
});
it("preserves descriptions for hover documentation", () => {
// This test ensures bodyParameters[].description is not stripped
// during JSON optimization. The description field is used for hover
// documentation in the workflow editor.
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// Get the description for a well-known field
// repository should have a description like "A repository on GitHub"
const repoDescription = payload?.getDescription("repository");
expect(repoDescription).toBeDefined();
expect(repoDescription?.length).toBeGreaterThan(0);
// sender should have a description
const senderDescription = payload?.getDescription("sender");
expect(senderDescription).toBeDefined();
expect(senderDescription?.length).toBeGreaterThan(0);
});
it("preserves childParamsGroups for nested property access", () => {
// This test ensures bodyParameters[].childParamsGroups is not stripped
// during JSON optimization. childParamsGroups defines nested properties
// used for autocompletion like github.event.repository.owner.login
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// repository has nested properties like owner, license, etc.
const repository = payload?.get("repository") as DescriptionDictionary | undefined;
expect(repository).toBeDefined();
// repository.owner should exist (nested via childParamsGroups)
const owner = repository?.get("owner") as DescriptionDictionary | undefined;
expect(owner).toBeDefined();
// repository.owner.login should exist (deeply nested)
const login = owner?.get("login");
expect(login).toBeDefined();
});
it("preserves name fields for property identification", () => {
// This test ensures bodyParameters[].name is not stripped
// during JSON optimization. The name field identifies each property.
const payload = getEventPayload("issues", "opened");
expect(payload).toBeDefined();
// Verify well-known property names exist
expect(payload?.get("action")).toBeDefined();
expect(payload?.get("issue")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
expect(payload?.get("sender")).toBeDefined();
// Verify nested property names work
const issue = payload?.get("issue") as DescriptionDictionary | undefined;
expect(issue?.get("title")).toBeDefined();
expect(issue?.get("number")).toBeDefined();
expect(issue?.get("user")).toBeDefined();
});
it("returns undefined for unknown event", () => {
const payload = getEventPayload("not_a_real_event", "default");
expect(payload).toBeUndefined();
});
});
});
@@ -1,10 +1,10 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import webhookObjects from "./objects.min.json";
import webhooks from "./webhooks.min.json";
import webhookObjects from "./objects.json";
import webhooks from "./webhooks.json";
import schedule from "./schedule.min.json";
import workflow_call from "./workflow_call.min.json";
import schedule from "./schedule.json";
import workflow_call from "./workflow_call.json";
const customEventPayloads: {[name: string]: unknown} = {
schedule,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context.js";
import {Mode} from "./default.js";
import {getGithubContext} from "./github.js";
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions/.";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
import {Mode} from "./default";
import {getGithubContext} from "./github";
describe("github context", () => {
it("single event", async () => {
@@ -1,11 +1,11 @@
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {ExpressionData} from "@actions/expressions/data/expressiondata";
import {TypesFilterConfig} from "@actions/workflow-parser/model/workflow-template";
import {WorkflowContext} from "../context/workflow-context.js";
import {Mode} from "./default.js";
import {getDescription} from "./descriptions.js";
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads.js";
import {getInputsContext} from "./inputs.js";
import {WorkflowContext} from "../context/workflow-context";
import {Mode} from "./default";
import {getDescription} from "./descriptions";
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads";
import {getInputsContext} from "./inputs";
export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
@@ -1,6 +1,6 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {InputConfig} from "@actions/workflow-parser/model/workflow-template";
import {WorkflowContext} from "../context/workflow-context.js";
import {WorkflowContext} from "../context/workflow-context";
export function getInputsContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary();
+1 -4
View File
@@ -1,7 +1,7 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {isMapping, isSequence} from "@actions/workflow-parser";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {WorkflowContext} from "../context/workflow-context";
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
@@ -35,9 +35,6 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
// Status
jobContext.add("status", new data.Null());
// Check run ID
jobContext.add("check_run_id", new data.Null());
return jobContext;
}
@@ -1,8 +1,8 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {StringData} from "@actions/expressions/data/string";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {getDescription} from "./descriptions.js";
import {WorkflowContext} from "../context/workflow-context";
import {getDescription} from "./descriptions";
export function getJobsContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#jobs-context
@@ -6,9 +6,9 @@ import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-to
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {Mode} from "./default.js";
import {getMatrixContext} from "./matrix.js";
import {WorkflowContext} from "../context/workflow-context";
import {Mode} from "./default";
import {getMatrixContext} from "./matrix";
type MatrixMap = {
[key: string]: Array<string> | Array<{[key: string]: string}>;
@@ -64,7 +64,7 @@ describe("matrix context", () => {
expect(workflowContext.job).toBeUndefined();
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null());
expect(context).toEqual(new DescriptionDictionary());
});
it("strategy not defined", () => {
@@ -73,7 +73,7 @@ describe("matrix context", () => {
expect(workflowContext.job!.strategy).toBeUndefined();
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null());
expect(context).toEqual(new DescriptionDictionary());
});
it("strategy is not a mapping token", () => {
@@ -81,7 +81,7 @@ describe("matrix context", () => {
expect(workflowContext.job!.strategy).toBeDefined();
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null());
expect(context).toEqual(new DescriptionDictionary());
});
it("matrix is not defined", () => {
@@ -3,15 +3,14 @@ import {isBasicExpression, isMapping, isSequence, isString} from "@actions/workf
import {KeyValuePair} from "@actions/workflow-parser/templates/tokens/key-value-pair";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {ContextValue, Mode} from "./default.js";
import {WorkflowContext} from "../context/workflow-context";
import {ContextValue, Mode} from "./default";
export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode): ContextValue {
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
if (!strategy || !isMapping(strategy)) {
// No strategy defined - matrix is null at runtime (not empty object)
return new data.Null();
return new DescriptionDictionary();
}
const matrix = strategy.find("matrix");
@@ -1,8 +1,8 @@
import {DescriptionDictionary} from "@actions/expressions";
import {StringData} from "@actions/expressions/data/string";
import {WorkflowContext} from "../context/workflow-context.js";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context.js";
import {getNeedsContext} from "./needs.js";
import {WorkflowContext} from "../context/workflow-context";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
import {getNeedsContext} from "./needs";
describe("needs context", () => {
describe("invalid workflow context", () => {
@@ -111,7 +111,7 @@ jobs:
on: push
jobs:
a:
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
uses: ./reusable-workflow-with-outputs.yaml
b:
needs: [a]
@@ -3,7 +3,7 @@ import {isMapping, isScalar, isString} from "@actions/workflow-parser";
import {isJob} from "@actions/workflow-parser/model/type-guards";
import {WorkflowJob} from "@actions/workflow-parser/model/workflow-template";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {WorkflowContext} from "../context/workflow-context";
export function getNeedsContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary();

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