From 5ad4c11cf8b25399076c0b6c5381dad1477a21fa Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 24 Feb 2026 21:05:36 +0100 Subject: [PATCH] Linter: Link Linter rule IDs to docs in CLI output --- javascript/packages/highlighter/src/color.ts | 8 ++++++++ .../packages/highlighter/src/diagnostic-renderer.ts | 8 ++++++-- javascript/packages/highlighter/src/highlighter.ts | 6 ++++++ .../highlighter/src/inline-diagnostic-renderer.ts | 9 ++++++--- .../packages/language-server/src/linter_service.ts | 4 ++-- .../linter/src/cli/formatters/detailed-formatter.ts | 8 ++++++-- .../linter/src/cli/formatters/simple-formatter.ts | 9 ++++++--- javascript/packages/linter/src/cli/summary-reporter.ts | 8 ++++++-- javascript/packages/linter/src/index.ts | 1 + javascript/packages/linter/src/urls.ts | 5 +++++ 10 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 javascript/packages/linter/src/urls.ts diff --git a/javascript/packages/highlighter/src/color.ts b/javascript/packages/highlighter/src/color.ts index bf0fd690f..be537d352 100644 --- a/javascript/packages/highlighter/src/color.ts +++ b/javascript/packages/highlighter/src/color.ts @@ -88,6 +88,14 @@ export const colorize = ( return text } +export const hyperlink = (text: string, url: string): string => { + if (process.env.NO_COLOR !== undefined) { + return text + } + + return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\` +} + export const severityColor = (severity: DiagnosticSeverity): Color => { switch (severity) { case "error": return "brightRed" diff --git a/javascript/packages/highlighter/src/diagnostic-renderer.ts b/javascript/packages/highlighter/src/diagnostic-renderer.ts index bf1e1df90..bdcdcf610 100644 --- a/javascript/packages/highlighter/src/diagnostic-renderer.ts +++ b/javascript/packages/highlighter/src/diagnostic-renderer.ts @@ -1,4 +1,4 @@ -import { colorize, severityColor, ANSI_REGEX, ANSI_REGEX_START, ANSI_ESCAPE } from "./color.js" +import { colorize, hyperlink, severityColor, ANSI_REGEX, ANSI_REGEX_START, ANSI_ESCAPE } from "./color.js" import { applyDimToStyledText } from "./util.js" import { LineWrapper } from "./line-wrapper.js" import { GUTTER_WIDTH, MIN_CONTENT_WIDTH } from "./gutter-config.js" @@ -13,6 +13,7 @@ export interface DiagnosticRenderOptions { wrapLines?: boolean maxWidth?: number truncateLines?: boolean + codeUrl?: string } export class DiagnosticRenderer { @@ -142,9 +143,12 @@ export class DiagnosticRenderer { const shouldTruncate = truncateLines const fileHeader = `${colorize(path, "cyan")}:${colorize(`${diagnostic.location.start.line}:${diagnostic.location.start.column}`, "cyan")}` + const { codeUrl } = options + const text = diagnostic.severity const color = severityColor(diagnostic.severity) - const diagnosticId = colorize(diagnostic.code || "-", "gray") + const diagnosticIdText = colorize(diagnostic.code || "-", "gray") + const diagnosticId = codeUrl ? hyperlink(diagnosticIdText, codeUrl) : diagnosticIdText const originalLines = content.split("\n") const targetLineNumber = diagnostic.location.start.line diff --git a/javascript/packages/highlighter/src/highlighter.ts b/javascript/packages/highlighter/src/highlighter.ts index 01f465084..aedd0c21d 100644 --- a/javascript/packages/highlighter/src/highlighter.ts +++ b/javascript/packages/highlighter/src/highlighter.ts @@ -20,6 +20,7 @@ export interface HighlightOptions { wrapLines?: boolean maxWidth?: number truncateLines?: boolean + codeUrlBuilder?: (code: string) => string } export interface HighlightDiagnosticOptions { @@ -29,6 +30,7 @@ export interface HighlightDiagnosticOptions { wrapLines?: boolean maxWidth?: number truncateLines?: boolean + codeUrl?: string } export class Highlighter { @@ -97,6 +99,7 @@ export class Highlighter { wrapLines = true, maxWidth = LineWrapper.getTerminalWidth(), truncateLines = false, + codeUrlBuilder, } = options // Case 1: Split diagnostics - render each diagnostic individually @@ -104,12 +107,14 @@ export class Highlighter { const results: string[] = [] for (let i = 0; i < diagnostics.length; i++) { const diagnostic = diagnostics[i] + const codeUrl = codeUrlBuilder && diagnostic.code ? codeUrlBuilder(diagnostic.code) : undefined const result = this.highlightDiagnostic(path, diagnostic, content, { contextLines, showLineNumbers, wrapLines, maxWidth, truncateLines, + codeUrl, }) results.push(result) @@ -141,6 +146,7 @@ export class Highlighter { wrapLines, maxWidth, truncateLines, + codeUrlBuilder, ) } diff --git a/javascript/packages/highlighter/src/inline-diagnostic-renderer.ts b/javascript/packages/highlighter/src/inline-diagnostic-renderer.ts index 67b4ffa15..e34658c0e 100644 --- a/javascript/packages/highlighter/src/inline-diagnostic-renderer.ts +++ b/javascript/packages/highlighter/src/inline-diagnostic-renderer.ts @@ -1,4 +1,4 @@ -import { colorize, severityColor } from "./color.js" +import { colorize, hyperlink, severityColor } from "./color.js" import { TextFormatter } from "./text-formatter.js" import { LineWrapper } from "./line-wrapper.js" import { GUTTER_WIDTH, MIN_CONTENT_WIDTH } from "./gutter-config.js" @@ -37,6 +37,7 @@ export class InlineDiagnosticRenderer { wrapLines = false, maxWidth = LineWrapper.getTerminalWidth(), truncateLines = false, + codeUrlBuilder?: (code: string) => string, ): string { const highlightedContent = this.syntaxRenderer.highlight(content) @@ -166,7 +167,8 @@ export class InlineDiagnosticRenderer { output += `${pointerPrefix}${pointerSpacing}${pointer}\n` const severityText = this.getSeverityText(diagnostic.severity) - const diagnosticId = colorize(diagnostic.code || "-", "gray") + const diagnosticIdText = colorize(diagnostic.code || "-", "gray") + const diagnosticId = codeUrlBuilder && diagnostic.code ? hyperlink(diagnosticIdText, codeUrlBuilder(diagnostic.code)) : diagnosticIdText const highlightedMessage = TextFormatter.highlightBackticks(diagnostic.message) const diagnosticText = `[${severityText}] ${highlightedMessage} (${diagnosticId})` const dimmedDiagnosticText = @@ -183,7 +185,8 @@ export class InlineDiagnosticRenderer { output += `${pointerSpacing}${pointer}\n` const severityText = this.getSeverityText(diagnostic.severity) - const diagnosticId = colorize(diagnostic.code || "-", "gray") + const diagnosticIdText = colorize(diagnostic.code || "-", "gray") + const diagnosticId = codeUrlBuilder && diagnostic.code ? hyperlink(diagnosticIdText, codeUrlBuilder(diagnostic.code)) : diagnosticIdText const highlightedMessage = TextFormatter.highlightBackticks(diagnostic.message) const diagnosticText = `[${severityText}] ${highlightedMessage} (${diagnosticId})` const dimmedDiagnosticText = diff --git a/javascript/packages/language-server/src/linter_service.ts b/javascript/packages/language-server/src/linter_service.ts index 775e3428f..d739e1e8b 100644 --- a/javascript/packages/language-server/src/linter_service.ts +++ b/javascript/packages/language-server/src/linter_service.ts @@ -1,7 +1,7 @@ import { Diagnostic, Range, Position, CodeDescription, Connection } from "vscode-languageserver/node" import { TextDocument } from "vscode-languageserver-textdocument" -import { Linter, rules, type RuleClass } from "@herb-tools/linter" +import { Linter, rules, ruleDocumentationUrl, type RuleClass } from "@herb-tools/linter" import { loadCustomRules as loadCustomRulesFromFs } from "@herb-tools/linter/loader" import { Herb } from "@herb-tools/node-wasm" import { Config } from "@herb-tools/config" @@ -158,7 +158,7 @@ export class LinterService { const codeDescription: CodeDescription = { href: customRulePath ? `file://${customRulePath}` - : `https://herb-tools.dev/linter/rules/${offense.rule}` + : ruleDocumentationUrl(offense.rule) } return { diff --git a/javascript/packages/linter/src/cli/formatters/detailed-formatter.ts b/javascript/packages/linter/src/cli/formatters/detailed-formatter.ts index 28b0870b2..8d01fe034 100644 --- a/javascript/packages/linter/src/cli/formatters/detailed-formatter.ts +++ b/javascript/packages/linter/src/cli/formatters/detailed-formatter.ts @@ -2,6 +2,7 @@ import { colorize, Highlighter, type ThemeInput, DEFAULT_THEME } from "@herb-too import { BaseFormatter } from "./base-formatter.js" import { LineWrapper } from "@herb-tools/highlighter" +import { ruleDocumentationUrl } from "../../urls.js" import type { Diagnostic } from "@herb-tools/core" import type { ProcessedFile } from "../file-processor.js" @@ -44,7 +45,8 @@ export class DetailedFormatter extends BaseFormatter { splitDiagnostics: true, contextLines: 2, wrapLines: this.wrapLines, - truncateLines: this.truncateLines + truncateLines: this.truncateLines, + codeUrlBuilder: ruleDocumentationUrl, }) console.log(`\n${highlighted}`) @@ -63,10 +65,12 @@ export class DetailedFormatter extends BaseFormatter { } } + const codeUrl = modifiedOffense.code ? ruleDocumentationUrl(modifiedOffense.code) : undefined const formatted = this.highlighter.highlightDiagnostic(filename, modifiedOffense, content, { contextLines: 2, wrapLines: this.wrapLines, - truncateLines: this.truncateLines + truncateLines: this.truncateLines, + codeUrl, }) console.log(`\n${formatted}`) diff --git a/javascript/packages/linter/src/cli/formatters/simple-formatter.ts b/javascript/packages/linter/src/cli/formatters/simple-formatter.ts index a88e3a0ed..776c9309e 100644 --- a/javascript/packages/linter/src/cli/formatters/simple-formatter.ts +++ b/javascript/packages/linter/src/cli/formatters/simple-formatter.ts @@ -1,6 +1,7 @@ -import { colorize } from "@herb-tools/highlighter" +import { colorize, hyperlink } from "@herb-tools/highlighter" import { BaseFormatter } from "./base-formatter.js" +import { ruleDocumentationUrl } from "../../urls.js" import type { Diagnostic } from "@herb-tools/core" import type { ProcessedFile } from "../file-processor.js" @@ -29,7 +30,8 @@ export class SimpleFormatter extends BaseFormatter { for (const offense of offenses) { const isError = offense.severity === "error" const severity = isError ? colorize("✗", "brightRed") : colorize("⚠", "brightYellow") - const rule = colorize(`(${offense.code})`, "blue") + const ruleText = colorize(`(${offense.code})`, "blue") + const rule = offense.code ? hyperlink(ruleText, ruleDocumentationUrl(offense.code)) : ruleText const locationString = `${offense.location.start.line}:${offense.location.start.column}` const paddedLocation = locationString.padEnd(4) @@ -44,7 +46,8 @@ export class SimpleFormatter extends BaseFormatter { for (const { offense, autocorrectable } of processedFiles) { const isError = offense.severity === "error" const severity = isError ? colorize("✗", "brightRed") : colorize("⚠", "brightYellow") - const rule = colorize(`(${offense.code})`, "blue") + const ruleText = colorize(`(${offense.code})`, "blue") + const rule = offense.code ? hyperlink(ruleText, ruleDocumentationUrl(offense.code)) : ruleText const locationString = `${offense.location.start.line}:${offense.location.start.column}` const paddedLocation = locationString.padEnd(4) const correctable = autocorrectable ? colorize(colorize(" [Correctable]", "green"), "bold") : "" diff --git a/javascript/packages/linter/src/cli/summary-reporter.ts b/javascript/packages/linter/src/cli/summary-reporter.ts index e6502a2e8..98ebabd05 100644 --- a/javascript/packages/linter/src/cli/summary-reporter.ts +++ b/javascript/packages/linter/src/cli/summary-reporter.ts @@ -1,4 +1,6 @@ -import { colorize } from "@herb-tools/highlighter" +import { colorize, hyperlink } from "@herb-tools/highlighter" + +import { ruleDocumentationUrl } from "../urls.js" export interface SummaryData { files: string[] @@ -144,7 +146,9 @@ export class SummaryReporter { for (const [rule, data] of displayedRules) { const fileCount = data.files.size const countText = `(${data.count} ${this.pluralize(data.count, "offense")} in ${fileCount} ${this.pluralize(fileCount, "file")})` - console.log(` ${colorize(rule, "gray")} ${colorize(colorize(countText, "gray"), "dim")}`) + const ruleText = colorize(rule, "gray") + const ruleLink = hyperlink(ruleText, ruleDocumentationUrl(rule)) + console.log(` ${ruleLink} ${colorize(colorize(countText, "gray"), "dim")}`) } if (remainingRules.length > 0) { diff --git a/javascript/packages/linter/src/index.ts b/javascript/packages/linter/src/index.ts index e08c1f3e5..96dab9eb3 100644 --- a/javascript/packages/linter/src/index.ts +++ b/javascript/packages/linter/src/index.ts @@ -1,5 +1,6 @@ export * from "./linter.js" export * from "./rules/index.js" export * from "./types.js" +export * from "./urls.js" export { rules } from "./rules.js" diff --git a/javascript/packages/linter/src/urls.ts b/javascript/packages/linter/src/urls.ts new file mode 100644 index 000000000..d5009c0d4 --- /dev/null +++ b/javascript/packages/linter/src/urls.ts @@ -0,0 +1,5 @@ +const DOCS_BASE_URL = "https://herb-tools.dev/linter/rules" + +export function ruleDocumentationUrl(ruleId: string): string { + return `${DOCS_BASE_URL}/${ruleId}` +}