diff --git a/javascript/packages/highlighter/src/diagnostic-markers.ts b/javascript/packages/highlighter/src/diagnostic-markers.ts new file mode 100644 index 000000000..38ed7e1ee --- /dev/null +++ b/javascript/packages/highlighter/src/diagnostic-markers.ts @@ -0,0 +1,52 @@ +import type { SerializedLocation } from "@herb-tools/core" + +/** + * A single `~~~` underline for one line of a (possibly multi-line) diagnostic. + * + * `start` and `end` are zero-based indices into the source line, `end` being exclusive. + */ +export interface DiagnosticMarker { + line: number + start: number + end: number +} + +/** + * Compute the underline markers for a diagnostic location. + * + * A diagnostic spanning multiple lines gets one marker per line: the first line is + * marked from `location.start.column`, the last line up to `location.end.column`, and + * every line in between is marked across its non-whitespace content. Lines without any + * content to mark (blank or whitespace-only) are skipped. + * + * @param location - The diagnostic location + * @param lines - The source content split into lines + * @returns One marker per marked line, ordered from first to last, never empty + */ +export function computeDiagnosticMarkers(location: SerializedLocation, lines: string[]): DiagnosticMarker[] { + const startLine = Math.max(1, location.start.line) + const endLine = Math.max(startLine, Math.min(location.end.line, Math.max(lines.length, startLine))) + + const markers: DiagnosticMarker[] = [] + + for (let lineNumber = startLine; lineNumber <= endLine; lineNumber++) { + const line = lines[lineNumber - 1] ?? "" + const isFirstLine = lineNumber === startLine + const isLastLine = lineNumber === location.end.line + + const start = Math.max(0, isFirstLine ? location.start.column : line.length - line.trimStart().length) + const end = isLastLine ? location.end.column : line.trimEnd().length + + if (end > start) { + markers.push({ line: lineNumber, start, end }) + } + } + + if (markers.length === 0) { + const start = Math.max(0, location.start.column) + + markers.push({ line: startLine, start, end: start + 1 }) + } + + return markers +} diff --git a/javascript/packages/highlighter/src/diagnostic-renderer.ts b/javascript/packages/highlighter/src/diagnostic-renderer.ts index a5492fb92..563eec181 100644 --- a/javascript/packages/highlighter/src/diagnostic-renderer.ts +++ b/javascript/packages/highlighter/src/diagnostic-renderer.ts @@ -2,7 +2,9 @@ import { colorize, hyperlink, severityColor, ANSI_REGEX, ANSI_REGEX_START, ANSI_ import { applyDimToStyledText } from "./util.js" import { LineWrapper } from "./line-wrapper.js" import { GUTTER_WIDTH, MIN_CONTENT_WIDTH } from "./gutter-config.js" +import { computeDiagnosticMarkers } from "./diagnostic-markers.js" +import type { DiagnosticMarker } from "./diagnostic-markers.js" import type { SyntaxRenderer } from "./syntax-renderer.js" import type { Diagnostic } from "@herb-tools/core" @@ -134,7 +136,7 @@ export class DiagnosticRenderer { ): string { const { contextLines = 2, - showLineNumbers: _showLineNumbers = true, // eslint-disable-line no-unused-vars + showLineNumbers = true, optimizeHighlighting = true, wrapLines = true, maxWidth = LineWrapper.getTerminalWidth(), @@ -153,11 +155,15 @@ export class DiagnosticRenderer { const diagnosticId = codeUrl ? hyperlink(diagnosticIdText, codeUrl) : diagnosticIdText const originalLines = content.split("\n") - const targetLineNumber = diagnostic.location.start.line - const column = diagnostic.location.start.column - 1 - const startLine = Math.max(1, targetLineNumber - contextLines) - const endLine = Math.min(originalLines.length, targetLineNumber + contextLines) + const markers = computeDiagnosticMarkers(diagnostic.location, originalLines) + const markersByLine = new Map(markers.map(marker => [marker.line, marker])) + + const firstMarkedLine = markers[0].line + const lastMarkedLine = markers[markers.length - 1].line + + const startLine = Math.max(1, firstMarkedLine - contextLines) + const endLine = Math.min(originalLines.length, lastMarkedLine + contextLines) let lines: string[] let lineOffset = 0 @@ -180,13 +186,21 @@ export class DiagnosticRenderer { lineOffset = 0 } + const separator = colorize("│", "gray") + const gutterPrefix = showLineNumbers ? ` ${separator} ` : "" + const availableWidth = showLineNumbers + ? Math.max(MIN_CONTENT_WIDTH, maxWidth - GUTTER_WIDTH) + : maxWidth + let contextOutput = "" - let adjustedColumn = column - let adjustedPointerLength = Math.max(1, diagnostic.location.end.column - diagnostic.location.start.column) for (let i = startLine; i <= endLine; i++) { const line = lines[i - 1 - lineOffset] || "" - const isTargetLine = i === targetLineNumber + const marker = markersByLine.get(i) + const isTargetLine = marker !== undefined + + let markerStart = marker ? marker.start : 0 + let markerLength = marker ? Math.max(1, marker.end - marker.start) : 0 const lineNumber = isTargetLine ? colorize(i.toString().padStart(3, " "), "bold") @@ -196,69 +210,54 @@ export class DiagnosticRenderer { ? colorize(" → ", color) : " " - const separator = colorize("│", "gray") - - let displayLine = line + const linePrefix = showLineNumbers ? `${prefix}${lineNumber} ${separator} ` : "" - if (isTargetLine) { - displayLine = line - } else { - displayLine = applyDimToStyledText(line) - } + const displayLine = isTargetLine ? line : applyDimToStyledText(line) if (shouldWrap) { - const linePrefix = `${prefix}${lineNumber} ${separator} ` - const availableWidth = Math.max(MIN_CONTENT_WIDTH, maxWidth - GUTTER_WIDTH) const wrappedLines = LineWrapper.wrapLine(displayLine, availableWidth, "") for (let j = 0; j < wrappedLines.length; j++) { if (j === 0) { contextOutput += `${linePrefix}${wrappedLines[j]}\n` } else { - contextOutput += ` ${separator} ${wrappedLines[j]}\n` + contextOutput += `${gutterPrefix}${wrappedLines[j]}\n` } } } else if (shouldTruncate) { - const linePrefix = `${prefix}${lineNumber} ${separator} ` - const availableWidth = Math.max(MIN_CONTENT_WIDTH, maxWidth - GUTTER_WIDTH) - let truncatedLine: string - if (isTargetLine) { - const diagnosticEnd = diagnostic.location.end.column - 1 - const result = this.truncateLineForDiagnostic(displayLine, column, diagnosticEnd, availableWidth) + if (marker) { + const result = this.truncateLineForDiagnostic(displayLine, marker.start, marker.end, availableWidth) truncatedLine = result.line - adjustedColumn = result.adjustedStart - adjustedPointerLength = Math.max(1, result.adjustedEnd - result.adjustedStart) + markerStart = result.adjustedStart + markerLength = Math.max(1, result.adjustedEnd - result.adjustedStart) } else { truncatedLine = LineWrapper.truncateLine(displayLine, availableWidth) } contextOutput += `${linePrefix}${truncatedLine}\n` } else { - contextOutput += `${prefix}${lineNumber} ${separator} ${displayLine}\n` + contextOutput += `${linePrefix}${displayLine}\n` } - if (isTargetLine) { - const pointerPrefix = ` ${colorize("│", "gray")}` - const pointerSpacing = " ".repeat(adjustedColumn + 2) - const adjustedPointer = colorize( - "~".repeat(adjustedPointerLength), - color, - ) - contextOutput += `${pointerPrefix}${pointerSpacing}${adjustedPointer}\n` + if (marker) { + const pointerPrefix = showLineNumbers ? ` ${separator}` : "" + const pointerSpacing = " ".repeat(Math.max(0, markerStart + (showLineNumbers ? 1 : 0))) + const pointer = colorize("~".repeat(markerLength), color) + + contextOutput += `${pointerPrefix}${pointerSpacing}${pointer}\n` } } const highlightedMessage = this.highlightBackticks(diagnostic.message) const { suffix } = options const suffixText = suffix ? ` ${suffix}` : "" + const header = showLineNumbers ? `${fileHeader}\n\n` : "" return `[${text}] ${highlightedMessage} (${diagnosticId})${suffixText} -${fileHeader} - -${contextOutput.trimEnd()} +${header}${contextOutput.trimEnd()} ` } } diff --git a/javascript/packages/highlighter/src/index.ts b/javascript/packages/highlighter/src/index.ts index faddfe9ed..de61201f6 100644 --- a/javascript/packages/highlighter/src/index.ts +++ b/javascript/packages/highlighter/src/index.ts @@ -1,4 +1,5 @@ export * from "./color.js" +export * from "./diagnostic-markers.js" export * from "./diagnostic-renderer.js" export * from "./file-renderer.js" export * from "./highlighter.js" diff --git a/javascript/packages/highlighter/src/inline-diagnostic-renderer.ts b/javascript/packages/highlighter/src/inline-diagnostic-renderer.ts index f295b8950..1c2899d8e 100644 --- a/javascript/packages/highlighter/src/inline-diagnostic-renderer.ts +++ b/javascript/packages/highlighter/src/inline-diagnostic-renderer.ts @@ -3,9 +3,17 @@ import { TextFormatter } from "./text-formatter.js" import { LineWrapper } from "./line-wrapper.js" import { GUTTER_WIDTH, MIN_CONTENT_WIDTH } from "./gutter-config.js" import { DIAGNOSTIC_SEVERITIES } from "@herb-tools/core" +import { computeDiagnosticMarkers } from "./diagnostic-markers.js" import type { Diagnostic, DiagnosticSeverity } from "@herb-tools/core" import type { SyntaxRenderer } from "./syntax-renderer.js" +import type { DiagnosticMarker } from "./diagnostic-markers.js" + +interface LineMarker { + diagnostic: Diagnostic + marker: DiagnosticMarker + isLastLine: boolean +} export class InlineDiagnosticRenderer { private syntaxRenderer: SyntaxRenderer @@ -40,16 +48,23 @@ export class InlineDiagnosticRenderer { codeUrlBuilder?: (code: string) => string, ): string { const highlightedContent = this.syntaxRenderer.highlight(content) + const contentLines = content.split("\n") - const diagnosticsByLine = new Map() + const markersByLine = new Map() for (const diagnostic of diagnostics) { - const lineNumber = diagnostic.location.start.line + const markers = computeDiagnosticMarkers(diagnostic.location, contentLines) - if (!diagnosticsByLine.has(lineNumber)) { - diagnosticsByLine.set(lineNumber, []) - } + markers.forEach((marker, index) => { + if (!markersByLine.has(marker.line)) { + markersByLine.set(marker.line, []) + } - diagnosticsByLine.get(lineNumber)!.push(diagnostic) + markersByLine.get(marker.line)!.push({ + diagnostic, + marker, + isLastLine: index === markers.length - 1, + }) + }) } const severityOrder: Record = { @@ -59,24 +74,26 @@ export class InlineDiagnosticRenderer { "hint": 3 } - for (const lineDiagnostics of diagnosticsByLine.values()) { - lineDiagnostics.sort((a, b) => { - const orderA = severityOrder[a.severity] ?? 99 - const orderB = severityOrder[b.severity] ?? 99 + for (const lineMarkers of markersByLine.values()) { + lineMarkers.sort((a, b) => { + const orderA = severityOrder[a.diagnostic.severity] ?? 99 + const orderB = severityOrder[b.diagnostic.severity] ?? 99 return orderA - orderB }) } const lines = highlightedContent.split("\n") let output = showLineNumbers ? `${colorize(path, "cyan")}\n\n` : "" - let previousLineHadDiagnostics = false + let previousLineHadMessages = false for (let i = 1; i <= lines.length; i++) { const line = lines[i - 1] || "" - const lineDiagnostics = diagnosticsByLine.get(i) || [] - const hasDiagnostics = lineDiagnostics.length > 0 + const lineMarkers = markersByLine.get(i) || [] + const lineDiagnostics = lineMarkers.map(({ diagnostic }) => diagnostic) + const hasDiagnostics = lineMarkers.length > 0 + const hasMessages = lineMarkers.some(({ isLastLine }) => isLastLine) - if (hasDiagnostics && previousLineHadDiagnostics) { + if (hasDiagnostics && previousLineHadMessages) { output += "\n" } @@ -149,56 +166,47 @@ export class InlineDiagnosticRenderer { } if (hasDiagnostics) { - for (const diagnostic of lineDiagnostics) { - const column = diagnostic.location.start.column - 1 - const pointerLength = Math.max( - 1, - diagnostic.location.end.column - diagnostic.location.start.column, + for (const { diagnostic, marker, isLastLine } of lineMarkers) { + const pointerLength = Math.max(1, marker.end - marker.start) + const pointer = colorize( + "~".repeat(pointerLength), + severityColor(diagnostic.severity), ) + const severityText = this.getSeverityText(diagnostic.severity) + const diagnosticIdText = diagnostic.code || "-" + const diagnosticId = codeUrlBuilder && diagnostic.code ? hyperlink(diagnosticIdText, codeUrlBuilder(diagnostic.code)) : diagnosticIdText + const highlightedMessage = TextFormatter.highlightBackticks(diagnostic.message) + const diagnosticText = `[${severityText}] ${highlightedMessage} (${diagnosticId})` + const dimmedDiagnosticText = + TextFormatter.applyDimToStyledText(diagnosticText) + if (showLineNumbers) { const pointerPrefix = ` ${colorize("│", "gray")}` - const pointerSpacing = " ".repeat(column + 2) - const pointer = colorize( - "~".repeat(pointerLength), - severityColor(diagnostic.severity), - ) + const pointerSpacing = " ".repeat(Math.max(0, marker.start + 1)) output += `${pointerPrefix}${pointerSpacing}${pointer}\n` - const severityText = this.getSeverityText(diagnostic.severity) - const diagnosticIdText = diagnostic.code || "-" - const diagnosticId = codeUrlBuilder && diagnostic.code ? hyperlink(diagnosticIdText, codeUrlBuilder(diagnostic.code)) : diagnosticIdText - const highlightedMessage = TextFormatter.highlightBackticks(diagnostic.message) - const diagnosticText = `[${severityText}] ${highlightedMessage} (${diagnosticId})` - const dimmedDiagnosticText = - TextFormatter.applyDimToStyledText(diagnosticText) - - output += `${pointerPrefix}${pointerSpacing}${dimmedDiagnosticText}\n` + if (isLastLine) { + output += `${pointerPrefix}${pointerSpacing}${dimmedDiagnosticText}\n` + } } else { - const pointerSpacing = " ".repeat(column) - const pointer = colorize( - "~".repeat(pointerLength), - severityColor(diagnostic.severity), - ) + const pointerSpacing = " ".repeat(Math.max(0, marker.start)) output += `${pointerSpacing}${pointer}\n` - const severityText = this.getSeverityText(diagnostic.severity) - const diagnosticIdText = diagnostic.code || "-" - const diagnosticId = codeUrlBuilder && diagnostic.code ? hyperlink(diagnosticIdText, codeUrlBuilder(diagnostic.code)) : diagnosticIdText - const highlightedMessage = TextFormatter.highlightBackticks(diagnostic.message) - const diagnosticText = `[${severityText}] ${highlightedMessage} (${diagnosticId})` - const dimmedDiagnosticText = - TextFormatter.applyDimToStyledText(diagnosticText) - - output += `${dimmedDiagnosticText}\n` + if (isLastLine) { + output += `${dimmedDiagnosticText}\n` + } } } - output += "\n" + + if (hasMessages) { + output += "\n" + } } - previousLineHadDiagnostics = hasDiagnostics + previousLineHadMessages = hasMessages } return output.trimEnd() diff --git a/javascript/packages/highlighter/test/__snapshots__/diagnostic-renderer.test.ts.snap b/javascript/packages/highlighter/test/__snapshots__/diagnostic-renderer.test.ts.snap new file mode 100644 index 000000000..69d14c823 --- /dev/null +++ b/javascript/packages/highlighter/test/__snapshots__/diagnostic-renderer.test.ts.snap @@ -0,0 +1,264 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DiagnosticRenderer > ANSI-aware truncation preserves styling > should preserve colors when extracting from end of styled line 1`] = ` +"[error] End content issue (end-content) + +/test/file.erb:1:95 + + →  1 │ …-too-long">ShortEnd… + │ ~~~~~~~~ +" +`; + +exports[`DiagnosticRenderer > ANSI-aware truncation preserves styling > should preserve colors when extracting from middle of styled line 1`] = ` +"[error] Middle issue (middle-content) + +/test/file.erb:1:30 + + →  1 │ …aaaaaaaaa-MIDDLE_TARGET-bbbbbbbbbb… + │ ~~~~~~~~~~~~~~ +" +`; + +exports[`DiagnosticRenderer > ANSI-aware truncation preserves styling > should preserve syntax highlighting colors in truncated output 1`] = ` +"[error] Line too long (line-length) + +/test/file.erb:1:1 + + →  1 │ <div class="this-is-a-very-long-cl… + │ ~~~~ +" +`; + +exports[`DiagnosticRenderer > NO_COLOR environment > should respect NO_COLOR environment variable 1`] = ` +"[error] Test error message (test-rule) + +/test/file.erb:2:5 + + 1 │ line 1 + → 2 │ line content + │ ~~~~~ + 3 │ line 3 +" +`; + +exports[`DiagnosticRenderer > error handling > should handle invalid column numbers gracefully 1`] = ` +"[error] Test error message (test-rule) + +/test/file.erb:2:999 + + 1 │ line 1 + → 2 │ short + │ ~ + 3 │ line 3 +" +`; + +exports[`DiagnosticRenderer > error handling > should handle invalid line numbers gracefully 1`] = ` +"[error] Test error message (test-rule) + +/test/file.erb:999:1 + + +" +`; + +exports[`DiagnosticRenderer > multi-line diagnostics > aligns markers to the content when line numbers are hidden 1`] = ` +"[warning] Multi-line offense (multi-line-rule) + + +
+ <% @gems.each do |topic_gem| %> + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + <%= render partial: "gem_card" %> + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + <% end %> + ~~~~~~~~~ +
+" +`; + +exports[`DiagnosticRenderer > multi-line diagnostics > does not mark blank lines inside the span 1`] = ` +"[error] Test error message (test-rule) + +/test/file.erb:1:0 + + → 1 │ <% if true %> + │ ~~~~~~~~~~~~~ + 2 │ + → 3 │ <% end %> + │ ~~~~~~~~~ +" +`; + +exports[`DiagnosticRenderer > multi-line diagnostics > keeps a single marker for single-line diagnostics 1`] = ` +"[error] Test error message (test-rule) + +/test/file.erb:2:5 + + 1 │ line 1 + → 2 │ line content + │ ~~~~~ + 3 │ line 3 +" +`; + +exports[`DiagnosticRenderer > multi-line diagnostics > marks every line the diagnostic spans and expands the trailing context off the end line 1`] = ` +"[warning] Multi-line offense (multi-line-rule) + +/test/file.erb:4:4 + + 2 │ + 3 │
+ → 4 │ <% @gems.each do |topic_gem| %> + │ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + → 5 │ <%= render partial: "gem_card" %> + │ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + → 6 │ <% end %> + │ ~~~~~~~~~ + 7 │
+ 8 │ +" +`; + +exports[`DiagnosticRenderer > renderSingle > should handle custom context lines 1`] = ` +"[error] Test error message (test-rule) + +/test/file.erb:5:1 + + 4 │ line 4 + → 5 │ line 5 error + │ ~~~~ + 6 │ line 6 +" +`; + +exports[`DiagnosticRenderer > renderSingle > should handle edge cases for line boundaries 1`] = ` +"[error] Test error message (test-rule) + +/test/file.erb:1:1 + + → 1 │ single line + │ ~~~~ +" +`; + +exports[`DiagnosticRenderer > renderSingle > should handle multi-character error ranges 1`] = ` +"[error] Test error message (test-rule) + +/test/file.erb:2:5 + + 1 │ line 1 + → 2 │ line content + │ ~~~~~~~~~~ + 3 │ line 3 +" +`; + +exports[`DiagnosticRenderer > renderSingle > should hide line numbers when requested 1`] = ` +"[error] Test error message (test-rule) + +line 1 +line content + ~~~~~ +line 3 +" +`; + +exports[`DiagnosticRenderer > renderSingle > should highlight backticks in messages 1`] = ` +"[error] Error with \`code\` in message (test-rule) + +/test/file.erb:2:5 + + 1 │ line 1 + → 2 │ line content + │ ~~~~~ + 3 │ line 3 +" +`; + +exports[`DiagnosticRenderer > renderSingle > should render a single error diagnostic 1`] = ` +"[error] Test error message (test-rule) + +/test/file.erb:2:5 + + 1 │ line 1 + → 2 │ line content + │ ~~~~~ + 3 │ line 3 +" +`; + +exports[`DiagnosticRenderer > renderSingle > should render a single warning diagnostic 1`] = ` +"[warning] Test error message (test-rule) + +/test/file.erb:2:5 + + 1 │ line 1 + → 2 │ line content + │ ~~~~~ + 3 │ line 3 +" +`; + +exports[`DiagnosticRenderer > smart diagnostic truncation > should adjust pointer position correctly for truncated diagnostics 1`] = ` +"[error] Test diagnostic positioning (test-positioning) + +/test/file.erb:1:50 + + → 1 │ …ame-that-should-be-truncated">Cont… + │ ~~~~~ +" +`; + +exports[`DiagnosticRenderer > smart diagnostic truncation > should handle truncation with context lines 1`] = ` +"[error] Long class name detected (class-name-length) + +/test/file.erb:2:13 + + 1 │
Short
+ → 2 │
Short
+" +`; + +exports[`DiagnosticRenderer > smart diagnostic truncation > should not truncate when maxWidth is sufficient 1`] = ` +"[error] Test short line (test-short) + +/test/file.erb:1:13 + + → 1 │
Content
+ │ ~~~~~ +" +`; + +exports[`DiagnosticRenderer > smart diagnostic truncation > should show ellipsis at beginning when diagnostic is at end of long line 1`] = ` +"[warning] Content should be more descriptive (content-description) + +/test/file.erb:1:95 + + → 1 │ …ncated-when-the-line-is-too-long">Content + │ ~~~~~~~ +" +`; + +exports[`DiagnosticRenderer > smart diagnostic truncation > should show ellipsis at end when diagnostic is at start of long line 1`] = ` +"[error] Class name should be shorter (class-name-length) + +/test/file.erb:1:13 + + → 1 │
smart diagnostic truncation > should show ellipsis on both sides when diagnostic is in middle of long line 1`] = ` +"[error] Avoid 'should-be' in class names (class-naming-convention) + +/test/file.erb:1:45 + + → 1 │ …-class-name-that-should-be-truncated-when-th… + │ ~~~~~~~~~ +" +`; diff --git a/javascript/packages/highlighter/test/__snapshots__/file-renderer.test.ts.snap b/javascript/packages/highlighter/test/__snapshots__/file-renderer.test.ts.snap new file mode 100644 index 000000000..66ca15293 --- /dev/null +++ b/javascript/packages/highlighter/test/__snapshots__/file-renderer.test.ts.snap @@ -0,0 +1,143 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`FileRenderer > NO_COLOR environment > should respect NO_COLOR environment variable 1`] = ` +"/test/file.erb + + 1 │
test
" +`; + +exports[`FileRenderer > edge cases > should handle special characters 1`] = ` +"/test/file.erb + + 1 │ line with tabs + 2 │ line with "quotes" + 3 │ line with "single quotes"" +`; + +exports[`FileRenderer > edge cases > should handle unicode characters 1`] = ` +"/test/file.erb + + 1 │ Unicode: 🎉 émojis and àccénts" +`; + +exports[`FileRenderer > edge cases > should handle very long lines 1`] = ` +"/test/file.erb + + 1 │ short line + 2 │ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + 3 │ short line" +`; + +exports[`FileRenderer > line truncation > should not truncate lines shorter than maxWidth 1`] = ` +"/test/file.erb + + 1 │ Short line" +`; + +exports[`FileRenderer > line truncation > should truncate lines in renderPlain method 1`] = `"This is another very long line that should be t…"`; + +exports[`FileRenderer > line truncation > should truncate long lines with ellipsis 1`] = ` +"/test/file.erb + + 1 │ This is a very long line that should …" +`; + +exports[`FileRenderer > renderPlain > should apply syntax highlighting 1`] = `"<div>Hello World"`; + +exports[`FileRenderer > renderPlain > should preserve exact content structure 1`] = ` +"line 1 + +line 3 + indented line" +`; + +exports[`FileRenderer > renderPlain > should render content without line numbers or file headers 1`] = ` +"line 1 +line 2 +line 3" +`; + +exports[`FileRenderer > renderWithFocusLine > should apply syntax highlighting 1`] = ` +"/test/file.erb + +  1 │ <div>line 1 + →  2 │ <span>line 2 +  3 │ <p>line 3" +`; + +exports[`FileRenderer > renderWithFocusLine > should handle focus line at beginning of file 1`] = ` +"/test/file.erb + + → 1 │ line 1 + 2 │ line 2" +`; + +exports[`FileRenderer > renderWithFocusLine > should handle focus line at end of file 1`] = ` +"/test/file.erb + + 2 │ line 2 + → 3 │ line 3" +`; + +exports[`FileRenderer > renderWithFocusLine > should handle large context that exceeds file bounds 1`] = ` +"/test/file.erb + + → 1 │ line 1 + 2 │ line 2" +`; + +exports[`FileRenderer > renderWithFocusLine > should highlight focus line and dim others 1`] = ` +"/test/file.erb + + 2 │ line 2 + → 3 │ line 3 + 4 │ line 4" +`; + +exports[`FileRenderer > renderWithFocusLine > should work without line numbers 1`] = ` +"line 1 +line 2 +line 3" +`; + +exports[`FileRenderer > renderWithLineNumbers > should apply syntax highlighting 1`] = ` +"/test/file.erb + +  1 │ <div>Hello" +`; + +exports[`FileRenderer > renderWithLineNumbers > should handle content with empty lines 1`] = ` +"/test/file.erb + + 1 │ line 1 + 2 │ + 3 │ line 3" +`; + +exports[`FileRenderer > renderWithLineNumbers > should handle empty content 1`] = ` +"/test/file.erb + + 1 │" +`; + +exports[`FileRenderer > renderWithLineNumbers > should handle single line content 1`] = ` +"/test/file.erb + + 1 │ single line" +`; + +exports[`FileRenderer > renderWithLineNumbers > should render content with line numbers 1`] = ` +"/test/file.erb + + 1 │ line 1 + 2 │ line 2 + 3 │ line 3" +`; + +exports[`FileRenderer > theme support > should work with different themes 1`] = ` +"/test/file.erb + +  1 │ <div>test" +`; + +exports[`FileRenderer > theme support > should work with simple theme 1`] = `"<span>test"`; diff --git a/javascript/packages/highlighter/test/__snapshots__/highlighter.test.ts.snap b/javascript/packages/highlighter/test/__snapshots__/highlighter.test.ts.snap new file mode 100644 index 000000000..2aeb446b5 --- /dev/null +++ b/javascript/packages/highlighter/test/__snapshots__/highlighter.test.ts.snap @@ -0,0 +1,118 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Highlighter > highlightFile method > should highlight a file 1`] = ` +"/test-highlighter-file.html.erb + +  1 │ <div class="container"> +  2 │ <% if user %> +  3 │ <span>Hello <%= user.name %>! +  4 │ <% end %> +  5 │ " +`; + +exports[`Highlighter > should handle ERB output tags 1`] = `"<%= user.name %>"`; + +exports[`Highlighter > should handle all Ruby keywords correctly 1`] = ` +[ + "<% if %>", + "<% unless %>", + "<% else %>", + "<% elsif %>", + "<% end %>", + "<% def %>", + "<% class %>", + "<% module %>", + "<% return %>", + "<% yield %>", + "<% break %>", + "<% next %>", + "<% case %>", + "<% when %>", + "<% then %>", + "<% while %>", + "<% until %>", + "<% for %>", + "<% in %>", + "<% do %>", + "<% begin %>", + "<% rescue %>", + "<% ensure %>", + "<% retry %>", + "<% raise %>", + "<% super %>", + "<% self %>", + "<% nil %>", + "<% true %>", + "<% false %>", + "<% and %>", + "<% or %>", + "<% not %>", +] +`; + +exports[`Highlighter > should handle mixed HTML and ERB content 1`] = `"<h1 id="<%= dom_id(article) %>">Title"`; + +exports[`Highlighter > should highlight ERB blocks with Ruby syntax 1`] = `"<% if true %>"`; + +exports[`Highlighter > should highlight HTML attributes 1`] = `"<div class="example" id="test">"`; + +exports[`Highlighter > should highlight basic HTML tags 1`] = `"<h1>Hello"`; + +exports[`Highlighter > should highlight complex ERB with if/elsif/else/end 1`] = ` +"<% if condition %> + <div>One +<% elsif other %> + <div>Two +<% else %> + <div>Three +<% end %>" +`; + +exports[`Highlighter > should not add colors when NO_COLOR is set 1`] = `"<% if true %>"`; + +exports[`Standalone utility functions > highlightContent should work with default theme 1`] = ` +" + +  1 │ <% def hello %><span>Hi<% end %>" +`; + +exports[`Standalone utility functions > highlightContent should work with github-light theme 1`] = ` +" + +  1 │ <% true %>" +`; + +exports[`Standalone utility functions > highlightFile should work with default theme 1`] = ` +" + +  1 │ <h1> +  2 │ <% unless condition %> +  3 │ <p>Default content +  4 │ <% end %> +  5 │ " +`; + +exports[`Standalone utility functions > highlightFile should work with simple theme 1`] = ` +" + +  1 │ <h1> +  2 │ <% unless condition %> +  3 │ <p>Default content +  4 │ <% end %> +  5 │ " +`; + +exports[`Standalone utility functions > should support focusLine with contextLines 1`] = ` +"test.erb + +  2 │ <div class="container"> + →  3 │ <% if user %> +  4 │  <span>Welcome" +`; + +exports[`Standalone utility functions > should support truncateLines option 1`] = ` +"test.erb + + 1 │
marks every line a multi-line diagnostic spans 1`] = ` +"/test/file.erb + + 1 │
+ → 2 │ <% @gems.each do |topic_gem| %> + │ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + → 3 │ <%= render partial: "gem_card" %> + │ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + → 4 │ <% end %> + │ ~~~~~~~~~ + │ [warning] Multi-line offense (multi-line-rule) + + 5 │
" +`; + +exports[`InlineDiagnosticRenderer > renders a single-line diagnostic with its message under the marker 1`] = ` +"/test/file.erb + + 1 │ line 1 + → 2 │ line content + │ ~~~~~ + │ [error] Test error message (test-rule) + + 3 │ line 3" +`; + +exports[`InlineDiagnosticRenderer > renders markers without line numbers 1`] = ` +"line 1 +line content + ~~~~~ +[error] Test error message (test-rule) + +line 3" +`; + +exports[`InlineDiagnosticRenderer > renders the message once, under the last line of a multi-line diagnostic 1`] = ` +"/test/file.erb + + → 1 │ <% if true %> + │ ~~~~~~~~~~~~~ + → 2 │ content + │ ~~~~~~~~~~~~~~~~~~~~ + → 3 │ <% end %> + │ ~~~~~~~~~ + │ [error] Multi-line offense (test-rule)" +`; diff --git a/javascript/packages/highlighter/test/__snapshots__/integration.test.ts.snap b/javascript/packages/highlighter/test/__snapshots__/integration.test.ts.snap new file mode 100644 index 000000000..859067bf7 --- /dev/null +++ b/javascript/packages/highlighter/test/__snapshots__/integration.test.ts.snap @@ -0,0 +1,47 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`herb-highlight CLI > should highlight file via CLI 1`] = ` +" + +  1 │ <h1 class="title"> +  2 │ <% if user.present? %> +  3 │ Welcome <%= user.name %>! +  4 │ <% else %> +  5 │ Please sign in +  6 │ <% end %> +  7 │  +" +`; + +exports[`herb-highlight CLI > should respect NO_COLOR environment variable 1`] = ` +" + + 1 │

+ 2 │ <% if user.present? %> + 3 │ Welcome <%= user.name %>! + 4 │ <% else %> + 5 │ Please sign in + 6 │ <% end %> + 7 │

+" +`; + +exports[`herb-highlight CLI > should support --context-lines option 1`] = ` +" + +  2 │  <% if user.present? %> + →  3 │ Welcome <%= user.name %>! +  4 │  <% else %> +" +`; + +exports[`herb-highlight CLI > should support --focus option 1`] = ` +" + +  1 │ <h1 class="title"> +  2 │  <% if user.present? %> + →  3 │ Welcome <%= user.name %>! +  4 │  <% else %> +  5 │  Please sign in +" +`; diff --git a/javascript/packages/highlighter/test/__snapshots__/syntax-renderer.test.ts.snap b/javascript/packages/highlighter/test/__snapshots__/syntax-renderer.test.ts.snap new file mode 100644 index 000000000..f1acde6cb --- /dev/null +++ b/javascript/packages/highlighter/test/__snapshots__/syntax-renderer.test.ts.snap @@ -0,0 +1,15 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SyntaxRenderer > ERB content highlighting > should highlight Ruby keywords in ERB blocks 1`] = `"<% if true %>"`; + +exports[`SyntaxRenderer > color disabled mode > should return plain text when NO_COLOR is set 1`] = `"
test
"`; + +exports[`SyntaxRenderer > comment state tracking > should preserve ERB highlighting in comments 1`] = `"" const result = commentRenderer.highlight(content) - expect(stripAnsiColors(result)).toContain("comment") + + expect(result).toMatchSnapshot() }) it("should preserve ERB highlighting in comments", async () => { @@ -223,9 +223,8 @@ describe("SyntaxRenderer", () => { const content = "" const result = erbCommentRenderer.highlight(content) - expect(result).toMatchInlineSnapshot( - `"