From 778b2604d030b92fa166d420fe9207459ae64deb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 3 May 2025 14:46:52 +0200 Subject: [PATCH 01/11] Add syntax for if-expression blocks --- language/syntaxes/expressions.tmGrammar.json | 68 +++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/language/syntaxes/expressions.tmGrammar.json b/language/syntaxes/expressions.tmGrammar.json index 6e4fe280..88cd66c9 100644 --- a/language/syntaxes/expressions.tmGrammar.json +++ b/language/syntaxes/expressions.tmGrammar.json @@ -5,6 +5,9 @@ { "include": "#expression" }, + { + "include": "#block-if-expression" + }, { "include": "#if-expression" } @@ -38,8 +41,71 @@ } } }, + "block-if-expression": { + "contentName": "meta.embedded.block.github-actions-expression", + "begin": "^\\s*\\b(if:) (?:(\\|)|(>))([1-9])?([-+])?(.*\\n?)", + "beginCaptures": { + "1": { + "patterns": [ + { + "include": "source.github-actions-workflow" + } + ] + }, + "2": { + "name": "keyword.control.flow.block-scalar.literal.yaml" + }, + "3": { + "name": "keyword.control.flow.block-scalar.folded.yaml" + }, + "4": { + "name": "constant.numeric.indentation-indicator.yaml" + }, + "5": { + "name": "storage.modifier.chomping-indicator.yaml" + }, + "6": { + "patterns": [ + { + "include": "#comment" + }, + { + "match": ".+", + "name": "invalid.illegal.expected-comment-or-newline.yaml" + } + ] + } + }, + "end": "^(?=\\S)|(?!\\G)", + "patterns": [ + { + "begin": "^([ ]+)(?! )", + "end": "^(?!\\1|\\s*$)", + "patterns": [ + { + "include": "#function-call" + }, + { + "include": "#context" + }, + { + "include": "#string" + }, + { + "include": "#number" + }, + { + "include": "#boolean" + }, + { + "include": "#null" + } + ] + } + ] + }, "if-expression": { - "match": "\\b(if:) (.*?)$", + "match": "^\\s*\\b(if:) (.*?)$", "contentName": "meta.embedded.block.github-actions-expression", "captures": { "1": { From cb13d23ab5a20aac404b9c5b84f4f5cf372acb9e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 3 May 2025 14:50:48 +0200 Subject: [PATCH 02/11] Create unified expression syntax pattern --- language/syntaxes/expressions.tmGrammar.json | 77 +++++++------------- 1 file changed, 27 insertions(+), 50 deletions(-) diff --git a/language/syntaxes/expressions.tmGrammar.json b/language/syntaxes/expressions.tmGrammar.json index 88cd66c9..b49627d0 100644 --- a/language/syntaxes/expressions.tmGrammar.json +++ b/language/syntaxes/expressions.tmGrammar.json @@ -3,7 +3,7 @@ "injectionSelector": "L:source.github-actions-workflow", "patterns": [ { - "include": "#expression" + "include": "#inline-expression" }, { "include": "#block-if-expression" @@ -13,29 +13,14 @@ } ], "repository": { - "expression": { + "inline-expression": { "match": "[|-]?\\$\\{\\{(.*?)\\}\\}", "name": "meta.embedded.block.github-actions-expression", "captures": { "1": { "patterns": [ { - "include": "#function-call" - }, - { - "include": "#context" - }, - { - "include": "#string" - }, - { - "include": "#number" - }, - { - "include": "#boolean" - }, - { - "include": "#null" + "include": "#expression" } ] } @@ -83,22 +68,7 @@ "end": "^(?!\\1|\\s*$)", "patterns": [ { - "include": "#function-call" - }, - { - "include": "#context" - }, - { - "include": "#string" - }, - { - "include": "#number" - }, - { - "include": "#boolean" - }, - { - "include": "#null" + "include": "#expression" } ] } @@ -118,27 +88,34 @@ "2": { "patterns": [ { - "include": "#function-call" - }, - { - "include": "#context" - }, - { - "include": "#string" - }, - { - "include": "#number" - }, - { - "include": "#boolean" - }, - { - "include": "#null" + "include": "#expression" } ] } } }, + "expression": { + "patterns": [ + { + "include": "#function-call" + }, + { + "include": "#context" + }, + { + "include": "#string" + }, + { + "include": "#number" + }, + { + "include": "#boolean" + }, + { + "include": "#null" + } + ] + }, "function-call": { "patterns": [ { From 0e62ee50c1823a2445b0b55dffd74fd09ce81603 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 3 May 2025 14:54:08 +0200 Subject: [PATCH 03/11] Add patterns for logical and comparison operators --- language/syntaxes/expressions.tmGrammar.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/language/syntaxes/expressions.tmGrammar.json b/language/syntaxes/expressions.tmGrammar.json index b49627d0..ab5c80ec 100644 --- a/language/syntaxes/expressions.tmGrammar.json +++ b/language/syntaxes/expressions.tmGrammar.json @@ -105,6 +105,12 @@ { "include": "#string" }, + { + "include": "#op-comparison" + }, + { + "include": "#op-logical" + }, { "include": "#number" }, @@ -141,6 +147,14 @@ "begin": "'", "end": "'" }, + "op-comparison": { + "name": "keyword.operator.comparison.github-actions-expression", + "match": "(==|!=)" + }, + "op-logical": { + "name": "keyword.operator.logical.github-actions-expression", + "match": "(&&|\\|\\|)" + }, "number": { "name": "constant.numeric.github-actions-expression", "match": "\\b[0-9]+(?:.[0-9]+)?\\b" From 809e22f3f1d1f64122fda38e87be088076c91b01 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 3 May 2025 14:55:37 +0200 Subject: [PATCH 04/11] Allow inline expressions to span multiple lines --- language/syntaxes/expressions.tmGrammar.json | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/language/syntaxes/expressions.tmGrammar.json b/language/syntaxes/expressions.tmGrammar.json index ab5c80ec..b97c1350 100644 --- a/language/syntaxes/expressions.tmGrammar.json +++ b/language/syntaxes/expressions.tmGrammar.json @@ -3,7 +3,7 @@ "injectionSelector": "L:source.github-actions-workflow", "patterns": [ { - "include": "#inline-expression" + "include": "#block-inline-expression" }, { "include": "#block-if-expression" @@ -13,18 +13,15 @@ } ], "repository": { - "inline-expression": { - "match": "[|-]?\\$\\{\\{(.*?)\\}\\}", + "block-inline-expression": { "name": "meta.embedded.block.github-actions-expression", - "captures": { - "1": { - "patterns": [ - { - "include": "#expression" - } - ] + "begin": "[|-]?\\$\\{\\{", + "end": "\\}\\}", + "patterns": [ + { + "include": "#expression" } - } + ] }, "block-if-expression": { "contentName": "meta.embedded.block.github-actions-expression", From d43c37c5cc83854043da92259afa1fd992a1b2e0 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Fri, 27 Feb 2026 10:20:23 -0800 Subject: [PATCH 05/11] Add testing harness for grammar --- language/README.md | 7 + language/syntaxes/expressions.tmGrammar.json | 9 +- src/secrets/index.test.ts | 1 + src/workflow/syntax/README.md | 124 ++++++++ src/workflow/syntax/expression-syntax.test.ts | 172 +++++++++++ .../syntax/fixtures/expression-multiline.yml | 14 + .../fixtures/expression-nested-braces.yml | 10 + .../syntax/fixtures/if-block-expression.yml | 20 ++ .../fixtures/if-comment-after-string.yml | 22 ++ .../syntax/fixtures/if-inline-edge-cases.yml | 12 + .../fixtures/inline-multiple-expressions.yml | 9 + src/workflow/syntax/syntax-test-utils.ts | 284 ++++++++++++++++++ 12 files changed, 683 insertions(+), 1 deletion(-) create mode 100644 language/README.md create mode 100644 src/workflow/syntax/README.md create mode 100644 src/workflow/syntax/expression-syntax.test.ts create mode 100644 src/workflow/syntax/fixtures/expression-multiline.yml create mode 100644 src/workflow/syntax/fixtures/expression-nested-braces.yml create mode 100644 src/workflow/syntax/fixtures/if-block-expression.yml create mode 100644 src/workflow/syntax/fixtures/if-comment-after-string.yml create mode 100644 src/workflow/syntax/fixtures/if-inline-edge-cases.yml create mode 100644 src/workflow/syntax/fixtures/inline-multiple-expressions.yml create mode 100644 src/workflow/syntax/syntax-test-utils.ts diff --git a/language/README.md b/language/README.md new file mode 100644 index 00000000..7514ef30 --- /dev/null +++ b/language/README.md @@ -0,0 +1,7 @@ +# Language Grammars And Syntax Tests + +Workflow grammar assets live in `language/syntaxes/`. + +For syntax-highlighting triage guidance and fixture-based regression test patterns, see: + +- `src/workflow/syntax/README.md` diff --git a/language/syntaxes/expressions.tmGrammar.json b/language/syntaxes/expressions.tmGrammar.json index b97c1350..c2077316 100644 --- a/language/syntaxes/expressions.tmGrammar.json +++ b/language/syntaxes/expressions.tmGrammar.json @@ -72,7 +72,7 @@ ] }, "if-expression": { - "match": "^\\s*\\b(if:) (.*?)$", + "match": "\\b(if:)\\s+((?:'(?:''|[^'])*'|[^#\\n])+?)(\\s+#.*)?$", "contentName": "meta.embedded.block.github-actions-expression", "captures": { "1": { @@ -88,6 +88,13 @@ "include": "#expression" } ] + }, + "3": { + "patterns": [ + { + "include": "source.github-actions-workflow" + } + ] } } }, diff --git a/src/secrets/index.test.ts b/src/secrets/index.test.ts index 135a8918..8de99b32 100644 --- a/src/secrets/index.test.ts +++ b/src/secrets/index.test.ts @@ -1,3 +1,4 @@ +import {describe, expect, it} from "@jest/globals"; import libsodium from "libsodium-wrappers"; import {encodeSecret} from "./index"; diff --git a/src/workflow/syntax/README.md b/src/workflow/syntax/README.md new file mode 100644 index 00000000..2dea4572 --- /dev/null +++ b/src/workflow/syntax/README.md @@ -0,0 +1,124 @@ +# Workflow Syntax Highlighting Triage & Tests + +This note documents a lightweight process for triaging syntax-highlighting bugs in workflow files and turning them into fixture-based regression tests. + +## What this covers + +This is for **TextMate grammar / tokenization** issues in: + +- `language/syntaxes/yaml.tmLanguage.json` +- `language/syntaxes/expressions.tmGrammar.json` +- workflow syntax injection grammars (for embedded languages) + +This is **not** the right path for: + +- parser/validation diagnostics from language services +- schema/completion issues +- runtime extension behavior + +## Triage checklist (quick) + +1. Reproduce in `GitHub Actions Workflow` language mode. +2. Run `Developer: Inspect Editor Tokens and Scopes`. +3. Check whether the bug is: + - wrong token scopes/colors (grammar bug) + - a diagnostic/problem message (language service/parser bug) +4. Identify likely grammar file: + - inline `${{ }}` / `if:` expression behavior: `language/syntaxes/expressions.tmGrammar.json` + - general YAML tokenization/comments/keys/scalars: `language/syntaxes/yaml.tmLanguage.json` + - embedded JS/shell/etc: injection grammar(s) +5. Add a fixture and a focused regression test before patching. + +## What to ask for in a bug report + +If the issue is syntax highlighting, ask for: + +- a minimal workflow snippet (`.yml`) +- exact line/token that looks wrong +- screenshot (optional but helpful) +- token inspector output for the wrong token (`textmate scopes`) +- expected behavior (what scope/color should have happened) + +Ideally, contributors can include a minimal repro snippet that can be copied directly into a fixture file. + +## Current test utilities + +Shared helpers live in: + +- `src/workflow/syntax/syntax-test-utils.ts` + +Current tests live in: + +- `src/workflow/syntax/*.test.ts` + +Current fixture files live in: + +- `src/workflow/syntax/fixtures/` + +The helpers are intentionally lightweight and focus on grammar-regression behavior (not VS Code integration tests). + +## Which helper to use + +- `readJson(relativePath)` + - Use to load grammar JSON files from `language/syntaxes/`. +- `readFixture(relativePath)` + - Use to load YAML fixture files from `src/workflow/syntax/fixtures/`. +- `analyzeSingleOuterEmbeddedBlockFixture(...)` + - Use when grammar has one outer context and one embedded block rule inside it (for example `github-script` + `with.script`). +- `analyzeTopLevelInjectionContexts(...)` + - Use when grammar has multiple top-level included contexts (for example `run` + `shell` per-shell contexts). +- `findGithubActionsInlineExpression(line)` + - Use in expression-regression tests that need to ensure `${{ ... }}` does not terminate on `}}` inside quoted strings (for example `#223`). + +## Fixture naming + +Use behavior-based, kebab-case fixture names: + +- format: `.yml` +- examples: + - `if-comment-after-string.yml` + - `expression-nested-braces.yml` + - `run-shell-embedded.yml` + +Avoid issue-number-only names in fixture filenames. Issue references should live in test comments or fixture comments. + +## Adding a new grammar regression test + +1. Add a minimal fixture file under `src/workflow/syntax/fixtures/` +2. Add/extend a Jest test in `src/workflow/syntax/*.test.ts` +3. Keep assertions narrow (what should be embedded, what should not be consumed, header/body boundaries, etc.) +4. Run `npm test` + +## Example: `#531`-style triage (inline comment after `if:`) + +Issue type: + +- likely grammar tokenization bug in `language/syntaxes/expressions.tmGrammar.json` +- symptom: `if: ... 'string' # comment` does not highlight the comment as a YAML comment + +Suggested test plan: + +1. Add a fixture with lines like: + +```yaml +jobs: + test: + if: matrix.os != 'macos-latest' # Cache causes errors on macOS +``` + +1. Add a focused test for the `if-expression` rule behavior in `expressions.tmGrammar.json` +2. Verify the expression matcher does not swallow the trailing comment, while preserving `#` inside quoted strings + +Note: + +- This kind of issue may need a new small helper in `syntax-test-utils.ts` for line/capture-level grammar matching, in addition to the embedded-block helpers already present. + +## Proposed pattern for community-submitted failing tests + +For syntax-highlighting bugs in this area, contributors can submit: + +1. A fixture file in `src/workflow/syntax/fixtures/` +2. A failing Jest assertion in `src/workflow/syntax/*.test.ts` +3. A short comment linking the issue number and describing the expected scopes/behavior + +That gives maintainers a reproducible regression case even before a fix is implemented. diff --git a/src/workflow/syntax/expression-syntax.test.ts b/src/workflow/syntax/expression-syntax.test.ts new file mode 100644 index 00000000..405daba0 --- /dev/null +++ b/src/workflow/syntax/expression-syntax.test.ts @@ -0,0 +1,172 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- + * Test code intentionally dereferences values after explicit existence/null checks + * to keep expectations concise and readable. + */ +import {describe, expect, it} from "@jest/globals"; +import { + findGithubActionsExpressionInText, + findGithubActionsInlineExpression, + readFixture, + readJson +} from "./syntax-test-utils"; + +type ExpressionsGrammar = { + repository: { + "block-inline-expression": { + begin: string; + end: string; + patterns: Array<{include: string}>; + }; + expression: { + patterns: Array<{include: string}>; + }; + "if-expression": { + match: string; + }; + "block-if-expression": { + begin: string; + beginCaptures: { + "6": { + patterns: Array<{include: string}>; + }; + }; + }; + "op-comparison": { + match: string; + }; + "op-logical": { + match: string; + }; + }; +}; + +function collectInlineExpressions(line: string): string[] { + const result: string[] = []; + let offset = 0; + + while (offset < line.length) { + const chunk = line.slice(offset); + const extracted = findGithubActionsInlineExpression(chunk); + if (!extracted) { + break; + } + result.push(extracted); + const localIndex = chunk.indexOf(extracted); + offset += localIndex + extracted.length; + } + + return result; +} + +describe("workflow expression syntax highlighting", () => { + // Regression test for https://github.com/github/vscode-github-actions/issues/531 + it("does not swallow trailing YAML comments on if: lines after quoted strings (#531)", () => { + const grammar = readJson("language/syntaxes/expressions.tmGrammar.json"); + const fixture = readFixture("src/workflow/syntax/fixtures/if-comment-after-string.yml"); + const ifLine = fixture.split(/\r?\n/).find(line => line.includes("if: matrix.os != 'macos-latest' #")); + + expect(ifLine).toBeDefined(); + + const ifExpression = new RegExp(grammar.repository["if-expression"].match); + const match = ifExpression.exec(ifLine!); + + expect(match).not.toBeNull(); + expect(match![1]).toBe("if:"); + + // Desired behavior: the expression capture should stop before the YAML comment. + // Current bug (#531): capture 2 includes the trailing "# ...", preventing comment tokenization. + expect(match![2]).toBe("matrix.os != 'macos-latest'"); + expect(match![3]).toBe(" # Cache causes errors on macOS"); + }); + + // Regression test for https://github.com/github/vscode-github-actions/issues/223 + it("does not terminate ${{ }} expression early when }} appears inside quoted strings", () => { + const grammar = readJson("language/syntaxes/expressions.tmGrammar.json"); + const fixture = readFixture("src/workflow/syntax/fixtures/expression-nested-braces.yml"); + const matrixLine = fixture.split(/\r?\n/).find(line => line.includes("matrix: ${{")); + + expect(matrixLine).toBeDefined(); + expect(grammar.repository["block-inline-expression"]).toBeDefined(); + expect(grammar.repository.expression.patterns).toContainEqual({include: "#string"}); + + const extracted = findGithubActionsInlineExpression(matrixLine!); + expect(extracted).toBe("${{ fromJSON(format('{{\"linting\":[\"{0}\"]}}', 'ubuntu-latest')).linting }}"); + }); + + it("supports block if-expression syntax (if: | / if: >)", () => { + const grammar = readJson("language/syntaxes/expressions.tmGrammar.json"); + const fixture = readFixture("src/workflow/syntax/fixtures/if-block-expression.yml"); + const ifLiteralLine = fixture.split(/\r?\n/).find(line => /^\s*if:\s*\|(?:\s+#.*)?$/.test(line)); + const ifFoldedLine = fixture.split(/\r?\n/).find(line => /^\s*if:\s*>(?:\s+#.*)?$/.test(line)); + + expect(ifLiteralLine).toBeDefined(); + expect(ifFoldedLine).toBeDefined(); + expect(grammar.repository["block-if-expression"]).toBeDefined(); + + const blockIfBegin = new RegExp(grammar.repository["block-if-expression"].begin); + expect(blockIfBegin.test(ifLiteralLine!)).toBe(true); + expect(blockIfBegin.test(ifFoldedLine!)).toBe(true); + }); + + it("supports multi-line inline expressions", () => { + const grammar = readJson("language/syntaxes/expressions.tmGrammar.json"); + const fixture = readFixture("src/workflow/syntax/fixtures/expression-multiline.yml"); + + expect(grammar.repository["block-inline-expression"]).toBeDefined(); + expect(grammar.repository["block-inline-expression"].begin).toContain("\\$\\{\\{"); + expect(grammar.repository["block-inline-expression"].end).toContain("\\}\\}"); + + const extracted = findGithubActionsExpressionInText(fixture); + expect(extracted).not.toBeNull(); + expect(extracted).toContain("${{ format("); + expect(extracted).toContain("github.ref"); + expect(extracted).toContain("github.sha"); + expect(extracted).toContain(") }}"); + }); + + it("supports logical and comparison operators in expression patterns", () => { + const grammar = readJson("language/syntaxes/expressions.tmGrammar.json"); + + expect(grammar.repository["op-comparison"]).toBeDefined(); + expect(grammar.repository["op-logical"]).toBeDefined(); + + const comparison = new RegExp(grammar.repository["op-comparison"].match, "g"); + const logical = new RegExp(grammar.repository["op-logical"].match, "g"); + const sample = "github.ref == 'refs/heads/main' && github.event_name != 'pull_request' || false"; + + expect(sample.match(comparison)).toEqual(["==", "!="]); + expect(sample.match(logical)).toEqual(["&&", "||"]); + }); + + it("keeps # inside quoted strings and still separates trailing comments on if: lines", () => { + const grammar = readJson("language/syntaxes/expressions.tmGrammar.json"); + const fixture = readFixture("src/workflow/syntax/fixtures/if-inline-edge-cases.yml"); + const lines = fixture.split(/\r?\n/); + const hashLine = lines.find(line => line.includes("if: contains(github.ref, '#main') #")); + const escapedLine = lines.find(line => line.includes("if: github.ref == 'it''s-main' #")); + + expect(hashLine).toBeDefined(); + expect(escapedLine).toBeDefined(); + + const ifExpression = new RegExp(grammar.repository["if-expression"].match); + const hashMatch = ifExpression.exec(hashLine!); + const escapedMatch = ifExpression.exec(escapedLine!); + + expect(hashMatch).not.toBeNull(); + expect(escapedMatch).not.toBeNull(); + expect(hashMatch![2]).toBe("contains(github.ref, '#main')"); + expect(escapedMatch![2]).toBe("github.ref == 'it''s-main'"); + expect(hashMatch![3]).toBe(" # trailing comment"); + expect(escapedMatch![3]).toBe(" # escaped quote case"); + }); + + it("handles multiple inline expressions on one line", () => { + const fixture = readFixture("src/workflow/syntax/fixtures/inline-multiple-expressions.yml"); + const combinedLine = fixture.split(/\r?\n/).find(line => line.includes("COMBINED: ")); + + expect(combinedLine).toBeDefined(); + const extracted = collectInlineExpressions(combinedLine!); + + expect(extracted).toEqual(["${{ github.ref }}", "${{ github.sha }}"]); + }); +}); diff --git a/src/workflow/syntax/fixtures/expression-multiline.yml b/src/workflow/syntax/fixtures/expression-multiline.yml new file mode 100644 index 00000000..caa4c9de --- /dev/null +++ b/src/workflow/syntax/fixtures/expression-multiline.yml @@ -0,0 +1,14 @@ +name: test + +# Regression-style fixture for multi-line inline expressions +on: workflow_dispatch + +jobs: + demo: + runs-on: ubuntu-latest + env: + CACHE_KEY: ${{ format( + '{0}-{1}', + github.ref, + github.sha + ) }} diff --git a/src/workflow/syntax/fixtures/expression-nested-braces.yml b/src/workflow/syntax/fixtures/expression-nested-braces.yml new file mode 100644 index 00000000..83e411b9 --- /dev/null +++ b/src/workflow/syntax/fixtures/expression-nested-braces.yml @@ -0,0 +1,10 @@ +name: test + +# Regression fixture for https://github.com/github/vscode-github-actions/issues/223 +on: workflow_dispatch + +jobs: + demo: + runs-on: ubuntu-latest + strategy: + matrix: ${{ fromJSON(format('{{"linting":["{0}"]}}', 'ubuntu-latest')).linting }} diff --git a/src/workflow/syntax/fixtures/if-block-expression.yml b/src/workflow/syntax/fixtures/if-block-expression.yml new file mode 100644 index 00000000..ffec02ab --- /dev/null +++ b/src/workflow/syntax/fixtures/if-block-expression.yml @@ -0,0 +1,20 @@ +name: test + +# Regression-style fixture for block if-expression syntax +on: workflow_dispatch + +jobs: + literal: + runs-on: ubuntu-latest + if: | # literal condition + github.ref == 'refs/heads/main' && + github.event_name != 'pull_request' + steps: + - run: echo "ok" + folded: + runs-on: ubuntu-latest + if: > # folded condition + github.ref == 'refs/heads/main' && + github.event_name != 'pull_request' + steps: + - run: echo "ok" diff --git a/src/workflow/syntax/fixtures/if-comment-after-string.yml b/src/workflow/syntax/fixtures/if-comment-after-string.yml new file mode 100644 index 00000000..f275d263 --- /dev/null +++ b/src/workflow/syntax/fixtures/if-comment-after-string.yml @@ -0,0 +1,22 @@ +name: test + +# Regression fixture for https://github.com/github/vscode-github-actions/issues/531 +on: workflow_dispatch + +jobs: + demo: + runs-on: ubuntu-latest + steps: + - name: Cache + uses: actions/cache@v4 + timeout-minutes: 2 + continue-on-error: true + if: matrix.os != 'macos-latest' # Cache causes errors on macOS + with: + path: | + ~/.cargo + target + key: ${{ github.job }}-${{ runner.os }}-${{ hashFiles('rust-toolchain') }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ github.job }}-${{ runner.os }}-${{ hashFiles('rust-toolchain') }}-${{ hashFiles('**/Cargo.lock') }} + ${{ github.job }}-${{ runner.os }}-${{ hashFiles('rust-toolchain') }}- diff --git a/src/workflow/syntax/fixtures/if-inline-edge-cases.yml b/src/workflow/syntax/fixtures/if-inline-edge-cases.yml new file mode 100644 index 00000000..655c2d03 --- /dev/null +++ b/src/workflow/syntax/fixtures/if-inline-edge-cases.yml @@ -0,0 +1,12 @@ +name: test + +on: workflow_dispatch + +jobs: + demo: + runs-on: ubuntu-latest + if: contains(github.ref, '#main') # trailing comment + steps: + - name: escaped quote + if: github.ref == 'it''s-main' # escaped quote case + run: echo "ok" diff --git a/src/workflow/syntax/fixtures/inline-multiple-expressions.yml b/src/workflow/syntax/fixtures/inline-multiple-expressions.yml new file mode 100644 index 00000000..6629db76 --- /dev/null +++ b/src/workflow/syntax/fixtures/inline-multiple-expressions.yml @@ -0,0 +1,9 @@ +name: test + +on: workflow_dispatch + +jobs: + demo: + runs-on: ubuntu-latest + env: + COMBINED: ${{ github.ref }}-${{ github.sha }} diff --git a/src/workflow/syntax/syntax-test-utils.ts b/src/workflow/syntax/syntax-test-utils.ts new file mode 100644 index 00000000..4e57efe6 --- /dev/null +++ b/src/workflow/syntax/syntax-test-utils.ts @@ -0,0 +1,284 @@ +import fs from "node:fs"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; + +export type GrammarRule = { + begin: string; + end: string; + patterns: Array<{include?: string; begin?: string; end?: string; contentName?: string}>; +}; + +export type EmbeddedHeader = { + lineNo: number; + repoKey: string; + embeddedScope: string; +}; + +function repoRoot(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +} + +/** + * Read and parse a JSON file from repository-relative path. + * Use for loading TextMate grammar JSON files under `language/syntaxes/`. + */ +export function readJson(relativePath: string): T { + return JSON.parse(fs.readFileSync(path.join(repoRoot(), relativePath), "utf8")) as T; +} + +/** + * Read a text fixture from repository-relative path. + * Use for loading `.yml` fixtures under `src/workflow/syntax/fixtures/`. + */ +export function readFixture(relativePath: string): string { + return fs.readFileSync(path.join(repoRoot(), relativePath), "utf8"); +} + +function blockEndRegex(template: string, indent: string): RegExp { + return new RegExp(template.replace("\\1", indent.replace(/\\/g, "\\\\"))); +} + +/** + * Analyze grammars that have a single outer context rule and one embedded block rule inside it. + * + * Typical use: + * - `actions/github-script` style rules where one step context contains a `script: |` block. + * + * Returns: + * - line numbers where embedded headers begin (`embeddedHeaderLines`) + * - line numbers treated as embedded body (`embeddedBodyLines`) + * - line numbers where outer context started (`outerStartLines`) + */ +export function analyzeSingleOuterEmbeddedBlockFixture( + text: string, + grammar: {patterns: [GrammarRule]}, + embeddedContentName: string, + markOuterStartLine?: (line: string) => boolean +): { + embeddedHeaderLines: number[]; + embeddedBodyLines: number[]; + outerStartLines: number[]; +} { + const outerRule = grammar.patterns[0]; + const embeddedRule = outerRule.patterns.find(p => p.contentName === embeddedContentName); + if (!embeddedRule?.begin || !embeddedRule.end) { + throw new Error(`Embedded rule not found for ${embeddedContentName}`); + } + + const outerBegin = new RegExp(outerRule.begin); + const outerEnd = new RegExp(outerRule.end); + const embeddedBegin = new RegExp(embeddedRule.begin); + + const lines = text.split(/\r?\n/); + const embeddedHeaderLines: number[] = []; + const embeddedBodyLines: number[] = []; + const outerStartLines: number[] = []; + + let inOuter = false; + let inEmbedded = false; + let currentEmbeddedEnd: RegExp | null = null; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const lineNo = i + 1; + + if (inOuter && inEmbedded && currentEmbeddedEnd?.test(line)) { + inEmbedded = false; + currentEmbeddedEnd = null; + } + + if (inOuter && !inEmbedded && outerEnd.test(line)) { + inOuter = false; + } + + if (!inOuter && outerBegin.test(line)) { + inOuter = true; + if (!markOuterStartLine || markOuterStartLine(line)) { + outerStartLines.push(lineNo); + } + } + + if (inOuter && !inEmbedded) { + const match = embeddedBegin.exec(line); + if (match) { + embeddedHeaderLines.push(lineNo); + inEmbedded = true; + currentEmbeddedEnd = blockEndRegex(embeddedRule.end, match[1]); + continue; + } + } + + if (inOuter && inEmbedded) { + embeddedBodyLines.push(lineNo); + } + } + + return {embeddedHeaderLines, embeddedBodyLines, outerStartLines}; +} + +type RunShellContext = { + repoKey: string; + outerBegin: RegExp; + outerEnd: RegExp; + runBegin: RegExp; + runEndTemplate: string; + embeddedScope: string; +}; + +/** + * Analyze grammars that expose multiple top-level injected contexts (usually via `patterns: [{include: ...}]`). + * + * Typical use: + * - `run` + `shell` embeddings where each shell has its own rule/context. + * + * Returns: + * - detected embedded header occurrences with line number, repository key, and embedded scope. + */ +export function analyzeTopLevelInjectionContexts( + text: string, + grammar: {patterns: Array<{include: string}>; repository: Record} +): { + embeddedHeaders: EmbeddedHeader[]; +} { + const contexts: RunShellContext[] = grammar.patterns.map(pattern => { + const repoKey = pattern.include.replace(/^#/, ""); + const rule = grammar.repository[repoKey]; + const embeddedRule = rule.patterns.find(p => p.contentName?.startsWith("meta.embedded.block.")); + if (!embeddedRule?.begin || !embeddedRule.end || !embeddedRule.contentName) { + throw new Error(`Embedded run rule not found for ${repoKey}`); + } + + return { + repoKey, + outerBegin: new RegExp(rule.begin), + outerEnd: new RegExp(rule.end), + runBegin: new RegExp(embeddedRule.begin), + runEndTemplate: embeddedRule.end, + embeddedScope: embeddedRule.contentName + }; + }); + + const embeddedHeaders: EmbeddedHeader[] = []; + const lines = text.split(/\r?\n/); + let active: RunShellContext | null = null; + let inEmbedded = false; + let currentEmbeddedEnd: RegExp | null = null; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const lineNo = i + 1; + + if (active && inEmbedded && currentEmbeddedEnd?.test(line)) { + inEmbedded = false; + currentEmbeddedEnd = null; + } + + if (active && !inEmbedded && active.outerEnd.test(line)) { + active = null; + } + + if (!active) { + active = contexts.find(ctx => ctx.outerBegin.test(line)) ?? null; + } + + if (active && !inEmbedded) { + const match = active.runBegin.exec(line); + if (match) { + embeddedHeaders.push({ + lineNo, + repoKey: active.repoKey, + embeddedScope: active.embeddedScope + }); + inEmbedded = true; + currentEmbeddedEnd = blockEndRegex(active.runEndTemplate, match[1]); + } + } + } + + return {embeddedHeaders}; +} + +/** + * Extract a `${{ ... }}` inline expression from a single line while ignoring `}}` that appear + * inside single-quoted string segments. + * + * This is a lightweight helper for expression-regression tests (for example issue #223), not a + * full parser for all expression grammar edge cases. + */ +export function findGithubActionsInlineExpression(line: string): string | null { + const start = line.indexOf("${{"); + if (start < 0) { + return null; + } + + let inSingleQuoted = false; + + for (let i = start + 3; i < line.length - 1; i += 1) { + const ch = line[i]; + const next = line[i + 1]; + + if (inSingleQuoted) { + if (ch === "'" && next === "'") { + i += 1; + continue; + } + if (ch === "'") { + inSingleQuoted = false; + } + continue; + } + + if (ch === "'") { + inSingleQuoted = true; + continue; + } + + if (ch === "}" && next === "}") { + return line.slice(start, i + 2); + } + } + + return null; +} + +/** + * Extract the first `${{ ... }}` expression from full text while ignoring `}}` that appear + * inside single-quoted string segments. + * + * Useful for regression tests that assert multi-line inline expression behavior. + */ +export function findGithubActionsExpressionInText(text: string): string | null { + const start = text.indexOf("${{"); + if (start < 0) { + return null; + } + + let inSingleQuoted = false; + + for (let i = start + 3; i < text.length - 1; i += 1) { + const ch = text[i]; + const next = text[i + 1]; + + if (inSingleQuoted) { + if (ch === "'" && next === "'") { + i += 1; + continue; + } + if (ch === "'") { + inSingleQuoted = false; + } + continue; + } + + if (ch === "'") { + inSingleQuoted = true; + continue; + } + + if (ch === "}" && next === "}") { + return text.slice(start, i + 2); + } + } + + return null; +} From cfd9be2692d945a5b5d2ba945083e5416a28cf17 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Fri, 27 Feb 2026 10:46:15 -0800 Subject: [PATCH 06/11] Fix the bullet numbering --- src/workflow/syntax/README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/workflow/syntax/README.md b/src/workflow/syntax/README.md index 2dea4572..7084dac6 100644 --- a/src/workflow/syntax/README.md +++ b/src/workflow/syntax/README.md @@ -100,14 +100,14 @@ Suggested test plan: 1. Add a fixture with lines like: -```yaml -jobs: - test: - if: matrix.os != 'macos-latest' # Cache causes errors on macOS -``` - -1. Add a focused test for the `if-expression` rule behavior in `expressions.tmGrammar.json` -2. Verify the expression matcher does not swallow the trailing comment, while preserving `#` inside quoted strings + ```yaml + jobs: + test: + if: matrix.os != 'macos-latest' # Cache causes errors on macOS + ``` + +2. Add a focused test for the `if-expression` rule behavior in `expressions.tmGrammar.json` +3. Verify the expression matcher does not swallow the trailing comment, while preserving `#` inside quoted strings Note: From 75ef67fc7fbe1c96518b67703dd5d327afc1e95d Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Fri, 27 Feb 2026 10:32:59 -0800 Subject: [PATCH 07/11] Add embedded syntax highlighting for github-script and run blocks --- .../github-script-embedded.tmLanguage.json | 51 +++++ .../run-shell-embedded.tmLanguage.json | 187 ++++++++++++++++++ package.json | 24 +++ .../fixtures/github-script-comments.yml | 16 ++ .../fixtures/github-script-embedded.yml | 21 ++ .../syntax/fixtures/run-shell-edge-cases.yml | 16 ++ .../syntax/fixtures/run-shell-embedded.yml | 33 ++++ src/workflow/syntax/syntax-embeddings.test.ts | 93 +++++++++ 8 files changed, 441 insertions(+) create mode 100644 language/syntaxes/github-script-embedded.tmLanguage.json create mode 100644 language/syntaxes/run-shell-embedded.tmLanguage.json create mode 100644 src/workflow/syntax/fixtures/github-script-comments.yml create mode 100644 src/workflow/syntax/fixtures/github-script-embedded.yml create mode 100644 src/workflow/syntax/fixtures/run-shell-edge-cases.yml create mode 100644 src/workflow/syntax/fixtures/run-shell-embedded.yml create mode 100644 src/workflow/syntax/syntax-embeddings.test.ts diff --git a/language/syntaxes/github-script-embedded.tmLanguage.json b/language/syntaxes/github-script-embedded.tmLanguage.json new file mode 100644 index 00000000..25feb000 --- /dev/null +++ b/language/syntaxes/github-script-embedded.tmLanguage.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "GitHub Actions github-script embedded JavaScript", + "scopeName": "source.github-actions-workflow.github-script-embedded", + "injectionSelector": "L:source.github-actions-workflow - meta.github-actions.github-script.context", + "patterns": [ + { + "name": "meta.github-actions.github-script.context", + "begin": "^(\\s*(?:-\\s+)?)(uses)(\\s*:\\s*)((?:\\\"actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?\\\")|(?:'actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?')|(?:actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?))(\\s*(?:#.*)?)$", + "beginCaptures": { + "2": { + "name": "entity.name.tag.yaml" + }, + "3": { + "name": "punctuation.separator.key-value.mapping.yaml" + }, + "4": { + "name": "string.unquoted.plain.out.yaml" + } + }, + "end": "^(?=\\s*-\\s|\\S|\\z)", + "patterns": [ + { + "contentName": "meta.embedded.block.javascript", + "begin": "^(\\s+)(script)(\\s*:\\s*)([>|][-+0-9\\s]*\\s*)(?:#.*)?$", + "beginCaptures": { + "2": { + "name": "entity.name.tag.yaml" + }, + "3": { + "name": "punctuation.separator.key-value.mapping.yaml" + }, + "4": { + "name": "string.unquoted.block.yaml" + } + }, + "end": "^(?!(?:\\1\\s+|\\s*$))", + "patterns": [ + { + "include": "source.js" + } + ] + }, + { + "include": "source.github-actions-workflow" + } + ] + } + ], + "repository": {} +} diff --git a/language/syntaxes/run-shell-embedded.tmLanguage.json b/language/syntaxes/run-shell-embedded.tmLanguage.json new file mode 100644 index 00000000..51e1796d --- /dev/null +++ b/language/syntaxes/run-shell-embedded.tmLanguage.json @@ -0,0 +1,187 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "GitHub Actions run+shell embedded syntax", + "scopeName": "source.github-actions-workflow.run-shell-embedded", + "injectionSelector": "L:source.github-actions-workflow - meta.github-actions.run-shell.context", + "patterns": [ + { + "include": "#shell-bash" + }, + { + "include": "#shell-powershell" + }, + { + "include": "#shell-cmd" + }, + { + "include": "#shell-python" + }, + { + "include": "#shell-node" + } + ], + "repository": { + "base-fallback": { + "patterns": [ + { + "include": "source.github-actions-workflow" + } + ] + }, + "shell-bash": { + "name": "meta.github-actions.run-shell.context", + "begin": "^(?=\\s*(?:-\\s+)?shell\\s*:\\s*(?:['\\\"])?(?:bash|sh)(?:['\\\"])?\\s*(?:#.*)?$)", + "end": "^(?=\\s*-\\s|\\S|\\z)", + "patterns": [ + { + "contentName": "meta.embedded.block.shellscript", + "begin": "^(\\s+)(run)(\\s*:\\s*)([>|][-+0-9\\s]*\\s*)(?:#.*)?$", + "beginCaptures": { + "2": { + "name": "entity.name.tag.yaml" + }, + "3": { + "name": "punctuation.separator.key-value.mapping.yaml" + }, + "4": { + "name": "string.unquoted.block.yaml" + } + }, + "end": "^(?!(?:\\1\\s+|\\s*$))", + "patterns": [ + { + "include": "source.shell" + } + ] + }, + { + "include": "#base-fallback" + } + ] + }, + "shell-powershell": { + "name": "meta.github-actions.run-shell.context", + "begin": "^(?=\\s*(?:-\\s+)?shell\\s*:\\s*(?:['\\\"])?(?:pwsh|powershell)(?:['\\\"])?\\s*(?:#.*)?$)", + "end": "^(?=\\s*-\\s|\\S|\\z)", + "patterns": [ + { + "contentName": "meta.embedded.block.powershell", + "begin": "^(\\s+)(run)(\\s*:\\s*)([>|][-+0-9\\s]*\\s*)(?:#.*)?$", + "beginCaptures": { + "2": { + "name": "entity.name.tag.yaml" + }, + "3": { + "name": "punctuation.separator.key-value.mapping.yaml" + }, + "4": { + "name": "string.unquoted.block.yaml" + } + }, + "end": "^(?!(?:\\1\\s+|\\s*$))", + "patterns": [ + { + "include": "source.powershell" + } + ] + }, + { + "include": "#base-fallback" + } + ] + }, + "shell-cmd": { + "name": "meta.github-actions.run-shell.context", + "begin": "^(?=\\s*(?:-\\s+)?shell\\s*:\\s*(?:['\\\"])?cmd(?:['\\\"])?\\s*(?:#.*)?$)", + "end": "^(?=\\s*-\\s|\\S|\\z)", + "patterns": [ + { + "contentName": "meta.embedded.block.bat", + "begin": "^(\\s+)(run)(\\s*:\\s*)([>|][-+0-9\\s]*\\s*)(?:#.*)?$", + "beginCaptures": { + "2": { + "name": "entity.name.tag.yaml" + }, + "3": { + "name": "punctuation.separator.key-value.mapping.yaml" + }, + "4": { + "name": "string.unquoted.block.yaml" + } + }, + "end": "^(?!(?:\\1\\s+|\\s*$))", + "patterns": [ + { + "include": "source.batchfile" + } + ] + }, + { + "include": "#base-fallback" + } + ] + }, + "shell-python": { + "name": "meta.github-actions.run-shell.context", + "begin": "^(?=\\s*(?:-\\s+)?shell\\s*:\\s*(?:['\\\"])?python(?:['\\\"])?\\s*(?:#.*)?$)", + "end": "^(?=\\s*-\\s|\\S|\\z)", + "patterns": [ + { + "contentName": "meta.embedded.block.python", + "begin": "^(\\s+)(run)(\\s*:\\s*)([>|][-+0-9\\s]*\\s*)(?:#.*)?$", + "beginCaptures": { + "2": { + "name": "entity.name.tag.yaml" + }, + "3": { + "name": "punctuation.separator.key-value.mapping.yaml" + }, + "4": { + "name": "string.unquoted.block.yaml" + } + }, + "end": "^(?!(?:\\1\\s+|\\s*$))", + "patterns": [ + { + "include": "source.python" + } + ] + }, + { + "include": "#base-fallback" + } + ] + }, + "shell-node": { + "name": "meta.github-actions.run-shell.context", + "begin": "^(?=\\s*(?:-\\s+)?shell\\s*:\\s*(?:['\\\"])?node(?:['\\\"])?\\s*(?:#.*)?$)", + "end": "^(?=\\s*-\\s|\\S|\\z)", + "patterns": [ + { + "contentName": "meta.embedded.block.javascript", + "begin": "^(\\s+)(run)(\\s*:\\s*)([>|][-+0-9\\s]*\\s*)(?:#.*)?$", + "beginCaptures": { + "2": { + "name": "entity.name.tag.yaml" + }, + "3": { + "name": "punctuation.separator.key-value.mapping.yaml" + }, + "4": { + "name": "string.unquoted.block.yaml" + } + }, + "end": "^(?!(?:\\1\\s+|\\s*$))", + "patterns": [ + { + "include": "source.js" + } + ] + }, + { + "include": "#base-fallback" + } + ] + } + } +} diff --git a/package.json b/package.json index 5dbb9d53..d8b7b4f5 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,30 @@ "injectTo": [ "source.github-actions-workflow" ] + }, + { + "scopeName": "source.github-actions-workflow.github-script-embedded", + "path": "./language/syntaxes/github-script-embedded.tmLanguage.json", + "injectTo": [ + "source.github-actions-workflow" + ], + "embeddedLanguages": { + "meta.embedded.block.javascript": "javascript" + } + }, + { + "scopeName": "source.github-actions-workflow.run-shell-embedded", + "path": "./language/syntaxes/run-shell-embedded.tmLanguage.json", + "injectTo": [ + "source.github-actions-workflow" + ], + "embeddedLanguages": { + "meta.embedded.block.shellscript": "shellscript", + "meta.embedded.block.powershell": "powershell", + "meta.embedded.block.bat": "bat", + "meta.embedded.block.python": "python", + "meta.embedded.block.javascript": "javascript" + } } ], "configuration": { diff --git a/src/workflow/syntax/fixtures/github-script-comments.yml b/src/workflow/syntax/fixtures/github-script-comments.yml new file mode 100644 index 00000000..abecb392 --- /dev/null +++ b/src/workflow/syntax/fixtures/github-script-comments.yml @@ -0,0 +1,16 @@ +name: test + +on: workflow_dispatch + +jobs: + demo: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v8 # unquoted uses comment + with: + script: | # comment on script header (unquoted uses) + const unquoted = true; + - uses: "actions/github-script@v8" # quoted uses comment + with: + script: | # comment on script header (quoted uses) + const quoted = true; diff --git a/src/workflow/syntax/fixtures/github-script-embedded.yml b/src/workflow/syntax/fixtures/github-script-embedded.yml new file mode 100644 index 00000000..9fb6eac6 --- /dev/null +++ b/src/workflow/syntax/fixtures/github-script-embedded.yml @@ -0,0 +1,21 @@ +name: test + +on: workflow_dispatch + +jobs: + demo: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/github-script@v8 + with: + script: | + const issue = context.issue; + await github.rest.issues.createComment({ + ...issue, + body: "hello" + }); + retries: 2 + - uses: actions/setup-node@v4 + with: + node-version: 20 diff --git a/src/workflow/syntax/fixtures/run-shell-edge-cases.yml b/src/workflow/syntax/fixtures/run-shell-edge-cases.yml new file mode 100644 index 00000000..8348d59b --- /dev/null +++ b/src/workflow/syntax/fixtures/run-shell-edge-cases.yml @@ -0,0 +1,16 @@ +name: test + +on: workflow_dispatch + +jobs: + demo: + runs-on: ubuntu-latest + steps: + - name: quoted shell with comment + shell: "pwsh" # explicit shell + run: | + Write-Host "hi" + + - name: non-block run should not embed + shell: bash + run: echo "hi" diff --git a/src/workflow/syntax/fixtures/run-shell-embedded.yml b/src/workflow/syntax/fixtures/run-shell-embedded.yml new file mode 100644 index 00000000..e76212ab --- /dev/null +++ b/src/workflow/syntax/fixtures/run-shell-embedded.yml @@ -0,0 +1,33 @@ +name: test + +jobs: + demo: + runs-on: ubuntu-latest + steps: + - name: pwsh example + shell: pwsh + env: + FOO: bar + run: | + Write-Host "hello" + + - name: bash example + shell: bash + working-directory: tools + run: > + echo hi + + - name: shell after run (documented non-match) + run: | + echo "no dynamic shell highlighting" + shell: python + + - name: quoted node shell + shell: "node" + run: | + console.log("hi") + + - name: unknown shell + shell: ruby + run: | + puts "hi" diff --git a/src/workflow/syntax/syntax-embeddings.test.ts b/src/workflow/syntax/syntax-embeddings.test.ts new file mode 100644 index 00000000..c5329808 --- /dev/null +++ b/src/workflow/syntax/syntax-embeddings.test.ts @@ -0,0 +1,93 @@ +import {describe, expect, it} from "@jest/globals"; +import { + analyzeSingleOuterEmbeddedBlockFixture, + analyzeTopLevelInjectionContexts, + type GrammarRule, + readFixture, + readJson +} from "./syntax-test-utils"; + +describe("workflow syntax embedding grammars", () => { + it("embeds JavaScript only in actions/github-script with.script block bodies", () => { + const grammar = readJson<{patterns: [GrammarRule]}>("language/syntaxes/github-script-embedded.tmLanguage.json"); + const fixture = readFixture("src/workflow/syntax/fixtures/github-script-embedded.yml"); + + const result = analyzeSingleOuterEmbeddedBlockFixture(fixture, grammar, "meta.embedded.block.javascript", line => + /uses\s*:\s*actions\/github-script\b/.test(line) + ); + const lines = fixture.split(/\r?\n/); + + expect(result.outerStartLines).toHaveLength(1); + expect(result.embeddedHeaderLines).toHaveLength(1); + expect(lines[result.outerStartLines[0] - 1]).toMatch(/uses:\s*["']?actions\/github-script@/); + expect(lines[result.embeddedHeaderLines[0] - 1]).toContain("script: |"); + + const embeddedBodyText = result.embeddedBodyLines.map(lineNo => lines[lineNo - 1]).join("\n"); + expect(embeddedBodyText).toContain("const issue = context.issue;"); + expect(embeddedBodyText).toContain('body: "hello"'); + expect(embeddedBodyText).not.toContain("uses: actions/github-script@"); + expect(embeddedBodyText).not.toContain("with:"); + expect(embeddedBodyText).not.toContain("script: |"); + expect(embeddedBodyText).not.toContain("retries:"); + }); + + it("embeds run blocks based on prior explicit shell in same step", () => { + const grammar = readJson<{ + patterns: Array<{include: string}>; + repository: Record; + }>("language/syntaxes/run-shell-embedded.tmLanguage.json"); + const fixture = readFixture("src/workflow/syntax/fixtures/run-shell-embedded.yml"); + + const result = analyzeTopLevelInjectionContexts(fixture, grammar); + const lines = fixture.split(/\r?\n/); + + expect(result.embeddedHeaders).toHaveLength(3); + expect(result.embeddedHeaders.map(h => [h.repoKey, h.embeddedScope])).toEqual([ + ["shell-powershell", "meta.embedded.block.powershell"], + ["shell-bash", "meta.embedded.block.shellscript"], + ["shell-node", "meta.embedded.block.javascript"] + ]); + for (const header of result.embeddedHeaders) { + expect(lines[header.lineNo - 1]).toContain("run:"); + } + }); + + it("supports comments on github-script uses/script headers for quoted and unquoted uses", () => { + const grammar = readJson<{patterns: [GrammarRule]}>("language/syntaxes/github-script-embedded.tmLanguage.json"); + const fixture = readFixture("src/workflow/syntax/fixtures/github-script-comments.yml"); + + const result = analyzeSingleOuterEmbeddedBlockFixture(fixture, grammar, "meta.embedded.block.javascript", line => + /uses\s*:\s*["']?actions\/github-script\b/.test(line) + ); + const lines = fixture.split(/\r?\n/); + + expect(result.outerStartLines).toHaveLength(2); + expect(result.embeddedHeaderLines).toHaveLength(2); + expect(lines[result.outerStartLines[0] - 1]).toContain("uses: actions/github-script@v8"); + expect(lines[result.outerStartLines[0] - 1]).toContain("# unquoted uses comment"); + expect(lines[result.outerStartLines[1] - 1]).toContain('uses: "actions/github-script@v8"'); + expect(lines[result.outerStartLines[1] - 1]).toContain("# quoted uses comment"); + expect(lines[result.embeddedHeaderLines[0] - 1]).toContain("# comment on script header (unquoted uses)"); + expect(lines[result.embeddedHeaderLines[1] - 1]).toContain("# comment on script header (quoted uses)"); + + const embeddedBodyText = result.embeddedBodyLines.map(lineNo => lines[lineNo - 1]).join("\n"); + expect(embeddedBodyText).toContain("const unquoted = true;"); + expect(embeddedBodyText).toContain("const quoted = true;"); + }); + + it("does not embed non-block run values even when shell is explicit", () => { + const grammar = readJson<{ + patterns: Array<{include: string}>; + repository: Record; + }>("language/syntaxes/run-shell-embedded.tmLanguage.json"); + const fixture = readFixture("src/workflow/syntax/fixtures/run-shell-edge-cases.yml"); + + const result = analyzeTopLevelInjectionContexts(fixture, grammar); + const lines = fixture.split(/\r?\n/); + + expect(result.embeddedHeaders).toHaveLength(1); + expect(result.embeddedHeaders[0].repoKey).toBe("shell-powershell"); + expect(result.embeddedHeaders[0].embeddedScope).toBe("meta.embedded.block.powershell"); + expect(lines[result.embeddedHeaders[0].lineNo - 1]).toContain("run: |"); + }); +}); From a2bd8cb8b0c68a53741d5bbe473689395115d7ba Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Fri, 27 Feb 2026 10:55:46 -0800 Subject: [PATCH 08/11] Update src/workflow/syntax/syntax-test-utils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/workflow/syntax/syntax-test-utils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/workflow/syntax/syntax-test-utils.ts b/src/workflow/syntax/syntax-test-utils.ts index 4e57efe6..0a3c6824 100644 --- a/src/workflow/syntax/syntax-test-utils.ts +++ b/src/workflow/syntax/syntax-test-utils.ts @@ -143,6 +143,9 @@ export function analyzeTopLevelInjectionContexts( const contexts: RunShellContext[] = grammar.patterns.map(pattern => { const repoKey = pattern.include.replace(/^#/, ""); const rule = grammar.repository[repoKey]; + if (!rule) { + throw new Error(`Repository rule not found for ${repoKey}`); + } const embeddedRule = rule.patterns.find(p => p.contentName?.startsWith("meta.embedded.block.")); if (!embeddedRule?.begin || !embeddedRule.end || !embeddedRule.contentName) { throw new Error(`Embedded run rule not found for ${repoKey}`); From 42b0803f4647c59c00f7ed8e5195cada5e99ea38 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Fri, 27 Feb 2026 10:57:40 -0800 Subject: [PATCH 09/11] Update language/syntaxes/expressions.tmGrammar.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- language/syntaxes/expressions.tmGrammar.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/language/syntaxes/expressions.tmGrammar.json b/language/syntaxes/expressions.tmGrammar.json index c2077316..6dd435bb 100644 --- a/language/syntaxes/expressions.tmGrammar.json +++ b/language/syntaxes/expressions.tmGrammar.json @@ -25,7 +25,7 @@ }, "block-if-expression": { "contentName": "meta.embedded.block.github-actions-expression", - "begin": "^\\s*\\b(if:) (?:(\\|)|(>))([1-9])?([-+])?(.*\\n?)", + "begin": "^\\s*\\b(if:)\\s+(?:(\\|)|(>))([1-9])?([-+])?(.*\\n?)", "beginCaptures": { "1": { "patterns": [ From a55d08a5a6bff505eded1df1753631649dc049ca Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Fri, 27 Feb 2026 11:03:08 -0800 Subject: [PATCH 10/11] Preserve YAML comments on github-script uses lines --- .../syntaxes/github-script-embedded.tmLanguage.json | 2 +- src/workflow/syntax/syntax-embeddings.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/language/syntaxes/github-script-embedded.tmLanguage.json b/language/syntaxes/github-script-embedded.tmLanguage.json index 25feb000..817fc71e 100644 --- a/language/syntaxes/github-script-embedded.tmLanguage.json +++ b/language/syntaxes/github-script-embedded.tmLanguage.json @@ -6,7 +6,7 @@ "patterns": [ { "name": "meta.github-actions.github-script.context", - "begin": "^(\\s*(?:-\\s+)?)(uses)(\\s*:\\s*)((?:\\\"actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?\\\")|(?:'actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?')|(?:actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?))(\\s*(?:#.*)?)$", + "begin": "^(\\s*(?:-\\s+)?)(uses)(\\s*:\\s*)((?:\\\"actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?\\\")|(?:'actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?')|(?:actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?))(?=\\s*(?:#.*)?$)", "beginCaptures": { "2": { "name": "entity.name.tag.yaml" diff --git a/src/workflow/syntax/syntax-embeddings.test.ts b/src/workflow/syntax/syntax-embeddings.test.ts index c5329808..bff8a237 100644 --- a/src/workflow/syntax/syntax-embeddings.test.ts +++ b/src/workflow/syntax/syntax-embeddings.test.ts @@ -55,11 +55,16 @@ describe("workflow syntax embedding grammars", () => { it("supports comments on github-script uses/script headers for quoted and unquoted uses", () => { const grammar = readJson<{patterns: [GrammarRule]}>("language/syntaxes/github-script-embedded.tmLanguage.json"); const fixture = readFixture("src/workflow/syntax/fixtures/github-script-comments.yml"); + const outerBegin = new RegExp(grammar.patterns[0].begin); const result = analyzeSingleOuterEmbeddedBlockFixture(fixture, grammar, "meta.embedded.block.javascript", line => /uses\s*:\s*["']?actions\/github-script\b/.test(line) ); const lines = fixture.split(/\r?\n/); + const unquotedUsesLine = lines.find(line => line.includes("uses: actions/github-script@v8")); + const quotedUsesLine = lines.find(line => line.includes('uses: "actions/github-script@v8"')); + const unquotedUsesMatch = unquotedUsesLine ? outerBegin.exec(unquotedUsesLine) : null; + const quotedUsesMatch = quotedUsesLine ? outerBegin.exec(quotedUsesLine) : null; expect(result.outerStartLines).toHaveLength(2); expect(result.embeddedHeaderLines).toHaveLength(2); @@ -67,6 +72,12 @@ describe("workflow syntax embedding grammars", () => { expect(lines[result.outerStartLines[0] - 1]).toContain("# unquoted uses comment"); expect(lines[result.outerStartLines[1] - 1]).toContain('uses: "actions/github-script@v8"'); expect(lines[result.outerStartLines[1] - 1]).toContain("# quoted uses comment"); + expect(unquotedUsesLine).toBeDefined(); + expect(quotedUsesLine).toBeDefined(); + expect(unquotedUsesMatch).not.toBeNull(); + expect(quotedUsesMatch).not.toBeNull(); + expect(unquotedUsesMatch![0]).toBe(" - uses: actions/github-script@v8"); + expect(quotedUsesMatch![0]).toBe(' - uses: "actions/github-script@v8"'); expect(lines[result.embeddedHeaderLines[0] - 1]).toContain("# comment on script header (unquoted uses)"); expect(lines[result.embeddedHeaderLines[1] - 1]).toContain("# comment on script header (quoted uses)"); From bb649f7a0f03be897ef3dcaeb9e3a701717410d1 Mon Sep 17 00:00:00 2001 From: Dan Crews Date: Fri, 27 Feb 2026 11:05:26 -0800 Subject: [PATCH 11/11] Let YAML tokenize github-script uses lines --- .../syntaxes/github-script-embedded.tmLanguage.json | 13 +------------ src/workflow/syntax/syntax-embeddings.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/language/syntaxes/github-script-embedded.tmLanguage.json b/language/syntaxes/github-script-embedded.tmLanguage.json index 817fc71e..3c30cabb 100644 --- a/language/syntaxes/github-script-embedded.tmLanguage.json +++ b/language/syntaxes/github-script-embedded.tmLanguage.json @@ -6,18 +6,7 @@ "patterns": [ { "name": "meta.github-actions.github-script.context", - "begin": "^(\\s*(?:-\\s+)?)(uses)(\\s*:\\s*)((?:\\\"actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?\\\")|(?:'actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?')|(?:actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?))(?=\\s*(?:#.*)?$)", - "beginCaptures": { - "2": { - "name": "entity.name.tag.yaml" - }, - "3": { - "name": "punctuation.separator.key-value.mapping.yaml" - }, - "4": { - "name": "string.unquoted.plain.out.yaml" - } - }, + "begin": "(?=^\\s*(?:-\\s+)?uses\\s*:\\s*(?:\\\"actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?\\\"|'actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?'|actions\\/github-script(?:\\@[A-Za-z0-9._-]+)?)(?:\\s+#.*)?$)", "end": "^(?=\\s*-\\s|\\S|\\z)", "patterns": [ { diff --git a/src/workflow/syntax/syntax-embeddings.test.ts b/src/workflow/syntax/syntax-embeddings.test.ts index bff8a237..28d405d0 100644 --- a/src/workflow/syntax/syntax-embeddings.test.ts +++ b/src/workflow/syntax/syntax-embeddings.test.ts @@ -76,8 +76,8 @@ describe("workflow syntax embedding grammars", () => { expect(quotedUsesLine).toBeDefined(); expect(unquotedUsesMatch).not.toBeNull(); expect(quotedUsesMatch).not.toBeNull(); - expect(unquotedUsesMatch![0]).toBe(" - uses: actions/github-script@v8"); - expect(quotedUsesMatch![0]).toBe(' - uses: "actions/github-script@v8"'); + expect(unquotedUsesMatch![0]).toBe(""); + expect(quotedUsesMatch![0]).toBe(""); expect(lines[result.embeddedHeaderLines[0] - 1]).toContain("# comment on script header (unquoted uses)"); expect(lines[result.embeddedHeaderLines[1] - 1]).toContain("# comment on script header (quoted uses)");