Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f260c4658 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.38",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
|
||||
@@ -2,7 +2,6 @@ import {DescriptionPair} from "./completion/descriptionDictionary.js";
|
||||
import {Dictionary, isDictionary} from "./data/dictionary.js";
|
||||
import {ExpressionData} from "./data/expressiondata.js";
|
||||
import {Evaluator} from "./evaluator.js";
|
||||
import {FeatureFlags} from "./features.js";
|
||||
import {wellKnownFunctions} from "./funcs.js";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
||||
import {Lexer, Token, TokenType} from "./lexer.js";
|
||||
@@ -27,15 +26,13 @@ export type CompletionItem = {
|
||||
* @param context Context available for the expression
|
||||
* @param extensionFunctions List of functions available
|
||||
* @param functions Optional map of functions to use during evaluation
|
||||
* @param featureFlags Optional feature flags to control which features are enabled
|
||||
* @returns Array of completion items
|
||||
*/
|
||||
export function complete(
|
||||
input: string,
|
||||
context: Dictionary,
|
||||
extensionFunctions: FunctionInfo[],
|
||||
functions?: Map<string, FunctionDefinition>,
|
||||
featureFlags?: FeatureFlags
|
||||
functions?: Map<string, FunctionDefinition>
|
||||
): CompletionItem[] {
|
||||
// Lex
|
||||
const lexer = new Lexer(input);
|
||||
@@ -66,7 +63,7 @@ export function complete(
|
||||
const result = contextKeys(context);
|
||||
|
||||
// Merge with functions
|
||||
result.push(...functionItems(extensionFunctions, featureFlags));
|
||||
result.push(...functionItems(extensionFunctions));
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -91,15 +88,10 @@ export function complete(
|
||||
return contextKeys(result);
|
||||
}
|
||||
|
||||
function functionItems(extensionFunctions: FunctionInfo[], featureFlags?: FeatureFlags): CompletionItem[] {
|
||||
function functionItems(extensionFunctions: FunctionInfo[]): CompletionItem[] {
|
||||
const result: CompletionItem[] = [];
|
||||
const flags = featureFlags ?? new FeatureFlags();
|
||||
|
||||
for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) {
|
||||
// Filter out case function if feature is disabled
|
||||
if (fdef.name === "case" && !flags.isEnabled("allowCaseFunction")) {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
label: fdef.name,
|
||||
description: fdef.description,
|
||||
|
||||
@@ -12,14 +12,15 @@ export enum ErrorType {
|
||||
ErrorExceededMaxLength,
|
||||
ErrorTooFewParameters,
|
||||
ErrorTooManyParameters,
|
||||
ErrorEvenParameters,
|
||||
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;
|
||||
}
|
||||
@@ -43,12 +44,14 @@ function errorDescription(typ: ErrorType): string {
|
||||
return "Too few parameters supplied";
|
||||
case ErrorType.ErrorTooManyParameters:
|
||||
return "Too many parameters supplied";
|
||||
case ErrorType.ErrorEvenParameters:
|
||||
return "Even number of parameters supplied, requires an odd number of parameters";
|
||||
case ErrorType.ErrorUnrecognizedContext:
|
||||
return "Unrecognized named-value";
|
||||
case ErrorType.ErrorUnrecognizedFunction:
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -51,12 +51,7 @@ describe("FeatureFlags", () => {
|
||||
|
||||
it("returns all features when all is enabled", () => {
|
||||
const flags = new FeatureFlags({all: true});
|
||||
expect(flags.getEnabledFeatures()).toEqual([
|
||||
"missingInputsQuickfix",
|
||||
"blockScalarChompingWarning",
|
||||
"actionScaffoldingSnippets",
|
||||
"allowCaseFunction"
|
||||
]);
|
||||
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,26 +21,6 @@ export interface ExperimentalFeatures {
|
||||
* @default false
|
||||
*/
|
||||
missingInputsQuickfix?: boolean;
|
||||
|
||||
/**
|
||||
* Warn when block scalars (| or >) use implicit clip chomping,
|
||||
* which adds a trailing newline that may be unintentional.
|
||||
* @default false
|
||||
*/
|
||||
blockScalarChompingWarning?: boolean;
|
||||
|
||||
/**
|
||||
* Enable action scaffolding snippets in action.yml files.
|
||||
* Offers Node.js, Composite, and Docker action scaffolds.
|
||||
* @default false
|
||||
*/
|
||||
actionScaffoldingSnippets?: boolean;
|
||||
|
||||
/**
|
||||
* Enable the case() function in expressions.
|
||||
* @default false
|
||||
*/
|
||||
allowCaseFunction?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,12 +32,7 @@ export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
|
||||
* All known experimental feature keys.
|
||||
* This list must be kept in sync with the ExperimentalFeatures interface.
|
||||
*/
|
||||
const allFeatureKeys: ExperimentalFeatureKey[] = [
|
||||
"missingInputsQuickfix",
|
||||
"blockScalarChompingWarning",
|
||||
"actionScaffoldingSnippets",
|
||||
"allowCaseFunction"
|
||||
];
|
||||
const allFeatureKeys: ExperimentalFeatureKey[] = ["missingInputsQuickfix"];
|
||||
|
||||
export class FeatureFlags {
|
||||
private readonly features: ExperimentalFeatures;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {ErrorType, ExpressionError} from "./errors.js";
|
||||
import {caseFunc} from "./funcs/case.js";
|
||||
import {contains} from "./funcs/contains.js";
|
||||
import {endswith} from "./funcs/endswith.js";
|
||||
import {format} from "./funcs/format.js";
|
||||
@@ -17,7 +16,6 @@ export type ParseContext = {
|
||||
};
|
||||
|
||||
export const wellKnownFunctions: {[name: string]: FunctionDefinition} = {
|
||||
case: caseFunc,
|
||||
contains: contains,
|
||||
endswith: endswith,
|
||||
format: format,
|
||||
@@ -55,9 +53,4 @@ export function validateFunction(context: ParseContext, identifier: Token, argCo
|
||||
if (argCount > f.maxArgs) {
|
||||
throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier);
|
||||
}
|
||||
|
||||
// case function requires an odd number of arguments
|
||||
if (name === "case" && argCount % 2 === 0) {
|
||||
throw new ExpressionError(ErrorType.ErrorEvenParameters, identifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import {ExpressionData, Kind} from "../data/index.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const caseFunc: FunctionDefinition = {
|
||||
name: "case",
|
||||
description:
|
||||
"`case( pred1, val1, pred2, val2, ..., default )`\n\nEvaluates predicates in order and returns the value corresponding to the first predicate that evaluates to `true`. If no predicate matches, it returns the last argument as the default value.",
|
||||
minArgs: 3,
|
||||
maxArgs: Number.MAX_SAFE_INTEGER,
|
||||
call: (...args: ExpressionData[]): ExpressionData => {
|
||||
// Evaluate predicate-result pairs
|
||||
for (let i = 0; i < args.length - 1; i += 2) {
|
||||
const predicate = args[i];
|
||||
|
||||
// Predicate must be a boolean
|
||||
if (predicate.kind !== Kind.Boolean) {
|
||||
throw new Error("case predicate must evaluate to a boolean value");
|
||||
}
|
||||
|
||||
// If predicate is true, return the corresponding result
|
||||
if (predicate.value) {
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
// No predicate matched, return default (last argument)
|
||||
return args[args.length - 1];
|
||||
}
|
||||
};
|
||||
@@ -2,9 +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
-157
@@ -1,157 +0,0 @@
|
||||
{
|
||||
"case": [
|
||||
{
|
||||
"expr": "case(true, 'first', 'default')",
|
||||
"result": { "kind": "String", "value": "first" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', 'default')",
|
||||
"result": { "kind": "String", "value": "default" }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, 'first', false, 'second', 'default')",
|
||||
"result": { "kind": "String", "value": "first" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', true, 'second', 'default')",
|
||||
"result": { "kind": "String", "value": "second" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', false, 'second', 'default')",
|
||||
"result": { "kind": "String", "value": "default" }
|
||||
},
|
||||
{
|
||||
"expr": "case(1 == 1, 'equal', 'not equal')",
|
||||
"result": { "kind": "String", "value": "equal" }
|
||||
},
|
||||
{
|
||||
"expr": "case(1 == 2, 'equal', 'not equal')",
|
||||
"result": { "kind": "String", "value": "not equal" }
|
||||
},
|
||||
{
|
||||
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
|
||||
"contexts": {
|
||||
"github": {
|
||||
"ref": "refs/heads/main",
|
||||
"event_name": "push"
|
||||
}
|
||||
},
|
||||
"result": { "kind": "String", "value": "main" }
|
||||
},
|
||||
{
|
||||
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
|
||||
"contexts": {
|
||||
"github": {
|
||||
"ref": "refs/heads/develop",
|
||||
"event_name": "pull_request"
|
||||
}
|
||||
},
|
||||
"result": { "kind": "String", "value": "pr" }
|
||||
},
|
||||
{
|
||||
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
|
||||
"contexts": {
|
||||
"github": {
|
||||
"ref": "refs/heads/develop",
|
||||
"event_name": "push"
|
||||
}
|
||||
},
|
||||
"result": { "kind": "String", "value": "other" }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, 123, 456)",
|
||||
"result": { "kind": "Number", "value": 123 }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 123, 456)",
|
||||
"result": { "kind": "Number", "value": 456 }
|
||||
},
|
||||
{
|
||||
"expr": "case(github.event == 'pull_request', 0, 1)",
|
||||
"contexts": {
|
||||
"github": {
|
||||
"event": "pull_request"
|
||||
}
|
||||
},
|
||||
"result": { "kind": "Number", "value": 0 }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 0, 1)",
|
||||
"result": { "kind": "Number", "value": 1 }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, false, true)",
|
||||
"result": { "kind": "Boolean", "value": false }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, false, true)",
|
||||
"result": { "kind": "Boolean", "value": true }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, '', 'default')",
|
||||
"result": { "kind": "String", "value": "" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', '')",
|
||||
"result": { "kind": "String", "value": "" }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, fromJSON('[1,2,3]'), 'default')",
|
||||
"result": { "kind": "Array", "value": [1, 2, 3] }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, fromJSON('{\"key\":\"value\"}'), 'default')",
|
||||
"result": { "kind": "Object", "value": { "key": "value" } }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', false, 'second', false, 'third', false, 'fourth', 'default')",
|
||||
"result": { "kind": "String", "value": "default" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', false, 'second', true, 'third', false, 'fourth', 'default')",
|
||||
"result": { "kind": "String", "value": "third" }
|
||||
},
|
||||
{
|
||||
"expr": "case('not a boolean', 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(1, 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(null, 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(fromJSON('[]'), 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(fromJSON('{}'), 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(true, 'first', false, 'second')",
|
||||
"err": {
|
||||
"kind": "parsing",
|
||||
"value": "Even number of parameters supplied, requires an odd number of parameters: 'case'. Located at position 1 within expression: case(true, 'first', false, 'second')"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -126,7 +126,6 @@ initializationOptions: {
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
|
||||
| `blockScalarChompingWarning` | Warn when block scalars (`\|` or `>`) use implicit clip chomping, which adds a trailing newline that may be unintentional |
|
||||
|
||||
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.38",
|
||||
"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.38",
|
||||
"@actions/workflow-parser": "^0.3.38",
|
||||
"@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",
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import {
|
||||
documentLinks,
|
||||
getCodeActions,
|
||||
getInlayHints,
|
||||
hover,
|
||||
validate,
|
||||
ValidationConfig
|
||||
} from "@actions/languageservice";
|
||||
import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice";
|
||||
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
||||
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {
|
||||
CodeAction,
|
||||
CodeActionKind,
|
||||
CodeActionParams,
|
||||
CompletionItem,
|
||||
Connection,
|
||||
DocumentLink,
|
||||
@@ -89,10 +79,7 @@ export function initConnection(connection: Connection) {
|
||||
documentLinkProvider: {
|
||||
resolveProvider: false
|
||||
},
|
||||
inlayHintProvider: true,
|
||||
codeActionProvider: {
|
||||
codeActionKinds: [CodeActionKind.QuickFix]
|
||||
}
|
||||
inlayHintProvider: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,8 +123,7 @@ export function initConnection(connection: Connection) {
|
||||
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
|
||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
|
||||
}),
|
||||
featureFlags
|
||||
})
|
||||
};
|
||||
|
||||
const result = await validate(textDocument, config);
|
||||
@@ -190,17 +176,6 @@ export function initConnection(connection: Connection) {
|
||||
});
|
||||
});
|
||||
|
||||
connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
|
||||
const document = getDocument(documents, params.textDocument);
|
||||
return getCodeActions({
|
||||
uri: params.textDocument.uri,
|
||||
documentContent: document.getText(),
|
||||
diagnostics: params.context.diagnostics,
|
||||
only: params.context.only,
|
||||
featureFlags
|
||||
});
|
||||
});
|
||||
|
||||
// Make the text document manager listen on the connection
|
||||
// for open, change and close text document events
|
||||
documents.listen(connection);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.38",
|
||||
"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.38",
|
||||
"@actions/workflow-parser": "^0.3.38",
|
||||
"@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",
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
|
||||
import {CodeActionContext, CodeActionProvider} from "./types.js";
|
||||
import {getQuickfixProviders} from "./quickfix/quickfix-providers.js";
|
||||
|
||||
export interface CodeActionParams {
|
||||
uri: string;
|
||||
documentContent: string;
|
||||
diagnostics: Diagnostic[];
|
||||
only?: string[];
|
||||
featureFlags?: FeatureFlags;
|
||||
}
|
||||
|
||||
export function getCodeActions(params: CodeActionParams): CodeAction[] {
|
||||
const actions: CodeAction[] = [];
|
||||
const context: CodeActionContext = {
|
||||
uri: params.uri,
|
||||
documentContent: params.documentContent,
|
||||
featureFlags: params.featureFlags
|
||||
};
|
||||
|
||||
// Build providers map based on feature flags
|
||||
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
|
||||
[CodeActionKind.QuickFix, getQuickfixProviders(params.featureFlags)]
|
||||
// [CodeActionKind.Refactor, getRefactorProviders(params.featureFlags)],
|
||||
// [CodeActionKind.Source, getSourceProviders(params.featureFlags)],
|
||||
// etc
|
||||
]);
|
||||
|
||||
// Filter to requested kinds, or use all if none specified
|
||||
const requestedKinds = params.only;
|
||||
const kindsToCheck = requestedKinds
|
||||
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
|
||||
: [...providersByKind.keys()];
|
||||
|
||||
for (const diagnostic of params.diagnostics) {
|
||||
for (const kind of kindsToCheck) {
|
||||
const providers = providersByKind.get(kind) ?? [];
|
||||
for (const provider of providers) {
|
||||
if (provider.diagnosticCodes.includes(diagnostic.code)) {
|
||||
const action = provider.createCodeAction(context, diagnostic);
|
||||
if (action) {
|
||||
action.kind = kind;
|
||||
action.diagnostics = [diagnostic];
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export type {CodeActionContext, CodeActionProvider} from "./types.js";
|
||||
@@ -1,245 +0,0 @@
|
||||
import {isMapping} from "@actions/workflow-parser";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {CodeAction, Position, TextEdit} from "vscode-languageserver-types";
|
||||
import {error} from "../../log.js";
|
||||
import {findToken} from "../../utils/find-token.js";
|
||||
import {getOrParseWorkflow} from "../../utils/workflow-cache.js";
|
||||
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action-reference.js";
|
||||
import {CodeActionContext, CodeActionProvider} from "../types.js";
|
||||
|
||||
/**
|
||||
* Information extracted from a step token needed to generate edits
|
||||
*/
|
||||
interface StepInfo {
|
||||
/** Column where step keys start (1-indexed), e.g., the column of "uses:" */
|
||||
stepKeyColumn: number;
|
||||
/** End line of the step (1-indexed) */
|
||||
stepEndLine: number;
|
||||
/** Detected indent size (spaces per level) */
|
||||
indentSize: number;
|
||||
/** Information about existing with: block, if present */
|
||||
withInfo?: {
|
||||
keyColumn: number;
|
||||
keyEndLine: number;
|
||||
valueEndLine: number;
|
||||
hasChildren: boolean;
|
||||
/** Column of first child input (1-indexed), for indentation detection */
|
||||
firstChildColumn?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const addMissingInputsProvider: CodeActionProvider = {
|
||||
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
|
||||
|
||||
createCodeAction(context: CodeActionContext, diagnostic): CodeAction | undefined {
|
||||
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse the document to get the step token
|
||||
const stepInfo = getStepInfo(context, diagnostic.range.start);
|
||||
if (!stepInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const edits = createInputEdits(data.missingInputs, stepInfo);
|
||||
if (!edits || edits.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const inputNames = data.missingInputs.map(i => i.name).join(", ");
|
||||
|
||||
return {
|
||||
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
|
||||
edit: {
|
||||
changes: {
|
||||
[context.uri]: edits
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the document and extract step information needed for generating edits.
|
||||
* Returns undefined if parsing fails or the step token cannot be found.
|
||||
*/
|
||||
function getStepInfo(context: CodeActionContext, diagnosticPosition: Position): StepInfo | undefined {
|
||||
// Parse the document (uses cache if available from validation)
|
||||
const file = {name: context.uri, content: context.documentContent};
|
||||
const parseResult = getOrParseWorkflow(file, context.uri);
|
||||
|
||||
if (!parseResult.value) {
|
||||
error("Failed to parse workflow for missing inputs quickfix");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Find the token at the diagnostic position
|
||||
const {path} = findToken(diagnosticPosition, parseResult.value);
|
||||
|
||||
// Walk up the path to find the step token (regular-step)
|
||||
const stepToken = findStepInPath(path);
|
||||
if (!stepToken) {
|
||||
error("Could not find step token for missing inputs quickfix");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return extractStepInfo(stepToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the step token (regular-step) in the token path
|
||||
*/
|
||||
function findStepInPath(path: TemplateToken[]): MappingToken | undefined {
|
||||
// Walk backwards through path to find the step
|
||||
for (let i = path.length - 1; i >= 0; i--) {
|
||||
if (path[i].definition?.key === "regular-step" && isMapping(path[i])) {
|
||||
return path[i] as MappingToken;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract position and indentation info from a step token
|
||||
*/
|
||||
function extractStepInfo(stepToken: MappingToken): StepInfo | undefined {
|
||||
if (!stepToken.range) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the column of the first key in the step
|
||||
let stepKeyColumn = stepToken.range.start.column;
|
||||
if (stepToken.count > 0) {
|
||||
const firstEntry = stepToken.get(0);
|
||||
if (firstEntry?.key.range) {
|
||||
stepKeyColumn = firstEntry.key.range.start.column;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the with: block if present
|
||||
let withKey: ScalarToken | undefined;
|
||||
let withToken: TemplateToken | undefined;
|
||||
for (const {key, value} of stepToken) {
|
||||
if (key.toString() === "with") {
|
||||
withKey = key;
|
||||
withToken = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate indent size
|
||||
let indentSize = 2; // Default
|
||||
let withInfo: StepInfo["withInfo"];
|
||||
|
||||
if (withKey?.range && withToken?.range) {
|
||||
// Has with: block - extract its info
|
||||
const hasChildren = isMapping(withToken) && withToken.count > 0;
|
||||
let firstChildColumn: number | undefined;
|
||||
|
||||
if (hasChildren) {
|
||||
const firstChild = (withToken as MappingToken).get(0);
|
||||
if (firstChild?.key.range) {
|
||||
firstChildColumn = firstChild.key.range.start.column;
|
||||
// Detect indent size from with: children
|
||||
indentSize = firstChildColumn - withKey.range.start.column;
|
||||
}
|
||||
}
|
||||
|
||||
withInfo = {
|
||||
keyColumn: withKey.range.start.column,
|
||||
keyEndLine: withKey.range.end.line,
|
||||
valueEndLine: withToken.range.end.line,
|
||||
hasChildren,
|
||||
firstChildColumn
|
||||
};
|
||||
} else {
|
||||
// No with: block - detect indent size using heuristics
|
||||
// Based on the step key column position, estimate indent size
|
||||
// 2-space indent files typically have step keys at column 7
|
||||
// 4-space indent files typically have step keys at column 15
|
||||
const zeroIndexedCol = stepKeyColumn - 1;
|
||||
if (zeroIndexedCol >= 10) {
|
||||
indentSize = 4;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stepKeyColumn,
|
||||
stepEndLine: stepToken.range.end.line,
|
||||
indentSize,
|
||||
withInfo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text edits to add missing inputs
|
||||
*/
|
||||
function createInputEdits(missingInputs: MissingInputsDiagnosticData["missingInputs"], stepInfo: StepInfo): TextEdit[] {
|
||||
const formatInputLines = (indent: string) =>
|
||||
missingInputs.map(input => {
|
||||
const value = input.default ?? '""';
|
||||
return `${indent}${input.name}: ${value}`;
|
||||
});
|
||||
|
||||
if (stepInfo.withInfo) {
|
||||
// `with:` exists - add inputs to existing block
|
||||
const withIndent = stepInfo.withInfo.keyColumn - 1; // 0-indexed
|
||||
const inputIndentSize = stepInfo.withInfo.firstChildColumn
|
||||
? stepInfo.withInfo.firstChildColumn - stepInfo.withInfo.keyColumn
|
||||
: stepInfo.indentSize;
|
||||
|
||||
const inputIndent = " ".repeat(withIndent + inputIndentSize);
|
||||
const inputLines = formatInputLines(inputIndent);
|
||||
|
||||
// Calculate insert position
|
||||
let insertLine: number;
|
||||
if (stepInfo.withInfo.hasChildren) {
|
||||
// Insert after the last child (at end of with: block)
|
||||
// valueEndLine is 1-indexed, we want 0-indexed for Position
|
||||
insertLine = stepInfo.withInfo.valueEndLine - 1;
|
||||
} else {
|
||||
// Empty with: block - insert on the next line after with:
|
||||
// keyEndLine is 1-indexed, convert to 0-indexed and go to next line
|
||||
insertLine = stepInfo.withInfo.keyEndLine;
|
||||
}
|
||||
|
||||
const insertPosition: Position = {
|
||||
line: insertLine,
|
||||
character: 0
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
range: {start: insertPosition, end: insertPosition},
|
||||
newText: inputLines.map(line => line + "\n").join("")
|
||||
}
|
||||
];
|
||||
} else {
|
||||
// No `with:` key - add `with:` at the same level as other step keys
|
||||
const withKeyIndent = stepInfo.stepKeyColumn - 1; // 0-indexed (columns are 1-based)
|
||||
|
||||
const withIndent = " ".repeat(withKeyIndent);
|
||||
const inputIndent = " ".repeat(withKeyIndent + stepInfo.indentSize);
|
||||
const inputLines = formatInputLines(inputIndent);
|
||||
|
||||
const newText = `${withIndent}with:\n` + inputLines.map(line => `${line}\n`).join("");
|
||||
|
||||
// Insert at end of step
|
||||
// stepEndLine is 1-indexed, we want 0-indexed and insert before the line after
|
||||
const insertPosition: Position = {
|
||||
line: stepInfo.stepEndLine - 1,
|
||||
character: 0
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
range: {start: insertPosition, end: insertPosition},
|
||||
newText
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {CodeActionProvider} from "../types.js";
|
||||
import {addMissingInputsProvider} from "./add-missing-inputs.js";
|
||||
|
||||
export function getQuickfixProviders(featureFlags?: FeatureFlags): CodeActionProvider[] {
|
||||
const providers: CodeActionProvider[] = [];
|
||||
|
||||
if (featureFlags?.isEnabled("missingInputsQuickfix")) {
|
||||
providers.push(addMissingInputsProvider);
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import * as path from "path";
|
||||
import {fileURLToPath} from "url";
|
||||
import {loadTestCases, runTestCase} from "./runner.js";
|
||||
import {ValidationConfig} from "../../validate.js";
|
||||
import {ActionMetadata, ActionReference} from "../../action.js";
|
||||
import {clearCache} from "../../utils/workflow-cache.js";
|
||||
|
||||
// ESM-compatible __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Mock action metadata provider for tests
|
||||
const validationConfig: ValidationConfig = {
|
||||
actionsMetadataProvider: {
|
||||
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
|
||||
const key = `${ref.owner}/${ref.name}@${ref.ref}`;
|
||||
|
||||
const metadata: Record<string, ActionMetadata> = {
|
||||
"actions/cache@v1": {
|
||||
name: "Cache",
|
||||
description: "Cache dependencies",
|
||||
inputs: {
|
||||
path: {
|
||||
description: "A list of files to cache",
|
||||
required: true
|
||||
},
|
||||
key: {
|
||||
description: "Cache key",
|
||||
required: true
|
||||
},
|
||||
"restore-keys": {
|
||||
description: "Restore keys",
|
||||
required: false
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions/setup-node@v3": {
|
||||
name: "Setup Node",
|
||||
description: "Setup Node.js",
|
||||
inputs: {
|
||||
"node-version": {
|
||||
description: "Node version",
|
||||
required: true,
|
||||
default: "16"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Promise.resolve(metadata[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Point to the source testdata directory
|
||||
const testdataDir = path.join(__dirname, "testdata");
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("code action golden tests", () => {
|
||||
const testCases = loadTestCases(testdataDir);
|
||||
|
||||
if (testCases.length === 0) {
|
||||
it.todo("no test cases found - add .yml files to testdata/");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, async () => {
|
||||
const result = await runTestCase(testCase, validationConfig);
|
||||
|
||||
if (!result.passed) {
|
||||
let errorMessage = result.error || "Test failed";
|
||||
|
||||
if (result.expected !== undefined && result.actual !== undefined) {
|
||||
errorMessage += "\n\n";
|
||||
errorMessage += "=== EXPECTED (golden file) ===\n";
|
||||
errorMessage += result.expected;
|
||||
errorMessage += "\n\n";
|
||||
errorMessage += "=== ACTUAL ===\n";
|
||||
errorMessage += result.actual;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,231 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import {TextEdit} from "vscode-languageserver-types";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {validate, ValidationConfig} from "../../validate.js";
|
||||
import {getCodeActions, CodeActionParams} from "../code-actions.js";
|
||||
|
||||
// Marker pattern: # want "diagnostic message" fix="code-action-name"
|
||||
const MARKER_PATTERN = /#\s*want\s+"([^"]+)"(?:\s+fix="([^"]+)")?/;
|
||||
|
||||
export interface TestCase {
|
||||
name: string;
|
||||
inputPath: string;
|
||||
goldenPath: string;
|
||||
input: string;
|
||||
golden: string;
|
||||
markers: Marker[];
|
||||
}
|
||||
|
||||
export interface Marker {
|
||||
line: number;
|
||||
message: string;
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markers from input file content
|
||||
*/
|
||||
export function parseMarkers(content: string): Marker[] {
|
||||
const lines = content.split("\n");
|
||||
const markers: Marker[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(MARKER_PATTERN);
|
||||
if (match) {
|
||||
markers.push({
|
||||
line: i,
|
||||
message: match[1],
|
||||
fix: match[2]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markers from content (for processing)
|
||||
*/
|
||||
export function stripMarkers(content: string): string {
|
||||
return content
|
||||
.split("\n")
|
||||
.map(line => line.replace(MARKER_PATTERN, "").trimEnd())
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all test cases from a testdata directory
|
||||
*/
|
||||
export function loadTestCases(testdataDir: string): TestCase[] {
|
||||
const testCases: TestCase[] = [];
|
||||
|
||||
function walkDir(dir: string) {
|
||||
const entries = fs.readdirSync(dir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".yml") && !entry.name.endsWith(".golden.yml")) {
|
||||
const goldenPath = fullPath.replace(".yml", ".golden.yml");
|
||||
|
||||
if (fs.existsSync(goldenPath)) {
|
||||
const input = fs.readFileSync(fullPath, "utf-8");
|
||||
const golden = fs.readFileSync(goldenPath, "utf-8");
|
||||
|
||||
testCases.push({
|
||||
name: path.relative(testdataDir, fullPath),
|
||||
inputPath: fullPath,
|
||||
goldenPath,
|
||||
input,
|
||||
golden,
|
||||
markers: parseMarkers(input)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkDir(testdataDir);
|
||||
return testCases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply text edits to a document
|
||||
*/
|
||||
export function applyEdits(content: string, edits: TextEdit[]): string {
|
||||
// Sort edits in reverse order by position to apply from bottom to top
|
||||
const sortedEdits = [...edits].sort((a, b) => {
|
||||
if (b.range.start.line !== a.range.start.line) {
|
||||
return b.range.start.line - a.range.start.line;
|
||||
}
|
||||
return b.range.start.character - a.range.start.character;
|
||||
});
|
||||
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const edit of sortedEdits) {
|
||||
const startLine = edit.range.start.line;
|
||||
const startChar = edit.range.start.character;
|
||||
const endLine = edit.range.end.line;
|
||||
const endChar = edit.range.end.character;
|
||||
|
||||
const before = lines[startLine].slice(0, startChar);
|
||||
const after = lines[endLine].slice(endChar);
|
||||
|
||||
const newLines = edit.newText.split("\n");
|
||||
newLines[0] = before + newLines[0];
|
||||
newLines[newLines.length - 1] = newLines[newLines.length - 1] + after;
|
||||
|
||||
lines.splice(startLine, endLine - startLine + 1, ...newLines);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single test case
|
||||
*/
|
||||
export async function runTestCase(testCase: TestCase, validationConfig: ValidationConfig): Promise<TestResult> {
|
||||
const strippedInput = stripMarkers(testCase.input);
|
||||
const document = TextDocument.create("file:///test.yml", "yaml", 1, strippedInput);
|
||||
|
||||
// 1. Validate and get diagnostics
|
||||
const diagnostics = await validate(document, validationConfig);
|
||||
|
||||
// 2. Verify all expected diagnostics are present
|
||||
const missingDiagnostics: string[] = [];
|
||||
for (const marker of testCase.markers) {
|
||||
const found = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
|
||||
if (!found) {
|
||||
missingDiagnostics.push(`line ${marker.line}: "${marker.message}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingDiagnostics.length > 0) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: false,
|
||||
error: `Missing expected diagnostics:\n ${missingDiagnostics.join(
|
||||
"\n "
|
||||
)}\n\nActual diagnostics:\n ${diagnostics.map(d => `line ${d.range.start.line}: "${d.message}"`).join("\n ")}`
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Collect all edits from all matching code actions
|
||||
const allEdits: TextEdit[] = [];
|
||||
|
||||
for (const marker of testCase.markers) {
|
||||
if (!marker.fix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const diagnostic = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
|
||||
|
||||
if (!diagnostic) {
|
||||
continue; // Already reported above
|
||||
}
|
||||
|
||||
const params: CodeActionParams = {
|
||||
uri: document.uri,
|
||||
documentContent: strippedInput,
|
||||
diagnostics: [diagnostic],
|
||||
featureFlags: new FeatureFlags({all: true})
|
||||
};
|
||||
|
||||
const actions = getCodeActions(params);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- marker.fix is checked at the start of the loop
|
||||
const matchingAction = actions.find(a => a.title.toLowerCase().includes(marker.fix!.toLowerCase()));
|
||||
|
||||
if (!matchingAction) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: false,
|
||||
error: `Code action "${marker.fix}" not found for diagnostic on line ${marker.line}.\nAvailable actions: ${
|
||||
actions.map(a => a.title).join(", ") || "(none)"
|
||||
}`
|
||||
};
|
||||
}
|
||||
|
||||
if (!matchingAction.edit?.changes) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: false,
|
||||
error: `Code action "${marker.fix}" has no edits`
|
||||
};
|
||||
}
|
||||
|
||||
const edits = matchingAction.edit.changes[document.uri] || [];
|
||||
allEdits.push(...edits);
|
||||
}
|
||||
|
||||
// 4. Apply all edits and compare to golden file
|
||||
const actualOutput = applyEdits(strippedInput, allEdits);
|
||||
const expectedOutput = testCase.golden;
|
||||
|
||||
if (actualOutput.trim() !== expectedOutput.trim()) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: false,
|
||||
error: "Output does not match golden file",
|
||||
expected: expectedOutput,
|
||||
actual: actualOutput
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: true
|
||||
};
|
||||
}
|
||||
languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key-without-inputs.golden.yml
Vendored
-9
@@ -1,9 +0,0 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ""
|
||||
key: ""
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
restore-keys: ${{ runner.os }}-
|
||||
path: ""
|
||||
key: ""
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||
restore-keys: ${{ runner.os }}-
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ""
|
||||
key: ""
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ""
|
||||
key: ""
|
||||
@@ -1,6 +0,0 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||
@@ -1,23 +0,0 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {CodeAction, Diagnostic} from "vscode-languageserver-types";
|
||||
|
||||
export interface CodeActionContext {
|
||||
uri: string;
|
||||
documentContent: string;
|
||||
featureFlags?: FeatureFlags;
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider that can produce a code action for a given diagnostic
|
||||
*/
|
||||
export interface CodeActionProvider {
|
||||
/**
|
||||
* The diagnostic codes this provider handles
|
||||
*/
|
||||
diagnosticCodes: (string | number | undefined)[];
|
||||
|
||||
/**
|
||||
* Create a code action for the diagnostic, if applicable
|
||||
*/
|
||||
createCodeAction(context: CodeActionContext, diagnostic: Diagnostic): CodeAction | undefined;
|
||||
}
|
||||
@@ -1,17 +1,11 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {complete, CompletionConfig} from "./complete";
|
||||
import {complete} from "./complete";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// Config to enable action scaffolding snippets
|
||||
const scaffoldingConfig: CompletionConfig = {
|
||||
featureFlags: new FeatureFlags({actionScaffoldingSnippets: true})
|
||||
};
|
||||
|
||||
describe("complete action files", () => {
|
||||
function createActionDocument(
|
||||
content: string,
|
||||
@@ -265,8 +259,8 @@ runs:
|
||||
const usingCompletion = completions.find(c => c.label === "using");
|
||||
expect(usingCompletion).toBeDefined();
|
||||
|
||||
// It should have a sortText that makes it sort after snippets
|
||||
expect(usingCompletion?.sortText).toBe("9_using");
|
||||
// It should have a sortText that makes it sort first
|
||||
expect(usingCompletion?.sortText).toBe("0_using");
|
||||
});
|
||||
|
||||
it("completes step keys inside composite action steps", async () => {
|
||||
@@ -399,159 +393,4 @@ runs:
|
||||
expect(labels).toContain("jobs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("action scaffolding snippets", () => {
|
||||
it("offers full scaffolding snippets in empty file", async () => {
|
||||
const [doc, position] = createActionDocument(`|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("Node.js Action");
|
||||
expect(labels).toContain("Composite Action");
|
||||
expect(labels).toContain("Docker Action");
|
||||
|
||||
// Verify they are snippets
|
||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
||||
expect(nodeSnippet?.kind).toBe(15); // CompletionItemKind.Snippet
|
||||
expect(nodeSnippet?.insertTextFormat).toBe(2); // InsertTextFormat.Snippet
|
||||
});
|
||||
|
||||
it("offers full scaffolding snippets when no name or description exists", async () => {
|
||||
const [doc, position] = createActionDocument(`author: me
|
||||
|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
|
||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
||||
expect(nodeSnippet).toBeDefined();
|
||||
// Full snippet should include name:
|
||||
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toContain("name:");
|
||||
});
|
||||
|
||||
it("offers runs-only snippets when name exists", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
|
||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
||||
expect(nodeSnippet).toBeDefined();
|
||||
// Runs-only snippet should start with inputs:, not name:
|
||||
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toMatch(/^inputs:/);
|
||||
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toContain("runs:");
|
||||
});
|
||||
|
||||
it("offers runs-only snippets when description exists", async () => {
|
||||
const [doc, position] = createActionDocument(`description: Does something
|
||||
|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
|
||||
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
||||
expect(compositeSnippet).toBeDefined();
|
||||
// Runs-only snippet should start with inputs:, not description:
|
||||
expect((compositeSnippet?.textEdit as {newText: string})?.newText).toMatch(/^inputs:/);
|
||||
expect((compositeSnippet?.textEdit as {newText: string})?.newText).toContain("runs:");
|
||||
});
|
||||
|
||||
it("does not offer snippets when runs.using already exists", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
runs:
|
||||
using: composite
|
||||
|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).not.toContain("Node.js Action");
|
||||
expect(labels).not.toContain("Composite Action");
|
||||
expect(labels).not.toContain("Docker Action");
|
||||
});
|
||||
|
||||
it("offers snippets inside runs when using is not set", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
runs:
|
||||
|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("Node.js Action");
|
||||
expect(labels).toContain("Composite Action");
|
||||
expect(labels).toContain("Docker Action");
|
||||
});
|
||||
|
||||
it("does not offer snippets at root level when runs exists", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
runs:
|
||||
steps: []
|
||||
|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).not.toContain("Node.js Action");
|
||||
expect(labels).not.toContain("Composite Action");
|
||||
expect(labels).not.toContain("Docker Action");
|
||||
});
|
||||
|
||||
it("does not offer snippets when nested inside runs steps", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- |`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).not.toContain("Node.js Action");
|
||||
expect(labels).not.toContain("Composite Action");
|
||||
expect(labels).not.toContain("Docker Action");
|
||||
});
|
||||
|
||||
it("Node.js snippet contains expected content", async () => {
|
||||
const [doc, position] = createActionDocument(`|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
|
||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
||||
const text = (nodeSnippet?.textEdit as {newText: string})?.newText;
|
||||
|
||||
expect(text).toContain("using: node24");
|
||||
expect(text).toContain("main:");
|
||||
expect(text).toContain("inputs:");
|
||||
expect(text).toContain("outputs:");
|
||||
});
|
||||
|
||||
it("Composite snippet contains expected content", async () => {
|
||||
const [doc, position] = createActionDocument(`|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
|
||||
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
||||
const text = (compositeSnippet?.textEdit as {newText: string})?.newText;
|
||||
|
||||
expect(text).toContain("using: composite");
|
||||
expect(text).toContain("steps:");
|
||||
expect(text).toContain("shell: bash");
|
||||
});
|
||||
|
||||
it("Docker snippet contains expected content", async () => {
|
||||
const [doc, position] = createActionDocument(`|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
|
||||
const dockerSnippet = completions.find(c => c.label === "Docker Action");
|
||||
const text = (dockerSnippet?.textEdit as {newText: string})?.newText;
|
||||
|
||||
expect(text).toContain("using: docker");
|
||||
expect(text).toContain("image:");
|
||||
expect(text).toContain("entrypoint:");
|
||||
});
|
||||
|
||||
it("does not offer snippets when feature flag is disabled", async () => {
|
||||
const [doc, position] = createActionDocument(`|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).not.toContain("Node.js Action");
|
||||
expect(labels).not.toContain("Composite Action");
|
||||
expect(labels).not.toContain("Docker Action");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {Position} from "vscode-languageserver-textdocument";
|
||||
import {CompletionItem, CompletionItemKind, InsertTextFormat, TextEdit} from "vscode-languageserver-types";
|
||||
import {Value} from "./value-providers/config.js";
|
||||
|
||||
/**
|
||||
* Valid keys for each action type under the `runs:` section.
|
||||
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
|
||||
*/
|
||||
const ACTION_NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
|
||||
const ACTION_COMPOSITE_KEYS = new Set(["using", "steps"]);
|
||||
const ACTION_DOCKER_KEYS = new Set([
|
||||
"using",
|
||||
"image",
|
||||
"args",
|
||||
"env",
|
||||
"entrypoint",
|
||||
"pre-entrypoint",
|
||||
"pre-if",
|
||||
"post-entrypoint",
|
||||
"post-if"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Action scaffolding snippets.
|
||||
*
|
||||
* Full variants include name, description, inputs, outputs, and runs.
|
||||
* Runs-only variants include just the runs block.
|
||||
*/
|
||||
const ACTION_SNIPPET_NODEJS_FULL = `name: '\${1:Action Name}'
|
||||
description: '\${2:What this action does}'
|
||||
|
||||
inputs:
|
||||
name:
|
||||
description: 'Name to greet'
|
||||
required: false
|
||||
default: 'World'
|
||||
|
||||
outputs:
|
||||
greeting:
|
||||
description: 'The greeting message'
|
||||
|
||||
runs:
|
||||
# For more on JavaScript actions (including @actions/toolkit), see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
|
||||
using: node24
|
||||
main: index.js
|
||||
# Sample index.js (vanilla JS, no build required):
|
||||
#
|
||||
# const fs = require('fs');
|
||||
# const name = process.env.INPUT_NAME || 'World';
|
||||
# const greeting = \\\`Hello \\\${name}\\\`;
|
||||
# console.log(greeting);
|
||||
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
|
||||
#
|
||||
# For JavaScript actions with @actions/toolkit, see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_NODEJS_RUNS = `inputs:
|
||||
name:
|
||||
description: 'Name to greet'
|
||||
required: false
|
||||
default: 'World'
|
||||
|
||||
outputs:
|
||||
greeting:
|
||||
description: 'The greeting message'
|
||||
|
||||
runs:
|
||||
# For more on JavaScript actions (including @actions/toolkit), see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
|
||||
using: node24
|
||||
main: index.js
|
||||
# Sample index.js (vanilla JS, no build required):
|
||||
#
|
||||
# const fs = require('fs');
|
||||
# const name = process.env.INPUT_NAME || 'World';
|
||||
# const greeting = \\\`Hello \\\${name}\\\`;
|
||||
# console.log(greeting);
|
||||
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_NODEJS_USING = `# For more on JavaScript actions (including @actions/toolkit), see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
|
||||
using: node24
|
||||
main: index.js
|
||||
# Sample index.js (vanilla JS, no build required):
|
||||
#
|
||||
# console.log('Hello World');
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_COMPOSITE_FULL = `name: '\${1:Action Name}'
|
||||
description: '\${2:What this action does}'
|
||||
|
||||
inputs:
|
||||
name:
|
||||
description: 'Name to greet'
|
||||
required: false
|
||||
default: 'World'
|
||||
|
||||
outputs:
|
||||
greeting:
|
||||
description: 'The greeting message'
|
||||
value: \\\${{ steps.greet.outputs.greeting }}
|
||||
|
||||
runs:
|
||||
# For more on composite actions, see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
|
||||
using: composite
|
||||
steps:
|
||||
- id: greet
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_NAME: \\\${{ inputs.name }}
|
||||
run: |
|
||||
GREETING="Hello \\$INPUT_NAME"
|
||||
echo "\\$GREETING"
|
||||
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_COMPOSITE_RUNS = `inputs:
|
||||
name:
|
||||
description: 'Name to greet'
|
||||
required: false
|
||||
default: 'World'
|
||||
|
||||
outputs:
|
||||
greeting:
|
||||
description: 'The greeting message'
|
||||
value: \\\${{ steps.greet.outputs.greeting }}
|
||||
|
||||
runs:
|
||||
# For more on composite actions, see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
|
||||
using: composite
|
||||
steps:
|
||||
- id: greet
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_NAME: \\\${{ inputs.name }}
|
||||
run: |
|
||||
GREETING="Hello \\$INPUT_NAME"
|
||||
echo "\\$GREETING"
|
||||
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_COMPOSITE_USING = `# For more on composite actions, see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
run: echo "Hello World"
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_DOCKER_FULL = `name: '\${1:Action Name}'
|
||||
description: '\${2:What this action does}'
|
||||
|
||||
inputs:
|
||||
name:
|
||||
description: 'Name to greet'
|
||||
required: false
|
||||
default: 'World'
|
||||
|
||||
outputs:
|
||||
greeting:
|
||||
description: 'The greeting message'
|
||||
|
||||
runs:
|
||||
# For more on Docker actions, see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
|
||||
using: docker
|
||||
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
|
||||
image: '\${3:docker://alpine:3.20}'
|
||||
env:
|
||||
INPUT_NAME: \\\${{ inputs.name }}
|
||||
entrypoint: '\${4:sh}'
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
GREETING="Hello \\$INPUT_NAME"
|
||||
echo "\\$GREETING"
|
||||
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_DOCKER_RUNS = `inputs:
|
||||
name:
|
||||
description: 'Name to greet'
|
||||
required: false
|
||||
default: 'World'
|
||||
|
||||
outputs:
|
||||
greeting:
|
||||
description: 'The greeting message'
|
||||
|
||||
runs:
|
||||
# For more on Docker actions, see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
|
||||
using: docker
|
||||
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
|
||||
image: '\${1:docker://alpine:3.20}'
|
||||
env:
|
||||
INPUT_NAME: \\\${{ inputs.name }}
|
||||
entrypoint: '\${2:sh}'
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
GREETING="Hello \\$INPUT_NAME"
|
||||
echo "\\$GREETING"
|
||||
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_DOCKER_USING = `# For more on Docker actions, see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
|
||||
using: docker
|
||||
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
|
||||
image: '\${1:docker://alpine:3.20}'
|
||||
entrypoint: '\${2:sh}'
|
||||
args:
|
||||
- -c
|
||||
- echo "Hello World"
|
||||
`;
|
||||
|
||||
/**
|
||||
* Filters action.yml `runs:` completions based on the `using:` value.
|
||||
*
|
||||
* When the user is completing keys under `runs:`:
|
||||
* - If `using: node20` is set, only show Node.js action keys
|
||||
* - If `using: composite` is set, only show composite action keys
|
||||
* - If `using: docker` is set, only show Docker action keys
|
||||
* - If `using:` is not set, show all keys but prioritize `using` first
|
||||
*/
|
||||
export function filterActionRunsCompletions(values: Value[], path: TemplateToken[], root: TemplateToken): Value[] {
|
||||
// Find the runs mapping from the root
|
||||
let runsMapping: MappingToken | undefined;
|
||||
if (root instanceof MappingToken) {
|
||||
for (let i = 0; i < root.count; i++) {
|
||||
const {key, value} = root.get(i);
|
||||
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
|
||||
runsMapping = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!runsMapping) {
|
||||
return values;
|
||||
}
|
||||
|
||||
// Check if the runs mapping is in our path (meaning we're completing inside it)
|
||||
const isInsideRuns = path.some(token => token === runsMapping);
|
||||
if (!isInsideRuns) {
|
||||
return values;
|
||||
}
|
||||
|
||||
// Find where runsMapping is in the path
|
||||
const runsMappingIndex = path.indexOf(runsMapping);
|
||||
if (runsMappingIndex === -1) {
|
||||
return values;
|
||||
}
|
||||
|
||||
// Check if there's anything after runsMapping in the path
|
||||
// If so, we're nested deeper (e.g., inside steps sequence or a step mapping)
|
||||
if (runsMappingIndex < path.length - 1) {
|
||||
return values;
|
||||
}
|
||||
|
||||
// Get the using value from the runs mapping
|
||||
let usingValue: string | undefined;
|
||||
for (let i = 0; i < runsMapping.count; i++) {
|
||||
const {key, value} = runsMapping.get(i);
|
||||
if (key.toString().toLowerCase() === "using") {
|
||||
usingValue = value.toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which keys to allow
|
||||
let allowedKeys: Set<string>;
|
||||
|
||||
if (!usingValue) {
|
||||
// No using value set - show all keys but prioritize "using"
|
||||
return values.map(v => {
|
||||
if (v.label.toLowerCase() === "using") {
|
||||
return {...v, sortText: "9_using"}; // Sort after snippets (0_, 1_, 2_)
|
||||
}
|
||||
return v;
|
||||
});
|
||||
} else if (usingValue.match(/^node\d+$/i)) {
|
||||
allowedKeys = ACTION_NODE_KEYS;
|
||||
} else if (usingValue.toLowerCase() === "composite") {
|
||||
allowedKeys = ACTION_COMPOSITE_KEYS;
|
||||
} else if (usingValue.toLowerCase() === "docker") {
|
||||
allowedKeys = ACTION_DOCKER_KEYS;
|
||||
} else {
|
||||
// Unknown using value - show all
|
||||
return values;
|
||||
}
|
||||
|
||||
// Filter to only allowed keys
|
||||
return values.filter(v => allowedKeys.has(v.label.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets action scaffolding snippet completions for action.yml files.
|
||||
*
|
||||
* Returns snippet completions when `runs.using` is not present, offering
|
||||
* three action types: Node.js, Composite, and Docker.
|
||||
*
|
||||
* Three variants per type:
|
||||
* - "_FULL": Full scaffold with name, description, inputs, outputs, and runs
|
||||
* - "_RUNS": Inputs, outputs, and runs (when name/description already exists)
|
||||
* - "_USING": Minimal runs content (when inside `runs:` mapping)
|
||||
*
|
||||
* Which variant is shown depends on context:
|
||||
* - Inside `runs:` mapping → "_USING" variants
|
||||
* - At root with name/description → "_RUNS" variants
|
||||
* - At root without name/description → "_FULL" variants
|
||||
*/
|
||||
export function getActionScaffoldingSnippets(
|
||||
root: TemplateToken | undefined,
|
||||
path: TemplateToken[],
|
||||
position: Position
|
||||
): CompletionItem[] {
|
||||
// Get the runs mapping from the root, if it exists
|
||||
let runsMapping: MappingToken | undefined;
|
||||
if (root instanceof MappingToken) {
|
||||
for (let i = 0; i < root.count; i++) {
|
||||
const {key, value} = root.get(i);
|
||||
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
|
||||
runsMapping = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if runs.using already exists - if so, no scaffolding needed
|
||||
if (runsMapping) {
|
||||
for (let i = 0; i < runsMapping.count; i++) {
|
||||
const {key} = runsMapping.get(i);
|
||||
if (key.toString().toLowerCase() === "using") {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show "_USING" variants directly inside `runs`
|
||||
const runsMappingIndex = runsMapping ? path.indexOf(runsMapping) : -1;
|
||||
const isDirectlyInsideRuns = runsMappingIndex !== -1 && runsMappingIndex === path.length - 1;
|
||||
if (isDirectlyInsideRuns) {
|
||||
return [
|
||||
createSnippetCompletion(
|
||||
"Node.js Action",
|
||||
"Scaffold a Node.js action",
|
||||
ACTION_SNIPPET_NODEJS_USING,
|
||||
position,
|
||||
"0_nodejs"
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Composite Action",
|
||||
"Scaffold a composite action",
|
||||
ACTION_SNIPPET_COMPOSITE_USING,
|
||||
position,
|
||||
"1_composite"
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Docker Action",
|
||||
"Scaffold a Docker action",
|
||||
ACTION_SNIPPET_DOCKER_USING,
|
||||
position,
|
||||
"2_docker"
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
// Not at root or `runs` already exists?
|
||||
const isAtRoot = path.length === 0 || (path.length === 1 && path[0] === root);
|
||||
if (!isAtRoot || runsMapping) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Determine which variant to show based on existing root keys
|
||||
let hasNameOrDescription = false;
|
||||
if (root instanceof MappingToken) {
|
||||
for (let i = 0; i < root.count; i++) {
|
||||
const keyStr = root.get(i).key.toString().toLowerCase();
|
||||
if (keyStr === "name" || keyStr === "description") {
|
||||
hasNameOrDescription = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show "_RUNS" variants (inputs, outputs, and runs block)
|
||||
if (hasNameOrDescription) {
|
||||
return [
|
||||
createSnippetCompletion(
|
||||
"Node.js Action",
|
||||
"Scaffold a Node.js action",
|
||||
ACTION_SNIPPET_NODEJS_RUNS,
|
||||
position,
|
||||
"1_nodejs"
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Composite Action",
|
||||
"Scaffold a composite action",
|
||||
ACTION_SNIPPET_COMPOSITE_RUNS,
|
||||
position,
|
||||
"2_composite"
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Docker Action",
|
||||
"Scaffold a Docker action",
|
||||
ACTION_SNIPPET_DOCKER_RUNS,
|
||||
position,
|
||||
"3_docker"
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
// Show "_FULL" variants (complete scaffold)
|
||||
return [
|
||||
createSnippetCompletion(
|
||||
"Node.js Action",
|
||||
"Scaffold a complete Node.js action",
|
||||
ACTION_SNIPPET_NODEJS_FULL,
|
||||
position,
|
||||
"1_nodejs"
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Composite Action",
|
||||
"Scaffold a complete composite action",
|
||||
ACTION_SNIPPET_COMPOSITE_FULL,
|
||||
position,
|
||||
"2_composite"
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Docker Action",
|
||||
"Scaffold a complete Docker action",
|
||||
ACTION_SNIPPET_DOCKER_FULL,
|
||||
position,
|
||||
"3_docker"
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a snippet completion item.
|
||||
*/
|
||||
function createSnippetCompletion(
|
||||
label: string,
|
||||
description: string,
|
||||
snippetText: string,
|
||||
position: Position,
|
||||
sortText: string
|
||||
): CompletionItem {
|
||||
return {
|
||||
label,
|
||||
kind: CompletionItemKind.Snippet,
|
||||
documentation: {
|
||||
kind: "markdown",
|
||||
value: description
|
||||
},
|
||||
insertTextFormat: InsertTextFormat.Snippet,
|
||||
sortText,
|
||||
textEdit: TextEdit.insert(position, snippetText)
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {data, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
|
||||
import {complete, getExpressionInput} from "./complete.js";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
@@ -68,16 +68,12 @@ describe("expressions", () => {
|
||||
describe("top-level auto-complete", () => {
|
||||
it("single region", async () => {
|
||||
const input = "run-name: ${{ | }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
});
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -112,16 +108,12 @@ describe("expressions", () => {
|
||||
|
||||
it("single region with existing input", async () => {
|
||||
const input = "run-name: ${{ g| }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
});
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -134,16 +126,12 @@ describe("expressions", () => {
|
||||
|
||||
it("single region with existing condition", async () => {
|
||||
const input = "run-name: ${{ g| == 'test' }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
});
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -156,16 +144,12 @@ describe("expressions", () => {
|
||||
|
||||
it("multiple regions with partial function", async () => {
|
||||
const input = "run-name: Run a ${{ inputs.test }} one-line script ${{ from|('test') == inputs.name }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
});
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -178,16 +162,12 @@ describe("expressions", () => {
|
||||
|
||||
it("multiple regions - first region", async () => {
|
||||
const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
});
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -200,16 +180,12 @@ describe("expressions", () => {
|
||||
|
||||
it("multiple regions", async () => {
|
||||
const input = "run-name: test-${{ github }}-${{ | }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
});
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -1150,10 +1126,7 @@ jobs:
|
||||
run: echo hi
|
||||
`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
});
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"env",
|
||||
"github",
|
||||
@@ -1166,7 +1139,6 @@ jobs:
|
||||
"steps",
|
||||
"strategy",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
|
||||
@@ -6,7 +6,6 @@ import {getPositionFromCursor} from "./test-utils/cursor-position.js";
|
||||
import {TestLogger} from "./test-utils/logger.js";
|
||||
import {clearCache} from "./utils/workflow-cache.js";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
||||
import {FeatureFlags} from "@actions/expressions/features";
|
||||
|
||||
registerLogger(new TestLogger());
|
||||
|
||||
@@ -896,32 +895,4 @@ jobs:
|
||||
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expression completions", () => {
|
||||
it("include case function when enabled", async () => {
|
||||
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
});
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
// Expression completions starting with 'c': case, contains
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("case");
|
||||
expect(labels).toContain("contains");
|
||||
});
|
||||
|
||||
it("exclude case function when disabled", async () => {
|
||||
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: false})
|
||||
});
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
// Expression completions starting with 'c': contains
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).not.toContain("case");
|
||||
expect(labels).toContain("contains");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+102
-17
@@ -1,4 +1,4 @@
|
||||
import {complete as completeExpression, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
|
||||
import {complete as completeExpression, DescriptionDictionary} from "@actions/expressions";
|
||||
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
||||
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
||||
import {getActionSchema} from "@actions/workflow-parser/actions/action-schema";
|
||||
@@ -16,7 +16,6 @@ import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
|
||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
||||
import {filterActionRunsCompletions, getActionScaffoldingSnippets} from "./complete-action.js";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
|
||||
import {ActionContext, getActionContext} from "./context/action-context.js";
|
||||
@@ -39,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);
|
||||
@@ -55,7 +72,6 @@ export type CompletionConfig = {
|
||||
valueProviderConfig?: ValueProviderConfig;
|
||||
contextProviderConfig?: ContextProviderConfig;
|
||||
fileProvider?: FileProvider;
|
||||
featureFlags?: FeatureFlags;
|
||||
};
|
||||
|
||||
export async function complete(
|
||||
@@ -132,7 +148,7 @@ export async function complete(
|
||||
Mode.Completion
|
||||
);
|
||||
|
||||
return getExpressionCompletionItems(token, context, newPos, config?.featureFlags);
|
||||
return getExpressionCompletionItems(token, context, newPos);
|
||||
}
|
||||
|
||||
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
|
||||
@@ -158,12 +174,6 @@ export async function complete(
|
||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
||||
values.push(...escapeHatches);
|
||||
|
||||
// Get action scaffolding snippets if applicable
|
||||
let actionSnippets: CompletionItem[] = [];
|
||||
if (isAction && config?.featureFlags?.isEnabled("actionScaffoldingSnippets")) {
|
||||
actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position);
|
||||
}
|
||||
|
||||
// Figure out what text to replace when the user picks a completion.
|
||||
// For example, if they typed `runs-|` and pick `runs-on`, we need to replace `runs-`.
|
||||
let replaceRange: Range | undefined;
|
||||
@@ -192,7 +202,7 @@ export async function complete(
|
||||
}
|
||||
|
||||
// Convert values to LSP CompletionItems
|
||||
const completionItems = values.map(value => {
|
||||
return values.map(value => {
|
||||
const newText = value.insertText || value.label;
|
||||
|
||||
// Escape hatches provide their own textEdit to restructure the YAML
|
||||
@@ -227,9 +237,6 @@ export async function complete(
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
// Add action scaffolding snippets if available
|
||||
return [...completionItems, ...actionSnippets];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -521,8 +528,7 @@ export function getExistingValues(token: TemplateToken | null, parent: TemplateT
|
||||
function getExpressionCompletionItems(
|
||||
token: TemplateToken,
|
||||
context: DescriptionDictionary,
|
||||
pos: Position,
|
||||
featureFlags?: FeatureFlags
|
||||
pos: Position
|
||||
): CompletionItem[] {
|
||||
if (!token.range) {
|
||||
return [];
|
||||
@@ -541,7 +547,7 @@ function getExpressionCompletionItems(
|
||||
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
|
||||
|
||||
try {
|
||||
return completeExpression(expressionInput, context, [], validatorFunctions, featureFlags).map(item =>
|
||||
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
|
||||
mapExpressionCompletionItem(item, currentInput[cursorOffset])
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -620,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()));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export {complete, CompletionConfig} from "./complete.js";
|
||||
export {complete} from "./complete.js";
|
||||
export {ContextProviderConfig} from "./context-providers/config.js";
|
||||
export {documentLinks} from "./document-links.js";
|
||||
export {hover} from "./hover.js";
|
||||
@@ -6,4 +6,3 @@ export {getInlayHints} from "./inlay-hints.js";
|
||||
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log.js";
|
||||
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate.js";
|
||||
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
||||
export {getCodeActions, CodeActionParams} from "./code-actions/code-actions.js";
|
||||
|
||||
@@ -249,21 +249,7 @@ jobs:
|
||||
line: 7
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "missing-required-inputs",
|
||||
data: {
|
||||
action: {
|
||||
name: "cache",
|
||||
owner: "actions",
|
||||
ref: "v1"
|
||||
},
|
||||
missingInputs: [
|
||||
{
|
||||
default: undefined,
|
||||
name: "path"
|
||||
}
|
||||
]
|
||||
}
|
||||
severity: DiagnosticSeverity.Error
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -308,25 +294,7 @@ jobs:
|
||||
line: 7
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "missing-required-inputs",
|
||||
data: {
|
||||
action: {
|
||||
name: "cache",
|
||||
owner: "actions",
|
||||
ref: "v1"
|
||||
},
|
||||
missingInputs: [
|
||||
{
|
||||
default: undefined,
|
||||
name: "path"
|
||||
},
|
||||
{
|
||||
default: undefined,
|
||||
name: "key"
|
||||
}
|
||||
]
|
||||
}
|
||||
severity: DiagnosticSeverity.Error
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -355,25 +323,7 @@ jobs:
|
||||
line: 6
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "missing-required-inputs",
|
||||
data: {
|
||||
action: {
|
||||
name: "cache",
|
||||
owner: "actions",
|
||||
ref: "v1"
|
||||
},
|
||||
missingInputs: [
|
||||
{
|
||||
default: undefined,
|
||||
name: "path"
|
||||
},
|
||||
{
|
||||
default: undefined,
|
||||
name: "key"
|
||||
}
|
||||
]
|
||||
}
|
||||
severity: DiagnosticSeverity.Error
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -4,22 +4,10 @@ import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {ActionReference, parseActionReference} from "./action.js";
|
||||
import {parseActionReference} from "./action.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {ValidationConfig} from "./validate.js";
|
||||
|
||||
export const DiagnosticCode = {
|
||||
MissingRequiredInputs: "missing-required-inputs"
|
||||
} as const;
|
||||
|
||||
export interface MissingInputsDiagnosticData {
|
||||
action: ActionReference;
|
||||
missingInputs: Array<{
|
||||
name: string;
|
||||
default?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates action references in workflow steps, checking for valid inputs and required inputs.
|
||||
*/
|
||||
@@ -106,22 +94,10 @@ export async function validateActionReference(
|
||||
missingRequiredInputs.length === 1
|
||||
? `Missing required input \`${missingRequiredInputs[0][0]}\``
|
||||
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
|
||||
|
||||
// Build minimal diagnostic data - position calculation happens in the quickfix
|
||||
const diagnosticData: MissingInputsDiagnosticData = {
|
||||
action,
|
||||
missingInputs: missingRequiredInputs.map(([name, input]) => ({
|
||||
name,
|
||||
default: input.default
|
||||
}))
|
||||
};
|
||||
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange((withKey || stepToken).range),
|
||||
message: message,
|
||||
code: DiagnosticCode.MissingRequiredInputs,
|
||||
data: diagnosticData
|
||||
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
|
||||
message: message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* Format string validation for format() function calls.
|
||||
* Port of Go's format_validator.go from actions-workflow-parser.
|
||||
*/
|
||||
|
||||
import {Expr, FunctionCall, Literal, Binary, Unary, Logical, Grouping, IndexAccess} from "@actions/expressions/ast";
|
||||
import {Kind} from "@actions/expressions/data/expressiondata";
|
||||
|
||||
/**
|
||||
* Error types for format string validation
|
||||
*/
|
||||
export type FormatStringError =
|
||||
| {type: "invalid-syntax"; message: string}
|
||||
| {type: "arg-count-mismatch"; expected: number; provided: number};
|
||||
|
||||
/**
|
||||
* Validates a format string and returns the maximum placeholder index.
|
||||
* Port of Go's validateFormatString from format_validator.go.
|
||||
*
|
||||
* @param formatString The format string to validate
|
||||
* @returns { valid: boolean, maxArgIndex: number } where maxArgIndex is -1 if no placeholders
|
||||
*/
|
||||
export function validateFormatString(formatString: string): {valid: boolean; maxArgIndex: number} {
|
||||
let maxIndex = -1;
|
||||
let i = 0;
|
||||
|
||||
while (i < formatString.length) {
|
||||
// Find next left brace
|
||||
let lbrace = -1;
|
||||
for (let j = i; j < formatString.length; j++) {
|
||||
if (formatString[j] === "{") {
|
||||
lbrace = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find next right brace
|
||||
let rbrace = -1;
|
||||
for (let j = i; j < formatString.length; j++) {
|
||||
if (formatString[j] === "}") {
|
||||
rbrace = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// No more braces
|
||||
if (lbrace < 0 && rbrace < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Left brace comes first (or only left brace exists)
|
||||
if (lbrace >= 0 && (rbrace < 0 || lbrace < rbrace)) {
|
||||
// Check if it's escaped
|
||||
if (lbrace + 1 < formatString.length && formatString[lbrace + 1] === "{") {
|
||||
// Escaped left brace
|
||||
i = lbrace + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// This is a placeholder opening - find the closing brace
|
||||
rbrace = -1;
|
||||
for (let j = lbrace + 1; j < formatString.length; j++) {
|
||||
if (formatString[j] === "}") {
|
||||
rbrace = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (rbrace < 0) {
|
||||
// Missing closing brace
|
||||
return {valid: false, maxArgIndex: -1};
|
||||
}
|
||||
|
||||
// Validate placeholder content (must be digits only)
|
||||
if (rbrace === lbrace + 1) {
|
||||
// Empty placeholder {}
|
||||
return {valid: false, maxArgIndex: -1};
|
||||
}
|
||||
|
||||
// Parse the index and validate it's all digits
|
||||
let index = 0;
|
||||
for (let j = lbrace + 1; j < rbrace; j++) {
|
||||
const c = formatString[j];
|
||||
if (c < "0" || c > "9") {
|
||||
// Non-numeric character
|
||||
return {valid: false, maxArgIndex: -1};
|
||||
}
|
||||
index = index * 10 + (c.charCodeAt(0) - "0".charCodeAt(0));
|
||||
}
|
||||
|
||||
if (index > maxIndex) {
|
||||
maxIndex = index;
|
||||
}
|
||||
|
||||
i = rbrace + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Right brace comes first (or only right brace exists)
|
||||
// Check if it's escaped
|
||||
if (rbrace + 1 < formatString.length && formatString[rbrace + 1] === "}") {
|
||||
// Escaped right brace
|
||||
i = rbrace + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unescaped right brace outside of placeholder
|
||||
return {valid: false, maxArgIndex: -1};
|
||||
}
|
||||
|
||||
return {valid: true, maxArgIndex: maxIndex};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks an expression AST to find and validate all format() function calls.
|
||||
*
|
||||
* @param expr The expression AST to validate
|
||||
* @returns Array of validation errors found
|
||||
*/
|
||||
export function validateFormatCalls(expr: Expr): FormatStringError[] {
|
||||
const errors: FormatStringError[] = [];
|
||||
const stack: Expr[] = [expr];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop();
|
||||
if (!node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node instanceof FunctionCall) {
|
||||
if (node.functionName.lexeme.toLowerCase() === "format") {
|
||||
const error = validateSingleFormatCall(node);
|
||||
if (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
// Push args for further processing (to find nested format calls)
|
||||
for (const arg of node.args) {
|
||||
stack.push(arg);
|
||||
}
|
||||
} else if (node instanceof Binary) {
|
||||
stack.push(node.left, node.right);
|
||||
} else if (node instanceof Unary) {
|
||||
stack.push(node.expr);
|
||||
} else if (node instanceof Logical) {
|
||||
for (const arg of node.args) {
|
||||
stack.push(arg);
|
||||
}
|
||||
} else if (node instanceof Grouping) {
|
||||
stack.push(node.group);
|
||||
} else if (node instanceof IndexAccess) {
|
||||
stack.push(node.expr, node.index);
|
||||
}
|
||||
// Literal, ContextAccess - no children to process
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a single format() function call.
|
||||
*
|
||||
* @param fc The FunctionCall AST node
|
||||
* @returns Validation error if found, undefined if valid
|
||||
*/
|
||||
function validateSingleFormatCall(fc: FunctionCall): FormatStringError | undefined {
|
||||
// Must have at least one argument (the format string)
|
||||
if (fc.args.length < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// First argument must be a string literal
|
||||
const firstArg = fc.args[0];
|
||||
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== Kind.String) {
|
||||
return undefined; // Can't validate dynamic format strings
|
||||
}
|
||||
|
||||
const formatString = firstArg.literal.coerceString();
|
||||
const numArgs = fc.args.length - 1; // Subtract 1 for format string itself
|
||||
|
||||
const {valid, maxArgIndex} = validateFormatString(formatString);
|
||||
|
||||
if (!valid) {
|
||||
return {
|
||||
type: "invalid-syntax",
|
||||
message: "Format string has invalid syntax (missing closing brace, unescaped braces, or invalid placeholder)"
|
||||
};
|
||||
}
|
||||
|
||||
if (maxArgIndex >= numArgs) {
|
||||
return {
|
||||
type: "arg-count-mismatch",
|
||||
expected: maxArgIndex + 1, // Convert 0-based index to count
|
||||
provided: numArgs
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,835 +0,0 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {registerLogger} from "./log.js";
|
||||
import {createDocument} from "./test-utils/document.js";
|
||||
import {TestLogger} from "./test-utils/logger.js";
|
||||
import {clearCache} from "./utils/workflow-cache.js";
|
||||
import {validate, ValidationConfig} from "./validate.js";
|
||||
|
||||
registerLogger(new TestLogger());
|
||||
|
||||
const configWithFlag: ValidationConfig = {
|
||||
featureFlags: new FeatureFlags({blockScalarChompingWarning: true})
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("block scalar chomping - warning cases", () => {
|
||||
describe("step-level env values", () => {
|
||||
it("warns with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo $VAR
|
||||
env:
|
||||
VAR: |
|
||||
\${{ matrix.value }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not warn with keep chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo $VAR
|
||||
env:
|
||||
VAR: |+
|
||||
\${{ matrix.value }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn with strip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo $VAR
|
||||
env:
|
||||
VAR: |-
|
||||
\${{ matrix.value }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses > indicator in warning message for folded scalars", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo $VAR
|
||||
env:
|
||||
VAR: >
|
||||
\${{ matrix.value }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '>' implicitly adds a trailing newline that may be unintentional. Use '>-' to remove it, or '>+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("warns for plain string env value with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo $VAR
|
||||
env:
|
||||
VAR: |
|
||||
hello world
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("job-level env values", () => {
|
||||
it("warns with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
MY_VAR: |
|
||||
some value
|
||||
steps:
|
||||
- run: echo done
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow-level env values", () => {
|
||||
it("warns with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
env:
|
||||
GLOBAL_VAR: |
|
||||
some value
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo done
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("container env values", () => {
|
||||
it("warns with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:18
|
||||
env:
|
||||
CONTAINER_VAR: |
|
||||
some value
|
||||
steps:
|
||||
- run: echo done
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("service container env values", () => {
|
||||
it("warns with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
env:
|
||||
REDIS_PASSWORD: |
|
||||
secret123
|
||||
steps:
|
||||
- run: echo done
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("action input (with)", () => {
|
||||
it("warns with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
script: |
|
||||
\${{ matrix.value }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not warn with keep chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
script: |+
|
||||
\${{ matrix.value }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn with strip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
script: |-
|
||||
\${{ matrix.value }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reusable workflow inputs (with)", () => {
|
||||
it("warns with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
call-workflow:
|
||||
uses: ./.github/workflows/reusable.yml
|
||||
with:
|
||||
my-input: |
|
||||
some value
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reusable workflow secrets", () => {
|
||||
it("warns with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
call-workflow:
|
||||
uses: ./.github/workflows/reusable.yml
|
||||
secrets:
|
||||
my-secret: |
|
||||
\${{ secrets.TOKEN }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("job outputs", () => {
|
||||
it("warns with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
my_output: |
|
||||
\${{ steps.test.outputs.value }}
|
||||
steps:
|
||||
- id: test
|
||||
run: echo "value=test" >> $GITHUB_OUTPUT
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not warn with strip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
my_output: |-
|
||||
\${{ steps.test.outputs.value }}
|
||||
steps:
|
||||
- id: test
|
||||
run: echo "value=test" >> $GITHUB_OUTPUT
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix values", () => {
|
||||
it("warns for matrix vector value with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
config:
|
||||
- |
|
||||
value1
|
||||
- value2
|
||||
steps:
|
||||
- run: echo \${{ matrix.config }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not warn with strip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
config:
|
||||
- |-
|
||||
value1
|
||||
- value2
|
||||
steps:
|
||||
- run: echo \${{ matrix.config }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns for matrix include value with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
include:
|
||||
- os: |
|
||||
windows-latest
|
||||
special: true
|
||||
steps:
|
||||
- run: echo \${{ matrix.os }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("warns for matrix exclude value with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
node: [16, 18]
|
||||
exclude:
|
||||
- os: |
|
||||
windows-latest
|
||||
node: 16
|
||||
steps:
|
||||
- run: echo \${{ matrix.os }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("warns for deeply nested matrix value with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
config:
|
||||
- foo:
|
||||
bar: |
|
||||
baz
|
||||
steps:
|
||||
- run: echo \${{ matrix.config }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("warns for deeply nested matrix include value with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
config:
|
||||
nested: |
|
||||
value
|
||||
steps:
|
||||
- run: echo \${{ matrix.config }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("warns for deeply nested matrix exclude value with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
exclude:
|
||||
- os: windows-latest
|
||||
config:
|
||||
nested: |
|
||||
value
|
||||
steps:
|
||||
- run: echo \${{ matrix.os }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("concurrency", () => {
|
||||
it("warns for concurrency string with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: |
|
||||
my-group-\${{ github.ref }}
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo done
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not warn for concurrency with strip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: |-
|
||||
my-group-\${{ github.ref }}
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo done
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns for concurrency.group with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: |
|
||||
my-group-\${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo done
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("warns for job-level concurrency with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: |
|
||||
job-group-\${{ github.ref }}
|
||||
steps:
|
||||
- run: echo done
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
||||
code: "block-scalar-chomping",
|
||||
severity: DiagnosticSeverity.Warning
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("block scalar chomping - no warning cases", () => {
|
||||
describe("fields trimmed server-side", () => {
|
||||
it("does not warn for job-if with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- run: echo done
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn for step-if with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo done
|
||||
if: |
|
||||
github.ref == 'refs/heads/main'
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn for runs-on with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: |
|
||||
ubuntu-latest
|
||||
steps:
|
||||
- run: echo done
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn for job name with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
name: |
|
||||
My Job
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo done
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn for step name with clip chomping", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: |
|
||||
My Step
|
||||
run: echo done
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("run field (intentionally allowed)", () => {
|
||||
it("does not warn for step run field", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo hello
|
||||
echo world
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn for run field with expression", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo \${{ github.ref }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-block scalars", () => {
|
||||
it("does not warn for quoted strings", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo $VAR
|
||||
env:
|
||||
VAR: "hello world"
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn for flow scalars", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo $VAR
|
||||
env:
|
||||
VAR: hello world
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn for inline expressions", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo $VAR
|
||||
env:
|
||||
VAR: \${{ matrix.value }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
|
||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +1,17 @@
|
||||
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
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";
|
||||
import {validateFormatString} from "./validate-format-string.js";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
function hasMessageContaining(results: Diagnostic[], substring: string): boolean {
|
||||
return results.some(r => r.message.includes(substring));
|
||||
}
|
||||
|
||||
describe("format string validation", () => {
|
||||
describe("validateFormatString unit tests", () => {
|
||||
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});
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvalidFormatString workflow validation", () => {
|
||||
it("errors on missing closing brace", async () => {
|
||||
const input = `
|
||||
@@ -82,12 +23,7 @@ jobs:
|
||||
- run: echo \${{ format('{0', github.event_name) }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "invalid-format-string",
|
||||
severity: DiagnosticSeverity.Error
|
||||
})
|
||||
);
|
||||
expect(hasMessageContaining(result, "Invalid format string")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on empty braces", async () => {
|
||||
@@ -100,11 +36,7 @@ jobs:
|
||||
- run: echo \${{ format('{}', github.event_name) }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "invalid-format-string"
|
||||
})
|
||||
);
|
||||
expect(hasMessageContaining(result, "Invalid format string")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on non-numeric placeholder", async () => {
|
||||
@@ -117,11 +49,7 @@ jobs:
|
||||
- run: echo \${{ format('{abc}', github.event_name) }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "invalid-format-string"
|
||||
})
|
||||
);
|
||||
expect(hasMessageContaining(result, "Invalid format string")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows valid format strings", async () => {
|
||||
@@ -134,11 +62,7 @@ jobs:
|
||||
- run: echo \${{ format('{0} {1}', github.event_name, github.ref) }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "invalid-format-string"
|
||||
})
|
||||
);
|
||||
expect(hasMessageContaining(result, "Invalid format string")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows escaped braces", async () => {
|
||||
@@ -151,11 +75,7 @@ jobs:
|
||||
- run: echo \${{ format('{{0}} {0}', github.event_name) }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "invalid-format-string"
|
||||
})
|
||||
);
|
||||
expect(hasMessageContaining(result, "Invalid format string")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,12 +90,7 @@ jobs:
|
||||
- run: echo \${{ format('{2}', 'arg0', 'arg1') }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "format-arg-count-mismatch",
|
||||
severity: DiagnosticSeverity.Error
|
||||
})
|
||||
);
|
||||
expect(hasMessageContaining(result, "Format string references {2}")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors when referencing arg 0 with no args", async () => {
|
||||
@@ -188,11 +103,7 @@ jobs:
|
||||
- run: echo \${{ format('{0}') }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "format-arg-count-mismatch"
|
||||
})
|
||||
);
|
||||
expect(hasMessageContaining(result, "Format string references {0}")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows when arg count matches", async () => {
|
||||
@@ -205,11 +116,7 @@ jobs:
|
||||
- run: echo \${{ format('{0} {1} {2}', 'a', 'b', 'c') }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "format-arg-count-mismatch"
|
||||
})
|
||||
);
|
||||
expect(hasMessageContaining(result, "Format string references")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles no placeholders correctly", async () => {
|
||||
@@ -222,11 +129,7 @@ jobs:
|
||||
- run: echo \${{ format('hello world') }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "format-arg-count-mismatch"
|
||||
})
|
||||
);
|
||||
expect(hasMessageContaining(result, "Format string references")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips validation for dynamic format strings", async () => {
|
||||
@@ -240,16 +143,8 @@ jobs:
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
// Should not have format errors since we can't validate dynamic strings
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "invalid-format-string"
|
||||
})
|
||||
);
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "format-arg-count-mismatch"
|
||||
})
|
||||
);
|
||||
expect(hasMessageContaining(result, "Invalid format string")).toBe(false);
|
||||
expect(hasMessageContaining(result, "Format string references")).toBe(false);
|
||||
});
|
||||
|
||||
it("validates nested format calls", async () => {
|
||||
@@ -263,11 +158,7 @@ jobs:
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
// The inner format call has an error
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "format-arg-count-mismatch"
|
||||
})
|
||||
);
|
||||
expect(hasMessageContaining(result, "Format string references {2}")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {FeatureFlags, Lexer, Parser, data} from "@actions/expressions";
|
||||
import {Lexer, Parser, data} from "@actions/expressions";
|
||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
@@ -27,7 +27,6 @@ import {mapRange} from "./utils/range.js";
|
||||
import {getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {validateActionReference} from "./validate-action-reference.js";
|
||||
import {validateAction} from "./validate-action.js";
|
||||
import {validateFormatCalls} from "./validate-format-string.js";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
||||
import {defaultValueProviders} from "./value-providers/default.js";
|
||||
|
||||
@@ -39,7 +38,6 @@ export type ValidationConfig = {
|
||||
contextProviderConfig?: ContextProviderConfig;
|
||||
actionsMetadataProvider?: ActionsMetadataProvider;
|
||||
fileProvider?: FileProvider;
|
||||
featureFlags?: FeatureFlags;
|
||||
};
|
||||
|
||||
export type ActionsMetadataProvider = {
|
||||
@@ -86,7 +84,7 @@ async function validateWorkflow(textDocument: TextDocument, config?: ValidationC
|
||||
});
|
||||
|
||||
// Validate expressions and value providers
|
||||
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config, config?.featureFlags);
|
||||
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config);
|
||||
}
|
||||
|
||||
// For now map parser errors directly to diagnostics
|
||||
@@ -110,10 +108,9 @@ async function additionalValidations(
|
||||
documentUri: URI,
|
||||
template: WorkflowTemplate,
|
||||
root: TemplateToken,
|
||||
config?: ValidationConfig,
|
||||
featureFlags?: FeatureFlags
|
||||
config?: ValidationConfig
|
||||
) {
|
||||
for (const [parent, token, key, ancestors] of TemplateToken.traverse(root)) {
|
||||
for (const [parent, token, key] of TemplateToken.traverse(root)) {
|
||||
// If the token is a value in a pair, use the key definition for validation
|
||||
// If the token has a parent (map, sequence, etc), use this definition for validation
|
||||
const validationToken = key || parent || token;
|
||||
@@ -131,12 +128,7 @@ async function additionalValidations(
|
||||
);
|
||||
}
|
||||
|
||||
// Validate block scalar chomping for expressions and strings
|
||||
if (featureFlags?.isEnabled("blockScalarChompingWarning")) {
|
||||
validateBlockScalarChomping(diagnostics, token, parent, key, ancestors);
|
||||
}
|
||||
|
||||
// `if` conditions allow omitting ${{ }}, so validate strings in these fields as expressions
|
||||
// If this is a job-if, step-if, or snapshot-if field (which are strings that should be treated as expressions), validate it
|
||||
const definitionKey = token.definition?.key;
|
||||
if (
|
||||
isString(token) &&
|
||||
@@ -156,9 +148,7 @@ async function additionalValidations(
|
||||
finalCondition,
|
||||
token.definitionInfo,
|
||||
undefined,
|
||||
token.source,
|
||||
undefined,
|
||||
token.blockScalarHeader
|
||||
token.source
|
||||
);
|
||||
|
||||
await validateExpression(
|
||||
@@ -745,28 +735,6 @@ async function validateExpression(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate format() function calls
|
||||
const formatErrors = validateFormatCalls(expr);
|
||||
for (const formatError of formatErrors) {
|
||||
if (formatError.type === "invalid-syntax") {
|
||||
diagnostics.push({
|
||||
message: `Invalid format string: ${formatError.message}`,
|
||||
range: mapRange(expression.range),
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "invalid-format-string"
|
||||
});
|
||||
} else if (formatError.type === "arg-count-mismatch") {
|
||||
diagnostics.push({
|
||||
message: `Format string references argument {${formatError.expected - 1}} but only ${
|
||||
formatError.provided
|
||||
} argument(s) provided`,
|
||||
range: mapRange(expression.range),
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "format-arg-count-mismatch"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const context = await getWorkflowExpressionContext(
|
||||
namedContexts,
|
||||
contextProviderConfig,
|
||||
@@ -854,90 +822,3 @@ function getStaticConcurrencyGroup(token: TemplateToken | undefined): StringToke
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates YAML block scalar chomping.
|
||||
*
|
||||
* Block scalars (| and >) implicitly add a trailing newline by default ("clip" chomping).
|
||||
* This is often unintended by the workflow author and can cause unexpected behavior.
|
||||
* This function warns on certain fields when clip chomping is used (implicit trailing newline)
|
||||
* and suggests they explicitly use strip (|-) or keep (|+) to clarify intent.
|
||||
*
|
||||
* Only specific fields are validated - those where trailing newlines may cause
|
||||
* issues but aren't automatically trimmed server-side. For example env, inputs, outputs, etc.
|
||||
*
|
||||
* Skipped fields:
|
||||
* - run: Multi-line scripts commonly have trailing newlines
|
||||
* - Fields trimmed server-side: name, uses, shell, if, etc.
|
||||
*/
|
||||
function validateBlockScalarChomping(
|
||||
diagnostics: Diagnostic[],
|
||||
token: TemplateToken,
|
||||
parent: TemplateToken | undefined,
|
||||
key: TemplateToken | undefined,
|
||||
ancestors: TemplateToken[]
|
||||
): void {
|
||||
// Not an expression or string?
|
||||
if (!isBasicExpression(token) && !isString(token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Not a block scalar?
|
||||
const header = token.blockScalarHeader;
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Not "clip" chomp style?
|
||||
if (header.includes("+") || header.includes("-")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we should warn
|
||||
let shouldWarn = false;
|
||||
const parentDefinitionName = parent?.definition?.key;
|
||||
const tokenDefinitionName = token.definition?.key;
|
||||
const keyName = key && isString(key) ? key.value : undefined;
|
||||
if (
|
||||
parentDefinitionName &&
|
||||
[
|
||||
"workflow-env",
|
||||
"job-env",
|
||||
"step-env",
|
||||
"container-env",
|
||||
"step-with",
|
||||
"job-outputs",
|
||||
"workflow-job-with",
|
||||
"workflow-job-secrets"
|
||||
].includes(parentDefinitionName)
|
||||
) {
|
||||
// env, with, outputs, or secrets fields
|
||||
shouldWarn = true;
|
||||
} else if (
|
||||
ancestors.some(ancestor => {
|
||||
const ancestorKey = ancestor.definition?.key;
|
||||
return ancestorKey === "matrix" || ancestorKey === "matrix-filter" || ancestorKey === "matrix-filter-item";
|
||||
})
|
||||
) {
|
||||
// Matrix values (vectors, include, exclude)
|
||||
shouldWarn = true;
|
||||
} else if (tokenDefinitionName && ["workflow-concurrency", "job-concurrency"].includes(tokenDefinitionName)) {
|
||||
// Concurrency shorthand
|
||||
shouldWarn = true;
|
||||
} else if (keyName === "group" && parentDefinitionName === "concurrency-mapping") {
|
||||
// Concurrency group field
|
||||
shouldWarn = true;
|
||||
}
|
||||
|
||||
if (!shouldWarn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blockIndicator = header.startsWith("|") ? "|" : ">";
|
||||
diagnostics.push({
|
||||
message: `Block scalar '${blockIndicator}' implicitly adds a trailing newline that may be unintentional. Use '${blockIndicator}-' to remove it, or '${blockIndicator}+' to explicitly keep it.`,
|
||||
range: mapRange(token.range),
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
code: "block-scalar-chomping"
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.38"
|
||||
"version": "0.3.34"
|
||||
}
|
||||
Generated
+9
-9
@@ -136,7 +136,7 @@
|
||||
},
|
||||
"expressions": {
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.38",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
@@ -396,11 +396,11 @@
|
||||
},
|
||||
"languageserver": {
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.38",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.38",
|
||||
"@actions/workflow-parser": "^0.3.38",
|
||||
"@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.38",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.38",
|
||||
"@actions/workflow-parser": "^0.3.38",
|
||||
"@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.38",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.38",
|
||||
"@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.38",
|
||||
"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.38",
|
||||
"@actions/expressions": "^0.3.34",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -201,355 +201,4 @@ jobs:
|
||||
throw new Error("expected if to be a string (will be converted to expression later)");
|
||||
}
|
||||
});
|
||||
|
||||
describe("Block scalar chomp style preservation", () => {
|
||||
it("preserves clip chomping (|) for literal block scalar", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST: |
|
||||
\${{ github.event_name }}
|
||||
steps:
|
||||
- run: echo hi`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
|
||||
const workflowRoot = result.value!.assertMapping("root")!;
|
||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||
const build = jobs.get(0).value.assertMapping("job");
|
||||
const env = build.get(1).value.assertMapping("env");
|
||||
const testToken = env.get(0).value;
|
||||
|
||||
if (!isBasicExpression(testToken)) {
|
||||
throw new Error("expected TEST to be a basic expression");
|
||||
}
|
||||
|
||||
expect(testToken.blockScalarHeader).toBe("|");
|
||||
});
|
||||
|
||||
it("preserves strip chomping (|-) for literal block scalar", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST: |-
|
||||
\${{ github.event_name }}
|
||||
steps:
|
||||
- run: echo hi`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
|
||||
const workflowRoot = result.value!.assertMapping("root")!;
|
||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||
const build = jobs.get(0).value.assertMapping("job");
|
||||
const env = build.get(1).value.assertMapping("env");
|
||||
const testToken = env.get(0).value;
|
||||
|
||||
if (!isBasicExpression(testToken)) {
|
||||
throw new Error("expected TEST to be a basic expression");
|
||||
}
|
||||
|
||||
expect(testToken.blockScalarHeader).toBe("|-");
|
||||
});
|
||||
|
||||
it("preserves keep chomping (|+) for literal block scalar", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST: |+
|
||||
\${{ github.event_name }}
|
||||
steps:
|
||||
- run: echo hi`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
|
||||
const workflowRoot = result.value!.assertMapping("root")!;
|
||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||
const build = jobs.get(0).value.assertMapping("job");
|
||||
const env = build.get(1).value.assertMapping("env");
|
||||
const testToken = env.get(0).value;
|
||||
|
||||
if (!isBasicExpression(testToken)) {
|
||||
throw new Error("expected TEST to be a basic expression");
|
||||
}
|
||||
|
||||
expect(testToken.blockScalarHeader).toBe("|+");
|
||||
});
|
||||
|
||||
it("preserves folded clip (>) chomping", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST: >
|
||||
\${{ github.event_name }}
|
||||
steps:
|
||||
- run: echo hi`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
|
||||
const workflowRoot = result.value!.assertMapping("root")!;
|
||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||
const build = jobs.get(0).value.assertMapping("job");
|
||||
const env = build.get(1).value.assertMapping("env");
|
||||
const testToken = env.get(0).value;
|
||||
|
||||
if (!isBasicExpression(testToken)) {
|
||||
throw new Error("expected TEST to be a basic expression");
|
||||
}
|
||||
|
||||
expect(testToken.blockScalarHeader).toBe(">");
|
||||
});
|
||||
|
||||
it("preserves folded strip (>-) chomping", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST: >-
|
||||
\${{ github.event_name }}
|
||||
steps:
|
||||
- run: echo hi`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
|
||||
const workflowRoot = result.value!.assertMapping("root")!;
|
||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||
const build = jobs.get(0).value.assertMapping("job");
|
||||
const env = build.get(1).value.assertMapping("env");
|
||||
const testToken = env.get(0).value;
|
||||
|
||||
if (!isBasicExpression(testToken)) {
|
||||
throw new Error("expected TEST to be a basic expression");
|
||||
}
|
||||
|
||||
expect(testToken.blockScalarHeader).toBe(">-");
|
||||
});
|
||||
|
||||
it("preserves with explicit indent (|2)", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST: |2
|
||||
\${{ github.event_name }}
|
||||
steps:
|
||||
- run: echo hi`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
|
||||
const workflowRoot = result.value!.assertMapping("root")!;
|
||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||
const build = jobs.get(0).value.assertMapping("job");
|
||||
const env = build.get(1).value.assertMapping("env");
|
||||
const testToken = env.get(0).value;
|
||||
|
||||
if (!isBasicExpression(testToken)) {
|
||||
throw new Error("expected TEST to be a basic expression");
|
||||
}
|
||||
|
||||
expect(testToken.blockScalarHeader).toBe("|2");
|
||||
});
|
||||
|
||||
it("preserves with explicit indent and strip (|-2)", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST: |-2
|
||||
\${{ github.event_name }}
|
||||
steps:
|
||||
- run: echo hi`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
|
||||
const workflowRoot = result.value!.assertMapping("root")!;
|
||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||
const build = jobs.get(0).value.assertMapping("job");
|
||||
const env = build.get(1).value.assertMapping("env");
|
||||
const testToken = env.get(0).value;
|
||||
|
||||
if (!isBasicExpression(testToken)) {
|
||||
throw new Error("expected TEST to be a basic expression");
|
||||
}
|
||||
|
||||
expect(testToken.blockScalarHeader).toBe("|-2");
|
||||
});
|
||||
|
||||
it("handles flow scalars (no chomp info for inline)", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST: \${{ github.event_name }}
|
||||
steps:
|
||||
- run: echo hi`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
|
||||
const workflowRoot = result.value!.assertMapping("root")!;
|
||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||
const build = jobs.get(0).value.assertMapping("job");
|
||||
const env = build.get(1).value.assertMapping("env");
|
||||
const testToken = env.get(0).value;
|
||||
|
||||
if (!isBasicExpression(testToken)) {
|
||||
throw new Error("expected TEST to be a basic expression");
|
||||
}
|
||||
|
||||
expect(testToken.blockScalarHeader).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves block scalar info for format expressions with multiple sub-expressions", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TEST: |
|
||||
Hello \${{ github.event_name }} World \${{ github.ref }}
|
||||
steps:
|
||||
- run: echo hi`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
|
||||
const workflowRoot = result.value!.assertMapping("root")!;
|
||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||
const build = jobs.get(0).value.assertMapping("job");
|
||||
const env = build.get(1).value.assertMapping("env");
|
||||
const testToken = env.get(0).value;
|
||||
|
||||
if (!isBasicExpression(testToken)) {
|
||||
throw new Error("expected TEST to be a basic expression");
|
||||
}
|
||||
|
||||
// The format expression should preserve the block scalar info
|
||||
expect(testToken.blockScalarHeader).toBe("|");
|
||||
});
|
||||
|
||||
it("preserves block scalar info on StringToken for isExpression fields", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'push'
|
||||
steps:
|
||||
- run: echo hi`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
|
||||
const workflowRoot = result.value!.assertMapping("root")!;
|
||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||
const build = jobs.get(0).value.assertMapping("job");
|
||||
const ifToken = build.get(1).value;
|
||||
|
||||
// For isExpression fields without ${{ }}, the token is a StringToken
|
||||
if (!isString(ifToken)) {
|
||||
throw new Error("expected if to be a string");
|
||||
}
|
||||
|
||||
expect(ifToken.blockScalarHeader).toBe("|");
|
||||
});
|
||||
|
||||
it("preserves block scalar info on StringToken for isExpression fields with strip", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: |-
|
||||
github.event_name == 'push'
|
||||
steps:
|
||||
- run: echo hi`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
|
||||
const workflowRoot = result.value!.assertMapping("root")!;
|
||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
||||
const build = jobs.get(0).value.assertMapping("job");
|
||||
const ifToken = build.get(1).value;
|
||||
|
||||
if (!isString(ifToken)) {
|
||||
throw new Error("expected if to be a string");
|
||||
}
|
||||
|
||||
expect(ifToken.blockScalarHeader).toBe("|-");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -613,9 +613,7 @@ class TemplateReader {
|
||||
`format('${format.join("")}'${args.join("")})`,
|
||||
definitionInfo,
|
||||
expressionTokens,
|
||||
raw,
|
||||
undefined,
|
||||
token.blockScalarHeader
|
||||
raw
|
||||
);
|
||||
}
|
||||
|
||||
@@ -697,8 +695,7 @@ class TemplateReader {
|
||||
definitionInfo,
|
||||
undefined,
|
||||
token.source,
|
||||
expressionRange,
|
||||
token.blockScalarHeader
|
||||
expressionRange
|
||||
),
|
||||
error: undefined
|
||||
};
|
||||
|
||||
@@ -24,19 +24,7 @@ export class BasicExpressionToken extends ExpressionToken {
|
||||
public readonly expressionRange: TokenRange | undefined;
|
||||
|
||||
/**
|
||||
* The block scalar header (e.g., "|", "|-", "|+", ">", ">-", ">+") if parsed from a YAML block scalar.
|
||||
*/
|
||||
public readonly blockScalarHeader: string | undefined;
|
||||
|
||||
/**
|
||||
* @param file The file ID where this token originated
|
||||
* @param range The range of the entire expression including `${{` and `}}`
|
||||
* @param expression The expression string without `${{` and `}}` markers
|
||||
* @param definitionInfo Schema definition info for this token
|
||||
* @param originalExpressions If transformed from individual expressions (e.g., format()), these are the originals
|
||||
* @param source The original source string from the YAML
|
||||
* @param expressionRange The range of just the expression, excluding `${{` and `}}`
|
||||
* @param blockScalarHeader The block scalar header (e.g., "|", "|-") if parsed from a YAML block scalar
|
||||
* @param originalExpressions If the basic expression was transformed from individual expressions, these will be the original ones
|
||||
*/
|
||||
public constructor(
|
||||
file: number | undefined,
|
||||
@@ -45,15 +33,13 @@ export class BasicExpressionToken extends ExpressionToken {
|
||||
definitionInfo: DefinitionInfo | undefined,
|
||||
originalExpressions: BasicExpressionToken[] | undefined,
|
||||
source: string | undefined,
|
||||
expressionRange?: TokenRange | undefined,
|
||||
blockScalarHeader?: string | undefined
|
||||
expressionRange?: TokenRange | undefined
|
||||
) {
|
||||
super(TokenType.BasicExpression, file, range, undefined, definitionInfo);
|
||||
this.expr = expression;
|
||||
this.source = source;
|
||||
this.originalExpressions = originalExpressions;
|
||||
this.expressionRange = expressionRange;
|
||||
this.blockScalarHeader = blockScalarHeader;
|
||||
}
|
||||
|
||||
public get expression(): string {
|
||||
@@ -69,8 +55,7 @@ export class BasicExpressionToken extends ExpressionToken {
|
||||
this.definitionInfo,
|
||||
this.originalExpressions,
|
||||
this.source,
|
||||
this.expressionRange,
|
||||
this.blockScalarHeader
|
||||
this.expressionRange
|
||||
)
|
||||
: new BasicExpressionToken(
|
||||
this.file,
|
||||
@@ -79,8 +64,7 @@ export class BasicExpressionToken extends ExpressionToken {
|
||||
this.definitionInfo,
|
||||
this.originalExpressions,
|
||||
this.source,
|
||||
this.expressionRange,
|
||||
this.blockScalarHeader
|
||||
this.expressionRange
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,26 +6,23 @@ import {TokenType} from "./types.js";
|
||||
export class StringToken extends LiteralToken {
|
||||
public readonly value: string;
|
||||
public readonly source: string | undefined;
|
||||
public readonly blockScalarHeader: string | undefined;
|
||||
|
||||
public constructor(
|
||||
file: number | undefined,
|
||||
range: TokenRange | undefined,
|
||||
value: string,
|
||||
definitionInfo: DefinitionInfo | undefined,
|
||||
source?: string,
|
||||
blockScalarHeader?: string
|
||||
source?: string
|
||||
) {
|
||||
super(TokenType.String, file, range, definitionInfo);
|
||||
this.value = value;
|
||||
this.source = source;
|
||||
this.blockScalarHeader = blockScalarHeader;
|
||||
}
|
||||
|
||||
public override clone(omitSource?: boolean): TemplateToken {
|
||||
return omitSource
|
||||
? new StringToken(undefined, undefined, this.value, this.definitionInfo, this.source, this.blockScalarHeader)
|
||||
: new StringToken(this.file, this.range, this.value, this.definitionInfo, this.source, this.blockScalarHeader);
|
||||
? new StringToken(undefined, undefined, this.value, this.definitionInfo, this.source)
|
||||
: new StringToken(this.file, this.range, this.value, this.definitionInfo, this.source);
|
||||
}
|
||||
|
||||
public override toString(): string {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion */
|
||||
import {nullTrace} from "../../test-utils/null-trace.js";
|
||||
import {parseWorkflow} from "../../workflows/workflow-parser.js";
|
||||
import {MappingToken} from "./mapping-token.js";
|
||||
import {SequenceToken} from "./sequence-token.js";
|
||||
import {StringToken} from "./string-token.js";
|
||||
import {TemplateToken} from "./template-token.js";
|
||||
|
||||
describe("traverse", () => {
|
||||
it("returns parent token, key, and ancestors", () => {
|
||||
it("returns parent token and key", () => {
|
||||
const workflow = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
@@ -20,118 +18,19 @@ describe("traverse", () => {
|
||||
const traverser = TemplateToken.traverse(root);
|
||||
|
||||
// Root
|
||||
const rootResult = traverser.next()!.value!;
|
||||
expect(rootResult[0]).toBeUndefined();
|
||||
expect(rootResult[1]).toBe(root);
|
||||
expect(rootResult[2]).toBeUndefined();
|
||||
expect(rootResult[3]).toEqual([]);
|
||||
expect(traverser.next()!.value).toEqual([undefined, root, undefined]);
|
||||
|
||||
// On
|
||||
const onResult = traverser.next().value!;
|
||||
expect(onResult[0]).toBe(root);
|
||||
expect(getValue(onResult[1])).toEqual("on");
|
||||
expect(onResult[2]).toBeUndefined();
|
||||
expect(onResult[3]).toEqual([root]);
|
||||
|
||||
// Push
|
||||
const pushResult = traverser.next().value!;
|
||||
expect(pushResult[0]).toBe(root);
|
||||
expect(getValue(pushResult[1])).toEqual("push");
|
||||
expect(getValue(pushResult[2])).toEqual("on");
|
||||
expect(pushResult[3]).toEqual([root]);
|
||||
});
|
||||
|
||||
it("returns ancestors for nested mappings", () => {
|
||||
const workflow = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const root = workflow.value!;
|
||||
const results = Array.from(TemplateToken.traverse(root));
|
||||
|
||||
// Find the "ubuntu-latest" token
|
||||
const ubuntuResult = results.find(r => getValue(r[1]) === "ubuntu-latest")!;
|
||||
expect(ubuntuResult).toBeDefined();
|
||||
|
||||
// Ancestors should be: root -> jobs mapping -> build mapping
|
||||
const ancestors = ubuntuResult[3];
|
||||
expect(ancestors.length).toBe(3);
|
||||
expect(ancestors[0]).toBe(root);
|
||||
expect(ancestors[1]).toBeInstanceOf(MappingToken); // jobs mapping
|
||||
expect(ancestors[2]).toBeInstanceOf(MappingToken); // build mapping
|
||||
});
|
||||
|
||||
it("returns ancestors for sequences", () => {
|
||||
const workflow = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hello`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const root = workflow.value!;
|
||||
const results = Array.from(TemplateToken.traverse(root));
|
||||
|
||||
// Find the "echo hello" token
|
||||
const echoResult = results.find(r => getValue(r[1]) === "echo hello")!;
|
||||
expect(echoResult).toBeDefined();
|
||||
|
||||
// Ancestors should be: root -> jobs mapping -> build mapping -> steps sequence -> step mapping
|
||||
const ancestors = echoResult[3];
|
||||
expect(ancestors.length).toBe(5);
|
||||
expect(ancestors[0]).toBe(root);
|
||||
expect(ancestors[1]).toBeInstanceOf(MappingToken); // jobs mapping
|
||||
expect(ancestors[2]).toBeInstanceOf(MappingToken); // build mapping
|
||||
expect(ancestors[3]).toBeInstanceOf(SequenceToken); // steps sequence
|
||||
expect(ancestors[4]).toBeInstanceOf(MappingToken); // step mapping
|
||||
});
|
||||
|
||||
it("returns correct ancestors for matrix values", () => {
|
||||
const workflow = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [a, b]
|
||||
steps:
|
||||
- run: echo hi`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const root = workflow.value!;
|
||||
const results = Array.from(TemplateToken.traverse(root));
|
||||
|
||||
// Find the "a" token (first matrix value)
|
||||
const nodeValueResult = results.find(r => {
|
||||
const token = r[1];
|
||||
return token instanceof StringToken && token.value === "a";
|
||||
})!;
|
||||
expect(nodeValueResult).toBeDefined();
|
||||
|
||||
// Ancestors: root -> jobs mapping -> build mapping -> strategy mapping -> matrix mapping -> node sequence
|
||||
const ancestors = nodeValueResult[3];
|
||||
expect(ancestors.length).toBeGreaterThanOrEqual(5);
|
||||
expect(ancestors[0]).toBe(root);
|
||||
// Last ancestor should be the sequence containing [a, b]
|
||||
expect(ancestors[ancestors.length - 1]).toBeInstanceOf(SequenceToken);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -185,23 +185,14 @@ export abstract class TemplateToken {
|
||||
|
||||
/**
|
||||
* Returns all tokens (depth first)
|
||||
* @param value The object to traverse
|
||||
* @param value The object to travese
|
||||
* @param omitKeys Whether to omit mapping keys
|
||||
* @yields A tuple of [parent, token, keyToken, ancestors] for each token in the tree
|
||||
*/
|
||||
public static *traverse(
|
||||
value: TemplateToken,
|
||||
omitKeys?: boolean
|
||||
): Generator<
|
||||
[
|
||||
parent: TemplateToken | undefined,
|
||||
token: TemplateToken,
|
||||
keyToken: TemplateToken | undefined,
|
||||
ancestors: TemplateToken[]
|
||||
],
|
||||
void
|
||||
> {
|
||||
yield [undefined, value, undefined, []];
|
||||
): Generator<[parent: TemplateToken | undefined, token: TemplateToken, keyToken: TemplateToken | undefined], void> {
|
||||
yield [undefined, value, undefined];
|
||||
|
||||
switch (value.templateTokenType) {
|
||||
case TokenType.Sequence:
|
||||
@@ -211,7 +202,7 @@ export abstract class TemplateToken {
|
||||
while (state.parent) {
|
||||
if (state.moveNext(omitKeys ?? false)) {
|
||||
value = state.current as TemplateToken;
|
||||
yield [state.parent?.current, value, state.currentKey, state.getAncestors()];
|
||||
yield [state.parent?.current, value, state.currentKey];
|
||||
|
||||
switch (value.type) {
|
||||
case TokenType.Sequence:
|
||||
|
||||
@@ -66,19 +66,4 @@ export class TraversalState {
|
||||
throw new Error(`Unexpected token type '${this._token.templateTokenType}' when traversing state`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ancestor tokens from root to the current token's parent container.
|
||||
*/
|
||||
public getAncestors(): TemplateToken[] {
|
||||
const ancestors: TemplateToken[] = [];
|
||||
let state: TraversalState | undefined = this.parent;
|
||||
while (state) {
|
||||
if (state.current) {
|
||||
ancestors.unshift(state.current);
|
||||
}
|
||||
state = state.parent;
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,27 +152,11 @@ export class YamlObjectReader implements ObjectReader {
|
||||
return new BooleanToken(fileId, range, value, undefined);
|
||||
case "string": {
|
||||
let source: string | undefined;
|
||||
let blockScalarHeader: string | undefined;
|
||||
|
||||
if (token.srcToken && "source" in token.srcToken) {
|
||||
source = token.srcToken.source;
|
||||
|
||||
// Extract block scalar header (e.g., |-, |+, >-)
|
||||
//
|
||||
// CST node interfaces are supported and documented per yaml library maintainer:
|
||||
// https://eemeli.org/yaml/#parser -> "For a complete description of CST node
|
||||
// interfaces, please consult the cst.ts source."
|
||||
// See also: https://github.com/eemeli/yaml/issues/643
|
||||
if (token.srcToken.type === "block-scalar" && "props" in token.srcToken) {
|
||||
const props = token.srcToken.props as Array<{type: string; source?: string}>;
|
||||
const headerProp = props.find(p => p.type === "block-scalar-header");
|
||||
if (headerProp?.source) {
|
||||
blockScalarHeader = headerProp.source;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new StringToken(fileId, range, value, undefined, source, blockScalarHeader);
|
||||
return new StringToken(fileId, range, value, undefined, source);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unexpected value type '${typeof value}' when reading object`);
|
||||
|
||||
Reference in New Issue
Block a user