From 667b1273e13d9768685f209fcf88c11718c8f6e2 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Sat, 22 Nov 2025 05:43:40 +0000 Subject: [PATCH] Validate expression block-scalar chomping --- ...validate.expressions-chomp-allowed.test.ts | 58 + ...te.expressions-chomp-error-boolean.test.ts | 142 ++ ...alidate.expressions-chomp-error-if.test.ts | 188 +++ ...ate.expressions-chomp-error-number.test.ts | 428 ++++++ ...ate.expressions-chomp-error-string.test.ts | 1361 +++++++++++++++++ ...validate.expressions-chomp-warning.test.ts | 227 +++ languageservice/src/validate.ts | 99 ++ workflow-parser/src/expressions.test.ts | 280 ++++ .../src/templates/template-reader.ts | 77 +- .../tokens/basic-expression-token.ts | 25 +- .../src/templates/tokens/string-token.ts | 29 +- .../src/workflows/yaml-object-reader.ts | 19 +- 12 files changed, 2923 insertions(+), 10 deletions(-) create mode 100644 languageservice/src/validate.expressions-chomp-allowed.test.ts create mode 100644 languageservice/src/validate.expressions-chomp-error-boolean.test.ts create mode 100644 languageservice/src/validate.expressions-chomp-error-if.test.ts create mode 100644 languageservice/src/validate.expressions-chomp-error-number.test.ts create mode 100644 languageservice/src/validate.expressions-chomp-error-string.test.ts create mode 100644 languageservice/src/validate.expressions-chomp-warning.test.ts diff --git a/languageservice/src/validate.expressions-chomp-allowed.test.ts b/languageservice/src/validate.expressions-chomp-allowed.test.ts new file mode 100644 index 00000000..3af1ec7a --- /dev/null +++ b/languageservice/src/validate.expressions-chomp-allowed.test.ts @@ -0,0 +1,58 @@ +import {registerLogger} from "./log"; +import {createDocument} from "./test-utils/document"; +import {TestLogger} from "./test-utils/logger"; +import {clearCache} from "./utils/workflow-cache"; +import {validate} from "./validate"; + +registerLogger(new TestLogger()); + +beforeEach(() => { + clearCache(); +}); + +describe("block scalar chomping - allowed cases", () => { + it("does NOT warn for step.run with clip chomping (exception)", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: | + echo \${{ github.event_name }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + + it("does not warn for inline expression", async () => { + const input = ` +on: push +jobs: + build: + if: \${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + + it("does not warn for quoted string", async () => { + const input = ` +on: push +jobs: + build: + if: "\${{ github.event_name == 'push' }}" + runs-on: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); +}); diff --git a/languageservice/src/validate.expressions-chomp-error-boolean.test.ts b/languageservice/src/validate.expressions-chomp-error-boolean.test.ts new file mode 100644 index 00000000..83a150c2 --- /dev/null +++ b/languageservice/src/validate.expressions-chomp-error-boolean.test.ts @@ -0,0 +1,142 @@ +import {DiagnosticSeverity} from "vscode-languageserver-types"; +import {registerLogger} from "./log"; +import {createDocument} from "./test-utils/document"; +import {TestLogger} from "./test-utils/logger"; +import {clearCache} from "./utils/workflow-cache"; +import {validate} from "./validate"; + +registerLogger(new TestLogger()); + +beforeEach(() => { + clearCache(); +}); + +describe("block scalar chomping - boolean fields", () => { + describe("job continue-on-error", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + continue-on-error: | + \${{ matrix.experimental }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks boolean evaluation. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + continue-on-error: |+ + \${{ matrix.experimental }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks boolean evaluation. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + continue-on-error: |- + \${{ matrix.experimental }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("step continue-on-error", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + continue-on-error: | + \${{ matrix.experimental }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks boolean evaluation. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + continue-on-error: |+ + \${{ matrix.experimental }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks boolean evaluation. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + continue-on-error: |- + \${{ matrix.experimental }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); +}); diff --git a/languageservice/src/validate.expressions-chomp-error-if.test.ts b/languageservice/src/validate.expressions-chomp-error-if.test.ts new file mode 100644 index 00000000..d38ddb2e --- /dev/null +++ b/languageservice/src/validate.expressions-chomp-error-if.test.ts @@ -0,0 +1,188 @@ +import {DiagnosticSeverity} from "vscode-languageserver-types"; +import {registerLogger} from "./log"; +import {createDocument} from "./test-utils/document"; +import {TestLogger} from "./test-utils/logger"; +import {clearCache} from "./utils/workflow-cache"; +import {validate} from "./validate"; + +registerLogger(new TestLogger()); + +beforeEach(() => { + clearCache(); +}); + +describe("block scalar chomping - if fields", () => { + describe("job-if", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + if: | + \${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks boolean evaluation. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + if: |+ + \${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks boolean evaluation. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + if: |- + \${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + + it("errors without ${{ }} (isExpression)", async () => { + const input = ` +on: push +jobs: + build: + if: | + github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks boolean evaluation. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("uses > indicator in error message for folded scalars", async () => { + const input = ` +on: push +jobs: + build: + if: > + \${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks boolean evaluation. Use '>-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + }); + + describe("step-if", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - if: | + \${{ github.event_name == 'push' }} + run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks boolean evaluation. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - if: |+ + \${{ github.event_name == 'push' }} + run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks boolean evaluation. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - if: |- + \${{ github.event_name == 'push' }} + run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); +}); diff --git a/languageservice/src/validate.expressions-chomp-error-number.test.ts b/languageservice/src/validate.expressions-chomp-error-number.test.ts new file mode 100644 index 00000000..76b03fef --- /dev/null +++ b/languageservice/src/validate.expressions-chomp-error-number.test.ts @@ -0,0 +1,428 @@ +import {DiagnosticSeverity} from "vscode-languageserver-types"; +import {registerLogger} from "./log"; +import {createDocument} from "./test-utils/document"; +import {TestLogger} from "./test-utils/logger"; +import {clearCache} from "./utils/workflow-cache"; +import {validate} from "./validate"; + +registerLogger(new TestLogger()); + +beforeEach(() => { + clearCache(); +}); + +describe("block scalar chomping - number fields", () => { + describe("job timeout-minutes", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: | + \${{ matrix.timeout }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks number parsing. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: |+ + \${{ matrix.timeout }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks number parsing. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: |- + \${{ matrix.timeout }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("container.ports", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: + image: node:16 + ports: | + \${{ fromJSON('[80, 443]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks number parsing. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: + image: node:16 + ports: |+ + \${{ fromJSON('[8080, 9090]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks number parsing. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: + image: node:16 + ports: |- + \${{ fromJSON('[80, 443]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("container.volumes", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: + image: node:16 + volumes: | + \${{ fromJSON('["/data:/data"]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks number parsing. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: + image: node:16 + volumes: |+ + \${{ fromJSON('["/data:/data"]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks number parsing. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: + image: node:16 + volumes: |- + \${{ fromJSON('["/data:/data"]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("services.ports", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + ports: | + \${{ fromJSON('[5432]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks number parsing. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + ports: |+ + \${{ fromJSON('[5432]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks number parsing. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + ports: |- + \${{ fromJSON('[5432]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("services.volumes", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + volumes: | + \${{ fromJSON('["/var/lib/postgresql/data"]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks number parsing. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + volumes: |+ + \${{ fromJSON('["/var/lib/postgresql/data"]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks number parsing. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + volumes: |- + \${{ fromJSON('["/var/lib/postgresql/data"]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("step timeout-minutes", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + timeout-minutes: | + \${{ matrix.timeout }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks number parsing. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + timeout-minutes: |+ + \${{ matrix.timeout }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline which breaks number parsing. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + timeout-minutes: |- + \${{ matrix.timeout }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); +}); diff --git a/languageservice/src/validate.expressions-chomp-error-string.test.ts b/languageservice/src/validate.expressions-chomp-error-string.test.ts new file mode 100644 index 00000000..ef4076c4 --- /dev/null +++ b/languageservice/src/validate.expressions-chomp-error-string.test.ts @@ -0,0 +1,1361 @@ +import {DiagnosticSeverity} from "vscode-languageserver-types"; +import {registerLogger} from "./log"; +import {createDocument} from "./test-utils/document"; +import {TestLogger} from "./test-utils/logger"; +import {clearCache} from "./utils/workflow-cache"; +import {validate} from "./validate"; + +registerLogger(new TestLogger()); + +beforeEach(() => { + clearCache(); +}); + +describe("block scalar chomping - string fields", () => { + describe("run-name", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +run-name: | + Test \${{ github.event_name }} +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +run-name: |+ + Test \${{ github.event_name }} +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +run-name: |- + Test \${{ github.event_name }} +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("job name", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + name: | + Build \${{ matrix.name }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + name: |+ + Build \${{ matrix.name }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + name: |- + Build \${{ matrix.name }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("workflow job name", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + call-workflow: + uses: ./.github/workflows/reusable.yml + name: | + Call \${{ matrix.name }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + call-workflow: + uses: ./.github/workflows/reusable.yml + name: |+ + Call \${{ matrix.name }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + call-workflow: + uses: ./.github/workflows/reusable.yml + name: |- + Call \${{ matrix.name }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("container (string form)", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: | + \${{ matrix.container }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: |+ + \${{ matrix.container }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: |- + \${{ matrix.container }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("container.image", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: + image: | + \${{ matrix.container }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: + image: |+ + \${{ matrix.image }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: + image: |- + \${{ matrix.image }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("container.credentials", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: + image: node:16 + credentials: | + \${{ secrets.DOCKER_TOKEN }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: + image: node:16 + credentials: |+ + \${{ secrets.DOCKER_TOKEN }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + container: + image: node:16 + credentials: |- + \${{ secrets.DOCKER_TOKEN }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("job defaults.run.shell", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + shell: | + \${{ matrix.shell }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + shell: |+ + \${{ matrix.shell }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + shell: |- + \${{ matrix.shell }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("job defaults.run.working-directory", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: | + \${{ matrix.dir }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: |+ + \${{ matrix.dir }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: |- + \${{ matrix.dir }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("job environment (string form)", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + environment: | + \${{ matrix.env }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + environment: |+ + \${{ matrix.env }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + environment: |- + \${{ matrix.env }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("job environment.name", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + environment: + name: | + \${{ matrix.env }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + environment: + name: |+ + \${{ matrix.env }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + environment: + name: |- + \${{ matrix.env }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("job environment.url", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + environment: + name: production + url: | + \${{ steps.deploy.outputs.url }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + environment: + name: production + url: |+ + \${{ steps.deploy.outputs.url }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + environment: + name: production + url: |- + \${{ steps.deploy.outputs.url }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("services (string form)", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + redis: | + \${{ matrix.redis-image }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + redis: |+ + \${{ matrix.redis-image }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + redis: |- + \${{ matrix.redis-image }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("services.image", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: | + \${{ matrix.postgres-version }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: |+ + \${{ matrix.postgres-version }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: |- + \${{ matrix.postgres-version }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("services.credentials", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + credentials: | + \${{ secrets.DOCKER_CREDS }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + credentials: |+ + \${{ secrets.DOCKER_CREDS }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + credentials: |- + \${{ secrets.DOCKER_CREDS }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("runs-on (string form)", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: | + \${{ matrix.os }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: |+ + \${{ matrix.os }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: |- + \${{ matrix.os }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("runs-on array item", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: + - | + \${{ matrix.os }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: + - |+ + \${{ matrix.os }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: + - |- + \${{ matrix.os }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("runs-on.group", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: + group: | + \${{ matrix.runner-group }} + labels: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: + group: |+ + \${{ matrix.runner-group }} + labels: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: + group: |- + \${{ matrix.runner-group }} + labels: ubuntu-latest + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("runs-on.labels (string form)", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: + labels: | + \${{ matrix.labels }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: + labels: |+ + \${{ matrix.labels }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: + labels: |- + \${{ matrix.labels }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("runs-on.labels array item", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: + labels: + - | + \${{ matrix.label }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: + labels: + - |+ + \${{ matrix.label }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: + labels: + - |- + \${{ matrix.label }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("step name", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: | + Test \${{ matrix.name }} + run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: |+ + Test \${{ matrix.name }} + run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: |- + Test \${{ matrix.name }} + run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("step working-directory", () => { + it("errors with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + working-directory: | + \${{ matrix.dir }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("errors with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + working-directory: |+ + \${{ matrix.dir }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "Block scalar adds trailing newline. Use '|-' to strip trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Error + }) + ); + }); + + it("does not error with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + working-directory: |- + \${{ matrix.dir }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); +}); diff --git a/languageservice/src/validate.expressions-chomp-warning.test.ts b/languageservice/src/validate.expressions-chomp-warning.test.ts new file mode 100644 index 00000000..971d7c06 --- /dev/null +++ b/languageservice/src/validate.expressions-chomp-warning.test.ts @@ -0,0 +1,227 @@ +import {DiagnosticSeverity} from "vscode-languageserver-types"; +import {registerLogger} from "./log"; +import {createDocument} from "./test-utils/document"; +import {TestLogger} from "./test-utils/logger"; +import {clearCache} from "./utils/workflow-cache"; +import {validate} from "./validate"; + +registerLogger(new TestLogger()); + +beforeEach(() => { + clearCache(); +}); + +describe("expression validation", () => { + describe("block scalar chomping - fields that warn only for clip", () => { + describe("env", () => { + it("warns with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: | + echo $VAR + env: + VAR: | + \${{ matrix.value }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline to expression result. Use '|-' to strip or '|+' to keep trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Warning + }) + ); + }); + + it("does not warn with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: | + echo $VAR + env: + VAR: |+ + \${{ matrix.value }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + + it("does not warn with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: | + echo $VAR + env: + VAR: |- + \${{ matrix.value }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + + it("uses > indicator in warning message for folded scalars", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: | + echo $VAR + env: + VAR: > + \${{ matrix.value }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline to expression result. Use '>-' to strip or '>+' to keep trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Warning + }) + ); + }); + }); + + describe("action input", () => { + it("warns with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: | + \${{ github.ref }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline to expression result. Use '|-' to strip or '|+' to keep trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Warning + }) + ); + }); + + it("does not warn with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: |+ + \${{ github.ref }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + + it("does not warn with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: |- + \${{ github.ref }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + + describe("matrix value", () => { + it("warns with clip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + version: | + \${{ fromJSON('[1, 2, 3]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toContainEqual( + expect.objectContaining({ + message: + "Block scalar adds trailing newline to expression result. Use '|-' to strip or '|+' to keep trailing newlines.", + code: "expression-block-scalar-chomping", + severity: DiagnosticSeverity.Warning + }) + ); + }); + + it("does not warn with keep chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + version: |+ + \${{ fromJSON('[1, 2, 3]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + + it("does not warn with strip chomping", async () => { + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + version: |- + \${{ fromJSON('[1, 2, 3]') }} + steps: + - run: echo hi +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result.filter(d => d.code === "expression-block-scalar-chomping")).toEqual([]); + }); + }); + }); +}); diff --git a/languageservice/src/validate.ts b/languageservice/src/validate.ts index 3bcbd0af..f310b38e 100644 --- a/languageservice/src/validate.ts +++ b/languageservice/src/validate.ts @@ -3,12 +3,14 @@ import {Expr} from "@actions/expressions/ast"; import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser"; import {ErrorPolicy} from "@actions/workflow-parser/model/convert"; import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context"; +import {Definition} from "@actions/workflow-parser/templates/schema/definition"; import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token"; import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token"; import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token"; import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range"; import {File} from "@actions/workflow-parser/workflows/file"; import {FileProvider} from "@actions/workflow-parser/workflows/file-provider"; +import {Scalar} from "yaml"; import {TextDocument} from "vscode-languageserver-textdocument"; import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types"; import {ActionMetadata, ActionReference} from "./action"; @@ -106,6 +108,8 @@ async function additionalValidations( config?.contextProviderConfig, getProviderContext(documentUri, template, root, token.range) ); + + validateChomp(diagnostics, token, parent, key, validationDefinition); } if (token.definition?.key === "regular-step" && token.range) { @@ -217,3 +221,98 @@ async function validateExpression( ); } } + +function validateChomp( + diagnostics: Diagnostic[], + token: BasicExpressionToken, + parent: TemplateToken | undefined, + key: TemplateToken | undefined, + validationDefinition: Definition | undefined +): void { + // Not "clip" or "keep" chomp style? + if (token.chompStyle !== "clip" && token.chompStyle !== "keep") { + return; + } + + // No definition? This can happen when the token is in an invalid position or the workflow has parse errors. + if (!validationDefinition) { + return; + } + + // Step "run"? + if (parent?.definition?.key === "run-step" && key?.isScalar && key.toString() === "run") { + return; + } + + // Block scalar indicator, i.e. | or > + const scalarIndicator = token.scalarType === Scalar.BLOCK_LITERAL ? "|" : ">"; + + const defKey = validationDefinition.key; + const parentDefKey = parent?.definition?.key; + + // Error for boolean fields + if ( + defKey === "job-if" || + defKey === "step-if" || + defKey === "step-continue-on-error" || + (parentDefKey === "job-factory" && key?.isScalar && key.toString() === "continue-on-error") + ) { + diagnostics.push({ + message: `Block scalar adds trailing newline which breaks boolean evaluation. Use '${scalarIndicator}-' to strip trailing newlines.`, + range: mapRange(token.range), + severity: DiagnosticSeverity.Error, + code: "expression-block-scalar-chomping", + source: "github-actions" + }); + } + // Error for number fields + else if ( + defKey === "step-timeout-minutes" || + (parentDefKey === "container-mapping" && key?.isScalar && ["ports", "volumes"].includes(key.toString())) || + (parentDefKey === "job-factory" && key?.isScalar && key.toString() === "timeout-minutes") + ) { + diagnostics.push({ + message: `Block scalar adds trailing newline which breaks number parsing. Use '${scalarIndicator}-' to strip trailing newlines.`, + range: mapRange(token.range), + severity: DiagnosticSeverity.Error, + code: "expression-block-scalar-chomping", + source: "github-actions" + }); + } + // Error for specific string fields + else if ( + defKey === "run-name" || + defKey === "step-name" || + defKey === "container" || + defKey === "services-container" || + defKey === "job-environment" || + defKey === "job-environment-name" || + defKey === "runs-on" || + defKey === "runs-on-labels" || + (parentDefKey === "container-mapping" && key?.isScalar && ["image", "credentials"].includes(key.toString())) || + (parentDefKey === "job-defaults-run" && key?.isScalar && ["shell", "working-directory"].includes(key.toString())) || + (parentDefKey === "job-environment-mapping" && key?.isScalar && key.toString() === "url") || + (parentDefKey === "job-factory" && key?.isScalar && key.toString() === "name") || + (parentDefKey === "workflow-job" && key?.isScalar && key.toString() === "name") || + (parentDefKey === "run-step" && key?.isScalar && key.toString() === "working-directory") || + (parentDefKey === "runs-on-mapping" && key?.isScalar && key.toString() === "group") + ) { + diagnostics.push({ + message: `Block scalar adds trailing newline. Use '${scalarIndicator}-' to strip trailing newlines.`, + range: mapRange(token.range), + severity: DiagnosticSeverity.Error, + code: "expression-block-scalar-chomping", + source: "github-actions" + }); + } + // Warning for everything else, but only on clip (default) + else if (token.chompStyle === "clip") { + diagnostics.push({ + message: `Block scalar adds trailing newline to expression result. Use '${scalarIndicator}-' to strip or '${scalarIndicator}+' to keep trailing newlines.`, + range: mapRange(token.range), + severity: DiagnosticSeverity.Warning, + code: "expression-block-scalar-chomping", + source: "github-actions" + }); + } +} diff --git a/workflow-parser/src/expressions.test.ts b/workflow-parser/src/expressions.test.ts index cf22c62f..6dd4e735 100644 --- a/workflow-parser/src/expressions.test.ts +++ b/workflow-parser/src/expressions.test.ts @@ -200,4 +200,284 @@ jobs: throw new Error("expected if to be a basic expression"); } }); + + describe("Block scalar chomp style preservation", () => { + it("preserves clip chomping (|) for literal block scalar", () => { + const result = parseWorkflow( + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + if: | + \${{ github.event_name == 'push' }} + steps: + - run: echo hi` + }, + nullTrace + ); + + expect(result.context.errors.getErrors()).toHaveLength(0); + + const workflowRoot = result.value!.assertMapping("root")!; + const jobs = workflowRoot.get(1).value.assertMapping("jobs"); + const build = jobs.get(0).value.assertMapping("job"); + const ifToken = build.get(1).value; + + if (!isBasicExpression(ifToken)) { + throw new Error("expected if to be a basic expression"); + } + + expect(ifToken.scalarType).toBe("BLOCK_LITERAL"); + expect(ifToken.chompStyle).toBe("clip"); + }); + + it("preserves strip chomping (|-) for literal block scalar", () => { + const result = parseWorkflow( + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + if: |- + \${{ github.event_name == 'push' }} + steps: + - run: echo hi` + }, + nullTrace + ); + + expect(result.context.errors.getErrors()).toHaveLength(0); + + const workflowRoot = result.value!.assertMapping("root")!; + const jobs = workflowRoot.get(1).value.assertMapping("jobs"); + const build = jobs.get(0).value.assertMapping("job"); + const ifToken = build.get(1).value; + + if (!isBasicExpression(ifToken)) { + throw new Error("expected if to be a basic expression"); + } + + expect(ifToken.scalarType).toBe("BLOCK_LITERAL"); + expect(ifToken.chompStyle).toBe("strip"); + }); + + it("preserves keep chomping (|+) for literal block scalar", () => { + const result = parseWorkflow( + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + if: |+ + \${{ github.event_name == 'push' }} + steps: + - run: echo hi` + }, + nullTrace + ); + + expect(result.context.errors.getErrors()).toHaveLength(0); + + const workflowRoot = result.value!.assertMapping("root")!; + const jobs = workflowRoot.get(1).value.assertMapping("jobs"); + const build = jobs.get(0).value.assertMapping("job"); + const ifToken = build.get(1).value; + + if (!isBasicExpression(ifToken)) { + throw new Error("expected if to be a basic expression"); + } + + expect(ifToken.scalarType).toBe("BLOCK_LITERAL"); + expect(ifToken.chompStyle).toBe("keep"); + }); + + it("preserves folded clip (>) chomping", () => { + const result = parseWorkflow( + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + if: > + \${{ github.event_name == 'push' }} + steps: + - run: echo hi` + }, + nullTrace + ); + + expect(result.context.errors.getErrors()).toHaveLength(0); + + const workflowRoot = result.value!.assertMapping("root")!; + const jobs = workflowRoot.get(1).value.assertMapping("jobs"); + const build = jobs.get(0).value.assertMapping("job"); + const ifToken = build.get(1).value; + + if (!isBasicExpression(ifToken)) { + throw new Error("expected if to be a basic expression"); + } + + expect(ifToken.scalarType).toBe("BLOCK_FOLDED"); + expect(ifToken.chompStyle).toBe("clip"); + }); + + it("preserves folded strip (>-) chomping", () => { + const result = parseWorkflow( + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + if: >- + \${{ github.event_name == 'push' }} + steps: + - run: echo hi` + }, + nullTrace + ); + + expect(result.context.errors.getErrors()).toHaveLength(0); + + const workflowRoot = result.value!.assertMapping("root")!; + const jobs = workflowRoot.get(1).value.assertMapping("jobs"); + const build = jobs.get(0).value.assertMapping("job"); + const ifToken = build.get(1).value; + + if (!isBasicExpression(ifToken)) { + throw new Error("expected if to be a basic expression"); + } + + expect(ifToken.scalarType).toBe("BLOCK_FOLDED"); + expect(ifToken.chompStyle).toBe("strip"); + }); + + it("preserves with explicit indent (|2)", () => { + const result = parseWorkflow( + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + if: |2 + \${{ github.event_name == 'push' }} + steps: + - run: echo hi` + }, + nullTrace + ); + + expect(result.context.errors.getErrors()).toHaveLength(0); + + const workflowRoot = result.value!.assertMapping("root")!; + const jobs = workflowRoot.get(1).value.assertMapping("jobs"); + const build = jobs.get(0).value.assertMapping("job"); + const ifToken = build.get(1).value; + + if (!isBasicExpression(ifToken)) { + throw new Error("expected if to be a basic expression"); + } + + expect(ifToken.scalarType).toBe("BLOCK_LITERAL"); + expect(ifToken.chompStyle).toBe("clip"); + }); + + it("preserves with explicit indent and strip (|-2)", () => { + const result = parseWorkflow( + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + if: |-2 + \${{ github.event_name == 'push' }} + steps: + - run: echo hi` + }, + nullTrace + ); + + expect(result.context.errors.getErrors()).toHaveLength(0); + + const workflowRoot = result.value!.assertMapping("root")!; + const jobs = workflowRoot.get(1).value.assertMapping("jobs"); + const build = jobs.get(0).value.assertMapping("job"); + const ifToken = build.get(1).value; + + if (!isBasicExpression(ifToken)) { + throw new Error("expected if to be a basic expression"); + } + + expect(ifToken.scalarType).toBe("BLOCK_LITERAL"); + expect(ifToken.chompStyle).toBe("strip"); + }); + + it("handles flow scalars (no chomp info for inline)", () => { + const result = parseWorkflow( + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + if: \${{ github.event_name == 'push' }} + steps: + - run: echo hi` + }, + nullTrace + ); + + expect(result.context.errors.getErrors()).toHaveLength(0); + + const workflowRoot = result.value!.assertMapping("root")!; + const jobs = workflowRoot.get(1).value.assertMapping("jobs"); + const build = jobs.get(0).value.assertMapping("job"); + const ifToken = build.get(1).value; + + if (!isBasicExpression(ifToken)) { + throw new Error("expected if to be a basic expression"); + } + + expect(ifToken.scalarType).toBeUndefined(); + expect(ifToken.chompStyle).toBeUndefined(); + }); + + it("handles job-if without ${{ }} (isExpression case)", () => { + const result = parseWorkflow( + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + if: | + github.event_name == 'push' + steps: + - run: echo hi` + }, + nullTrace + ); + + expect(result.context.errors.getErrors()).toHaveLength(0); + + const workflowRoot = result.value!.assertMapping("root")!; + const jobs = workflowRoot.get(1).value.assertMapping("jobs"); + const build = jobs.get(0).value.assertMapping("job"); + const ifToken = build.get(1).value; + + if (!isBasicExpression(ifToken)) { + throw new Error("expected if to be a basic expression"); + } + + expect(ifToken.scalarType).toBe("BLOCK_LITERAL"); + expect(ifToken.chompStyle).toBe("clip"); + }); + }); }); diff --git a/workflow-parser/src/templates/template-reader.ts b/workflow-parser/src/templates/template-reader.ts index abeb362c..b59830c9 100644 --- a/workflow-parser/src/templates/template-reader.ts +++ b/workflow-parser/src/templates/template-reader.ts @@ -1,6 +1,7 @@ // template-reader *just* does schema validation /* eslint-disable @typescript-eslint/no-non-null-assertion */ +import {Scalar} from "yaml"; import {ObjectReader} from "./object-reader"; import {TemplateSchema} from "./schema"; import {DefinitionInfo} from "./schema/definition-info"; @@ -459,7 +460,20 @@ class TemplateReader { // Doesn't contain "${{" // Check if value should still be evaluated as an expression if (definitionInfo.definition instanceof StringDefinition && definitionInfo.definition.isExpression) { - const expression = this.parseIntoExpressionToken(token.range!, raw, allowedContext, token, definitionInfo); + // For isExpression fields (e.g., 'if' conditions), parse token.value (not raw). + // Example YAML: + // if: | + // github.event_name == 'push' + // token.source/raw = "|\n github.event_name == 'push'\n" (includes block scalar header) + // token.value = "github.event_name == 'push'\n" (clean expression content) + // We need token.value because the '|' would interfere with expression parsing. + const expression = this.parseIntoExpressionToken( + token.range!, + token.value, + allowedContext, + token, + definitionInfo + ); if (expression) { return expression; } @@ -606,13 +620,17 @@ class TemplateReader { } } + const blockScalarInfo = parseBlockScalarInfo(token); return new BasicExpressionToken( this._fileId, token.range, `format('${format.join("")}'${args.join("")})`, definitionInfo, expressionTokens, - raw + raw, + undefined, + blockScalarInfo.scalarType, + blockScalarInfo.chompStyle ); } @@ -686,6 +704,7 @@ class TemplateReader { }; // Return the expression + const blockScalarInfo = parseBlockScalarInfo(token); return { expression: new BasicExpressionToken( this._fileId, @@ -694,7 +713,9 @@ class TemplateReader { definitionInfo, undefined, token.source, - expressionRange + expressionRange, + blockScalarInfo.scalarType, + blockScalarInfo.chompStyle ), error: undefined }; @@ -801,3 +822,53 @@ interface MatchDirectiveResult { parameters: string[]; error: Error | undefined; } + +interface BlockScalarInfo { + scalarType: Scalar.Type | undefined; + chompStyle: "clip" | "strip" | "keep" | undefined; +} + +/** + * Parse the block scalar info from the StringToken + * @param token The StringToken that may contain block scalar information + * @returns The scalar type and chomp style + */ +function parseBlockScalarInfo(token: StringToken): BlockScalarInfo { + const scalarType = token.scalarType; + + // Only block scalars have chomp styles + if (scalarType !== Scalar.BLOCK_LITERAL && scalarType !== Scalar.BLOCK_FOLDED) { + return {scalarType: undefined, chompStyle: undefined}; + } + + // Parse chomp style from the block scalar header + // Look for block scalar indicators at the start: | or > + // Followed by optional chomp indicator (-, +) and/or explicit indent (digit) + // Examples: |, |-, |+, |2, |-2, |+2, >, >-, >+, >2, >-2, >+2 + const header = token.blockScalarHeader; + if (!header) { + // If there's no header, assume clip (default) + return {scalarType, chompStyle: "clip"}; + } + + const blockScalarMatch = header.match(/^(\||>)([-+])?(\d)?/); + + if (!blockScalarMatch) { + // Assume clip if we can't parse the indicator + return {scalarType, chompStyle: "clip"}; + } + + const chompIndicator = blockScalarMatch[2]; + + let chompStyle: "clip" | "strip" | "keep"; + if (chompIndicator === "-") { + chompStyle = "strip"; + } else if (chompIndicator === "+") { + chompStyle = "keep"; + } else { + // No chomp indicator means clip (default) + chompStyle = "clip"; + } + + return {scalarType, chompStyle}; +} diff --git a/workflow-parser/src/templates/tokens/basic-expression-token.ts b/workflow-parser/src/templates/tokens/basic-expression-token.ts index 01c7a448..7321aa8d 100644 --- a/workflow-parser/src/templates/tokens/basic-expression-token.ts +++ b/workflow-parser/src/templates/tokens/basic-expression-token.ts @@ -1,3 +1,4 @@ +import {Scalar} from "yaml"; import {DefinitionInfo} from "../schema/definition-info"; import {CLOSE_EXPRESSION, OPEN_EXPRESSION} from "../template-constants"; @@ -23,6 +24,16 @@ export class BasicExpressionToken extends ExpressionToken { */ public readonly expressionRange: TokenRange | undefined; + /** + * The YAML scalar type (e.g., BLOCK_LITERAL, BLOCK_FOLDED, PLAIN, etc.) if the expression was parsed from a block scalar. + */ + public readonly scalarType: Scalar.Type | undefined; + + /** + * The chomp style of the block scalar: 'clip' (default, keeps one newline), 'strip' (removes all), or 'keep' (keeps all). + */ + public readonly chompStyle: "clip" | "strip" | "keep" | undefined; + /** * @param originalExpressions If the basic expression was transformed from individual expressions, these will be the original ones */ @@ -33,13 +44,17 @@ export class BasicExpressionToken extends ExpressionToken { definitionInfo: DefinitionInfo | undefined, originalExpressions: BasicExpressionToken[] | undefined, source: string | undefined, - expressionRange?: TokenRange | undefined + expressionRange?: TokenRange | undefined, + scalarType?: Scalar.Type | undefined, + chompStyle?: "clip" | "strip" | "keep" | undefined ) { super(TokenType.BasicExpression, file, range, undefined, definitionInfo); this.expr = expression; this.source = source; this.originalExpressions = originalExpressions; this.expressionRange = expressionRange; + this.scalarType = scalarType; + this.chompStyle = chompStyle; } public get expression(): string { @@ -55,7 +70,9 @@ export class BasicExpressionToken extends ExpressionToken { this.definitionInfo, this.originalExpressions, this.source, - this.expressionRange + this.expressionRange, + this.scalarType, + this.chompStyle ) : new BasicExpressionToken( this.file, @@ -64,7 +81,9 @@ export class BasicExpressionToken extends ExpressionToken { this.definitionInfo, this.originalExpressions, this.source, - this.expressionRange + this.expressionRange, + this.scalarType, + this.chompStyle ); } diff --git a/workflow-parser/src/templates/tokens/string-token.ts b/workflow-parser/src/templates/tokens/string-token.ts index bc32d654..e6dd014f 100644 --- a/workflow-parser/src/templates/tokens/string-token.ts +++ b/workflow-parser/src/templates/tokens/string-token.ts @@ -1,3 +1,4 @@ +import {Scalar} from "yaml"; import {LiteralToken, TemplateToken} from "."; import {DefinitionInfo} from "../schema/definition-info"; import {TokenRange} from "./token-range"; @@ -6,23 +7,45 @@ import {TokenType} from "./types"; export class StringToken extends LiteralToken { public readonly value: string; public readonly source: string | undefined; + public readonly scalarType: Scalar.Type | undefined; + public readonly blockScalarHeader: string | undefined; public constructor( file: number | undefined, range: TokenRange | undefined, value: string, definitionInfo: DefinitionInfo | undefined, - source?: string + source?: string, + scalarType?: Scalar.Type, + blockScalarHeader?: string ) { super(TokenType.String, file, range, definitionInfo); this.value = value; this.source = source; + this.scalarType = scalarType; + this.blockScalarHeader = blockScalarHeader; } public override clone(omitSource?: boolean): TemplateToken { return omitSource - ? new StringToken(undefined, undefined, this.value, this.definitionInfo, this.source) - : new StringToken(this.file, this.range, this.value, this.definitionInfo, this.source); + ? new StringToken( + undefined, + undefined, + this.value, + this.definitionInfo, + this.source, + this.scalarType, + this.blockScalarHeader + ) + : new StringToken( + this.file, + this.range, + this.value, + this.definitionInfo, + this.source, + this.scalarType, + this.blockScalarHeader + ); } public override toString(): string { diff --git a/workflow-parser/src/workflows/yaml-object-reader.ts b/workflow-parser/src/workflows/yaml-object-reader.ts index ce93d9ab..741813ee 100644 --- a/workflow-parser/src/workflows/yaml-object-reader.ts +++ b/workflow-parser/src/workflows/yaml-object-reader.ts @@ -117,11 +117,28 @@ export class YamlObjectReader implements ObjectReader { return new BooleanToken(fileId, range, value, undefined); case "string": { let source: string | undefined; + const scalarType = token.type; + let blockScalarHeader: string | undefined; + if (token.srcToken && "source" in token.srcToken) { source = token.srcToken.source; + // Extract block scalar header. For example |-, |+, >- + // + // This relies on undocumented internal behavior (srcToken.props). + // Feature request for official support: https://github.com/eemeli/yaml/issues/643 + if (token.srcToken.type === "block-scalar" && "props" in token.srcToken) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const props = token.srcToken.props as any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const headerProp = props.find((p: any) => p.type === "block-scalar-header"); + if (headerProp && "source" in headerProp) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + blockScalarHeader = headerProp.source; + } + } } - return new StringToken(fileId, range, value, undefined, source); + return new StringToken(fileId, range, value, undefined, source, scalarType, blockScalarHeader); } default: throw new Error(`Unexpected value type '${typeof value}' when reading object`);