Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77ed325c44 | |||
| 1372d6dec7 | |||
| a06de82217 | |||
| 36b909a32d | |||
| 9a8a94bd21 | |||
| 8aa246e9d9 | |||
| ffc3778653 | |||
| 38f730cdce | |||
| a810405967 | |||
| 840d04cea8 | |||
| 0446b065b0 | |||
| 763dff2018 | |||
| 0c9d817440 | |||
| cc316ab9de | |||
| d5670c383a | |||
| f62a0e189d | |||
| 9e1662f1d4 | |||
| 5db2e80f32 | |||
| 83de320ba9 | |||
| 74e6638098 | |||
| f8b8b57248 | |||
| aa1e7d8aec | |||
| bd6ce5923b | |||
| 3de9820cd8 | |||
| a7f581bde5 | |||
| 8c0a3a947b | |||
| eb71b18f2b | |||
| 92c5235a00 | |||
| 9f770badd3 | |||
| 9dd856db3d | |||
| 4a881d9ea1 | |||
| 6a0408d237 | |||
| 0c2f39f1d0 | |||
| fb5c6e4f27 | |||
| f29f508cec | |||
| d69c1fa0f3 | |||
| 191a7b6a00 | |||
| 0410ab8302 | |||
| 7ac83f43a6 | |||
| ef457b29fa | |||
| fea8440c1d | |||
| 3c0a5f79fc | |||
| 448180bd7f | |||
| d2f52a9043 | |||
| 46b216a6dc | |||
| 0fe7798548 | |||
| bdd72406c3 | |||
| 33291f0f8d | |||
| 8511ae2e6d | |||
| cd1078fb2f | |||
| 96be7ce46c | |||
| c2bf928e7b | |||
| 74d69b24ab | |||
| 22aa458809 | |||
| f3f11d8658 | |||
| 5359433879 | |||
| a8bfe74256 | |||
| e2c5f1f74a | |||
| 2a203ec742 | |||
| 92960e0093 | |||
| 0fe31c6656 | |||
| 67dd4fbd61 | |||
| 4a7e08774d | |||
| 9ec1c123a8 | |||
| aad3bcd291 | |||
| 248934d513 | |||
| b605cb6582 | |||
| 05debf64b0 | |||
| 1baa74a67e | |||
| fa27dfa563 | |||
| 228acc3cd9 | |||
| 9f30846fde | |||
| 2816233a40 | |||
| 54404aa9ff | |||
| 0ebe1262ee | |||
| 94d7f7b124 | |||
| f439272f69 | |||
| 161574adac | |||
| dbf7752734 | |||
| 78231482f5 | |||
| 2e46c66878 | |||
| 44900feff7 | |||
| 39b9b14e3a | |||
| 71ff7b49c3 | |||
| 1a42526360 | |||
| 1cfe9f9f86 | |||
| 6641228870 | |||
| c1ad4d14df | |||
| 6a47895521 | |||
| c67c353245 | |||
| c6d2036302 | |||
| 56ce46afa6 | |||
| e3b56c2416 | |||
| d2ffb50a92 | |||
| 3734de18ee | |||
| 90e7932e97 | |||
| f84e42c1f1 | |||
| 08c78d2a73 | |||
| 26f3969cde | |||
| 61a6fc54f2 | |||
| 6511be5ab4 | |||
| a06ceee92b | |||
| efd53330a3 | |||
| 86888cf4c8 | |||
| ed4c2ce44c | |||
| 9bb4c76612 | |||
| 8b86b48961 | |||
| c0062e5287 | |||
| 2eb53df976 | |||
| 656a821a94 | |||
| fbdc2a5749 | |||
| 47ec2dc734 | |||
| 1395ae198f | |||
| 589c1e34f4 | |||
| 1f2031c2f3 | |||
| ecebf60561 | |||
| 9922d3983f |
@@ -1 +1,4 @@
|
|||||||
* @actions/actions-vscode-reviewers
|
* @actions/actions-vscode-reviewers
|
||||||
|
|
||||||
|
# Owners maintaining https://github.com/actions/runner-images
|
||||||
|
/languageservice/src/value-providers/default.ts @actions/runner-images-writers @actions/actions-vscode-reviewers
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [18.x, 20.x, 22.x]
|
node-version: [20.x, 22.x, 24.x]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -37,10 +37,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 24.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 24.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
registry-url: 'https://npm.pkg.github.com'
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "16"
|
node-version: 24.x
|
||||||
|
|
||||||
- name: Bump version and push
|
- name: Bump version and push
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
id-token: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PKG_VERSION: "" # will be set in the workflow
|
PKG_VERSION: "" # will be set in the workflow
|
||||||
@@ -69,9 +69,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 24.x
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
scope: '@actions'
|
|
||||||
|
|
||||||
- name: Parse version from lerna.json
|
- name: Parse version from lerna.json
|
||||||
run: |
|
run: |
|
||||||
@@ -97,13 +96,6 @@ jobs:
|
|||||||
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
|
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
|
||||||
await core.summary.write();
|
await core.summary.write();
|
||||||
|
|
||||||
- name: setup authentication
|
|
||||||
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
|
|
||||||
env:
|
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
|
|
||||||
- name: Publish packages
|
- name: Publish packages
|
||||||
run: |
|
run: |
|
||||||
lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
|
npx lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
|
||||||
env:
|
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
@@ -4,6 +4,9 @@ lerna-debug.log
|
|||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Nx cache (generated by Lerna/Nx)
|
||||||
|
.nx/
|
||||||
|
|
||||||
# Minified JSON (generated at build time)
|
# Minified JSON (generated at build time)
|
||||||
*.min.json
|
*.min.json
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ dist
|
|||||||
*.js
|
*.js
|
||||||
*.json
|
*.json
|
||||||
*.d.ts
|
*.d.ts
|
||||||
|
/.nx/workspace-data
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Agents
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```
|
||||||
|
npx lerna run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```
|
||||||
|
npm -w @actions/expressions test
|
||||||
|
npm -w @actions/workflow-parser test
|
||||||
|
npm -w @actions/languageservice test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
Always run formatting before committing:
|
||||||
|
|
||||||
|
```
|
||||||
|
npx prettier --write <changed files>
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify with:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run format-check -ws
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature flags
|
||||||
|
|
||||||
|
Feature flags are defined in `expressions/src/features.ts` (`ExperimentalFeatures` interface + `allFeatureKeys` array). They are plumbed through `ConvertOptions`, `CompletionConfig`, `ValidationConfig`, and `initializationOptions`. When a feature graduates to stable, remove its flag and make the behavior unconditional.
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
# ESM Migration Plan: Add File Extensions to Imports
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines the plan to migrate from TypeScript's deprecated `"moduleResolution": "node"` (node10) to `"moduleResolution": "node16"` or `"nodenext"`. This change is necessary because the published ESM packages have extensionless imports that don't work correctly in modern ESM environments.
|
||||||
|
|
||||||
|
## TL;DR - Remaining Work
|
||||||
|
|
||||||
|
- [x] expressions - Migrated ✅
|
||||||
|
- [x] workflow-parser - Migrated ✅
|
||||||
|
- [x] languageservice - Migrated ✅
|
||||||
|
- [x] languageserver - Add `.js` extensions to imports ✅
|
||||||
|
- [ ] languageserver - Update `tsconfig.build.json` to `moduleResolution: "node16"` (blocked by vscode-languageserver)
|
||||||
|
- [ ] languageserver - Upgrade `vscode-languageserver` to stable v10+ when released
|
||||||
|
|
||||||
|
**Blocker:** `vscode-languageserver@8.0.2` lacks ESM exports. Stable v10 with `exports` field needed.
|
||||||
|
|
||||||
|
### ⚠️ Important: `skipLibCheck: true` Required
|
||||||
|
|
||||||
|
All migrated packages use `skipLibCheck: true` in their `tsconfig.build.json`. This works around a TS2386 "Overload signatures must all be optional or required" error in `@types/node/module.d.ts`.
|
||||||
|
|
||||||
|
**Why can't we just fix the error?** The error is in `@types/node`, a third-party package maintained by DefinitelyTyped. We can't modify `node_modules`, and upstream fixes take time.
|
||||||
|
|
||||||
|
**Is `skipLibCheck` safe?** Yes. It only skips type checking of `.d.ts` files (declaration files from dependencies). Our own `.ts` source files are still fully type-checked. This is a common and recommended workaround for issues in third-party type definitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issues Fixed
|
||||||
|
|
||||||
|
This migration will resolve the following issues:
|
||||||
|
|
||||||
|
- **#154** - Upgrade `moduleResolution` from `node` to `node16` or `nodenext` in tsconfig
|
||||||
|
- **#110** - Published ESM code has imports without file extensions
|
||||||
|
- **#64** - expressions: ERR_MODULE_NOT_FOUND attempting to run example demo script
|
||||||
|
- **#146** - Can not import `@actions/workflow-parser`
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
|
||||||
|
All packages use `"moduleResolution": "node"`:
|
||||||
|
|
||||||
|
| Package | moduleResolution | TypeScript |
|
||||||
|
|---------|------------------|------------|
|
||||||
|
| expressions | `"node"` | ^4.7.4 |
|
||||||
|
| workflow-parser | `"node"` | ^4.8.4 |
|
||||||
|
| languageservice | `"node"` | ^4.8.4 |
|
||||||
|
| languageserver | `"node"` | ^4.8.4 |
|
||||||
|
| browser-playground | `"Node16"` ✅ | ^4.9.4 |
|
||||||
|
|
||||||
|
This causes TypeScript to emit code like:
|
||||||
|
```javascript
|
||||||
|
// Published to npm - INVALID ESM
|
||||||
|
export { Expr } from "./ast"; // Missing .js extension!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Fails
|
||||||
|
|
||||||
|
ESM in Node.js 12+ **requires** explicit file extensions. When users try to import these packages:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// User's code
|
||||||
|
import { Expr } from "@actions/expressions";
|
||||||
|
```
|
||||||
|
|
||||||
|
Node.js fails with:
|
||||||
|
```
|
||||||
|
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/@actions/expressions/dist/ast'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
### Option A: TypeScript 5.7+ with `rewriteRelativeImportExtensions` (Recommended)
|
||||||
|
|
||||||
|
TypeScript 5.7 introduced a new compiler option that automatically rewrites `.ts` extensions to `.js` in output:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"moduleResolution": "node16", // or "nodenext"
|
||||||
|
"rewriteRelativeImportExtensions": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Source code:**
|
||||||
|
```typescript
|
||||||
|
import { Expr } from "./ast.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Compiled output:**
|
||||||
|
```javascript
|
||||||
|
export { Expr } from "./ast.js";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Source uses `.ts` extensions (matches actual files)
|
||||||
|
- Works with Deno (which requires `.ts` extensions)
|
||||||
|
- TypeScript automatically transforms to `.js`
|
||||||
|
- Modern, forward-looking approach
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Requires TypeScript 5.7+
|
||||||
|
- Relatively new feature
|
||||||
|
- **BUG:** See "Known Issues" section below
|
||||||
|
|
||||||
|
### Option B: Manual `.js` Extensions
|
||||||
|
|
||||||
|
Use `.js` extensions in source TypeScript files:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Expr } from "./ast.js"; // Points to .ts file, but use .js extension
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Works with TypeScript 4.7+ (with node16 moduleResolution)
|
||||||
|
- Well-established pattern
|
||||||
|
- No post-processing needed
|
||||||
|
- Works with ts-jest without extra configuration
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Confusing - `.js` files don't exist at write time
|
||||||
|
- Doesn't work with Deno out of the box
|
||||||
|
|
||||||
|
### Recommendation
|
||||||
|
|
||||||
|
**Use Option B** (manual `.js` extensions). Option A with `rewriteRelativeImportExtensions` has compatibility issues with ts-jest and requires additional workarounds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues and Workarounds (December 2025)
|
||||||
|
|
||||||
|
### 1. TypeScript Version Conflicts in Monorepo
|
||||||
|
|
||||||
|
**Problem:** The root `node_modules/typescript` was version 4.9.5 (pulled in by `ts-node` and `tsutils` dependencies), while workspace packages specified `^5.8.3`.
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- `npx tsc --version` showed 4.9.5
|
||||||
|
- `require('typescript').version` in ts-jest showed 5.8.3
|
||||||
|
- Confusing build failures
|
||||||
|
|
||||||
|
**Solution:** Add npm overrides in root `package.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"overrides": {
|
||||||
|
"typescript": "5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ts-jest Compatibility with TypeScript 5.9+
|
||||||
|
|
||||||
|
**Problem:** ts-jest 29.4.6 uses `typescript.JSDocParsingMode.ParseAll` which doesn't exist in TypeScript's ES module exports.
|
||||||
|
|
||||||
|
**Error:**
|
||||||
|
```
|
||||||
|
TypeError: Cannot read properties of undefined (reading 'ParseAll')
|
||||||
|
at Object.<anonymous> (node_modules/ts-jest/dist/compiler/ts-compiler.js:43:123)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root Cause:** ts-jest accesses `typescript_1.default.JSDocParsingMode.ParseAll` but TypeScript has no default export in ESM.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Use ts-jest 29.0.3 (older version that doesn't use this API)
|
||||||
|
- OR wait for ts-jest fix
|
||||||
|
- **Stay on TypeScript 5.8.3, not 5.9+**
|
||||||
|
|
||||||
|
### 3. TypeScript `rewriteRelativeImportExtensions` Bug with .d.ts Files
|
||||||
|
|
||||||
|
**Problem:** TypeScript's `rewriteRelativeImportExtensions: true` correctly rewrites `.ts` → `.js` in `.js` output files, but **incorrectly keeps `.ts` extensions in `.d.ts` declaration files**.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
- Source: `export { Expr } from "./ast.ts";`
|
||||||
|
- Output `index.js`: `export { Expr } from "./ast.js";` ✅ Correct
|
||||||
|
- Output `index.d.ts`: `export { Expr } from "./ast.ts";` ❌ Wrong (should be `.js`)
|
||||||
|
|
||||||
|
**Upstream Issue:** https://github.com/microsoft/TypeScript/issues/61037 (marked "Help Wanted", in Backlog, NOT FIXED as of Dec 2025)
|
||||||
|
|
||||||
|
**Workaround:** Post-process `.d.ts` files with a script. See `script/fix-dts-extensions.cjs`.
|
||||||
|
|
||||||
|
**Note:** Since we use Option B (manual `.js` extensions), this bug does not affect our migration.
|
||||||
|
|
||||||
|
### 4. yaml Package Internal Types Not Exported
|
||||||
|
|
||||||
|
**Problem:** The `yaml` package does not export internal types like `LinePos` and `NodeBase` that are used in `workflow-parser/src/workflows/yaml-object-reader.ts`.
|
||||||
|
|
||||||
|
**Error:**
|
||||||
|
```
|
||||||
|
error TS2305: Module '"yaml"' has no exported member 'LinePos'.
|
||||||
|
error TS2305: Module '"yaml"' has no exported member 'NodeBase'.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:** Define local type aliases in the file that uses them:
|
||||||
|
```typescript
|
||||||
|
// Local type definitions to replace yaml internal imports
|
||||||
|
type LinePos = { line: number; col: number };
|
||||||
|
type NodeBase = { range?: [number, number, number] };
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. languageserver Blocked by vscode-languageserver Dependency
|
||||||
|
|
||||||
|
**Problem:** The `vscode-languageserver` package (v8.0.2) does not have proper ESM exports. When using `moduleResolution: "node16"`, TypeScript requires packages to have an `exports` field in `package.json` for subpath imports to work.
|
||||||
|
|
||||||
|
**Error:**
|
||||||
|
```
|
||||||
|
src/index.ts(6,8): error TS2307: Cannot find module 'vscode-languageserver/browser' or its corresponding type declarations.
|
||||||
|
src/connection.ts(1,43): error TS2307: Cannot find module 'vscode-languageserver/node' or its corresponding type declarations.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root Cause:** The `vscode-languageserver` package.json only has `main` and `browser` fields, but no `exports` field:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"main": "./lib/node/main.js",
|
||||||
|
"browser": {
|
||||||
|
"./lib/node/main.js": "./lib/browser/main.js"
|
||||||
|
}
|
||||||
|
// No "exports" field!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With `moduleResolution: "node16"`, TypeScript follows Node.js ESM resolution rules which require explicit `exports` for subpath imports like `vscode-languageserver/browser` and `vscode-languageserver/node`.
|
||||||
|
|
||||||
|
**Status:** Partial - `.js` extensions added, waiting for stable `vscode-languageserver` release with ESM exports to complete migration.
|
||||||
|
|
||||||
|
**Completed:** All relative imports in languageserver source files have been updated to use `.js` extensions. This is compatible with the current `moduleResolution: "node"` and will enable a seamless migration once a stable vscode-languageserver version with ESM exports is available.
|
||||||
|
|
||||||
|
**Options to resolve:**
|
||||||
|
- Wait for stable vscode-languageserver v10+ with ESM exports
|
||||||
|
- Use pre-release `vscode-languageserver@10.0.0-next.16` (has proper exports but is unstable)
|
||||||
|
- Fork or patch the dependency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Status
|
||||||
|
|
||||||
|
| Package | Tests | ESM Status |
|
||||||
|
|---------|-------|------------|
|
||||||
|
| expressions | 1068 | ✅ Migrated |
|
||||||
|
| workflow-parser | 292 | ✅ Migrated |
|
||||||
|
| languageservice | 452 | ✅ Migrated |
|
||||||
|
| languageserver | 31 | 🔶 Partial (`.js` extensions added, awaiting stable vscode-languageserver) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Configuration Changes
|
||||||
|
|
||||||
|
### tsconfig.build.json (each migrated package)
|
||||||
|
|
||||||
|
**Note:** We use **Option B** (manual `.js` extensions in source files) rather than `rewriteRelativeImportExtensions` because Option A caused ts-jest compatibility issues (tests would hang indefinitely).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "node16",
|
||||||
|
"moduleResolution": "node16",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"target": "ES2022"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `skipLibCheck: true` is needed to work around @types/node compatibility issues with TypeScript 5.x (TS2386 overload signature errors).
|
||||||
|
```
|
||||||
|
|
||||||
|
### jest.config.js (each migrated package)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
export default {
|
||||||
|
preset: "ts-jest/presets/default-esm",
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||||
|
"^(\\.{1,2}/.*)\\.ts$": "$1",
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
"^.+\\.tsx?$": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
useESM: true,
|
||||||
|
isolatedModules: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
moduleFileExtensions: ["ts", "js"],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Root package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"overrides": {
|
||||||
|
"typescript": "5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Each workspace package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"ts-jest": "^29.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [TypeScript moduleResolution reference](https://www.typescriptlang.org/docs/handbook/modules/reference.html)
|
||||||
|
- [TypeScript 5.7 rewriteRelativeImportExtensions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-7.html#path-rewriting-for-relative-paths)
|
||||||
|
- [TypeScript .d.ts extension bug #61037](https://github.com/microsoft/TypeScript/issues/61037)
|
||||||
|
- [Node.js ESM mandatory extensions](https://nodejs.org/api/esm.html#mandatory-file-extensions)
|
||||||
|
- [ts-jest ESM support](https://kulshekhar.github.io/ts-jest/docs/guides/esm-support)
|
||||||
|
- [Community fork that works](https://github.com/boxbuild-io/actions-languageservices/commit/077fb2b58dfd2cca3d6e3df1fdf9e26e75db24ae)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/expressions",
|
"name": "@actions/expressions",
|
||||||
"version": "0.3.25",
|
"version": "0.3.54",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/index.ts",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"format": "prettier --write '**/*.ts'",
|
"format": "prettier --write '**/*.ts'",
|
||||||
"format-check": "prettier --check '**/*.ts'",
|
"format-check": "prettier --check '**/*.ts'",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
||||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||||
"prepublishOnly": "npm run build && npm run test",
|
"prepublishOnly": "npm run build && npm run test",
|
||||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 20"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
@@ -60,6 +60,6 @@
|
|||||||
"prettier": "^2.8.3",
|
"prettier": "^2.8.3",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"ts-jest": "^29.0.3",
|
"ts-jest": "^29.0.3",
|
||||||
"typescript": "^4.7.4"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {ExpressionData} from "./data";
|
import {ExpressionData} from "./data/index.js";
|
||||||
import {Token} from "./lexer";
|
import {Token} from "./lexer.js";
|
||||||
|
|
||||||
export interface ExprVisitor<R> {
|
export interface ExprVisitor<R> {
|
||||||
visitLiteral(literal: Literal): R;
|
visitLiteral(literal: Literal): R;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {complete, CompletionItem, trimTokenVector} from "./completion";
|
import {complete, CompletionItem, trimTokenVector} from "./completion.js";
|
||||||
import {DescriptionDictionary} from "./completion/descriptionDictionary";
|
import {DescriptionDictionary} from "./completion/descriptionDictionary.js";
|
||||||
import {BooleanData} from "./data/boolean";
|
import {BooleanData} from "./data/boolean.js";
|
||||||
import {Dictionary} from "./data/dictionary";
|
import {Dictionary} from "./data/dictionary.js";
|
||||||
import {StringData} from "./data/string";
|
import {StringData} from "./data/string.js";
|
||||||
import {wellKnownFunctions} from "./funcs";
|
import {wellKnownFunctions} from "./funcs.js";
|
||||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
|
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
||||||
import {Lexer, TokenType} from "./lexer";
|
import {Lexer, TokenType} from "./lexer.js";
|
||||||
|
|
||||||
const testContext = new Dictionary(
|
const testContext = new Dictionary(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {DescriptionPair} from "./completion/descriptionDictionary";
|
import {DescriptionPair} from "./completion/descriptionDictionary.js";
|
||||||
import {Dictionary, isDictionary} from "./data/dictionary";
|
import {Dictionary, isDictionary} from "./data/dictionary.js";
|
||||||
import {ExpressionData} from "./data/expressiondata";
|
import {ExpressionData} from "./data/expressiondata.js";
|
||||||
import {Evaluator} from "./evaluator";
|
import {Evaluator} from "./evaluator.js";
|
||||||
import {wellKnownFunctions} from "./funcs";
|
import {FeatureFlags} from "./features.js";
|
||||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
|
import {wellKnownFunctions} from "./funcs.js";
|
||||||
import {Lexer, Token, TokenType} from "./lexer";
|
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
||||||
import {Parser} from "./parser";
|
import {Lexer, Token, TokenType} from "./lexer.js";
|
||||||
|
import {Parser} from "./parser.js";
|
||||||
|
|
||||||
export type CompletionItem = {
|
export type CompletionItem = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -26,13 +27,15 @@ export type CompletionItem = {
|
|||||||
* @param context Context available for the expression
|
* @param context Context available for the expression
|
||||||
* @param extensionFunctions List of functions available
|
* @param extensionFunctions List of functions available
|
||||||
* @param functions Optional map of functions to use during evaluation
|
* @param functions Optional map of functions to use during evaluation
|
||||||
|
* @param featureFlags Optional feature flags to control which features are enabled
|
||||||
* @returns Array of completion items
|
* @returns Array of completion items
|
||||||
*/
|
*/
|
||||||
export function complete(
|
export function complete(
|
||||||
input: string,
|
input: string,
|
||||||
context: Dictionary,
|
context: Dictionary,
|
||||||
extensionFunctions: FunctionInfo[],
|
extensionFunctions: FunctionInfo[],
|
||||||
functions?: Map<string, FunctionDefinition>
|
functions?: Map<string, FunctionDefinition>,
|
||||||
|
featureFlags?: FeatureFlags
|
||||||
): CompletionItem[] {
|
): CompletionItem[] {
|
||||||
// Lex
|
// Lex
|
||||||
const lexer = new Lexer(input);
|
const lexer = new Lexer(input);
|
||||||
@@ -63,7 +66,7 @@ export function complete(
|
|||||||
const result = contextKeys(context);
|
const result = contextKeys(context);
|
||||||
|
|
||||||
// Merge with functions
|
// Merge with functions
|
||||||
result.push(...functionItems(extensionFunctions));
|
result.push(...functionItems(extensionFunctions, featureFlags));
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -88,10 +91,15 @@ export function complete(
|
|||||||
return contextKeys(result);
|
return contextKeys(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
function functionItems(extensionFunctions: FunctionInfo[]): CompletionItem[] {
|
function functionItems(extensionFunctions: FunctionInfo[], featureFlags?: FeatureFlags): CompletionItem[] {
|
||||||
const result: CompletionItem[] = [];
|
const result: CompletionItem[] = [];
|
||||||
|
const flags = featureFlags ?? new FeatureFlags();
|
||||||
|
|
||||||
for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) {
|
for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) {
|
||||||
|
// Filter out case function if feature is disabled
|
||||||
|
if (fdef.name === "case" && !flags.isEnabled("allowCaseFunction")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
result.push({
|
result.push({
|
||||||
label: fdef.name,
|
label: fdef.name,
|
||||||
description: fdef.description,
|
description: fdef.description,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {StringData} from "../data";
|
import {StringData} from "../data/index.js";
|
||||||
import {DescriptionDictionary} from "./descriptionDictionary";
|
import {DescriptionDictionary} from "./descriptionDictionary.js";
|
||||||
|
|
||||||
describe("description dictionary", () => {
|
describe("description dictionary", () => {
|
||||||
it("pairs contains all values", () => {
|
it("pairs contains all values", () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Dictionary} from "../data/dictionary";
|
import {Dictionary} from "../data/dictionary.js";
|
||||||
import {ExpressionData, Kind, Pair} from "../data/expressiondata";
|
import {ExpressionData, Kind, Pair} from "../data/expressiondata.js";
|
||||||
|
|
||||||
export type DescriptionPair = Pair & {description?: string};
|
export type DescriptionPair = Pair & {description?: string};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata";
|
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata.js";
|
||||||
|
|
||||||
export class Array implements ExpressionDataInterface {
|
export class Array implements ExpressionDataInterface {
|
||||||
private v: ExpressionData[] = [];
|
private v: ExpressionData[] = [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
||||||
|
|
||||||
export class BooleanData implements ExpressionDataInterface {
|
export class BooleanData implements ExpressionDataInterface {
|
||||||
constructor(public readonly value: boolean) {}
|
constructor(public readonly value: boolean) {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Dictionary} from "./dictionary";
|
import {Dictionary} from "./dictionary.js";
|
||||||
import {StringData} from "./string";
|
import {StringData} from "./string.js";
|
||||||
|
|
||||||
describe("dictionary", () => {
|
describe("dictionary", () => {
|
||||||
it("pairs contains all values", () => {
|
it("pairs contains all values", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata";
|
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata.js";
|
||||||
|
|
||||||
export class Dictionary implements ExpressionDataInterface {
|
export class Dictionary implements ExpressionDataInterface {
|
||||||
private keys: string[] = [];
|
private keys: string[] = [];
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {Dictionary} from "./dictionary";
|
import {Dictionary} from "./dictionary.js";
|
||||||
import {Null} from "./null";
|
import {Null} from "./null.js";
|
||||||
import {Array} from "./array";
|
import {Array} from "./array.js";
|
||||||
import {StringData} from "./string";
|
import {StringData} from "./string.js";
|
||||||
import {NumberData} from "./number";
|
import {NumberData} from "./number.js";
|
||||||
import {BooleanData} from "./boolean";
|
import {BooleanData} from "./boolean.js";
|
||||||
|
|
||||||
export enum Kind {
|
export enum Kind {
|
||||||
String = 0,
|
String = 0,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export {Array} from "./array";
|
export {Array} from "./array.js";
|
||||||
export {BooleanData} from "./boolean";
|
export {BooleanData} from "./boolean.js";
|
||||||
export {Dictionary} from "./dictionary";
|
export {Dictionary} from "./dictionary.js";
|
||||||
export {ExpressionData, Kind} from "./expressiondata";
|
export {ExpressionData, Kind} from "./expressiondata.js";
|
||||||
export {Null} from "./null";
|
export {Null} from "./null.js";
|
||||||
export {NumberData} from "./number";
|
export {NumberData} from "./number.js";
|
||||||
export {replacer} from "./replacer";
|
export {replacer} from "./replacer.js";
|
||||||
export {reviver} from "./reviver";
|
export {reviver} from "./reviver.js";
|
||||||
export {StringData} from "./string";
|
export {StringData} from "./string.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
||||||
|
|
||||||
export class Null implements ExpressionDataInterface {
|
export class Null implements ExpressionDataInterface {
|
||||||
public readonly kind = Kind.Null;
|
public readonly kind = Kind.Null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {NumberData} from "./number";
|
import {NumberData} from "./number.js";
|
||||||
|
|
||||||
describe("number", () => {
|
describe("number", () => {
|
||||||
it("coerces to string", () => {
|
it("coerces to string", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
||||||
|
|
||||||
export class NumberData implements ExpressionDataInterface {
|
export class NumberData implements ExpressionDataInterface {
|
||||||
constructor(public readonly value: number) {}
|
constructor(public readonly value: number) {}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {Array} from "./array";
|
import {Array} from "./array.js";
|
||||||
import {Dictionary} from "./dictionary";
|
import {Dictionary} from "./dictionary.js";
|
||||||
import {Null} from "./null";
|
import {Null} from "./null.js";
|
||||||
import {NumberData} from "./number";
|
import {NumberData} from "./number.js";
|
||||||
import {replacer} from "./replacer";
|
import {replacer} from "./replacer.js";
|
||||||
import {StringData} from "./string";
|
import {StringData} from "./string.js";
|
||||||
|
|
||||||
describe("replacer", () => {
|
describe("replacer", () => {
|
||||||
it("null", () => {
|
it("null", () => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {Array} from "./array";
|
import {Array} from "./array.js";
|
||||||
import {BooleanData} from "./boolean";
|
import {BooleanData} from "./boolean.js";
|
||||||
import {Dictionary} from "./dictionary";
|
import {Dictionary} from "./dictionary.js";
|
||||||
import {Null} from "./null";
|
import {Null} from "./null.js";
|
||||||
import {NumberData} from "./number";
|
import {NumberData} from "./number.js";
|
||||||
import {StringData} from "./string";
|
import {StringData} from "./string.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON
|
* Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {Array} from "./array";
|
import {Array} from "./array.js";
|
||||||
import {BooleanData} from "./boolean";
|
import {BooleanData} from "./boolean.js";
|
||||||
import {Dictionary} from "./dictionary";
|
import {Dictionary} from "./dictionary.js";
|
||||||
import {ExpressionData} from "./expressiondata";
|
import {ExpressionData} from "./expressiondata.js";
|
||||||
import {Null} from "./null";
|
import {Null} from "./null.js";
|
||||||
import {NumberData} from "./number";
|
import {NumberData} from "./number.js";
|
||||||
import {reviver} from "./reviver";
|
import {reviver} from "./reviver.js";
|
||||||
import {StringData} from "./string";
|
import {StringData} from "./string.js";
|
||||||
|
|
||||||
describe("reviver", () => {
|
describe("reviver", () => {
|
||||||
const tests: {
|
const tests: {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {Array as dArray} from "./array";
|
import {Array as dArray} from "./array.js";
|
||||||
import {BooleanData} from "./boolean";
|
import {BooleanData} from "./boolean.js";
|
||||||
import {Dictionary} from "./dictionary";
|
import {Dictionary} from "./dictionary.js";
|
||||||
import {ExpressionData} from "./expressiondata";
|
import {ExpressionData} from "./expressiondata.js";
|
||||||
import {Null} from "./null";
|
import {Null} from "./null.js";
|
||||||
import {NumberData} from "./number";
|
import {NumberData} from "./number.js";
|
||||||
import {StringData} from "./string";
|
import {StringData} from "./string.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object.
|
* Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
||||||
|
|
||||||
export class StringData implements ExpressionDataInterface {
|
export class StringData implements ExpressionDataInterface {
|
||||||
constructor(public readonly value: string) {}
|
constructor(public readonly value: string) {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Pos, Token, tokenString} from "./lexer";
|
import {Pos, Token, tokenString} from "./lexer.js";
|
||||||
|
|
||||||
export const MAX_PARSER_DEPTH = 50;
|
export const MAX_PARSER_DEPTH = 50;
|
||||||
export const MAX_EXPRESSION_LENGTH = 21000;
|
export const MAX_EXPRESSION_LENGTH = 21000;
|
||||||
@@ -12,6 +12,7 @@ export enum ErrorType {
|
|||||||
ErrorExceededMaxLength,
|
ErrorExceededMaxLength,
|
||||||
ErrorTooFewParameters,
|
ErrorTooFewParameters,
|
||||||
ErrorTooManyParameters,
|
ErrorTooManyParameters,
|
||||||
|
ErrorEvenParameters,
|
||||||
ErrorUnrecognizedContext,
|
ErrorUnrecognizedContext,
|
||||||
ErrorUnrecognizedFunction
|
ErrorUnrecognizedFunction
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,8 @@ function errorDescription(typ: ErrorType): string {
|
|||||||
return "Too few parameters supplied";
|
return "Too few parameters supplied";
|
||||||
case ErrorType.ErrorTooManyParameters:
|
case ErrorType.ErrorTooManyParameters:
|
||||||
return "Too many parameters supplied";
|
return "Too many parameters supplied";
|
||||||
|
case ErrorType.ErrorEvenParameters:
|
||||||
|
return "Even number of parameters supplied, requires an odd number of parameters";
|
||||||
case ErrorType.ErrorUnrecognizedContext:
|
case ErrorType.ErrorUnrecognizedContext:
|
||||||
return "Unrecognized named-value";
|
return "Unrecognized named-value";
|
||||||
case ErrorType.ErrorUnrecognizedFunction:
|
case ErrorType.ErrorUnrecognizedFunction:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as data from "./data";
|
import * as data from "./data/index.js";
|
||||||
import {ExpressionEvaluationError} from "./errors";
|
import {ExpressionEvaluationError} from "./errors.js";
|
||||||
import {Evaluator} from "./evaluator";
|
import {Evaluator} from "./evaluator.js";
|
||||||
import {Lexer} from "./lexer";
|
import {Lexer} from "./lexer.js";
|
||||||
import {Parser} from "./parser";
|
import {Parser} from "./parser.js";
|
||||||
|
|
||||||
describe("evaluator", () => {
|
describe("evaluator", () => {
|
||||||
const lexAndParse = (input: string) => {
|
const lexAndParse = (input: string) => {
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import {
|
|||||||
Logical,
|
Logical,
|
||||||
Star,
|
Star,
|
||||||
Unary
|
Unary
|
||||||
} from "./ast";
|
} from "./ast.js";
|
||||||
import * as data from "./data";
|
import * as data from "./data/index.js";
|
||||||
import {FilteredArray} from "./filtered_array";
|
import {FilteredArray} from "./filtered_array.js";
|
||||||
import {wellKnownFunctions} from "./funcs";
|
import {wellKnownFunctions} from "./funcs.js";
|
||||||
import {FunctionDefinition} from "./funcs/info";
|
import {FunctionDefinition} from "./funcs/info.js";
|
||||||
import {idxHelper} from "./idxHelper";
|
import {idxHelper} from "./idxHelper.js";
|
||||||
import {TokenType} from "./lexer";
|
import {TokenType} from "./lexer.js";
|
||||||
import {equals, falsy, greaterThan, lessThan, truthy} from "./result";
|
import {equals, falsy, greaterThan, lessThan, truthy} from "./result.js";
|
||||||
|
|
||||||
export class Evaluator implements ExprVisitor<data.ExpressionData> {
|
export class Evaluator implements ExprVisitor<data.ExpressionData> {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {FeatureFlags} from "./features.js";
|
||||||
|
|
||||||
|
describe("FeatureFlags", () => {
|
||||||
|
describe("isEnabled", () => {
|
||||||
|
it("returns false by default when no options provided", () => {
|
||||||
|
const flags = new FeatureFlags();
|
||||||
|
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false by default when empty options provided", () => {
|
||||||
|
const flags = new FeatureFlags({});
|
||||||
|
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when feature is explicitly enabled", () => {
|
||||||
|
const flags = new FeatureFlags({missingInputsQuickfix: true});
|
||||||
|
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when feature is explicitly disabled", () => {
|
||||||
|
const flags = new FeatureFlags({missingInputsQuickfix: false});
|
||||||
|
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when all is enabled", () => {
|
||||||
|
const flags = new FeatureFlags({all: true});
|
||||||
|
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
||||||
|
expect(flags.isEnabled("allowConcurrencyQueue")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit feature flag takes precedence over all:true", () => {
|
||||||
|
const flags = new FeatureFlags({all: true, missingInputsQuickfix: false});
|
||||||
|
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit feature flag takes precedence over all:false", () => {
|
||||||
|
const flags = new FeatureFlags({all: false, missingInputsQuickfix: true});
|
||||||
|
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getEnabledFeatures", () => {
|
||||||
|
it("returns empty array when no features enabled", () => {
|
||||||
|
const flags = new FeatureFlags();
|
||||||
|
expect(flags.getEnabledFeatures()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns enabled features", () => {
|
||||||
|
const flags = new FeatureFlags({missingInputsQuickfix: true});
|
||||||
|
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all features when all is enabled", () => {
|
||||||
|
const flags = new FeatureFlags({all: true});
|
||||||
|
expect(flags.getEnabledFeatures()).toEqual([
|
||||||
|
"missingInputsQuickfix",
|
||||||
|
"blockScalarChompingWarning",
|
||||||
|
"allowCaseFunction",
|
||||||
|
"allowCopilotRequestsPermission",
|
||||||
|
"allowConcurrencyQueue"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Experimental feature flags.
|
||||||
|
*
|
||||||
|
* Individual feature flags take precedence over `all`.
|
||||||
|
* Example: { all: true, missingInputsQuickfix: false } enables all
|
||||||
|
* experimental features EXCEPT missingInputsQuickfix.
|
||||||
|
*
|
||||||
|
* When a feature graduates to stable, its flag becomes a no-op
|
||||||
|
* (the feature will be enabled regardless of the configuration value).
|
||||||
|
*/
|
||||||
|
export interface ExperimentalFeatures {
|
||||||
|
/**
|
||||||
|
* Enable all experimental features.
|
||||||
|
* Individual feature flags take precedence over this setting.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
all?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable quickfix code action for missing required action inputs.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
missingInputsQuickfix?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warn when block scalars (| or >) use implicit clip chomping,
|
||||||
|
* which adds a trailing newline that may be unintentional.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
blockScalarChompingWarning?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable the case() function in expressions.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
allowCaseFunction?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable the copilot-requests permission in workflow permissions.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
allowCopilotRequestsPermission?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable the queue property in workflow concurrency settings.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
allowConcurrencyQueue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys of ExperimentalFeatures that represent actual features (excludes 'all')
|
||||||
|
*/
|
||||||
|
export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All known experimental feature keys.
|
||||||
|
* This list must be kept in sync with the ExperimentalFeatures interface.
|
||||||
|
*/
|
||||||
|
const allFeatureKeys: ExperimentalFeatureKey[] = [
|
||||||
|
"missingInputsQuickfix",
|
||||||
|
"blockScalarChompingWarning",
|
||||||
|
"allowCaseFunction",
|
||||||
|
"allowCopilotRequestsPermission",
|
||||||
|
"allowConcurrencyQueue"
|
||||||
|
];
|
||||||
|
|
||||||
|
export class FeatureFlags {
|
||||||
|
private readonly features: ExperimentalFeatures;
|
||||||
|
|
||||||
|
constructor(features?: ExperimentalFeatures) {
|
||||||
|
this.features = features ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an experimental feature is enabled.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. Explicit feature flag (if set)
|
||||||
|
* 2. `all` flag (if set)
|
||||||
|
* 3. false (default)
|
||||||
|
*/
|
||||||
|
isEnabled(feature: ExperimentalFeatureKey): boolean {
|
||||||
|
const explicit = this.features[feature];
|
||||||
|
if (explicit !== undefined) {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
return this.features.all ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns list of all enabled experimental features.
|
||||||
|
*/
|
||||||
|
getEnabledFeatures(): ExperimentalFeatureKey[] {
|
||||||
|
return allFeatureKeys.filter(key => this.isEnabled(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import * as data from "./data";
|
import * as data from "./data/index.js";
|
||||||
|
|
||||||
export class FilteredArray extends data.Array {}
|
export class FilteredArray extends data.Array {}
|
||||||
|
|||||||
+17
-10
@@ -1,13 +1,14 @@
|
|||||||
import {ErrorType, ExpressionError} from "./errors";
|
import {ErrorType, ExpressionError} from "./errors.js";
|
||||||
import {contains} from "./funcs/contains";
|
import {caseFunc} from "./funcs/case.js";
|
||||||
import {endswith} from "./funcs/endswith";
|
import {contains} from "./funcs/contains.js";
|
||||||
import {format} from "./funcs/format";
|
import {endswith} from "./funcs/endswith.js";
|
||||||
import {fromjson} from "./funcs/fromjson";
|
import {format} from "./funcs/format.js";
|
||||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
|
import {fromjson} from "./funcs/fromjson.js";
|
||||||
import {join} from "./funcs/join";
|
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
||||||
import {startswith} from "./funcs/startswith";
|
import {join} from "./funcs/join.js";
|
||||||
import {tojson} from "./funcs/tojson";
|
import {startswith} from "./funcs/startswith.js";
|
||||||
import {Token} from "./lexer";
|
import {tojson} from "./funcs/tojson.js";
|
||||||
|
import {Token} from "./lexer.js";
|
||||||
|
|
||||||
export type ParseContext = {
|
export type ParseContext = {
|
||||||
allowUnknownKeywords: boolean;
|
allowUnknownKeywords: boolean;
|
||||||
@@ -16,6 +17,7 @@ export type ParseContext = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const wellKnownFunctions: {[name: string]: FunctionDefinition} = {
|
export const wellKnownFunctions: {[name: string]: FunctionDefinition} = {
|
||||||
|
case: caseFunc,
|
||||||
contains: contains,
|
contains: contains,
|
||||||
endswith: endswith,
|
endswith: endswith,
|
||||||
format: format,
|
format: format,
|
||||||
@@ -53,4 +55,9 @@ export function validateFunction(context: ParseContext, identifier: Token, argCo
|
|||||||
if (argCount > f.maxArgs) {
|
if (argCount > f.maxArgs) {
|
||||||
throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier);
|
throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// case function requires an odd number of arguments
|
||||||
|
if (name === "case" && argCount % 2 === 0) {
|
||||||
|
throw new ExpressionError(ErrorType.ErrorEvenParameters, identifier);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {ExpressionData, Kind} from "../data/index.js";
|
||||||
|
import {FunctionDefinition} from "./info.js";
|
||||||
|
|
||||||
|
export const caseFunc: FunctionDefinition = {
|
||||||
|
name: "case",
|
||||||
|
description:
|
||||||
|
"`case( pred1, val1, pred2, val2, ..., default )`\n\nEvaluates predicates in order and returns the value corresponding to the first predicate that evaluates to `true`. If no predicate matches, it returns the last argument as the default value.",
|
||||||
|
minArgs: 3,
|
||||||
|
maxArgs: Number.MAX_SAFE_INTEGER,
|
||||||
|
call: (...args: ExpressionData[]): ExpressionData => {
|
||||||
|
// Evaluate predicate-result pairs
|
||||||
|
for (let i = 0; i < args.length - 1; i += 2) {
|
||||||
|
const predicate = args[i];
|
||||||
|
|
||||||
|
// Predicate must be a boolean
|
||||||
|
if (predicate.kind !== Kind.Boolean) {
|
||||||
|
throw new Error("case predicate must evaluate to a boolean value");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If predicate is true, return the corresponding result
|
||||||
|
if (predicate.value) {
|
||||||
|
return args[i + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No predicate matched, return default (last argument)
|
||||||
|
return args[args.length - 1];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {BooleanData, ExpressionData, Kind} from "../data";
|
import {BooleanData, ExpressionData, Kind} from "../data/index.js";
|
||||||
import {equals} from "../result";
|
import {equals} from "../result.js";
|
||||||
import {FunctionDefinition} from "./info";
|
import {FunctionDefinition} from "./info.js";
|
||||||
|
|
||||||
export const contains: FunctionDefinition = {
|
export const contains: FunctionDefinition = {
|
||||||
name: "contains",
|
name: "contains",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {BooleanData, ExpressionData} from "../data";
|
import {BooleanData, ExpressionData} from "../data/index.js";
|
||||||
import {toUpperSpecial} from "../result";
|
import {toUpperSpecial} from "../result.js";
|
||||||
import {FunctionDefinition} from "./info";
|
import {FunctionDefinition} from "./info.js";
|
||||||
|
|
||||||
export const endswith: FunctionDefinition = {
|
export const endswith: FunctionDefinition = {
|
||||||
name: "endsWith",
|
name: "endsWith",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Null, NumberData, StringData} from "../data";
|
import {Null, NumberData, StringData} from "../data/index.js";
|
||||||
import {format} from "./format";
|
import {format} from "./format.js";
|
||||||
|
|
||||||
describe("format", () => {
|
describe("format", () => {
|
||||||
it("null", () => {
|
it("null", () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {ExpressionData, StringData} from "../data";
|
import {ExpressionData, StringData} from "../data/index.js";
|
||||||
import {FunctionDefinition} from "./info";
|
import {FunctionDefinition} from "./info.js";
|
||||||
|
|
||||||
export const format: FunctionDefinition = {
|
export const format: FunctionDefinition = {
|
||||||
name: "format",
|
name: "format",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {ExpressionData} from "../data";
|
import {ExpressionData} from "../data/index.js";
|
||||||
import {reviver} from "../data/reviver";
|
import {reviver} from "../data/reviver.js";
|
||||||
import {ExpressionEvaluationError} from "../errors";
|
import {ExpressionEvaluationError} from "../errors.js";
|
||||||
import {FunctionDefinition} from "./info";
|
import {FunctionDefinition} from "./info.js";
|
||||||
|
|
||||||
export const fromjson: FunctionDefinition = {
|
export const fromjson: FunctionDefinition = {
|
||||||
name: "fromJson",
|
name: "fromJson",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionData} from "../data";
|
import {ExpressionData} from "../data/index.js";
|
||||||
|
|
||||||
export interface FunctionInfo {
|
export interface FunctionInfo {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {ExpressionData, Kind, StringData} from "../data";
|
import {ExpressionData, Kind, StringData} from "../data/index.js";
|
||||||
import {FunctionDefinition} from "./info";
|
import {FunctionDefinition} from "./info.js";
|
||||||
|
|
||||||
export const join: FunctionDefinition = {
|
export const join: FunctionDefinition = {
|
||||||
name: "join",
|
name: "join",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {BooleanData, ExpressionData} from "../data";
|
import {BooleanData, ExpressionData} from "../data/index.js";
|
||||||
import {toUpperSpecial} from "../result";
|
import {toUpperSpecial} from "../result.js";
|
||||||
import {FunctionDefinition} from "./info";
|
import {FunctionDefinition} from "./info.js";
|
||||||
|
|
||||||
export const startswith: FunctionDefinition = {
|
export const startswith: FunctionDefinition = {
|
||||||
name: "startsWith",
|
name: "startsWith",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {ExpressionData, StringData} from "../data";
|
import {ExpressionData, StringData} from "../data/index.js";
|
||||||
import {replacer} from "../data/replacer";
|
import {replacer} from "../data/replacer.js";
|
||||||
import {FunctionDefinition} from "./info";
|
import {FunctionDefinition} from "./info.js";
|
||||||
|
|
||||||
export const tojson: FunctionDefinition = {
|
export const tojson: FunctionDefinition = {
|
||||||
name: "toJson",
|
name: "toJson",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionData} from "./data";
|
import {ExpressionData} from "./data/index.js";
|
||||||
|
|
||||||
export class idxHelper {
|
export class idxHelper {
|
||||||
public readonly str: string | undefined;
|
public readonly str: string | undefined;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
export {Expr} from "./ast";
|
export {Expr} from "./ast.js";
|
||||||
export {complete, CompletionItem} from "./completion";
|
export {complete, CompletionItem} from "./completion.js";
|
||||||
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary";
|
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary.js";
|
||||||
export * as data from "./data";
|
export * as data from "./data/index.js";
|
||||||
export {ExpressionError, ExpressionEvaluationError} from "./errors";
|
export {ExpressionError, ExpressionEvaluationError} from "./errors.js";
|
||||||
export {Evaluator} from "./evaluator";
|
export {Evaluator} from "./evaluator.js";
|
||||||
export {wellKnownFunctions} from "./funcs";
|
export {ExperimentalFeatureKey, ExperimentalFeatures, FeatureFlags} from "./features.js";
|
||||||
export {Lexer, Result} from "./lexer";
|
export {wellKnownFunctions} from "./funcs.js";
|
||||||
export {Parser} from "./parser";
|
export {Lexer, Result} from "./lexer.js";
|
||||||
|
export {Parser} from "./parser.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Lexer, Token, TokenType} from "./lexer";
|
import {Lexer, Token, TokenType} from "./lexer.js";
|
||||||
|
|
||||||
describe("lexer", () => {
|
describe("lexer", () => {
|
||||||
const tests: {
|
const tests: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {StringData} from "./data";
|
import {StringData} from "./data/index.js";
|
||||||
import {MAX_EXPRESSION_LENGTH} from "./errors";
|
import {MAX_EXPRESSION_LENGTH} from "./errors.js";
|
||||||
|
|
||||||
export enum TokenType {
|
export enum TokenType {
|
||||||
UNKNOWN,
|
UNKNOWN,
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import {Binary, ContextAccess, Expr, FunctionCall, Grouping, IndexAccess, Literal, Logical, Star, Unary} from "./ast";
|
import {
|
||||||
import * as data from "./data";
|
Binary,
|
||||||
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors";
|
ContextAccess,
|
||||||
import {ParseContext, validateFunction} from "./funcs";
|
Expr,
|
||||||
import {FunctionInfo} from "./funcs/info";
|
FunctionCall,
|
||||||
import {Token, TokenType} from "./lexer";
|
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";
|
||||||
|
|
||||||
export class Parser {
|
export class Parser {
|
||||||
private extContexts: Map<string, boolean>;
|
private extContexts: Map<string, boolean>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {BooleanData, ExpressionData, NumberData, StringData} from "./data";
|
import {BooleanData, ExpressionData, NumberData, StringData} from "./data/index.js";
|
||||||
import {coerceTypes, toUpperSpecial} from "./result";
|
import {coerceTypes, toUpperSpecial} from "./result.js";
|
||||||
|
|
||||||
describe("coerceTypes", () => {
|
describe("coerceTypes", () => {
|
||||||
const tests: {
|
const tests: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as data from "./data";
|
import * as data from "./data/index.js";
|
||||||
|
|
||||||
export function falsy(d: data.ExpressionData): boolean {
|
export function falsy(d: data.ExpressionData): boolean {
|
||||||
switch (d.kind) {
|
switch (d.kind) {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import {Expr} from "./ast";
|
import {Expr} from "./ast.js";
|
||||||
import * as data from "./data";
|
import * as data from "./data/index.js";
|
||||||
import {kindStr} from "./data/expressiondata";
|
import {kindStr} from "./data/expressiondata.js";
|
||||||
import {replacer} from "./data/replacer";
|
import {replacer} from "./data/replacer.js";
|
||||||
import {reviver} from "./data/reviver";
|
import {reviver} from "./data/reviver.js";
|
||||||
import {ExpressionError} from "./errors";
|
import {ExpressionError} from "./errors.js";
|
||||||
import {Evaluator} from "./evaluator";
|
import {Evaluator} from "./evaluator.js";
|
||||||
import {Lexer, Result} from "./lexer";
|
import {Lexer, Result} from "./lexer.js";
|
||||||
import {Parser} from "./parser";
|
import {Parser} from "./parser.js";
|
||||||
|
|
||||||
interface TestResult {
|
interface TestResult {
|
||||||
value: data.ExpressionData;
|
value: data.ExpressionData;
|
||||||
|
|||||||
Vendored
+157
@@ -0,0 +1,157 @@
|
|||||||
|
{
|
||||||
|
"case": [
|
||||||
|
{
|
||||||
|
"expr": "case(true, 'first', 'default')",
|
||||||
|
"result": { "kind": "String", "value": "first" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(false, 'first', 'default')",
|
||||||
|
"result": { "kind": "String", "value": "default" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(true, 'first', false, 'second', 'default')",
|
||||||
|
"result": { "kind": "String", "value": "first" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(false, 'first', true, 'second', 'default')",
|
||||||
|
"result": { "kind": "String", "value": "second" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(false, 'first', false, 'second', 'default')",
|
||||||
|
"result": { "kind": "String", "value": "default" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(1 == 1, 'equal', 'not equal')",
|
||||||
|
"result": { "kind": "String", "value": "equal" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(1 == 2, 'equal', 'not equal')",
|
||||||
|
"result": { "kind": "String", "value": "not equal" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
|
||||||
|
"contexts": {
|
||||||
|
"github": {
|
||||||
|
"ref": "refs/heads/main",
|
||||||
|
"event_name": "push"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"result": { "kind": "String", "value": "main" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
|
||||||
|
"contexts": {
|
||||||
|
"github": {
|
||||||
|
"ref": "refs/heads/develop",
|
||||||
|
"event_name": "pull_request"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"result": { "kind": "String", "value": "pr" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
|
||||||
|
"contexts": {
|
||||||
|
"github": {
|
||||||
|
"ref": "refs/heads/develop",
|
||||||
|
"event_name": "push"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"result": { "kind": "String", "value": "other" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(true, 123, 456)",
|
||||||
|
"result": { "kind": "Number", "value": 123 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(false, 123, 456)",
|
||||||
|
"result": { "kind": "Number", "value": 456 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(github.event == 'pull_request', 0, 1)",
|
||||||
|
"contexts": {
|
||||||
|
"github": {
|
||||||
|
"event": "pull_request"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"result": { "kind": "Number", "value": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(false, 0, 1)",
|
||||||
|
"result": { "kind": "Number", "value": 1 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(true, false, true)",
|
||||||
|
"result": { "kind": "Boolean", "value": false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(false, false, true)",
|
||||||
|
"result": { "kind": "Boolean", "value": true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(true, '', 'default')",
|
||||||
|
"result": { "kind": "String", "value": "" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(false, 'first', '')",
|
||||||
|
"result": { "kind": "String", "value": "" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(true, fromJSON('[1,2,3]'), 'default')",
|
||||||
|
"result": { "kind": "Array", "value": [1, 2, 3] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(true, fromJSON('{\"key\":\"value\"}'), 'default')",
|
||||||
|
"result": { "kind": "Object", "value": { "key": "value" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(false, 'first', false, 'second', false, 'third', false, 'fourth', 'default')",
|
||||||
|
"result": { "kind": "String", "value": "default" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(false, 'first', false, 'second', true, 'third', false, 'fourth', 'default')",
|
||||||
|
"result": { "kind": "String", "value": "third" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case('not a boolean', 'first', 'default')",
|
||||||
|
"err": {
|
||||||
|
"kind": "evaluation",
|
||||||
|
"value": "case predicate must evaluate to a boolean value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(1, 'first', 'default')",
|
||||||
|
"err": {
|
||||||
|
"kind": "evaluation",
|
||||||
|
"value": "case predicate must evaluate to a boolean value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(null, 'first', 'default')",
|
||||||
|
"err": {
|
||||||
|
"kind": "evaluation",
|
||||||
|
"value": "case predicate must evaluate to a boolean value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(fromJSON('[]'), 'first', 'default')",
|
||||||
|
"err": {
|
||||||
|
"kind": "evaluation",
|
||||||
|
"value": "case predicate must evaluate to a boolean value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(fromJSON('{}'), 'first', 'default')",
|
||||||
|
"err": {
|
||||||
|
"kind": "evaluation",
|
||||||
|
"value": "case predicate must evaluate to a boolean value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "case(true, 'first', false, 'second')",
|
||||||
|
"err": {
|
||||||
|
"kind": "parsing",
|
||||||
|
"value": "Even number of parameters supplied, requires an odd number of parameters: 'case'. Located at position 1 within expression: case(true, 'first', false, 'second')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
"exclude": ["./src/**/*.test.ts"],
|
"exclude": ["./src/**/*.test.ts"],
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"module": "node16",
|
||||||
|
"moduleResolution": "node16",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"outDir": "./dist"
|
"outDir": "./dist",
|
||||||
|
"skipLibCheck": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ The [package](https://www.npmjs.com/package/@actions/languageserver) contains Ty
|
|||||||
npm install @actions/languageserver
|
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
|
## Usage
|
||||||
|
|
||||||
### Basic usage using `vscode-languageserver-node`
|
### Basic usage using `vscode-languageserver-node`
|
||||||
@@ -76,6 +84,11 @@ export interface InitializationOptions {
|
|||||||
* Desired log level
|
* Desired log level
|
||||||
*/
|
*/
|
||||||
logLevel?: LogLevel;
|
logLevel?: LogLevel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Experimental features that are opt-in
|
||||||
|
*/
|
||||||
|
experimentalFeatures?: ExperimentalFeatures;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -92,6 +105,178 @@ const clientOptions: LanguageClientOptions = {
|
|||||||
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
|
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Experimental Features
|
||||||
|
|
||||||
|
The language server supports opt-in experimental features via the `experimentalFeatures` initialization option. These features may change or be removed in between releases.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
initializationOptions: {
|
||||||
|
experimentalFeatures: {
|
||||||
|
// Enable all experimental features
|
||||||
|
all: true,
|
||||||
|
|
||||||
|
// Or enable specific features
|
||||||
|
missingInputsQuickfix: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available experimental features:**
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
|
||||||
|
| `blockScalarChompingWarning` | Warn when block scalars (`\|` or `>`) use implicit clip chomping, which adds a trailing newline that may be unintentional |
|
||||||
|
| `allowConcurrencyQueue` | Enable the `concurrency.queue` workflow property |
|
||||||
|
|
||||||
|
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
|
||||||
|
|
||||||
|
When a feature graduates to stable, its flag becomes a no-op and the feature will be enabled regardless of the configuration value.
|
||||||
|
|
||||||
|
### Standalone CLI
|
||||||
|
|
||||||
|
After installing globally, you can run the language server directly:
|
||||||
|
|
||||||
|
```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
|
## Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations.
|
See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations.
|
||||||
@@ -110,6 +295,27 @@ or to watch for changes
|
|||||||
npm run watch
|
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
|
### Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import "../dist/cli.bundle.cjs";
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageserver",
|
"name": "@actions/languageserver",
|
||||||
"version": "0.3.25",
|
"version": "0.3.54",
|
||||||
"description": "Language server for GitHub Actions",
|
"description": "Language server for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -31,20 +31,25 @@
|
|||||||
"url": "https://github.com/actions/languageservices"
|
"url": "https://github.com/actions/languageservices"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build tsconfig.build.json",
|
"build": "tsc --build tsconfig.build.json && npm run build:cli",
|
||||||
|
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
|
||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"format": "prettier --write '**/*.ts'",
|
"format": "prettier --write '**/*.ts'",
|
||||||
"format-check": "prettier --check '**/*.ts'",
|
"format-check": "prettier --check '**/*.ts'",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
||||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||||
"prepublishOnly": "npm run build && npm run test",
|
"prepublishOnly": "npm run build && npm run test",
|
||||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch",
|
||||||
|
"watch:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs --watch"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"actions-languageserver": "./bin/actions-languageserver"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/languageservice": "^0.3.25",
|
"@actions/languageservice": "^0.3.54",
|
||||||
"@actions/workflow-parser": "^0.3.25",
|
"@actions/workflow-parser": "^0.3.54",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
"@octokit/types": "^9.0.0",
|
"@octokit/types": "^9.0.0",
|
||||||
"vscode-languageserver": "^8.0.2",
|
"vscode-languageserver": "^8.0.2",
|
||||||
@@ -52,23 +57,26 @@
|
|||||||
"yaml": "^2.1.3"
|
"yaml": "^2.1.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 20"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*",
|
||||||
|
"bin/**/*"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.0.3",
|
"@types/jest": "^29.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.56.0",
|
"@typescript-eslint/eslint-plugin": "^5.56.0",
|
||||||
"@typescript-eslint/parser": "^5.56.0",
|
"@typescript-eslint/parser": "^5.56.0",
|
||||||
|
"esbuild": "^0.27.1",
|
||||||
"eslint": "^8.36.0",
|
"eslint": "^8.36.0",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"fetch-mock": "^9.11.0",
|
"fetch-mock": "^9.11.0",
|
||||||
"jest": "^29.0.3",
|
"jest": "^29.0.3",
|
||||||
|
"node-fetch": "^2.6.7",
|
||||||
"prettier": "^2.8.3",
|
"prettier": "^2.8.3",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"ts-jest": "^29.0.3",
|
"ts-jest": "^29.0.3",
|
||||||
"typescript": "^4.8.4"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
|
import {
|
||||||
|
documentLinks,
|
||||||
|
getCodeActions,
|
||||||
|
getInlayHints,
|
||||||
|
hover,
|
||||||
|
validate,
|
||||||
|
ValidationConfig
|
||||||
|
} from "@actions/languageservice";
|
||||||
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
||||||
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {
|
import {
|
||||||
|
CodeAction,
|
||||||
|
CodeActionKind,
|
||||||
|
CodeActionParams,
|
||||||
CompletionItem,
|
CompletionItem,
|
||||||
Connection,
|
Connection,
|
||||||
DocumentLink,
|
DocumentLink,
|
||||||
@@ -12,24 +22,27 @@ import {
|
|||||||
HoverParams,
|
HoverParams,
|
||||||
InitializeParams,
|
InitializeParams,
|
||||||
InitializeResult,
|
InitializeResult,
|
||||||
|
InlayHint,
|
||||||
|
InlayHintParams,
|
||||||
TextDocumentIdentifier,
|
TextDocumentIdentifier,
|
||||||
TextDocumentPositionParams,
|
TextDocumentPositionParams,
|
||||||
TextDocuments,
|
TextDocuments,
|
||||||
TextDocumentSyncKind
|
TextDocumentSyncKind
|
||||||
} from "vscode-languageserver";
|
} from "vscode-languageserver";
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {getClient} from "./client";
|
import {getClient} from "./client.js";
|
||||||
import {Commands} from "./commands";
|
import {Commands} from "./commands.js";
|
||||||
import {contextProviders} from "./context-providers";
|
import {contextProviders} from "./context-providers.js";
|
||||||
import {descriptionProvider} from "./description-provider";
|
import {descriptionProvider} from "./description-provider.js";
|
||||||
import {getFileProvider} from "./file-provider";
|
import {FeatureFlags} from "@actions/expressions";
|
||||||
import {InitializationOptions, RepositoryContext} from "./initializationOptions";
|
import {getFileProvider} from "./file-provider.js";
|
||||||
import {onCompletion} from "./on-completion";
|
import {InitializationOptions, RepositoryContext} from "./initializationOptions.js";
|
||||||
import {ReadFileRequest, Requests} from "./request";
|
import {onCompletion} from "./on-completion.js";
|
||||||
import {getActionsMetadataProvider} from "./utils/action-metadata";
|
import {ReadFileRequest, Requests} from "./request.js";
|
||||||
import {TTLCache} from "./utils/cache";
|
import {getActionsMetadataProvider} from "./utils/action-metadata.js";
|
||||||
import {timeOperation} from "./utils/timer";
|
import {TTLCache} from "./utils/cache.js";
|
||||||
import {valueProviders} from "./value-providers";
|
import {timeOperation} from "./utils/timer.js";
|
||||||
|
import {valueProviders} from "./value-providers.js";
|
||||||
|
|
||||||
export function initConnection(connection: Connection) {
|
export function initConnection(connection: Connection) {
|
||||||
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
|
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
|
||||||
@@ -39,6 +52,7 @@ export function initConnection(connection: Connection) {
|
|||||||
const cache = new TTLCache();
|
const cache = new TTLCache();
|
||||||
|
|
||||||
let hasWorkspaceFolderCapability = false;
|
let hasWorkspaceFolderCapability = false;
|
||||||
|
let featureFlags = new FeatureFlags();
|
||||||
|
|
||||||
// Register remote console logger with language service
|
// Register remote console logger with language service
|
||||||
registerLogger(connection.console);
|
registerLogger(connection.console);
|
||||||
@@ -62,6 +76,8 @@ export function initConnection(connection: Connection) {
|
|||||||
setLogLevel(options.logLevel);
|
setLogLevel(options.logLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
featureFlags = new FeatureFlags(options.experimentalFeatures);
|
||||||
|
|
||||||
const result: InitializeResult = {
|
const result: InitializeResult = {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
textDocumentSync: TextDocumentSyncKind.Full,
|
textDocumentSync: TextDocumentSyncKind.Full,
|
||||||
@@ -72,6 +88,10 @@ export function initConnection(connection: Connection) {
|
|||||||
hoverProvider: true,
|
hoverProvider: true,
|
||||||
documentLinkProvider: {
|
documentLinkProvider: {
|
||||||
resolveProvider: false
|
resolveProvider: false
|
||||||
|
},
|
||||||
|
inlayHintProvider: true,
|
||||||
|
codeActionProvider: {
|
||||||
|
codeActionKinds: [CodeActionKind.QuickFix]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -88,6 +108,11 @@ export function initConnection(connection: Connection) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
connection.onInitialized(() => {
|
connection.onInitialized(() => {
|
||||||
|
const enabledFeatures = featureFlags.getEnabledFeatures();
|
||||||
|
if (enabledFeatures.length > 0) {
|
||||||
|
connection.console.info(`Experimental features enabled: ${enabledFeatures.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (hasWorkspaceFolderCapability) {
|
if (hasWorkspaceFolderCapability) {
|
||||||
connection.workspace.onDidChangeWorkspaceFolders(() => {
|
connection.workspace.onDidChangeWorkspaceFolders(() => {
|
||||||
clearCache();
|
clearCache();
|
||||||
@@ -111,7 +136,8 @@ export function initConnection(connection: Connection) {
|
|||||||
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
|
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
|
||||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||||
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
|
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
|
||||||
})
|
}),
|
||||||
|
featureFlags
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validate(textDocument, config);
|
const result = await validate(textDocument, config);
|
||||||
@@ -128,7 +154,8 @@ export function initConnection(connection: Connection) {
|
|||||||
getDocument(documents, textDocument),
|
getDocument(documents, textDocument),
|
||||||
client,
|
client,
|
||||||
repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)),
|
repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)),
|
||||||
cache
|
cache,
|
||||||
|
featureFlags
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -158,6 +185,23 @@ export function initConnection(connection: Connection) {
|
|||||||
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
|
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
connection.languages.inlayHint.on(async ({textDocument}: InlayHintParams): Promise<InlayHint[] | null> => {
|
||||||
|
return timeOperation("inlayHints", () => {
|
||||||
|
return getInlayHints(getDocument(documents, textDocument));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
|
||||||
|
const document = getDocument(documents, params.textDocument);
|
||||||
|
return getCodeActions({
|
||||||
|
uri: params.textDocument.uri,
|
||||||
|
documentContent: document.getText(),
|
||||||
|
diagnostics: params.context.diagnostics,
|
||||||
|
only: params.context.only,
|
||||||
|
featureFlags
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Make the text document manager listen on the connection
|
// Make the text document manager listen on the connection
|
||||||
// for open, change and close text document events
|
// for open, change and close text document events
|
||||||
documents.listen(connection);
|
documents.listen(connection);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||||
import {Mode} from "@actions/languageservice/context-providers/default";
|
import {Mode} from "@actions/languageservice/context-providers/default";
|
||||||
import {contextProviders} from "./context-providers";
|
import {contextProviders} from "./context-providers.js";
|
||||||
import {RepositoryContext} from "./initializationOptions";
|
import {RepositoryContext} from "./initializationOptions.js";
|
||||||
import {TTLCache} from "./utils/cache";
|
import {TTLCache} from "./utils/cache.js";
|
||||||
|
|
||||||
describe("contextProviders", () => {
|
describe("contextProviders", () => {
|
||||||
const mockCache = new TTLCache();
|
const mockCache = new TTLCache();
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import {ContextProviderConfig} from "@actions/languageservice";
|
|||||||
import {Mode} from "@actions/languageservice/context-providers/default";
|
import {Mode} from "@actions/languageservice/context-providers/default";
|
||||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {getSecrets} from "./context-providers/secrets";
|
import {getSecrets} from "./context-providers/secrets.js";
|
||||||
import {getStepsContext} from "./context-providers/steps";
|
import {getStepsContext} from "./context-providers/steps.js";
|
||||||
import {getVariables} from "./context-providers/variables";
|
import {getVariables} from "./context-providers/variables.js";
|
||||||
import {RepositoryContext} from "./initializationOptions";
|
import {RepositoryContext} from "./initializationOptions.js";
|
||||||
import {TTLCache} from "./utils/cache";
|
import {TTLCache} from "./utils/cache.js";
|
||||||
|
|
||||||
export function contextProviders(
|
export function contextProviders(
|
||||||
client: Octokit | undefined,
|
client: Octokit | undefined,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {ActionOutputs, ActionReference} from "@actions/languageservice/action";
|
import {ActionOutputs, ActionReference} from "@actions/languageservice/action";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache.js";
|
||||||
|
|
||||||
export async function getActionOutputs(
|
export async function getActionOutputs(
|
||||||
octokit: Octokit,
|
octokit: Octokit,
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import {warn} from "@actions/languageservice/log";
|
|||||||
import {isMapping, isString} from "@actions/workflow-parser";
|
import {isMapping, isString} from "@actions/workflow-parser";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
|
|
||||||
import {RepositoryContext} from "../initializationOptions";
|
import {RepositoryContext} from "../initializationOptions.js";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache.js";
|
||||||
import {errorStatus} from "../utils/error";
|
import {errorStatus} from "../utils/error.js";
|
||||||
import {getRepoPermission} from "../utils/repo-permission";
|
import {getRepoPermission} from "../utils/repo-permission.js";
|
||||||
|
|
||||||
export async function getSecrets(
|
export async function getSecrets(
|
||||||
workflowContext: WorkflowContext,
|
workflowContext: WorkflowContext,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import {getStepsContext as getDefaultStepsContext} from "@actions/languageservic
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
|
|
||||||
import {createWorkflowContext} from "../test-utils/workflow-context";
|
import {createWorkflowContext} from "../test-utils/workflow-context.js";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache.js";
|
||||||
import {getStepsContext} from "./steps";
|
import {getStepsContext} from "./steps.js";
|
||||||
|
|
||||||
const workflow = `
|
const workflow = `
|
||||||
name: Caching Primes
|
name: Caching Primes
|
||||||
@@ -84,13 +84,17 @@ it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
|
|||||||
|
|
||||||
// Get the step context
|
// Get the step context
|
||||||
const stepContext = stepsContext?.get("cache-primes");
|
const stepContext = stepsContext?.get("cache-primes");
|
||||||
expect(stepContext).toBeDefined();
|
if (!stepContext) {
|
||||||
expect(isDescriptionDictionary(stepContext!)).toBe(true);
|
throw new Error("Expected stepContext to be defined");
|
||||||
|
}
|
||||||
|
expect(isDescriptionDictionary(stepContext)).toBe(true);
|
||||||
|
|
||||||
// Get the outputs - should be a dictionary, not null
|
// Get the outputs - should be a dictionary, not null
|
||||||
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||||
expect(outputs).toBeDefined();
|
if (!outputs) {
|
||||||
expect(isDescriptionDictionary(outputs!)).toBe(true);
|
throw new Error("Expected outputs to be defined");
|
||||||
|
}
|
||||||
|
expect(isDescriptionDictionary(outputs)).toBe(true);
|
||||||
|
|
||||||
// Outputs should be marked incomplete to allow dynamic outputs
|
// Outputs should be marked incomplete to allow dynamic outputs
|
||||||
const outputsDict = outputs as DescriptionDictionary;
|
const outputsDict = outputs as DescriptionDictionary;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {parseActionReference} from "@actions/languageservice/action";
|
|||||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache.js";
|
||||||
import {getActionOutputs} from "./action-outputs";
|
import {getActionOutputs} from "./action-outputs.js";
|
||||||
|
|
||||||
export async function getStepsContext(
|
export async function getStepsContext(
|
||||||
octokit: Octokit,
|
octokit: Octokit,
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {isMapping, isString} from "@actions/workflow-parser";
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {RequestError} from "@octokit/request-error";
|
import {RequestError} from "@octokit/request-error";
|
||||||
|
|
||||||
import {RepositoryContext} from "../initializationOptions";
|
import {RepositoryContext} from "../initializationOptions.js";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache.js";
|
||||||
import {errorStatus} from "../utils/error";
|
import {errorStatus} from "../utils/error.js";
|
||||||
import {getRepoPermission} from "../utils/repo-permission";
|
import {getRepoPermission} from "../utils/repo-permission.js";
|
||||||
|
|
||||||
export async function getVariables(
|
export async function getVariables(
|
||||||
workflowContext: WorkflowContext,
|
workflowContext: WorkflowContext,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {DescriptionProvider} from "@actions/languageservice/hover";
|
import {DescriptionProvider} from "@actions/languageservice/hover";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {getActionDescription} from "./description-providers/action-description";
|
import {getActionDescription} from "./description-providers/action-description.js";
|
||||||
import {getActionInputDescription} from "./description-providers/action-input";
|
import {getActionInputDescription} from "./description-providers/action-input.js";
|
||||||
import {TTLCache} from "./utils/cache";
|
import {TTLCache} from "./utils/cache.js";
|
||||||
|
|
||||||
export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider {
|
export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider {
|
||||||
const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => {
|
const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
import {createWorkflowContext} from "../test-utils/workflow-context";
|
import {createWorkflowContext} from "../test-utils/workflow-context.js";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache.js";
|
||||||
import {getActionDescription} from "./action-description";
|
import {getActionDescription} from "./action-description.js";
|
||||||
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
|
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
|
||||||
|
|
||||||
const workflow = `
|
const workflow = `
|
||||||
name: Hello World
|
name: Hello World
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import {actionUrl, parseActionReference} from "@actions/languageservice/action";
|
|||||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache.js";
|
||||||
|
|
||||||
export async function getActionDescription(client: Octokit, cache: TTLCache, step: Step): Promise<string | undefined> {
|
export async function getActionDescription(client: Octokit, cache: TTLCache, step: Step): Promise<string | undefined> {
|
||||||
if (!isActionStep(step)) {
|
if (!isActionStep(step)) {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import {StringToken} from "@actions/workflow-parser/templates/tokens/string-toke
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
|
|
||||||
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
|
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
|
||||||
import {createWorkflowContext} from "../test-utils/workflow-context";
|
import {createWorkflowContext} from "../test-utils/workflow-context.js";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache.js";
|
||||||
import {getActionInputDescription} from "./action-input";
|
import {getActionInputDescription} from "./action-input.js";
|
||||||
|
|
||||||
const workflow = `
|
const workflow = `
|
||||||
name: Hello World
|
name: Hello World
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
|||||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache.js";
|
||||||
|
|
||||||
export async function getActionInputDescription(
|
export async function getActionInputDescription(
|
||||||
client: Octokit,
|
client: Octokit,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {File} from "@actions/workflow-parser/workflows/file";
|
|||||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||||
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
|
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {TTLCache} from "./utils/cache";
|
import {TTLCache} from "./utils/cache.js";
|
||||||
import * as vscodeURI from "vscode-uri";
|
import * as vscodeURI from "vscode-uri";
|
||||||
|
|
||||||
export function getFileProvider(
|
export function getFileProvider(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "vscode-languageserver/browser";
|
} 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.js";
|
||||||
|
|
||||||
/** Helper function determining whether we are executing with node runtime */
|
/** Helper function determining whether we are executing with node runtime */
|
||||||
function isNode(): boolean {
|
function isNode(): boolean {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {ExperimentalFeatures} from "@actions/expressions";
|
||||||
import {LogLevel} from "@actions/languageservice/log";
|
import {LogLevel} from "@actions/languageservice/log";
|
||||||
export {LogLevel} from "@actions/languageservice/log";
|
export {LogLevel} from "@actions/languageservice/log";
|
||||||
|
|
||||||
@@ -28,6 +29,12 @@ export interface InitializationOptions {
|
|||||||
* If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3"
|
* If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3"
|
||||||
*/
|
*/
|
||||||
gitHubApiUrl?: string;
|
gitHubApiUrl?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Experimental features that are opt-in.
|
||||||
|
* Features listed here may change or be removed without notice.
|
||||||
|
*/
|
||||||
|
experimentalFeatures?: ExperimentalFeatures;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepositoryContext {
|
export interface RepositoryContext {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import {complete} from "@actions/languageservice/complete";
|
import {complete} from "@actions/languageservice/complete";
|
||||||
|
import type {FeatureFlags} from "@actions/expressions";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {CompletionItem, Connection, Position} from "vscode-languageserver";
|
import {CompletionItem, Connection, Position} from "vscode-languageserver";
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {contextProviders} from "./context-providers";
|
import {contextProviders} from "./context-providers.js";
|
||||||
import {getFileProvider} from "./file-provider";
|
import {getFileProvider} from "./file-provider.js";
|
||||||
import {RepositoryContext} from "./initializationOptions";
|
import {RepositoryContext} from "./initializationOptions.js";
|
||||||
import {Requests} from "./request";
|
import {Requests} from "./request.js";
|
||||||
import {TTLCache} from "./utils/cache";
|
import {TTLCache} from "./utils/cache.js";
|
||||||
import {valueProviders} from "./value-providers";
|
import {valueProviders} from "./value-providers.js";
|
||||||
|
|
||||||
export async function onCompletion(
|
export async function onCompletion(
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
@@ -15,11 +16,13 @@ export async function onCompletion(
|
|||||||
document: TextDocument,
|
document: TextDocument,
|
||||||
client: Octokit | undefined,
|
client: Octokit | undefined,
|
||||||
repoContext: RepositoryContext | undefined,
|
repoContext: RepositoryContext | undefined,
|
||||||
cache: TTLCache
|
cache: TTLCache,
|
||||||
|
featureFlags?: FeatureFlags
|
||||||
): Promise<CompletionItem[]> {
|
): Promise<CompletionItem[]> {
|
||||||
return await complete(document, position, {
|
return await complete(document, position, {
|
||||||
valueProviderConfig: repoContext && valueProviders(client, repoContext, cache),
|
valueProviderConfig: repoContext && valueProviders(client, repoContext, cache),
|
||||||
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
|
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
|
||||||
|
featureFlags,
|
||||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||||
return await connection.sendRequest(Requests.ReadFile, {path});
|
return await connection.sendRequest(Requests.ReadFile, {path});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
import {fetchActionMetadata} from "./action-metadata";
|
import {fetchActionMetadata} from "./action-metadata.js";
|
||||||
import {TTLCache} from "./cache";
|
import {TTLCache} from "./cache.js";
|
||||||
|
|
||||||
// A simplified version of the action.yml file from actions/checkout
|
// A simplified version of the action.yml file from actions/checkout
|
||||||
const actionMetadataContent = `
|
const actionMetadataContent = `
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {ActionsMetadataProvider} from "@actions/languageservice";
|
|||||||
import {error} from "@actions/languageservice/log";
|
import {error} from "@actions/languageservice/log";
|
||||||
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
|
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
|
||||||
import {parse} from "yaml";
|
import {parse} from "yaml";
|
||||||
import {TTLCache} from "./cache";
|
import {TTLCache} from "./cache.js";
|
||||||
import {errorMessage, errorStatus} from "./error";
|
import {errorMessage, errorStatus} from "./error.js";
|
||||||
|
|
||||||
export function getActionsMetadataProvider(
|
export function getActionsMetadataProvider(
|
||||||
client: Octokit | undefined,
|
client: Octokit | undefined,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {error} from "@actions/languageservice/log";
|
import {error} from "@actions/languageservice/log";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {RepositoryContext} from "../initializationOptions";
|
import {RepositoryContext} from "../initializationOptions.js";
|
||||||
import {TTLCache} from "./cache";
|
import {TTLCache} from "./cache.js";
|
||||||
import {errorStatus} from "./error";
|
import {errorStatus} from "./error.js";
|
||||||
import {getUsername} from "./username";
|
import {getUsername} from "./username.js";
|
||||||
|
|
||||||
export type RepoPermission = "admin" | "write" | "read" | "none";
|
export type RepoPermission = "admin" | "write" | "read" | "none";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {TTLCache} from "./cache";
|
import {TTLCache} from "./cache.js";
|
||||||
|
|
||||||
export async function getUsername(octokit: Octokit, cache: TTLCache): Promise<string> {
|
export async function getUsername(octokit: Octokit, cache: TTLCache): Promise<string> {
|
||||||
return await cache.get(`/username`, undefined, () => fetchUsername(octokit));
|
return await cache.get(`/username`, undefined, () => fetchUsername(octokit));
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import {ValueProviderConfig} from "@actions/languageservice";
|
|||||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||||
import {ValueProviderKind} from "@actions/languageservice/value-providers/config";
|
import {ValueProviderKind} from "@actions/languageservice/value-providers/config";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {RepositoryContext} from "./initializationOptions";
|
import {RepositoryContext} from "./initializationOptions.js";
|
||||||
import {TTLCache} from "./utils/cache";
|
import {TTLCache} from "./utils/cache.js";
|
||||||
import {getActionInputValues} from "./value-providers/action-inputs";
|
import {getActionInputValues} from "./value-providers/action-inputs.js";
|
||||||
import {getEnvironments} from "./value-providers/job-environment";
|
import {getEnvironments} from "./value-providers/job-environment.js";
|
||||||
import {getRunnerLabels} from "./value-providers/runs-on";
|
import {getRunnerLabels} from "./value-providers/runs-on.js";
|
||||||
|
|
||||||
export function valueProviders(
|
export function valueProviders(
|
||||||
client: Octokit | undefined,
|
client: Octokit | undefined,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {WorkflowContext} from "@actions/languageservice/context/workflow-context
|
|||||||
import {Value} from "@actions/languageservice/value-providers/config";
|
import {Value} from "@actions/languageservice/value-providers/config";
|
||||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache.js";
|
||||||
|
|
||||||
export async function getActionInputs(
|
export async function getActionInputs(
|
||||||
client: Octokit,
|
client: Octokit,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Value} from "@actions/languageservice/value-providers/config";
|
import {Value} from "@actions/languageservice/value-providers/config";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache.js";
|
||||||
|
|
||||||
export async function getEnvironments(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> {
|
export async function getEnvironments(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> {
|
||||||
const environments = await cache.get(`${owner}/${name}/environments`, undefined, () =>
|
const environments = await cache.get(`${owner}/${name}/environments`, undefined, () =>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import {log} from "@actions/languageservice/log";
|
|||||||
import {Value} from "@actions/languageservice/value-providers/config";
|
import {Value} from "@actions/languageservice/value-providers/config";
|
||||||
import {DEFAULT_RUNNER_LABELS} from "@actions/languageservice/value-providers/default";
|
import {DEFAULT_RUNNER_LABELS} from "@actions/languageservice/value-providers/default";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {TTLCache} from "../utils/cache";
|
import {TTLCache} from "../utils/cache.js";
|
||||||
import {errorMessage} from "../utils/error";
|
import {errorMessage} from "../utils/error.js";
|
||||||
|
|
||||||
// Limitation: getRunnerLabels returns default hosted labels and labels for repository self-hosted runners.
|
// Limitation: getRunnerLabels returns default hosted labels and labels for repository self-hosted runners.
|
||||||
// It doesn't return labels for organization runners visible to the repository.
|
// It doesn't return labels for organization runners visible to the repository.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"outDir": "./dist"
|
"outDir": "./dist",
|
||||||
|
"skipLibCheck": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageservice",
|
"name": "@actions/languageservice",
|
||||||
"version": "0.3.25",
|
"version": "0.3.54",
|
||||||
"description": "Language service for GitHub Actions",
|
"description": "Language service for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"format": "prettier --write '**/*.ts'",
|
"format": "prettier --write '**/*.ts'",
|
||||||
"format-check": "prettier --check '**/*.ts'",
|
"format-check": "prettier --check '**/*.ts'",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
||||||
"lint-fix": "eslint --fix '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",
|
"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",
|
"prebuild": "npm run minify-json",
|
||||||
@@ -47,15 +47,15 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.25",
|
"@actions/expressions": "^0.3.54",
|
||||||
"@actions/workflow-parser": "^0.3.25",
|
"@actions/workflow-parser": "^0.3.54",
|
||||||
"vscode-languageserver-textdocument": "^1.0.7",
|
"vscode-languageserver-textdocument": "^1.0.7",
|
||||||
"vscode-languageserver-types": "^3.17.2",
|
"vscode-languageserver-types": "^3.17.2",
|
||||||
"vscode-uri": "^3.0.8",
|
"vscode-uri": "^3.0.8",
|
||||||
"yaml": "^2.1.1"
|
"yaml": "^2.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 20"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
@@ -74,6 +74,6 @@
|
|||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"ts-jest": "^29.0.3",
|
"ts-jest": "^29.0.3",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^4.8.4"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {actionIdentifier, parseActionReference as parse} from "./action";
|
import {actionIdentifier, parseActionReference as parse} from "./action.js";
|
||||||
|
|
||||||
describe("parseActionReference", () => {
|
describe("parseActionReference", () => {
|
||||||
it("basic action", () => {
|
it("basic action", () => {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {FeatureFlags} from "@actions/expressions";
|
||||||
|
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
|
||||||
|
import {CodeActionContext, CodeActionProvider} from "./types.js";
|
||||||
|
import {getQuickfixProviders} from "./quickfix/quickfix-providers.js";
|
||||||
|
|
||||||
|
export interface CodeActionParams {
|
||||||
|
uri: string;
|
||||||
|
documentContent: string;
|
||||||
|
diagnostics: Diagnostic[];
|
||||||
|
only?: string[];
|
||||||
|
featureFlags?: FeatureFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCodeActions(params: CodeActionParams): CodeAction[] {
|
||||||
|
const actions: CodeAction[] = [];
|
||||||
|
const context: CodeActionContext = {
|
||||||
|
uri: params.uri,
|
||||||
|
documentContent: params.documentContent,
|
||||||
|
featureFlags: params.featureFlags
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build providers map based on feature flags
|
||||||
|
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
|
||||||
|
[CodeActionKind.QuickFix, getQuickfixProviders(params.featureFlags)]
|
||||||
|
// [CodeActionKind.Refactor, getRefactorProviders(params.featureFlags)],
|
||||||
|
// [CodeActionKind.Source, getSourceProviders(params.featureFlags)],
|
||||||
|
// etc
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Filter to requested kinds, or use all if none specified
|
||||||
|
const requestedKinds = params.only;
|
||||||
|
const kindsToCheck = requestedKinds
|
||||||
|
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
|
||||||
|
: [...providersByKind.keys()];
|
||||||
|
|
||||||
|
for (const diagnostic of params.diagnostics) {
|
||||||
|
for (const kind of kindsToCheck) {
|
||||||
|
const providers = providersByKind.get(kind) ?? [];
|
||||||
|
for (const provider of providers) {
|
||||||
|
if (provider.diagnosticCodes.includes(diagnostic.code)) {
|
||||||
|
const action = provider.createCodeAction(context, diagnostic);
|
||||||
|
if (action) {
|
||||||
|
action.kind = kind;
|
||||||
|
action.diagnostics = [diagnostic];
|
||||||
|
actions.push(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {CodeActionContext, CodeActionProvider} from "./types.js";
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import {isMapping} from "@actions/workflow-parser";
|
||||||
|
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
|
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
|
||||||
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||||
|
import {CodeAction, Position, TextEdit} from "vscode-languageserver-types";
|
||||||
|
import {error} from "../../log.js";
|
||||||
|
import {findToken} from "../../utils/find-token.js";
|
||||||
|
import {getOrParseWorkflow} from "../../utils/workflow-cache.js";
|
||||||
|
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action-reference.js";
|
||||||
|
import {CodeActionContext, CodeActionProvider} from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information extracted from a step token needed to generate edits
|
||||||
|
*/
|
||||||
|
interface StepInfo {
|
||||||
|
/** Column where step keys start (1-indexed), e.g., the column of "uses:" */
|
||||||
|
stepKeyColumn: number;
|
||||||
|
/** End line of the step (1-indexed) */
|
||||||
|
stepEndLine: number;
|
||||||
|
/** Detected indent size (spaces per level) */
|
||||||
|
indentSize: number;
|
||||||
|
/** Information about existing with: block, if present */
|
||||||
|
withInfo?: {
|
||||||
|
keyColumn: number;
|
||||||
|
keyEndLine: number;
|
||||||
|
valueEndLine: number;
|
||||||
|
hasChildren: boolean;
|
||||||
|
/** Column of first child input (1-indexed), for indentation detection */
|
||||||
|
firstChildColumn?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addMissingInputsProvider: CodeActionProvider = {
|
||||||
|
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
|
||||||
|
|
||||||
|
createCodeAction(context: CodeActionContext, diagnostic): CodeAction | undefined {
|
||||||
|
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
|
||||||
|
if (!data) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the document to get the step token
|
||||||
|
const stepInfo = getStepInfo(context, diagnostic.range.start);
|
||||||
|
if (!stepInfo) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const edits = createInputEdits(data.missingInputs, stepInfo);
|
||||||
|
if (!edits || edits.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputNames = data.missingInputs.map(i => i.name).join(", ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
|
||||||
|
edit: {
|
||||||
|
changes: {
|
||||||
|
[context.uri]: edits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the document and extract step information needed for generating edits.
|
||||||
|
* Returns undefined if parsing fails or the step token cannot be found.
|
||||||
|
*/
|
||||||
|
function getStepInfo(context: CodeActionContext, diagnosticPosition: Position): StepInfo | undefined {
|
||||||
|
// Parse the document (uses cache if available from validation)
|
||||||
|
const file = {name: context.uri, content: context.documentContent};
|
||||||
|
const parseResult = getOrParseWorkflow(file, context.uri);
|
||||||
|
|
||||||
|
if (!parseResult.value) {
|
||||||
|
error("Failed to parse workflow for missing inputs quickfix");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the token at the diagnostic position
|
||||||
|
const {path} = findToken(diagnosticPosition, parseResult.value);
|
||||||
|
|
||||||
|
// Walk up the path to find the step token (regular-step)
|
||||||
|
const stepToken = findStepInPath(path);
|
||||||
|
if (!stepToken) {
|
||||||
|
error("Could not find step token for missing inputs quickfix");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractStepInfo(stepToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the step token (regular-step) in the token path
|
||||||
|
*/
|
||||||
|
function findStepInPath(path: TemplateToken[]): MappingToken | undefined {
|
||||||
|
// Walk backwards through path to find the step
|
||||||
|
for (let i = path.length - 1; i >= 0; i--) {
|
||||||
|
if (path[i].definition?.key === "regular-step" && isMapping(path[i])) {
|
||||||
|
return path[i] as MappingToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract position and indentation info from a step token
|
||||||
|
*/
|
||||||
|
function extractStepInfo(stepToken: MappingToken): StepInfo | undefined {
|
||||||
|
if (!stepToken.range) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the column of the first key in the step
|
||||||
|
let stepKeyColumn = stepToken.range.start.column;
|
||||||
|
if (stepToken.count > 0) {
|
||||||
|
const firstEntry = stepToken.get(0);
|
||||||
|
if (firstEntry?.key.range) {
|
||||||
|
stepKeyColumn = firstEntry.key.range.start.column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the with: block if present
|
||||||
|
let withKey: ScalarToken | undefined;
|
||||||
|
let withToken: TemplateToken | undefined;
|
||||||
|
for (const {key, value} of stepToken) {
|
||||||
|
if (key.toString() === "with") {
|
||||||
|
withKey = key;
|
||||||
|
withToken = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate indent size
|
||||||
|
let indentSize = 2; // Default
|
||||||
|
let withInfo: StepInfo["withInfo"];
|
||||||
|
|
||||||
|
if (withKey?.range && withToken?.range) {
|
||||||
|
// Has with: block - extract its info
|
||||||
|
const hasChildren = isMapping(withToken) && withToken.count > 0;
|
||||||
|
let firstChildColumn: number | undefined;
|
||||||
|
|
||||||
|
if (hasChildren) {
|
||||||
|
const firstChild = (withToken as MappingToken).get(0);
|
||||||
|
if (firstChild?.key.range) {
|
||||||
|
firstChildColumn = firstChild.key.range.start.column;
|
||||||
|
// Detect indent size from with: children
|
||||||
|
indentSize = firstChildColumn - withKey.range.start.column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withInfo = {
|
||||||
|
keyColumn: withKey.range.start.column,
|
||||||
|
keyEndLine: withKey.range.end.line,
|
||||||
|
valueEndLine: withToken.range.end.line,
|
||||||
|
hasChildren,
|
||||||
|
firstChildColumn
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// No with: block - detect indent size using heuristics
|
||||||
|
// Based on the step key column position, estimate indent size
|
||||||
|
// 2-space indent files typically have step keys at column 7
|
||||||
|
// 4-space indent files typically have step keys at column 15
|
||||||
|
const zeroIndexedCol = stepKeyColumn - 1;
|
||||||
|
if (zeroIndexedCol >= 10) {
|
||||||
|
indentSize = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stepKeyColumn,
|
||||||
|
stepEndLine: stepToken.range.end.line,
|
||||||
|
indentSize,
|
||||||
|
withInfo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate text edits to add missing inputs
|
||||||
|
*/
|
||||||
|
function createInputEdits(missingInputs: MissingInputsDiagnosticData["missingInputs"], stepInfo: StepInfo): TextEdit[] {
|
||||||
|
const formatInputLines = (indent: string) =>
|
||||||
|
missingInputs.map(input => {
|
||||||
|
const value = input.default ?? '""';
|
||||||
|
return `${indent}${input.name}: ${value}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stepInfo.withInfo) {
|
||||||
|
// `with:` exists - add inputs to existing block
|
||||||
|
const withIndent = stepInfo.withInfo.keyColumn - 1; // 0-indexed
|
||||||
|
const inputIndentSize = stepInfo.withInfo.firstChildColumn
|
||||||
|
? stepInfo.withInfo.firstChildColumn - stepInfo.withInfo.keyColumn
|
||||||
|
: stepInfo.indentSize;
|
||||||
|
|
||||||
|
const inputIndent = " ".repeat(withIndent + inputIndentSize);
|
||||||
|
const inputLines = formatInputLines(inputIndent);
|
||||||
|
|
||||||
|
// Calculate insert position
|
||||||
|
let insertLine: number;
|
||||||
|
if (stepInfo.withInfo.hasChildren) {
|
||||||
|
// Insert after the last child (at end of with: block)
|
||||||
|
// valueEndLine is 1-indexed, we want 0-indexed for Position
|
||||||
|
insertLine = stepInfo.withInfo.valueEndLine - 1;
|
||||||
|
} else {
|
||||||
|
// Empty with: block - insert on the next line after with:
|
||||||
|
// keyEndLine is 1-indexed, convert to 0-indexed and go to next line
|
||||||
|
insertLine = stepInfo.withInfo.keyEndLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertPosition: Position = {
|
||||||
|
line: insertLine,
|
||||||
|
character: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
range: {start: insertPosition, end: insertPosition},
|
||||||
|
newText: inputLines.map(line => line + "\n").join("")
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// No `with:` key - add `with:` at the same level as other step keys
|
||||||
|
const withKeyIndent = stepInfo.stepKeyColumn - 1; // 0-indexed (columns are 1-based)
|
||||||
|
|
||||||
|
const withIndent = " ".repeat(withKeyIndent);
|
||||||
|
const inputIndent = " ".repeat(withKeyIndent + stepInfo.indentSize);
|
||||||
|
const inputLines = formatInputLines(inputIndent);
|
||||||
|
|
||||||
|
const newText = `${withIndent}with:\n` + inputLines.map(line => `${line}\n`).join("");
|
||||||
|
|
||||||
|
// Insert at end of step
|
||||||
|
// stepEndLine is 1-indexed, we want 0-indexed and insert before the line after
|
||||||
|
const insertPosition: Position = {
|
||||||
|
line: stepInfo.stepEndLine - 1,
|
||||||
|
character: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
range: {start: insertPosition, end: insertPosition},
|
||||||
|
newText
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import {FeatureFlags} from "@actions/expressions";
|
||||||
|
import {CodeActionProvider} from "../types.js";
|
||||||
|
import {addMissingInputsProvider} from "./add-missing-inputs.js";
|
||||||
|
|
||||||
|
export function getQuickfixProviders(featureFlags?: FeatureFlags): CodeActionProvider[] {
|
||||||
|
const providers: CodeActionProvider[] = [];
|
||||||
|
|
||||||
|
if (featureFlags?.isEnabled("missingInputsQuickfix")) {
|
||||||
|
providers.push(addMissingInputsProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import * as path from "path";
|
||||||
|
import {fileURLToPath} from "url";
|
||||||
|
import {loadTestCases, runTestCase} from "./runner.js";
|
||||||
|
import {ValidationConfig} from "../../validate.js";
|
||||||
|
import {ActionMetadata, ActionReference} from "../../action.js";
|
||||||
|
import {clearCache} from "../../utils/workflow-cache.js";
|
||||||
|
|
||||||
|
// ESM-compatible __dirname
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Mock action metadata provider for tests
|
||||||
|
const validationConfig: ValidationConfig = {
|
||||||
|
actionsMetadataProvider: {
|
||||||
|
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
|
||||||
|
const key = `${ref.owner}/${ref.name}@${ref.ref}`;
|
||||||
|
|
||||||
|
const metadata: Record<string, ActionMetadata> = {
|
||||||
|
"actions/cache@v1": {
|
||||||
|
name: "Cache",
|
||||||
|
description: "Cache dependencies",
|
||||||
|
inputs: {
|
||||||
|
path: {
|
||||||
|
description: "A list of files to cache",
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
key: {
|
||||||
|
description: "Cache key",
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
"restore-keys": {
|
||||||
|
description: "Restore keys",
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions/setup-node@v3": {
|
||||||
|
name: "Setup Node",
|
||||||
|
description: "Setup Node.js",
|
||||||
|
inputs: {
|
||||||
|
"node-version": {
|
||||||
|
description: "Node version",
|
||||||
|
required: true,
|
||||||
|
default: "16"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Promise.resolve(metadata[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Point to the source testdata directory
|
||||||
|
const testdataDir = path.join(__dirname, "testdata");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("code action golden tests", () => {
|
||||||
|
const testCases = loadTestCases(testdataDir);
|
||||||
|
|
||||||
|
if (testCases.length === 0) {
|
||||||
|
it.todo("no test cases found - add .yml files to testdata/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
it(testCase.name, async () => {
|
||||||
|
const result = await runTestCase(testCase, validationConfig);
|
||||||
|
|
||||||
|
if (!result.passed) {
|
||||||
|
let errorMessage = result.error || "Test failed";
|
||||||
|
|
||||||
|
if (result.expected !== undefined && result.actual !== undefined) {
|
||||||
|
errorMessage += "\n\n";
|
||||||
|
errorMessage += "=== EXPECTED (golden file) ===\n";
|
||||||
|
errorMessage += result.expected;
|
||||||
|
errorMessage += "\n\n";
|
||||||
|
errorMessage += "=== ACTUAL ===\n";
|
||||||
|
errorMessage += result.actual;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import {TextEdit} from "vscode-languageserver-types";
|
||||||
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||||
|
import {FeatureFlags} from "@actions/expressions";
|
||||||
|
import {validate, ValidationConfig} from "../../validate.js";
|
||||||
|
import {getCodeActions, CodeActionParams} from "../code-actions.js";
|
||||||
|
|
||||||
|
// Marker pattern: # want "diagnostic message" fix="code-action-name"
|
||||||
|
const MARKER_PATTERN = /#\s*want\s+"([^"]+)"(?:\s+fix="([^"]+)")?/;
|
||||||
|
|
||||||
|
export interface TestCase {
|
||||||
|
name: string;
|
||||||
|
inputPath: string;
|
||||||
|
goldenPath: string;
|
||||||
|
input: string;
|
||||||
|
golden: string;
|
||||||
|
markers: Marker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Marker {
|
||||||
|
line: number;
|
||||||
|
message: string;
|
||||||
|
fix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestResult {
|
||||||
|
name: string;
|
||||||
|
passed: boolean;
|
||||||
|
error?: string;
|
||||||
|
expected?: string;
|
||||||
|
actual?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markers from input file content
|
||||||
|
*/
|
||||||
|
export function parseMarkers(content: string): Marker[] {
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const markers: Marker[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const match = lines[i].match(MARKER_PATTERN);
|
||||||
|
if (match) {
|
||||||
|
markers.push({
|
||||||
|
line: i,
|
||||||
|
message: match[1],
|
||||||
|
fix: match[2]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip markers from content (for processing)
|
||||||
|
*/
|
||||||
|
export function stripMarkers(content: string): string {
|
||||||
|
return content
|
||||||
|
.split("\n")
|
||||||
|
.map(line => line.replace(MARKER_PATTERN, "").trimEnd())
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all test cases from a testdata directory
|
||||||
|
*/
|
||||||
|
export function loadTestCases(testdataDir: string): TestCase[] {
|
||||||
|
const testCases: TestCase[] = [];
|
||||||
|
|
||||||
|
function walkDir(dir: string) {
|
||||||
|
const entries = fs.readdirSync(dir, {withFileTypes: true});
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walkDir(fullPath);
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith(".yml") && !entry.name.endsWith(".golden.yml")) {
|
||||||
|
const goldenPath = fullPath.replace(".yml", ".golden.yml");
|
||||||
|
|
||||||
|
if (fs.existsSync(goldenPath)) {
|
||||||
|
const input = fs.readFileSync(fullPath, "utf-8");
|
||||||
|
const golden = fs.readFileSync(goldenPath, "utf-8");
|
||||||
|
|
||||||
|
testCases.push({
|
||||||
|
name: path.relative(testdataDir, fullPath),
|
||||||
|
inputPath: fullPath,
|
||||||
|
goldenPath,
|
||||||
|
input,
|
||||||
|
golden,
|
||||||
|
markers: parseMarkers(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walkDir(testdataDir);
|
||||||
|
return testCases;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply text edits to a document
|
||||||
|
*/
|
||||||
|
export function applyEdits(content: string, edits: TextEdit[]): string {
|
||||||
|
// Sort edits in reverse order by position to apply from bottom to top
|
||||||
|
const sortedEdits = [...edits].sort((a, b) => {
|
||||||
|
if (b.range.start.line !== a.range.start.line) {
|
||||||
|
return b.range.start.line - a.range.start.line;
|
||||||
|
}
|
||||||
|
return b.range.start.character - a.range.start.character;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = content.split("\n");
|
||||||
|
|
||||||
|
for (const edit of sortedEdits) {
|
||||||
|
const startLine = edit.range.start.line;
|
||||||
|
const startChar = edit.range.start.character;
|
||||||
|
const endLine = edit.range.end.line;
|
||||||
|
const endChar = edit.range.end.character;
|
||||||
|
|
||||||
|
const before = lines[startLine].slice(0, startChar);
|
||||||
|
const after = lines[endLine].slice(endChar);
|
||||||
|
|
||||||
|
const newLines = edit.newText.split("\n");
|
||||||
|
newLines[0] = before + newLines[0];
|
||||||
|
newLines[newLines.length - 1] = newLines[newLines.length - 1] + after;
|
||||||
|
|
||||||
|
lines.splice(startLine, endLine - startLine + 1, ...newLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a single test case
|
||||||
|
*/
|
||||||
|
export async function runTestCase(testCase: TestCase, validationConfig: ValidationConfig): Promise<TestResult> {
|
||||||
|
const strippedInput = stripMarkers(testCase.input);
|
||||||
|
const document = TextDocument.create("file:///test.yml", "yaml", 1, strippedInput);
|
||||||
|
|
||||||
|
// 1. Validate and get diagnostics
|
||||||
|
const diagnostics = await validate(document, validationConfig);
|
||||||
|
|
||||||
|
// 2. Verify all expected diagnostics are present
|
||||||
|
const missingDiagnostics: string[] = [];
|
||||||
|
for (const marker of testCase.markers) {
|
||||||
|
const found = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
|
||||||
|
if (!found) {
|
||||||
|
missingDiagnostics.push(`line ${marker.line}: "${marker.message}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingDiagnostics.length > 0) {
|
||||||
|
return {
|
||||||
|
name: testCase.name,
|
||||||
|
passed: false,
|
||||||
|
error: `Missing expected diagnostics:\n ${missingDiagnostics.join(
|
||||||
|
"\n "
|
||||||
|
)}\n\nActual diagnostics:\n ${diagnostics.map(d => `line ${d.range.start.line}: "${d.message}"`).join("\n ")}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Collect all edits from all matching code actions
|
||||||
|
const allEdits: TextEdit[] = [];
|
||||||
|
|
||||||
|
for (const marker of testCase.markers) {
|
||||||
|
if (!marker.fix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagnostic = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
|
||||||
|
|
||||||
|
if (!diagnostic) {
|
||||||
|
continue; // Already reported above
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: CodeActionParams = {
|
||||||
|
uri: document.uri,
|
||||||
|
documentContent: strippedInput,
|
||||||
|
diagnostics: [diagnostic],
|
||||||
|
featureFlags: new FeatureFlags({all: true})
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = getCodeActions(params);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- marker.fix is checked at the start of the loop
|
||||||
|
const matchingAction = actions.find(a => a.title.toLowerCase().includes(marker.fix!.toLowerCase()));
|
||||||
|
|
||||||
|
if (!matchingAction) {
|
||||||
|
return {
|
||||||
|
name: testCase.name,
|
||||||
|
passed: false,
|
||||||
|
error: `Code action "${marker.fix}" not found for diagnostic on line ${marker.line}.\nAvailable actions: ${
|
||||||
|
actions.map(a => a.title).join(", ") || "(none)"
|
||||||
|
}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchingAction.edit?.changes) {
|
||||||
|
return {
|
||||||
|
name: testCase.name,
|
||||||
|
passed: false,
|
||||||
|
error: `Code action "${marker.fix}" has no edits`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const edits = matchingAction.edit.changes[document.uri] || [];
|
||||||
|
allEdits.push(...edits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Apply all edits and compare to golden file
|
||||||
|
const actualOutput = applyEdits(strippedInput, allEdits);
|
||||||
|
const expectedOutput = testCase.golden;
|
||||||
|
|
||||||
|
if (actualOutput.trim() !== expectedOutput.trim()) {
|
||||||
|
return {
|
||||||
|
name: testCase.name,
|
||||||
|
passed: false,
|
||||||
|
error: "Output does not match golden file",
|
||||||
|
expected: expectedOutput,
|
||||||
|
actual: actualOutput
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: testCase.name,
|
||||||
|
passed: true
|
||||||
|
};
|
||||||
|
}
|
||||||
languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key-without-inputs.golden.yml
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/cache@v1
|
||||||
|
with:
|
||||||
|
path: ""
|
||||||
|
key: ""
|
||||||
Vendored
+7
@@ -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"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/cache@v1
|
||||||
|
with:
|
||||||
|
restore-keys: ${{ runner.os }}-
|
||||||
|
path: ""
|
||||||
|
key: ""
|
||||||
+8
@@ -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 }}-
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/cache@v1
|
||||||
|
with:
|
||||||
|
path: ""
|
||||||
|
key: ""
|
||||||
+6
@@ -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"
|
||||||
+9
@@ -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"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {FeatureFlags} from "@actions/expressions";
|
||||||
|
import {CodeAction, Diagnostic} from "vscode-languageserver-types";
|
||||||
|
|
||||||
|
export interface CodeActionContext {
|
||||||
|
uri: string;
|
||||||
|
documentContent: string;
|
||||||
|
featureFlags?: FeatureFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A provider that can produce a code action for a given diagnostic
|
||||||
|
*/
|
||||||
|
export interface CodeActionProvider {
|
||||||
|
/**
|
||||||
|
* The diagnostic codes this provider handles
|
||||||
|
*/
|
||||||
|
diagnosticCodes: (string | number | undefined)[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a code action for the diagnostic, if applicable
|
||||||
|
*/
|
||||||
|
createCodeAction(context: CodeActionContext, diagnostic: Diagnostic): CodeAction | undefined;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user