Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f260c4658 | ||
|
|
dbf7752734 | ||
|
|
78231482f5 | ||
|
|
2e46c66878 | ||
|
|
39b9b14e3a | ||
|
|
71ff7b49c3 | ||
|
|
1a42526360 | ||
|
|
1cfe9f9f86 | ||
|
|
6641228870 | ||
|
|
c1ad4d14df | ||
|
|
6a47895521 | ||
|
|
c67c353245 | ||
|
|
c6d2036302 |
@@ -4,6 +4,27 @@
|
||||
|
||||
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:** `[email protected]` 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:
|
||||
@@ -199,14 +220,13 @@ src/connection.ts(1,43): error TS2307: Cannot find module 'vscode-languageserver
|
||||
|
||||
With `moduleResolution: "node16"`, TypeScript follows Node.js ESM resolution rules which require explicit `exports` for subpath imports like `vscode-languageserver/browser` and `vscode-languageserver/node`.
|
||||
|
||||
**Status:** Verified December 2025. Version 9.0.1 is available but ESM export support is not confirmed.
|
||||
**Status:** Partial - `.js` extensions added, waiting for stable `vscode-languageserver` release with ESM exports to complete migration.
|
||||
|
||||
**Current Decision:** The languageserver package is **deferred** from this migration until the upstream `vscode-languageserver` package adds proper ESM exports. It will continue using the old `moduleResolution: "node"` configuration.
|
||||
**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 vscode-languageserver to add ESM exports
|
||||
- Try upgrading to vscode-languageserver v9.x to see if exports were added
|
||||
- Use a bundler to work around the module resolution
|
||||
- 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
|
||||
|
||||
---
|
||||
@@ -218,7 +238,7 @@ With `moduleResolution: "node16"`, TypeScript follows Node.js ESM resolution rul
|
||||
| expressions | 1068 | ✅ Migrated |
|
||||
| workflow-parser | 292 | ✅ Migrated |
|
||||
| languageservice | 452 | ✅ Migrated |
|
||||
| languageserver | 6 files | ⏸️ Deferred (vscode-languageserver lacks ESM exports) |
|
||||
| languageserver | 31 | 🔶 Partial (`.js` extensions added, awaiting stable vscode-languageserver) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.31",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
|
||||
@@ -13,12 +13,14 @@ export enum ErrorType {
|
||||
ErrorTooFewParameters,
|
||||
ErrorTooManyParameters,
|
||||
ErrorUnrecognizedContext,
|
||||
ErrorUnrecognizedFunction
|
||||
ErrorUnrecognizedFunction,
|
||||
ErrorInvalidFormatString,
|
||||
ErrorFormatArgCountMismatch
|
||||
}
|
||||
|
||||
export class ExpressionError extends Error {
|
||||
constructor(private typ: ErrorType, private tok: Token) {
|
||||
super(`${errorDescription(typ)}: '${tokenString(tok)}'`);
|
||||
constructor(private typ: ErrorType, private tok: Token, customMessage?: string) {
|
||||
super(customMessage ?? `${errorDescription(typ)}: '${tokenString(tok)}'`);
|
||||
|
||||
this.pos = this.tok.range.start;
|
||||
}
|
||||
@@ -46,6 +48,10 @@ function errorDescription(typ: ErrorType): string {
|
||||
return "Unrecognized named-value";
|
||||
case ErrorType.ErrorUnrecognizedFunction:
|
||||
return "Unrecognized function";
|
||||
case ErrorType.ErrorInvalidFormatString:
|
||||
return "Invalid format string";
|
||||
case ErrorType.ErrorFormatArgCountMismatch:
|
||||
return "Format string argument count mismatch";
|
||||
default: // Should never reach here.
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {FeatureFlags} from "./features.js";
|
||||
|
||||
describe("FeatureFlags", () => {
|
||||
describe("isEnabled", () => {
|
||||
it("returns false by default when no options provided", () => {
|
||||
const flags = new FeatureFlags();
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false by default when empty options provided", () => {
|
||||
const flags = new FeatureFlags({});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when feature is explicitly enabled", () => {
|
||||
const flags = new FeatureFlags({missingInputsQuickfix: true});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when feature is explicitly disabled", () => {
|
||||
const flags = new FeatureFlags({missingInputsQuickfix: false});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when all is enabled", () => {
|
||||
const flags = new FeatureFlags({all: true});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
||||
});
|
||||
|
||||
it("explicit feature flag takes precedence over all:true", () => {
|
||||
const flags = new FeatureFlags({all: true, missingInputsQuickfix: false});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
||||
});
|
||||
|
||||
it("explicit feature flag takes precedence over all:false", () => {
|
||||
const flags = new FeatureFlags({all: false, missingInputsQuickfix: true});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnabledFeatures", () => {
|
||||
it("returns empty array when no features enabled", () => {
|
||||
const flags = new FeatureFlags();
|
||||
expect(flags.getEnabledFeatures()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns enabled features", () => {
|
||||
const flags = new FeatureFlags({missingInputsQuickfix: true});
|
||||
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
|
||||
});
|
||||
|
||||
it("returns all features when all is enabled", () => {
|
||||
const flags = new FeatureFlags({all: true});
|
||||
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"];
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ export {Expr} from "./ast.js";
|
||||
export {complete, CompletionItem} from "./completion.js";
|
||||
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary.js";
|
||||
export * as data from "./data/index.js";
|
||||
export {ExpressionError, ExpressionEvaluationError} from "./errors.js";
|
||||
export {ErrorType, ExpressionError, ExpressionEvaluationError} from "./errors.js";
|
||||
export {Evaluator} from "./evaluator.js";
|
||||
export {ExperimentalFeatureKey, ExperimentalFeatures, FeatureFlags} from "./features.js";
|
||||
export {wellKnownFunctions} from "./funcs.js";
|
||||
export {Lexer, Result} from "./lexer.js";
|
||||
export {Parser} from "./parser.js";
|
||||
export {validateFormatString} from "./validate-format.js";
|
||||
|
||||
@@ -15,6 +15,7 @@ import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors.js";
|
||||
import {ParseContext, validateFunction} from "./funcs.js";
|
||||
import {FunctionInfo} from "./funcs/info.js";
|
||||
import {Token, TokenType} from "./lexer.js";
|
||||
import {validateFormatString} from "./validate-format.js";
|
||||
|
||||
export class Parser {
|
||||
private extContexts: Map<string, boolean>;
|
||||
@@ -261,6 +262,30 @@ export class Parser {
|
||||
|
||||
validateFunction(this.context, identifier, args.length);
|
||||
|
||||
// Validate format() calls
|
||||
if (identifier.lexeme.toLowerCase() === "format" && args.length > 0) {
|
||||
const firstArg = args[0];
|
||||
if (firstArg instanceof Literal && firstArg.literal.kind === data.Kind.String) {
|
||||
const formatString = firstArg.literal.coerceString();
|
||||
const result = validateFormatString(formatString);
|
||||
|
||||
if (!result.valid) {
|
||||
throw new ExpressionError(ErrorType.ErrorInvalidFormatString, identifier);
|
||||
}
|
||||
|
||||
// Check argument count: format string uses {0} to {N}, so need N+1 args after format string
|
||||
const providedArgs = args.length - 1;
|
||||
const requiredArgs = result.maxArgIndex + 1;
|
||||
if (requiredArgs > providedArgs) {
|
||||
throw new ExpressionError(
|
||||
ErrorType.ErrorFormatArgCountMismatch,
|
||||
identifier,
|
||||
`Format string references {${result.maxArgIndex}} but only ${providedArgs} argument(s) provided`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new FunctionCall(identifier, args);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {validateFormatString} from "./validate-format.js";
|
||||
|
||||
describe("validateFormatString", () => {
|
||||
it("returns valid for simple placeholder", () => {
|
||||
const result = validateFormatString("{0}");
|
||||
expect(result).toEqual({valid: true, maxArgIndex: 0});
|
||||
});
|
||||
|
||||
it("returns valid for multiple placeholders", () => {
|
||||
const result = validateFormatString("{0} {1} {2}");
|
||||
expect(result).toEqual({valid: true, maxArgIndex: 2});
|
||||
});
|
||||
|
||||
it("returns valid for text with placeholder", () => {
|
||||
const result = validateFormatString("hello {0} world");
|
||||
expect(result).toEqual({valid: true, maxArgIndex: 0});
|
||||
});
|
||||
|
||||
it("returns valid for escaped left braces", () => {
|
||||
const result = validateFormatString("{{0}} {0}");
|
||||
expect(result).toEqual({valid: true, maxArgIndex: 0});
|
||||
});
|
||||
|
||||
it("returns valid for escaped right braces", () => {
|
||||
const result = validateFormatString("{0}}}");
|
||||
expect(result).toEqual({valid: true, maxArgIndex: 0});
|
||||
});
|
||||
|
||||
it("returns valid for no placeholders", () => {
|
||||
const result = validateFormatString("hello world");
|
||||
expect(result).toEqual({valid: true, maxArgIndex: -1});
|
||||
});
|
||||
|
||||
it("returns invalid for missing closing brace", () => {
|
||||
const result = validateFormatString("{0");
|
||||
expect(result).toEqual({valid: false, maxArgIndex: -1});
|
||||
});
|
||||
|
||||
it("returns invalid for empty placeholder", () => {
|
||||
const result = validateFormatString("{}");
|
||||
expect(result).toEqual({valid: false, maxArgIndex: -1});
|
||||
});
|
||||
|
||||
it("returns invalid for non-numeric placeholder", () => {
|
||||
const result = validateFormatString("{abc}");
|
||||
expect(result).toEqual({valid: false, maxArgIndex: -1});
|
||||
});
|
||||
|
||||
it("returns invalid for unescaped closing brace", () => {
|
||||
const result = validateFormatString("text } more");
|
||||
expect(result).toEqual({valid: false, maxArgIndex: -1});
|
||||
});
|
||||
|
||||
it("handles out-of-order placeholders", () => {
|
||||
const result = validateFormatString("{2} {0} {1}");
|
||||
expect(result).toEqual({valid: true, maxArgIndex: 2});
|
||||
});
|
||||
|
||||
it("handles repeated placeholders", () => {
|
||||
const result = validateFormatString("{0} {0} {0}");
|
||||
expect(result).toEqual({valid: true, maxArgIndex: 0});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Format string validation for format() function calls.
|
||||
* Validates format string syntax and argument count at parse time.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates a format string and returns the maximum placeholder index.
|
||||
*
|
||||
* @param formatString The format string to validate
|
||||
* @returns { valid: boolean, maxArgIndex: number } where maxArgIndex is -1 if no placeholders
|
||||
*/
|
||||
export function validateFormatString(formatString: string): {valid: boolean; maxArgIndex: number} {
|
||||
let maxIndex = -1;
|
||||
let i = 0;
|
||||
|
||||
while (i < formatString.length) {
|
||||
// Find next left brace
|
||||
let lbrace = -1;
|
||||
for (let j = i; j < formatString.length; j++) {
|
||||
if (formatString[j] === "{") {
|
||||
lbrace = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find next right brace
|
||||
let rbrace = -1;
|
||||
for (let j = i; j < formatString.length; j++) {
|
||||
if (formatString[j] === "}") {
|
||||
rbrace = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// No more braces
|
||||
if (lbrace < 0 && rbrace < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Left brace comes first (or only left brace exists)
|
||||
if (lbrace >= 0 && (rbrace < 0 || lbrace < rbrace)) {
|
||||
// Check if it's escaped
|
||||
if (lbrace + 1 < formatString.length && formatString[lbrace + 1] === "{") {
|
||||
// Escaped left brace
|
||||
i = lbrace + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// This is a placeholder opening - find the closing brace
|
||||
rbrace = -1;
|
||||
for (let j = lbrace + 1; j < formatString.length; j++) {
|
||||
if (formatString[j] === "}") {
|
||||
rbrace = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (rbrace < 0) {
|
||||
// Missing closing brace
|
||||
return {valid: false, maxArgIndex: -1};
|
||||
}
|
||||
|
||||
// Validate placeholder content (must be digits only)
|
||||
if (rbrace === lbrace + 1) {
|
||||
// Empty placeholder {}
|
||||
return {valid: false, maxArgIndex: -1};
|
||||
}
|
||||
|
||||
// Parse the index and validate it's all digits
|
||||
let index = 0;
|
||||
for (let j = lbrace + 1; j < rbrace; j++) {
|
||||
const c = formatString[j];
|
||||
if (c < "0" || c > "9") {
|
||||
// Non-numeric character
|
||||
return {valid: false, maxArgIndex: -1};
|
||||
}
|
||||
index = index * 10 + (c.charCodeAt(0) - "0".charCodeAt(0));
|
||||
}
|
||||
|
||||
if (index > maxIndex) {
|
||||
maxIndex = index;
|
||||
}
|
||||
|
||||
i = rbrace + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Right brace comes first (or only right brace exists)
|
||||
// Check if it's escaped
|
||||
if (rbrace + 1 < formatString.length && formatString[rbrace + 1] === "}") {
|
||||
// Escaped right brace
|
||||
i = rbrace + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unescaped right brace outside of placeholder
|
||||
return {valid: false, maxArgIndex: -1};
|
||||
}
|
||||
|
||||
return {valid: true, maxArgIndex: maxIndex};
|
||||
}
|
||||
Vendored
+34
-34
@@ -87,120 +87,120 @@
|
||||
{
|
||||
"expr": "format('{0')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: {0"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('{0', '')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: {0"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('{0}}', '')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: {0}}"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('{0}}}}', '')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: {0}}}}"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('0}')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: 0}"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('0}', '')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: 0}"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('{{0}')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: {{0}"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('{{0}', '')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: {{0}"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('{{{{0}')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: {{{{0}"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('{{{{0}', '')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: {{{{0}"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('}0{')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: }0{"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('}0{', '')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: }0{"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('}{0}')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: }{0}"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('}{0}', '')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: }{0}"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('{0}{', '')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string is invalid: {0}{"
|
||||
"kind": "parsing",
|
||||
"value": "Invalid format string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('{0}')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string references more arguments than were supplied: {0}"
|
||||
"kind": "parsing",
|
||||
"value": "Format string references {0} but only 0 argument(s) provided"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "format('{0}{1}', 'abc')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "The following format string references more arguments than were supplied: {0}{1}"
|
||||
"kind": "parsing",
|
||||
"value": "Format string references {1} but only 1 argument(s) provided"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -84,6 +84,11 @@ export interface InitializationOptions {
|
||||
* Desired log level
|
||||
*/
|
||||
logLevel?: LogLevel;
|
||||
|
||||
/**
|
||||
* Experimental features that are opt-in
|
||||
*/
|
||||
experimentalFeatures?: ExperimentalFeatures;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -100,6 +105,32 @@ const clientOptions: LanguageClientOptions = {
|
||||
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
|
||||
```
|
||||
|
||||
### Experimental Features
|
||||
|
||||
The language server supports opt-in experimental features via the `experimentalFeatures` initialization option. These features may change or be removed in between releases.
|
||||
|
||||
```typescript
|
||||
initializationOptions: {
|
||||
experimentalFeatures: {
|
||||
// Enable all experimental features
|
||||
all: true,
|
||||
|
||||
// Or enable specific features
|
||||
missingInputsQuickfix: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Available experimental features:**
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
|
||||
|
||||
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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.31",
|
||||
"version": "0.3.34",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -48,8 +48,8 @@
|
||||
"actions-languageserver": "./bin/actions-languageserver"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.31",
|
||||
"@actions/workflow-parser": "^0.3.31",
|
||||
"@actions/languageservice": "^0.3.34",
|
||||
"@actions/workflow-parser": "^0.3.34",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
|
||||
@@ -20,18 +20,19 @@ import {
|
||||
TextDocumentSyncKind
|
||||
} from "vscode-languageserver";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {getClient} from "./client";
|
||||
import {Commands} from "./commands";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {descriptionProvider} from "./description-provider";
|
||||
import {getFileProvider} from "./file-provider";
|
||||
import {InitializationOptions, RepositoryContext} from "./initializationOptions";
|
||||
import {onCompletion} from "./on-completion";
|
||||
import {ReadFileRequest, Requests} from "./request";
|
||||
import {getActionsMetadataProvider} from "./utils/action-metadata";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {timeOperation} from "./utils/timer";
|
||||
import {valueProviders} from "./value-providers";
|
||||
import {getClient} from "./client.js";
|
||||
import {Commands} from "./commands.js";
|
||||
import {contextProviders} from "./context-providers.js";
|
||||
import {descriptionProvider} from "./description-provider.js";
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {getFileProvider} from "./file-provider.js";
|
||||
import {InitializationOptions, RepositoryContext} from "./initializationOptions.js";
|
||||
import {onCompletion} from "./on-completion.js";
|
||||
import {ReadFileRequest, Requests} from "./request.js";
|
||||
import {getActionsMetadataProvider} from "./utils/action-metadata.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
import {timeOperation} from "./utils/timer.js";
|
||||
import {valueProviders} from "./value-providers.js";
|
||||
|
||||
export function initConnection(connection: Connection) {
|
||||
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
|
||||
@@ -41,6 +42,7 @@ export function initConnection(connection: Connection) {
|
||||
const cache = new TTLCache();
|
||||
|
||||
let hasWorkspaceFolderCapability = false;
|
||||
let featureFlags = new FeatureFlags();
|
||||
|
||||
// Register remote console logger with language service
|
||||
registerLogger(connection.console);
|
||||
@@ -64,6 +66,8 @@ export function initConnection(connection: Connection) {
|
||||
setLogLevel(options.logLevel);
|
||||
}
|
||||
|
||||
featureFlags = new FeatureFlags(options.experimentalFeatures);
|
||||
|
||||
const result: InitializeResult = {
|
||||
capabilities: {
|
||||
textDocumentSync: TextDocumentSyncKind.Full,
|
||||
@@ -91,6 +95,11 @@ export function initConnection(connection: Connection) {
|
||||
});
|
||||
|
||||
connection.onInitialized(() => {
|
||||
const enabledFeatures = featureFlags.getEnabledFeatures();
|
||||
if (enabledFeatures.length > 0) {
|
||||
connection.console.info(`Experimental features enabled: ${enabledFeatures.join(", ")}`);
|
||||
}
|
||||
|
||||
if (hasWorkspaceFolderCapability) {
|
||||
connection.workspace.onDidChangeWorkspaceFolders(() => {
|
||||
clearCache();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||
import {Mode} from "@actions/languageservice/context-providers/default";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {contextProviders} from "./context-providers.js";
|
||||
import {RepositoryContext} from "./initializationOptions.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
|
||||
describe("contextProviders", () => {
|
||||
const mockCache = new TTLCache();
|
||||
|
||||
@@ -3,11 +3,11 @@ import {ContextProviderConfig} from "@actions/languageservice";
|
||||
import {Mode} from "@actions/languageservice/context-providers/default";
|
||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {getSecrets} from "./context-providers/secrets";
|
||||
import {getStepsContext} from "./context-providers/steps";
|
||||
import {getVariables} from "./context-providers/variables";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {getSecrets} from "./context-providers/secrets.js";
|
||||
import {getStepsContext} from "./context-providers/steps.js";
|
||||
import {getVariables} from "./context-providers/variables.js";
|
||||
import {RepositoryContext} from "./initializationOptions.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
|
||||
export function contextProviders(
|
||||
client: Octokit | undefined,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {ActionOutputs, ActionReference} from "@actions/languageservice/action";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
|
||||
export async function getActionOutputs(
|
||||
octokit: Octokit,
|
||||
|
||||
@@ -6,10 +6,10 @@ import {warn} from "@actions/languageservice/log";
|
||||
import {isMapping, isString} from "@actions/workflow-parser";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
|
||||
import {RepositoryContext} from "../initializationOptions";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {errorStatus} from "../utils/error";
|
||||
import {getRepoPermission} from "../utils/repo-permission";
|
||||
import {RepositoryContext} from "../initializationOptions.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {errorStatus} from "../utils/error.js";
|
||||
import {getRepoPermission} from "../utils/repo-permission.js";
|
||||
|
||||
export async function getSecrets(
|
||||
workflowContext: WorkflowContext,
|
||||
|
||||
@@ -3,9 +3,9 @@ import {getStepsContext as getDefaultStepsContext} from "@actions/languageservic
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {getStepsContext} from "./steps";
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {getStepsContext} from "./steps.js";
|
||||
|
||||
const workflow = `
|
||||
name: Caching Primes
|
||||
|
||||
@@ -3,8 +3,8 @@ import {parseActionReference} from "@actions/languageservice/action";
|
||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {getActionOutputs} from "./action-outputs";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {getActionOutputs} from "./action-outputs.js";
|
||||
|
||||
export async function getStepsContext(
|
||||
octokit: Octokit,
|
||||
|
||||
@@ -7,10 +7,10 @@ import {isMapping, isString} from "@actions/workflow-parser";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {RequestError} from "@octokit/request-error";
|
||||
|
||||
import {RepositoryContext} from "../initializationOptions";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {errorStatus} from "../utils/error";
|
||||
import {getRepoPermission} from "../utils/repo-permission";
|
||||
import {RepositoryContext} from "../initializationOptions.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {errorStatus} from "../utils/error.js";
|
||||
import {getRepoPermission} from "../utils/repo-permission.js";
|
||||
|
||||
export async function getVariables(
|
||||
workflowContext: WorkflowContext,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {DescriptionProvider} from "@actions/languageservice/hover";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {getActionDescription} from "./description-providers/action-description";
|
||||
import {getActionInputDescription} from "./description-providers/action-input";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {getActionDescription} from "./description-providers/action-description.js";
|
||||
import {getActionInputDescription} from "./description-providers/action-input.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
|
||||
export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider {
|
||||
const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {getActionDescription} from "./action-description";
|
||||
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {getActionDescription} from "./action-description.js";
|
||||
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
|
||||
|
||||
const workflow = `
|
||||
name: Hello World
|
||||
|
||||
@@ -2,8 +2,8 @@ import {actionUrl, parseActionReference} from "@actions/languageservice/action";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
|
||||
export async function getActionDescription(client: Octokit, cache: TTLCache, step: Step): Promise<string | undefined> {
|
||||
if (!isActionStep(step)) {
|
||||
|
||||
@@ -2,10 +2,10 @@ import {StringToken} from "@actions/workflow-parser/templates/tokens/string-toke
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {getActionInputDescription} from "./action-input";
|
||||
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {getActionInputDescription} from "./action-input.js";
|
||||
|
||||
const workflow = `
|
||||
name: Hello World
|
||||
|
||||
@@ -4,8 +4,8 @@ import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
|
||||
export async function getActionInputDescription(
|
||||
client: Octokit,
|
||||
|
||||
@@ -2,7 +2,7 @@ import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
import * as vscodeURI from "vscode-uri";
|
||||
|
||||
export function getFileProvider(
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "vscode-languageserver/browser";
|
||||
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 */
|
||||
function isNode(): boolean {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {ExperimentalFeatures} from "@actions/expressions";
|
||||
import {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"
|
||||
*/
|
||||
gitHubApiUrl?: string;
|
||||
|
||||
/**
|
||||
* Experimental features that are opt-in.
|
||||
* Features listed here may change or be removed without notice.
|
||||
*/
|
||||
experimentalFeatures?: ExperimentalFeatures;
|
||||
}
|
||||
|
||||
export interface RepositoryContext {
|
||||
|
||||
@@ -2,12 +2,12 @@ import {complete} from "@actions/languageservice/complete";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {CompletionItem, Connection, Position} from "vscode-languageserver";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {getFileProvider} from "./file-provider";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {Requests} from "./request";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {valueProviders} from "./value-providers";
|
||||
import {contextProviders} from "./context-providers.js";
|
||||
import {getFileProvider} from "./file-provider.js";
|
||||
import {RepositoryContext} from "./initializationOptions.js";
|
||||
import {Requests} from "./request.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
import {valueProviders} from "./value-providers.js";
|
||||
|
||||
export async function onCompletion(
|
||||
connection: Connection,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
import {fetchActionMetadata} from "./action-metadata";
|
||||
import {TTLCache} from "./cache";
|
||||
import {fetchActionMetadata} from "./action-metadata.js";
|
||||
import {TTLCache} from "./cache.js";
|
||||
|
||||
// A simplified version of the action.yml file from actions/checkout
|
||||
const actionMetadataContent = `
|
||||
|
||||
@@ -3,8 +3,8 @@ import {ActionsMetadataProvider} from "@actions/languageservice";
|
||||
import {error} from "@actions/languageservice/log";
|
||||
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
|
||||
import {parse} from "yaml";
|
||||
import {TTLCache} from "./cache";
|
||||
import {errorMessage, errorStatus} from "./error";
|
||||
import {TTLCache} from "./cache.js";
|
||||
import {errorMessage, errorStatus} from "./error.js";
|
||||
|
||||
export function getActionsMetadataProvider(
|
||||
client: Octokit | undefined,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {error} from "@actions/languageservice/log";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {RepositoryContext} from "../initializationOptions";
|
||||
import {TTLCache} from "./cache";
|
||||
import {errorStatus} from "./error";
|
||||
import {getUsername} from "./username";
|
||||
import {RepositoryContext} from "../initializationOptions.js";
|
||||
import {TTLCache} from "./cache.js";
|
||||
import {errorStatus} from "./error.js";
|
||||
import {getUsername} from "./username.js";
|
||||
|
||||
export type RepoPermission = "admin" | "write" | "read" | "none";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "./cache";
|
||||
import {TTLCache} from "./cache.js";
|
||||
|
||||
export async function getUsername(octokit: Octokit, cache: TTLCache): Promise<string> {
|
||||
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 {ValueProviderKind} from "@actions/languageservice/value-providers/config";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {getActionInputValues} from "./value-providers/action-inputs";
|
||||
import {getEnvironments} from "./value-providers/job-environment";
|
||||
import {getRunnerLabels} from "./value-providers/runs-on";
|
||||
import {RepositoryContext} from "./initializationOptions.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
import {getActionInputValues} from "./value-providers/action-inputs.js";
|
||||
import {getEnvironments} from "./value-providers/job-environment.js";
|
||||
import {getRunnerLabels} from "./value-providers/runs-on.js";
|
||||
|
||||
export function valueProviders(
|
||||
client: Octokit | undefined,
|
||||
|
||||
@@ -3,8 +3,8 @@ import {WorkflowContext} from "@actions/languageservice/context/workflow-context
|
||||
import {Value} from "@actions/languageservice/value-providers/config";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
|
||||
export async function getActionInputs(
|
||||
client: Octokit,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Value} from "@actions/languageservice/value-providers/config";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
|
||||
export async function getEnvironments(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> {
|
||||
const environments = await cache.get(`${owner}/${name}/environments`, undefined, () =>
|
||||
|
||||
@@ -2,8 +2,8 @@ import {log} from "@actions/languageservice/log";
|
||||
import {Value} from "@actions/languageservice/value-providers/config";
|
||||
import {DEFAULT_RUNNER_LABELS} from "@actions/languageservice/value-providers/default";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {errorMessage} from "../utils/error";
|
||||
import {TTLCache} from "../utils/cache.js";
|
||||
import {errorMessage} from "../utils/error.js";
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noEmit": false,
|
||||
"outDir": "./dist"
|
||||
"outDir": "./dist",
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.31",
|
||||
"version": "0.3.34",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -47,8 +47,8 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.31",
|
||||
"@actions/workflow-parser": "^0.3.31",
|
||||
"@actions/expressions": "^0.3.34",
|
||||
"@actions/workflow-parser": "^0.3.34",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
|
||||
@@ -184,6 +184,107 @@ runs:
|
||||
|
||||
expect(labels).toContain("using");
|
||||
});
|
||||
|
||||
it("filters runs keys for node20 actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
// Should show Node.js action keys
|
||||
expect(labels).toContain("main");
|
||||
expect(labels).toContain("pre");
|
||||
expect(labels).toContain("post");
|
||||
expect(labels).toContain("pre-if");
|
||||
expect(labels).toContain("post-if");
|
||||
|
||||
// Should NOT show composite or docker keys
|
||||
expect(labels).not.toContain("steps");
|
||||
expect(labels).not.toContain("image");
|
||||
expect(labels).not.toContain("entrypoint");
|
||||
});
|
||||
|
||||
it("filters runs keys for composite actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: composite
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
// Should show composite action keys
|
||||
expect(labels).toContain("steps");
|
||||
|
||||
// Should NOT show Node.js or docker keys
|
||||
expect(labels).not.toContain("main");
|
||||
expect(labels).not.toContain("pre");
|
||||
expect(labels).not.toContain("post");
|
||||
expect(labels).not.toContain("image");
|
||||
});
|
||||
|
||||
it("filters runs keys for docker actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: docker
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
// Should show Docker action keys
|
||||
expect(labels).toContain("image");
|
||||
expect(labels).toContain("args");
|
||||
expect(labels).toContain("env");
|
||||
expect(labels).toContain("entrypoint");
|
||||
expect(labels).toContain("pre-entrypoint");
|
||||
expect(labels).toContain("post-entrypoint");
|
||||
|
||||
// Should NOT show Node.js or composite keys
|
||||
expect(labels).not.toContain("main");
|
||||
expect(labels).not.toContain("steps");
|
||||
});
|
||||
|
||||
it("prioritizes using when not set", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
// Find the using completion
|
||||
const usingCompletion = completions.find(c => c.label === "using");
|
||||
expect(usingCompletion).toBeDefined();
|
||||
|
||||
// It should have a sortText that makes it sort first
|
||||
expect(usingCompletion?.sortText).toBe("0_using");
|
||||
});
|
||||
|
||||
it("completes step keys inside composite action steps", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo hello
|
||||
shell: bash
|
||||
- |`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
// Should show step keys, not filtered by runs-level logic
|
||||
expect(labels).toContain("run");
|
||||
expect(labels).toContain("uses");
|
||||
expect(labels).toContain("shell");
|
||||
expect(labels).toContain("id");
|
||||
expect(labels).toContain("name");
|
||||
expect(labels).toContain("if");
|
||||
expect(labels).toContain("env");
|
||||
expect(labels).toContain("working-directory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding completions", () => {
|
||||
@@ -251,6 +352,38 @@ runs:
|
||||
expect(labels).not.toContain("jobs");
|
||||
});
|
||||
|
||||
it("includes descriptions from schema for completion items", async () => {
|
||||
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const authorCompletion = completions.find(c => c.label === "author");
|
||||
expect(authorCompletion).toBeDefined();
|
||||
expect(authorCompletion?.documentation).toBeDefined();
|
||||
expect((authorCompletion?.documentation as {value: string})?.value).toContain("author");
|
||||
});
|
||||
|
||||
it("includes descriptions for branding completion", async () => {
|
||||
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const brandingCompletion = completions.find(c => c.label === "branding");
|
||||
expect(brandingCompletion).toBeDefined();
|
||||
expect(brandingCompletion?.documentation).toBeDefined();
|
||||
expect((brandingCompletion?.documentation as {value: string})?.value).toContain("branding");
|
||||
});
|
||||
|
||||
it("falls back to type description when property has no description", async () => {
|
||||
// `inputs` uses shorthand form in schema: "inputs": "inputs-strict"
|
||||
// So the property has no description, but the type `inputs-strict` does
|
||||
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const inputsCompletion = completions.find(c => c.label === "inputs");
|
||||
expect(inputsCompletion).toBeDefined();
|
||||
expect(inputsCompletion?.documentation).toBeDefined();
|
||||
expect((inputsCompletion?.documentation as {value: string})?.value).toContain("Input parameters");
|
||||
});
|
||||
|
||||
it("does not route workflow files to action completion", async () => {
|
||||
const doc = TextDocument.create("file:///repo/.github/workflows/ci.yml", "yaml", 1, `o`);
|
||||
const completions = await complete(doc, {line: 0, character: 1});
|
||||
|
||||
@@ -723,16 +723,28 @@ jobs:
|
||||
expect(switchToList!.sortText).toEqual("zzz_switch_1");
|
||||
expect(switchToFull!.sortText).toEqual("zzz_switch_2");
|
||||
|
||||
// Escape hatches should have textEdit that restructures the YAML
|
||||
// Escape hatches should have textEdit at cursor position (for VS Code filtering compatibility)
|
||||
const listEdit = switchToList!.textEdit as TextEdit;
|
||||
const fullEdit = switchToFull!.textEdit as TextEdit;
|
||||
|
||||
expect(listEdit.newText).toEqual("runs-on:\n - ");
|
||||
expect(fullEdit.newText).toEqual("runs-on:\n ");
|
||||
// Main textEdit inserts newline and indented content at cursor position
|
||||
expect(listEdit.newText).toEqual("\n - ");
|
||||
expect(fullEdit.newText).toEqual("\n ");
|
||||
|
||||
// TextEdit range should cover from key start to cursor position
|
||||
expect(listEdit.range.start).toEqual({line: 3, character: 4});
|
||||
expect(fullEdit.range.start).toEqual({line: 3, character: 4});
|
||||
// TextEdit range should be at cursor position (empty range)
|
||||
expect(listEdit.range.start).toEqual({line: 3, character: 13});
|
||||
expect(listEdit.range.end).toEqual({line: 3, character: 13});
|
||||
expect(fullEdit.range.start).toEqual({line: 3, character: 13});
|
||||
expect(fullEdit.range.end).toEqual({line: 3, character: 13});
|
||||
|
||||
// additionalTextEdits should clean up the key portion
|
||||
expect(switchToList!.additionalTextEdits).toHaveLength(1);
|
||||
expect(switchToList!.additionalTextEdits![0].range.start).toEqual({line: 3, character: 4});
|
||||
expect(switchToList!.additionalTextEdits![0].range.end).toEqual({line: 3, character: 13});
|
||||
expect(switchToList!.additionalTextEdits![0].newText).toEqual("runs-on:");
|
||||
|
||||
expect(switchToFull!.additionalTextEdits).toHaveLength(1);
|
||||
expect(switchToFull!.additionalTextEdits![0].newText).toEqual("runs-on:");
|
||||
});
|
||||
|
||||
it("permissions shows only switch to full syntax (no sequence form)", async () => {
|
||||
@@ -824,9 +836,16 @@ jobs:
|
||||
|
||||
const switchToList = result.find(x => x.label === "(switch to list)");
|
||||
const textEdit = switchToList!.textEdit as TextEdit;
|
||||
const additionalEdits = switchToList!.additionalTextEdits!;
|
||||
|
||||
// Applying this edit to "runs-on: " should produce "runs-on:\n - "
|
||||
expect(textEdit.newText).toEqual("runs-on:\n - ");
|
||||
// Main textEdit inserts newline content at cursor
|
||||
expect(textEdit.newText).toEqual("\n - ");
|
||||
|
||||
// additionalTextEdits replaces "runs-on: " with "runs-on:"
|
||||
expect(additionalEdits).toHaveLength(1);
|
||||
expect(additionalEdits[0].newText).toEqual("runs-on:");
|
||||
|
||||
// Combined result when applied: "runs-on:\n - "
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+142
-13
@@ -38,6 +38,24 @@ import {Value, ValueProviderConfig} from "./value-providers/config.js";
|
||||
import {defaultValueProviders} from "./value-providers/default.js";
|
||||
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
|
||||
|
||||
/**
|
||||
* Valid keys for each action type under the `runs:` section.
|
||||
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
|
||||
*/
|
||||
const ACTION_NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
|
||||
const ACTION_COMPOSITE_KEYS = new Set(["using", "steps"]);
|
||||
const ACTION_DOCKER_KEYS = new Set([
|
||||
"using",
|
||||
"image",
|
||||
"args",
|
||||
"env",
|
||||
"entrypoint",
|
||||
"pre-entrypoint",
|
||||
"pre-if",
|
||||
"post-entrypoint",
|
||||
"post-if"
|
||||
]);
|
||||
|
||||
export function getExpressionInput(input: string, pos: number): string {
|
||||
// Find start marker around the cursor position
|
||||
let startPos = input.lastIndexOf(OPEN_EXPRESSION, pos);
|
||||
@@ -137,7 +155,7 @@ export async function complete(
|
||||
const indentString = " ".repeat(indentation.tabSize);
|
||||
|
||||
// YAML key/value completions
|
||||
const values = await getValues(
|
||||
let values = await getValues(
|
||||
token,
|
||||
keyToken,
|
||||
parent,
|
||||
@@ -147,6 +165,11 @@ export async function complete(
|
||||
schema
|
||||
);
|
||||
|
||||
// Filter action.yml `runs:` completions based on `using:` value
|
||||
if (isAction && parsedTemplate.value) {
|
||||
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
|
||||
}
|
||||
|
||||
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
|
||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
||||
values.push(...escapeHatches);
|
||||
@@ -192,6 +215,12 @@ export async function complete(
|
||||
textEdit = TextEdit.insert(position, newText);
|
||||
}
|
||||
|
||||
// Convert additionalTextEdits if present
|
||||
let additionalTextEdits: TextEdit[] | undefined;
|
||||
if (value.additionalTextEdits) {
|
||||
additionalTextEdits = value.additionalTextEdits.map(edit => TextEdit.replace(edit.range, edit.newText));
|
||||
}
|
||||
|
||||
const item: CompletionItem = {
|
||||
label: value.label,
|
||||
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
|
||||
@@ -202,7 +231,8 @@ export async function complete(
|
||||
value: value.description
|
||||
},
|
||||
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
|
||||
textEdit
|
||||
textEdit,
|
||||
additionalTextEdits
|
||||
};
|
||||
|
||||
return item;
|
||||
@@ -388,9 +418,19 @@ function getEscapeHatchCompletions(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Calculate the range from key start to current position
|
||||
// This covers "key: " so we can replace it with "key:\n - " or "key:\n "
|
||||
const editRange = {
|
||||
// For VS Code compatibility, we use a cursor-position range for the main textEdit
|
||||
// and additionalTextEdits to clean up the key portion. This prevents VS Code from
|
||||
// filtering out escape hatches based on the key text (e.g., "runs-on: ").
|
||||
//
|
||||
// Main textEdit: insert at cursor position (newline + indented content)
|
||||
// additionalTextEdits: replace "key: " with "key:" (removes trailing space)
|
||||
const cursorRange = {
|
||||
start: {line: position.line, character: position.character},
|
||||
end: {line: position.line, character: position.character}
|
||||
};
|
||||
|
||||
// Range from key start to cursor - used to replace "key: " with "key:" in additionalTextEdits
|
||||
const keyToCursorRange = {
|
||||
start: {line: keyRange.start.line - 1, character: keyRange.start.column - 1},
|
||||
end: {line: position.line, character: position.character}
|
||||
};
|
||||
@@ -399,11 +439,16 @@ function getEscapeHatchCompletions(
|
||||
results.push({
|
||||
label: "(switch to list)",
|
||||
sortText: "zzz_switch_1",
|
||||
filterText: keyName, // Allow filtering by key name
|
||||
textEdit: {
|
||||
range: editRange,
|
||||
newText: `${keyName}:\n${indentation}- `
|
||||
}
|
||||
range: cursorRange,
|
||||
newText: `\n${indentation}- `
|
||||
},
|
||||
additionalTextEdits: [
|
||||
{
|
||||
range: keyToCursorRange,
|
||||
newText: `${keyName}:`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -411,11 +456,16 @@ function getEscapeHatchCompletions(
|
||||
results.push({
|
||||
label: "(switch to mapping)",
|
||||
sortText: "zzz_switch_2",
|
||||
filterText: keyName, // Allow filtering by key name
|
||||
textEdit: {
|
||||
range: editRange,
|
||||
newText: `${keyName}:\n${indentation}`
|
||||
}
|
||||
range: cursorRange,
|
||||
newText: `\n${indentation}`
|
||||
},
|
||||
additionalTextEdits: [
|
||||
{
|
||||
range: keyToCursorRange,
|
||||
newText: `${keyName}:`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -576,3 +626,82 @@ function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: P
|
||||
// = 32 + 11 = 43
|
||||
return lengthOfContentBeforeCurrentLine + pos.character;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters action.yml `runs:` completions based on the `using:` value.
|
||||
*
|
||||
* When the user is completing keys under `runs:`:
|
||||
* - If `using: node20` is set, only show Node.js action keys
|
||||
* - If `using: composite` is set, only show composite action keys
|
||||
* - If `using: docker` is set, only show Docker action keys
|
||||
* - If `using:` is not set, show all keys but prioritize `using` first
|
||||
*/
|
||||
function filterActionRunsCompletions(values: Value[], path: TemplateToken[], root: TemplateToken): Value[] {
|
||||
// Find the runs mapping from the root
|
||||
let runsMapping: MappingToken | undefined;
|
||||
if (root instanceof MappingToken) {
|
||||
for (let i = 0; i < root.count; i++) {
|
||||
const {key, value} = root.get(i);
|
||||
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
|
||||
runsMapping = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!runsMapping) {
|
||||
return values;
|
||||
}
|
||||
|
||||
// Check if the runs mapping is in our path (meaning we're completing inside it)
|
||||
const isInsideRuns = path.some(token => token === runsMapping);
|
||||
if (!isInsideRuns) {
|
||||
return values;
|
||||
}
|
||||
|
||||
// Find where runsMapping is in the path
|
||||
const runsMappingIndex = path.indexOf(runsMapping);
|
||||
if (runsMappingIndex === -1) {
|
||||
return values;
|
||||
}
|
||||
|
||||
// Check if there's anything after runsMapping in the path
|
||||
// If so, we're nested deeper (e.g., inside steps sequence or a step mapping)
|
||||
if (runsMappingIndex < path.length - 1) {
|
||||
return values;
|
||||
}
|
||||
|
||||
// Get the using value from the runs mapping
|
||||
let usingValue: string | undefined;
|
||||
for (let i = 0; i < runsMapping.count; i++) {
|
||||
const {key, value} = runsMapping.get(i);
|
||||
if (key.toString().toLowerCase() === "using") {
|
||||
usingValue = value.toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which keys to allow
|
||||
let allowedKeys: Set<string>;
|
||||
|
||||
if (!usingValue) {
|
||||
// No using value set - show all keys but prioritize "using"
|
||||
return values.map(v => {
|
||||
if (v.label.toLowerCase() === "using") {
|
||||
return {...v, sortText: "0_using"}; // Sort first
|
||||
}
|
||||
return v;
|
||||
});
|
||||
} else if (usingValue.match(/^node\d+$/i)) {
|
||||
allowedKeys = ACTION_NODE_KEYS;
|
||||
} else if (usingValue.toLowerCase() === "composite") {
|
||||
allowedKeys = ACTION_COMPOSITE_KEYS;
|
||||
} else if (usingValue.toLowerCase() === "docker") {
|
||||
allowedKeys = ACTION_DOCKER_KEYS;
|
||||
} else {
|
||||
// Unknown using value - show all
|
||||
return values;
|
||||
}
|
||||
|
||||
// Filter to only allowed keys
|
||||
return values.filter(v => allowedKeys.has(v.label.toLowerCase()));
|
||||
}
|
||||
|
||||
@@ -53,6 +53,20 @@ ru|ns:
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("runs");
|
||||
});
|
||||
|
||||
it("shows description for author key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
au|thor: Me
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("author");
|
||||
expect(result?.contents).toContain("Documentation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runs properties", () => {
|
||||
@@ -145,6 +159,7 @@ brand|ing:
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("brand");
|
||||
expect(result?.contents).toContain("Documentation");
|
||||
});
|
||||
|
||||
it("shows description for icon key", async () => {
|
||||
|
||||
@@ -110,8 +110,7 @@ jobs:
|
||||
`;
|
||||
const result = await hover(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
// Cron description is now shown via diagnostics, not hover
|
||||
expect(result?.contents).toEqual("");
|
||||
expect(result?.contents).toEqual("Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00");
|
||||
});
|
||||
|
||||
it("on a cron mapping key", async () => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import {data, DescriptionDictionary, Parser} from "@actions/expressions";
|
||||
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
|
||||
import {Lexer} from "@actions/expressions/lexer";
|
||||
import {parseAction} from "@actions/workflow-parser/actions/action-parser";
|
||||
import {isString} from "@actions/workflow-parser";
|
||||
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
@@ -134,6 +136,17 @@ export async function hover(document: TextDocument, position: Position, config?:
|
||||
// Non-expression hover: show the schema description for the YAML key or value
|
||||
info(`Calculating hover for token with definition ${hoverToken.definition.key}`);
|
||||
|
||||
// Check for cron expression hover
|
||||
if (isString(hoverToken) && hoverToken.definition.key === "cron-pattern") {
|
||||
const cronDescription = getCronDescription(hoverToken.value);
|
||||
if (cronDescription) {
|
||||
return {
|
||||
contents: cronDescription,
|
||||
range: mapRange(hoverToken.range)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let description: string;
|
||||
if (!isAction && tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
|
||||
// Reusable workflow call: fetch the called workflow's input descriptions
|
||||
|
||||
@@ -347,4 +347,184 @@ runs:
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid key combinations based on using type", () => {
|
||||
it("reports error for node20 action with steps", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid - node20 with steps
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
steps:
|
||||
- run: echo "hello"
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
// Schema reports "Unexpected value 'steps'" for invalid keys
|
||||
expect(diagnostics.some(d => d.message.includes("steps"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports error for composite action with main", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid - composite with main
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "hello"
|
||||
shell: bash
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
// Schema reports "Unexpected value 'main'" for invalid keys
|
||||
expect(diagnostics.some(d => d.message.includes("main"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports error for docker action with steps", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid - docker with steps
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
steps:
|
||||
- run: echo "hello"
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
// Schema reports "Unexpected value 'steps'" for invalid keys
|
||||
expect(diagnostics.some(d => d.message.includes("steps"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports error for docker action with main", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid - docker with main
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
// Schema reports "Unexpected value 'main'" for invalid keys
|
||||
expect(diagnostics.some(d => d.message.includes("main"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports error for node20 action missing main", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid - node20 without main
|
||||
runs:
|
||||
using: node20
|
||||
pre: setup.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics.some(d => d.message.includes("main"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports error for node24 action missing main", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid - node24 without main
|
||||
runs:
|
||||
using: node24
|
||||
pre: setup.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message === "'main' is required for Node.js actions (using: node24)")).toBe(true);
|
||||
// Should NOT have duplicate schema error
|
||||
expect(diagnostics.filter(d => d.message.includes("main")).length).toBe(1);
|
||||
});
|
||||
|
||||
it("reports error for node24 action with only using (no narrowing key)", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid - node24 without main
|
||||
runs:
|
||||
using: node24
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message === "'main' is required for Node.js actions (using: node24)")).toBe(true);
|
||||
// Should NOT have the generic "not enough info" schema error
|
||||
expect(diagnostics.some(d => d.message.includes("There's not enough info"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports error for composite action missing steps", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid - composite without steps
|
||||
runs:
|
||||
using: composite
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message === "'steps' is required for composite actions (using: composite)")).toBe(
|
||||
true
|
||||
);
|
||||
// Should NOT have duplicate schema error
|
||||
expect(diagnostics.some(d => d.message.includes("There's not enough info"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports error for docker action missing image", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid - docker without image
|
||||
runs:
|
||||
using: docker
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message === "'image' is required for Docker actions (using: docker)")).toBe(true);
|
||||
// Should NOT have duplicate schema error
|
||||
expect(diagnostics.some(d => d.message.includes("There's not enough info"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports error for docker action with entrypoint but missing image", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid - docker without image
|
||||
runs:
|
||||
using: docker
|
||||
entrypoint: /entrypoint.sh
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message === "'image' is required for Docker actions (using: docker)")).toBe(true);
|
||||
// Should NOT have duplicate "Required property is missing: image" schema error
|
||||
expect(diagnostics.filter(d => d.message.includes("image")).length).toBe(1);
|
||||
});
|
||||
|
||||
it("lets schema handle missing using", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid - no using
|
||||
runs:
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
// Should have schema error about not enough info or unexpected value
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
// Should NOT have custom validation error (can't determine action type)
|
||||
expect(diagnostics.some(d => d.message.includes("is required for"))).toBe(false);
|
||||
});
|
||||
|
||||
it("lets schema handle invalid using value", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid - bad using value
|
||||
runs:
|
||||
using: not-supported
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
// Should have schema error about unexpected value
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
// Should NOT have custom validation error (unknown action type)
|
||||
expect(diagnostics.some(d => d.message.includes("is required for"))).toBe(false);
|
||||
expect(diagnostics.some(d => d.message.includes("is not valid for"))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
import {isMapping} from "@actions/workflow-parser";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {TemplateValidationError} from "@actions/workflow-parser/templates/template-validation-error";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
@@ -16,6 +18,31 @@ import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cac
|
||||
import {validateActionReference} from "./validate-action-reference.js";
|
||||
import {ValidationConfig} from "./validate.js";
|
||||
|
||||
/**
|
||||
* Valid keys for each action type under the `runs:` section.
|
||||
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
|
||||
*/
|
||||
const NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
|
||||
const COMPOSITE_KEYS = new Set(["using", "steps"]);
|
||||
const DOCKER_KEYS = new Set([
|
||||
"using",
|
||||
"image",
|
||||
"args",
|
||||
"env",
|
||||
"entrypoint",
|
||||
"pre-entrypoint",
|
||||
"pre-if",
|
||||
"post-entrypoint",
|
||||
"post-if"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Required keys for each action type (besides 'using').
|
||||
*/
|
||||
const NODE_REQUIRED_KEYS = ["main"];
|
||||
const COMPOSITE_REQUIRED_KEYS = ["steps"];
|
||||
const DOCKER_REQUIRED_KEYS = ["image"];
|
||||
|
||||
/**
|
||||
* Validates an action.yml file
|
||||
*
|
||||
@@ -38,8 +65,16 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
||||
return [];
|
||||
}
|
||||
|
||||
// Map parser errors to diagnostics
|
||||
for (const err of result.context.errors.getErrors()) {
|
||||
// Get schema errors
|
||||
const schemaErrors = result.context.errors.getErrors();
|
||||
|
||||
// Run custom runs key validation, which also filters redundant schema errors in place
|
||||
if (result.value) {
|
||||
diagnostics.push(...validateRunsKeysAndFilterErrors(result.value, schemaErrors));
|
||||
}
|
||||
|
||||
// Map remaining schema errors to diagnostics
|
||||
for (const err of schemaErrors) {
|
||||
const range = mapRange(err.range);
|
||||
|
||||
// Determine severity based on error type
|
||||
@@ -102,3 +137,133 @@ function findStepsSequence(root: TemplateToken): SequenceToken | undefined {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the keys under `runs:` are valid for the specified `using:` type.
|
||||
* Also filters out schema errors (in place) that this validation replaces with more specific messages.
|
||||
*/
|
||||
function validateRunsKeysAndFilterErrors(
|
||||
root: TemplateToken,
|
||||
schemaErrors: TemplateValidationError[] // mutated: redundant errors are removed
|
||||
): Diagnostic[] {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
// Find the runs mapping from the root
|
||||
let runsMapping: MappingToken | undefined;
|
||||
if (root instanceof MappingToken) {
|
||||
for (let i = 0; i < root.count; i++) {
|
||||
const {key, value} = root.get(i);
|
||||
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
|
||||
runsMapping = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!runsMapping) {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
// Get the using value from the runs mapping
|
||||
let usingValue: string | undefined;
|
||||
for (let i = 0; i < runsMapping.count; i++) {
|
||||
const {key, value} = runsMapping.get(i);
|
||||
if (key.toString().toLowerCase() === "using") {
|
||||
usingValue = value.toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!usingValue) {
|
||||
return diagnostics; // No using value, let schema validation handle it
|
||||
}
|
||||
|
||||
// Determine allowed keys, required keys, and action type name
|
||||
let allowedKeys: Set<string>;
|
||||
let requiredKeys: string[];
|
||||
let actionType: string;
|
||||
|
||||
if (usingValue.match(/^node\d+$/i)) {
|
||||
allowedKeys = NODE_KEYS;
|
||||
requiredKeys = NODE_REQUIRED_KEYS;
|
||||
actionType = "Node.js";
|
||||
} else if (usingValue.toLowerCase() === "composite") {
|
||||
allowedKeys = COMPOSITE_KEYS;
|
||||
requiredKeys = COMPOSITE_REQUIRED_KEYS;
|
||||
actionType = "composite";
|
||||
} else if (usingValue.toLowerCase() === "docker") {
|
||||
allowedKeys = DOCKER_KEYS;
|
||||
requiredKeys = DOCKER_REQUIRED_KEYS;
|
||||
actionType = "Docker";
|
||||
} else {
|
||||
return diagnostics; // Unknown type, let schema validation handle it
|
||||
}
|
||||
|
||||
// Get all present keys
|
||||
const presentKeys = new Set<string>();
|
||||
for (let i = 0; i < runsMapping.count; i++) {
|
||||
const {key} = runsMapping.get(i);
|
||||
presentKeys.add(key.toString().toLowerCase());
|
||||
}
|
||||
|
||||
// Check for invalid keys
|
||||
for (let i = 0; i < runsMapping.count; i++) {
|
||||
const {key} = runsMapping.get(i);
|
||||
const keyStr = key.toString().toLowerCase();
|
||||
|
||||
if (!allowedKeys.has(keyStr)) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(key.range),
|
||||
message: `'${key.toString()}' is not valid for ${actionType} actions (using: ${usingValue})`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing required keys
|
||||
for (const requiredKey of requiredKeys) {
|
||||
if (!presentKeys.has(requiredKey)) {
|
||||
// Find the 'using' key to report the error location
|
||||
let usingKeyRange = runsMapping.range;
|
||||
for (let i = 0; i < runsMapping.count; i++) {
|
||||
const {key} = runsMapping.get(i);
|
||||
if (key.toString().toLowerCase() === "using") {
|
||||
usingKeyRange = key.range;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(usingKeyRange),
|
||||
message: `'${requiredKey}' is required for ${actionType} actions (using: ${usingValue})`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove schema errors that we're replacing with more specific messages (mutate in place)
|
||||
for (let i = schemaErrors.length - 1; i >= 0; i--) {
|
||||
const err = schemaErrors[i];
|
||||
|
||||
// Keep errors not at the runs section start
|
||||
if (
|
||||
err.range?.start.line !== runsMapping.range?.start.line ||
|
||||
err.range?.start.column !== runsMapping.range?.start.column
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is an error we're replacing
|
||||
const isOneOfAmbiguity = err.rawMessage.startsWith("There's not enough info to determine");
|
||||
const isRequiredKey = /^Required property is missing: (main|steps|image)$/.test(err.rawMessage);
|
||||
|
||||
if (!isOneOfAmbiguity && !isRequiredKey) {
|
||||
continue; // Keep errors we're not replacing
|
||||
}
|
||||
|
||||
// Remove only if we have custom diagnostics for this
|
||||
if (diagnostics.length > 0) {
|
||||
schemaErrors.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
@@ -211,4 +211,104 @@ jobs:
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/github/vscode-github-actions/issues/542
|
||||
describe("YAML-quoted expressions", () => {
|
||||
it("allows double-quoted expression in job-if", async () => {
|
||||
// Quotes are needed when the expression contains a colon
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
publish:
|
||||
if: "\${{ startsWith(github.event.head_commit.message, 'chore: release') }}"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "expression-literal-text-in-condition"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("allows single-quoted expression in job-if", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
publish:
|
||||
if: '\${{ startsWith(github.event.head_commit.message, "chore: release") }}'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "expression-literal-text-in-condition"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("allows double-quoted expression in step-if", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: "\${{ contains(github.event.head_commit.message, 'skip: ci') }}"
|
||||
run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "expression-literal-text-in-condition"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("still errors when there is actual literal text outside expression", async () => {
|
||||
// Even with quotes, if there's literal text outside ${{ }}, it should error
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
if: "push == \${{ github.event_name }}"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "expression-literal-text-in-condition"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("errors on multiple expressions with literal text between them", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
if: "\${{ true }} and \${{ false }}"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "expression-literal-text-in-condition"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import {Diagnostic} from "vscode-languageserver-types";
|
||||
import {createDocument} from "./test-utils/document.js";
|
||||
import {validate} from "./validate.js";
|
||||
import {clearCache} from "./utils/workflow-cache.js";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
function hasMessageContaining(results: Diagnostic[], substring: string): boolean {
|
||||
return results.some(r => r.message.includes(substring));
|
||||
}
|
||||
|
||||
describe("format string validation", () => {
|
||||
describe("InvalidFormatString workflow validation", () => {
|
||||
it("errors on missing closing brace", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ format('{0', github.event_name) }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(hasMessageContaining(result, "Invalid format string")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on empty braces", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ format('{}', github.event_name) }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(hasMessageContaining(result, "Invalid format string")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on non-numeric placeholder", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ format('{abc}', github.event_name) }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(hasMessageContaining(result, "Invalid format string")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows valid format strings", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ format('{0} {1}', github.event_name, github.ref) }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(hasMessageContaining(result, "Invalid format string")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows escaped braces", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ format('{{0}} {0}', github.event_name) }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(hasMessageContaining(result, "Invalid format string")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormatArgCountMismatch workflow validation", () => {
|
||||
it("errors when placeholder exceeds arg count", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ format('{2}', 'arg0', 'arg1') }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(hasMessageContaining(result, "Format string references {2}")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors when referencing arg 0 with no args", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ format('{0}') }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(hasMessageContaining(result, "Format string references {0}")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows when arg count matches", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ format('{0} {1} {2}', 'a', 'b', 'c') }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(hasMessageContaining(result, "Format string references")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles no placeholders correctly", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ format('hello world') }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(hasMessageContaining(result, "Format string references")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips validation for dynamic format strings", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ format(env.FORMAT_STRING, 'arg') }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
// Should not have format errors since we can't validate dynamic strings
|
||||
expect(hasMessageContaining(result, "Invalid format string")).toBe(false);
|
||||
expect(hasMessageContaining(result, "Format string references")).toBe(false);
|
||||
});
|
||||
|
||||
it("validates nested format calls", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ format('{0}', format('{2}', 'a')) }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
// The inner format call has an error
|
||||
expect(hasMessageContaining(result, "Format string references {2}")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,12 @@ export interface Value {
|
||||
range: {start: {line: number; character: number}; end: {line: number; character: number}};
|
||||
newText: string;
|
||||
};
|
||||
|
||||
/** Additional text edits to apply after the main edit (e.g., cleanup edits) */
|
||||
additionalTextEdits?: {
|
||||
range: {start: {line: number; character: number}; end: {line: number; character: number}};
|
||||
newText: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export enum ValueProviderKind {
|
||||
|
||||
@@ -107,10 +107,14 @@ function mappingValues(
|
||||
for (const [key, value] of Object.entries(mappingDefinition.properties)) {
|
||||
let insertText: string | undefined;
|
||||
|
||||
let description: string | undefined;
|
||||
// Prefer the property's own description (from the schema's property definition),
|
||||
// fall back to the type definition's description if the property doesn't have one
|
||||
let description: string | undefined = value.description;
|
||||
if (value.type) {
|
||||
const typeDef = definitions[value.type];
|
||||
description = typeDef?.description;
|
||||
if (!description) {
|
||||
description = typeDef?.description;
|
||||
}
|
||||
|
||||
if (typeDef) {
|
||||
switch (typeDef.definitionType) {
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.31"
|
||||
"version": "0.3.34"
|
||||
}
|
||||
Generated
+9
-9
@@ -136,7 +136,7 @@
|
||||
},
|
||||
"expressions": {
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.31",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
@@ -396,11 +396,11 @@
|
||||
},
|
||||
"languageserver": {
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.31",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.31",
|
||||
"@actions/workflow-parser": "^0.3.31",
|
||||
"@actions/languageservice": "^0.3.34",
|
||||
"@actions/workflow-parser": "^0.3.34",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -940,11 +940,11 @@
|
||||
},
|
||||
"languageservice": {
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.31",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.31",
|
||||
"@actions/workflow-parser": "^0.3.31",
|
||||
"@actions/expressions": "^0.3.34",
|
||||
"@actions/workflow-parser": "^0.3.34",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
@@ -13345,10 +13345,10 @@
|
||||
},
|
||||
"workflow-parser": {
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.31",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.31",
|
||||
"@actions/expressions": "^0.3.34",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.31",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -48,7 +48,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.31",
|
||||
"@actions/expressions": "^0.3.34",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -267,6 +267,7 @@
|
||||
},
|
||||
"main": {
|
||||
"type": "non-empty-string",
|
||||
"required": true,
|
||||
"description": "The file that contains your action code. The runtime specified in using executes this file.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsmain)"
|
||||
},
|
||||
"pre": {
|
||||
|
||||
@@ -451,7 +451,13 @@ class TemplateReader {
|
||||
}
|
||||
|
||||
const allowedContext = definitionInfo.allowedContext;
|
||||
const raw = token.source || token.value;
|
||||
const isSingleLine = token.range === undefined || token.range.start.line === token.range.end.line;
|
||||
|
||||
// For single-line strings, use token.value (without YAML quotes) for expression detection,
|
||||
// because token.source includes quote characters that would be incorrectly detected as literal text.
|
||||
// For multi-line block scalars, use token.source directly because it makes position calculation easier
|
||||
// (no quote characters to handle, and token.source preserves the original line/column structure in YAML).
|
||||
const raw = isSingleLine ? token.value : token.source ?? token.value;
|
||||
|
||||
let startExpression: number = raw.indexOf(OPEN_EXPRESSION);
|
||||
if (startExpression < 0) {
|
||||
@@ -496,14 +502,17 @@ class TemplateReader {
|
||||
);
|
||||
|
||||
let tr = token.range!;
|
||||
if (tr.start.line === tr.end.line) {
|
||||
// If it's a single line expression, adjust the range to only cover the sub-expression
|
||||
if (isSingleLine) {
|
||||
// Single-line: Adjust the range to only cover the sub-expression.
|
||||
// Calculate offset to account for YAML quote characters.
|
||||
// For example, `"${{ expr }}"` has source with quotes, value without.
|
||||
const offset = (token.source ?? raw).indexOf(OPEN_EXPRESSION) - raw.indexOf(OPEN_EXPRESSION);
|
||||
tr = {
|
||||
start: {line: tr.start.line, column: tr.start.column + startExpression},
|
||||
end: {line: tr.end.line, column: tr.start.column + endExpression + 1}
|
||||
start: {line: tr.start.line, column: tr.start.column + startExpression + offset},
|
||||
end: {line: tr.end.line, column: tr.start.column + endExpression + 1 + offset}
|
||||
};
|
||||
} else {
|
||||
// Adjust the range to only cover the expression for multi-line strings
|
||||
// Multi-line: Adjust the range to only cover the expression
|
||||
const startRaw = raw.substring(0, startExpression);
|
||||
const adjustedStartLine = startRaw.split("\n").length;
|
||||
const beginningOfLine = startRaw.lastIndexOf("\n");
|
||||
|
||||
Reference in New Issue
Block a user