From 08c78d2a73302f96a9ac17b68bdbefa481e219c9 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Mon, 29 Dec 2025 13:47:30 -0600 Subject: [PATCH] Replace cron info diagnostics with inlay hints (#270) - Remove DiagnosticSeverity.Information for valid cron expressions - Add new inlay-hints.ts module with getInlayHints() function - Register inlayHintProvider capability in language server - Display human-readable cron descriptions as inline hints Related #269 --- languageserver/src/connection.ts | 13 ++- languageservice/src/index.ts | 1 + languageservice/src/inlay-hints.test.ts | 116 ++++++++++++++++++++++++ languageservice/src/inlay-hints.ts | 56 ++++++++++++ languageservice/src/validate.test.ts | 22 +---- languageservice/src/validate.ts | 11 --- 6 files changed, 186 insertions(+), 33 deletions(-) create mode 100644 languageservice/src/inlay-hints.test.ts create mode 100644 languageservice/src/inlay-hints.ts diff --git a/languageserver/src/connection.ts b/languageserver/src/connection.ts index 90b139b..35de5f6 100644 --- a/languageserver/src/connection.ts +++ b/languageserver/src/connection.ts @@ -1,4 +1,4 @@ -import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice"; +import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice"; import {registerLogger, setLogLevel} from "@actions/languageservice/log"; import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache"; import {Octokit} from "@octokit/rest"; @@ -12,6 +12,8 @@ import { HoverParams, InitializeParams, InitializeResult, + InlayHint, + InlayHintParams, TextDocumentIdentifier, TextDocumentPositionParams, TextDocuments, @@ -72,7 +74,8 @@ export function initConnection(connection: Connection) { hoverProvider: true, documentLinkProvider: { resolveProvider: false - } + }, + inlayHintProvider: true } }; @@ -158,6 +161,12 @@ export function initConnection(connection: Connection) { return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri); }); + connection.languages.inlayHint.on(async ({textDocument}: InlayHintParams): Promise => { + return timeOperation("inlayHints", () => { + return getInlayHints(getDocument(documents, textDocument)); + }); + }); + // Make the text document manager listen on the connection // for open, change and close text document events documents.listen(connection); diff --git a/languageservice/src/index.ts b/languageservice/src/index.ts index a8efda3..20c5c76 100644 --- a/languageservice/src/index.ts +++ b/languageservice/src/index.ts @@ -2,6 +2,7 @@ export {complete} from "./complete.js"; export {ContextProviderConfig} from "./context-providers/config.js"; export {documentLinks} from "./document-links.js"; export {hover} from "./hover.js"; +export {getInlayHints} from "./inlay-hints.js"; export {Logger, LogLevel, registerLogger, setLogLevel} from "./log.js"; export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate.js"; export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js"; diff --git a/languageservice/src/inlay-hints.test.ts b/languageservice/src/inlay-hints.test.ts new file mode 100644 index 0000000..41811c2 --- /dev/null +++ b/languageservice/src/inlay-hints.test.ts @@ -0,0 +1,116 @@ +import {InlayHintKind} from "vscode-languageserver-types"; +import {getInlayHints} from "./inlay-hints.js"; +import {registerLogger} from "./log.js"; +import {createDocument} from "./test-utils/document.js"; +import {TestLogger} from "./test-utils/logger.js"; +import {clearCache} from "./utils/workflow-cache.js"; + +registerLogger(new TestLogger()); + +beforeEach(() => { + clearCache(); +}); + +describe("inlay-hints", () => { + describe("cron expressions", () => { + it("returns inlay hint for valid cron expression", () => { + const input = `on: + schedule: + - cron: '0 * * * *' +`; + const document = createDocument("test.yaml", input); + const hints = getInlayHints(document); + + expect(hints).toHaveLength(1); + expect(hints[0].label).toBe("→ Runs every hour"); + expect(hints[0].kind).toBe(InlayHintKind.Parameter); + expect(hints[0].paddingLeft).toBe(true); + }); + + it("returns correct position at end of cron value", () => { + const input = `on: + schedule: + - cron: '0 3 * * 1' +`; + const document = createDocument("test.yaml", input); + const hints = getInlayHints(document); + + expect(hints).toHaveLength(1); + // Position should be at the end of the cron string value (after the closing quote) + // Line 3 (0-indexed: 2), end of '0 3 * * 1' + expect(hints[0].position.line).toBe(2); + }); + + it("returns no hint for invalid cron expression", () => { + const input = `on: + schedule: + - cron: 'invalid cron' +`; + const document = createDocument("test.yaml", input); + const hints = getInlayHints(document); + + expect(hints).toHaveLength(0); + }); + + it("returns multiple hints for multiple cron expressions", () => { + const input = `on: + schedule: + - cron: '0 * * * *' + - cron: '0 0 * * *' +`; + const document = createDocument("test.yaml", input); + const hints = getInlayHints(document); + + expect(hints).toHaveLength(2); + expect(hints[0].label).toBe("→ Runs every hour"); + expect(hints[1].label).toBe("→ Runs at 00:00"); + }); + + it("returns hint with descriptive label for weekly cron", () => { + const input = `on: + schedule: + - cron: '0 3 * * 1' +`; + const document = createDocument("test.yaml", input); + const hints = getInlayHints(document); + + expect(hints).toHaveLength(1); + expect(hints[0].label).toContain("Monday"); + }); + + it("returns no hints for empty workflow", () => { + const input = ``; + const document = createDocument("test.yaml", input); + const hints = getInlayHints(document); + + expect(hints).toHaveLength(0); + }); + + it("returns no hints for workflow without schedule", () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hello +`; + const document = createDocument("test.yaml", input); + const hints = getInlayHints(document); + + expect(hints).toHaveLength(0); + }); + + it("returns hint for frequent cron that triggers warning", () => { + // Even crons that trigger the <5min warning should still get inlay hints + const input = `on: + schedule: + - cron: '* * * * *' +`; + const document = createDocument("test.yaml", input); + const hints = getInlayHints(document); + + expect(hints).toHaveLength(1); + expect(hints[0].label).toBe("→ Runs every minute"); + }); + }); +}); diff --git a/languageservice/src/inlay-hints.ts b/languageservice/src/inlay-hints.ts new file mode 100644 index 0000000..892de43 --- /dev/null +++ b/languageservice/src/inlay-hints.ts @@ -0,0 +1,56 @@ +import {isString} from "@actions/workflow-parser"; +import {getCronDescription} from "@actions/workflow-parser/model/converter/cron"; +import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token"; +import {File} from "@actions/workflow-parser/workflows/file"; +import {TextDocument} from "vscode-languageserver-textdocument"; +import {InlayHint, InlayHintKind} from "vscode-languageserver-types"; +import {fetchOrParseWorkflow} from "./utils/workflow-cache.js"; + +/** + * Returns inlay hints for a workflow document. + * Currently supports cron expressions, showing a human-readable description + * of the schedule inline after the cron value. + * + * @param document Text document to get inlay hints for + * @returns Array of inlay hints + */ +export function getInlayHints(document: TextDocument): InlayHint[] { + const file: File = { + name: document.uri, + content: document.getText() + }; + + const result = fetchOrParseWorkflow(file, document.uri); + if (!result?.value) { + return []; + } + + const hints: InlayHint[] = []; + + // Traverse the workflow AST to find cron expressions + for (const [parent, token, key] of TemplateToken.traverse(result.value)) { + const validationToken = key || parent || token; + const validationDefinition = validationToken.definition; + + // Check for cron-pattern tokens + if (isString(token) && token.range && validationDefinition?.key === "cron-pattern") { + const cronValue = token.value; + const description = getCronDescription(cronValue); + + if (description) { + // Position the hint at the end of the cron value + hints.push({ + position: { + line: token.range.end.line - 1, // Convert from 1-based to 0-based + character: token.range.end.column - 1 // Convert from 1-based to 0-based + }, + label: `→ ${description}`, + kind: InlayHintKind.Parameter, + paddingLeft: true + }); + } + } + } + + return hints; +} diff --git a/languageservice/src/validate.test.ts b/languageservice/src/validate.test.ts index cac98de..0b2e7af 100644 --- a/languageservice/src/validate.test.ts +++ b/languageservice/src/validate.test.ts @@ -231,7 +231,7 @@ jobs: } as Diagnostic); }); - it("cron with interval of 5 minutes or more shows info", async () => { + it("cron with interval of 5 minutes or more shows no diagnostic", async () => { const result = await validate( createDocument( "wf.yaml", @@ -245,25 +245,7 @@ jobs: {valueProviderConfig: defaultValueProviders} ); - expect(result.length).toBe(1); - expect(result[0]).toEqual({ - message: "Runs every 5 minutes", - severity: DiagnosticSeverity.Information, - code: "on-schedule", - codeDescription: { - href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule" - }, - range: { - end: { - character: 25, - line: 2 - }, - start: { - character: 12, - line: 2 - } - } - } as Diagnostic); + expect(result.length).toBe(0); }); it("cron with comma-separated minutes less than 5 apart shows warning", async () => { diff --git a/languageservice/src/validate.ts b/languageservice/src/validate.ts index 2365fd6..8a023a3 100644 --- a/languageservice/src/validate.ts +++ b/languageservice/src/validate.ts @@ -258,17 +258,6 @@ function validateCronExpression(diagnostics: Diagnostic[], token: StringToken): href: CRON_SCHEDULE_DOCS_URL } }); - } else { - // Show info message for valid cron expressions - diagnostics.push({ - message: description, - range: mapRange(token.range), - severity: DiagnosticSeverity.Information, - code: "on-schedule", - codeDescription: { - href: CRON_SCHEDULE_DOCS_URL - } - }); } }