Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ad6b2e620 |
@@ -0,0 +1,52 @@
|
|||||||
|
# Future Breaking Changes
|
||||||
|
|
||||||
|
This document tracks cleanup changes we want to make in a future major version bump. These are architectural improvements that would break existing import paths or APIs.
|
||||||
|
|
||||||
|
**Current version:** 0.x (pre-1.0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `@actions/workflow-parser`
|
||||||
|
|
||||||
|
### Move shared utilities from `workflows/` to `templates/`
|
||||||
|
|
||||||
|
Several files in `workflows/` are actually generic and should live in `templates/`:
|
||||||
|
|
||||||
|
| File | Current Location | Proposed Location | Notes |
|
||||||
|
|------|------------------|-------------------|-------|
|
||||||
|
| `yaml-object-reader.ts` | `workflows/` | `templates/` | Generic YAML parsing, no workflow dependencies |
|
||||||
|
| `file.ts` | `workflows/` | `templates/` | Generic `{ name, content }` interface |
|
||||||
|
| `file-provider.ts` | `workflows/` | `templates/` | Generic interface |
|
||||||
|
|
||||||
|
**Impact:** Import paths change for consumers using deep imports.
|
||||||
|
|
||||||
|
### Consolidate export strategy
|
||||||
|
|
||||||
|
Currently:
|
||||||
|
- `index.ts` exports the "public API"
|
||||||
|
- `package.json` has `"./*"` allowing deep imports to anything
|
||||||
|
|
||||||
|
Consider:
|
||||||
|
- Explicitly define which subpaths are stable API
|
||||||
|
- Document internal vs public paths
|
||||||
|
- Or: export everything needed from `index.ts` subpath exports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `@actions/languageservice`
|
||||||
|
|
||||||
|
### Rename `action.ts` for clarity
|
||||||
|
|
||||||
|
`languageservice/src/action.ts` contains types for **consuming** actions (validating `uses:` in workflows). The name is ambiguous now that we have action.yml **authoring** support.
|
||||||
|
|
||||||
|
Consider renaming to:
|
||||||
|
- `action-metadata.ts` — clearer that it's about fetched metadata
|
||||||
|
- `action-consumer.ts` — clearer about the use case
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Add items here as we discover them during development
|
||||||
|
- Group by package
|
||||||
|
- Include impact assessment for each change
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/expressions",
|
"name": "@actions/expressions",
|
||||||
"version": "0.3.29",
|
"version": "0.3.28",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageserver",
|
"name": "@actions/languageserver",
|
||||||
"version": "0.3.29",
|
"version": "0.3.28",
|
||||||
"description": "Language server for GitHub Actions",
|
"description": "Language server for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -48,8 +48,8 @@
|
|||||||
"actions-languageserver": "./bin/actions-languageserver"
|
"actions-languageserver": "./bin/actions-languageserver"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/languageservice": "^0.3.29",
|
"@actions/languageservice": "^0.3.28",
|
||||||
"@actions/workflow-parser": "^0.3.29",
|
"@actions/workflow-parser": "^0.3.28",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
"@octokit/types": "^9.0.0",
|
"@octokit/types": "^9.0.0",
|
||||||
"vscode-languageserver": "^8.0.2",
|
"vscode-languageserver": "^8.0.2",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice";
|
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
|
||||||
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
||||||
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
@@ -12,8 +12,6 @@ import {
|
|||||||
HoverParams,
|
HoverParams,
|
||||||
InitializeParams,
|
InitializeParams,
|
||||||
InitializeResult,
|
InitializeResult,
|
||||||
InlayHint,
|
|
||||||
InlayHintParams,
|
|
||||||
TextDocumentIdentifier,
|
TextDocumentIdentifier,
|
||||||
TextDocumentPositionParams,
|
TextDocumentPositionParams,
|
||||||
TextDocuments,
|
TextDocuments,
|
||||||
@@ -74,8 +72,7 @@ export function initConnection(connection: Connection) {
|
|||||||
hoverProvider: true,
|
hoverProvider: true,
|
||||||
documentLinkProvider: {
|
documentLinkProvider: {
|
||||||
resolveProvider: false
|
resolveProvider: false
|
||||||
},
|
}
|
||||||
inlayHintProvider: true
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,12 +158,6 @@ export function initConnection(connection: Connection) {
|
|||||||
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
|
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.languages.inlayHint.on(async ({textDocument}: InlayHintParams): Promise<InlayHint[] | null> => {
|
|
||||||
return timeOperation("inlayHints", () => {
|
|
||||||
return getInlayHints(getDocument(documents, textDocument));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make the text document manager listen on the connection
|
// Make the text document manager listen on the connection
|
||||||
// for open, change and close text document events
|
// for open, change and close text document events
|
||||||
documents.listen(connection);
|
documents.listen(connection);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageservice",
|
"name": "@actions/languageservice",
|
||||||
"version": "0.3.29",
|
"version": "0.3.28",
|
||||||
"description": "Language service for GitHub Actions",
|
"description": "Language service for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.29",
|
"@actions/expressions": "^0.3.28",
|
||||||
"@actions/workflow-parser": "^0.3.29",
|
"@actions/workflow-parser": "^0.3.28",
|
||||||
"vscode-languageserver-textdocument": "^1.0.7",
|
"vscode-languageserver-textdocument": "^1.0.7",
|
||||||
"vscode-languageserver-types": "^3.17.2",
|
"vscode-languageserver-types": "^3.17.2",
|
||||||
"vscode-uri": "^3.0.8",
|
"vscode-uri": "^3.0.8",
|
||||||
|
|||||||
@@ -19,12 +19,9 @@ describe("completion", () => {
|
|||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
expect(result.length).toEqual(12);
|
||||||
expect(result.length).toEqual(14);
|
|
||||||
const labels = result.map(x => x.label);
|
const labels = result.map(x => x.label);
|
||||||
expect(labels).toContain("macos-latest");
|
expect(labels).toContain("macos-latest");
|
||||||
expect(labels).toContain("(switch to list)");
|
|
||||||
expect(labels).toContain("(switch to mapping)");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("needs", async () => {
|
it("needs", async () => {
|
||||||
@@ -98,7 +95,6 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
types: |`;
|
types: |`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
// Expect string values plus escape hatch to switch to list form
|
|
||||||
expect(result.map(x => x.label)).toEqual([
|
expect(result.map(x => x.label)).toEqual([
|
||||||
"created",
|
"created",
|
||||||
"deleted",
|
"deleted",
|
||||||
@@ -106,8 +102,7 @@ jobs:
|
|||||||
"prereleased",
|
"prereleased",
|
||||||
"published",
|
"published",
|
||||||
"released",
|
"released",
|
||||||
"unpublished",
|
"unpublished"
|
||||||
"(switch to list)"
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,11 +190,8 @@ jobs:
|
|||||||
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
|
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
// Custom value plus escape hatches for list and full syntax
|
expect(result.length).toEqual(1);
|
||||||
expect(result.length).toEqual(3);
|
|
||||||
expect(result[0].label).toEqual("my-custom-label");
|
expect(result[0].label).toEqual("my-custom-label");
|
||||||
expect(result.map(x => x.label)).toContain("(switch to list)");
|
|
||||||
expect(result.map(x => x.label)).toContain("(switch to mapping)");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("custom value providers for sequences", async () => {
|
it("custom value providers for sequences", async () => {
|
||||||
@@ -220,9 +212,7 @@ jobs:
|
|||||||
expect(result[0].label).toEqual("my-custom-label");
|
expect(result[0].label).toEqual("my-custom-label");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show mapping keys or parent sibling keys in Key mode", async () => {
|
it("does not show parent mapping sibling keys", async () => {
|
||||||
// At `container: |`, the scalar form is a string with no constants.
|
|
||||||
// Mapping keys should NOT be shown inline - but escape hatch to full syntax IS shown.
|
|
||||||
const input = `on: push
|
const input = `on: push
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -230,21 +220,20 @@ jobs:
|
|||||||
runs-on: ubuntu-latest`;
|
runs-on: ubuntu-latest`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
// Only escape hatch to full syntax (container has mapping form but no sequence)
|
expect(result.length).toEqual(6);
|
||||||
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
// Should not contain other top-level job keys like `if` and `runs-on`
|
||||||
|
expect(result.map(x => x.label)).not.toContain("if");
|
||||||
|
expect(result.map(x => x.label)).not.toContain("runs-on");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show mapping keys in Key mode when structure is uncommitted", async () => {
|
it("shows mapping keys within a new map ", async () => {
|
||||||
// At `concurrency: |`, user is in Key mode but hasn't committed to a structure.
|
|
||||||
// The scalar form is a string with no constants, so no scalar completions.
|
|
||||||
// But escape hatch to full syntax IS shown as a way out.
|
|
||||||
const input = `on: push
|
const input = `on: push
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
concurrency: |`;
|
concurrency: |`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
expect(result.map(x => x.label).sort()).toEqual(["cancel-in-progress", "group"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("job key", async () => {
|
it("job key", async () => {
|
||||||
@@ -277,10 +266,7 @@ jobs:
|
|||||||
concurrency: 'group-name'`;
|
concurrency: 'group-name'`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
// Verify we get job-level completions, but concurrency is already present so excluded
|
expect(result).toHaveLength(29);
|
||||||
expect(result.length).toBeGreaterThan(20);
|
|
||||||
expect(result.some(x => x.label === "runs-on")).toBe(true);
|
|
||||||
expect(result.some(x => x.label === "concurrency")).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("step key without space after colon", async () => {
|
it("step key without space after colon", async () => {
|
||||||
@@ -349,9 +335,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
`;
|
`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
// Verify we get job-level completions including runs-on variants
|
expect(result).toHaveLength(25);
|
||||||
expect(result.length).toBeGreaterThan(20);
|
|
||||||
expect(result.some(x => x.label === "steps")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("complete from behind a colon will replace it", async () => {
|
it("complete from behind a colon will replace it", async () => {
|
||||||
@@ -364,8 +348,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
`;
|
`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
// Verify we get job-level completions
|
expect(result).toHaveLength(25);
|
||||||
expect(result.length).toBeGreaterThan(20);
|
|
||||||
const textEdit = result[0].textEdit as TextEdit;
|
const textEdit = result[0].textEdit as TextEdit;
|
||||||
expect(textEdit.range).toEqual({
|
expect(textEdit.range).toEqual({
|
||||||
start: {line: 5, character: 4},
|
start: {line: 5, character: 4},
|
||||||
@@ -464,9 +447,8 @@ jobs:
|
|||||||
"timeout-minutes: "
|
"timeout-minutes: "
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// One-of (scalar variant)
|
// One-of
|
||||||
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.detail === undefined);
|
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
|
||||||
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("custom indentation", async () => {
|
it("custom indentation", async () => {
|
||||||
@@ -488,21 +470,20 @@ jobs:
|
|||||||
"timeout-minutes: "
|
"timeout-minutes: "
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// One-of (scalar variant)
|
// One-of
|
||||||
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.detail === undefined);
|
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
|
||||||
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show mapping keys in Key mode for one-of with mapping variant", async () => {
|
it("adds a new line and indentation for mapping keys when the key is given", async () => {
|
||||||
// At `concurrency: |`, mapping keys should NOT be shown.
|
|
||||||
// Users who want the mapping form should use `concurrency (full syntax)` at parent level.
|
|
||||||
const input = "concurrency: |";
|
const input = "concurrency: |";
|
||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
expect(result.filter(x => x.label === "cancel-in-progress")).toEqual([]);
|
expect(result.filter(x => x.label === "cancel-in-progress").map(x => x.textEdit?.newText)).toEqual([
|
||||||
expect(result.filter(x => x.label === "group")).toEqual([]);
|
"\n cancel-in-progress: "
|
||||||
|
]);
|
||||||
|
expect(result.filter(x => x.label === "group").map(x => x.textEdit?.newText)).toEqual(["\n group: "]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not add new line if no key in line", async () => {
|
it("does not add new line if no key in line", async () => {
|
||||||
@@ -529,9 +510,7 @@ jobs:
|
|||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
// Scalar variant inserts "types: "
|
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]);
|
||||||
const scalarVariant = result.find(x => x.label === "types" && x.detail === undefined);
|
|
||||||
expect(scalarVariant?.textEdit?.newText).toEqual("types: ");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show mapping keys for one-of when user has typed a scalar value", async () => {
|
it("does not show mapping keys for one-of when user has typed a scalar value", async () => {
|
||||||
@@ -542,14 +521,12 @@ jobs:
|
|||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
// check_run's scalar form only accepts null, so typing anything should show no completions
|
// check_run's scalar form only accepts null, so typing anything should show no completions
|
||||||
// (we don't show mapping keys like `types` anymore - user should use check_run with detail "full syntax" instead)
|
// (we don't show mapping keys like `types` anymore - user should use `check_run (full syntax)` instead)
|
||||||
expect(result.filter(x => x.label === "types")).toEqual([]);
|
expect(result.filter(x => x.label === "types")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows only scalar options for one-of in Key mode when user hasn't committed to a type", async () => {
|
it("shows all options for one-of when user hasn't committed to a type yet", async () => {
|
||||||
// At `permissions: |` user hasn't typed anything yet - show only scalar options
|
// At `permissions: |` user hasn't typed anything yet - show all options
|
||||||
// Mapping keys are NOT shown because they would require a newline
|
|
||||||
// Users who want the mapping form can use `permissions (full syntax)` at the parent level
|
|
||||||
const input = "on: push\npermissions: |";
|
const input = "on: push\npermissions: |";
|
||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
@@ -558,9 +535,9 @@ jobs:
|
|||||||
expect(result.filter(x => x.label === "read-all").map(x => x.textEdit?.newText)).toEqual(["read-all"]);
|
expect(result.filter(x => x.label === "read-all").map(x => x.textEdit?.newText)).toEqual(["read-all"]);
|
||||||
expect(result.filter(x => x.label === "write-all").map(x => x.textEdit?.newText)).toEqual(["write-all"]);
|
expect(result.filter(x => x.label === "write-all").map(x => x.textEdit?.newText)).toEqual(["write-all"]);
|
||||||
|
|
||||||
// Mapping keys should NOT be shown - they require a newline which is confusing inline
|
// Mapping keys should also be available (user hasn't committed yet)
|
||||||
expect(result.filter(x => x.label === "actions")).toEqual([]);
|
expect(result.filter(x => x.label === "actions").map(x => x.textEdit?.newText)).toEqual(["\n actions: "]);
|
||||||
expect(result.filter(x => x.label === "contents")).toEqual([]);
|
expect(result.filter(x => x.label === "contents").map(x => x.textEdit?.newText)).toEqual(["\n contents: "]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters to scalar options when user has started typing a scalar", async () => {
|
it("filters to scalar options when user has started typing a scalar", async () => {
|
||||||
@@ -576,18 +553,20 @@ jobs:
|
|||||||
expect(result.filter(x => x.label === "contents")).toEqual([]);
|
expect(result.filter(x => x.label === "contents")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows both simple and full syntax for null+mapping one-of", async () => {
|
it("shows full syntax for null+mapping one-of (skips null-only scalar)", async () => {
|
||||||
// check_run is a one-of: [null, mapping]. Show both:
|
// check_run is a one-of: [null, mapping].
|
||||||
// - check_run (simple, just the key with colon)
|
// Since the scalar form is only null (no string constants), we skip it
|
||||||
// - check_run with detail "full syntax" (ready to add mapping keys)
|
// to avoid clobbering string constants from elsewhere in the schema.
|
||||||
|
// User should see check_run (full syntax) for the mapping form.
|
||||||
const input = "on:\n |";
|
const input = "on:\n |";
|
||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
// Should have both check_run (scalar) and check_run with detail "full syntax"
|
// Should NOT have plain check_run (null-only scalar is skipped)
|
||||||
const checkRunVariants = result.filter(x => x.label === "check_run");
|
// Instead, string constant check_run from on-string-strict is available
|
||||||
expect(checkRunVariants.some(x => x.detail === undefined)).toBe(true);
|
expect(result.some(x => x.label === "check_run")).toBe(true);
|
||||||
expect(checkRunVariants.some(x => x.detail === "full syntax")).toBe(true);
|
// Full syntax variant should be available
|
||||||
|
expect(result.some(x => x.label === "check_run (full syntax)")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows all three variants for scalar+sequence+mapping one-of", async () => {
|
it("shows all three variants for scalar+sequence+mapping one-of", async () => {
|
||||||
@@ -599,12 +578,10 @@ jobs:
|
|||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
// Should have runs-on (scalar), runs-on with detail "list", and runs-on with detail "full syntax"
|
// Should have runs-on, runs-on (list), and runs-on (full syntax)
|
||||||
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
expect(result.some(x => x.label === "runs-on")).toBe(true);
|
||||||
expect(runsOnVariants.length).toBe(3);
|
expect(result.some(x => x.label === "runs-on (list)")).toBe(true);
|
||||||
expect(runsOnVariants.some(x => x.detail === undefined)).toBe(true);
|
expect(result.some(x => x.label === "runs-on (full syntax)")).toBe(true);
|
||||||
expect(runsOnVariants.some(x => x.detail === "list")).toBe(true);
|
|
||||||
expect(runsOnVariants.some(x => x.detail === "full syntax")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates correct insertText for one-of variants in parent mode", async () => {
|
it("generates correct insertText for one-of variants in parent mode", async () => {
|
||||||
@@ -616,34 +593,31 @@ jobs:
|
|||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
|
||||||
|
|
||||||
// Scalar: just key with colon and space
|
// Scalar: just key with colon and space
|
||||||
expect(runsOnVariants.find(x => x.detail === undefined)?.textEdit?.newText).toEqual("runs-on: ");
|
expect(result.find(x => x.label === "runs-on")?.textEdit?.newText).toEqual("runs-on: ");
|
||||||
|
|
||||||
// Sequence: key with colon, newline, and list item
|
// Sequence: key with colon, newline, and list item
|
||||||
expect(runsOnVariants.find(x => x.detail === "list")?.textEdit?.newText).toEqual("runs-on:\n - ");
|
expect(result.find(x => x.label === "runs-on (list)")?.textEdit?.newText).toEqual("runs-on:\n - ");
|
||||||
|
|
||||||
// Mapping: key with colon, newline, and indentation for nested keys
|
// Mapping: key with colon, newline, and indentation for nested keys
|
||||||
expect(runsOnVariants.find(x => x.detail === "full syntax")?.textEdit?.newText).toEqual("runs-on:\n ");
|
expect(result.find(x => x.label === "runs-on (full syntax)")?.textEdit?.newText).toEqual("runs-on:\n ");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates correct insertText for one-of variants in parent mode", async () => {
|
it("generates correct insertText for one-of variants in key mode", async () => {
|
||||||
// concurrency is a one-of: [string, mapping] - testing parent mode (inside mapping)
|
// concurrency is a one-of: [string, mapping] - testing key mode (after colon on same line)
|
||||||
// At `concurrency:\n |`, user HAS committed to mapping structure, so mapping keys are shown
|
const input = "concurrency: |";
|
||||||
const input = "concurrency:\n |";
|
|
||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
// In parent mode: just key + colon + space (no leading newline)
|
// Scalar in key mode: newline + indented key + colon + space
|
||||||
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("group: ");
|
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("\n group: ");
|
||||||
|
|
||||||
// Boolean in parent mode (cancel-in-progress): key + colon + space
|
// Boolean in key mode (cancel-in-progress): newline + indented key + colon + space
|
||||||
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("cancel-in-progress: ");
|
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("\n cancel-in-progress: ");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses sortText for ordering qualified one-of variants", async () => {
|
it("uses base key as filterText for qualified one-of variants", async () => {
|
||||||
// runs-on has multiple structural types, so variants need sorting
|
// runs-on has multiple structural types, so variants get qualifiers
|
||||||
const input = `on: push
|
const input = `on: push
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -651,14 +625,12 @@ jobs:
|
|||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
// Scalar: no qualifier, so no filterText needed
|
||||||
|
expect(result.find(x => x.label === "runs-on")?.filterText).toBeUndefined();
|
||||||
|
|
||||||
// Scalar: no sortText needed (sorts naturally first)
|
// Sequence and mapping: qualified labels should filter on base key
|
||||||
expect(runsOnVariants.find(x => x.detail === undefined)?.sortText).toBeUndefined();
|
expect(result.find(x => x.label === "runs-on (list)")?.filterText).toEqual("runs-on");
|
||||||
|
expect(result.find(x => x.label === "runs-on (full syntax)")?.filterText).toEqual("runs-on");
|
||||||
// Sequence and mapping: sortText controls ordering
|
|
||||||
expect(runsOnVariants.find(x => x.detail === "list")?.sortText).toEqual("runs-on 1");
|
|
||||||
expect(runsOnVariants.find(x => x.detail === "full syntax")?.sortText).toEqual("runs-on 2");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scalar event completion inserts inline without newline", async () => {
|
it("scalar event completion inserts inline without newline", async () => {
|
||||||
@@ -672,13 +644,14 @@ jobs:
|
|||||||
const push = result.find(x => x.label === "push");
|
const push = result.find(x => x.label === "push");
|
||||||
expect(push?.textEdit?.newText).toEqual("push");
|
expect(push?.textEdit?.newText).toEqual("push");
|
||||||
|
|
||||||
const checkRun = result.find(x => x.label === "check_run" && x.detail === undefined);
|
const checkRun = result.find(x => x.label === "check_run");
|
||||||
expect(checkRun?.textEdit?.newText).toEqual("check_run");
|
expect(checkRun?.textEdit?.newText).toEqual("check_run");
|
||||||
|
|
||||||
// Full syntax form should NOT be shown in Key mode - it requires a newline
|
// Full syntax form inserts as a mapping key (with newline in Key mode)
|
||||||
// which is confusing when typing inline. Users who want the mapping form
|
// This is expected behavior - it starts the mapping form
|
||||||
// can use `on (full syntax)` at the parent level.
|
const checkRunFull = result.find(x => x.label === "check_run (full syntax)");
|
||||||
expect(result.find(x => x.label === "check_run" && x.detail === "full syntax")).toBeUndefined();
|
// In Key mode: \n + indent + key + : + \n + indent + indent (for nested content)
|
||||||
|
expect(checkRunFull?.textEdit?.newText).toEqual("\n check_run:\n ");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters to sequence options when user has started a sequence", async () => {
|
it("filters to sequence options when user has started a sequence", async () => {
|
||||||
@@ -699,130 +672,4 @@ jobs:
|
|||||||
expect(result.filter(x => x.label === "group")).toEqual([]);
|
expect(result.filter(x => x.label === "group")).toEqual([]);
|
||||||
expect(result.filter(x => x.label === "labels")).toEqual([]);
|
expect(result.filter(x => x.label === "labels")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("escape hatch completions", () => {
|
|
||||||
it("runs-on shows switch to list and full syntax", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: |`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
// Should have escape hatches at the end
|
|
||||||
const switchToList = result.find(x => x.label === "(switch to list)");
|
|
||||||
const switchToFull = result.find(x => x.label === "(switch to mapping)");
|
|
||||||
|
|
||||||
expect(switchToList).toBeDefined();
|
|
||||||
expect(switchToFull).toBeDefined();
|
|
||||||
|
|
||||||
// Escape hatches should sort last
|
|
||||||
expect(switchToList!.sortText).toEqual("zzz_switch_1");
|
|
||||||
expect(switchToFull!.sortText).toEqual("zzz_switch_2");
|
|
||||||
|
|
||||||
// Escape hatches should have textEdit that restructures the YAML
|
|
||||||
const listEdit = switchToList!.textEdit as TextEdit;
|
|
||||||
const fullEdit = switchToFull!.textEdit as TextEdit;
|
|
||||||
|
|
||||||
expect(listEdit.newText).toEqual("runs-on:\n - ");
|
|
||||||
expect(fullEdit.newText).toEqual("runs-on:\n ");
|
|
||||||
|
|
||||||
// TextEdit range should cover from key start to cursor position
|
|
||||||
expect(listEdit.range.start).toEqual({line: 3, character: 4});
|
|
||||||
expect(fullEdit.range.start).toEqual({line: 3, character: 4});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("permissions shows only switch to full syntax (no sequence form)", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
permissions: |`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
// Should have full syntax escape hatch but NOT list (permissions has no sequence form)
|
|
||||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
|
|
||||||
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("escape hatches are not shown when value is non-empty", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-|`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
// User has started typing a scalar value, no escape hatches
|
|
||||||
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
|
||||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("escape hatches are not shown when inside a sequence", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on:
|
|
||||||
- |`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
// User is already in sequence form, no escape hatches
|
|
||||||
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
|
||||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("escape hatches are not shown when inside a mapping", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on:
|
|
||||||
group: |`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
// User is in mapping form completing a value, no escape hatches for the parent
|
|
||||||
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
|
||||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("escape hatches ARE shown even when no scalar completions exist", async () => {
|
|
||||||
// concurrency: | has no scalar constants, but escape hatch provides a way out
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
concurrency: |`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
// Escape hatch to mapping should be available even with no scalar completions
|
|
||||||
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pure mapping type (strategy) shows switch to mapping", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
strategy: |`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pure sequence type (steps) shows switch to list", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
steps: |`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
expect(result.some(x => x.label === "(switch to list)")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selecting switch to list restructures YAML", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: |`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
const switchToList = result.find(x => x.label === "(switch to list)");
|
|
||||||
const textEdit = switchToList!.textEdit as TextEdit;
|
|
||||||
|
|
||||||
// Applying this edit to "runs-on: " should produce "runs-on:\n - "
|
|
||||||
expect(textEdit.newText).toEqual("runs-on:\n - ");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import {complete as completeExpression, DescriptionDictionary} from "@actions/ex
|
|||||||
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
||||||
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||||
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
|
|
||||||
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
|
|
||||||
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
@@ -11,7 +9,6 @@ import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range"
|
|||||||
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
||||||
import {File} from "@actions/workflow-parser/workflows/file";
|
import {File} from "@actions/workflow-parser/workflows/file";
|
||||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||||
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
|
|
||||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
||||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||||
@@ -103,17 +100,8 @@ export async function complete(
|
|||||||
|
|
||||||
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
|
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
|
||||||
|
|
||||||
// Add escape hatch completions when completing an empty scalar value for a one-of field.
|
|
||||||
// These provide a way out of "dead end" situations where no scalar completions exist
|
|
||||||
// but alternative structural forms (list, mapping) are available.
|
|
||||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos);
|
|
||||||
values.push(...escapeHatches);
|
|
||||||
|
|
||||||
// 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;
|
let replaceRange: Range | undefined;
|
||||||
if (token?.range) {
|
if (token?.range) {
|
||||||
// Prefer the token's range since it accounts for YAML syntax like quotes
|
|
||||||
replaceRange = mapRange(token.range);
|
replaceRange = mapRange(token.range);
|
||||||
} else if (!token) {
|
} else if (!token) {
|
||||||
// Not a valid token, create a range from the current position
|
// Not a valid token, create a range from the current position
|
||||||
@@ -139,19 +127,8 @@ export async function complete(
|
|||||||
return values.map(value => {
|
return values.map(value => {
|
||||||
const newText = value.insertText || value.label;
|
const newText = value.insertText || value.label;
|
||||||
|
|
||||||
// Escape hatches provide their own textEdit to restructure the YAML
|
|
||||||
let textEdit: TextEdit;
|
|
||||||
if (value.textEdit) {
|
|
||||||
textEdit = TextEdit.replace(value.textEdit.range, value.textEdit.newText);
|
|
||||||
} else if (replaceRange) {
|
|
||||||
textEdit = TextEdit.replace(replaceRange, newText);
|
|
||||||
} else {
|
|
||||||
textEdit = TextEdit.insert(position, newText);
|
|
||||||
}
|
|
||||||
|
|
||||||
const item: CompletionItem = {
|
const item: CompletionItem = {
|
||||||
label: value.label,
|
label: value.label,
|
||||||
detail: value.detail,
|
|
||||||
filterText: value.filterText,
|
filterText: value.filterText,
|
||||||
sortText: value.sortText,
|
sortText: value.sortText,
|
||||||
documentation: value.description && {
|
documentation: value.description && {
|
||||||
@@ -159,7 +136,7 @@ export async function complete(
|
|||||||
value: value.description
|
value: value.description
|
||||||
},
|
},
|
||||||
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
|
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
|
||||||
textEdit
|
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
|
||||||
};
|
};
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
@@ -268,112 +245,6 @@ function getTokenStructure(token: TemplateToken | null): TokenStructure {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates escape hatch completions that allow switching from scalar form to
|
|
||||||
* alternative structural forms (sequence or mapping) when the value is empty.
|
|
||||||
*
|
|
||||||
* For example, at `runs-on: |`, this adds "(switch to list)" and "(switch to full syntax)"
|
|
||||||
* completions that restructure the YAML to `runs-on:\n - |` or `runs-on:\n |`.
|
|
||||||
*
|
|
||||||
* Only shown when:
|
|
||||||
* - Completing in value position (keyToken exists)
|
|
||||||
* - Value is empty (user hasn't committed to a structure yet)
|
|
||||||
* - Definition allows sequence or mapping structure
|
|
||||||
*/
|
|
||||||
function getEscapeHatchCompletions(
|
|
||||||
token: TemplateToken | null,
|
|
||||||
keyToken: TemplateToken | null,
|
|
||||||
indentation: string,
|
|
||||||
position: Position
|
|
||||||
): Value[] {
|
|
||||||
// Only show escape hatches when value is empty
|
|
||||||
const tokenStructure = getTokenStructure(token);
|
|
||||||
if (tokenStructure !== undefined) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need a key token with a definition
|
|
||||||
if (!keyToken?.definition) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which structural types are available from the definition
|
|
||||||
const def = keyToken.definition;
|
|
||||||
const schema = getWorkflowSchema();
|
|
||||||
const buckets = {
|
|
||||||
sequence: false,
|
|
||||||
mapping: false
|
|
||||||
};
|
|
||||||
|
|
||||||
if (def instanceof OneOfDefinition) {
|
|
||||||
// OneOf: check each variant
|
|
||||||
for (const variantKey of def.oneOf) {
|
|
||||||
const variantDef = schema.definitions[variantKey];
|
|
||||||
if (variantDef) {
|
|
||||||
switch (variantDef.definitionType) {
|
|
||||||
case DefinitionType.Sequence:
|
|
||||||
buckets.sequence = true;
|
|
||||||
break;
|
|
||||||
case DefinitionType.Mapping:
|
|
||||||
buckets.mapping = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Single definition type
|
|
||||||
switch (def.definitionType) {
|
|
||||||
case DefinitionType.Sequence:
|
|
||||||
buckets.sequence = true;
|
|
||||||
break;
|
|
||||||
case DefinitionType.Mapping:
|
|
||||||
buckets.mapping = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: Value[] = [];
|
|
||||||
const keyName = isString(keyToken) ? keyToken.value : "";
|
|
||||||
const keyRange = keyToken.range;
|
|
||||||
|
|
||||||
if (!keyRange || !keyName) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the range from key start to current position
|
|
||||||
// This covers "key: " so we can replace it with "key:\n - " or "key:\n "
|
|
||||||
const editRange = {
|
|
||||||
start: {line: keyRange.start.line - 1, character: keyRange.start.column - 1},
|
|
||||||
end: {line: position.line, character: position.character}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (buckets.sequence) {
|
|
||||||
results.push({
|
|
||||||
label: "(switch to list)",
|
|
||||||
sortText: "zzz_switch_1",
|
|
||||||
filterText: keyName, // Allow filtering by key name
|
|
||||||
textEdit: {
|
|
||||||
range: editRange,
|
|
||||||
newText: `${keyName}:\n${indentation}- `
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buckets.mapping) {
|
|
||||||
results.push({
|
|
||||||
label: "(switch to mapping)",
|
|
||||||
sortText: "zzz_switch_2",
|
|
||||||
filterText: keyName, // Allow filtering by key name
|
|
||||||
textEdit: {
|
|
||||||
range: editRange,
|
|
||||||
newText: `${keyName}:\n${indentation}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collects values that are already present in the current context, so they can be
|
* Collects values that are already present in the current context, so they can be
|
||||||
* excluded from completion suggestions.
|
* excluded from completion suggestions.
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ describe("end-to-end", () => {
|
|||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result.length).toEqual(13);
|
expect(result.length).toEqual(13);
|
||||||
const labelsWithDetails = result.map(x => (x.detail ? `${x.label} (${x.detail})` : x.label));
|
const labels = result.map(x => x.label);
|
||||||
expect(labelsWithDetails).toEqual([
|
expect(labels).toEqual([
|
||||||
"concurrency",
|
"concurrency",
|
||||||
"concurrency (full syntax)",
|
"concurrency (full syntax)",
|
||||||
"defaults",
|
"defaults",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export {complete} from "./complete.js";
|
|||||||
export {ContextProviderConfig} from "./context-providers/config.js";
|
export {ContextProviderConfig} from "./context-providers/config.js";
|
||||||
export {documentLinks} from "./document-links.js";
|
export {documentLinks} from "./document-links.js";
|
||||||
export {hover} from "./hover.js";
|
export {hover} from "./hover.js";
|
||||||
export {getInlayHints} from "./inlay-hints.js";
|
|
||||||
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log.js";
|
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log.js";
|
||||||
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate.js";
|
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate.js";
|
||||||
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
import {InlayHintKind} from "vscode-languageserver-types";
|
|
||||||
import {getInlayHints} from "./inlay-hints.js";
|
|
||||||
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";
|
|
||||||
|
|
||||||
registerLogger(new TestLogger());
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
clearCache();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("inlay-hints", () => {
|
|
||||||
describe("cron expressions", () => {
|
|
||||||
it("returns inlay hint for valid cron expression", () => {
|
|
||||||
const input = `on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 * * * *'
|
|
||||||
`;
|
|
||||||
const document = createDocument("test.yaml", input);
|
|
||||||
const hints = getInlayHints(document);
|
|
||||||
|
|
||||||
expect(hints).toHaveLength(1);
|
|
||||||
expect(hints[0].label).toBe("→ Runs every hour");
|
|
||||||
expect(hints[0].kind).toBe(InlayHintKind.Parameter);
|
|
||||||
expect(hints[0].paddingLeft).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns correct position at end of cron value", () => {
|
|
||||||
const input = `on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 3 * * 1'
|
|
||||||
`;
|
|
||||||
const document = createDocument("test.yaml", input);
|
|
||||||
const hints = getInlayHints(document);
|
|
||||||
|
|
||||||
expect(hints).toHaveLength(1);
|
|
||||||
// Position should be at the end of the cron string value (after the closing quote)
|
|
||||||
// Line 3 (0-indexed: 2), end of '0 3 * * 1'
|
|
||||||
expect(hints[0].position.line).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns no hint for invalid cron expression", () => {
|
|
||||||
const input = `on:
|
|
||||||
schedule:
|
|
||||||
- cron: 'invalid cron'
|
|
||||||
`;
|
|
||||||
const document = createDocument("test.yaml", input);
|
|
||||||
const hints = getInlayHints(document);
|
|
||||||
|
|
||||||
expect(hints).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns multiple hints for multiple cron expressions", () => {
|
|
||||||
const input = `on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 * * * *'
|
|
||||||
- cron: '0 0 * * *'
|
|
||||||
`;
|
|
||||||
const document = createDocument("test.yaml", input);
|
|
||||||
const hints = getInlayHints(document);
|
|
||||||
|
|
||||||
expect(hints).toHaveLength(2);
|
|
||||||
expect(hints[0].label).toBe("→ Runs every hour");
|
|
||||||
expect(hints[1].label).toBe("→ Runs at 00:00");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns hint with descriptive label for weekly cron", () => {
|
|
||||||
const input = `on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 3 * * 1'
|
|
||||||
`;
|
|
||||||
const document = createDocument("test.yaml", input);
|
|
||||||
const hints = getInlayHints(document);
|
|
||||||
|
|
||||||
expect(hints).toHaveLength(1);
|
|
||||||
expect(hints[0].label).toContain("Monday");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns no hints for empty workflow", () => {
|
|
||||||
const input = ``;
|
|
||||||
const document = createDocument("test.yaml", input);
|
|
||||||
const hints = getInlayHints(document);
|
|
||||||
|
|
||||||
expect(hints).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns no hints for workflow without schedule", () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hello
|
|
||||||
`;
|
|
||||||
const document = createDocument("test.yaml", input);
|
|
||||||
const hints = getInlayHints(document);
|
|
||||||
|
|
||||||
expect(hints).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns hint for frequent cron that triggers warning", () => {
|
|
||||||
// Even crons that trigger the <5min warning should still get inlay hints
|
|
||||||
const input = `on:
|
|
||||||
schedule:
|
|
||||||
- cron: '* * * * *'
|
|
||||||
`;
|
|
||||||
const document = createDocument("test.yaml", input);
|
|
||||||
const hints = getInlayHints(document);
|
|
||||||
|
|
||||||
expect(hints).toHaveLength(1);
|
|
||||||
expect(hints[0].label).toBe("→ Runs every minute");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import {isString} from "@actions/workflow-parser";
|
|
||||||
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
|
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
|
||||||
import {File} from "@actions/workflow-parser/workflows/file";
|
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
|
||||||
import {InlayHint, InlayHintKind} from "vscode-languageserver-types";
|
|
||||||
import {fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns inlay hints for a workflow document.
|
|
||||||
* Currently supports cron expressions, showing a human-readable description
|
|
||||||
* of the schedule inline after the cron value.
|
|
||||||
*
|
|
||||||
* @param document Text document to get inlay hints for
|
|
||||||
* @returns Array of inlay hints
|
|
||||||
*/
|
|
||||||
export function getInlayHints(document: TextDocument): InlayHint[] {
|
|
||||||
const file: File = {
|
|
||||||
name: document.uri,
|
|
||||||
content: document.getText()
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = fetchOrParseWorkflow(file, document.uri);
|
|
||||||
if (!result?.value) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const hints: InlayHint[] = [];
|
|
||||||
|
|
||||||
// Traverse the workflow AST to find cron expressions
|
|
||||||
for (const [parent, token, key] of TemplateToken.traverse(result.value)) {
|
|
||||||
const validationToken = key || parent || token;
|
|
||||||
const validationDefinition = validationToken.definition;
|
|
||||||
|
|
||||||
// Check for cron-pattern tokens
|
|
||||||
if (isString(token) && token.range && validationDefinition?.key === "cron-pattern") {
|
|
||||||
const cronValue = token.value;
|
|
||||||
const description = getCronDescription(cronValue);
|
|
||||||
|
|
||||||
if (description) {
|
|
||||||
// Position the hint at the end of the cron value
|
|
||||||
hints.push({
|
|
||||||
position: {
|
|
||||||
line: token.range.end.line - 1, // Convert from 1-based to 0-based
|
|
||||||
character: token.range.end.column - 1 // Convert from 1-based to 0-based
|
|
||||||
},
|
|
||||||
label: `→ ${description}`,
|
|
||||||
kind: InlayHintKind.Parameter,
|
|
||||||
paddingLeft: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hints;
|
|
||||||
}
|
|
||||||
@@ -231,7 +231,7 @@ jobs:
|
|||||||
} as Diagnostic);
|
} as Diagnostic);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cron with interval of 5 minutes or more shows no diagnostic", async () => {
|
it("cron with interval of 5 minutes or more shows info", async () => {
|
||||||
const result = await validate(
|
const result = await validate(
|
||||||
createDocument(
|
createDocument(
|
||||||
"wf.yaml",
|
"wf.yaml",
|
||||||
@@ -245,7 +245,25 @@ jobs:
|
|||||||
{valueProviderConfig: defaultValueProviders}
|
{valueProviderConfig: defaultValueProviders}
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.length).toBe(0);
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
message: "Runs every 5 minutes",
|
||||||
|
severity: DiagnosticSeverity.Information,
|
||||||
|
code: "on-schedule",
|
||||||
|
codeDescription: {
|
||||||
|
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
|
||||||
|
},
|
||||||
|
range: {
|
||||||
|
end: {
|
||||||
|
character: 25,
|
||||||
|
line: 2
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
character: 12,
|
||||||
|
line: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as Diagnostic);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cron with comma-separated minutes less than 5 apart shows warning", async () => {
|
it("cron with comma-separated minutes less than 5 apart shows warning", async () => {
|
||||||
|
|||||||
@@ -258,6 +258,17 @@ function validateCronExpression(diagnostics: Diagnostic[], token: StringToken):
|
|||||||
href: CRON_SCHEDULE_DOCS_URL
|
href: CRON_SCHEDULE_DOCS_URL
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Show info message for valid cron expressions
|
||||||
|
diagnostics.push({
|
||||||
|
message: description,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
severity: DiagnosticSeverity.Information,
|
||||||
|
code: "on-schedule",
|
||||||
|
codeDescription: {
|
||||||
|
href: CRON_SCHEDULE_DOCS_URL
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ export interface Value {
|
|||||||
/** Optional description to show when auto-completing */
|
/** Optional description to show when auto-completing */
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
/** Optional detail shown after the label, e.g. type or kind information */
|
|
||||||
detail?: string;
|
|
||||||
|
|
||||||
/** Whether this value is deprecated */
|
/** Whether this value is deprecated */
|
||||||
deprecated?: boolean;
|
deprecated?: boolean;
|
||||||
|
|
||||||
@@ -21,12 +18,6 @@ export interface Value {
|
|||||||
|
|
||||||
/** Sort text to control ordering, if not given `label` will be used for sorting */
|
/** Sort text to control ordering, if not given `label` will be used for sorting */
|
||||||
sortText?: string;
|
sortText?: string;
|
||||||
|
|
||||||
/** Custom text edit with specific range, overrides default range calculation */
|
|
||||||
textEdit?: {
|
|
||||||
range: {start: {line: number; character: number}; end: {line: number; character: number}};
|
|
||||||
newText: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ValueProviderKind {
|
export enum ValueProviderKind {
|
||||||
|
|||||||
@@ -198,32 +198,15 @@ function oneOfValues(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// In Key mode (after colon, e.g., `on: |`), only include scalar variants when
|
|
||||||
// completing an empty value. Mapping/sequence forms require newlines which is
|
|
||||||
// confusing when typing inline. Users who want those forms can use completions
|
|
||||||
// like `(full syntax)` or `(list)` at the parent level.
|
|
||||||
if (!tokenStructure && mode === DefinitionValueMode.Key) {
|
|
||||||
const variantBucket = getStructuralBucket(variantDef.definitionType);
|
|
||||||
if (variantBucket !== "scalar") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
values.push(...definitionValues(variantDef, indentation, mode, tokenStructure));
|
values.push(...definitionValues(variantDef, indentation, mode, tokenStructure));
|
||||||
}
|
}
|
||||||
return distinctValues(values);
|
return distinctValues(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates values by label and detail.
|
|
||||||
* Values with the same label but different details are preserved as distinct items.
|
|
||||||
*/
|
|
||||||
function distinctValues(values: Value[]): Value[] {
|
function distinctValues(values: Value[]): Value[] {
|
||||||
const map = new Map<string, Value>();
|
const map = new Map<string, Value>();
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
// Include detail in the key to preserve variants with different details
|
map.set(value.label, value);
|
||||||
const key = value.detail ? `${value.label}\0${value.detail}` : value.label;
|
|
||||||
map.set(key, value);
|
|
||||||
}
|
}
|
||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
}
|
}
|
||||||
@@ -323,10 +306,10 @@ function expandOneOfToCompletions(
|
|||||||
? `\n${indentation}${key}:\n${indentation}${indentation}- `
|
? `\n${indentation}${key}:\n${indentation}${indentation}- `
|
||||||
: `${key}:\n${indentation}- `;
|
: `${key}:\n${indentation}- `;
|
||||||
results.push({
|
results.push({
|
||||||
label: key,
|
label: needsQualifier ? `${key} (list)` : key,
|
||||||
description,
|
description,
|
||||||
detail: needsQualifier ? "list" : undefined,
|
|
||||||
insertText,
|
insertText,
|
||||||
|
filterText: needsQualifier ? key : undefined,
|
||||||
sortText: needsQualifier ? `${key} 1` : undefined
|
sortText: needsQualifier ? `${key} 1` : undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -337,10 +320,10 @@ function expandOneOfToCompletions(
|
|||||||
? `\n${indentation}${key}:\n${indentation}${indentation}`
|
? `\n${indentation}${key}:\n${indentation}${indentation}`
|
||||||
: `${key}:\n${indentation}`;
|
: `${key}:\n${indentation}`;
|
||||||
results.push({
|
results.push({
|
||||||
label: key,
|
label: needsQualifier ? `${key} (full syntax)` : key,
|
||||||
description,
|
description,
|
||||||
detail: needsQualifier ? "full syntax" : undefined,
|
|
||||||
insertText,
|
insertText,
|
||||||
|
filterText: needsQualifier ? key : undefined,
|
||||||
sortText: needsQualifier ? `${key} 2` : undefined
|
sortText: needsQualifier ? `${key} 2` : undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,5 +6,5 @@
|
|||||||
"languageservice",
|
"languageservice",
|
||||||
"languageserver"
|
"languageserver"
|
||||||
],
|
],
|
||||||
"version": "0.3.29"
|
"version": "0.3.28"
|
||||||
}
|
}
|
||||||
Generated
+9
-9
@@ -136,7 +136,7 @@
|
|||||||
},
|
},
|
||||||
"expressions": {
|
"expressions": {
|
||||||
"name": "@actions/expressions",
|
"name": "@actions/expressions",
|
||||||
"version": "0.3.29",
|
"version": "0.3.28",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.0.3",
|
"@types/jest": "^29.0.3",
|
||||||
@@ -396,11 +396,11 @@
|
|||||||
},
|
},
|
||||||
"languageserver": {
|
"languageserver": {
|
||||||
"name": "@actions/languageserver",
|
"name": "@actions/languageserver",
|
||||||
"version": "0.3.29",
|
"version": "0.3.28",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/languageservice": "^0.3.29",
|
"@actions/languageservice": "^0.3.28",
|
||||||
"@actions/workflow-parser": "^0.3.29",
|
"@actions/workflow-parser": "^0.3.28",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
"@octokit/types": "^9.0.0",
|
"@octokit/types": "^9.0.0",
|
||||||
"vscode-languageserver": "^8.0.2",
|
"vscode-languageserver": "^8.0.2",
|
||||||
@@ -940,11 +940,11 @@
|
|||||||
},
|
},
|
||||||
"languageservice": {
|
"languageservice": {
|
||||||
"name": "@actions/languageservice",
|
"name": "@actions/languageservice",
|
||||||
"version": "0.3.29",
|
"version": "0.3.28",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.29",
|
"@actions/expressions": "^0.3.28",
|
||||||
"@actions/workflow-parser": "^0.3.29",
|
"@actions/workflow-parser": "^0.3.28",
|
||||||
"vscode-languageserver-textdocument": "^1.0.7",
|
"vscode-languageserver-textdocument": "^1.0.7",
|
||||||
"vscode-languageserver-types": "^3.17.2",
|
"vscode-languageserver-types": "^3.17.2",
|
||||||
"vscode-uri": "^3.0.8",
|
"vscode-uri": "^3.0.8",
|
||||||
@@ -13345,10 +13345,10 @@
|
|||||||
},
|
},
|
||||||
"workflow-parser": {
|
"workflow-parser": {
|
||||||
"name": "@actions/workflow-parser",
|
"name": "@actions/workflow-parser",
|
||||||
"version": "0.3.29",
|
"version": "0.3.28",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.29",
|
"@actions/expressions": "^0.3.28",
|
||||||
"cronstrue": "^2.21.0",
|
"cronstrue": "^2.21.0",
|
||||||
"yaml": "^2.0.0-8"
|
"yaml": "^2.0.0-8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/workflow-parser",
|
"name": "@actions/workflow-parser",
|
||||||
"version": "0.3.29",
|
"version": "0.3.28",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/index.ts",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.29",
|
"@actions/expressions": "^0.3.28",
|
||||||
"cronstrue": "^2.21.0",
|
"cronstrue": "^2.21.0",
|
||||||
"yaml": "^2.0.0-8"
|
"yaml": "^2.0.0-8"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user