diff --git a/languageservice/src/complete.multiline-if.test.ts b/languageservice/src/complete.multiline-if.test.ts new file mode 100644 index 0000000..45944b3 --- /dev/null +++ b/languageservice/src/complete.multiline-if.test.ts @@ -0,0 +1,169 @@ +import {complete} from "./complete"; +import {TextDocument} from "vscode-languageserver-textdocument"; +import {clearCache} from "./utils/workflow-cache"; +import {getPositionFromCursor} from "./test-utils/cursor-position"; + +beforeEach(() => { + clearCache(); +}); + +describe("Issue #81 - multi-line if expression completion", () => { + it("should complete in block scalar if with | (exact position)", async () => { + // Exact reproduction from issue - cursor after "github." in block scalar + const input = `on: push + +jobs: + build: + if: | + github. + runs-on: ubuntu-latest + steps: + - run: echo`; + + const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input); + // Line 5 (0-indexed) = " github.", character 13 = after the dot + const pos = {line: 5, character: 13}; + + const result = await complete(doc, pos, {}); + + expect(result.length).toBeGreaterThan(0); + expect(result.map(x => x.label)).toContain("event"); + expect(result.map(x => x.label)).toContain("actor"); + }); + + it("should complete in block scalar if with > (exact position)", async () => { + const input = `on: push + +jobs: + build: + if: > + github. + runs-on: ubuntu-latest + steps: + - run: echo`; + + const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input); + const pos = {line: 5, character: 13}; + + const result = await complete(doc, pos, {}); + + expect(result.length).toBeGreaterThan(0); + expect(result.map(x => x.label)).toContain("event"); + }); + + it("should complete in block scalar with multiple lines", async () => { + const input = `on: push +jobs: + build: + if: | + github.event_name == 'push' && + github.| + runs-on: ubuntu-latest + steps: + - run: echo`; + + // Skip 1 to skip the `|` block scalar indicator (same character as cursor marker) + const result = await complete(...getPositionFromCursor(input, 1), {}); + + expect(result.length).toBeGreaterThan(0); + expect(result.map(x => x.label)).toContain("event"); + }); + + it("should complete step if in block scalar", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo + if: | + github. +`; + + const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input); + // Line 7 = " github.", character 15 = after the dot (8 spaces + 7 chars) + const pos = {line: 7, character: 15}; + + const result = await complete(doc, pos, {}); + + expect(result.length).toBeGreaterThan(0); + expect(result.map(x => x.label)).toContain("event"); + }); + + it("should complete in block scalar with ${{ expression markers", async () => { + // This case works because transform() skips lines with ${{ + // Note: Using explicit position because | appears in multiple places (block scalar, ||, cursor) + const input = `on: push +jobs: + build: + if: | + \${{ + github.ref == 'refs/heads/main' || + github. + runs-on: ubuntu-latest + steps: + - run: echo`; + + const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input); + // Line 6 = " github." = 8 spaces + 7 chars = 15 chars, cursor after dot is at char 15 + const pos = {line: 6, character: 15}; + + const result = await complete(doc, pos, {}); + + expect(result.length).toBeGreaterThan(0); + expect(result.map(x => x.label)).toContain("ref"); + expect(result.map(x => x.label)).toContain("ref_name"); + }); +}); + +describe("Edge cases for getOffsetInContent", () => { + it("should complete in single-line if (not block scalar)", async () => { + const input = `on: push +jobs: + build: + if: github.| + runs-on: ubuntu-latest + steps: + - run: echo`; + + const result = await complete(...getPositionFromCursor(input), {}); + + expect(result.length).toBeGreaterThan(0); + expect(result.map(x => x.label)).toContain("event"); + }); + + it("should complete on third content line of block scalar", async () => { + const input = `on: push +jobs: + build: + if: | + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + github.| + runs-on: ubuntu-latest + steps: + - run: echo`; + + const result = await complete(...getPositionFromCursor(input, 1), {}); + + expect(result.length).toBeGreaterThan(0); + expect(result.map(x => x.label)).toContain("event"); + }); + + it("should complete when block scalar has empty first line", async () => { + const input = `on: push +jobs: + build: + if: | + + github.| + runs-on: ubuntu-latest + steps: + - run: echo`; + + const result = await complete(...getPositionFromCursor(input, 1), {}); + + expect(result.length).toBeGreaterThan(0); + expect(result.map(x => x.label)).toContain("event"); + }); +}); diff --git a/languageservice/src/complete.ts b/languageservice/src/complete.ts index 943905b..1b636a6 100644 --- a/languageservice/src/complete.ts +++ b/languageservice/src/complete.ts @@ -5,6 +5,7 @@ import {ErrorPolicy} from "@actions/workflow-parser/model/convert"; import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants"; import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index"; import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token"; +import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range"; import {TokenType} from "@actions/workflow-parser/templates/tokens/types"; import {File} from "@actions/workflow-parser/workflows/file"; import {FileProvider} from "@actions/workflow-parser/workflows/file-provider"; @@ -19,7 +20,6 @@ import {isPotentiallyExpression} from "./utils/expression-detection"; import {findToken} from "./utils/find-token"; import {guessIndentation} from "./utils/indentation-guesser"; import {mapRange} from "./utils/range"; -import {getRelCharOffset} from "./utils/rel-char-pos"; import {isPlaceholder, transform} from "./utils/transform"; import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache"; import {Value, ValueProviderConfig} from "./value-providers/config"; @@ -238,12 +238,12 @@ function getExpressionCompletionItems( currentInput = stringToken.source || stringToken.value; } - const relCharOffset = getRelCharOffset(token.range, currentInput, pos); - const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim(); + const cursorOffset = getOffsetInContent(token.range, currentInput, pos); + const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim(); try { return completeExpression(expressionInput, context, [], validatorFunctions).map(item => - mapExpressionCompletionItem(item, currentInput[relCharOffset]) + mapExpressionCompletionItem(item, currentInput[cursorOffset]) ); } catch (e) { error(`Error while completing expression: '${(e as Error)?.message || ""}'`); @@ -274,3 +274,50 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable }; } + +/** + * Converts a document position to an offset within the token's content string. + */ +function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: Position): number { + const range = mapRange(tokenRange); + + if (range.start.line === range.end.line) { + // Single-line example: + // if: github.ref == 'main' + // ^8 ^15 (cursor) + // currentInput = "github.ref == 'main'" + // offset = 15 - 8 = 7 + return pos.character - range.start.character; + } + + // Multi-line example: + // if: | <- line 3 (range.start.line) + // first line <- line 4, content line 0 + // second line <- line 5, content line 1 + // github. <- line 6, content line 2, cursor at index 11 + // ^11 (cursor) + // + // currentInput = " first line\n second line\n github." + // ^0 ^15 ^32 ^43 + + // Line index within content. + // From the example: + // lineIndexWithinContent = pos.line - range.start.line - 1 + // = 6 - 3 - 1 = 2 + const lineIndexWithinContent = pos.line - range.start.line - 1; + + // Length of content before current line. + // From the example: + // lengthOfContentBeforeCurrentLine => 14 + 1 = 15 (after first iteration) + // => 31 + 1 = 32 (after second iteration) + let lengthOfContentBeforeCurrentLine = 0; + for (let i = 0; i < lineIndexWithinContent; i++) { + lengthOfContentBeforeCurrentLine = currentInput.indexOf("\n", lengthOfContentBeforeCurrentLine) + 1; + } + + // Final offset within content. + // From the example: + // finalOffset = lengthOfContentBeforeCurrentLine + pos.character + // = 32 + 11 = 43 + return lengthOfContentBeforeCurrentLine + pos.character; +} diff --git a/languageservice/src/utils/rel-char-pos.ts b/languageservice/src/utils/rel-char-pos.ts deleted file mode 100644 index 03537cf..0000000 --- a/languageservice/src/utils/rel-char-pos.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range"; -import {Position} from "vscode-languageserver-textdocument"; -import {mapRange} from "./range"; - -export function getRelCharOffset(tokenRange: TokenRange, currentInput: string, pos: Position): number { - const range = mapRange(tokenRange); - if (range.start.line !== range.end.line) { - const lines = currentInput.split("\n"); - const lineDiff = pos.line - range.start.line - 1; - const linesBeforeCusor = lines.slice(0, lineDiff); - return linesBeforeCusor.join("\n").length + pos.character + 1; - } else { - return pos.character - range.start.character; - } -}