Compare commits

...

98 Commits

Author SHA1 Message Date
GitHub Actions fdad3474fc Release extension version 0.4.0 2026-04-14 14:24:54 +00:00
Salman Chishti 0446b065b0 Merge pull request #348 from actions/salmanmkc/job-workflow-context-properties
feat: add job.workflow_* context properties
2026-04-14 13:45:08 +01:00
Salman Muin Kayser Chishti 763dff2018 fix: address review nits - update doc comments, test names, and description wording
- Update getJobContext doc comment to include workflow identity fields
- Rename test to reflect all returned fields, not just status/check_run_id
- Rename validate test to 'job.workflow_* fields' covering all 4 properties
- Clarify workflow_ref description: 'ref path to' instead of 'ref of'
2026-04-14 10:55:58 +00:00
Salman Muin Kayser Chishti 0c9d817440 feat: add job.workflow_* context properties
Add workflow_ref, workflow_sha, workflow_repository, and
workflow_file_path to the job context for reusable workflow jobs.
These fields provide direct access to the workflow file information
without needing to parse github.workflow_ref.

- Add 4 new fields to getJobContext() in job.ts
- Add descriptions in descriptions.json
- Update autocomplete test expectations
- Add validation and unit tests
2026-04-10 22:23:20 +01:00
eric sciple cc316ab9de Remove phantom github.job_workflow_sha from language service (#347)
This property is listed in the GitHub context provider but is never
populated at runtime by the runner. Users see it in autocomplete,
use it in workflows, and it silently evaluates to empty string.

Remove from keys array and description metadata.
2026-04-03 18:33:15 -05:00
github-actions[bot] d5670c383a Release extension version 0.3.51 (#346)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-04-03 10:34:58 -05:00
eric sciple f62a0e189d Remove allowServiceContainerCommand feature flag (#345)
Service container entrypoint/command support is now unconditional.
2026-04-03 10:29:34 -05:00
github-actions[bot] 9e1662f1d4 Release extension version 0.3.50 (#344)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-03-31 20:46:47 -05:00
eric sciple 5db2e80f32 Add entrypoint and command keys for service containers (#343)
Introduce service-container-mapping schema definition with entrypoint
and command properties, gated behind allowServiceContainerCommand
feature flag. Job containers remain unaffected.
2026-03-31 15:45:18 -05:00
github-actions[bot] 83de320ba9 Release extension version 0.3.49 (#342)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-03-20 09:47:56 -05:00
Angel Kou 74e6638098 Remove timezone feature flag in languageservice (#341)
* Remove timezone feature flag in languageservice

* Prettier

* Address comment

---------

Co-authored-by: Angel Kou <jiakou@microsoft.com>
2026-03-19 14:10:38 -07:00
github-actions[bot] f8b8b57248 Release extension version 0.3.48 (#340)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-03-18 11:02:31 -05:00
eric sciple aa1e7d8aec Add deployment key support for job environment (#338)
Add a boolean 'deployment' property to the job environment mapping.
When set to false, the parsed environment reference sets
skipDeployment to signal that no deployment record should be created.
2026-03-18 10:53:25 -05:00
github-actions[bot] bd6ce5923b Release extension version 0.3.47 (#336)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-03-10 11:38:05 -05:00
Tim Rogers 3de9820cd8 Add copilot-requests permission, gated by feature flag (#335)
* Add copilot-requests permission gated by feature flag

This add a new 'copilot-requests' permission to the workflow schema,
gated behind the 'allowCopilotRequestsPermission' experimental
feature flag.

When the flag is disabled (default), `copilot-requests` is filtered
out of autocomplete suggestions. When enabled, it appears
alongside other permissions like actions, contents, pull-requests,
etc.

* Update workflow-parser/src/workflow-v1.0.json

* Add additional unit test coverage

* Fix formatting
2026-03-10 09:48:54 -05:00
Angel Kou a7f581bde5 Add timezone to workflow and pass FF (#334)
* Add timezone to workflow and pass FF

* Prettier fixes

* Prettier fixes

* Prettier fixes

* Guard timezone autocomplete behind FF

* Prettier fix

* Address PR comments

* Prettier fix

* Remove comma

* Remove template assignment

* Move description

* Fix test

* Prettier again!

* Address comments

* Change error when timezone key is entered but FF is off

* Prettier

---------

Co-authored-by: Angel Kou <jiakou@microsoft.com>
2026-03-05 17:59:56 -08:00
github-actions[bot] 8c0a3a947b Release extension version 0.3.46 (#333)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-02-26 09:57:24 -06:00
eric sciple eb71b18f2b Revert "Merge pull request #320 from actions/allanguigou/default-case" (#332)
This reverts commit 191a7b6a00, reversing
changes made to 448180bd7f.
2026-02-26 09:50:07 -06:00
eric sciple 92c5235a00 Upgrade lerna to v9 for OIDC trusted publishing (#330)
- Upgrade lerna from v8 to v9 (adds OIDC trusted publishing support)
- Remove registry-url, scope, and packages:write from release workflow
- Remove NPM_CONFIG_PROVENANCE env (automatic with OIDC)
- Update workspace typescript devDependency from ^4.8.4 to ^5.8.3
- Remove root typescript override (no longer needed)
2026-02-25 19:58:54 -06:00
eric sciple 9f770badd3 Upgrade Node.js to 24 for npm trusted publishing (#329) 2026-02-25 15:04:40 -06:00
eric sciple 9dd856db3d Switch to npm trusted publishing (OIDC) (#327)
Replace NPM_TOKEN-based authentication with OIDC trusted publishing.
This eliminates the need for long-lived npm access tokens.

Changes:
- Add id-token: write permission to the release job
- Add registry-url to setup-node
- Remove the setup authentication step (.npmrc token write)
- Remove NPM_TOKEN env var from the Publish packages step

Requires trusted publisher configuration on npmjs.com for each package.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 13:15:38 -06:00
github-actions[bot] 4a881d9ea1 Release extension version 0.3.45 (#326)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-02-24 11:19:31 -06:00
Paulo Santos 6a0408d237 Update default runner image labels (#325)
* update default runner image labels

* chore: format in style of file

* remove old labels

* tests: update expected length of runner labels in tests

* tests: fix another test, missed
2026-02-24 11:02:54 -06:00
Paulo Santos 0c2f39f1d0 Add @actions/runner-images-writers to CODEOWNERS (#324)
* added @actions/runner-images-writers to CODEOWNERS

* target specific file and add comment

* added both teams to file ownership
2026-02-24 11:02:35 -06:00
eric sciple fb5c6e4f27 Add private repository access to step-uses description (#322)
Update the step-uses description to mention that actions can also be
used from private repositories when access is enabled via repository
settings.

Fixes #319
2026-01-30 09:23:48 -06:00
Allan Guigou f29f508cec Merge pull request #321 from actions/release/0.3.44
Release version 0.3.44
2026-01-29 15:36:01 -05:00
GitHub Actions d69c1fa0f3 Release extension version 0.3.44 2026-01-29 18:13:09 +00:00
Allan Guigou 191a7b6a00 Merge pull request #320 from actions/allanguigou/default-case
Remove experimental flag for `case` function
2026-01-29 13:10:33 -05:00
Allan Guigou 0410ab8302 Add featureFlags param with lint ignore 2026-01-29 17:24:35 +00:00
Allan Guigou 7ac83f43a6 Fix unused param 2026-01-29 16:51:18 +00:00
Allan Guigou ef457b29fa Remove unused feature flag param 2026-01-29 16:08:16 +00:00
Allan Guigou fea8440c1d Fix lint 2026-01-29 15:56:43 +00:00
Allan Guigou 3c0a5f79fc Remove experimental flag for case function 2026-01-29 14:34:51 +00:00
github-actions[bot] 448180bd7f Release extension version 0.3.43 (#318)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-27 08:57:45 -06:00
eric sciple d2f52a9043 Validate implicit if conditions in action.yml files (#317)
## Problem

In workflow YAML files, writing `if: foo == bar` shows an error because `foo` and `bar` are not valid contexts. However, the same invalid expression in an action.yml file showed no error.

## Solution

Add expression validation for implicit `if` conditions in action.yml files, matching the behavior of workflow YAML validation.

## What's new

1. **Pre-if/post-if validation** (node and docker actions)
   - `pre-if: foo == bar` now shows error for unknown context
   - `post-if: unknownFunc()` now shows error for unknown function

2. **Composite step `if` validation** (fix)
   - Errors from `convertToIfCondition` were being lost due to call ordering
   - Now captured correctly by calling conversion before retrieving errors

## Why the refactor?

The diff includes consolidating multiple validation loops into a single `validateAllTokens()` traversal. This matches the pattern used in workflow YAML validation (`additionalValidations`), making the code consistent between the two validation paths.
2026-01-27 08:37:42 -06:00
github-actions[bot] 46b216a6dc Release extension version 0.3.42 (#316)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-25 20:53:27 -06:00
eric sciple 0fe7798548 Support pre-if/post-if autocomplete and fix expression functions for action.yml (#314) 2026-01-25 20:47:30 -06:00
github-actions[bot] bdd72406c3 Release extension version 0.3.41 (#313)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-23 00:09:45 -06:00
eric sciple 33291f0f8d Add missing validation for action.yml (parity with workflow files) (#311)
* Add missing validation for action.yml (parity with workflow files)

- Add uses format validation for composite action steps
  - Validates owner/repo@ref format
  - Supports docker:// and ./ local references
  - Warns about shortened SHA refs (security concern)
  - Detects reusable workflow references in wrong context

- Add if literal text detection for composite action steps
  - Detects literal text outside ${{ }} that makes conditions always truthy
  - Works for both plain string and mixed expression formats
  - Uses shared hasFormatWithLiteralText() utility

- Add pre-if/post-if validation for node and docker actions
  - Errors on explicit ${{ }} syntax (runner only supports implicit expressions)
  - Literal text detection for implicit expressions
  - New runs-if schema type with proper context (runner, github, job, env, inputs, status functions)
  - Validates only in strict schema used by language services

- Add format() function validation for all expressions
  - Validates format string syntax in all expression contexts
  - Checks argument count matches placeholders

- Fix env and matrix context providers to return complete=false
  - Prevents false positive 'unknown context' errors
  - Matches behavior of other dynamic contexts (secrets, vars, etc.)

- Refactor validation utilities into utils/validate-uses.ts and utils/validate-if.ts
  - Shared between workflow and action validation
  - Consistent error messages and codes

* Add strategy and matrix contexts to runs-if definition

Based on runner source code analysis (actions/runner):
- ExecutionContext.InitializeJob() populates ExpressionValues from message.ContextData
- strategy and matrix are part of message.ContextData, available before any steps run
- StepsRunner evaluates all steps (pre, main, post) using the same code path

Did NOT add:
- steps: empty at pre-if time (no steps completed yet)
- hashFiles: workspace files don't exist at pre-step time
2026-01-23 00:02:02 -06:00
eric sciple 8511ae2e6d Allow empty string for container options (#312) 2026-01-22 15:21:11 -06:00
github-actions[bot] cd1078fb2f Release extension version 0.3.40 (#310)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-21 17:05:31 -06:00
eric sciple 96be7ce46c Clean up feature flag actionScaffoldingSnippets (#309) 2026-01-21 16:52:14 -06:00
eric sciple c2bf928e7b Add 'snippet' label detail to action scaffolding completions (#308) 2026-01-21 15:56:11 -06:00
eric sciple 74d69b24ab Fix scaffolding snippets to replace typed text instead of inserting (#307) 2026-01-21 15:41:25 -06:00
eric sciple 22aa458809 Add documentation links to action scaffolding snippets (#306) 2026-01-21 14:24:57 -06:00
github-actions[bot] f3f11d8658 Release extension version 0.3.39 (#305)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-19 15:19:33 -06:00
eric sciple 5359433879 Pass featureFlags to onCompletion in language server (#304)
* Pass featureFlags to onCompletion in language server

* Use import type for FeatureFlags in on-completion.ts
2026-01-19 15:11:32 -06:00
github-actions[bot] a8bfe74256 Release extension version 0.3.38 (#303)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-19 14:00:31 -06:00
eric sciple e2c5f1f74a Fix shell variable escaping in action.yml snippets (#302) 2026-01-19 13:55:42 -06:00
github-actions[bot] 2a203ec742 Release extension version 0.3.37 (#301)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-19 09:56:35 -06:00
eric sciple 92960e0093 Fix action snippet completions: sort order, indent, and $ escaping (#300) 2026-01-19 09:39:04 -06:00
Francesco Renzi 0fe31c6656 Setup CodeActions and add quickfix for missing inputs (#254)
* Setup CodeActions and add quickfix for missing inputs

* PR feedback

* Update languageservice/src/code-actions/quickfix/add-missing-inputs.ts

Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>

* Fix indentSize detection for code actions after rebase

- Add indentSize to MissingInputsDiagnosticData interface
- Pass indentSize parameter from validate.ts to validateActionReference
- Detect indentSize from workflow structure (jobs key to first child)
- Fall back to detecting from with: block children when available

* update typescript

* formatting

* linting

* Gate missing inputs quickfix behind feature flag

* Address PR review: rename files, move position calculation to quickfix

- Rename index.ts files to follow repo patterns:
  - code-actions/index.ts → code-actions/code-actions.ts
  - code-actions/quickfix/index.ts → quickfix/quickfix-providers.ts
- Move position calculation from validation to quickfix:
  - MissingInputsDiagnosticData now passes raw token ranges
  - Quickfix computes insertion position and indentation at code action time
  - detectIndentSize moved from validate.ts to validate-action-reference.ts

* wip

* Remove pointless comment

---------

Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-01-14 15:58:20 +00:00
Allan Guigou 67dd4fbd61 Merge pull request #298 from actions/release/0.3.36
Release version 0.3.36
2026-01-14 10:26:27 -05:00
GitHub Actions 4a7e08774d Release extension version 0.3.36 2026-01-14 15:24:00 +00:00
Allan Guigou 9ec1c123a8 Merge pull request #294 from actions/allanguigou/case
Add support for case function
2026-01-14 10:13:17 -05:00
Allan Guigou aad3bcd291 Fix tests 2026-01-14 14:48:52 +00:00
Allan Guigou 248934d513 Fix formatting 2026-01-14 14:17:49 +00:00
Allan Guigou b605cb6582 Merge branch 'main' into allanguigou/case 2026-01-14 13:51:13 +00:00
Allan Guigou 05debf64b0 Add experimental flag for case function 2026-01-14 13:17:15 +00:00
github-actions[bot] 1baa74a67e Release extension version 0.3.35 (#297)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-13 19:27:39 -06:00
eric sciple fa27dfa563 Add action.yml scaffolding snippets (#296) 2026-01-13 09:14:20 -06:00
Allan Guigou 228acc3cd9 Update case.ts 2026-01-13 09:53:34 -05:00
Allan Guigou 9f30846fde Merge branch 'main' into allanguigou/case 2026-01-13 09:46:34 -05:00
eric sciple 2816233a40 Add block scalar newline warning (#295)
In YAML, block scalars (`|` and `>`) silently add a trailing newline by default
("clip" chomping). This can cause subtle bugs when the newline is unintentional.

This PR adds a warning when clip chomping is used in fields where trailing
newlines commonly cause issues:

- Environment variables (workflow, job, step, container, service levels)
- Action inputs (`with:`)
- Reusable workflow inputs and secrets
- Job outputs
- Matrix values (including `include` and `exclude`)
- Concurrency groups

The warning suggests using `|-` (strip) or `|+` (keep) to be explicit.

Intentionally does NOT warn for:
- `run:` scripts (trailing newlines are normal)
- Fields trimmed server-side (`if:`, `name:`, `runs-on:`, etc.)

The feature is gated behind the `blockScalarChompingWarning` feature flag.
2026-01-12 09:36:43 -06:00
eric sciple 54404aa9ff Add format string validation (#292)
Validates format() function calls for:
- Invalid syntax (missing closing brace, empty placeholder, non-numeric placeholder)
- Argument count mismatch (placeholder references arg that doesn't exist)

Port of Go's format_validator.go from actions-workflow-parser.
2026-01-08 09:25:37 -06:00
Allan Guigou 0ebe1262ee Add case to completion tests 2026-01-08 15:01:43 +00:00
Allan Guigou 94d7f7b124 Remove unncessary type conversion 2026-01-08 14:33:59 +00:00
Allan Guigou f439272f69 Update import paths to include file extensions 2026-01-08 09:21:12 -05:00
Allan Guigou 161574adac Merge branch 'main' into allanguigou/case 2026-01-08 09:12:05 -05:00
eric sciple dbf7752734 Show cron description on hover (#291)
Related #286 - When hovering over a cron expression, show the human-readable
description instead of empty content. Users who have inlay hints disabled
can now still see the cron description.
2026-01-07 08:43:22 -06:00
eric sciple 78231482f5 Fix completion and validation issues in action.yml (#290)
Follow-up to https://github.com/actions/languageservices/pull/289

## What this fixes

**Autocomplete was broken inside composite action steps.** When you typed inside a step and triggered autocomplete, nothing showed up. Now you correctly get suggestions like run, uses, shell, etc.

**Duplicate error messages for missing required fields.** When a required field was missing (like main for Node.js actions), users saw two error messages - one generic schema validation error, and one custom error with a clear explanation. Now they only see the custom one.

For example, with using: node24 but no main:
- Before: Two errors shown
  - Schema: "There's not enough info to determine what you meant. Add one of these properties: args, entrypoint, image, main, ..."
  - Custom: "'main' is required for Node.js actions (using: node24)"
- After: Only the custom error is shown
2026-01-07 08:42:59 -06:00
eric sciple 2e46c66878 Context-aware autocomplete and validation for action.yml runs section (#289)
- Set main as required in node-runs-strict schema definition
- Add validation for invalid key combinations based on using value
- Add validation for missing required keys (main for node, steps for composite, image for docker)
- Filter autocomplete suggestions based on using value
- Prioritize 'using' in completions when not set yet

Fixes context-aware autocomplete for action.yml files where different
action types (node, composite, docker) have different valid keys under runs:
2026-01-06 21:09:38 -06:00
Allan Guigou 44900feff7 Add support for case function 2026-01-06 14:42:01 +00:00
Francesco Renzi 39b9b14e3a Add experimentalFeatures to initialization options (#287)
* Add experimentalFeatures to initialization options

Introduce a feature flagging system for opt-in experimental features.
Clients can enable features via initializationOptions.experimentalFeatures
with granular per-feature control or an 'all' flag to enable everything.

First experimental feature: missingInputsQuickfix (for upcoming code actions)
2026-01-06 07:03:51 +00:00
github-actions[bot] 71ff7b49c3 Release extension version 0.3.34 (#288)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-05 09:02:23 -06:00
eric sciple 1a42526360 Fix false positive for literal text in if conditions (#285)
* Fix false positive for literal text in `if` conditions

Use token.value (parsed string without YAML quotes) instead of token.source
(raw YAML text) for expression parsing in single-line strings. This fixes a
false positive where `if: "${{ expr }}"` incorrectly triggered the
"literal text in condition" error because the outer quotes were treated as
literal text.

Follow-up to PR #216
Related issue: https://github.com/github/vscode-github-actions/issues/542

* Move issue reference to comment
2026-01-05 08:33:10 -06:00
eric sciple 1cfe9f9f86 languageserver: add .js extensions to imports (ESM prep) (#259) 2026-01-04 15:00:01 -06:00
github-actions[bot] 6641228870 Release extension version 0.3.33 (#284)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-04 14:05:43 -06:00
eric sciple c1ad4d14df Use property descriptions for completion items (#283)
* Use property descriptions for completion items

* Add test for type description fallback
2026-01-04 13:08:13 -06:00
eric sciple 6a47895521 Use additionalTextEdits for escape hatch completions (#282)
Escape hatch completions now use a two-part edit strategy for VS Code compatibility:

- Main textEdit: Inserts newline and indented content at cursor position
  (empty range so VS Code won't filter based on key text)

- additionalTextEdits: Replaces 'key: ' with 'key:' to remove trailing space

This prevents VS Code from filtering out escape hatches while still
producing the correct final YAML structure.
2026-01-04 13:07:42 -06:00
github-actions[bot] c67c353245 Release extension version 0.3.32 (#281)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-02 14:47:49 -06:00
eric sciple c6d2036302 Remove filterText from escape hatch completions (#280)
Escape hatch completions like '(switch to list)' and '(switch to mapping)'
were being filtered out in VS Code because filterText was set to the key
name (e.g., 'runs-on'), which doesn't match the empty string at the cursor
position when completing a value.

Since escape hatches only appear when the value is empty anyway, there's no
need for filterText. Without it, VS Code uses the label for filtering,
which properly shows them when no text is typed.
2026-01-02 14:44:57 -06:00
github-actions[bot] 56ce46afa6 Release extension version 0.3.31 (#279)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-02 12:21:22 -06:00
eric sciple e3b56c2416 Use labelDetails for completion item qualifiers (#278)
* Use labelDetails for completion item qualifiers

Use labelDetails.description instead of detail for qualifier text like
'full syntax' and 'list'. This renders the text inline after the label
in the completion menu, making variants immediately distinguishable
without hovering.

* Fix formatting
2026-01-02 12:18:53 -06:00
eric sciple d2ffb50a92 Add language service support for action.yml files (#275)
- Add validation, completion, hover, and document links for action.yml files
- Implement document type detection to route action.yml to action-specific handlers
- Add expression context for composite actions (inputs, steps, github, runner, etc.)
- Add schema validation for required fields, branding, and composite step requirements
- Support JavaScript (node20/node24), Docker, and composite action types
- Validate action references in composite action uses steps
- Add JSDoc comments to parser and template functions
- Refactor hover to use hoverToken consistently
- Fix lint errors and add return type annotations
2026-01-02 10:38:52 -06:00
github-actions[bot] 3734de18ee Release extension version 0.3.30 (#274)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-30 12:01:49 -06:00
eric sciple 90e7932e97 Add runs-on label completions for mapping syntax (#273)
Provides runner label completions (ubuntu-latest, macos-latest, etc.)
when using the runs-on mapping syntax with the labels property:

  jobs:
    build:
      runs-on:
        labels: |

  jobs:
    build:
      runs-on:
        labels:
          - |

Previously, completions only worked for the simple runs-on syntax:

  jobs:
    build:
      runs-on: |

The fix registers the same value provider for both 'runs-on' and
'runs-on-labels' definition keys in the schema.
2025-12-30 10:30:19 -06:00
github-actions[bot] f84e42c1f1 Release extension version 0.3.29 (#272)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-29 22:27:06 -06:00
eric sciple 08c78d2a73 Replace cron info diagnostics with inlay hints (#270)
- Remove DiagnosticSeverity.Information for valid cron expressions
- Add new inlay-hints.ts module with getInlayHints() function
- Register inlayHintProvider capability in language server
- Display human-readable cron descriptions as inline hints

Related #269
2025-12-29 13:47:30 -06:00
eric sciple 26f3969cde Add escape hatch completions to switch structural forms (#271)
When completing an empty value position (e.g., `runs-on: |`), add special
completions that let users switch to alternative structural forms:

- "(switch to list)" - restructures to `key:\n  - `
- "(switch to mapping)" - restructures to `key:\n  `

These help users escape "dead end" situations where the current form has
no valid completions but alternative forms are available in the schema.
2025-12-29 12:54:53 -06:00
eric sciple 61a6fc54f2 Use detail field for one-of qualifiers instead of label (#266)
- Move qualifiers (list, full syntax) from label to detail field
- Remove filterText since labels are now clean
- Update distinctValues to preserve variants with different details
- Standard LSP pattern: detail shown after label in completion UI
2025-12-29 10:45:46 -06:00
eric sciple 6511be5ab4 Fix autocomplete showing mapping keys for empty values (#268)
Follow-up to #265

When completing an empty value (e.g., `permissions: |`), mapping keys were
incorrectly shown alongside scalar values. This made completions confusing.

Before:
- `permissions: |` showed read-all, write-all, AND actions, contents, etc.
- `on: |` showed check_run AND check_run (full syntax), etc.

After:
- `permissions: |` shows only read-all and write-all
- `on: |` shows only event names like push, check_run
- `concurrency: |` shows no completions (user types their own group name)

Users who want the mapping form choose (full syntax) completions at the
parent level.
2025-12-29 09:50:59 -06:00
github-actions[bot] a06ceee92b Release extension version 0.3.28 (#267)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-23 14:24:06 -06:00
eric sciple efd53330a3 Remove invalid autocomplete options for committed structural types (#265)
Recent autocomplete improvements (typing activation, completion chaining, schema
variant surfacing) now guide users to discover the full schema naturally. This
change removes the legacy behavior that showed invalid options and silently
transformed YAML upon insertion.

Key changes:
- Filter one-of completion options based on the token's actual structural type
- When user commits to scalar (non-empty string), only show scalar options
- When user commits to mapping/sequence, only show those options
- Skip null-only scalars in Key mode to prevent clobbering string constants
- Scalar event completions (e.g., check_run at 'on: |') now insert inline

This ensures that when a user explicitly chooses a simplified form, they only
see values valid for that form, creating a cleaner and more predictable
autocomplete experience.
2025-12-23 10:25:49 -06:00
github-actions[bot] 86888cf4c8 Release extension version 0.3.27 (#264)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-22 11:28:48 -06:00
Robin Neatherway ed4c2ce44c Add support for job.check_run_id (#205)
This was recently added: https://github.com/orgs/community/discussions/8945#discussioncomment-14374985
2025-12-22 11:11:34 -06:00
eric sciple 9bb4c76612 Expand one-of keys to multiple completion items (#261)
* Expand one-of keys to multiple completion items

Some workflow fields accept multiple YAML structures (scalar, sequence, or
mapping), but completions previously only showed a single option—leaving users
unaware of the full schema flexibility. This change surfaces structural options
and inserts the correct YAML scaffolding so users land in the right place to
keep typing.

Example: runs-on

Completing runs-on now shows three options:
- runs-on         → Ready for a string like ubuntu-latest
- runs-on (list)  → Ready to add runner labels
- runs-on (full syntax) → Ready for labels:, group:, etc.

Notes:
- Qualifiers (list) and (full syntax) only appear when multiple structural types exist
- Scalar completions use the plain key name
- Qualified variants use filterText matching the base key

* Sort expanded one-of completions: scalar, list, full syntax
2025-12-22 10:49:49 -06:00
eric sciple 8b86b48961 Add warning for short SHA refs in uses (#260) 2025-12-22 08:34:29 -06:00
141 changed files with 14461 additions and 2679 deletions
+3
View File
@@ -1 +1,4 @@
* @actions/actions-vscode-reviewers
# Owners maintaining https://github.com/actions/runner-images
/languageservice/src/value-providers/default.ts @actions/runner-images-writers @actions/actions-vscode-reviewers
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
node-version: [20.x, 22.x, 24.x]
steps:
- uses: actions/checkout@v4
@@ -37,10 +37,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22.x
- name: Use Node.js 24.x
uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 24.x
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
- run: npm ci
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "16"
node-version: 24.x
- name: Bump version and push
run: |
+3 -11
View File
@@ -59,7 +59,7 @@ jobs:
permissions:
contents: write
packages: write
id-token: write
env:
PKG_VERSION: "" # will be set in the workflow
@@ -69,9 +69,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 24.x
cache: "npm"
scope: '@actions'
- name: Parse version from lerna.json
run: |
@@ -97,13 +96,6 @@ jobs:
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
await core.summary.write();
- name: setup authentication
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish packages
run: |
lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
npx lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
+3
View File
@@ -4,6 +4,9 @@ lerna-debug.log
node_modules
.DS_Store
# Nx cache (generated by Lerna/Nx)
.nx/
# Minified JSON (generated at build time)
*.min.json
+2 -1
View File
@@ -3,4 +3,5 @@ dist
*.md
*.js
*.json
*.d.ts
*.d.ts
/.nx/workspace-data
+33
View File
@@ -0,0 +1,33 @@
# Agents
## Build
```
npx lerna run build
```
## Test
```
npm -w @actions/expressions test
npm -w @actions/workflow-parser test
npm -w @actions/languageservice test
```
## Format
Always run formatting before committing:
```
npx prettier --write <changed files>
```
Verify with:
```
npm run format-check -ws
```
## Feature flags
Feature flags are defined in `expressions/src/features.ts` (`ExperimentalFeatures` interface + `allFeatureKeys` array). They are plumbed through `ConvertOptions`, `CompletionConfig`, `ValidationConfig`, and `initializationOptions`. When a feature graduates to stable, remove its flag and make the behavior unconditional.
+26 -6
View File
@@ -4,6 +4,27 @@
This document outlines the plan to migrate from TypeScript's deprecated `"moduleResolution": "node"` (node10) to `"moduleResolution": "node16"` or `"nodenext"`. This change is necessary because the published ESM packages have extensionless imports that don't work correctly in modern ESM environments.
## TL;DR - Remaining Work
- [x] expressions - Migrated ✅
- [x] workflow-parser - Migrated ✅
- [x] languageservice - Migrated ✅
- [x] languageserver - Add `.js` extensions to imports ✅
- [ ] languageserver - Update `tsconfig.build.json` to `moduleResolution: "node16"` (blocked by vscode-languageserver)
- [ ] languageserver - Upgrade `vscode-languageserver` to stable v10+ when released
**Blocker:** `vscode-languageserver@8.0.2` lacks ESM exports. Stable v10 with `exports` field needed.
### ⚠️ Important: `skipLibCheck: true` Required
All migrated packages use `skipLibCheck: true` in their `tsconfig.build.json`. This works around a TS2386 "Overload signatures must all be optional or required" error in `@types/node/module.d.ts`.
**Why can't we just fix the error?** The error is in `@types/node`, a third-party package maintained by DefinitelyTyped. We can't modify `node_modules`, and upstream fixes take time.
**Is `skipLibCheck` safe?** Yes. It only skips type checking of `.d.ts` files (declaration files from dependencies). Our own `.ts` source files are still fully type-checked. This is a common and recommended workaround for issues in third-party type definitions.
---
## Issues Fixed
This migration will resolve the following issues:
@@ -199,14 +220,13 @@ src/connection.ts(1,43): error TS2307: Cannot find module 'vscode-languageserver
With `moduleResolution: "node16"`, TypeScript follows Node.js ESM resolution rules which require explicit `exports` for subpath imports like `vscode-languageserver/browser` and `vscode-languageserver/node`.
**Status:** Verified December 2025. Version 9.0.1 is available but ESM export support is not confirmed.
**Status:** Partial - `.js` extensions added, waiting for stable `vscode-languageserver` release with ESM exports to complete migration.
**Current Decision:** The languageserver package is **deferred** from this migration until the upstream `vscode-languageserver` package adds proper ESM exports. It will continue using the old `moduleResolution: "node"` configuration.
**Completed:** All relative imports in languageserver source files have been updated to use `.js` extensions. This is compatible with the current `moduleResolution: "node"` and will enable a seamless migration once a stable vscode-languageserver version with ESM exports is available.
**Options to resolve:**
- Wait for vscode-languageserver to add ESM exports
- Try upgrading to vscode-languageserver v9.x to see if exports were added
- Use a bundler to work around the module resolution
- Wait for stable vscode-languageserver v10+ with ESM exports
- Use pre-release `vscode-languageserver@10.0.0-next.16` (has proper exports but is unstable)
- Fork or patch the dependency
---
@@ -218,7 +238,7 @@ With `moduleResolution: "node16"`, TypeScript follows Node.js ESM resolution rul
| expressions | 1068 | ✅ Migrated |
| workflow-parser | 292 | ✅ Migrated |
| languageservice | 452 | ✅ Migrated |
| languageserver | 6 files | ⏸️ Deferred (vscode-languageserver lacks ESM exports) |
| languageserver | 31 | 🔶 Partial (`.js` extensions added, awaiting stable vscode-languageserver) |
---
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.26",
"version": "0.4.0",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -36,7 +36,7 @@
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"prepublishOnly": "npm run build && npm run test",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
@@ -44,7 +44,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"engines": {
"node": ">= 18"
"node": ">= 20"
},
"files": [
"dist/**/*"
+11 -3
View File
@@ -2,6 +2,7 @@ import {DescriptionPair} from "./completion/descriptionDictionary.js";
import {Dictionary, isDictionary} from "./data/dictionary.js";
import {ExpressionData} from "./data/expressiondata.js";
import {Evaluator} from "./evaluator.js";
import {FeatureFlags} from "./features.js";
import {wellKnownFunctions} from "./funcs.js";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
import {Lexer, Token, TokenType} from "./lexer.js";
@@ -26,13 +27,15 @@ export type CompletionItem = {
* @param context Context available for the expression
* @param extensionFunctions List of functions available
* @param functions Optional map of functions to use during evaluation
* @param featureFlags Optional feature flags to control which features are enabled
* @returns Array of completion items
*/
export function complete(
input: string,
context: Dictionary,
extensionFunctions: FunctionInfo[],
functions?: Map<string, FunctionDefinition>
functions?: Map<string, FunctionDefinition>,
featureFlags?: FeatureFlags
): CompletionItem[] {
// Lex
const lexer = new Lexer(input);
@@ -63,7 +66,7 @@ export function complete(
const result = contextKeys(context);
// Merge with functions
result.push(...functionItems(extensionFunctions));
result.push(...functionItems(extensionFunctions, featureFlags));
return result;
}
@@ -88,10 +91,15 @@ export function complete(
return contextKeys(result);
}
function functionItems(extensionFunctions: FunctionInfo[]): CompletionItem[] {
function functionItems(extensionFunctions: FunctionInfo[], featureFlags?: FeatureFlags): CompletionItem[] {
const result: CompletionItem[] = [];
const flags = featureFlags ?? new FeatureFlags();
for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) {
// Filter out case function if feature is disabled
if (fdef.name === "case" && !flags.isEnabled("allowCaseFunction")) {
continue;
}
result.push({
label: fdef.name,
description: fdef.description,
+3
View File
@@ -12,6 +12,7 @@ export enum ErrorType {
ErrorExceededMaxLength,
ErrorTooFewParameters,
ErrorTooManyParameters,
ErrorEvenParameters,
ErrorUnrecognizedContext,
ErrorUnrecognizedFunction
}
@@ -42,6 +43,8 @@ function errorDescription(typ: ErrorType): string {
return "Too few parameters supplied";
case ErrorType.ErrorTooManyParameters:
return "Too many parameters supplied";
case ErrorType.ErrorEvenParameters:
return "Even number of parameters supplied, requires an odd number of parameters";
case ErrorType.ErrorUnrecognizedContext:
return "Unrecognized named-value";
case ErrorType.ErrorUnrecognizedFunction:
+62
View File
@@ -0,0 +1,62 @@
import {FeatureFlags} from "./features.js";
describe("FeatureFlags", () => {
describe("isEnabled", () => {
it("returns false by default when no options provided", () => {
const flags = new FeatureFlags();
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("returns false by default when empty options provided", () => {
const flags = new FeatureFlags({});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("returns true when feature is explicitly enabled", () => {
const flags = new FeatureFlags({missingInputsQuickfix: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
});
it("returns false when feature is explicitly disabled", () => {
const flags = new FeatureFlags({missingInputsQuickfix: false});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("returns true when all is enabled", () => {
const flags = new FeatureFlags({all: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
});
it("explicit feature flag takes precedence over all:true", () => {
const flags = new FeatureFlags({all: true, missingInputsQuickfix: false});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("explicit feature flag takes precedence over all:false", () => {
const flags = new FeatureFlags({all: false, missingInputsQuickfix: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
});
});
describe("getEnabledFeatures", () => {
it("returns empty array when no features enabled", () => {
const flags = new FeatureFlags();
expect(flags.getEnabledFeatures()).toEqual([]);
});
it("returns enabled features", () => {
const flags = new FeatureFlags({missingInputsQuickfix: true});
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
});
it("returns all features when all is enabled", () => {
const flags = new FeatureFlags({all: true});
expect(flags.getEnabledFeatures()).toEqual([
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCopilotRequestsPermission"
]);
});
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* Experimental feature flags.
*
* Individual feature flags take precedence over `all`.
* Example: { all: true, missingInputsQuickfix: false } enables all
* experimental features EXCEPT missingInputsQuickfix.
*
* When a feature graduates to stable, its flag becomes a no-op
* (the feature will be enabled regardless of the configuration value).
*/
export interface ExperimentalFeatures {
/**
* Enable all experimental features.
* Individual feature flags take precedence over this setting.
* @default false
*/
all?: boolean;
/**
* Enable quickfix code action for missing required action inputs.
* @default false
*/
missingInputsQuickfix?: boolean;
/**
* Warn when block scalars (| or >) use implicit clip chomping,
* which adds a trailing newline that may be unintentional.
* @default false
*/
blockScalarChompingWarning?: boolean;
/**
* Enable the case() function in expressions.
* @default false
*/
allowCaseFunction?: boolean;
/**
* Enable the copilot-requests permission in workflow permissions.
* @default false
*/
allowCopilotRequestsPermission?: boolean;
}
/**
* Keys of ExperimentalFeatures that represent actual features (excludes 'all')
*/
export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
/**
* All known experimental feature keys.
* This list must be kept in sync with the ExperimentalFeatures interface.
*/
const allFeatureKeys: ExperimentalFeatureKey[] = [
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCopilotRequestsPermission"
];
export class FeatureFlags {
private readonly features: ExperimentalFeatures;
constructor(features?: ExperimentalFeatures) {
this.features = features ?? {};
}
/**
* Check if an experimental feature is enabled.
*
* Resolution order:
* 1. Explicit feature flag (if set)
* 2. `all` flag (if set)
* 3. false (default)
*/
isEnabled(feature: ExperimentalFeatureKey): boolean {
const explicit = this.features[feature];
if (explicit !== undefined) {
return explicit;
}
return this.features.all ?? false;
}
/**
* Returns list of all enabled experimental features.
*/
getEnabledFeatures(): ExperimentalFeatureKey[] {
return allFeatureKeys.filter(key => this.isEnabled(key));
}
}
+7
View File
@@ -1,4 +1,5 @@
import {ErrorType, ExpressionError} from "./errors.js";
import {caseFunc} from "./funcs/case.js";
import {contains} from "./funcs/contains.js";
import {endswith} from "./funcs/endswith.js";
import {format} from "./funcs/format.js";
@@ -16,6 +17,7 @@ export type ParseContext = {
};
export const wellKnownFunctions: {[name: string]: FunctionDefinition} = {
case: caseFunc,
contains: contains,
endswith: endswith,
format: format,
@@ -53,4 +55,9 @@ export function validateFunction(context: ParseContext, identifier: Token, argCo
if (argCount > f.maxArgs) {
throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier);
}
// case function requires an odd number of arguments
if (name === "case" && argCount % 2 === 0) {
throw new ExpressionError(ErrorType.ErrorEvenParameters, identifier);
}
}
+29
View File
@@ -0,0 +1,29 @@
import {ExpressionData, Kind} from "../data/index.js";
import {FunctionDefinition} from "./info.js";
export const caseFunc: FunctionDefinition = {
name: "case",
description:
"`case( pred1, val1, pred2, val2, ..., default )`\n\nEvaluates predicates in order and returns the value corresponding to the first predicate that evaluates to `true`. If no predicate matches, it returns the last argument as the default value.",
minArgs: 3,
maxArgs: Number.MAX_SAFE_INTEGER,
call: (...args: ExpressionData[]): ExpressionData => {
// Evaluate predicate-result pairs
for (let i = 0; i < args.length - 1; i += 2) {
const predicate = args[i];
// Predicate must be a boolean
if (predicate.kind !== Kind.Boolean) {
throw new Error("case predicate must evaluate to a boolean value");
}
// If predicate is true, return the corresponding result
if (predicate.value) {
return args[i + 1];
}
}
// No predicate matched, return default (last argument)
return args[args.length - 1];
}
};
+1
View File
@@ -4,6 +4,7 @@ export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from ".
export * as data from "./data/index.js";
export {ExpressionError, ExpressionEvaluationError} from "./errors.js";
export {Evaluator} from "./evaluator.js";
export {ExperimentalFeatureKey, ExperimentalFeatures, FeatureFlags} from "./features.js";
export {wellKnownFunctions} from "./funcs.js";
export {Lexer, Result} from "./lexer.js";
export {Parser} from "./parser.js";
+157
View File
@@ -0,0 +1,157 @@
{
"case": [
{
"expr": "case(true, 'first', 'default')",
"result": { "kind": "String", "value": "first" }
},
{
"expr": "case(false, 'first', 'default')",
"result": { "kind": "String", "value": "default" }
},
{
"expr": "case(true, 'first', false, 'second', 'default')",
"result": { "kind": "String", "value": "first" }
},
{
"expr": "case(false, 'first', true, 'second', 'default')",
"result": { "kind": "String", "value": "second" }
},
{
"expr": "case(false, 'first', false, 'second', 'default')",
"result": { "kind": "String", "value": "default" }
},
{
"expr": "case(1 == 1, 'equal', 'not equal')",
"result": { "kind": "String", "value": "equal" }
},
{
"expr": "case(1 == 2, 'equal', 'not equal')",
"result": { "kind": "String", "value": "not equal" }
},
{
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
"contexts": {
"github": {
"ref": "refs/heads/main",
"event_name": "push"
}
},
"result": { "kind": "String", "value": "main" }
},
{
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
"contexts": {
"github": {
"ref": "refs/heads/develop",
"event_name": "pull_request"
}
},
"result": { "kind": "String", "value": "pr" }
},
{
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
"contexts": {
"github": {
"ref": "refs/heads/develop",
"event_name": "push"
}
},
"result": { "kind": "String", "value": "other" }
},
{
"expr": "case(true, 123, 456)",
"result": { "kind": "Number", "value": 123 }
},
{
"expr": "case(false, 123, 456)",
"result": { "kind": "Number", "value": 456 }
},
{
"expr": "case(github.event == 'pull_request', 0, 1)",
"contexts": {
"github": {
"event": "pull_request"
}
},
"result": { "kind": "Number", "value": 0 }
},
{
"expr": "case(false, 0, 1)",
"result": { "kind": "Number", "value": 1 }
},
{
"expr": "case(true, false, true)",
"result": { "kind": "Boolean", "value": false }
},
{
"expr": "case(false, false, true)",
"result": { "kind": "Boolean", "value": true }
},
{
"expr": "case(true, '', 'default')",
"result": { "kind": "String", "value": "" }
},
{
"expr": "case(false, 'first', '')",
"result": { "kind": "String", "value": "" }
},
{
"expr": "case(true, fromJSON('[1,2,3]'), 'default')",
"result": { "kind": "Array", "value": [1, 2, 3] }
},
{
"expr": "case(true, fromJSON('{\"key\":\"value\"}'), 'default')",
"result": { "kind": "Object", "value": { "key": "value" } }
},
{
"expr": "case(false, 'first', false, 'second', false, 'third', false, 'fourth', 'default')",
"result": { "kind": "String", "value": "default" }
},
{
"expr": "case(false, 'first', false, 'second', true, 'third', false, 'fourth', 'default')",
"result": { "kind": "String", "value": "third" }
},
{
"expr": "case('not a boolean', 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(1, 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(null, 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(fromJSON('[]'), 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(fromJSON('{}'), 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(true, 'first', false, 'second')",
"err": {
"kind": "parsing",
"value": "Even number of parameters supplied, requires an odd number of parameters: 'case'. Located at position 1 within expression: case(true, 'first', false, 'second')"
}
}
]
}
+32
View File
@@ -84,6 +84,11 @@ export interface InitializationOptions {
* Desired log level
*/
logLevel?: LogLevel;
/**
* Experimental features that are opt-in
*/
experimentalFeatures?: ExperimentalFeatures;
}
```
@@ -100,6 +105,33 @@ const clientOptions: LanguageClientOptions = {
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
```
### Experimental Features
The language server supports opt-in experimental features via the `experimentalFeatures` initialization option. These features may change or be removed in between releases.
```typescript
initializationOptions: {
experimentalFeatures: {
// Enable all experimental features
all: true,
// Or enable specific features
missingInputsQuickfix: true,
}
}
```
**Available experimental features:**
| Feature | Description |
|---------|-------------|
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
| `blockScalarChompingWarning` | Warn when block scalars (`\|` or `>`) use implicit clip chomping, which adds a trailing newline that may be unintentional |
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
When a feature graduates to stable, its flag becomes a no-op and the feature will be enabled regardless of the configuration value.
### Standalone CLI
After installing globally, you can run the language server directly:
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.26",
"version": "0.4.0",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -36,7 +36,7 @@
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"prepublishOnly": "npm run build && npm run test",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
@@ -48,8 +48,8 @@
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.26",
"@actions/workflow-parser": "^0.3.26",
"@actions/languageservice": "^0.4.0",
"@actions/workflow-parser": "^0.4.0",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -57,7 +57,7 @@
"yaml": "^2.1.3"
},
"engines": {
"node": ">= 18"
"node": ">= 20"
},
"files": [
"dist/**/*",
@@ -73,9 +73,10 @@
"eslint-plugin-prettier": "^4.2.1",
"fetch-mock": "^9.11.0",
"jest": "^29.0.3",
"node-fetch": "^2.6.7",
"prettier": "^2.8.3",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"typescript": "^4.8.4"
"typescript": "^5.8.3"
}
}
+59 -15
View File
@@ -1,8 +1,18 @@
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
import {
documentLinks,
getCodeActions,
getInlayHints,
hover,
validate,
ValidationConfig
} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest";
import {
CodeAction,
CodeActionKind,
CodeActionParams,
CompletionItem,
Connection,
DocumentLink,
@@ -12,24 +22,27 @@ import {
HoverParams,
InitializeParams,
InitializeResult,
InlayHint,
InlayHintParams,
TextDocumentIdentifier,
TextDocumentPositionParams,
TextDocuments,
TextDocumentSyncKind
} from "vscode-languageserver";
import {TextDocument} from "vscode-languageserver-textdocument";
import {getClient} from "./client";
import {Commands} from "./commands";
import {contextProviders} from "./context-providers";
import {descriptionProvider} from "./description-provider";
import {getFileProvider} from "./file-provider";
import {InitializationOptions, RepositoryContext} from "./initializationOptions";
import {onCompletion} from "./on-completion";
import {ReadFileRequest, Requests} from "./request";
import {getActionsMetadataProvider} from "./utils/action-metadata";
import {TTLCache} from "./utils/cache";
import {timeOperation} from "./utils/timer";
import {valueProviders} from "./value-providers";
import {getClient} from "./client.js";
import {Commands} from "./commands.js";
import {contextProviders} from "./context-providers.js";
import {descriptionProvider} from "./description-provider.js";
import {FeatureFlags} from "@actions/expressions";
import {getFileProvider} from "./file-provider.js";
import {InitializationOptions, RepositoryContext} from "./initializationOptions.js";
import {onCompletion} from "./on-completion.js";
import {ReadFileRequest, Requests} from "./request.js";
import {getActionsMetadataProvider} from "./utils/action-metadata.js";
import {TTLCache} from "./utils/cache.js";
import {timeOperation} from "./utils/timer.js";
import {valueProviders} from "./value-providers.js";
export function initConnection(connection: Connection) {
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
@@ -39,6 +52,7 @@ export function initConnection(connection: Connection) {
const cache = new TTLCache();
let hasWorkspaceFolderCapability = false;
let featureFlags = new FeatureFlags();
// Register remote console logger with language service
registerLogger(connection.console);
@@ -62,6 +76,8 @@ export function initConnection(connection: Connection) {
setLogLevel(options.logLevel);
}
featureFlags = new FeatureFlags(options.experimentalFeatures);
const result: InitializeResult = {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Full,
@@ -72,6 +88,10 @@ export function initConnection(connection: Connection) {
hoverProvider: true,
documentLinkProvider: {
resolveProvider: false
},
inlayHintProvider: true,
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix]
}
}
};
@@ -88,6 +108,11 @@ export function initConnection(connection: Connection) {
});
connection.onInitialized(() => {
const enabledFeatures = featureFlags.getEnabledFeatures();
if (enabledFeatures.length > 0) {
connection.console.info(`Experimental features enabled: ${enabledFeatures.join(", ")}`);
}
if (hasWorkspaceFolderCapability) {
connection.workspace.onDidChangeWorkspaceFolders(() => {
clearCache();
@@ -111,7 +136,8 @@ export function initConnection(connection: Connection) {
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
})
}),
featureFlags
};
const result = await validate(textDocument, config);
@@ -128,7 +154,8 @@ export function initConnection(connection: Connection) {
getDocument(documents, textDocument),
client,
repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)),
cache
cache,
featureFlags
)
);
});
@@ -158,6 +185,23 @@ export function initConnection(connection: Connection) {
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));
});
});
connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
const document = getDocument(documents, params.textDocument);
return getCodeActions({
uri: params.textDocument.uri,
documentContent: document.getText(),
diagnostics: params.context.diagnostics,
only: params.context.only,
featureFlags
});
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
+3 -3
View File
@@ -1,9 +1,9 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {Mode} from "@actions/languageservice/context-providers/default";
import {contextProviders} from "./context-providers";
import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache";
import {contextProviders} from "./context-providers.js";
import {RepositoryContext} from "./initializationOptions.js";
import {TTLCache} from "./utils/cache.js";
describe("contextProviders", () => {
const mockCache = new TTLCache();
+5 -5
View File
@@ -3,11 +3,11 @@ import {ContextProviderConfig} from "@actions/languageservice";
import {Mode} from "@actions/languageservice/context-providers/default";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {Octokit} from "@octokit/rest";
import {getSecrets} from "./context-providers/secrets";
import {getStepsContext} from "./context-providers/steps";
import {getVariables} from "./context-providers/variables";
import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache";
import {getSecrets} from "./context-providers/secrets.js";
import {getStepsContext} from "./context-providers/steps.js";
import {getVariables} from "./context-providers/variables.js";
import {RepositoryContext} from "./initializationOptions.js";
import {TTLCache} from "./utils/cache.js";
export function contextProviders(
client: Octokit | undefined,
@@ -1,7 +1,7 @@
import {ActionOutputs, ActionReference} from "@actions/languageservice/action";
import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache";
import {fetchActionMetadata} from "../utils/action-metadata.js";
import {TTLCache} from "../utils/cache.js";
export async function getActionOutputs(
octokit: Octokit,
@@ -6,10 +6,10 @@ import {warn} from "@actions/languageservice/log";
import {isMapping, isString} from "@actions/workflow-parser";
import {Octokit} from "@octokit/rest";
import {RepositoryContext} from "../initializationOptions";
import {TTLCache} from "../utils/cache";
import {errorStatus} from "../utils/error";
import {getRepoPermission} from "../utils/repo-permission";
import {RepositoryContext} from "../initializationOptions.js";
import {TTLCache} from "../utils/cache.js";
import {errorStatus} from "../utils/error.js";
import {getRepoPermission} from "../utils/repo-permission.js";
export async function getSecrets(
workflowContext: WorkflowContext,
@@ -3,9 +3,9 @@ import {getStepsContext as getDefaultStepsContext} from "@actions/languageservic
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
import {createWorkflowContext} from "../test-utils/workflow-context";
import {TTLCache} from "../utils/cache";
import {getStepsContext} from "./steps";
import {createWorkflowContext} from "../test-utils/workflow-context.js";
import {TTLCache} from "../utils/cache.js";
import {getStepsContext} from "./steps.js";
const workflow = `
name: Caching Primes
@@ -84,13 +84,17 @@ it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
// Get the step context
const stepContext = stepsContext?.get("cache-primes");
expect(stepContext).toBeDefined();
expect(isDescriptionDictionary(stepContext!)).toBe(true);
if (!stepContext) {
throw new Error("Expected stepContext to be defined");
}
expect(isDescriptionDictionary(stepContext)).toBe(true);
// Get the outputs - should be a dictionary, not null
const outputs = (stepContext as DescriptionDictionary).get("outputs");
expect(outputs).toBeDefined();
expect(isDescriptionDictionary(outputs!)).toBe(true);
if (!outputs) {
throw new Error("Expected outputs to be defined");
}
expect(isDescriptionDictionary(outputs)).toBe(true);
// Outputs should be marked incomplete to allow dynamic outputs
const outputsDict = outputs as DescriptionDictionary;
@@ -3,8 +3,8 @@ import {parseActionReference} from "@actions/languageservice/action";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Octokit} from "@octokit/rest";
import {TTLCache} from "../utils/cache";
import {getActionOutputs} from "./action-outputs";
import {TTLCache} from "../utils/cache.js";
import {getActionOutputs} from "./action-outputs.js";
export async function getStepsContext(
octokit: Octokit,
@@ -7,10 +7,10 @@ import {isMapping, isString} from "@actions/workflow-parser";
import {Octokit} from "@octokit/rest";
import {RequestError} from "@octokit/request-error";
import {RepositoryContext} from "../initializationOptions";
import {TTLCache} from "../utils/cache";
import {errorStatus} from "../utils/error";
import {getRepoPermission} from "../utils/repo-permission";
import {RepositoryContext} from "../initializationOptions.js";
import {TTLCache} from "../utils/cache.js";
import {errorStatus} from "../utils/error.js";
import {getRepoPermission} from "../utils/repo-permission.js";
export async function getVariables(
workflowContext: WorkflowContext,
+3 -3
View File
@@ -1,8 +1,8 @@
import {DescriptionProvider} from "@actions/languageservice/hover";
import {Octokit} from "@octokit/rest";
import {getActionDescription} from "./description-providers/action-description";
import {getActionInputDescription} from "./description-providers/action-input";
import {TTLCache} from "./utils/cache";
import {getActionDescription} from "./description-providers/action-description.js";
import {getActionInputDescription} from "./description-providers/action-input.js";
import {TTLCache} from "./utils/cache.js";
export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider {
const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => {
@@ -1,9 +1,9 @@
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
import {createWorkflowContext} from "../test-utils/workflow-context";
import {TTLCache} from "../utils/cache";
import {getActionDescription} from "./action-description";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
import {createWorkflowContext} from "../test-utils/workflow-context.js";
import {TTLCache} from "../utils/cache.js";
import {getActionDescription} from "./action-description.js";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
const workflow = `
name: Hello World
@@ -2,8 +2,8 @@ import {actionUrl, parseActionReference} from "@actions/languageservice/action";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache";
import {fetchActionMetadata} from "../utils/action-metadata.js";
import {TTLCache} from "../utils/cache.js";
export async function getActionDescription(client: Octokit, cache: TTLCache, step: Step): Promise<string | undefined> {
if (!isActionStep(step)) {
@@ -2,10 +2,10 @@ import {StringToken} from "@actions/workflow-parser/templates/tokens/string-toke
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
import {createWorkflowContext} from "../test-utils/workflow-context";
import {TTLCache} from "../utils/cache";
import {getActionInputDescription} from "./action-input";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
import {createWorkflowContext} from "../test-utils/workflow-context.js";
import {TTLCache} from "../utils/cache.js";
import {getActionInputDescription} from "./action-input.js";
const workflow = `
name: Hello World
@@ -4,8 +4,8 @@ import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache";
import {fetchActionMetadata} from "../utils/action-metadata.js";
import {TTLCache} from "../utils/cache.js";
export async function getActionInputDescription(
client: Octokit,
+1 -1
View File
@@ -2,7 +2,7 @@ import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
import {Octokit} from "@octokit/rest";
import {TTLCache} from "./utils/cache";
import {TTLCache} from "./utils/cache.js";
import * as vscodeURI from "vscode-uri";
export function getFileProvider(
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from "vscode-languageserver/browser";
import {createConnection as createNodeConnection} from "vscode-languageserver/node";
import {initConnection} from "./connection";
import {initConnection} from "./connection.js";
/** Helper function determining whether we are executing with node runtime */
function isNode(): boolean {
@@ -1,3 +1,4 @@
import {ExperimentalFeatures} from "@actions/expressions";
import {LogLevel} from "@actions/languageservice/log";
export {LogLevel} from "@actions/languageservice/log";
@@ -28,6 +29,12 @@ export interface InitializationOptions {
* If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3"
*/
gitHubApiUrl?: string;
/**
* Experimental features that are opt-in.
* Features listed here may change or be removed without notice.
*/
experimentalFeatures?: ExperimentalFeatures;
}
export interface RepositoryContext {
+10 -7
View File
@@ -1,13 +1,14 @@
import {complete} from "@actions/languageservice/complete";
import type {FeatureFlags} from "@actions/expressions";
import {Octokit} from "@octokit/rest";
import {CompletionItem, Connection, Position} from "vscode-languageserver";
import {TextDocument} from "vscode-languageserver-textdocument";
import {contextProviders} from "./context-providers";
import {getFileProvider} from "./file-provider";
import {RepositoryContext} from "./initializationOptions";
import {Requests} from "./request";
import {TTLCache} from "./utils/cache";
import {valueProviders} from "./value-providers";
import {contextProviders} from "./context-providers.js";
import {getFileProvider} from "./file-provider.js";
import {RepositoryContext} from "./initializationOptions.js";
import {Requests} from "./request.js";
import {TTLCache} from "./utils/cache.js";
import {valueProviders} from "./value-providers.js";
export async function onCompletion(
connection: Connection,
@@ -15,11 +16,13 @@ export async function onCompletion(
document: TextDocument,
client: Octokit | undefined,
repoContext: RepositoryContext | undefined,
cache: TTLCache
cache: TTLCache,
featureFlags?: FeatureFlags
): Promise<CompletionItem[]> {
return await complete(document, position, {
valueProviderConfig: repoContext && valueProviders(client, repoContext, cache),
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
featureFlags,
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
return await connection.sendRequest(Requests.ReadFile, {path});
})
@@ -1,7 +1,7 @@
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
import {fetchActionMetadata} from "./action-metadata";
import {TTLCache} from "./cache";
import {fetchActionMetadata} from "./action-metadata.js";
import {TTLCache} from "./cache.js";
// A simplified version of the action.yml file from actions/checkout
const actionMetadataContent = `
+2 -2
View File
@@ -3,8 +3,8 @@ import {ActionsMetadataProvider} from "@actions/languageservice";
import {error} from "@actions/languageservice/log";
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
import {parse} from "yaml";
import {TTLCache} from "./cache";
import {errorMessage, errorStatus} from "./error";
import {TTLCache} from "./cache.js";
import {errorMessage, errorStatus} from "./error.js";
export function getActionsMetadataProvider(
client: Octokit | undefined,
+4 -4
View File
@@ -1,9 +1,9 @@
import {error} from "@actions/languageservice/log";
import {Octokit} from "@octokit/rest";
import {RepositoryContext} from "../initializationOptions";
import {TTLCache} from "./cache";
import {errorStatus} from "./error";
import {getUsername} from "./username";
import {RepositoryContext} from "../initializationOptions.js";
import {TTLCache} from "./cache.js";
import {errorStatus} from "./error.js";
import {getUsername} from "./username.js";
export type RepoPermission = "admin" | "write" | "read" | "none";
+1 -1
View File
@@ -1,5 +1,5 @@
import {Octokit} from "@octokit/rest";
import {TTLCache} from "./cache";
import {TTLCache} from "./cache.js";
export async function getUsername(octokit: Octokit, cache: TTLCache): Promise<string> {
return await cache.get(`/username`, undefined, () => fetchUsername(octokit));
+5 -5
View File
@@ -2,11 +2,11 @@ import {ValueProviderConfig} from "@actions/languageservice";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {ValueProviderKind} from "@actions/languageservice/value-providers/config";
import {Octokit} from "@octokit/rest";
import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache";
import {getActionInputValues} from "./value-providers/action-inputs";
import {getEnvironments} from "./value-providers/job-environment";
import {getRunnerLabels} from "./value-providers/runs-on";
import {RepositoryContext} from "./initializationOptions.js";
import {TTLCache} from "./utils/cache.js";
import {getActionInputValues} from "./value-providers/action-inputs.js";
import {getEnvironments} from "./value-providers/job-environment.js";
import {getRunnerLabels} from "./value-providers/runs-on.js";
export function valueProviders(
client: Octokit | undefined,
@@ -3,8 +3,8 @@ import {WorkflowContext} from "@actions/languageservice/context/workflow-context
import {Value} from "@actions/languageservice/value-providers/config";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache";
import {fetchActionMetadata} from "../utils/action-metadata.js";
import {TTLCache} from "../utils/cache.js";
export async function getActionInputs(
client: Octokit,
@@ -1,6 +1,6 @@
import {Value} from "@actions/languageservice/value-providers/config";
import {Octokit} from "@octokit/rest";
import {TTLCache} from "../utils/cache";
import {TTLCache} from "../utils/cache.js";
export async function getEnvironments(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> {
const environments = await cache.get(`${owner}/${name}/environments`, undefined, () =>
@@ -2,8 +2,8 @@ import {log} from "@actions/languageservice/log";
import {Value} from "@actions/languageservice/value-providers/config";
import {DEFAULT_RUNNER_LABELS} from "@actions/languageservice/value-providers/default";
import {Octokit} from "@octokit/rest";
import {TTLCache} from "../utils/cache";
import {errorMessage} from "../utils/error";
import {TTLCache} from "../utils/cache.js";
import {errorMessage} from "../utils/error.js";
// Limitation: getRunnerLabels returns default hosted labels and labels for repository self-hosted runners.
// It doesn't return labels for organization runners visible to the repository.
+2 -1
View File
@@ -5,6 +5,7 @@
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "./dist"
"outDir": "./dist",
"skipLibCheck": true
}
}
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.26",
"version": "0.4.0",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -35,7 +35,7 @@
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
"prebuild": "npm run minify-json",
@@ -47,15 +47,15 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.26",
"@actions/workflow-parser": "^0.3.26",
"@actions/expressions": "^0.4.0",
"@actions/workflow-parser": "^0.4.0",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
"yaml": "^2.1.1"
},
"engines": {
"node": ">= 18"
"node": ">= 20"
},
"files": [
"dist/**/*"
@@ -74,6 +74,6 @@
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1",
"typescript": "^4.8.4"
"typescript": "^5.8.3"
}
}
@@ -0,0 +1,55 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
import {CodeActionContext, CodeActionProvider} from "./types.js";
import {getQuickfixProviders} from "./quickfix/quickfix-providers.js";
export interface CodeActionParams {
uri: string;
documentContent: string;
diagnostics: Diagnostic[];
only?: string[];
featureFlags?: FeatureFlags;
}
export function getCodeActions(params: CodeActionParams): CodeAction[] {
const actions: CodeAction[] = [];
const context: CodeActionContext = {
uri: params.uri,
documentContent: params.documentContent,
featureFlags: params.featureFlags
};
// Build providers map based on feature flags
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
[CodeActionKind.QuickFix, getQuickfixProviders(params.featureFlags)]
// [CodeActionKind.Refactor, getRefactorProviders(params.featureFlags)],
// [CodeActionKind.Source, getSourceProviders(params.featureFlags)],
// etc
]);
// Filter to requested kinds, or use all if none specified
const requestedKinds = params.only;
const kindsToCheck = requestedKinds
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
: [...providersByKind.keys()];
for (const diagnostic of params.diagnostics) {
for (const kind of kindsToCheck) {
const providers = providersByKind.get(kind) ?? [];
for (const provider of providers) {
if (provider.diagnosticCodes.includes(diagnostic.code)) {
const action = provider.createCodeAction(context, diagnostic);
if (action) {
action.kind = kind;
action.diagnostics = [diagnostic];
actions.push(action);
}
}
}
}
}
return actions;
}
export type {CodeActionContext, CodeActionProvider} from "./types.js";
@@ -0,0 +1,245 @@
import {isMapping} from "@actions/workflow-parser";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {CodeAction, Position, TextEdit} from "vscode-languageserver-types";
import {error} from "../../log.js";
import {findToken} from "../../utils/find-token.js";
import {getOrParseWorkflow} from "../../utils/workflow-cache.js";
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action-reference.js";
import {CodeActionContext, CodeActionProvider} from "../types.js";
/**
* Information extracted from a step token needed to generate edits
*/
interface StepInfo {
/** Column where step keys start (1-indexed), e.g., the column of "uses:" */
stepKeyColumn: number;
/** End line of the step (1-indexed) */
stepEndLine: number;
/** Detected indent size (spaces per level) */
indentSize: number;
/** Information about existing with: block, if present */
withInfo?: {
keyColumn: number;
keyEndLine: number;
valueEndLine: number;
hasChildren: boolean;
/** Column of first child input (1-indexed), for indentation detection */
firstChildColumn?: number;
};
}
export const addMissingInputsProvider: CodeActionProvider = {
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
createCodeAction(context: CodeActionContext, diagnostic): CodeAction | undefined {
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
if (!data) {
return undefined;
}
// Parse the document to get the step token
const stepInfo = getStepInfo(context, diagnostic.range.start);
if (!stepInfo) {
return undefined;
}
const edits = createInputEdits(data.missingInputs, stepInfo);
if (!edits || edits.length === 0) {
return undefined;
}
const inputNames = data.missingInputs.map(i => i.name).join(", ");
return {
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
edit: {
changes: {
[context.uri]: edits
}
}
};
}
};
/**
* Parse the document and extract step information needed for generating edits.
* Returns undefined if parsing fails or the step token cannot be found.
*/
function getStepInfo(context: CodeActionContext, diagnosticPosition: Position): StepInfo | undefined {
// Parse the document (uses cache if available from validation)
const file = {name: context.uri, content: context.documentContent};
const parseResult = getOrParseWorkflow(file, context.uri);
if (!parseResult.value) {
error("Failed to parse workflow for missing inputs quickfix");
return undefined;
}
// Find the token at the diagnostic position
const {path} = findToken(diagnosticPosition, parseResult.value);
// Walk up the path to find the step token (regular-step)
const stepToken = findStepInPath(path);
if (!stepToken) {
error("Could not find step token for missing inputs quickfix");
return undefined;
}
return extractStepInfo(stepToken);
}
/**
* Find the step token (regular-step) in the token path
*/
function findStepInPath(path: TemplateToken[]): MappingToken | undefined {
// Walk backwards through path to find the step
for (let i = path.length - 1; i >= 0; i--) {
if (path[i].definition?.key === "regular-step" && isMapping(path[i])) {
return path[i] as MappingToken;
}
}
return undefined;
}
/**
* Extract position and indentation info from a step token
*/
function extractStepInfo(stepToken: MappingToken): StepInfo | undefined {
if (!stepToken.range) {
return undefined;
}
// Get the column of the first key in the step
let stepKeyColumn = stepToken.range.start.column;
if (stepToken.count > 0) {
const firstEntry = stepToken.get(0);
if (firstEntry?.key.range) {
stepKeyColumn = firstEntry.key.range.start.column;
}
}
// Find the with: block if present
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const {key, value} of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
break;
}
}
// Calculate indent size
let indentSize = 2; // Default
let withInfo: StepInfo["withInfo"];
if (withKey?.range && withToken?.range) {
// Has with: block - extract its info
const hasChildren = isMapping(withToken) && withToken.count > 0;
let firstChildColumn: number | undefined;
if (hasChildren) {
const firstChild = (withToken as MappingToken).get(0);
if (firstChild?.key.range) {
firstChildColumn = firstChild.key.range.start.column;
// Detect indent size from with: children
indentSize = firstChildColumn - withKey.range.start.column;
}
}
withInfo = {
keyColumn: withKey.range.start.column,
keyEndLine: withKey.range.end.line,
valueEndLine: withToken.range.end.line,
hasChildren,
firstChildColumn
};
} else {
// No with: block - detect indent size using heuristics
// Based on the step key column position, estimate indent size
// 2-space indent files typically have step keys at column 7
// 4-space indent files typically have step keys at column 15
const zeroIndexedCol = stepKeyColumn - 1;
if (zeroIndexedCol >= 10) {
indentSize = 4;
}
}
return {
stepKeyColumn,
stepEndLine: stepToken.range.end.line,
indentSize,
withInfo
};
}
/**
* Generate text edits to add missing inputs
*/
function createInputEdits(missingInputs: MissingInputsDiagnosticData["missingInputs"], stepInfo: StepInfo): TextEdit[] {
const formatInputLines = (indent: string) =>
missingInputs.map(input => {
const value = input.default ?? '""';
return `${indent}${input.name}: ${value}`;
});
if (stepInfo.withInfo) {
// `with:` exists - add inputs to existing block
const withIndent = stepInfo.withInfo.keyColumn - 1; // 0-indexed
const inputIndentSize = stepInfo.withInfo.firstChildColumn
? stepInfo.withInfo.firstChildColumn - stepInfo.withInfo.keyColumn
: stepInfo.indentSize;
const inputIndent = " ".repeat(withIndent + inputIndentSize);
const inputLines = formatInputLines(inputIndent);
// Calculate insert position
let insertLine: number;
if (stepInfo.withInfo.hasChildren) {
// Insert after the last child (at end of with: block)
// valueEndLine is 1-indexed, we want 0-indexed for Position
insertLine = stepInfo.withInfo.valueEndLine - 1;
} else {
// Empty with: block - insert on the next line after with:
// keyEndLine is 1-indexed, convert to 0-indexed and go to next line
insertLine = stepInfo.withInfo.keyEndLine;
}
const insertPosition: Position = {
line: insertLine,
character: 0
};
return [
{
range: {start: insertPosition, end: insertPosition},
newText: inputLines.map(line => line + "\n").join("")
}
];
} else {
// No `with:` key - add `with:` at the same level as other step keys
const withKeyIndent = stepInfo.stepKeyColumn - 1; // 0-indexed (columns are 1-based)
const withIndent = " ".repeat(withKeyIndent);
const inputIndent = " ".repeat(withKeyIndent + stepInfo.indentSize);
const inputLines = formatInputLines(inputIndent);
const newText = `${withIndent}with:\n` + inputLines.map(line => `${line}\n`).join("");
// Insert at end of step
// stepEndLine is 1-indexed, we want 0-indexed and insert before the line after
const insertPosition: Position = {
line: stepInfo.stepEndLine - 1,
character: 0
};
return [
{
range: {start: insertPosition, end: insertPosition},
newText
}
];
}
}
@@ -0,0 +1,13 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeActionProvider} from "../types.js";
import {addMissingInputsProvider} from "./add-missing-inputs.js";
export function getQuickfixProviders(featureFlags?: FeatureFlags): CodeActionProvider[] {
const providers: CodeActionProvider[] = [];
if (featureFlags?.isEnabled("missingInputsQuickfix")) {
providers.push(addMissingInputsProvider);
}
return providers;
}
@@ -0,0 +1,90 @@
import * as path from "path";
import {fileURLToPath} from "url";
import {loadTestCases, runTestCase} from "./runner.js";
import {ValidationConfig} from "../../validate.js";
import {ActionMetadata, ActionReference} from "../../action.js";
import {clearCache} from "../../utils/workflow-cache.js";
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Mock action metadata provider for tests
const validationConfig: ValidationConfig = {
actionsMetadataProvider: {
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
const key = `${ref.owner}/${ref.name}@${ref.ref}`;
const metadata: Record<string, ActionMetadata> = {
"actions/cache@v1": {
name: "Cache",
description: "Cache dependencies",
inputs: {
path: {
description: "A list of files to cache",
required: true
},
key: {
description: "Cache key",
required: true
},
"restore-keys": {
description: "Restore keys",
required: false
}
}
},
"actions/setup-node@v3": {
name: "Setup Node",
description: "Setup Node.js",
inputs: {
"node-version": {
description: "Node version",
required: true,
default: "16"
}
}
}
};
return Promise.resolve(metadata[key]);
}
}
};
// Point to the source testdata directory
const testdataDir = path.join(__dirname, "testdata");
beforeEach(() => {
clearCache();
});
describe("code action golden tests", () => {
const testCases = loadTestCases(testdataDir);
if (testCases.length === 0) {
it.todo("no test cases found - add .yml files to testdata/");
return;
}
for (const testCase of testCases) {
it(testCase.name, async () => {
const result = await runTestCase(testCase, validationConfig);
if (!result.passed) {
let errorMessage = result.error || "Test failed";
if (result.expected !== undefined && result.actual !== undefined) {
errorMessage += "\n\n";
errorMessage += "=== EXPECTED (golden file) ===\n";
errorMessage += result.expected;
errorMessage += "\n\n";
errorMessage += "=== ACTUAL ===\n";
errorMessage += result.actual;
}
throw new Error(errorMessage);
}
});
}
});
@@ -0,0 +1,231 @@
import * as fs from "fs";
import * as path from "path";
import {TextEdit} from "vscode-languageserver-types";
import {TextDocument} from "vscode-languageserver-textdocument";
import {FeatureFlags} from "@actions/expressions";
import {validate, ValidationConfig} from "../../validate.js";
import {getCodeActions, CodeActionParams} from "../code-actions.js";
// Marker pattern: # want "diagnostic message" fix="code-action-name"
const MARKER_PATTERN = /#\s*want\s+"([^"]+)"(?:\s+fix="([^"]+)")?/;
export interface TestCase {
name: string;
inputPath: string;
goldenPath: string;
input: string;
golden: string;
markers: Marker[];
}
export interface Marker {
line: number;
message: string;
fix?: string;
}
export interface TestResult {
name: string;
passed: boolean;
error?: string;
expected?: string;
actual?: string;
}
/**
* Parse markers from input file content
*/
export function parseMarkers(content: string): Marker[] {
const lines = content.split("\n");
const markers: Marker[] = [];
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(MARKER_PATTERN);
if (match) {
markers.push({
line: i,
message: match[1],
fix: match[2]
});
}
}
return markers;
}
/**
* Strip markers from content (for processing)
*/
export function stripMarkers(content: string): string {
return content
.split("\n")
.map(line => line.replace(MARKER_PATTERN, "").trimEnd())
.join("\n");
}
/**
* Load all test cases from a testdata directory
*/
export function loadTestCases(testdataDir: string): TestCase[] {
const testCases: TestCase[] = [];
function walkDir(dir: string) {
const entries = fs.readdirSync(dir, {withFileTypes: true});
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".yml") && !entry.name.endsWith(".golden.yml")) {
const goldenPath = fullPath.replace(".yml", ".golden.yml");
if (fs.existsSync(goldenPath)) {
const input = fs.readFileSync(fullPath, "utf-8");
const golden = fs.readFileSync(goldenPath, "utf-8");
testCases.push({
name: path.relative(testdataDir, fullPath),
inputPath: fullPath,
goldenPath,
input,
golden,
markers: parseMarkers(input)
});
}
}
}
}
walkDir(testdataDir);
return testCases;
}
/**
* Apply text edits to a document
*/
export function applyEdits(content: string, edits: TextEdit[]): string {
// Sort edits in reverse order by position to apply from bottom to top
const sortedEdits = [...edits].sort((a, b) => {
if (b.range.start.line !== a.range.start.line) {
return b.range.start.line - a.range.start.line;
}
return b.range.start.character - a.range.start.character;
});
const lines = content.split("\n");
for (const edit of sortedEdits) {
const startLine = edit.range.start.line;
const startChar = edit.range.start.character;
const endLine = edit.range.end.line;
const endChar = edit.range.end.character;
const before = lines[startLine].slice(0, startChar);
const after = lines[endLine].slice(endChar);
const newLines = edit.newText.split("\n");
newLines[0] = before + newLines[0];
newLines[newLines.length - 1] = newLines[newLines.length - 1] + after;
lines.splice(startLine, endLine - startLine + 1, ...newLines);
}
return lines.join("\n");
}
/**
* Run a single test case
*/
export async function runTestCase(testCase: TestCase, validationConfig: ValidationConfig): Promise<TestResult> {
const strippedInput = stripMarkers(testCase.input);
const document = TextDocument.create("file:///test.yml", "yaml", 1, strippedInput);
// 1. Validate and get diagnostics
const diagnostics = await validate(document, validationConfig);
// 2. Verify all expected diagnostics are present
const missingDiagnostics: string[] = [];
for (const marker of testCase.markers) {
const found = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
if (!found) {
missingDiagnostics.push(`line ${marker.line}: "${marker.message}"`);
}
}
if (missingDiagnostics.length > 0) {
return {
name: testCase.name,
passed: false,
error: `Missing expected diagnostics:\n ${missingDiagnostics.join(
"\n "
)}\n\nActual diagnostics:\n ${diagnostics.map(d => `line ${d.range.start.line}: "${d.message}"`).join("\n ")}`
};
}
// 3. Collect all edits from all matching code actions
const allEdits: TextEdit[] = [];
for (const marker of testCase.markers) {
if (!marker.fix) {
continue;
}
const diagnostic = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
if (!diagnostic) {
continue; // Already reported above
}
const params: CodeActionParams = {
uri: document.uri,
documentContent: strippedInput,
diagnostics: [diagnostic],
featureFlags: new FeatureFlags({all: true})
};
const actions = getCodeActions(params);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- marker.fix is checked at the start of the loop
const matchingAction = actions.find(a => a.title.toLowerCase().includes(marker.fix!.toLowerCase()));
if (!matchingAction) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" not found for diagnostic on line ${marker.line}.\nAvailable actions: ${
actions.map(a => a.title).join(", ") || "(none)"
}`
};
}
if (!matchingAction.edit?.changes) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" has no edits`
};
}
const edits = matchingAction.edit.changes[document.uri] || [];
allEdits.push(...edits);
}
// 4. Apply all edits and compare to golden file
const actualOutput = applyEdits(strippedInput, allEdits);
const expectedOutput = testCase.golden;
if (actualOutput.trim() !== expectedOutput.trim()) {
return {
name: testCase.name,
passed: false,
error: "Output does not match golden file",
expected: expectedOutput,
actual: actualOutput
};
}
return {
name: testCase.name,
passed: true
};
}
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,7 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
@@ -0,0 +1,10 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: ${{ runner.os }}-
path: ""
key: ""
@@ -0,0 +1,8 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
restore-keys: ${{ runner.os }}-
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,6 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,6 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
+23
View File
@@ -0,0 +1,23 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeAction, Diagnostic} from "vscode-languageserver-types";
export interface CodeActionContext {
uri: string;
documentContent: string;
featureFlags?: FeatureFlags;
}
/**
* A provider that can produce a code action for a given diagnostic
*/
export interface CodeActionProvider {
/**
* The diagnostic codes this provider handles
*/
diagnosticCodes: (string | number | undefined)[];
/**
* Create a code action for the diagnostic, if applicable
*/
createCodeAction(context: CodeActionContext, diagnostic: Diagnostic): CodeAction | undefined;
}
+689
View File
@@ -0,0 +1,689 @@
import {TextDocument} from "vscode-languageserver-textdocument";
import {complete} from "./complete";
import {clearCache} from "./utils/workflow-cache";
beforeEach(() => {
clearCache();
});
describe("complete action files", () => {
function createActionDocument(
content: string,
uri = "file:///test/action.yml"
): [TextDocument, {line: number; character: number}] {
// Parse cursor position and remove the | character
const cursorIndex = content.indexOf("|");
if (cursorIndex === -1) {
throw new Error("No cursor (|) found in content");
}
const newContent = content.substring(0, cursorIndex) + content.substring(cursorIndex + 1);
const doc = TextDocument.create(uri, "yaml", 1, newContent);
const position = doc.positionAt(cursorIndex);
return [doc, position];
}
describe("expression completion in composite actions", () => {
it("completes inputs context", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
inputs:
name:
description: The name
greeting:
description: The greeting
default: Hello
runs:
using: composite
steps:
- run: echo "\${{ inputs.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("name");
expect(labels).toContain("greeting");
});
it("completes steps context with prior step IDs", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- id: step1
run: echo "hello"
shell: bash
- id: step2
run: echo "\${{ steps.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("step1");
expect(labels).not.toContain("step2"); // Current step should not be included
});
it("completes step properties", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- id: greet
run: echo "hello"
shell: bash
- run: echo "\${{ steps.greet.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("outputs");
expect(labels).toContain("outcome");
expect(labels).toContain("conclusion");
});
it("does not include steps from after cursor position", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- id: first
run: echo "first"
shell: bash
- run: echo "\${{ steps.| }}"
shell: bash
- id: last
run: echo "last"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("first");
expect(labels).not.toContain("last");
});
it("completes github context in actions", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- run: echo "\${{ github.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("actor");
expect(labels).toContain("repository");
expect(labels).toContain("ref");
});
it("completes runner context in actions", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- run: echo "\${{ runner.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("os");
expect(labels).toContain("arch");
expect(labels).toContain("temp");
});
it("completes if expression value for composite run step", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- if: |
run: echo "hello"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show expression-related completions (status functions and contexts)
expect(labels).toContain("always");
expect(labels).toContain("success");
expect(labels).toContain("failure");
expect(labels).toContain("cancelled");
expect(labels).toContain("runner");
expect(labels).toContain("github");
expect(labels).toContain("inputs");
expect(labels).toContain("steps");
});
it("completes if expression value for composite uses step", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- if: |
uses: actions/checkout@v4`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show expression-related completions
expect(labels).toContain("always");
expect(labels).toContain("success");
expect(labels).toContain("failure");
expect(labels).toContain("cancelled");
expect(labels).toContain("runner");
expect(labels).toContain("github");
});
});
describe("top-level completions", () => {
it("completes top-level keys", async () => {
const [doc, position] = createActionDocument(`n|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("name");
});
it("completes at empty line", async () => {
const [doc, position] = createActionDocument(`name: My Action
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("description");
expect(labels).toContain("runs");
expect(labels).toContain("inputs");
expect(labels).toContain("outputs");
expect(labels).toContain("branding");
expect(labels).toContain("author");
});
});
describe("runs completions", () => {
it("completes runs.using values", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("composite");
expect(labels).toContain("node20");
expect(labels).toContain("docker");
});
it("completes runs keys", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("using");
});
it("filters runs keys for node20 actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node20
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show Node.js action keys
expect(labels).toContain("main");
expect(labels).toContain("pre");
expect(labels).toContain("post");
expect(labels).toContain("pre-if");
expect(labels).toContain("post-if");
// Should NOT show composite or docker keys
expect(labels).not.toContain("steps");
expect(labels).not.toContain("image");
expect(labels).not.toContain("entrypoint");
});
it("filters runs keys for node24 actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node24
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show Node.js action keys
expect(labels).toContain("main");
expect(labels).toContain("pre");
expect(labels).toContain("post");
expect(labels).toContain("pre-if");
expect(labels).toContain("post-if");
// Should NOT show composite or docker keys
expect(labels).not.toContain("steps");
expect(labels).not.toContain("image");
expect(labels).not.toContain("entrypoint");
});
it("completes pre-if expression value for node actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node24
main: index.js
pre: setup.js
pre-if: |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show expression-related completions (context functions and namespaces)
expect(labels).toContain("always");
expect(labels).toContain("success");
expect(labels).toContain("failure");
expect(labels).toContain("cancelled");
expect(labels).toContain("runner");
expect(labels).toContain("github");
expect(labels).toContain("inputs");
expect(labels).toContain("hashFiles");
});
it("completes post-if expression value for node actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node24
main: index.js
post: cleanup.js
post-if: |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show expression-related completions
expect(labels).toContain("always");
expect(labels).toContain("runner");
expect(labels).toContain("hashFiles");
});
it("completes pre-if expression value for docker actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: docker
image: docker://alpine
pre-entrypoint: setup.sh
pre-if: |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show expression-related completions
expect(labels).toContain("always");
expect(labels).toContain("runner");
expect(labels).toContain("github");
expect(labels).toContain("hashFiles");
});
it("filters runs keys for composite actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: composite
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show composite action keys
expect(labels).toContain("steps");
// Should NOT show Node.js or docker keys
expect(labels).not.toContain("main");
expect(labels).not.toContain("pre");
expect(labels).not.toContain("post");
expect(labels).not.toContain("image");
});
it("filters runs keys for docker actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: docker
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show Docker action keys
expect(labels).toContain("image");
expect(labels).toContain("args");
expect(labels).toContain("env");
expect(labels).toContain("entrypoint");
expect(labels).toContain("pre-entrypoint");
expect(labels).toContain("post-entrypoint");
// Should NOT show Node.js or composite keys
expect(labels).not.toContain("main");
expect(labels).not.toContain("steps");
});
it("prioritizes using when not set", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
|`);
const completions = await complete(doc, position);
// Find the using completion
const usingCompletion = completions.find(c => c.label === "using");
expect(usingCompletion).toBeDefined();
// It should have a sortText that makes it sort after snippets
expect(usingCompletion?.sortText).toBe("9_using");
});
it("completes step keys inside composite action steps", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: composite
steps:
- run: echo hello
shell: bash
- |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show step keys, not filtered by runs-level logic
expect(labels).toContain("run");
expect(labels).toContain("uses");
expect(labels).toContain("shell");
expect(labels).toContain("id");
expect(labels).toContain("name");
expect(labels).toContain("if");
expect(labels).toContain("env");
expect(labels).toContain("working-directory");
});
});
describe("branding completions", () => {
it("completes branding keys", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node20
main: index.js
branding:
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("icon");
expect(labels).toContain("color");
});
it("completes branding color values", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node20
main: index.js
branding:
color: |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("blue");
expect(labels).toContain("green");
expect(labels).toContain("red");
});
});
describe("inputs completions", () => {
it("completes input property keys", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
inputs:
my-input:
|
runs:
using: node20
main: index.js`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("description");
expect(labels).toContain("required");
expect(labels).toContain("default");
expect(labels).toContain("deprecationMessage");
});
});
describe("document type routing", () => {
it("routes action.yml to action completion", async () => {
const [doc, position] = createActionDocument(`n|`, "file:///my-repo/action.yml");
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("name");
// Should NOT contain workflow-specific keys
expect(labels).not.toContain("on");
expect(labels).not.toContain("jobs");
});
it("includes descriptions from schema for completion items", async () => {
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
const completions = await complete(doc, position);
const authorCompletion = completions.find(c => c.label === "author");
expect(authorCompletion).toBeDefined();
expect(authorCompletion?.documentation).toBeDefined();
expect((authorCompletion?.documentation as {value: string})?.value).toContain("author");
});
it("includes descriptions for branding completion", async () => {
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
const completions = await complete(doc, position);
const brandingCompletion = completions.find(c => c.label === "branding");
expect(brandingCompletion).toBeDefined();
expect(brandingCompletion?.documentation).toBeDefined();
expect((brandingCompletion?.documentation as {value: string})?.value).toContain("branding");
});
it("falls back to type description when property has no description", async () => {
// `inputs` uses shorthand form in schema: "inputs": "inputs-strict"
// So the property has no description, but the type `inputs-strict` does
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
const completions = await complete(doc, position);
const inputsCompletion = completions.find(c => c.label === "inputs");
expect(inputsCompletion).toBeDefined();
expect(inputsCompletion?.documentation).toBeDefined();
expect((inputsCompletion?.documentation as {value: string})?.value).toContain("Input parameters");
});
it("does not route workflow files to action completion", async () => {
const doc = TextDocument.create("file:///repo/.github/workflows/ci.yml", "yaml", 1, `o`);
const completions = await complete(doc, {line: 0, character: 1});
const labels = completions.map(c => c.label);
expect(labels).toContain("on");
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);
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);
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);
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);
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);
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);
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);
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);
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);
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);
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);
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("replaces typed text when selecting scaffolding snippet", async () => {
// User typed "compo" and then triggered completion
const [doc, position] = createActionDocument(`compo|`);
const completions = await complete(doc, position);
const compositeSnippet = completions.find(c => c.label === "Composite Action");
expect(compositeSnippet).toBeDefined();
// The textEdit should replace "compo", not insert after it
const textEdit = compositeSnippet?.textEdit as {range: {start: {character: number}; end: {character: number}}};
expect(textEdit.range.start.character).toBe(0); // Start of "compo"
expect(textEdit.range.end.character).toBe(5); // End of "compo"
});
it("handles empty file with no typed text", async () => {
const [doc, position] = createActionDocument(`|`);
const completions = await complete(doc, position);
const compositeSnippet = completions.find(c => c.label === "Composite Action");
const textEdit = compositeSnippet?.textEdit as {range: {start: {character: number}; end: {character: number}}};
// Zero-length range is fine when there's nothing to replace
expect(textEdit.range.start.character).toBe(0);
expect(textEdit.range.end.character).toBe(0);
});
});
});
+480
View File
@@ -0,0 +1,480 @@
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, Range, 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\\\`);
`;
const ACTION_SNIPPET_NODEJS_RUNS = `inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
runs:
# For more on JavaScript actions (including @actions/toolkit), see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
using: node24
main: index.js
# Sample index.js (vanilla JS, no build required):
#
# const fs = require('fs');
# const name = process.env.INPUT_NAME || 'World';
# const greeting = \\\`Hello \\\${name}\\\`;
# console.log(greeting);
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
`;
const ACTION_SNIPPET_NODEJS_USING = `# For more on JavaScript actions (including @actions/toolkit), see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
using: node24
main: index.js
# Sample index.js (vanilla JS, no build required):
#
# console.log('Hello World');
`;
const ACTION_SNIPPET_COMPOSITE_FULL = `name: '\${1:Action Name}'
description: '\${2:What this action does}'
inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
value: \\\${{ steps.greet.outputs.greeting }}
runs:
# For more on composite actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
using: composite
steps:
- id: greet
shell: bash
env:
INPUT_NAME: \\\${{ inputs.name }}
run: |
GREETING="Hello \\$INPUT_NAME"
echo "\\$GREETING"
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
`;
const ACTION_SNIPPET_COMPOSITE_RUNS = `inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
value: \\\${{ steps.greet.outputs.greeting }}
runs:
# For more on composite actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
using: composite
steps:
- id: greet
shell: bash
env:
INPUT_NAME: \\\${{ inputs.name }}
run: |
GREETING="Hello \\$INPUT_NAME"
echo "\\$GREETING"
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
`;
const ACTION_SNIPPET_COMPOSITE_USING = `# For more on composite actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
using: composite
steps:
- shell: bash
run: echo "Hello World"
`;
const ACTION_SNIPPET_DOCKER_FULL = `name: '\${1:Action Name}'
description: '\${2:What this action does}'
inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
runs:
# For more on Docker actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
using: docker
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
image: '\${3:docker://alpine:3.20}'
env:
INPUT_NAME: \\\${{ inputs.name }}
entrypoint: '\${4:sh}'
args:
- -c
- |
GREETING="Hello \\$INPUT_NAME"
echo "\\$GREETING"
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
`;
const ACTION_SNIPPET_DOCKER_RUNS = `inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
runs:
# For more on Docker actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
using: docker
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
image: '\${1:docker://alpine:3.20}'
env:
INPUT_NAME: \\\${{ inputs.name }}
entrypoint: '\${2:sh}'
args:
- -c
- |
GREETING="Hello \\$INPUT_NAME"
echo "\\$GREETING"
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
`;
const ACTION_SNIPPET_DOCKER_USING = `# For more on Docker actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
using: docker
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
image: '\${1:docker://alpine:3.20}'
entrypoint: '\${2:sh}'
args:
- -c
- echo "Hello World"
`;
/**
* Filters action.yml `runs:` completions based on the `using:` value.
*
* When the user is completing keys under `runs:`:
* - If `using: node20` is set, only show Node.js action keys
* - If `using: composite` is set, only show composite action keys
* - If `using: docker` is set, only show Docker action keys
* - If `using:` is not set, show all keys but prioritize `using` first
*/
export function filterActionRunsCompletions(values: Value[], path: TemplateToken[], root: TemplateToken): Value[] {
// Find the runs mapping from the root
let runsMapping: MappingToken | undefined;
if (root instanceof MappingToken) {
for (let i = 0; i < root.count; i++) {
const {key, value} = root.get(i);
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
runsMapping = value;
break;
}
}
}
if (!runsMapping) {
return values;
}
// Check if the runs mapping is in our path (meaning we're completing inside it)
const isInsideRuns = path.some(token => token === runsMapping);
if (!isInsideRuns) {
return values;
}
// Find where runsMapping is in the path
const runsMappingIndex = path.indexOf(runsMapping);
if (runsMappingIndex === -1) {
return values;
}
// Check if there's anything after runsMapping in the path
// If so, we're nested deeper (e.g., inside steps sequence or a step mapping)
if (runsMappingIndex < path.length - 1) {
return values;
}
// Get the using value from the runs mapping
let usingValue: string | undefined;
for (let i = 0; i < runsMapping.count; i++) {
const {key, value} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
usingValue = value.toString();
break;
}
}
// Determine which keys to allow
let allowedKeys: Set<string>;
if (!usingValue) {
// No using value set - show all keys but prioritize "using"
return values.map(v => {
if (v.label.toLowerCase() === "using") {
return {...v, sortText: "9_using"}; // Sort after snippets (0_, 1_, 2_)
}
return v;
});
} else if (usingValue.match(/^node\d+$/i)) {
allowedKeys = ACTION_NODE_KEYS;
} else if (usingValue.toLowerCase() === "composite") {
allowedKeys = ACTION_COMPOSITE_KEYS;
} else if (usingValue.toLowerCase() === "docker") {
allowedKeys = ACTION_DOCKER_KEYS;
} else {
// Unknown using value - show all
return values;
}
// Filter to only allowed keys
return values.filter(v => allowedKeys.has(v.label.toLowerCase()));
}
/**
* Gets action scaffolding snippet completions for action.yml files.
*
* Returns snippet completions when `runs.using` is not present, offering
* three action types: Node.js, Composite, and Docker.
*
* Three variants per type:
* - "_FULL": Full scaffold with name, description, inputs, outputs, and runs
* - "_RUNS": Inputs, outputs, and runs (when name/description already exists)
* - "_USING": Minimal runs content (when inside `runs:` mapping)
*
* Which variant is shown depends on context:
* - Inside `runs:` mapping → "_USING" variants
* - At root with name/description → "_RUNS" variants
* - At root without name/description → "_FULL" variants
*/
export function getActionScaffoldingSnippets(
root: TemplateToken | undefined,
path: TemplateToken[],
position: Position,
replaceRange?: Range
): 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\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
ACTION_SNIPPET_NODEJS_USING,
position,
"0_nodejs",
replaceRange
),
createSnippetCompletion(
"Composite Action",
"Scaffold a composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
ACTION_SNIPPET_COMPOSITE_USING,
position,
"1_composite",
replaceRange
),
createSnippetCompletion(
"Docker Action",
"Scaffold a Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
ACTION_SNIPPET_DOCKER_USING,
position,
"2_docker",
replaceRange
)
];
}
// 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\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
ACTION_SNIPPET_NODEJS_RUNS,
position,
"1_nodejs",
replaceRange
),
createSnippetCompletion(
"Composite Action",
"Scaffold a composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
ACTION_SNIPPET_COMPOSITE_RUNS,
position,
"2_composite",
replaceRange
),
createSnippetCompletion(
"Docker Action",
"Scaffold a Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
ACTION_SNIPPET_DOCKER_RUNS,
position,
"3_docker",
replaceRange
)
];
}
// Show "_FULL" variants (complete scaffold)
return [
createSnippetCompletion(
"Node.js Action",
"Scaffold a complete Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
ACTION_SNIPPET_NODEJS_FULL,
position,
"1_nodejs",
replaceRange
),
createSnippetCompletion(
"Composite Action",
"Scaffold a complete composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
ACTION_SNIPPET_COMPOSITE_FULL,
position,
"2_composite",
replaceRange
),
createSnippetCompletion(
"Docker Action",
"Scaffold a complete Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
ACTION_SNIPPET_DOCKER_FULL,
position,
"3_docker",
replaceRange
)
];
}
/**
* Creates a snippet completion item.
*/
function createSnippetCompletion(
label: string,
description: string,
snippetText: string,
position: Position,
sortText: string,
replaceRange?: Range
): CompletionItem {
// Use replace if we have a range, otherwise insert at position
const textEdit = replaceRange ? TextEdit.replace(replaceRange, snippetText) : TextEdit.insert(position, snippetText);
return {
label,
labelDetails: {description: "snippet"},
kind: CompletionItemKind.Snippet,
documentation: {
kind: "markdown",
value: description
},
insertTextFormat: InsertTextFormat.Snippet,
sortText,
textEdit
};
}
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {data, DescriptionDictionary} from "@actions/expressions";
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
import {data, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
import {CompletionItem, CompletionItemKind, MarkupContent} from "vscode-languageserver-types";
import {complete, getExpressionInput} from "./complete.js";
import {ContextProviderConfig} from "./context-providers/config.js";
import {registerLogger} from "./log.js";
@@ -68,12 +68,16 @@ describe("expressions", () => {
describe("top-level auto-complete", () => {
it("single region", async () => {
const input = "run-name: ${{ | }}";
const result = await complete(...getPositionFromCursor(input));
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -108,12 +112,16 @@ describe("expressions", () => {
it("single region with existing input", async () => {
const input = "run-name: ${{ g| }}";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -126,12 +134,16 @@ describe("expressions", () => {
it("single region with existing condition", async () => {
const input = "run-name: ${{ g| == 'test' }}";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -144,12 +156,16 @@ describe("expressions", () => {
it("multiple regions with partial function", async () => {
const input = "run-name: Run a ${{ inputs.test }} one-line script ${{ from|('test') == inputs.name }}";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -162,12 +178,16 @@ describe("expressions", () => {
it("multiple regions - first region", async () => {
const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -180,12 +200,16 @@ describe("expressions", () => {
it("multiple regions", async () => {
const input = "run-name: test-${{ github }}-${{ | }}";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -395,6 +419,36 @@ jobs:
expect(result.map(x => x.label)).toEqual(["event"]);
});
it("includes both contexts and extension functions", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo
if: |`;
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const labels = result.map(x => x.label);
// Context namespaces should be present
expect(labels).toContain("github");
expect(labels).toContain("runner");
expect(labels).toContain("env");
expect(labels).toContain("steps");
// Extension functions should be present (from schema context array)
expect(labels).toContain("hashFiles");
expect(labels).toContain("always");
expect(labels).toContain("success");
expect(labels).toContain("failure");
expect(labels).toContain("cancelled");
// Built-in functions should be present
expect(labels).toContain("toJson");
expect(labels).toContain("fromJson");
expect(labels).toContain("contains");
});
});
});
@@ -1110,7 +1164,16 @@ jobs:
`;
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual(["container", "services", "status"]);
expect(result.map(x => x.label)).toEqual([
"check_run_id",
"container",
"services",
"status",
"workflow_file_path",
"workflow_ref",
"workflow_repository",
"workflow_sha"
]);
});
it("job context is suggested within a job output", async () => {
@@ -1126,7 +1189,10 @@ jobs:
run: echo hi
`;
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig,
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result.map(x => x.label)).toEqual([
"env",
"github",
@@ -1139,6 +1205,7 @@ jobs:
"steps",
"strategy",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -1250,6 +1317,7 @@ jobs:
expect(hashFiles).toBeDefined();
expect(hashFiles!.kind).toBe(CompletionItemKind.Function);
expect(hashFiles!.insertText).toBe("hashFiles()");
expect((hashFiles!.documentation as MarkupContent)?.value).toContain("Returns a single hash for the set of files");
// Not a function
const github = result.find(x => x.label === "github");
+555 -39
View File
@@ -6,6 +6,7 @@ import {getPositionFromCursor} from "./test-utils/cursor-position.js";
import {TestLogger} from "./test-utils/logger.js";
import {clearCache} from "./utils/workflow-cache.js";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
import {FeatureFlags} from "@actions/expressions/features";
registerLogger(new TestLogger());
@@ -19,9 +20,12 @@ describe("completion", () => {
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(12);
// 28 runner labels + 2 escape hatches (switch to list, switch to full syntax)
expect(result.length).toEqual(30);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
expect(labels).toContain("(switch to list)");
expect(labels).toContain("(switch to mapping)");
});
it("needs", async () => {
@@ -44,7 +48,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(9);
expect(result.length).toEqual(13);
expect(result[0].label).toEqual("concurrency");
});
@@ -56,7 +60,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(11);
expect(result.length).toEqual(27);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
@@ -70,7 +74,7 @@ jobs:
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(21);
expect(result.length).toEqual(30);
});
it("string definition completion in sequence", async () => {
@@ -95,6 +99,7 @@ jobs:
release:
types: |`;
const result = await complete(...getPositionFromCursor(input));
// Expect string values plus escape hatch to switch to list form
expect(result.map(x => x.label)).toEqual([
"created",
"deleted",
@@ -102,7 +107,8 @@ jobs:
"prereleased",
"published",
"released",
"unpublished"
"unpublished",
"(switch to list)"
]);
});
@@ -190,8 +196,11 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
expect(result).not.toBeUndefined();
expect(result.length).toEqual(1);
// Custom value plus escape hatches for list and full syntax
expect(result.length).toEqual(3);
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 () => {
@@ -212,7 +221,9 @@ jobs:
expect(result[0].label).toEqual("my-custom-label");
});
it("does not show parent mapping sibling keys", async () => {
it("does not show mapping keys or parent sibling keys in Key mode", 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
jobs:
build:
@@ -220,20 +231,21 @@ jobs:
runs-on: ubuntu-latest`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(6);
// 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");
// Only escape hatch to full syntax (container has mapping form but no sequence)
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
});
it("shows mapping keys within a new map ", async () => {
it("does not show mapping keys in Key mode when structure is uncommitted", 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
jobs:
build:
concurrency: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.map(x => x.label).sort()).toEqual(["cancel-in-progress", "group"]);
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
});
it("job key", async () => {
@@ -243,7 +255,7 @@ jobs:
runs-|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(21);
expect(result).toHaveLength(30);
});
it("job key with comment afterwards", async () => {
@@ -254,7 +266,7 @@ jobs:
#`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(21);
expect(result).toHaveLength(30);
});
it("job key with other values afterwards", async () => {
@@ -266,7 +278,10 @@ jobs:
concurrency: 'group-name'`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(20);
// Verify we get job-level completions, but concurrency is already present so excluded
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 () => {
@@ -335,7 +350,9 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(17);
// Verify we get job-level completions including runs-on variants
expect(result.length).toBeGreaterThan(20);
expect(result.some(x => x.label === "steps")).toBe(true);
});
it("complete from behind a colon will replace it", async () => {
@@ -348,7 +365,8 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(17);
// Verify we get job-level completions
expect(result.length).toBeGreaterThan(20);
const textEdit = result[0].textEdit as TextEdit;
expect(textEdit.range).toEqual({
start: {line: 5, character: 4},
@@ -447,8 +465,9 @@ jobs:
"timeout-minutes: "
]);
// One-of
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
// One-of (scalar variant)
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
});
it("custom indentation", async () => {
@@ -470,20 +489,21 @@ jobs:
"timeout-minutes: "
]);
// One-of
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
// One-of (scalar variant)
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
});
});
it("adds a new line and indentation for mapping keys when the key is given", async () => {
it("does not show mapping keys in Key mode for one-of with mapping variant", 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 result = await complete(...getPositionFromCursor(input));
expect(result.filter(x => x.label === "cancel-in-progress").map(x => x.textEdit?.newText)).toEqual([
"\n cancel-in-progress: "
]);
expect(result.filter(x => x.label === "group").map(x => x.textEdit?.newText)).toEqual(["\n group: "]);
expect(result.filter(x => x.label === "cancel-in-progress")).toEqual([]);
expect(result.filter(x => x.label === "group")).toEqual([]);
});
it("does not add new line if no key in line", async () => {
@@ -494,12 +514,15 @@ jobs:
expect(result.filter(x => x.label === "run-name").map(x => x.textEdit?.newText)).toEqual(["run-name: "]);
});
it("adds new line for nested mapping", async () => {
it("does not show mapping keys when user has started typing a scalar value", async () => {
// User typed `workflow_dispatch: in` - they've committed to a scalar value
// Should not show mapping keys like `inputs`
const input = "on:\n workflow_dispatch: in|";
const result = await complete(...getPositionFromCursor(input));
expect(result.filter(x => x.label === "inputs").map(x => x.textEdit?.newText)).toEqual(["\n inputs:\n "]);
// No mapping keys should be shown since user started typing a scalar
expect(result.filter(x => x.label === "inputs")).toEqual([]);
});
it("adds : for one-of", async () => {
@@ -507,30 +530,523 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]);
// Scalar variant inserts "types: "
const scalarVariant = result.find(x => x.label === "types" && x.labelDetails === undefined);
expect(scalarVariant?.textEdit?.newText).toEqual("types: ");
});
it("adds newline and indentation for one-of in key mode", async () => {
it("does not show mapping keys for one-of when user has typed a scalar value", async () => {
// User typed `check_run: ty` - they've committed to scalar form
// The only valid value for check_run scalar is null, so no completions
const input = "on:\n check_run: ty|";
const result = await complete(...getPositionFromCursor(input));
// When completing a one-of property in key mode (after colon on same line),
// insert newline + indentation + key + colon to create valid YAML structure
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["\n types: "]);
// 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)
expect(result.filter(x => x.label === "types")).toEqual([]);
});
it("handles mixed string and mapping completions for one-of in key mode", async () => {
it("shows only scalar options for one-of in Key mode when user hasn't committed to a type", async () => {
// At `permissions: |` user hasn't typed anything yet - show only scalar 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 result = await complete(...getPositionFromCursor(input));
// String values (read-all, write-all) should insert directly without newline
// String values (read-all, write-all) should be available
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"]);
// Mapping keys with one-of types should insert with newline and indentation
expect(result.filter(x => x.label === "actions").map(x => x.textEdit?.newText)).toEqual(["\n actions: "]);
expect(result.filter(x => x.label === "contents").map(x => x.textEdit?.newText)).toEqual(["\n contents: "]);
// Mapping keys should NOT be shown - they require a newline which is confusing inline
expect(result.filter(x => x.label === "actions")).toEqual([]);
expect(result.filter(x => x.label === "contents")).toEqual([]);
});
it("filters to scalar options when user has started typing a scalar", async () => {
// User typed `permissions: r` - they've committed to scalar form
const input = "on: push\npermissions: r|";
const result = await complete(...getPositionFromCursor(input));
// Only scalar values should be shown (filtering on 'r')
expect(result.some(x => x.label === "read-all")).toBe(true);
// Mapping keys should NOT be shown
expect(result.filter(x => x.label === "actions")).toEqual([]);
expect(result.filter(x => x.label === "contents")).toEqual([]);
});
it("shows both simple and full syntax for null+mapping one-of", async () => {
// check_run is a one-of: [null, mapping]. Show both:
// - check_run (simple, just the key with colon)
// - check_run with detail "full syntax" (ready to add mapping keys)
const input = "on:\n |";
const result = await complete(...getPositionFromCursor(input));
// Should have both check_run (scalar) and check_run with detail "full syntax"
const checkRunVariants = result.filter(x => x.label === "check_run");
expect(checkRunVariants.some(x => x.labelDetails === undefined)).toBe(true);
expect(checkRunVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true);
});
it("shows all three variants for scalar+sequence+mapping one-of", async () => {
// runs-on is a one-of: [string, sequence, mapping]
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
// Should have runs-on (scalar), runs-on with detail "list", and runs-on with detail "full syntax"
const runsOnVariants = result.filter(x => x.label === "runs-on");
expect(runsOnVariants.length).toBe(3);
expect(runsOnVariants.some(x => x.labelDetails === undefined)).toBe(true);
expect(runsOnVariants.some(x => x.labelDetails?.description === "list")).toBe(true);
expect(runsOnVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true);
});
it("generates correct insertText for one-of variants in parent mode", async () => {
// runs-on is a one-of: [string, sequence, mapping]
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
const runsOnVariants = result.filter(x => x.label === "runs-on");
// Scalar: just key with colon and space
expect(runsOnVariants.find(x => x.labelDetails === undefined)?.textEdit?.newText).toEqual("runs-on: ");
// Sequence: key with colon, newline, and list item
expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.textEdit?.newText).toEqual(
"runs-on:\n - "
);
// Mapping: key with colon, newline, and indentation for nested keys
expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.textEdit?.newText).toEqual(
"runs-on:\n "
);
});
it("generates correct insertText for one-of variants in parent mode", async () => {
// concurrency is a one-of: [string, mapping] - testing parent mode (inside mapping)
// At `concurrency:\n |`, user HAS committed to mapping structure, so mapping keys are shown
const input = "concurrency:\n |";
const result = await complete(...getPositionFromCursor(input));
// In parent mode: just key + colon + space (no leading newline)
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("group: ");
// Boolean in parent mode (cancel-in-progress): key + colon + space
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("cancel-in-progress: ");
});
it("uses sortText for ordering qualified one-of variants", async () => {
// runs-on has multiple structural types, so variants need sorting
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
const runsOnVariants = result.filter(x => x.label === "runs-on");
// Scalar: no sortText needed (sorts naturally first)
expect(runsOnVariants.find(x => x.labelDetails === undefined)?.sortText).toBeUndefined();
// Sequence and mapping: sortText controls ordering
expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.sortText).toEqual("runs-on 1");
expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.sortText).toEqual("runs-on 2");
});
it("scalar event completion inserts inline without newline", async () => {
// At `on: |` user is completing the value for 'on' key
// Scalar events like `push`, `check_run` should insert inline
const input = "on: |";
const result = await complete(...getPositionFromCursor(input));
// Scalar forms should NOT have newline - they insert inline
const push = result.find(x => x.label === "push");
expect(push?.textEdit?.newText).toEqual("push");
const checkRun = result.find(x => x.label === "check_run" && x.labelDetails === undefined);
expect(checkRun?.textEdit?.newText).toEqual("check_run");
// Full syntax form should NOT be shown in Key mode - it requires a newline
// which is confusing when typing inline. Users who want the mapping form
// can use `on (full syntax)` at the parent level.
expect(result.find(x => x.label === "check_run" && x.labelDetails?.description === "full syntax")).toBeUndefined();
});
it("filters to sequence options when user has started a sequence", async () => {
// User started a sequence with `- ` syntax - they've committed to sequence form
const input = `on: push
jobs:
build:
runs-on:
- |`;
const result = await complete(...getPositionFromCursor(input));
// Should show runner labels (sequence item values)
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
expect(result.some(x => x.label === "macos-latest")).toBe(true);
// Should NOT show mapping keys like `group` or `labels` (those are for full syntax)
expect(result.filter(x => x.label === "group")).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 at cursor position (for VS Code filtering compatibility)
const listEdit = switchToList!.textEdit as TextEdit;
const fullEdit = switchToFull!.textEdit as TextEdit;
// Main textEdit inserts newline and indented content at cursor position
expect(listEdit.newText).toEqual("\n - ");
expect(fullEdit.newText).toEqual("\n ");
// TextEdit range should be at cursor position (empty range)
expect(listEdit.range.start).toEqual({line: 3, character: 13});
expect(listEdit.range.end).toEqual({line: 3, character: 13});
expect(fullEdit.range.start).toEqual({line: 3, character: 13});
expect(fullEdit.range.end).toEqual({line: 3, character: 13});
// additionalTextEdits should clean up the key portion
expect(switchToList!.additionalTextEdits).toHaveLength(1);
expect(switchToList!.additionalTextEdits![0].range.start).toEqual({line: 3, character: 4});
expect(switchToList!.additionalTextEdits![0].range.end).toEqual({line: 3, character: 13});
expect(switchToList!.additionalTextEdits![0].newText).toEqual("runs-on:");
expect(switchToFull!.additionalTextEdits).toHaveLength(1);
expect(switchToFull!.additionalTextEdits![0].newText).toEqual("runs-on:");
});
it("permissions shows only switch to full syntax (no sequence form)", async () => {
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;
const additionalEdits = switchToList!.additionalTextEdits!;
// Main textEdit inserts newline content at cursor
expect(textEdit.newText).toEqual("\n - ");
// additionalTextEdits replaces "runs-on: " with "runs-on:"
expect(additionalEdits).toHaveLength(1);
expect(additionalEdits[0].newText).toEqual("runs-on:");
// Combined result when applied: "runs-on:\n - "
});
});
describe("runs-on mapping syntax", () => {
it("provides label completions for labels as scalar", async () => {
const input = `on: push
jobs:
build:
runs-on:
labels: |`;
const result = await complete(...getPositionFromCursor(input));
// Should show runner labels
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
expect(result.some(x => x.label === "macos-latest")).toBe(true);
expect(result.some(x => x.label === "self-hosted")).toBe(true);
});
it("provides label completions for labels as sequence item", async () => {
const input = `on: push
jobs:
build:
runs-on:
labels:
- |`;
const result = await complete(...getPositionFromCursor(input));
// Should show runner labels
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
expect(result.some(x => x.label === "macos-latest")).toBe(true);
expect(result.some(x => x.label === "self-hosted")).toBe(true);
});
it("excludes already used labels in sequence", async () => {
const input = `on: push
jobs:
build:
runs-on:
labels:
- ubuntu-latest
- |`;
const result = await complete(...getPositionFromCursor(input));
// Should NOT show ubuntu-latest since it's already in the list
expect(result.some(x => x.label === "ubuntu-latest")).toBe(false);
// But should show other labels
expect(result.some(x => x.label === "macos-latest")).toBe(true);
});
});
describe("expression completions", () => {
it("include case function when enabled", async () => {
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCaseFunction: true})
});
expect(result).not.toBeUndefined();
// Expression completions starting with 'c': case, contains
const labels = result.map(x => x.label);
expect(labels).toContain("case");
expect(labels).toContain("contains");
});
it("exclude case function when disabled", async () => {
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCaseFunction: false})
});
expect(result).not.toBeUndefined();
// Expression completions starting with 'c': contains
const labels = result.map(x => x.label);
expect(labels).not.toContain("case");
expect(labels).toContain("contains");
});
});
});
describe("schedule timezone completion", () => {
it("includes timezone for schedule", async () => {
const input = `on:
schedule:
- |`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("cron");
expect(labels).toContain("timezone");
});
});
describe("permissions copilot-requests completion", () => {
it("includes copilot-requests when allowCopilotRequestsPermission is enabled", async () => {
const input = `on: push
permissions:
|`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: true})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).toContain("copilot-requests");
});
it("excludes copilot-requests when allowCopilotRequestsPermission is disabled", async () => {
const input = `on: push
permissions:
|`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: false})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).not.toContain("copilot-requests");
});
it("excludes copilot-requests when no feature flags are provided", async () => {
const input = `on: push
permissions:
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).not.toContain("copilot-requests");
});
it("includes copilot-requests in job-level permissions when allowCopilotRequestsPermission is enabled", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
permissions:
|`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: true})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).toContain("copilot-requests");
});
it("excludes copilot-requests from job-level permissions when allowCopilotRequestsPermission is disabled", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
permissions:
|`;
const result = await complete(...getPositionFromCursor(input), {
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: false})
});
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("actions");
expect(labels).not.toContain("copilot-requests");
});
});
describe("service container command/entrypoint completion", () => {
it("suggests entrypoint and command in service container", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
services:
redis:
image: redis
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).toContain("entrypoint");
expect(labels).toContain("command");
});
it("does not suggest entrypoint and command in job container", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
container:
image: node:20
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
const labels = result.map(x => x.label);
expect(labels).not.toContain("entrypoint");
expect(labels).not.toContain("command");
});
});
+367 -49
View File
@@ -1,7 +1,13 @@
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 {FunctionInfo} from "@actions/expressions/funcs/info";
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
import {getActionSchema} from "@actions/workflow-parser/actions/action-schema";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
@@ -9,22 +15,32 @@ import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range"
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
import {File} from "@actions/workflow-parser/workflows/file";
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 {getContext, Mode} from "./context-providers/default.js";
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
import {getFunctionDescription} from "./context-providers/descriptions.js";
import {ActionContext, getActionContext} from "./context/action-context.js";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
import {validatorFunctions} from "./expression-validation/functions.js";
import {error} from "./log.js";
import {detectDocumentType} from "./utils/document-type.js";
import {isPotentiallyExpression} from "./utils/expression-detection.js";
import {findToken} from "./utils/find-token.js";
import {guessIndentation} from "./utils/indentation-guesser.js";
import {mapRange} from "./utils/range.js";
import {isPlaceholder, transform} from "./utils/transform.js";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
import {
getOrConvertActionTemplate,
getOrConvertWorkflowTemplate,
getOrParseAction,
getOrParseWorkflow
} from "./utils/workflow-cache.js";
import {Value, ValueProviderConfig} from "./value-providers/config.js";
import {defaultValueProviders} from "./value-providers/default.js";
import {DefinitionValueMode, definitionValues} from "./value-providers/definition.js";
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
export function getExpressionInput(input: string, pos: number): string {
// Find start marker around the cursor position
@@ -42,6 +58,7 @@ export type CompletionConfig = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
fileProvider?: FileProvider;
featureFlags?: FeatureFlags;
};
export async function complete(
@@ -65,43 +82,105 @@ export async function complete(
content: newDoc.getText()
};
const parsedWorkflow = fetchOrParseWorkflow(file, textDocument.uri, true);
if (!parsedWorkflow.value) {
// Determine document type - unknown defaults to workflow (backwards compatibility)
const isAction = detectDocumentType(textDocument.uri) === "action";
// Parse the document
const parsedTemplate = isAction
? getOrParseAction(file, textDocument.uri, true)
: getOrParseWorkflow(file, textDocument.uri, true);
if (!parsedTemplate.value) {
return [];
}
const template = await fetchOrConvertWorkflowTemplate(
parsedWorkflow.context,
parsedWorkflow.value,
textDocument.uri,
config,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion
const schema = isAction ? getActionSchema() : getWorkflowSchema();
const {token, keyToken, parent, path} = findToken(newPos, parsedTemplate.value);
// Build context for position-aware completions (e.g., steps.*, needs.*, inputs.*)
let workflowContext: WorkflowContext | undefined;
let actionContext: ActionContext | undefined;
if (isAction) {
const actionTemplate = getOrConvertActionTemplate(
parsedTemplate.context,
parsedTemplate.value,
textDocument.uri,
{errorPolicy: ErrorPolicy.TryConversion},
true
);
actionContext = getActionContext(textDocument.uri, actionTemplate, path);
} else {
const workflowTemplate = await getOrConvertWorkflowTemplate(
parsedTemplate.context,
parsedTemplate.value,
textDocument.uri,
config,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion,
featureFlags: config?.featureFlags
},
true
);
workflowContext = workflowTemplate ? getWorkflowContext(textDocument.uri, workflowTemplate, path) : undefined;
}
// Expression completions
if (token && (isBasicExpression(token) || isPotentiallyExpression(token, isAction))) {
const allowedContext = token.definitionInfo?.allowedContext || [];
const {namedContexts, functions: extensionFunctions} = splitAllowedContext(allowedContext);
const context = isAction
? getActionExpressionContext(namedContexts, config?.contextProviderConfig, actionContext, Mode.Completion)
: await getWorkflowExpressionContext(
namedContexts,
config?.contextProviderConfig,
workflowContext,
Mode.Completion
);
// Populate function descriptions for completion display
for (const func of extensionFunctions) {
func.description = getFunctionDescription(func.name);
}
);
const {token, keyToken, parent, path} = findToken(newPos, parsedWorkflow.value);
const workflowContext = getWorkflowContext(textDocument.uri, template, path);
// If we are inside an expression, take a different code-path. The workflow parser does not correctly create
// expression nodes for invalid expressions and during editing expressions are invalid most of the time.
if (token) {
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
const allowedContext = token.definitionInfo?.allowedContext || [];
const context = await getContext(allowedContext, config?.contextProviderConfig, workflowContext, Mode.Completion);
return getExpressionCompletionItems(token, context, newPos);
}
return getExpressionCompletionItems(token, context, extensionFunctions, newPos, config?.featureFlags);
}
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
const indentString = " ".repeat(indentation.tabSize);
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
// YAML key/value completions
let values = await getValues(
token,
keyToken,
parent,
config?.valueProviderConfig,
workflowContext,
indentString,
schema
);
// Filter action.yml `runs:` completions based on `using:` value
if (isAction && parsedTemplate.value) {
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
}
// Filter `copilot-requests` from permissions completions when the feature flag is disabled
if (
!config?.featureFlags?.isEnabled("allowCopilotRequestsPermission") &&
parent?.definition?.key === "permissions-mapping"
) {
values = values.filter(v => v.label !== "copilot-requests");
}
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
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;
if (token?.range) {
// Prefer the token's range since it accounts for YAML syntax like quotes
replaceRange = mapRange(token.range);
} else if (!token) {
// Not a valid token, create a range from the current position
@@ -124,30 +203,72 @@ export async function complete(
}
}
return values.map(value => {
// Get action scaffolding snippets if applicable
let actionSnippets: CompletionItem[] = [];
if (isAction) {
actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position, replaceRange);
}
// Convert values to LSP CompletionItems
const completionItems = values.map(value => {
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);
}
// Convert additionalTextEdits if present
let additionalTextEdits: TextEdit[] | undefined;
if (value.additionalTextEdits) {
additionalTextEdits = value.additionalTextEdits.map(edit => TextEdit.replace(edit.range, edit.newText));
}
const item: CompletionItem = {
label: value.label,
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
filterText: value.filterText,
sortText: value.sortText,
documentation: value.description && {
kind: "markdown",
value: value.description
},
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
textEdit,
additionalTextEdits
};
return item;
});
// Add action scaffolding snippets if available
return [...completionItems, ...actionSnippets];
}
/**
* Retrieves completion values for a token based on value providers and definitions.
*
* This function determines which values to suggest for auto-completion by:
* 1. First checking for custom value providers configured for the token's definition key
* 2. Then checking for default value providers for the token's definition key
* 3. Finally falling back to values derived from the token's schema definition
*
* The results are filtered to exclude duplicates (e.g., keys already defined in a mapping
* or values already present in a sequence) and sorted alphabetically.
*/
async function getValues(
token: TemplateToken | null,
keyToken: TemplateToken | null,
parent: TemplateToken | null,
valueProviderConfig: ValueProviderConfig | undefined,
workflowContext: WorkflowContext,
indentation: string
workflowContext: WorkflowContext | undefined,
indentation: string,
schema: TemplateSchema
): Promise<Value[]> {
if (!parent) {
return [];
@@ -158,20 +279,23 @@ async function getValues(
// Use the value providers from the parent if the current key is null
const valueProviderToken = keyToken || parent;
const customValueProvider =
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
if (customValueProvider) {
const customValues = await customValueProvider.get(workflowContext, existingValues);
if (customValues) {
return filterAndSortCompletionOptions(customValues, existingValues);
// Value providers require workflow context - only use them for workflows
if (workflowContext) {
const customValueProvider =
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
if (customValueProvider) {
const customValues = await customValueProvider.get(workflowContext, existingValues);
if (customValues) {
return filterAndSortCompletionOptions(customValues, existingValues);
}
}
}
const defaultValueProvider =
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
if (defaultValueProvider) {
const values = await defaultValueProvider.get(workflowContext, existingValues);
return filterAndSortCompletionOptions(values, existingValues);
const defaultValueProvider =
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
if (defaultValueProvider) {
const values = await defaultValueProvider.get(workflowContext, existingValues);
return filterAndSortCompletionOptions(values, existingValues);
}
}
// Use the definition if there are no value providers
@@ -180,10 +304,202 @@ async function getValues(
return [];
}
const values = definitionValues(def, indentation, keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent);
// When a schema allows multiple formats (e.g., `runs-on` can be a string OR a mapping),
// only suggest completions that match what the user has already started typing.
// For example, if they've started a mapping, don't suggest string values.
const tokenStructure = getTokenStructure(token);
const values = definitionValues(
def,
indentation,
keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent,
tokenStructure,
schema
);
return filterAndSortCompletionOptions(values, existingValues);
}
/**
* Determines what YAML structure the user has committed to, if any.
*
* Returns:
* - "mapping" if the user has started a key-value structure (e.g., `runs-on:\n group: |`)
* - "sequence" if the user has started a list (e.g., `runs-on:\n - |`)
* - "scalar" if the user has started typing a plain value (e.g., `runs-on: ubuntu-|`)
* - undefined if the user hasn't committed yet (e.g., `runs-on: |` with nothing typed)
*/
function getTokenStructure(token: TemplateToken | null): TokenStructure {
if (!token) {
return undefined;
}
switch (token.templateTokenType) {
case TokenType.Mapping:
return "mapping";
case TokenType.Sequence:
return "sequence";
case TokenType.Null:
// Null means `key: ` with nothing - user hasn't committed to a type yet
return undefined;
case TokenType.String: {
// Empty string means `key: |` - user hasn't committed yet
// Non-empty string means user has started typing a scalar value
const stringToken = token.assertString("getTokenStructure expected string token");
if (stringToken.value === "") {
return undefined;
}
return "scalar";
}
case TokenType.Boolean:
case TokenType.Number:
return "scalar";
default:
return undefined;
}
}
/**
* 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,
schema: TemplateSchema
): 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 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 [];
}
// For VS Code compatibility, we use a cursor-position range for the main textEdit
// and additionalTextEdits to clean up the key portion. This prevents VS Code from
// filtering out escape hatches based on the key text (e.g., "runs-on: ").
//
// Main textEdit: insert at cursor position (newline + indented content)
// additionalTextEdits: replace "key: " with "key:" (removes trailing space)
const cursorRange = {
start: {line: position.line, character: position.character},
end: {line: position.line, character: position.character}
};
// Range from key start to cursor - used to replace "key: " with "key:" in additionalTextEdits
const keyToCursorRange = {
start: {line: keyRange.start.line - 1, character: keyRange.start.column - 1},
end: {line: position.line, character: position.character}
};
if (buckets.sequence) {
results.push({
label: "(switch to list)",
sortText: "zzz_switch_1",
textEdit: {
range: cursorRange,
newText: `\n${indentation}- `
},
additionalTextEdits: [
{
range: keyToCursorRange,
newText: `${keyName}:`
}
]
});
}
if (buckets.mapping) {
results.push({
label: "(switch to mapping)",
sortText: "zzz_switch_2",
textEdit: {
range: cursorRange,
newText: `\n${indentation}`
},
additionalTextEdits: [
{
range: keyToCursorRange,
newText: `${keyName}:`
}
]
});
}
return results;
}
/**
* Collects values that are already present in the current context, so they can be
* excluded from completion suggestions.
*
* For sequences (lists), returns all existing items. For example, if the user has:
* labels:
* - bug
* - |
* This returns {"bug"} so we don't suggest "bug" again.
*
* For mappings, returns all existing keys. For example, if the user has:
* jobs:
* build:
* runs-on: ubuntu-latest
* |
* This returns {"runs-on"} so we don't suggest "runs-on" again.
*/
export function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
// For incomplete YAML, we may only have a parent token
if (token) {
@@ -223,7 +539,9 @@ export function getExistingValues(token: TemplateToken | null, parent: TemplateT
function getExpressionCompletionItems(
token: TemplateToken,
context: DescriptionDictionary,
pos: Position
extensionFunctions: FunctionInfo[],
pos: Position,
featureFlags?: FeatureFlags
): CompletionItem[] {
if (!token.range) {
return [];
@@ -242,8 +560,8 @@ function getExpressionCompletionItems(
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
try {
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
mapExpressionCompletionItem(item, currentInput[cursorOffset])
return completeExpression(expressionInput, context, extensionFunctions, validatorFunctions, featureFlags).map(
item => mapExpressionCompletionItem(item, currentInput[cursorOffset])
);
} catch (e) {
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
@@ -253,7 +571,7 @@ function getExpressionCompletionItems(
function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<string>) {
options = options.filter(x => !existingValues?.has(x.label));
options.sort((a, b) => a.label.localeCompare(b.label));
options.sort((a, b) => (a.sortText ?? a.label).localeCompare(b.sortText ?? b.label));
return options;
}
@@ -1,8 +1,8 @@
import {DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "../context/workflow-context.js";
import {getContext, Mode} from "./default.js";
import {getWorkflowExpressionContext, Mode} from "./default.js";
describe("getContext", () => {
describe("getWorkflowExpressionContext", () => {
const emptyWorkflowContext: WorkflowContext = {
uri: "test.yaml",
template: undefined
@@ -10,7 +10,7 @@ describe("getContext", () => {
describe("when no contextProviderConfig is provided", () => {
it("should mark secrets context as incomplete", async () => {
const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
const result = await getWorkflowExpressionContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets") as DescriptionDictionary;
expect(secretsContext).toBeDefined();
@@ -18,7 +18,7 @@ describe("getContext", () => {
});
it("should mark vars context as incomplete", async () => {
const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
const result = await getWorkflowExpressionContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars") as DescriptionDictionary;
expect(varsContext).toBeDefined();
@@ -26,7 +26,12 @@ describe("getContext", () => {
});
it("should not mark other contexts as incomplete", async () => {
const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation);
const result = await getWorkflowExpressionContext(
["env", "github"],
undefined,
emptyWorkflowContext,
Mode.Validation
);
const envContext = result.get("env") as DescriptionDictionary;
const githubContext = result.get("github") as DescriptionDictionary;
@@ -48,7 +53,7 @@ describe("getContext", () => {
getContext: () => Promise.resolve(providedContext)
};
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const result = await getWorkflowExpressionContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets");
expect(secretsContext).toBe(providedContext);
@@ -63,7 +68,7 @@ describe("getContext", () => {
getContext: () => Promise.resolve(providedContext)
};
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const result = await getWorkflowExpressionContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars");
expect(varsContext).toBe(providedContext);
@@ -77,7 +82,7 @@ describe("getContext", () => {
getContext: () => Promise.resolve(undefined)
};
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const result = await getWorkflowExpressionContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets") as DescriptionDictionary;
expect(secretsContext.complete).toBe(false);
@@ -88,7 +93,7 @@ describe("getContext", () => {
getContext: () => Promise.resolve(undefined)
};
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const result = await getWorkflowExpressionContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars") as DescriptionDictionary;
expect(varsContext.complete).toBe(false);
+179 -29
View File
@@ -1,5 +1,6 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {Kind} from "@actions/expressions/data/expressiondata";
import {ActionContext, getActionInputs, getActionStepIdsBefore} from "../context/action-context.js";
import {WorkflowContext} from "../context/workflow-context.js";
import {ContextProviderConfig} from "./config.js";
import {getDescription, RootContext} from "./descriptions.js";
@@ -12,7 +13,6 @@ import {getMatrixContext} from "./matrix.js";
import {getNeedsContext} from "./needs.js";
import {getSecretsContext} from "./secrets.js";
import {getStepsContext} from "./steps.js";
import {getStrategyContext} from "./strategy.js";
// ContextValue is the type of the value returned by a context provider
// Null indicates that the context provider doesn't have any value to provide
@@ -24,10 +24,13 @@ export enum Mode {
Hover
}
export async function getContext(
/**
* Build expression context for workflow files (e.g., github.*, steps.*, needs.*)
*/
export async function getWorkflowExpressionContext(
names: string[],
config: ContextProviderConfig | undefined,
workflowContext: WorkflowContext,
workflowContext: WorkflowContext | undefined,
mode: Mode
): Promise<DescriptionDictionary> {
const context = new DescriptionDictionary();
@@ -41,7 +44,9 @@ export async function getContext(
continue;
}
const remoteValue = await config?.getContext(contextName, value, workflowContext, mode);
const remoteValue = workflowContext
? await config?.getContext(contextName, value, workflowContext, mode)
: undefined;
if (remoteValue) {
value = remoteValue;
} else if (contextName === "secrets" || contextName === "vars") {
@@ -57,61 +62,206 @@ export async function getContext(
return context;
}
function getDefaultContext(name: string, workflowContext: WorkflowContext, mode: Mode): ContextValue | undefined {
/**
* Maps context name to its provider (e.g., "steps" -> getStepsContext)
*/
function getDefaultContext(
name: string,
workflowContext: WorkflowContext | undefined,
mode: Mode
): ContextValue | undefined {
switch (name) {
case "env":
return getEnvContext(workflowContext);
return workflowContext ? getEnvContext(workflowContext) : new DescriptionDictionary();
case "github":
return getGithubContext(workflowContext, mode);
case "inputs":
return getInputsContext(workflowContext);
return workflowContext ? getInputsContext(workflowContext) : new DescriptionDictionary();
case "reusableWorkflowJob":
case "job":
return getJobContext(workflowContext);
return workflowContext ? getJobContext(workflowContext) : new DescriptionDictionary();
case "jobs":
return getJobsContext(workflowContext);
return workflowContext ? getJobsContext(workflowContext) : new DescriptionDictionary();
case "matrix":
return getMatrixContext(workflowContext, mode);
return workflowContext ? getMatrixContext(workflowContext, mode) : new DescriptionDictionary();
case "needs":
return getNeedsContext(workflowContext);
return workflowContext ? getNeedsContext(workflowContext) : new DescriptionDictionary();
case "runner":
return objectToDictionary({
arch: "X64",
debug: "1",
environment: "github-hosted",
name: "GitHub Actions 2",
os: "Linux",
temp: "/home/runner/work/_temp",
tool_cache: "/opt/hostedtoolcache",
workspace: "/home/runner/work/repo"
});
return getRunnerContext();
case "secrets":
return getSecretsContext(workflowContext, mode);
return workflowContext ? getSecretsContext(workflowContext, mode) : new DescriptionDictionary();
case "steps":
return getStepsContext(workflowContext);
return workflowContext ? getStepsContext(workflowContext) : new DescriptionDictionary();
case "strategy":
return getStrategyContext(workflowContext);
return getStrategyContext();
}
return undefined;
}
function objectToDictionary(object: {[key: string]: string}): DescriptionDictionary {
const dictionary = new DescriptionDictionary();
/**
* Returns the strategy context with default values (fail-fast, job-index, etc.)
*/
function getStrategyContext(): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
return new DescriptionDictionary(
{key: "fail-fast", value: new data.BooleanData(true), description: getDescription("strategy", "fail-fast")},
{key: "job-index", value: new data.NumberData(0), description: getDescription("strategy", "job-index")},
{key: "job-total", value: new data.NumberData(1), description: getDescription("strategy", "job-total")},
{key: "max-parallel", value: new data.NumberData(1), description: getDescription("strategy", "max-parallel")}
);
}
for (const key in object) {
dictionary.add(key, new data.StringData(object[key]));
/**
* Returns the runner context with environment info (arch, os, temp, workspace, etc.)
*/
function getRunnerContext(): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
return new DescriptionDictionary(
{key: "arch", value: new data.StringData("X64"), description: getDescription("runner", "arch")},
{key: "debug", value: new data.StringData("1"), description: getDescription("runner", "debug")},
{
key: "environment",
value: new data.StringData("github-hosted"),
description: getDescription("runner", "environment")
},
{key: "name", value: new data.StringData("GitHub Actions 2"), description: getDescription("runner", "name")},
{key: "os", value: new data.StringData("Linux"), description: getDescription("runner", "os")},
{key: "temp", value: new data.StringData("/home/runner/work/_temp"), description: getDescription("runner", "temp")},
{
key: "tool_cache",
value: new data.StringData("/opt/hostedtoolcache"),
description: getDescription("runner", "tool_cache")
},
{
key: "workspace",
value: new data.StringData("/home/runner/work/repo"),
description: getDescription("runner", "workspace")
}
);
}
/**
* Get context for expression completion in action.yml files.
* Actions have a more limited set of contexts available compared to workflows.
*/
export function getActionExpressionContext(
names: string[],
config: ContextProviderConfig | undefined,
actionContext: ActionContext | undefined,
mode: Mode
): DescriptionDictionary {
const context = new DescriptionDictionary();
for (const contextName of names) {
const value = getDefaultActionContext(contextName, actionContext, mode);
if (value) {
context.add(contextName, value, getDescription(RootContext, contextName));
}
}
return dictionary;
return context;
}
/**
* Maps context name to its provider for action.yml files (e.g., "inputs" -> getActionInputsContext)
*/
function getDefaultActionContext(
name: string,
actionContext: ActionContext | undefined,
mode: Mode
): ContextValue | undefined {
switch (name) {
case "inputs":
// Return empty dictionary if no context - still allows completion, just without specific input names
return actionContext ? getActionInputsContext(actionContext) : new DescriptionDictionary();
case "steps":
// Return empty dictionary if no context - still allows completion, just without specific step IDs
return actionContext ? getActionStepsContext(actionContext) : new DescriptionDictionary();
case "github":
// Use the same github context but without workflow-specific event info
// Actions inherit the event context from the calling workflow at runtime
return getGithubContext(undefined, mode);
case "runner":
return getRunnerContext();
case "env": {
// Actions can access env but we don't know what env vars the calling workflow defines
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
const envContext = new DescriptionDictionary();
envContext.complete = false;
return envContext;
}
case "job": {
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
const jobContext = new DescriptionDictionary();
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
const containerContext = new DescriptionDictionary();
containerContext.add("id", new data.StringData(""), getDescription("job", "container.id"));
containerContext.add("network", new data.StringData(""), getDescription("job", "container.network"));
jobContext.add("container", containerContext, getDescription("job", "container"));
jobContext.add("services", new DescriptionDictionary(), getDescription("job", "services"));
return jobContext;
}
case "strategy":
return getStrategyContext();
case "matrix": {
// Actions can access matrix context at runtime but we don't know the calling workflow's matrix
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
const matrixContext = new DescriptionDictionary();
matrixContext.complete = false;
return matrixContext;
}
}
return undefined;
}
/**
* Get inputs context for action files based on defined inputs
*/
function getActionInputsContext(actionContext: ActionContext): DescriptionDictionary {
const dict = new DescriptionDictionary();
const inputs = getActionInputs(actionContext.template);
for (const input of inputs) {
dict.add(input.id, new data.StringData(""), input.description || "");
}
return dict;
}
/**
* Get steps context for composite action files based on step IDs
*/
function getActionStepsContext(actionContext: ActionContext): DescriptionDictionary {
const dict = new DescriptionDictionary();
const stepIds = getActionStepIdsBefore(actionContext);
for (const stepId of stepIds) {
const stepDict = new DescriptionDictionary();
stepDict.add("outputs", new DescriptionDictionary(), getDescription("steps", "outputs"));
stepDict.add("outcome", new data.StringData("success"), getDescription("steps", "outcome"));
stepDict.add("conclusion", new data.StringData("success"), getDescription("steps", "conclusion"));
dict.add(stepId, stepDict, `Step: ${stepId}`);
}
return dict;
}
@@ -105,13 +105,6 @@
"job": {
"description": "The [`job_id`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_id) of the current job.\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
},
"job_workflow_sha": {
"description": "For jobs using a reusable workflow, the commit SHA for the reusable workflow file.",
"versions": {
"ghes": ">=3.9",
"ghae": ">=3.9"
}
},
"path": {
"description": "Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"[Workflow commands for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-system-path).\""
},
@@ -198,6 +191,47 @@
"description": "The default working directory on the runner for steps, and the default location of your repository when using the [`checkout`](https://github.com/actions/checkout) action."
}
},
"job": {
"container": {
"description": "Information about the job's container. For more information about containers, see \"[Running jobs in a container](https://docs.github.com/actions/using-jobs/running-jobs-in-a-container).\""
},
"container.id": {
"description": "The ID of the container."
},
"container.network": {
"description": "The ID of the container network. The runner creates the network used by all containers in a job."
},
"services": {
"description": "The service containers created for a job. For more information about service containers, see \"[Using service containers](https://docs.github.com/actions/using-containerized-services/about-service-containers).\""
},
"services.<service_id>.id": {
"description": "The ID of the service container."
},
"services.<service_id>.network": {
"description": "The ID of the service container network. The runner creates the network used by all containers in a job."
},
"services.<service_id>.ports": {
"description": "The exposed ports of the service container."
},
"status": {
"description": "The current status of the job. Possible values are `success`, `failure`, or `cancelled`."
},
"check_run_id": {
"description": "The unique identifier of the check run for this job."
},
"workflow_file_path": {
"description": "The path of the workflow file that contains the job. For example, `.github/workflows/my-workflow.yml`."
},
"workflow_ref": {
"description": "The ref path to the workflow file that contains the job. For example, `octocat/hello-world/.github/workflows/my-workflow.yml@refs/heads/my_branch`."
},
"workflow_repository": {
"description": "The owner and repository name of the workflow file that contains the job. For example, `octocat/Hello-World`."
},
"workflow_sha": {
"description": "The commit SHA of the workflow file that contains the job."
}
},
"secrets": {
"GITHUB_TOKEN": {
"description": "Automatically created token for each workflow run. For more information, see \"[Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication).\""
@@ -7,7 +7,10 @@ import {getDescription} from "./descriptions.js";
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads.js";
import {getInputsContext} from "./inputs.js";
export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
/**
* Returns the github context with properties like actor, ref, sha, event, etc.
*/
export function getGithubContext(workflowContext: WorkflowContext | undefined, mode: Mode): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
const keys = [
"action",
@@ -26,7 +29,6 @@ export function getGithubContext(workflowContext: WorkflowContext, mode: Mode):
"graphql_url",
"head_ref",
"job",
"job_workflow_sha",
"path",
"ref",
"ref_name",
@@ -73,7 +75,10 @@ export function getGithubContext(workflowContext: WorkflowContext, mode: Mode):
);
}
function getEventContext(workflowContext: WorkflowContext, mode: Mode): ExpressionData {
/**
* Builds the github.event context based on workflow trigger configuration.
*/
function getEventContext(workflowContext: WorkflowContext | undefined, mode: Mode): ExpressionData {
const d = new DescriptionDictionary();
const eventsConfig = workflowContext?.template?.events;
@@ -0,0 +1,197 @@
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {getJobContext} from "./job.js";
function stringToToken(value: string): StringToken {
return new StringToken(undefined, undefined, value, undefined);
}
describe("job context", () => {
it("returns empty context when no job", () => {
const workflowContext = {} as WorkflowContext;
const context = getJobContext(workflowContext);
// When there's no job, context is empty
expect(context.pairs().length).toBe(0);
});
it("returns status, check_run_id, and workflow fields when job has no container or services", () => {
const workflowContext = {job: {}} as WorkflowContext;
const context = getJobContext(workflowContext);
expect(context.get("status")).toBeDefined();
expect(context.get("check_run_id")).toBeDefined();
expect(context.get("workflow_ref")).toBeDefined();
expect(context.get("workflow_sha")).toBeDefined();
expect(context.get("workflow_repository")).toBeDefined();
expect(context.get("workflow_file_path")).toBeDefined();
expect(context.get("container")).toBeUndefined();
expect(context.get("services")).toBeUndefined();
});
describe("container context", () => {
it("includes container with id and network when container is defined", () => {
const containerToken = new MappingToken(undefined, undefined, undefined);
containerToken.add(stringToToken("image"), stringToToken("node:18"));
const workflowContext = {
job: {container: containerToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const container = context.get("container");
expect(container).toBeDefined();
if (!container) return;
expect(isDescriptionDictionary(container)).toBe(true);
const containerDict = container as DescriptionDictionary;
expect(containerDict.get("id")).toBeDefined();
expect(containerDict.get("network")).toBeDefined();
expect(containerDict.get("ports")).toBeUndefined(); // job container has no ports
});
it("container has descriptions", () => {
const containerToken = new MappingToken(undefined, undefined, undefined);
containerToken.add(stringToToken("image"), stringToToken("node:18"));
const workflowContext = {
job: {container: containerToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const containerDescription = context.getDescription("container");
expect(containerDescription).toBeDefined();
const containerDict = context.get("container") as DescriptionDictionary;
expect(containerDict.getDescription("id")).toBeDefined();
expect(containerDict.getDescription("network")).toBeDefined();
});
});
describe("services context", () => {
it("includes services with id, network, and ports", () => {
const redisToken = new MappingToken(undefined, undefined, undefined);
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
const servicesToken = new MappingToken(undefined, undefined, undefined);
servicesToken.add(stringToToken("redis"), redisToken);
const workflowContext = {
job: {services: servicesToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const services = context.get("services");
expect(services).toBeDefined();
if (!services) return;
expect(isDescriptionDictionary(services)).toBe(true);
const servicesDict = services as DescriptionDictionary;
const redis = servicesDict.get("redis");
expect(redis).toBeDefined();
if (!redis) return;
expect(isDescriptionDictionary(redis)).toBe(true);
const redisDict = redis as DescriptionDictionary;
expect(redisDict.get("id")).toBeDefined();
expect(redisDict.get("network")).toBeDefined();
expect(redisDict.get("ports")).toBeDefined(); // services have ports
});
it("parses service ports in host:container format", () => {
const portsSequence = new SequenceToken(undefined, undefined, undefined);
portsSequence.add(stringToToken("6379:6379"));
portsSequence.add(stringToToken("8080:80"));
const redisToken = new MappingToken(undefined, undefined, undefined);
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
redisToken.add(stringToToken("ports"), portsSequence);
const servicesToken = new MappingToken(undefined, undefined, undefined);
servicesToken.add(stringToToken("redis"), redisToken);
const workflowContext = {
job: {services: servicesToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const services = context.get("services") as DescriptionDictionary;
const redis = services.get("redis") as DescriptionDictionary;
const ports = redis.get("ports") as DescriptionDictionary;
// Container ports should be the keys (second part of host:container)
expect(ports.get("6379")).toBeDefined();
expect(ports.get("80")).toBeDefined();
});
it("parses service ports in single port format", () => {
const portsSequence = new SequenceToken(undefined, undefined, undefined);
portsSequence.add(stringToToken("6379"));
const redisToken = new MappingToken(undefined, undefined, undefined);
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
redisToken.add(stringToToken("ports"), portsSequence);
const servicesToken = new MappingToken(undefined, undefined, undefined);
servicesToken.add(stringToToken("redis"), redisToken);
const workflowContext = {
job: {services: servicesToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const services = context.get("services") as DescriptionDictionary;
const redis = services.get("redis") as DescriptionDictionary;
const ports = redis.get("ports") as DescriptionDictionary;
// Single port format uses the port as the key
expect(ports.get("6379")).toBeDefined();
});
it("services have descriptions", () => {
const redisToken = new MappingToken(undefined, undefined, undefined);
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
const servicesToken = new MappingToken(undefined, undefined, undefined);
servicesToken.add(stringToToken("redis"), redisToken);
const workflowContext = {
job: {services: servicesToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const servicesDescription = context.getDescription("services");
expect(servicesDescription).toBeDefined();
const services = context.get("services") as DescriptionDictionary;
const redis = services.get("redis") as DescriptionDictionary;
expect(redis.getDescription("id")).toBeDefined();
expect(redis.getDescription("network")).toBeDefined();
expect(redis.getDescription("ports")).toBeDefined();
});
});
describe("workflow context fields", () => {
it("includes workflow context fields with descriptions", () => {
const workflowContext = {job: {}} as WorkflowContext;
const context = getJobContext(workflowContext);
expect(context.get("workflow_ref")).toBeDefined();
expect(context.get("workflow_sha")).toBeDefined();
expect(context.get("workflow_repository")).toBeDefined();
expect(context.get("workflow_file_path")).toBeDefined();
expect(context.getDescription("workflow_ref")).toBeDefined();
expect(context.getDescription("workflow_sha")).toBeDefined();
expect(context.getDescription("workflow_repository")).toBeDefined();
expect(context.getDescription("workflow_file_path")).toBeDefined();
});
});
});
+43 -24
View File
@@ -2,7 +2,11 @@ import {data, DescriptionDictionary} from "@actions/expressions";
import {isMapping, isSequence} from "@actions/workflow-parser";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {getDescription} from "./descriptions.js";
/**
* Returns the job context with container, services, status, check_run_id, and workflow identity fields.
*/
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
const jobContext = new DescriptionDictionary();
@@ -15,7 +19,7 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
const jobContainer = job.container;
if (jobContainer && isMapping(jobContainer)) {
const containerContext = createContainerContext(jobContainer, false);
jobContext.add("container", containerContext);
jobContext.add("container", containerContext, getDescription("job", "container"));
}
// Services
@@ -29,39 +33,54 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
const serviceContext = createContainerContext(service.value, true);
servicesContext.add(service.key.toString(), serviceContext);
}
jobContext.add("services", servicesContext);
jobContext.add("services", servicesContext, getDescription("job", "services"));
}
// Status
jobContext.add("status", new data.Null());
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
// Check run ID
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
// Workflow context fields (populated at runtime for reusable workflow jobs)
jobContext.add("workflow_file_path", new data.StringData(""), getDescription("job", "workflow_file_path"));
jobContext.add("workflow_ref", new data.StringData(""), getDescription("job", "workflow_ref"));
jobContext.add("workflow_repository", new data.StringData(""), getDescription("job", "workflow_repository"));
jobContext.add("workflow_sha", new data.StringData(""), getDescription("job", "workflow_sha"));
return jobContext;
}
function createContainerContext(container: MappingToken, isServices: boolean): data.Dictionary {
const containerContext = new data.Dictionary();
for (const {key, value} of container) {
if (isSequence(value)) {
// service ports are the only thing that is part of the job context
if (key.toString() !== "ports") {
continue;
}
const ports = new data.Dictionary();
for (const item of value) {
// We can determine the context mapping fully only if the port is defined
// as a mapping (i.e. <port1>:<port2>), single ports are assigned randomly
const portParts = item.toString().split(":");
if (isServices && portParts.length === 2) {
ports.add(portParts[1], new data.StringData(portParts[0]));
} else {
// If the port isn't a mapping, just use null
ports.add(portParts[0], new data.Null());
function createContainerContext(container: MappingToken, isServices: boolean): DescriptionDictionary {
const containerContext = new DescriptionDictionary();
// id and network are always available
containerContext.add(
"id",
new data.StringData(""),
getDescription("job", isServices ? "services.<service_id>.id" : "container.id")
);
containerContext.add(
"network",
new data.StringData(""),
getDescription("job", isServices ? "services.<service_id>.network" : "container.network")
);
// ports are only available for service containers (not job container)
if (isServices) {
const ports = new DescriptionDictionary();
for (const {key, value} of container) {
if (key.toString() === "ports" && isSequence(value)) {
for (const item of value) {
const portParts = item.toString().split(":");
// The key is the container port (second part if host:container format)
const containerPort = portParts.length === 2 ? portParts[1] : portParts[0];
ports.add(containerPort, new data.StringData(""));
}
}
containerContext.add(key.toString(), ports);
}
containerContext.add("ports", ports, getDescription("job", "services.<service_id>.ports"));
}
containerContext.add("id", new data.Null());
containerContext.add("network", new data.Null());
return containerContext;
}
@@ -1,126 +0,0 @@
import {data} from "@actions/expressions";
import {Job} from "@actions/workflow-parser/model/workflow-template";
import {BooleanToken} from "@actions/workflow-parser/templates/tokens/boolean-token";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {NumberToken} from "@actions/workflow-parser/templates/tokens/number-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {getStrategyContext} from "./strategy.js";
function stringToToken(value: string) {
return new StringToken(undefined, undefined, value, undefined);
}
function boolToToken(value: boolean) {
return new BooleanToken(undefined, undefined, value, undefined);
}
function numberToToken(value: number) {
return new NumberToken(undefined, undefined, value, undefined);
}
function contextFromStrategy(strategy?: TemplateToken) {
return {
job: {
strategy: strategy
}
} as WorkflowContext;
}
describe("strategy context", () => {
describe("no strategy defined", () => {
it("returns defaults when job is undefined", () => {
const workflowContext = {} as WorkflowContext;
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
it("returns defaults when strategy is undefined", () => {
const job = {} as Job;
const workflowContext = {job} as WorkflowContext;
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
it("returns defaults when strategy is not a mapping", () => {
const workflowContext = contextFromStrategy(stringToToken("hello"));
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
});
describe("strategy defined with partial properties", () => {
it("uses specified fail-fast, defaults for others", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("fail-fast"), boolToToken(false));
const workflowContext = contextFromStrategy(strategy);
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
it("uses specified max-parallel, defaults for others", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("max-parallel"), numberToToken(5));
const workflowContext = contextFromStrategy(strategy);
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(5));
});
it("only has matrix defined, all strategy properties use defaults", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
const matrix = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), matrix);
const workflowContext = contextFromStrategy(strategy);
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
});
describe("strategy with all properties defined", () => {
it("uses all specified values", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("fail-fast"), boolToToken(false));
strategy.add(stringToToken("max-parallel"), numberToToken(3));
const workflowContext = contextFromStrategy(strategy);
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
// job-index and job-total are runtime values, not specified in YAML
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(3));
});
});
});
@@ -1,49 +0,0 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {isMapping, isScalar, isString} from "@actions/workflow-parser";
import {WorkflowContext} from "../context/workflow-context.js";
import {scalarToData} from "../utils/scalar-to-data.js";
// Default strategy values when no strategy block is defined
const DEFAULT_STRATEGY = {
"fail-fast": new data.BooleanData(true),
"job-index": new data.NumberData(0),
"job-total": new data.NumberData(1),
"max-parallel": new data.NumberData(1)
};
export function getStrategyContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
const keys = ["fail-fast", "job-index", "job-total", "max-parallel"];
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
if (!strategy || !isMapping(strategy)) {
// No strategy defined - return defaults that match runtime behavior
return new DescriptionDictionary(
...keys.map(key => {
return {key, value: DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]};
})
);
}
const strategyContext = new DescriptionDictionary();
for (const pair of strategy) {
if (!isString(pair.key)) {
continue;
}
if (!keys.includes(pair.key.value)) {
continue;
}
const value = isScalar(pair.value) ? scalarToData(pair.value) : new data.Null();
strategyContext.add(pair.key.value, value);
}
for (const key of keys) {
if (!strategyContext.get(key)) {
// Use default value for missing properties
strategyContext.add(key, DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]);
}
}
return strategyContext;
}
@@ -0,0 +1,122 @@
import {isMapping} from "@actions/workflow-parser";
import {ActionInputDefinition, ActionTemplate} from "@actions/workflow-parser/actions/action-template";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
/**
* Context information for an action.yml file, used to provide
* expression completion with action-specific values.
*/
export interface ActionContext {
uri: string;
/** The converted action template */
template: ActionTemplate | undefined;
/** If the context is for a position within a composite step, this will be the step */
step?: Step;
}
/**
* Build context from a converted action template and token path.
* Similar to getWorkflowContext but for action files.
*/
export function getActionContext(
uri: string,
template: ActionTemplate | undefined,
tokenPath: TemplateToken[]
): ActionContext {
const context: ActionContext = {uri, template};
if (!template) {
return context;
}
// Only composite actions have steps
if (template.runs?.using !== "composite") {
return context;
}
const compositeRuns = template.runs;
if (!compositeRuns.steps?.length) {
return context;
}
// Find the current step from the token path
let stepsSequence: SequenceToken | undefined;
let stepToken: MappingToken | undefined;
for (const token of tokenPath) {
const defKey = token.definition?.key;
if (defKey === "composite-steps" && token instanceof SequenceToken) {
stepsSequence = token;
} else if ((defKey === "run-step" || defKey === "uses-step") && isMapping(token)) {
stepToken = token;
}
}
if (stepsSequence && stepToken) {
context.step = findStep(compositeRuns.steps, stepsSequence, stepToken);
}
return context;
}
/**
* Find the Step that corresponds to the given step token.
*/
function findStep(steps: Step[], stepsSequence: SequenceToken, stepToken: MappingToken): Step | undefined {
// Find the step by matching index in the sequence
let stepIndex = -1;
for (let i = 0; i < stepsSequence.count; i++) {
if (stepsSequence.get(i) === stepToken) {
stepIndex = i;
break;
}
}
if (stepIndex === -1 || stepIndex >= steps.length) {
return undefined;
}
return steps[stepIndex];
}
/**
* Get input definitions from the action template.
*/
export function getActionInputs(template: ActionTemplate | undefined): ActionInputDefinition[] {
return template?.inputs ?? [];
}
/**
* Get step IDs from composite action steps that appear before the current step.
* This is used for `steps.<id>` context completion - you can only reference
* steps that have already run.
*/
export function getActionStepIdsBefore(context: ActionContext): string[] {
const template = context.template;
if (!template || template.runs?.using !== "composite") {
return [];
}
const compositeRuns = template.runs;
const steps = compositeRuns.steps ?? [];
const currentStep = context.step;
const stepIds: string[] = [];
for (const step of steps) {
// Stop when we reach the current step
if (currentStep && step === currentStep) {
break;
}
// Only include steps with explicit IDs
if (step.id) {
stepIds.push(step.id);
}
}
return stepIds;
}
@@ -6,6 +6,10 @@ import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
/**
* Represents the contextual position within a workflow file.
* Used to determine which expression contexts are available at a given location.
*/
export interface WorkflowContext {
uri: string;
@@ -21,6 +25,12 @@ export interface WorkflowContext {
step?: Step;
}
/**
* Builds a WorkflowContext by walking the token path to identify the current job and step.
* @param uri - The URI of the workflow file
* @param template - The parsed workflow template
* @param tokenPath - The path of tokens from root to the current position
*/
export function getWorkflowContext(
uri: string,
template: WorkflowTemplate | undefined,
@@ -73,6 +83,10 @@ export function getWorkflowContext(
return context;
}
/**
* Finds a Step by matching the step token's position in the steps sequence.
* Steps may not have IDs, so we locate them by index rather than by identifier.
*/
function findStep(steps?: Step[], stepSequence?: SequenceToken, stepToken?: MappingToken): Step | undefined {
if (!steps || !stepSequence || !stepToken) {
return undefined;
@@ -3,6 +3,9 @@ import {DESCRIPTION} from "@actions/workflow-parser/templates/template-constants
import {WorkflowContext} from "../context/workflow-context.js";
import {TokenResult} from "../utils/find-token.js";
/**
* Checks if the token is an input value in a reusable workflow job's `with:` block.
*/
export function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean {
return (
tokenResult.parent?.definition?.key === "workflow-job-with" &&
@@ -11,6 +14,11 @@ export function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean {
);
}
/**
* Gets the description of an input from a called reusable workflow.
* When a workflow calls another workflow with `uses:`, this fetches the input's
* description from the called workflow's `workflow_call.inputs` definitions.
*/
export function getReusableWorkflowInputDescription(
workflowContext: WorkflowContext,
tokenResult: TokenResult
@@ -129,4 +129,31 @@ jobs:
}
]);
});
it("links for actions in composite action", async () => {
const input = `name: My Composite Action
description: A composite action with nested actions
runs:
using: composite
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: echo "Hello"
shell: bash`;
const result = await documentLinks(createDocument("action.yml", input), undefined);
expect(result).toHaveLength(2);
expect(result[0].target).toBe("https://www.github.com/actions/checkout/tree/v4/");
expect(result[0].tooltip).toBe("Open action on GitHub");
expect(result[1].target).toBe("https://www.github.com/actions/setup-node/tree/v4/");
});
it("no links for non-composite action", async () => {
const input = `name: My Node Action
description: A node action
runs:
using: node20
main: index.js`;
const result = await documentLinks(createDocument("action.yml", input), undefined);
expect(result).toHaveLength(0);
});
});
+64 -11
View File
@@ -6,29 +6,82 @@ import {TextDocument} from "vscode-languageserver-textdocument";
import {DocumentLink} from "vscode-languageserver-types";
import * as vscodeURI from "vscode-uri";
import {actionUrl, parseActionReference} from "./action.js";
import {isActionDocument} from "./utils/document-type.js";
import {mapRange} from "./utils/range.js";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
import {
getOrConvertActionTemplate,
getOrConvertWorkflowTemplate,
getOrParseAction,
getOrParseWorkflow
} from "./utils/workflow-cache.js";
/**
* Generates clickable links for action references and reusable workflows.
*/
export async function documentLinks(document: TextDocument, workspace: string | undefined): Promise<DocumentLink[]> {
const file: File = {
name: document.uri,
content: document.getText()
};
const parsedWorkflow = fetchOrParseWorkflow(file, document.uri);
return isActionDocument(document.uri)
? actionDocumentLinks(file, document.uri)
: workflowDocumentLinks(file, document.uri, workspace);
}
/**
* Generates clickable links for action references in action.yml files.
*/
function actionDocumentLinks(file: File, uri: string): DocumentLink[] {
const parsedAction = getOrParseAction(file, uri);
if (!parsedAction?.value) {
return [];
}
const template = getOrConvertActionTemplate(parsedAction.context, parsedAction.value, uri, {
errorPolicy: ErrorPolicy.TryConversion
});
const links: DocumentLink[] = [];
// Only composite actions have steps
if (template?.runs?.using !== "composite") {
return links;
}
const steps = template.runs.steps ?? [];
for (const step of steps) {
if ("uses" in step) {
const actionRef = parseActionReference(step.uses.value);
if (!actionRef) {
continue;
}
const url = actionUrl(actionRef);
links.push({
range: mapRange(step.uses.range),
target: url,
tooltip: `Open action on GitHub`
});
}
}
return links;
}
/**
* Generates clickable links for action references and reusable workflows in workflow files.
*/
async function workflowDocumentLinks(file: File, uri: string, workspace: string | undefined): Promise<DocumentLink[]> {
const parsedWorkflow = getOrParseWorkflow(file, uri);
if (!parsedWorkflow?.value) {
return [];
}
const template = await fetchOrConvertWorkflowTemplate(
parsedWorkflow.context,
parsedWorkflow.value,
document.uri,
undefined,
{
errorPolicy: ErrorPolicy.TryConversion
}
);
const template = await getOrConvertWorkflowTemplate(parsedWorkflow.context, parsedWorkflow.value, uri, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
const links: DocumentLink[] = [];
+9 -3
View File
@@ -21,17 +21,23 @@ describe("end-to-end", () => {
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(9);
const labels = result.map(x => x.label);
expect(labels).toEqual([
expect(result.length).toEqual(13);
const labelsWithDetails = result.map(x =>
x.labelDetails?.description ? `${x.label} (${x.labelDetails.description})` : x.label
);
expect(labelsWithDetails).toEqual([
"concurrency",
"concurrency (full syntax)",
"defaults",
"description",
"env",
"jobs",
"name",
"on",
"on (list)",
"on (full syntax)",
"permissions",
"permissions (full syntax)",
"run-name"
]);
});
@@ -3,7 +3,7 @@ import {convertWorkflowTemplate, parseWorkflow} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {File} from "@actions/workflow-parser/workflows/file";
import {ContextProviderConfig} from "../context-providers/config.js";
import {getContext, Mode} from "../context-providers/default.js";
import {getWorkflowExpressionContext, Mode} from "../context-providers/default.js";
import {getWorkflowContext} from "../context/workflow-context.js";
import {validatorFunctions} from "../expression-validation/functions.js";
import {nullTrace} from "../nulltrace.js";
@@ -116,7 +116,12 @@ async function hoverExpression(input: string) {
errorPolicy: ErrorPolicy.TryConversion
});
const workflowContext = getWorkflowContext(td.uri, template, []);
const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion);
const context = await getWorkflowExpressionContext(
allowedContext,
contextProviderConfig,
workflowContext,
Mode.Completion
);
const l = new Lexer(td.getText());
const lr = l.lex();
+217
View File
@@ -0,0 +1,217 @@
import {TextDocument} from "vscode-languageserver-textdocument";
import {hover} from "./hover";
describe("hover action files", () => {
function createActionDocument(
content: string,
uri = "file:///test/action.yml"
): [TextDocument, {line: number; character: number}] {
// Parse cursor position and remove the | character
const cursorIndex = content.indexOf("|");
if (cursorIndex === -1) {
throw new Error("No cursor (|) found in content");
}
const newContent = content.substring(0, cursorIndex) + content.substring(cursorIndex + 1);
const doc = TextDocument.create(uri, "yaml", 1, newContent);
const position = doc.positionAt(cursorIndex);
return [doc, position];
}
describe("top-level keys", () => {
it("shows description for name key", async () => {
const [doc, position] = createActionDocument(`na|me: My Action
description: Test
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("name");
});
it("shows description for description key", async () => {
const [doc, position] = createActionDocument(`name: My Action
descrip|tion: Test
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("description");
});
it("shows description for runs key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
ru|ns:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("runs");
});
it("shows description for author key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
au|thor: Me
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("author");
expect(result?.contents).toContain("Documentation");
});
});
describe("runs properties", () => {
it("shows description for using key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
us|ing: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("runtime");
});
it("shows description for main key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
using: node20
ma|in: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("main");
});
});
describe("inputs", () => {
it("shows description for inputs section", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
inp|uts:
my-input:
description: A test input
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("input");
});
it("shows description for required key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
inputs:
my-input:
description: A test input
requ|ired: true
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("required");
});
it("shows allowed context for default value", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
inputs:
my-input:
description: A test input
def|ault: foo
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
// Input defaults can use expressions with github, strategy, matrix, job, runner contexts
expect(result?.contents).toContain("github");
});
});
describe("branding", () => {
it("shows description for branding section", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
using: node20
main: index.js
brand|ing:
icon: activity
color: blue`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("brand");
expect(result?.contents).toContain("Documentation");
});
it("shows description for icon key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
using: node20
main: index.js
branding:
ic|on: activity
color: blue`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("icon");
});
});
describe("document type routing", () => {
it("routes action.yml to action hover", async () => {
const [doc, position] = createActionDocument(
`na|me: My Action
description: Test
runs:
using: node20
main: index.js`,
"file:///my-repo/action.yml"
);
const result = await hover(doc, position);
expect(result).not.toBeNull();
});
it("does not route workflow files to action hover", async () => {
const doc = TextDocument.create(
"file:///repo/.github/workflows/ci.yml",
"yaml",
1,
`name: CI
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hello`
);
// Hovering over 'name' in a workflow file should give workflow-specific info
const result = await hover(doc, {line: 0, character: 2});
// The workflow hover might not have description for workflow name,
// but it should not crash
expect(result === null || result.contents !== undefined).toBe(true);
});
});
});
+8 -5
View File
@@ -110,8 +110,7 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// Cron description is now shown via diagnostics, not hover
expect(result?.contents).toEqual("");
expect(result?.contents).toEqual("Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00");
});
it("on a cron mapping key", async () => {
@@ -121,7 +120,9 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual("");
expect(result?.contents).toEqual(
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
);
});
it("on an invalid cron schedule", async () => {
@@ -131,7 +132,9 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual("");
expect(result?.contents).toEqual(
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
);
});
it("shows context inherited from parent nodes", async () => {
@@ -196,7 +199,7 @@ jobs:
const result = await hover(...getPositionFromCursor(input), testHoverConfig("uses", "step-uses", undefined));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image."
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, a [private repository with access enabled](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository), or in a published Docker container image."
);
});
});
+127 -58
View File
@@ -1,6 +1,9 @@
import {data, DescriptionDictionary, Parser} from "@actions/expressions";
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
import {Lexer} from "@actions/expressions/lexer";
import {parseAction} from "@actions/workflow-parser/actions/action-parser";
import {isString} from "@actions/workflow-parser";
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
@@ -10,8 +13,9 @@ import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {Hover} from "vscode-languageserver-types";
import {ContextProviderConfig} from "./context-providers/config.js";
import {getContext, Mode} from "./context-providers/default.js";
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
import {getFunctionDescription} from "./context-providers/descriptions.js";
import {ActionContext, getActionContext} from "./context/action-context.js";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
import {
getReusableWorkflowInputDescription,
@@ -20,10 +24,12 @@ import {
import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-pos.js";
import {HoverVisitor} from "./expression-hover/visitor.js";
import {info} from "./log.js";
import {nullTrace} from "./nulltrace.js";
import {isActionDocument} from "./utils/document-type.js";
import {isPotentiallyExpression} from "./utils/expression-detection.js";
import {findToken} from "./utils/find-token.js";
import {mapRange} from "./utils/range.js";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
import {getOrConvertActionTemplate, getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
export type HoverConfig = {
descriptionProvider?: DescriptionProvider;
@@ -32,79 +38,136 @@ export type HoverConfig = {
};
export type DescriptionProvider = {
getDescription(context: WorkflowContext, token: TemplateToken, path: TemplateToken[]): Promise<string | undefined>;
getDescription(
context: WorkflowContext | ActionContext,
token: TemplateToken,
path: TemplateToken[]
): Promise<string | undefined>;
};
/**
* Returns hover information for the token at the given position.
*/
export async function hover(document: TextDocument, position: Position, config?: HoverConfig): Promise<Hover | null> {
const file: File = {
name: document.uri,
content: document.getText()
};
const parsedWorkflow = fetchOrParseWorkflow(file, document.uri);
if (!parsedWorkflow?.value) {
// Determine document type based on file path (action.yml vs workflow file)
const isAction = isActionDocument(document.uri);
// Parse document
const parsedTemplate = isAction ? parseAction(file, nullTrace) : getOrParseWorkflow(file, document.uri);
if (!parsedTemplate?.value) {
return null;
}
const template = await fetchOrConvertWorkflowTemplate(
parsedWorkflow.context,
parsedWorkflow.value,
document.uri,
config,
{
errorPolicy: ErrorPolicy.TryConversion,
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
}
);
const tokenResult = findToken(position, parsedWorkflow.value);
// Find the token at the cursor position
const tokenResult = findToken(position, parsedTemplate.value);
const {token, keyToken, parent} = tokenResult;
const tokenDefinitionInfo = (keyToken || parent || token)?.definitionInfo;
const workflowContext = getWorkflowContext(document.uri, template, tokenResult.path);
if (token && tokenDefinitionInfo) {
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
info(`Calculating expression hover for token with definition ${tokenDefinitionInfo.definition.key}`);
const allowedContext = tokenDefinitionInfo.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
const context = await getContext(namedContexts, config?.contextProviderConfig, workflowContext, Mode.Completion);
for (const func of functions) {
func.description = getFunctionDescription(func.name);
}
const exprPos = mapToExpressionPos(token, position);
if (exprPos) {
return expressionHover(exprPos, context, namedContexts, functions);
}
}
}
if (!token?.definition) {
// Early exit if there's nothing to provide hover for
const hoverToken = token || keyToken;
const isExpressionHover =
token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token, isAction));
if (!isExpressionHover && !hoverToken?.definition) {
return null;
}
info(`Calculating hover for token with definition ${token.definition.key}`);
// Build document context (jobs, steps, inputs, etc.) from the parsed template
const documentContext = isAction
? getActionContext(
document.uri,
getOrConvertActionTemplate(parsedTemplate.context, parsedTemplate.value, document.uri, {
errorPolicy: ErrorPolicy.TryConversion
}),
tokenResult.path
)
: getWorkflowContext(
document.uri,
await getOrConvertWorkflowTemplate(parsedTemplate.context, parsedTemplate.value, document.uri, config, {
errorPolicy: ErrorPolicy.TryConversion,
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
}),
tokenResult.path
);
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
description = appendContext(description, token.definitionInfo?.allowedContext);
return {
contents: description,
range: mapRange(token.range)
} satisfies Hover;
// Expression hover
if (isExpressionHover) {
info(`Calculating expression hover for token with definition ${tokenDefinitionInfo.definition.key}`);
const allowedContext = tokenDefinitionInfo.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
// Build expression context with named contexts (github, env, etc.) and their descriptions
const expressionContext = isAction
? getActionExpressionContext(
namedContexts,
config?.contextProviderConfig,
documentContext as ActionContext,
Mode.Hover
)
: await getWorkflowExpressionContext(
namedContexts,
config?.contextProviderConfig,
documentContext as WorkflowContext,
Mode.Hover
);
// Populate function descriptions for hover display
for (const func of functions) {
func.description = getFunctionDescription(func.name);
}
// Convert document position to expression-relative position
const exprPos = mapToExpressionPos(token, position);
if (exprPos) {
// Find the expression element at the cursor and return its description
return expressionHover(exprPos, expressionContext, namedContexts, functions);
}
}
let description = await getDescription(config, workflowContext, token, tokenResult.path);
description = appendContext(description, token.definitionInfo?.allowedContext);
if (!hoverToken?.definition) {
return null;
}
// Non-expression hover: show the schema description for the YAML key or value
info(`Calculating hover for token with definition ${hoverToken.definition.key}`);
// Check for cron expression hover
if (isString(hoverToken) && hoverToken.definition.key === "cron-pattern") {
const cronDescription = getCronDescription(hoverToken.value);
if (cronDescription) {
return {
contents: cronDescription,
range: mapRange(hoverToken.range)
};
}
}
let description: string;
if (!isAction && tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
// Reusable workflow call: fetch the called workflow's input descriptions
description = getReusableWorkflowInputDescription(documentContext as WorkflowContext, tokenResult);
} else {
// Default: use custom provider or token's schema description
description =
(await getDescription(config, documentContext, hoverToken, tokenResult.path)) || hoverToken.description || "";
}
// Return hover with description and available expression contexts
return {
contents: description,
range: mapRange(token.range)
contents: appendContext(description, hoverToken.definitionInfo?.allowedContext),
range: mapRange(hoverToken.range)
} satisfies Hover;
}
/**
* Appends available expression contexts and functions to a hover description.
* For example: "Available expression contexts: `github`, `env`"
*/
function appendContext(description: string, allowedContext?: string[]) {
if (!allowedContext || allowedContext.length == 0) {
return description;
@@ -128,24 +191,30 @@ function appendContext(description: string, allowedContext?: string[]) {
return `${description}${namedContextsString}${functionsString}`;
}
/**
* Gets a custom description from the configured description provider.
* Used to fetch rich descriptions like action input docs from GitHub repos.
*/
async function getDescription(
config: HoverConfig | undefined,
workflowContext: WorkflowContext,
documentContext: WorkflowContext | ActionContext,
token: TemplateToken,
path: TemplateToken[]
) {
const defaultDescription = token.description || "";
): Promise<string | undefined> {
if (!config?.descriptionProvider) {
return defaultDescription;
return undefined;
}
const description = await config.descriptionProvider.getDescription(workflowContext, token, path);
return description || defaultDescription;
return await config.descriptionProvider.getDescription(documentContext, token, path);
}
/**
* Parses an expression and finds the element at the cursor position to show its description.
* For example, hovering over `github.actor` shows "The login of the user that triggered the workflow".
*/
function expressionHover(
exprPos: ExpressionPos,
context: DescriptionDictionary,
expressionContext: DescriptionDictionary,
namedContexts: string[],
functions: FunctionInfo[]
): Hover | null {
@@ -165,7 +234,7 @@ function expressionHover(
call: () => new data.Null()
});
}
const hv = new HoverVisitor(position, context, functionMap);
const hv = new HoverVisitor(position, expressionContext, functionMap);
const hoverResult = hv.hover(expr);
if (!hoverResult) {
return null;
+3 -1
View File
@@ -1,7 +1,9 @@
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";
export {getInlayHints} from "./inlay-hints.js";
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log.js";
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate.js";
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
export {getCodeActions, CodeActionParams} from "./code-actions/code-actions.js";
+116
View File
@@ -0,0 +1,116 @@
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");
});
});
});
+62
View File
@@ -0,0 +1,62 @@
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 {isActionDocument} from "./utils/document-type.js";
import {getOrParseWorkflow} 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[] {
// Inlay hints are only supported for workflow files (cron expressions)
if (isActionDocument(document.uri)) {
return [];
}
const file: File = {
name: document.uri,
content: document.getText()
};
const result = getOrParseWorkflow(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;
}
@@ -0,0 +1,98 @@
import {detectDocumentType, isActionDocument, isWorkflowDocument} from "./document-type";
describe("detectDocumentType", () => {
describe("action files", () => {
it("detects action.yml", () => {
expect(detectDocumentType("/path/to/action.yml")).toBe("action");
});
it("detects action.yaml", () => {
expect(detectDocumentType("/path/to/action.yaml")).toBe("action");
});
it("detects action.yml with case insensitivity", () => {
expect(detectDocumentType("/path/to/ACTION.YML")).toBe("action");
expect(detectDocumentType("/path/to/Action.Yaml")).toBe("action");
});
it("detects nested action.yml", () => {
expect(detectDocumentType("/repo/.github/actions/my-action/action.yml")).toBe("action");
});
it("detects bare action.yml", () => {
expect(detectDocumentType("action.yml")).toBe("action");
});
it("handles Windows paths", () => {
expect(detectDocumentType("C:\\Users\\me\\action.yml")).toBe("action");
expect(detectDocumentType("C:\\repo\\.github\\actions\\my-action\\action.yml")).toBe("action");
});
});
describe("workflow files", () => {
it("detects workflow files in .github/workflows", () => {
expect(detectDocumentType("/repo/.github/workflows/ci.yml")).toBe("workflow");
expect(detectDocumentType("/repo/.github/workflows/build.yaml")).toBe("workflow");
});
it("detects workflow files in .github/workflows-lab", () => {
expect(detectDocumentType("/repo/.github/workflows-lab/ci.yml")).toBe("workflow");
expect(detectDocumentType("/repo/.github/workflows-lab/build.yaml")).toBe("workflow");
});
it("detects workflow files case insensitively", () => {
expect(detectDocumentType("/repo/.github/workflows/CI.YML")).toBe("workflow");
});
it("handles Windows paths for workflows", () => {
expect(detectDocumentType("C:\\repo\\.github\\workflows\\ci.yml")).toBe("workflow");
expect(detectDocumentType("C:\\repo\\.github\\workflows-lab\\ci.yml")).toBe("workflow");
});
it("workflow path takes precedence over action filename", () => {
// Edge case: action.yml inside .github/workflows should be treated as workflow
expect(detectDocumentType("/repo/.github/workflows/action.yml")).toBe("workflow");
expect(detectDocumentType("/repo/.github/workflows/action.yaml")).toBe("workflow");
expect(detectDocumentType("/repo/.github/workflows-lab/action.yml")).toBe("workflow");
});
});
describe("unknown files", () => {
it("returns unknown for other yaml files", () => {
expect(detectDocumentType("/path/to/config.yml")).toBe("unknown");
expect(detectDocumentType("/path/to/docker-compose.yaml")).toBe("unknown");
});
it("returns unknown for non-yaml files", () => {
expect(detectDocumentType("/path/to/file.txt")).toBe("unknown");
});
});
});
describe("isActionDocument", () => {
it("returns true for action files", () => {
expect(isActionDocument("/path/to/action.yml")).toBe(true);
});
it("returns false for workflow files", () => {
expect(isActionDocument("/repo/.github/workflows/ci.yml")).toBe(false);
});
it("returns false for unknown files", () => {
expect(isActionDocument("/path/to/config.yml")).toBe(false);
});
});
describe("isWorkflowDocument", () => {
it("returns true for workflow files", () => {
expect(isWorkflowDocument("/repo/.github/workflows/ci.yml")).toBe(true);
});
it("returns false for action files", () => {
expect(isWorkflowDocument("/path/to/action.yml")).toBe(false);
});
it("returns false for unknown files", () => {
expect(isWorkflowDocument("/path/to/config.yml")).toBe(false);
});
});
@@ -0,0 +1,48 @@
/**
* Document type detection for workflow and action files.
* Detection is based on file path/name only - content heuristics are not used
* because files in non-standard locations wouldn't work as workflows/actions anyway.
*/
export type DocumentType = "workflow" | "action" | "unknown";
/**
* Detects whether a document is a workflow file, action file, or unknown based on its URI.
*
* @param uri The document URI or file path
* @returns The detected document type
*/
export function detectDocumentType(uri: string): DocumentType {
// Normalize path separators
const normalizedUri = uri.replace(/\\/g, "/");
// Check for workflow file patterns FIRST (more specific path takes precedence)
// Matches: .github/workflows/*.yml or .github/workflows/*.yaml
// Also matches: .github/workflows-lab/*.yml or .github/workflows-lab/*.yaml
// This ensures .github/workflows/action.yml is treated as a workflow, not an action
if (/\.github\/workflows(-lab)?\/[^/]+\.ya?ml$/i.test(normalizedUri)) {
return "workflow";
}
// Check for action.yml/action.yaml patterns
// Matches: action.yml, action.yaml, .github/actions/my-action/action.yml, etc.
if (/\/action\.ya?ml$/i.test(normalizedUri) || /^action\.ya?ml$/i.test(normalizedUri)) {
return "action";
}
return "unknown";
}
/**
* Check if a document is an action file
*/
export function isActionDocument(uri: string): boolean {
return detectDocumentType(uri) === "action";
}
/**
* Check if a document is a workflow file
*/
export function isWorkflowDocument(uri: string): boolean {
return detectDocumentType(uri) === "workflow";
}
@@ -0,0 +1,170 @@
import {isPotentiallyExpression} from "./expression-detection.js";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
import {Definition} from "@actions/workflow-parser/templates/schema/definition";
// Helper to create a mock TemplateToken with the properties we need to test
function createMockToken(options: {value?: string; definitionKey?: string; isString?: boolean}): TemplateToken {
const {value = "", definitionKey, isString = true} = options;
const mockDefinition = definitionKey ? ({key: definitionKey} as Definition) : undefined;
return {
value: isString ? value : undefined,
definition: mockDefinition,
templateTokenType: isString ? TokenType.String : TokenType.Mapping,
// Required by isString type guard (isLiteral checks isLiteral property)
isLiteral: isString,
isScalar: isString
} as unknown as TemplateToken;
}
describe("isPotentiallyExpression", () => {
describe("expression markers", () => {
it("returns true when token value contains ${{", () => {
const token = createMockToken({value: "${{ github.actor }}"});
expect(isPotentiallyExpression(token, false)).toBe(true);
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns true when token value contains embedded ${{", () => {
const token = createMockToken({value: "Hello ${{ github.actor }}!"});
expect(isPotentiallyExpression(token, false)).toBe(true);
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns false when token value does not contain ${{", () => {
const token = createMockToken({value: "plain text"});
expect(isPotentiallyExpression(token, false)).toBe(false);
expect(isPotentiallyExpression(token, true)).toBe(false);
});
it("returns false for non-string tokens without expression marker", () => {
const token = createMockToken({isString: false});
expect(isPotentiallyExpression(token, false)).toBe(false);
expect(isPotentiallyExpression(token, true)).toBe(false);
});
});
describe("workflow schema if-conditions", () => {
it("returns true for job-if definition in workflow", () => {
const token = createMockToken({value: "success()", definitionKey: "job-if"});
expect(isPotentiallyExpression(token, false)).toBe(true);
});
it("returns false for job-if definition in action (not valid in action schema)", () => {
const token = createMockToken({value: "success()", definitionKey: "job-if"});
expect(isPotentiallyExpression(token, true)).toBe(false);
});
it("returns true for step-if definition in workflow", () => {
const token = createMockToken({value: "failure()", definitionKey: "step-if"});
expect(isPotentiallyExpression(token, false)).toBe(true);
});
it("returns true for snapshot-if definition in workflow", () => {
const token = createMockToken({value: "always()", definitionKey: "snapshot-if"});
expect(isPotentiallyExpression(token, false)).toBe(true);
});
it("returns false for snapshot-if definition in action (not valid in action schema)", () => {
const token = createMockToken({value: "always()", definitionKey: "snapshot-if"});
expect(isPotentiallyExpression(token, true)).toBe(false);
});
});
describe("action schema if-conditions", () => {
describe("composite action step if (run and uses)", () => {
it("returns true for step-if definition in action", () => {
const token = createMockToken({value: "success()", definitionKey: "step-if"});
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns true for step-if with run step condition", () => {
// Composite action run step: if condition
const token = createMockToken({value: "github.event_name == 'push'", definitionKey: "step-if"});
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns true for step-if with uses step condition", () => {
// Composite action uses step: if condition
const token = createMockToken({value: "runner.os == 'Linux'", definitionKey: "step-if"});
expect(isPotentiallyExpression(token, true)).toBe(true);
});
});
describe("pre-if and post-if (node/docker actions)", () => {
it("returns true for runs-if definition in action (pre-if)", () => {
const token = createMockToken({value: "runner.os == 'Linux'", definitionKey: "runs-if"});
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns true for runs-if definition in action (post-if)", () => {
const token = createMockToken({value: "always()", definitionKey: "runs-if"});
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns false for runs-if definition in workflow (not valid in workflow schema)", () => {
const token = createMockToken({value: "always()", definitionKey: "runs-if"});
expect(isPotentiallyExpression(token, false)).toBe(false);
});
});
});
describe("mixed scenarios", () => {
it("returns true when expression marker present even if definition is not if-related", () => {
const token = createMockToken({value: "${{ github.actor }}", definitionKey: "some-other-definition"});
expect(isPotentiallyExpression(token, false)).toBe(true);
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns true when both expression marker and if definition present", () => {
const token = createMockToken({value: "${{ success() }}", definitionKey: "step-if"});
expect(isPotentiallyExpression(token, false)).toBe(true);
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns false for plain text with non-if definition", () => {
const token = createMockToken({value: "plain text", definitionKey: "string"});
expect(isPotentiallyExpression(token, false)).toBe(false);
expect(isPotentiallyExpression(token, true)).toBe(false);
});
it("returns false when token has no definition and no expression marker", () => {
const token = createMockToken({value: "plain text"});
expect(isPotentiallyExpression(token, false)).toBe(false);
expect(isPotentiallyExpression(token, true)).toBe(false);
});
});
describe("edge cases", () => {
it("handles empty string value", () => {
const token = createMockToken({value: ""});
expect(isPotentiallyExpression(token, false)).toBe(false);
expect(isPotentiallyExpression(token, true)).toBe(false);
});
it("handles expression marker as if-condition value", () => {
const token = createMockToken({value: "${{ always() }}", definitionKey: "job-if"});
expect(isPotentiallyExpression(token, false)).toBe(true);
// For action, job-if is not valid, but ${{ is present
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("handles partial expression marker", () => {
const token = createMockToken({value: "${incomplete"});
expect(isPotentiallyExpression(token, false)).toBe(false);
expect(isPotentiallyExpression(token, true)).toBe(false);
});
it("handles ${{ at different positions", () => {
const startToken = createMockToken({value: "${{ foo }} bar"});
const middleToken = createMockToken({value: "bar ${{ foo }} baz"});
const endToken = createMockToken({value: "bar ${{ foo }}"});
expect(isPotentiallyExpression(startToken, false)).toBe(true);
expect(isPotentiallyExpression(middleToken, false)).toBe(true);
expect(isPotentiallyExpression(endToken, false)).toBe(true);
});
});
});
@@ -2,10 +2,36 @@ import {isString} from "@actions/workflow-parser";
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
export function isPotentiallyExpression(token: TemplateToken): boolean {
const containsExpression = isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0;
// If conditions are always expressions (job-if, step-if, snapshot-if)
const definitionKey = token.definition?.key;
const isIfCondition = definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if";
return containsExpression || isIfCondition;
/**
* Workflow schema if-condition definition keys.
* - job-if: job level if condition
* - step-if: step level if condition
* - snapshot-if: snapshot if condition
*/
const WORKFLOW_IF_DEFINITIONS = new Set(["job-if", "step-if", "snapshot-if"]);
/**
* Action schema if-condition definition keys.
* - step-if: composite action step if condition (run-step and uses-step)
* - runs-if: pre-if and post-if at the runs level (node/docker actions)
*/
const ACTION_IF_DEFINITIONS = new Set(["step-if", "runs-if"]);
export function isPotentiallyExpression(token: TemplateToken, isAction: boolean): boolean {
// Check if token contains expression syntax
if (isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0) {
return true;
}
// Check if token is an if-condition (always treated as expressions)
if (!token.definition?.key) {
return false;
}
// Definition keys differ between workflow and action schemas
if (isAction) {
return ACTION_IF_DEFINITIONS.has(token.definition.key);
} else {
return WORKFLOW_IF_DEFINITIONS.has(token.definition.key);
}
}
+18 -2
View File
@@ -6,8 +6,24 @@ import {Range} from "vscode-languageserver-types";
const PLACEHOLDER_KEY = "key";
// Transform a document to work around YAML parsing issues
// Based on `_transform` in https://github.com/cschleiden/github-actions-parser/blob/main/src/lib/parser/complete.ts#L311
/**
* Transforms a document to make it valid YAML so the parser can understand
* the cursor position during auto-completion.
*
* When typing in an IDE, the document is usually invalid YAML:
* - `runs-on` without `:` isn't a valid key
* - Empty lines don't parse as anything
* - `- ` without a value isn't complete
*
* This function inserts placeholders to make the document parseable:
* - Empty line → inserts `key:` placeholder
* - Line without colon → appends `:`
* - Sequence item `- ` → inserts `key` after the dash
*
* Lines containing `${{` are skipped to avoid breaking multi-line strings.
*
* The `isPlaceholder()` helper filters out the fake entries from completions.
*/
export function transform(doc: TextDocument, pos: Position): [TextDocument, Position] {
let offset = doc.offsetAt(pos);
+65
View File
@@ -0,0 +1,65 @@
/**
* Shared validation utilities for `if` condition literal text detection.
* Used by both workflow and action validation.
*/
import {data} from "@actions/expressions";
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
/**
* Checks if a format function contains literal text in its format string.
* This indicates user confusion about how expressions work.
*
* Example: format('push == {0}', github.event_name)
* The literal text "push == " will always evaluate to truthy.
*
* @param expr The expression to check
* @returns true if the expression is a format() call with literal text
*/
export function hasFormatWithLiteralText(expr: Expr): boolean {
// If this is a logical AND expression (from ensureStatusFunction wrapping)
// check the right side for the format call
if (expr instanceof Logical && expr.operator.lexeme === "&&" && expr.args.length === 2) {
return hasFormatWithLiteralText(expr.args[1]);
}
if (!(expr instanceof FunctionCall)) {
return false;
}
// Check if this is a format function
if (expr.functionName.lexeme.toLowerCase() !== "format") {
return false;
}
// Check if the first argument is a string literal
if (expr.args.length < 1) {
return false;
}
const firstArg = expr.args[0];
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== data.Kind.String) {
return false;
}
// Get the format string and trim whitespace
const formatString = firstArg.literal.coerceString();
const trimmed = formatString.trim();
// Check if there's literal text (non-replacement tokens) after trimming
let inToken = false;
for (let i = 0; i < trimmed.length; i++) {
if (!inToken && trimmed[i] === "{") {
inToken = true;
} else if (inToken && trimmed[i] === "}") {
inToken = false;
} else if (inToken && trimmed[i] >= "0" && trimmed[i] <= "9") {
// OK - this is a replacement token like {0}, {1}, etc.
} else {
// Found literal text
return true;
}
}
return false;
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Shared validation utilities for step `uses` field format.
* Used by both workflow and action validation.
*/
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {mapRange} from "./range.js";
// Matches a short SHA (7-8 hex characters) that looks like it should be a full SHA
const SHORT_SHA_PATTERN = /^[0-9a-f]{7,8}$/i;
const SHORT_SHA_DOCS_URL =
"https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions";
/**
* Checks if a ref looks like a short SHA and adds a warning if so.
* Returns true if a warning was added.
*/
export function warnIfShortSha(diagnostics: Diagnostic[], token: StringToken, ref: string): boolean {
if (SHORT_SHA_PATTERN.test(ref)) {
diagnostics.push({
message: `The provided ref '${ref}' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.`,
severity: DiagnosticSeverity.Warning,
range: mapRange(token.range),
code: "short-sha-ref",
codeDescription: {
href: SHORT_SHA_DOCS_URL
}
});
return true;
}
return false;
}
/**
* Validates the format of a step's `uses` field.
*
* Valid formats:
* - docker://image:tag
* - ./local/path
* - .\local\path (Windows)
* - {owner}/{repo}@{ref}
* - {owner}/{repo}/{path}@{ref}
*/
export function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
const uses = token.value;
// Empty uses value
if (!uses) {
diagnostics.push({
message: "'uses' value in action cannot be blank",
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
return;
}
// Docker image reference - always valid format
if (uses.startsWith("docker://")) {
return;
}
// Local action path - always valid format
if (uses.startsWith("./") || uses.startsWith(".\\")) {
return;
}
// Remote action: must be {owner}/{repo}[/path]@{ref}
const atSegments = uses.split("@");
// Must have exactly one @
if (atSegments.length !== 2) {
addStepUsesFormatError(diagnostics, token);
return;
}
const [repoPath, gitRef] = atSegments;
// Ref cannot be empty
if (!gitRef) {
addStepUsesFormatError(diagnostics, token);
return;
}
// Split by / or \ to get path segments
const pathSegments = repoPath.split(/[\\/]/);
// Must have at least owner and repo (both non-empty)
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
addStepUsesFormatError(diagnostics, token);
return;
}
// Check if this is a reusable workflow reference (should be at job level, not step)
// Path would be like: owner/repo/.github/workflows/file.yml
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
diagnostics.push({
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
return;
}
// Warn if ref looks like a short SHA
warnIfShortSha(diagnostics, token, gitRef);
}
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
diagnostics.push({
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
}
+65 -13
View File
@@ -1,4 +1,10 @@
import {convertWorkflowTemplate, parseWorkflow, ParseWorkflowResult, WorkflowTemplate} from "@actions/workflow-parser";
import {convertWorkflowTemplate, parseWorkflow, TemplateParseResult, WorkflowTemplate} from "@actions/workflow-parser";
import {parseAction} from "@actions/workflow-parser/actions/action-parser";
import {
ActionTemplate,
ActionTemplateConverterOptions,
convertActionTemplate
} from "@actions/workflow-parser/actions/action-template";
import {WorkflowTemplateConverterOptions} from "@actions/workflow-parser/model/convert";
import {TemplateContext} from "@actions/workflow-parser/templates/template-context";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
@@ -7,28 +13,36 @@ import {File} from "@actions/workflow-parser/workflows/file";
import {CompletionConfig} from "../complete.js";
import {nullTrace} from "../nulltrace.js";
const parsedWorkflowCache = new Map<string, ParseWorkflowResult>();
const parsedWorkflowCache = new Map<string, TemplateParseResult>();
const parsedActionCache = new Map<string, TemplateParseResult>();
const workflowTemplateCache = new Map<string, WorkflowTemplate>();
const actionTemplateCache = new Map<string, ActionTemplate>();
export function clearCacheEntry(uri: string) {
parsedWorkflowCache.delete(uri);
parsedWorkflowCache.delete(workflowKey(uri, true));
parsedWorkflowCache.delete(cacheKey(uri, true));
parsedActionCache.delete(uri);
parsedActionCache.delete(cacheKey(uri, true));
workflowTemplateCache.delete(uri);
workflowTemplateCache.delete(workflowKey(uri, true));
workflowTemplateCache.delete(cacheKey(uri, true));
actionTemplateCache.delete(uri);
actionTemplateCache.delete(cacheKey(uri, true));
}
export function clearCache() {
parsedWorkflowCache.clear();
parsedActionCache.clear();
workflowTemplateCache.clear();
actionTemplateCache.clear();
}
/**
* Parses a workflow file and caches the result
* Parses a workflow file, returning cached result if available
* @param transformed Indicates whether the workflow has been transformed before parsing
* @returns the {@link ParseWorkflowResult}
* @returns the {@link TemplateParseResult}
*/
export function fetchOrParseWorkflow(file: File, uri: string, transformed = false): ParseWorkflowResult {
const key = workflowKey(uri, transformed);
export function getOrParseWorkflow(file: File, uri: string, transformed = false): TemplateParseResult {
const key = cacheKey(uri, transformed);
const cachedResult = parsedWorkflowCache.get(key);
if (cachedResult) {
return cachedResult;
@@ -39,11 +53,27 @@ export function fetchOrParseWorkflow(file: File, uri: string, transformed = fals
}
/**
* Converts a workflow template and caches the result
* Parses an action file, returning cached result if available
* @param transformed Indicates whether the action has been transformed before parsing
* @returns the {@link TemplateParseResult}
*/
export function getOrParseAction(file: File, uri: string, transformed = false): TemplateParseResult {
const key = cacheKey(uri, transformed);
const cachedResult = parsedActionCache.get(key);
if (cachedResult) {
return cachedResult;
}
const result = parseAction(file, nullTrace);
parsedActionCache.set(key, result);
return result;
}
/**
* Converts a workflow template, returning cached result if available
* @param transformed Indicates whether the workflow has been transformed before parsing
* @returns the converted {@link WorkflowTemplate}
*/
export async function fetchOrConvertWorkflowTemplate(
export async function getOrConvertWorkflowTemplate(
context: TemplateContext,
template: TemplateToken,
uri: string,
@@ -51,7 +81,7 @@ export async function fetchOrConvertWorkflowTemplate(
options?: WorkflowTemplateConverterOptions,
transformed = false
): Promise<WorkflowTemplate> {
const key = workflowKey(uri, transformed);
const key = cacheKey(uri, transformed);
const cachedTemplate = workflowTemplateCache.get(key);
if (cachedTemplate) {
return cachedTemplate;
@@ -61,8 +91,30 @@ export async function fetchOrConvertWorkflowTemplate(
return workflowTemplate;
}
// Use a separate cache key for transformed workflows
function workflowKey(uri: string, transformed: boolean): string {
/**
* Converts an action template, returning cached result if available
* @param transformed Indicates whether the action has been transformed before parsing
* @returns the converted {@link ActionTemplate}
*/
export function getOrConvertActionTemplate(
context: TemplateContext,
template: TemplateToken,
uri: string,
options?: ActionTemplateConverterOptions,
transformed = false
): ActionTemplate {
const key = cacheKey(uri, transformed);
const cachedTemplate = actionTemplateCache.get(key);
if (cachedTemplate) {
return cachedTemplate;
}
const actionTemplate = convertActionTemplate(context, template, options);
actionTemplateCache.set(key, actionTemplate);
return actionTemplate;
}
// Use a separate cache key for transformed documents
function cacheKey(uri: string, transformed: boolean): string {
if (transformed) {
return `transformed-${uri}`;
}
@@ -249,7 +249,21 @@ jobs:
line: 7
}
},
severity: DiagnosticSeverity.Error
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
missingInputs: [
{
default: undefined,
name: "path"
}
]
}
}
]);
});
@@ -294,7 +308,25 @@ jobs:
line: 7
}
},
severity: DiagnosticSeverity.Error
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
]
}
}
]);
});
@@ -323,7 +355,25 @@ jobs:
line: 6
}
},
severity: DiagnosticSeverity.Error
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
]
}
}
]);
});
@@ -0,0 +1,127 @@
import {isMapping} from "@actions/workflow-parser";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {ActionReference, parseActionReference} from "./action.js";
import {mapRange} from "./utils/range.js";
import {ValidationConfig} from "./validate.js";
export const DiagnosticCode = {
MissingRequiredInputs: "missing-required-inputs"
} as const;
export interface MissingInputsDiagnosticData {
action: ActionReference;
missingInputs: Array<{
name: string;
default?: string;
}>;
}
/**
* Validates action references in workflow steps, checking for valid inputs and required inputs.
*/
export async function validateActionReference(
diagnostics: Diagnostic[],
stepToken: TemplateToken,
step: Step | undefined,
config: ValidationConfig | undefined
): Promise<void> {
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.actionsMetadataProvider) {
return;
}
// Parse the action reference (e.g., "actions/checkout@v4" -> {owner, name, ref})
const action = parseActionReference(step.uses.value);
if (!action) {
return;
}
// Fetch the action's metadata (action.yml) to get input definitions
const actionMetadata = await config.actionsMetadataProvider.fetchActionMetadata(action);
if (actionMetadata === undefined) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(step.uses.range),
message: `Unable to resolve action \`${step.uses.value}\`, repository or version not found`
});
return;
}
// Find the "with" key in the step token to get the inputs passed to the action
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const {key, value} of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
break;
}
}
// Collect the inputs provided in the step's "with" block
const stepInputs = new Map<string, ScalarToken>();
if (withToken && isMapping(withToken)) {
for (const {key} of withToken) {
stepInputs.set(key.toString(), key);
}
}
// Skip validation if the action doesn't define any inputs
const actionInputs = actionMetadata.inputs;
if (actionInputs === undefined) {
return;
}
// Check each provided input is valid and not deprecated
for (const [input, inputToken] of stepInputs) {
if (!actionInputs[input]) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(inputToken.range),
message: `Invalid action input '${input}'`
});
}
const deprecationMessage = actionInputs[input]?.deprecationMessage;
if (deprecationMessage) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
range: mapRange(inputToken.range),
message: deprecationMessage
});
}
}
// Check for required inputs that weren't provided and don't have defaults
const missingRequiredInputs = Object.entries(actionInputs).filter(
([inputName, input]) => input.required && !stepInputs.has(inputName) && input.default === undefined
);
// Report missing required inputs
if (missingRequiredInputs.length > 0) {
const message =
missingRequiredInputs.length === 1
? `Missing required input \`${missingRequiredInputs[0][0]}\``
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
// Build minimal diagnostic data - position calculation happens in the quickfix
const diagnosticData: MissingInputsDiagnosticData = {
action,
missingInputs: missingRequiredInputs.map(([name, input]) => ({
name,
default: input.default
}))
};
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange((withKey || stepToken).range),
message: message,
code: DiagnosticCode.MissingRequiredInputs,
data: diagnosticData
});
}
}
File diff suppressed because it is too large Load Diff
+453 -63
View File
@@ -1,92 +1,482 @@
import {isMapping} from "@actions/workflow-parser";
/**
* Validation for action.yml / action.yaml manifest files
*/
import {Lexer, Parser} from "@actions/expressions";
import {Expr} from "@actions/expressions/ast";
import {isMapping, isString} from "@actions/workflow-parser";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {ActionTemplate} from "@actions/workflow-parser/actions/action-template";
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
import {TemplateValidationError} from "@actions/workflow-parser/templates/template-validation-error";
import {File} from "@actions/workflow-parser/workflows/file";
import {TextDocument} from "vscode-languageserver-textdocument";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {parseActionReference} from "./action.js";
import {error} from "./log.js";
import {mapRange} from "./utils/range.js";
import {hasFormatWithLiteralText} from "./utils/validate-if.js";
import {validateStepUsesFormat} from "./utils/validate-uses.js";
import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cache.js";
import {validateActionReference} from "./validate-action-reference.js";
import {validateFormatCalls} from "./validate-format-string.js";
import {ValidationConfig} from "./validate.js";
export async function validateAction(
/**
* Valid keys for each action type under the `runs:` section.
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
*/
const NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
const COMPOSITE_KEYS = new Set(["using", "steps"]);
const DOCKER_KEYS = new Set([
"using",
"image",
"args",
"env",
"entrypoint",
"pre-entrypoint",
"pre-if",
"post-entrypoint",
"post-if"
]);
/**
* Required keys for each action type (besides 'using').
*/
const NODE_REQUIRED_KEYS = ["main"];
const COMPOSITE_REQUIRED_KEYS = ["steps"];
const DOCKER_REQUIRED_KEYS = ["image"];
/**
* Validates an action.yml file
*
* @param textDocument Document to validate
* @param config Optional validation configuration for action metadata provider
* @returns Array of diagnostics
*/
export async function validateAction(textDocument: TextDocument, config?: ValidationConfig): Promise<Diagnostic[]> {
const file: File = {
name: textDocument.uri,
content: textDocument.getText()
};
const diagnostics: Diagnostic[] = [];
try {
// Parse and validate the action.yml against the schema
const result = getOrParseAction(file, textDocument.uri);
if (!result) {
return [];
}
// Convert the action template (this may add validation errors for pre-if/post-if)
let template: ActionTemplate | undefined;
if (result.value) {
template = getOrConvertActionTemplate(result.context, result.value, textDocument.uri, {
errorPolicy: ErrorPolicy.TryConversion
});
}
// Get schema and conversion errors (must be after conversion to include conversion errors)
const schemaErrors = result.context.errors.getErrors();
// Run custom runs key validation, which also filters redundant schema errors in place
if (result.value) {
diagnostics.push(...validateRunsKeysAndFilterErrors(result.value, schemaErrors));
}
// Map remaining schema errors to diagnostics
for (const err of schemaErrors) {
const range = mapRange(err.range);
// Determine severity based on error type
let severity: DiagnosticSeverity = DiagnosticSeverity.Error;
// Treat deprecation warnings as warnings
if (err.rawMessage.includes("deprecated")) {
severity = DiagnosticSeverity.Warning;
}
diagnostics.push({
message: err.rawMessage,
range,
severity
});
}
// Validate composite action steps if we have a parsed result
if (result.value && template) {
// Only composite actions have steps to validate
if (template.runs?.using === "composite") {
const steps = template.runs.steps ?? [];
// Find the steps sequence token from the raw parsed result
const stepsSequence = findStepsSequence(result.value);
if (stepsSequence) {
// Validate each action step
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const stepToken = stepsSequence.get(i);
// Validate action references (inputs, required fields) for uses steps
if (isActionStep(step) && isMapping(stepToken)) {
await validateActionReference(diagnostics, stepToken, step, config);
}
// Validate step uses format
if (isMapping(stepToken)) {
validateStepUsesField(diagnostics, stepToken);
}
}
}
}
// Single traversal for all expression validation (like workflow's additionalValidations)
validateAllTokens(diagnostics, result.value);
}
} catch (e) {
error(`Unhandled error while validating action file: ${(e as Error).message}`);
}
return diagnostics;
}
/**
* Validates the `uses` field format in a composite action step.
*/
function validateStepUsesField(diagnostics: Diagnostic[], stepToken: MappingToken): void {
for (let i = 0; i < stepToken.count; i++) {
const {key, value} = stepToken.get(i);
const keyStr = isString(key) ? key.value.toLowerCase() : "";
if (keyStr === "uses" && isString(value)) {
validateStepUsesFormat(diagnostics, value);
}
}
}
/**
* Single traversal validation for all tokens in the action template.
* This follows the same pattern as workflow validation's additionalValidations:
* - For BasicExpressionToken: validate format() calls
* - For StringToken on if conditions: validate literal text detection and format() calls
* - For pre-if/post-if with explicit ${{ }}: report error (not supported by runner)
*
* Context validation (unknown named values) is handled by workflow-parser during conversion.
*/
function validateAllTokens(diagnostics: Diagnostic[], root: TemplateToken): void {
for (const [parent, token] of TemplateToken.traverse(root)) {
const definitionKey = token.definition?.key;
// Validate all BasicExpressionToken instances for format() calls
if (token instanceof BasicExpressionToken && token.range) {
// Check for literal text in if conditions (format with literal text)
if (definitionKey === "step-if") {
validateIfLiteralText(diagnostics, token);
}
// Validate format() calls for all expressions
for (const expression of token.originalExpressions || [token]) {
validateExpressionFormatCalls(diagnostics, expression);
}
// Check for explicit ${{ }} in pre-if/post-if (not supported by runner)
if (definitionKey === "runs-if" && parent instanceof MappingToken) {
// Resolve the key name (pre-if or post-if) from parent mapping
let keyName: string | undefined;
for (let i = 0; i < parent.count; i++) {
const {key, value} = parent.get(i);
if (value === token) {
keyName = key.toString().toLowerCase();
break;
}
}
if (keyName) {
diagnostics.push({
message: `Explicit expression syntax \${{ }} is not supported for '${keyName}'. Remove the \${{ }} markers and use the expression directly.`,
range: mapRange(token.range),
severity: DiagnosticSeverity.Error,
code: "explicit-expression-not-allowed"
});
}
}
}
// Handle implicit if conditions (StringToken without ${{ }})
// These allow expression syntax without the markers
if (isString(token) && token.range) {
if (definitionKey === "step-if" || definitionKey === "runs-if") {
validateImplicitIfCondition(diagnostics, token);
}
}
}
}
const LITERAL_TEXT_IN_CONDITION_MESSAGE =
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?";
const LITERAL_TEXT_IN_CONDITION_CODE = "expression-literal-text-in-condition";
/**
* Validates an implicit if condition (StringToken without ${{ }}).
* Checks for literal text detection and validates format() calls.
*/
function validateImplicitIfCondition(diagnostics: Diagnostic[], token: StringToken): void {
const condition = token.value.trim();
if (!condition) {
return;
}
const allowedContext = token.definitionInfo?.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
// Ensure the condition has a status function, wrapping if needed
const finalCondition = ensureStatusFunction(condition, token.definitionInfo);
try {
const l = new Lexer(finalCondition);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
const expr = p.parse();
// Check for literal text in the expression (format with literal text)
if (hasFormatWithLiteralText(expr)) {
diagnostics.push({
message: LITERAL_TEXT_IN_CONDITION_MESSAGE,
range: mapRange(token.range),
severity: DiagnosticSeverity.Error,
code: LITERAL_TEXT_IN_CONDITION_CODE
});
}
// Validate format() function calls
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
} catch {
// Ignore parse errors - they'll be caught by schema validation or workflow-parser
}
}
/**
* Validates a BasicExpressionToken for literal text in if conditions.
*/
function validateIfLiteralText(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
const allowedContext = token.definitionInfo?.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
try {
const l = new Lexer(token.expression);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
const expr = p.parse();
if (hasFormatWithLiteralText(expr)) {
diagnostics.push({
message: LITERAL_TEXT_IN_CONDITION_MESSAGE,
range: mapRange(token.range),
severity: DiagnosticSeverity.Error,
code: LITERAL_TEXT_IN_CONDITION_CODE
});
}
} catch {
// Ignore parse errors - they'll be caught by schema validation or workflow-parser
}
}
/**
* Validates format() function calls in an expression token.
*/
function validateExpressionFormatCalls(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
const allowedContext = token.definitionInfo?.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
try {
const l = new Lexer(token.expression);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
const expr = p.parse();
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
} catch {
// Ignore parse errors - they'll be caught by schema validation
}
}
/**
* Helper to validate format() function calls and add diagnostics.
*/
function validateFormatCallsAndAddDiagnostics(
diagnostics: Diagnostic[],
stepToken: TemplateToken,
step: Step | undefined,
config: ValidationConfig | undefined
): Promise<void> {
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.actionsMetadataProvider) {
return;
expr: Expr,
range: TokenRange | undefined
): void {
const formatErrors = validateFormatCalls(expr);
for (const formatError of formatErrors) {
if (formatError.type === "invalid-syntax") {
diagnostics.push({
message: `Invalid format string: ${formatError.message}`,
range: mapRange(range),
severity: DiagnosticSeverity.Error,
code: "invalid-format-string"
});
} else if (formatError.type === "arg-count-mismatch") {
diagnostics.push({
message: `Format string references argument {${formatError.expected - 1}} but only ${
formatError.provided
} argument(s) provided`,
range: mapRange(range),
severity: DiagnosticSeverity.Error,
code: "format-arg-count-mismatch"
});
}
}
}
/**
* Find the steps sequence token from the raw action template.
* Traverses the token tree looking for the "composite-steps" definition.
*/
function findStepsSequence(root: TemplateToken): SequenceToken | undefined {
for (const [, token] of TemplateToken.traverse(root)) {
if (token.definition?.key === "composite-steps" && token instanceof SequenceToken) {
return token;
}
}
return undefined;
}
/**
* Validates that the keys under `runs:` are valid for the specified `using:` type.
* Also filters out schema errors (in place) that this validation replaces with more specific messages.
*/
function validateRunsKeysAndFilterErrors(
root: TemplateToken,
schemaErrors: TemplateValidationError[] // mutated: redundant errors are removed
): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
// Find the runs mapping from the root
let runsMapping: MappingToken | undefined;
if (root instanceof MappingToken) {
for (let i = 0; i < root.count; i++) {
const {key, value} = root.get(i);
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
runsMapping = value;
break;
}
}
}
if (!runsMapping) {
return diagnostics;
}
const action = parseActionReference(step.uses.value);
if (!action) {
return;
}
const actionMetadata = await config.actionsMetadataProvider.fetchActionMetadata(action);
if (actionMetadata === undefined) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(step.uses.range),
message: `Unable to resolve action \`${step.uses.value}\`, repository or version not found`
});
return;
}
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const {key, value} of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
// 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;
}
}
const stepInputs = new Map<string, ScalarToken>();
if (withToken && isMapping(withToken)) {
for (const {key} of withToken) {
stepInputs.set(key.toString(), key);
}
if (!usingValue) {
return diagnostics; // No using value, let schema validation handle it
}
const actionInputs = actionMetadata.inputs;
if (actionInputs === undefined) {
return;
// Determine allowed keys, required keys, and action type name
let allowedKeys: Set<string>;
let requiredKeys: string[];
let actionType: string;
if (usingValue.match(/^node\d+$/i)) {
allowedKeys = NODE_KEYS;
requiredKeys = NODE_REQUIRED_KEYS;
actionType = "Node.js";
} else if (usingValue.toLowerCase() === "composite") {
allowedKeys = COMPOSITE_KEYS;
requiredKeys = COMPOSITE_REQUIRED_KEYS;
actionType = "composite";
} else if (usingValue.toLowerCase() === "docker") {
allowedKeys = DOCKER_KEYS;
requiredKeys = DOCKER_REQUIRED_KEYS;
actionType = "Docker";
} else {
return diagnostics; // Unknown type, let schema validation handle it
}
for (const [input, inputToken] of stepInputs) {
if (!actionInputs[input]) {
// Get all present keys
const presentKeys = new Set<string>();
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
presentKeys.add(key.toString().toLowerCase());
}
// Check for invalid keys
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
const keyStr = key.toString().toLowerCase();
if (!allowedKeys.has(keyStr)) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(inputToken.range),
message: `Invalid action input '${input}'`
range: mapRange(key.range),
message: `'${key.toString()}' is not valid for ${actionType} actions (using: ${usingValue})`
});
}
}
// Check for missing required keys
for (const requiredKey of requiredKeys) {
if (!presentKeys.has(requiredKey)) {
// Find the 'using' key to report the error location
let usingKeyRange = runsMapping.range;
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
usingKeyRange = key.range;
break;
}
}
const deprecationMessage = actionInputs[input]?.deprecationMessage;
if (deprecationMessage) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
range: mapRange(inputToken.range),
message: deprecationMessage
severity: DiagnosticSeverity.Error,
range: mapRange(usingKeyRange),
message: `'${requiredKey}' is required for ${actionType} actions (using: ${usingValue})`
});
}
}
const missingRequiredInputs = Object.entries(actionInputs).filter(
([inputName, input]) => input.required && !stepInputs.has(inputName) && input.default === undefined
);
// Remove schema errors that we're replacing with more specific messages (mutate in place)
for (let i = schemaErrors.length - 1; i >= 0; i--) {
const err = schemaErrors[i];
if (missingRequiredInputs.length > 0) {
const message =
missingRequiredInputs.length === 1
? `Missing required input \`${missingRequiredInputs[0][0]}\``
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
message: message
});
// Keep errors not at the runs section start
if (
err.range?.start.line !== runsMapping.range?.start.line ||
err.range?.start.column !== runsMapping.range?.start.column
) {
continue;
}
// Check if this is an error we're replacing
const isOneOfAmbiguity = err.rawMessage.startsWith("There's not enough info to determine");
const isRequiredKey = /^Required property is missing: (main|steps|image)$/.test(err.rawMessage);
if (!isOneOfAmbiguity && !isRequiredKey) {
continue; // Keep errors we're not replacing
}
// Remove only if we have custom diagnostics for this
if (diagnostics.length > 0) {
schemaErrors.splice(i, 1);
}
}
return diagnostics;
}
@@ -0,0 +1,199 @@
/**
* Format string validation for format() function calls.
* Port of Go's format_validator.go from actions-workflow-parser.
*/
import {Expr, FunctionCall, Literal, Binary, Unary, Logical, Grouping, IndexAccess} from "@actions/expressions/ast";
import {Kind} from "@actions/expressions/data/expressiondata";
/**
* Error types for format string validation
*/
export type FormatStringError =
| {type: "invalid-syntax"; message: string}
| {type: "arg-count-mismatch"; expected: number; provided: number};
/**
* Validates a format string and returns the maximum placeholder index.
* Port of Go's validateFormatString from format_validator.go.
*
* @param formatString The format string to validate
* @returns { valid: boolean, maxArgIndex: number } where maxArgIndex is -1 if no placeholders
*/
export function validateFormatString(formatString: string): {valid: boolean; maxArgIndex: number} {
let maxIndex = -1;
let i = 0;
while (i < formatString.length) {
// Find next left brace
let lbrace = -1;
for (let j = i; j < formatString.length; j++) {
if (formatString[j] === "{") {
lbrace = j;
break;
}
}
// Find next right brace
let rbrace = -1;
for (let j = i; j < formatString.length; j++) {
if (formatString[j] === "}") {
rbrace = j;
break;
}
}
// No more braces
if (lbrace < 0 && rbrace < 0) {
break;
}
// Left brace comes first (or only left brace exists)
if (lbrace >= 0 && (rbrace < 0 || lbrace < rbrace)) {
// Check if it's escaped
if (lbrace + 1 < formatString.length && formatString[lbrace + 1] === "{") {
// Escaped left brace
i = lbrace + 2;
continue;
}
// This is a placeholder opening - find the closing brace
rbrace = -1;
for (let j = lbrace + 1; j < formatString.length; j++) {
if (formatString[j] === "}") {
rbrace = j;
break;
}
}
if (rbrace < 0) {
// Missing closing brace
return {valid: false, maxArgIndex: -1};
}
// Validate placeholder content (must be digits only)
if (rbrace === lbrace + 1) {
// Empty placeholder {}
return {valid: false, maxArgIndex: -1};
}
// Parse the index and validate it's all digits
let index = 0;
for (let j = lbrace + 1; j < rbrace; j++) {
const c = formatString[j];
if (c < "0" || c > "9") {
// Non-numeric character
return {valid: false, maxArgIndex: -1};
}
index = index * 10 + (c.charCodeAt(0) - "0".charCodeAt(0));
}
if (index > maxIndex) {
maxIndex = index;
}
i = rbrace + 1;
continue;
}
// Right brace comes first (or only right brace exists)
// Check if it's escaped
if (rbrace + 1 < formatString.length && formatString[rbrace + 1] === "}") {
// Escaped right brace
i = rbrace + 2;
continue;
}
// Unescaped right brace outside of placeholder
return {valid: false, maxArgIndex: -1};
}
return {valid: true, maxArgIndex: maxIndex};
}
/**
* Walks an expression AST to find and validate all format() function calls.
*
* @param expr The expression AST to validate
* @returns Array of validation errors found
*/
export function validateFormatCalls(expr: Expr): FormatStringError[] {
const errors: FormatStringError[] = [];
const stack: Expr[] = [expr];
while (stack.length > 0) {
const node = stack.pop();
if (!node) {
continue;
}
if (node instanceof FunctionCall) {
if (node.functionName.lexeme.toLowerCase() === "format") {
const error = validateSingleFormatCall(node);
if (error) {
errors.push(error);
}
}
// Push args for further processing (to find nested format calls)
for (const arg of node.args) {
stack.push(arg);
}
} else if (node instanceof Binary) {
stack.push(node.left, node.right);
} else if (node instanceof Unary) {
stack.push(node.expr);
} else if (node instanceof Logical) {
for (const arg of node.args) {
stack.push(arg);
}
} else if (node instanceof Grouping) {
stack.push(node.group);
} else if (node instanceof IndexAccess) {
stack.push(node.expr, node.index);
}
// Literal, ContextAccess - no children to process
}
return errors;
}
/**
* Validates a single format() function call.
*
* @param fc The FunctionCall AST node
* @returns Validation error if found, undefined if valid
*/
function validateSingleFormatCall(fc: FunctionCall): FormatStringError | undefined {
// Must have at least one argument (the format string)
if (fc.args.length < 1) {
return undefined;
}
// First argument must be a string literal
const firstArg = fc.args[0];
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== Kind.String) {
return undefined; // Can't validate dynamic format strings
}
const formatString = firstArg.literal.coerceString();
const numArgs = fc.args.length - 1; // Subtract 1 for format string itself
const {valid, maxArgIndex} = validateFormatString(formatString);
if (!valid) {
return {
type: "invalid-syntax",
message: "Format string has invalid syntax (missing closing brace, unescaped braces, or invalid placeholder)"
};
}
if (maxArgIndex >= numArgs) {
return {
type: "arg-count-mismatch",
expected: maxArgIndex + 1, // Convert 0-based index to count
provided: numArgs
};
}
return undefined;
}
@@ -0,0 +1,835 @@
import {FeatureFlags} from "@actions/expressions";
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {registerLogger} from "./log.js";
import {createDocument} from "./test-utils/document.js";
import {TestLogger} from "./test-utils/logger.js";
import {clearCache} from "./utils/workflow-cache.js";
import {validate, ValidationConfig} from "./validate.js";
registerLogger(new TestLogger());
const configWithFlag: ValidationConfig = {
featureFlags: new FeatureFlags({blockScalarChompingWarning: true})
};
beforeEach(() => {
clearCache();
});
describe("block scalar chomping - warning cases", () => {
describe("step-level env values", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: |
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("does not warn with keep chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: |+
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn with strip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: |-
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("uses > indicator in warning message for folded scalars", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: >
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '>' implicitly adds a trailing newline that may be unintentional. Use '>-' to remove it, or '>+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("warns for plain string env value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: |
hello world
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("job-level env values", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
env:
MY_VAR: |
some value
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("workflow-level env values", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
env:
GLOBAL_VAR: |
some value
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("container env values", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
container:
image: node:18
env:
CONTAINER_VAR: |
some value
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("service container env values", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
services:
redis:
image: redis
env:
REDIS_PASSWORD: |
secret123
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("action input (with)", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
script: |
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("does not warn with keep chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
script: |+
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn with strip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
script: |-
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
});
describe("reusable workflow inputs (with)", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
call-workflow:
uses: ./.github/workflows/reusable.yml
with:
my-input: |
some value
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("reusable workflow secrets", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
call-workflow:
uses: ./.github/workflows/reusable.yml
secrets:
my-secret: |
\${{ secrets.TOKEN }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("job outputs", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
outputs:
my_output: |
\${{ steps.test.outputs.value }}
steps:
- id: test
run: echo "value=test" >> $GITHUB_OUTPUT
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("does not warn with strip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
outputs:
my_output: |-
\${{ steps.test.outputs.value }}
steps:
- id: test
run: echo "value=test" >> $GITHUB_OUTPUT
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
});
describe("matrix values", () => {
it("warns for matrix vector value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
config:
- |
value1
- value2
steps:
- run: echo \${{ matrix.config }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("does not warn with strip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
config:
- |-
value1
- value2
steps:
- run: echo \${{ matrix.config }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("warns for matrix include value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest]
include:
- os: |
windows-latest
special: true
steps:
- run: echo \${{ matrix.os }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("warns for matrix exclude value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [16, 18]
exclude:
- os: |
windows-latest
node: 16
steps:
- run: echo \${{ matrix.os }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("warns for deeply nested matrix value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
config:
- foo:
bar: |
baz
steps:
- run: echo \${{ matrix.config }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("warns for deeply nested matrix include value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest]
include:
- os: ubuntu-latest
config:
nested: |
value
steps:
- run: echo \${{ matrix.config }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("warns for deeply nested matrix exclude value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
exclude:
- os: windows-latest
config:
nested: |
value
steps:
- run: echo \${{ matrix.os }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("concurrency", () => {
it("warns for concurrency string with clip chomping", async () => {
const input = `
on: push
concurrency: |
my-group-\${{ github.ref }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("does not warn for concurrency with strip chomping", async () => {
const input = `
on: push
concurrency: |-
my-group-\${{ github.ref }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("warns for concurrency.group with clip chomping", async () => {
const input = `
on: push
concurrency:
group: |
my-group-\${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("warns for job-level concurrency with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
concurrency: |
job-group-\${{ github.ref }}
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
});
describe("block scalar chomping - no warning cases", () => {
describe("fields trimmed server-side", () => {
it("does not warn for job-if with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
if: |
github.ref == 'refs/heads/main'
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for step-if with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo done
if: |
github.ref == 'refs/heads/main'
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for runs-on with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: |
ubuntu-latest
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for job name with clip chomping", async () => {
const input = `
on: push
jobs:
build:
name: |
My Job
runs-on: ubuntu-latest
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for step name with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: |
My Step
run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
});
describe("run field (intentionally allowed)", () => {
it("does not warn for step run field", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
echo hello
echo world
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for run field with expression", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
echo \${{ github.ref }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
});
describe("non-block scalars", () => {
it("does not warn for quoted strings", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: "hello world"
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for flow scalars", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: hello world
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for inline expressions", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: \${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
});
});

Some files were not shown because too many files have changed in this diff Show More