import { SourceMapConsumer, type RawSourceMap } from 'source-map-js'
import { DefaultMap } from '../utils/default-map'
const COLUMN_WIDTH = 100
const CONTEXT_LINES = 3
type Range = { start: [number, number]; end: [number, number] }
interface Annotation {
original: Range
generated: Range | null
source: string
}
interface MappedAnnotation extends Annotation {
generated: Range
}
interface SourceMapPoint {
original: [number, number]
generated: [number, number]
source: string
}
interface SourceVisualization {
before: string[]
line: string
marker: string
after: string[]
}
export interface SourceMapVisualizationRange {
original: Range & { source: string }
generated: Range | null
}
export function visualizeSourceMap(map: RawSourceMap, css: string) {
let smc = new SourceMapConsumer(map)
let sourceContents = buildSourceContents(map.sources, map.sourcesContent ?? [])
let points: SourceMapPoint[] = []
smc.eachMapping((mapping) => {
if (
mapping.source === null ||
mapping.originalLine === null ||
mapping.originalColumn === null
) {
return
}
points.push({
original: [mapping.originalLine, mapping.originalColumn],
generated: [mapping.generatedLine, mapping.generatedColumn],
source: mapping.source,
})
})
return renderVisualization(sourceContents, buildAnnotationsFromSourceMapPoints(points), css)
}
export function visualizeSourceMapRanges(
sources: Record<string, string>,
generated: string,
ranges: SourceMapVisualizationRange[],
) {
let sourceContents = buildSourceContents(Object.keys(sources), Object.values(sources))
let annotations: Annotation[] = []
for (let range of ranges) {
annotations.push({
generated: range.generated,
original: range.original,
source: range.original.source,
})
}
return renderVisualization(sourceContents, annotations, generated)
}
function buildSourceContents(sources: string[], contents: (string | null)[]) {
let result = new Map<string, string[]>()
for (let i = 0; i < sources.length; i++) {
let content = contents[i]
if (content === undefined || content === null) continue
result.set(sources[i], content.split('\n'))
}
return result
}
function buildAnnotationsFromSourceMapPoints(points: SourceMapPoint[]) {
let annotations: Annotation[] = []
let current: MappedAnnotation | null = null
function finish(next: SourceMapPoint | null) {
if (current === null) return
if (samePosition(current.generated.start, current.generated.end)) {
if (next === null) {
current.generated.end = [current.generated.start[0] + 1, 0]
} else if (next.generated[0] > current.generated.start[0]) {
current.generated.end = [current.generated.start[0] + 1, 0]
if (
next.source === current.source &&
next.generated[0] === current.generated.start[0] + 1 &&
next.generated[1] === 0 &&
next.original[0] === current.original.start[0] + 1 &&
next.original[1] === 0
) {
current.original.end = next.original
}
}
}
annotations.push(current)
current = null
}
for (let point of points) {
if (
current !== null &&
current.source === point.source &&
current.generated.start[0] === point.generated[0] &&
point.generated[1] > current.generated.end[1] &&
comparePosition(current.original.start, point.original) <= 0
) {
current.generated.end = point.generated
current.original.end = point.original
continue
}
finish(point)
current = {
original: {
start: point.original,
end: point.original,
},
generated: {
start: point.generated,
end: point.generated,
},
source: point.source,
}
}
finish(null)
return annotations
}
function renderVisualization(
sourceContents: Map<string, string[]>,
annotationsList: Annotation[],
generated: string,
) {
let generatedLines = generated.split('\n')
let nextLabelIdx = 0
let labelsBySource = new DefaultMap<string, string>(() => annotationLabel(nextLabelIdx++))
let labels = annotationsList.map((annotation) => {
return labelsBySource.get(sourceKey(annotation.source, annotation.original))
})
let generatedLineWidth = String(generatedLines.length).length
let sourceLineWidth = Math.max(
1,
...Array.from(sourceContents.values(), (lines) => String(lines.length).length),
)
let leftWidth = Math.min(
100,
Math.max(
withLineNumber(generatedLineWidth, 'output.css').length,
...annotationsList.map((annotation, idx) => {
let label = labels[idx]
if (annotation.generated === null) {
return withoutLineNumber(generatedLineWidth, `unmapped ${label}`).length
}
let line = withLineNumber(
generatedLineWidth,
generatedLines[annotation.generated.start[0] - 1] ?? '',
annotation.generated.start[0],
)
let marker = withoutLineNumber(
generatedLineWidth,
visualizeRange(
annotation.generated,
label,
generatedLines[annotation.generated.start[0] - 1] ?? '',
),
)
let after = visualizeGeneratedRangeContinuation(
generatedLines,
generatedLineWidth,
annotation.generated,
label,
)
return Math.max(line.length, marker.length, ...after.map((line) => line.length))
}),
),
)
let sources = new Set(annotationsList.map((annotation) => annotation.source))
let sourceHeader = sources.size === 1 ? (annotationsList[0]?.source ?? 'original') : 'original'
let result: string[] = []
function add(left: string, right: string) {
result.push(sideBySide(left, right, leftWidth))
}
add(
withLineNumber(generatedLineWidth, 'output.css'),
withLineNumber(sourceLineWidth, sourceHeader),
)
add(withoutLineNumber(generatedLineWidth, ''), withoutLineNumber(sourceLineWidth, ''))
let lastLine = 0
let lastSourceLine = new Map<string, number>()
let lastSource: string | null = null
let renderedSources = new Set<string>()
for (let idx = 0; idx < annotationsList.length; idx++) {
let annotation = annotationsList[idx]
let label = labels[idx]
if (annotation.generated === null) {
let source = visualizeSource(
sourceContents,
lastSourceLine,
renderedSources,
sourceLineWidth,
annotation.source,
annotation.original,
label,
)
if (sources.size > 1 && source.line !== '' && annotation.source !== lastSource) {
add(
withoutLineNumber(generatedLineWidth, ''),
withoutLineNumber(sourceLineWidth, `--- ${annotation.source} ---`),
)
lastSource = annotation.source
}
for (let line of source.before) {
add(withoutLineNumber(generatedLineWidth, ''), line)
}
add(withoutLineNumber(generatedLineWidth, `unmapped ${label}`), source.line)
add(withoutLineNumber(generatedLineWidth, ''), source.marker)
for (let line of source.after) {
add(withoutLineNumber(generatedLineWidth, ''), line)
}
continue
}
let lineNumber = annotation.generated.start[0]
for (let i = lastLine + 1; i < lineNumber; i++) {
add(
withLineNumber(generatedLineWidth, generatedLines[i - 1] ?? '', i),
withoutLineNumber(sourceLineWidth, ''),
)
}
let endLine = coveredEndLine(annotation.generated)
let generatedAfter = visualizeGeneratedRangeContinuation(
generatedLines,
generatedLineWidth,
annotation.generated,
label,
)
let source = visualizeSource(
sourceContents,
lastSourceLine,
renderedSources,
sourceLineWidth,
annotation.source,
annotation.original,
label,
)
if (sources.size > 1 && source.line !== '' && annotation.source !== lastSource) {
add(
withoutLineNumber(generatedLineWidth, ''),
withoutLineNumber(sourceLineWidth, `--- ${annotation.source} ---`),
)
lastSource = annotation.source
}
for (let line of source.before) {
add(withoutLineNumber(generatedLineWidth, ''), line)
}
add(
withLineNumber(generatedLineWidth, generatedLines[lineNumber - 1] ?? '', lineNumber),
source.line,
)
add(
withoutLineNumber(
generatedLineWidth,
visualizeRange(annotation.generated, label, generatedLines[lineNumber - 1] ?? ''),
),
source.marker,
)
for (let i = 0; i < Math.max(generatedAfter.length, source.after.length); i++) {
add(generatedAfter[i] ?? withoutLineNumber(generatedLineWidth, ''), source.after[i] ?? '')
}
lastLine = endLine
}
for (let i = lastLine + 1; i <= generatedLines.length; i++) {
add(
withLineNumber(generatedLineWidth, generatedLines[i - 1] ?? '', i),
withoutLineNumber(sourceLineWidth, ''),
)
}
return `\n${result.join('\n')}\n`
}
function sideBySide(left: string, right: string, leftWidth: number) {
return `${fitColumn(left, leftWidth)} | ${fitColumn(right, COLUMN_WIDTH, false)}`
}
function withLineNumber(width: number, value: string, line?: number) {
return `${line === undefined ? ' '.repeat(width) : String(line).padStart(width)} ${value}`
}
function withoutLineNumber(width: number, value: string) {
if (value === '') return ''
return `${' '.repeat(width)} ${value}`
}
function fitColumn(value: string, width: number, pad = true) {
if (value.length <= width) {
return pad ? value.padEnd(width) : value
}
let markerIdx = value.indexOf(' @ ')
if (markerIdx !== -1) {
let labelIdx = value.lastIndexOf(' ', markerIdx - 1) + 1
let suffix = value.slice(labelIdx)
let prefixWidth = width - suffix.length - 4
if (prefixWidth > 0) {
let result = `${value.slice(0, prefixWidth)}... ${suffix}`
return pad ? result.padEnd(width) : result
}
}
return `${value.slice(0, width - 3)}...`
}
function annotationLabel(idx: number) {
let label = ''
for (let current = idx; current >= 0; current = Math.floor(current / 26) - 1) {
label = String.fromCharCode(65 + (current % 26)) + label
}
return label
}
function visualizeRange(range: Range, label: string, line?: string) {
let start = range.start[1]
let width = (() => {
if (line !== undefined && range.start[0] !== range.end[0]) {
return Math.max(1, line.length - start)
}
return Math.max(1, range.end[1] - start)
})()
return `${' '.repeat(start)}${'^'.repeat(width)} ${label} @ ${formatRange(range)}`
}
function visualizeSource(
sourceContents: Map<string, string[]>,
lastSourceLine: Map<string, number>,
renderedSources: Set<string>,
sourceLineWidth: number,
source: string,
range: Range,
label: string,
): SourceVisualization {
let lines = sourceContents.get(source)
if (lines === undefined) return { before: [], line: '', marker: '', after: [] }
let startLine = range.start[0]
let line = lines[startLine - 1]
if (line === undefined) return { before: [], line: '', marker: '', after: [] }
let before: string[] = []
let lastLine = lastSourceLine.get(source) ?? 0
if (lastLine > 0 && startLine > lastLine && startLine - lastLine <= CONTEXT_LINES + 1) {
for (let i = lastLine + 1; i < startLine; i++) {
let line = lines[i - 1] ?? ''
if (line.trim() === '') continue
before.push(withLineNumber(sourceLineWidth, line, i))
}
}
let key = sourceKey(source, range)
if (renderedSources.has(key)) {
lastSourceLine.set(source, Math.max(lastLine, startLine))
return { before: [], line: '', marker: '', after: [] }
}
renderedSources.add(key)
let endLine = coveredEndLine(range)
let after = visualizeSourceRangeContinuation(lines, sourceLineWidth, range, label)
if (startLine > lastLine) {
for (let i = endLine + 1; i <= Math.min(endLine + CONTEXT_LINES, lines.length); i++) {
let line = lines[i - 1]
if (line?.trim() !== '}') break
after.push(withLineNumber(sourceLineWidth, line, i))
endLine = i
}
}
lastSourceLine.set(source, Math.max(lastLine, endLine))
return {
before,
line: withLineNumber(sourceLineWidth, line, startLine),
marker: withoutLineNumber(sourceLineWidth, visualizeRange(range, label, line)),
after,
}
}
function visualizeSourceRangeContinuation(
lines: string[],
sourceLineWidth: number,
range: Range,
label: string,
) {
let result: string[] = []
for (let i = range.start[0] + 1; i <= coveredEndLine(range); i++) {
let line = lines[i - 1] ?? ''
result.push(withLineNumber(sourceLineWidth, line, i))
result.push(
withoutLineNumber(sourceLineWidth, visualizeContinuationRange(range, label, line, i)),
)
}
return result
}
function visualizeGeneratedRangeContinuation(
lines: string[],
generatedLineWidth: number,
range: Range,
label: string,
) {
let result: string[] = []
for (let i = range.start[0] + 1; i <= coveredEndLine(range); i++) {
let line = lines[i - 1] ?? ''
result.push(withLineNumber(generatedLineWidth, line, i))
result.push(
withoutLineNumber(generatedLineWidth, visualizeContinuationRange(range, label, line, i)),
)
}
return result
}
function coveredEndLine(range: Range) {
if (range.start[0] !== range.end[0] && range.end[1] === 0) {
return range.end[0] - 1
}
return range.end[0]
}
function visualizeContinuationRange(range: Range, label: string, line: string, lineNumber: number) {
let start = lineNumber === range.start[0] ? range.start[1] : 0
let end = lineNumber === range.end[0] ? range.end[1] : line.length
let width = Math.max(1, end - start)
return `${' '.repeat(start)}${'^'.repeat(width)} ${label}`
}
function sourceKey(source: string, range: Range) {
return `${source}:${range.start[0]}:${range.start[1]}:${range.end[0]}:${range.end[1]}`
}
function samePosition(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1]
}
function comparePosition(a: [number, number], b: [number, number]) {
return a[0] - b[0] || a[1] - b[1]
}
function formatRange(range: Range): string {
if (range.start[0] === range.end[0]) {
if (range.start[1] === range.end[1]) {
return `${range.start[0]}:${range.start[1]}`
}
return `${range.start[0]}:${range.start[1]}-${range.end[1]}`
}
return `${range.start[0]}:${range.start[1]}-${range.end[0]}:${range.end[1]}`
}