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 6e4fe280..6dd435bb 100644 --- a/language/syntaxes/expressions.tmGrammar.json +++ b/language/syntaxes/expressions.tmGrammar.json @@ -3,43 +3,76 @@ "injectionSelector": "L:source.github-actions-workflow", "patterns": [ { - "include": "#expression" + "include": "#block-inline-expression" + }, + { + "include": "#block-if-expression" }, { "include": "#if-expression" } ], "repository": { - "expression": { - "match": "[|-]?\\$\\{\\{(.*?)\\}\\}", + "block-inline-expression": { "name": "meta.embedded.block.github-actions-expression", - "captures": { + "begin": "[|-]?\\$\\{\\{", + "end": "\\}\\}", + "patterns": [ + { + "include": "#expression" + } + ] + }, + "block-if-expression": { + "contentName": "meta.embedded.block.github-actions-expression", + "begin": "^\\s*\\b(if:)\\s+(?:(\\|)|(>))([1-9])?([-+])?(.*\\n?)", + "beginCaptures": { "1": { "patterns": [ { - "include": "#function-call" - }, - { - "include": "#context" - }, - { - "include": "#string" - }, + "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": "#number" + "include": "#comment" }, { - "include": "#boolean" - }, + "match": ".+", + "name": "invalid.illegal.expected-comment-or-newline.yaml" + } + ] + } + }, + "end": "^(?=\\S)|(?!\\G)", + "patterns": [ + { + "begin": "^([ ]+)(?! )", + "end": "^(?!\\1|\\s*$)", + "patterns": [ { - "include": "#null" + "include": "#expression" } ] } - } + ] }, "if-expression": { - "match": "\\b(if:) (.*?)$", + "match": "\\b(if:)\\s+((?:'(?:''|[^'])*'|[^#\\n])+?)(\\s+#.*)?$", "contentName": "meta.embedded.block.github-actions-expression", "captures": { "1": { @@ -52,27 +85,47 @@ "2": { "patterns": [ { - "include": "#function-call" - }, - { - "include": "#context" - }, - { - "include": "#string" - }, - { - "include": "#number" - }, - { - "include": "#boolean" - }, + "include": "#expression" + } + ] + }, + "3": { + "patterns": [ { - "include": "#null" + "include": "source.github-actions-workflow" } ] } } }, + "expression": { + "patterns": [ + { + "include": "#function-call" + }, + { + "include": "#context" + }, + { + "include": "#string" + }, + { + "include": "#op-comparison" + }, + { + "include": "#op-logical" + }, + { + "include": "#number" + }, + { + "include": "#boolean" + }, + { + "include": "#null" + } + ] + }, "function-call": { "patterns": [ { @@ -98,6 +151,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" diff --git a/language/syntaxes/github-script-embedded.tmLanguage.json b/language/syntaxes/github-script-embedded.tmLanguage.json new file mode 100644 index 00000000..3c30cabb --- /dev/null +++ b/language/syntaxes/github-script-embedded.tmLanguage.json @@ -0,0 +1,40 @@ +{ + "$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+#.*)?$)", + "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/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..7084dac6 --- /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 + ``` + +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: + +- 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/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/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/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..28d405d0 --- /dev/null +++ b/src/workflow/syntax/syntax-embeddings.test.ts @@ -0,0 +1,104 @@ +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 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); + 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(unquotedUsesLine).toBeDefined(); + expect(quotedUsesLine).toBeDefined(); + expect(unquotedUsesMatch).not.toBeNull(); + expect(quotedUsesMatch).not.toBeNull(); + 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)"); + + 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: |"); + }); +}); diff --git a/src/workflow/syntax/syntax-test-utils.ts b/src/workflow/syntax/syntax-test-utils.ts new file mode 100644 index 00000000..0a3c6824 --- /dev/null +++ b/src/workflow/syntax/syntax-test-utils.ts @@ -0,0 +1,287 @@ +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]; + 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}`); + } + + 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; +}