Add action.yml scaffolding snippets (#296)
This commit is contained in:
@@ -51,7 +51,11 @@ describe("FeatureFlags", () => {
|
||||
|
||||
it("returns all features when all is enabled", () => {
|
||||
const flags = new FeatureFlags({all: true});
|
||||
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix", "blockScalarChompingWarning"]);
|
||||
expect(flags.getEnabledFeatures()).toEqual([
|
||||
"missingInputsQuickfix",
|
||||
"blockScalarChompingWarning",
|
||||
"actionScaffoldingSnippets"
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface ExperimentalFeatures {
|
||||
* @default false
|
||||
*/
|
||||
blockScalarChompingWarning?: boolean;
|
||||
|
||||
/**
|
||||
* Enable action scaffolding snippets in action.yml files.
|
||||
* Offers Node.js, Composite, and Docker action scaffolds.
|
||||
* @default false
|
||||
*/
|
||||
actionScaffoldingSnippets?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +46,11 @@ 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"];
|
||||
const allFeatureKeys: ExperimentalFeatureKey[] = [
|
||||
"missingInputsQuickfix",
|
||||
"blockScalarChompingWarning",
|
||||
"actionScaffoldingSnippets"
|
||||
];
|
||||
|
||||
export class FeatureFlags {
|
||||
private readonly features: ExperimentalFeatures;
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {complete} from "./complete";
|
||||
import {complete, CompletionConfig} 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,
|
||||
@@ -393,4 +399,159 @@ 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
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: "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()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
"1_nodejs"
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Composite Action",
|
||||
"Scaffold a composite action",
|
||||
ACTION_SNIPPET_COMPOSITE_USING,
|
||||
position,
|
||||
"2_composite"
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Docker Action",
|
||||
"Scaffold a Docker action",
|
||||
ACTION_SNIPPET_DOCKER_USING,
|
||||
position,
|
||||
"3_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,4 +1,4 @@
|
||||
import {complete as completeExpression, DescriptionDictionary} from "@actions/expressions";
|
||||
import {complete as completeExpression, DescriptionDictionary, FeatureFlags} 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,6 +16,7 @@ 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";
|
||||
@@ -38,24 +39,6 @@ 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);
|
||||
@@ -72,6 +55,7 @@ export type CompletionConfig = {
|
||||
valueProviderConfig?: ValueProviderConfig;
|
||||
contextProviderConfig?: ContextProviderConfig;
|
||||
fileProvider?: FileProvider;
|
||||
featureFlags?: FeatureFlags;
|
||||
};
|
||||
|
||||
export async function complete(
|
||||
@@ -174,6 +158,12 @@ 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;
|
||||
@@ -202,7 +192,7 @@ export async function complete(
|
||||
}
|
||||
|
||||
// Convert values to LSP CompletionItems
|
||||
return values.map(value => {
|
||||
const completionItems = values.map(value => {
|
||||
const newText = value.insertText || value.label;
|
||||
|
||||
// Escape hatches provide their own textEdit to restructure the YAML
|
||||
@@ -237,6 +227,9 @@ export async function complete(
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
// Add action scaffolding snippets if available
|
||||
return [...completionItems, ...actionSnippets];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -626,82 +619,3 @@ 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} from "./complete.js";
|
||||
export {complete, CompletionConfig} from "./complete.js";
|
||||
export {ContextProviderConfig} from "./context-providers/config.js";
|
||||
export {documentLinks} from "./document-links.js";
|
||||
export {hover} from "./hover.js";
|
||||
|
||||
Reference in New Issue
Block a user