Context-aware autocomplete and validation for action.yml runs section (#289)

- Set main as required in node-runs-strict schema definition
- Add validation for invalid key combinations based on using value
- Add validation for missing required keys (main for node, steps for composite, image for docker)
- Filter autocomplete suggestions based on using value
- Prioritize 'using' in completions when not set yet

Fixes context-aware autocomplete for action.yml files where different
action types (node, composite, docker) have different valid keys under runs:
This commit is contained in:
eric sciple
2026-01-06 21:09:38 -06:00
committed by GitHub
parent 39b9b14e3a
commit 2e46c66878
5 changed files with 403 additions and 1 deletions
+111 -1
View File
@@ -38,6 +38,24 @@ import {Value, ValueProviderConfig} from "./value-providers/config.js";
import {defaultValueProviders} from "./value-providers/default.js";
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
/**
* Valid keys for each action type under the `runs:` section.
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
*/
const ACTION_NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
const ACTION_COMPOSITE_KEYS = new Set(["using", "steps"]);
const ACTION_DOCKER_KEYS = new Set([
"using",
"image",
"args",
"env",
"entrypoint",
"pre-entrypoint",
"pre-if",
"post-entrypoint",
"post-if"
]);
export function getExpressionInput(input: string, pos: number): string {
// Find start marker around the cursor position
let startPos = input.lastIndexOf(OPEN_EXPRESSION, pos);
@@ -137,7 +155,7 @@ export async function complete(
const indentString = " ".repeat(indentation.tabSize);
// YAML key/value completions
const values = await getValues(
let values = await getValues(
token,
keyToken,
parent,
@@ -147,6 +165,11 @@ export async function complete(
schema
);
// Filter action.yml `runs:` completions based on `using:` value
if (isAction && parsedTemplate.value) {
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
}
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
values.push(...escapeHatches);
@@ -603,3 +626,90 @@ 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;
}
// Also verify we're completing at the runs level, not deeper (like inside steps)
// The runs mapping should be the last mapping in the path before the completion position
// or the path should only have root -> runs
let lastMappingIndex = -1;
for (let i = path.length - 1; i >= 0; i--) {
if (path[i] instanceof MappingToken) {
lastMappingIndex = i;
break;
}
}
if (lastMappingIndex === -1) {
return values;
}
// If the last mapping in path is not the runs mapping, we're nested deeper (e.g., inside steps)
const lastMapping = path[lastMappingIndex];
if (lastMapping !== runsMapping) {
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()));
}