Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions javascript/packages/highlighter/src/diagnostic-markers.ts
Original file line number Diff line number Diff line change
@@ -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
}
77 changes: 38 additions & 39 deletions javascript/packages/highlighter/src/diagnostic-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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(),
Expand All @@ -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<number, DiagnosticMarker>(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
Expand All @@ -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")
Expand All @@ -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()}
`
}
}
1 change: 1 addition & 0 deletions javascript/packages/highlighter/src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
106 changes: 57 additions & 49 deletions javascript/packages/highlighter/src/inline-diagnostic-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<number, Diagnostic[]>()
const markersByLine = new Map<number, LineMarker[]>()
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<DiagnosticSeverity, number> = {
Expand All @@ -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"
}

Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading