diff --git a/.prettierignore b/.prettierignore index 58c48e15..1c29073f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ src/test/fixtures/generated/** src/test/fixtures/samples/sample_*.* +src/test/fixtures/diff/** diff --git a/CLAUDE.md b/CLAUDE.md index 22696b64..ba3eef13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,8 @@ dev server. - esm-env - `DEV` flag (required peer) - magic-string, zimmerframe - build-time preprocessor helpers (`dependencies`; `svelte_preprocess_fuz_code` only) -- fuz_util (@fuzdev/fuz_util) - preprocessor helper (required peer) +- fuz_util (@fuzdev/fuz_util) - preprocessor helper + diff data for the diff + viewer (required peer) - fuz_ui (@fuzdev/fuz_ui) - docs system (dev only) ## Scope @@ -47,6 +48,8 @@ fuz_code is a **syntax highlighting library**: - 9 built-in languages (TS, JS, CSS, HTML, JSON, Svelte, Markdown, Shell, Rust) - Extensible by writing a lexer (`SyntaxLang`) - Optional Svelte component (`Code.svelte`) +- Syntax-highlighted diff rendering, unified and side-by-side + (`CodeDiff.svelte`/`CodeDiffSplit.svelte` over `diff_html.ts`) ### What fuz_code does NOT include @@ -70,13 +73,17 @@ src/ │ ├── syntax_styler_global.ts # pre-configured global instance │ ├── lexer.ts # lexer substrate: Lexer, TokenTypeRegistry, flat events, HTML render │ ├── lexer_*.ts # hand-written lexers (json, ts, css, bash, markup, svelte, md, rust) +│ ├── diff_html.ts # diff viewer: render_diff_unified_html over fuz_util's diff data │ ├── Code.svelte # main Svelte component +│ ├── CodeDiff.svelte # diff viewer component (unified view) +│ ├── CodeDiffSplit.svelte # diff viewer component (side-by-side view) │ ├── CodeHighlight.svelte # experimental CSS Highlight API │ ├── CodeTextarea.svelte # experimental live-highlighted textarea │ ├── highlight_manager.ts # CSS Highlight API manager │ ├── range_highlighting.svelte.ts # shared range-highlighting helper │ ├── highlight_priorities.ts # generated token priorities │ ├── theme.css # token CSS classes +│ ├── theme_diff.css # diff viewer theme (row tints via fuz_css intent variables) │ ├── theme_variables.css # CSS variable fallbacks │ └── theme_highlight.css # CSS Highlight API theme ├── test/ # test files and fixtures @@ -86,6 +93,7 @@ src/ │ ├── pathological.ts # pathological input generators (tests + benchmark) │ └── fixtures/ │ ├── samples/ # source of truth sample files +│ ├── diff/ # diff case dirs, each an a/b source pair │ ├── generated/ # generated fixture outputs │ ├── check.test.ts # fixture validation │ └── update.task.ts # fixture regeneration task @@ -115,9 +123,33 @@ The lexer emits a flat event stream (`LexedSyntax`) — leaf/open/close records in one `Int32Array` with interned type ids; plain text is implicit between events (recovered from offsets). `render_syntax_html` streams HTML from it in one forward pass, wrapping spans with classes like `.token_keyword`, -`.token_string` (styled by `theme.css`); `syntax_events_to_tokens` flattens it +`.token_string` (styled by `theme.css`); `render_syntax_html_lines` renders +one balanced fragment per source line (spans open at a newline close there +and reopen on the next line), with optional `marks` ranges wrapped in +`` tags — marks wrap text runs only, cut at token boundaries, so +nesting always stays valid; `syntax_events_to_tokens` flattens it to `{type, start, end}` for tests and fixtures. +### Diff viewer + +`diff_html.ts` renders syntax-highlighted unified diffs: +`render_diff_unified_html(a, b, options)` composes `@fuzdev/fuz_util/diff.ts` +(Myers line diff, hunks, intra-line segments) with whole-document lexing per +side and `render_syntax_html_lines`. Rows are semantic ``/`` +(context rows are ``) carrying `diff_line` + `diff_add`/`diff_remove`/ +`diff_same`, with aria-hidden unselectable gutters, `+`/`-` markers as CSS +generated content (copied text stays clean code), intra-line `` +emphasis, and elided unchanged regions as zero-JS `
` blocks +(options: `'details' | 'omit' | 'none'`). `render_diff_split_html` is the +side-by-side sibling — a flat cell sequence for a two-column grid, pairing +k-th remove with k-th add and padding unpaired sides with `.diff_spacer` +cells. `CodeDiff.svelte`/`CodeDiffSplit.svelte` are the thin wrappers owning +the `
`/`
` elements +(split defaults `wrap` on — half-width panes); `theme_diff.css` (opt-in, +alongside `theme.css`) styles both, with row tints keyed off fuz_css intent +variables (`--positive_*`/`--negative_*`) through `--diff_*` custom +properties. + ### Language definitions One `SyntaxLang` lexer per language, registered via `add_lang`: @@ -177,6 +209,20 @@ can't overflow the call stack; past the cap a region stays plain text. - `nomargin` - boolean for margin control - `syntax_styler` - custom `SyntaxStyler` (default: `syntax_styler_global`) +**CodeDiff.svelte / CodeDiffSplit.svelte props** (unified / side-by-side): + +- `a`, `b` - the original and updated source texts (or `dangerous_raw_html` + like `Code.svelte`) +- `lang` - language identifier (default: 'svelte'; `null` renders plain rows + with diff chrome only) +- `context_lines` - unchanged lines around changes (default: 3) +- `elide` - `'details' | 'omit' | 'none'` for unchanged regions (default: + 'details') +- `intraline` - intra-line `` emphasis on paired lines (default: true) +- `line_numbers` - gutters (default: true) +- `wrap`, `nomargin`, `syntax_styler` - as in `Code.svelte` (`wrap` defaults + on for the split view) + ## Supported languages `ts`, `js`, `css`, `html`, `json`, `svelte`, `md`, `sh`, `rust`/`rs` @@ -193,6 +239,12 @@ can't overflow the call stack; past the cap a region stays plain text. Generated fixtures in `generated/{lang}/` include `.html` (tokenized output) and `.txt` (debug output with token names). +Diff fixtures follow the same flow with pairs: each `src/test/fixtures/diff/{case}/` +holds an `a.{lang}` + `b.{lang}` source pair (byte-exact — the whole dir is +prettierignored, since trailing newlines and whitespace are diff inputs), and +`generated/diff/{case}` gets `.html` (unified), `.split.html`, and `.txt` +(stats + plain unified-diff text). + ## Performance ```bash @@ -336,7 +388,7 @@ New languages are written as lexers: ## Demo pages -- `/docs` - tomes: usage, samples, textarea, benchmark, api +- `/docs` - tomes: usage, samples, diff, textarea, benchmark, api - `/benchmark` - interactive browser benchmark (work vs paint timing) ## Project standards diff --git a/src/lib/CodeDiff.svelte b/src/lib/CodeDiff.svelte new file mode 100644 index 00000000..6725a562 --- /dev/null +++ b/src/lib/CodeDiff.svelte @@ -0,0 +1,134 @@ + + + + +
+ {#if children}{@render children(html_content)}{:else}{@html html_content}{/if} +
diff --git a/src/lib/CodeDiffSplit.svelte b/src/lib/CodeDiffSplit.svelte new file mode 100644 index 00000000..6a75be8a --- /dev/null +++ b/src/lib/CodeDiffSplit.svelte @@ -0,0 +1,136 @@ + + + + +
+ {#if children}{@render children(html_content)}{:else}{@html html_content}{/if} +
diff --git a/src/lib/diff_html.ts b/src/lib/diff_html.ts new file mode 100644 index 00000000..ac74972c --- /dev/null +++ b/src/lib/diff_html.ts @@ -0,0 +1,343 @@ +/** + * Diff-viewer HTML generation: syntax-highlighted unified diffs with CSS + * classes, composed from `@fuzdev/fuz_util`'s diff data and the lexer + * substrate's per-line renderer. The returned HTML is the rows only — the + * caller owns the wrapper element (`CodeDiff.svelte` renders + * `
`, styled by `theme_diff.css`). + * + * @module + */ + +import {diff_lines, diff_hunks, diff_segments, type DiffLine} from '@fuzdev/fuz_util/diff.ts'; + +import {render_syntax_html_lines, type SyntaxHtmlMark} from './lexer.ts'; +import {syntax_styler_global} from './syntax_styler_global.ts'; +import type {SyntaxStyler} from './syntax_styler.ts'; + +/** + * Options for `render_diff_unified_html`. + */ +export interface RenderDiffOptions { + /** + * Language id for syntax highlighting. `null` (or an unregistered id) + * renders plain escaped text with diff chrome only. + * + * @default null + */ + lang?: string | null; + /** + * @default syntax_styler_global + */ + syntax_styler?: SyntaxStyler; + /** + * Unchanged lines of context around changes. + * + * @default 3 + */ + context_lines?: number; + /** + * How elided unchanged regions render: `'details'` as collapsed native + * `
` blocks (expandable with zero JS, full content in the DOM), + * `'omit'` as an inert count row (bounded output for huge inputs), + * `'none'` fully expanded. + * + * @default 'details' + */ + elide?: 'details' | 'omit' | 'none'; + /** + * Whether paired remove/add lines get intra-line changed-range emphasis + * (`` spans via `diff_segments`). + * + * @default true + */ + intraline?: boolean; + /** + * Whether to render line-number gutters. + * + * @default true + */ + line_numbers?: boolean; + /** + * See `DiffOptions.max_cost` in `@fuzdev/fuz_util/diff.ts`. + */ + max_cost?: number; +} + +/** + * Returns the start offset of each 0-based line in `text`. + */ +const line_starts = (text: string): Array => { + const starts: Array = [0]; + let i = text.indexOf('\n'); + while (i !== -1) { + starts.push(i + 1); + i = text.indexOf('\n', i + 1); + } + return starts; +}; + +/** + * Collects absolute intra-line emphasis ranges for both sides by pairing + * each contiguous remove-run with the add-run that follows it (k-th with + * k-th) and diffing the pairs char-level. + */ +const collect_marks = ( + lines: Array, + a_starts: Array, + b_starts: Array, +): {a_marks: Array; b_marks: Array} => { + const a_marks: Array = []; + const b_marks: Array = []; + let i = 0; + while (i < lines.length) { + if (lines[i]!.type !== 'remove') { + i++; + continue; + } + const removes_start = i; + while (i < lines.length && lines[i]!.type === 'remove') i++; + const adds_start = i; + while (i < lines.length && lines[i]!.type === 'add') i++; + const pairs = Math.min(adds_start - removes_start, i - adds_start); + for (let k = 0; k < pairs; k++) { + const removed = lines[removes_start + k]!; + const added = lines[adds_start + k]!; + const segments = diff_segments(removed.text, added.text); + if (!segments) continue; + const a_offset = a_starts[removed.a_line! - 1]!; + for (const [start, end] of segments.a_ranges) { + a_marks.push({start: a_offset + start, end: a_offset + end}); + } + const b_offset = b_starts[added.b_line! - 1]!; + for (const [start, end] of segments.b_ranges) { + b_marks.push({start: b_offset + start, end: b_offset + end}); + } + } + } + return {a_marks, b_marks}; +}; + +// shared per-render state for the unified and split emitters +interface DiffRenderData { + lines: Array; + hunks: ReturnType; + a_fragments: Array; + b_fragments: Array; + line_numbers: boolean; + elide: 'details' | 'omit' | 'none'; + /** + * Gutter number width in chars, fitting the larger side's final line number. + */ + width: number; + pad: string; +} + +const prepare_diff = (a: string, b: string, options: RenderDiffOptions): DiffRenderData => { + const { + lang = null, + syntax_styler = syntax_styler_global, + context_lines = 3, + elide = 'details', + intraline = true, + line_numbers = true, + max_cost, + } = options; + + const lines = diff_lines(a, b, {max_cost}); + const hunks = diff_hunks(lines, context_lines); + + const marks = intraline ? collect_marks(lines, line_starts(a), line_starts(b)) : null; + + const effective_lang = lang !== null && syntax_styler.has_lang(lang) ? lang : 'plaintext'; + const a_fragments = render_syntax_html_lines(syntax_styler.lex(a, effective_lang), { + marks: marks?.a_marks, + }); + const b_fragments = render_syntax_html_lines(syntax_styler.lex(b, effective_lang), { + marks: marks?.b_marks, + }); + + let a_total = 0; + let b_total = 0; + for (const line of lines) { + if (line.a_line !== null) a_total = line.a_line; + if (line.b_line !== null) b_total = line.b_line; + } + const width = String(Math.max(a_total, b_total, 1)).length; + + return { + lines, + hunks, + a_fragments, + b_fragments, + line_numbers, + elide, + width, + pad: ''.padStart(width), + }; +}; + +/** + * Walks the full line list hunk-by-hunk, calling `on_lines` for each hunk's + * rows and `on_elided` for the unchanged regions between them. + */ +const walk_hunks = ( + data: DiffRenderData, + on_elided: (elided: Array) => void, + on_lines: (lines: Array) => void, +): void => { + const {lines, hunks} = data; + let cursor = 0; + for (const hunk of hunks) { + const start = lines.indexOf(hunk.lines[0]!, cursor); + if (start > cursor) on_elided(lines.slice(cursor, start)); + on_lines(hunk.lines); + cursor = start + hunk.lines.length; + } + if (cursor < lines.length) on_elided(lines.slice(cursor)); +}; + +const elided_count = (elided: Array): string => + `${elided.length} unchanged line${elided.length === 1 ? '' : 's'}`; + +/** + * Generates syntax-highlighted unified-diff rows for two versions of a + * source text. Each row is one element — ``/`` for added/removed + * lines (semantics survive copy/paste and screen readers), `` for + * context — carrying `diff_line` plus `diff_add`/`diff_remove`/`diff_same`. + * Gutters are `aria-hidden` and unselectable, and the `+`/`-` markers are + * CSS generated content, so selecting and copying yields clean code. Both + * sides are lexed as whole documents, so multi-line constructs highlight + * correctly; removed rows render from `a`'s highlight, added and context + * rows from `b`'s. Returns rows only — the caller owns the + * `
` wrapper (`CodeDiff.svelte` does this). + */ +export const render_diff_unified_html = ( + a: string, + b: string, + options: RenderDiffOptions = {}, +): string => { + const data = prepare_diff(a, b, options); + const {a_fragments, b_fragments, line_numbers, elide, width, pad} = data; + + const render_row = (line: DiffLine): string => { + let gutter = ''; + if (line_numbers) { + const a_num = line.a_line === null ? pad : String(line.a_line).padStart(width); + const b_num = line.b_line === null ? pad : String(line.b_line).padStart(width); + gutter = ``; + } + const fragment = + line.type === 'remove' ? a_fragments[line.a_line! - 1]! : b_fragments[line.b_line! - 1]!; + const no_newline = line.no_newline ? '' : ''; + const text = `${fragment}${no_newline}`; + if (line.type === 'add') { + return `${gutter}${text}`; + } + if (line.type === 'remove') { + return `${gutter}${text}`; + } + return `${gutter}${text}`; + }; + + const out: Array = []; + walk_hunks( + data, + (elided) => { + if (elide === 'omit') { + out.push(`
${elided_count(elided)}
`); + } else if (elide === 'none') { + for (const line of elided) out.push(render_row(line)); + } else { + out.push(`
${elided_count(elided)}`); + for (const line of elided) out.push(render_row(line)); + out.push('
'); + } + }, + (hunk_lines) => { + for (const line of hunk_lines) out.push(render_row(line)); + }, + ); + return out.join(''); +}; + +/** + * Generates syntax-highlighted side-by-side diff cells for two versions of a + * source text — the split-view sibling of `render_diff_unified_html`, same + * options and row semantics. Output is a flat sequence of half-row cells for + * a two-column grid: each visual row is one `a`-side cell then one `b`-side + * cell (``/``/`` with `diff_cell` classes; unpaired sides + * get an empty `.diff_spacer` cell). Elided regions span both columns, with + * their rows in a nested `.diff_split_rows` grid. The caller owns the + * `
` grid wrapper (`CodeDiffSplit.svelte`). + */ +export const render_diff_split_html = ( + a: string, + b: string, + options: RenderDiffOptions = {}, +): string => { + const data = prepare_diff(a, b, options); + const {a_fragments, b_fragments, line_numbers, elide, width, pad} = data; + + const render_cell = (line: DiffLine | null, side: 'a' | 'b'): string => { + if (line === null) return ''; + const num = side === 'a' ? line.a_line : line.b_line; + const gutter = line_numbers + ? `` + : ''; + const fragment = side === 'a' ? a_fragments[line.a_line! - 1]! : b_fragments[line.b_line! - 1]!; + const no_newline = line.no_newline ? '' : ''; + const text = `${fragment}${no_newline}`; + if (line.type === 'same') { + return `${gutter}${text}`; + } + return side === 'a' + ? `${gutter}${text}` + : `${gutter}${text}`; + }; + + // pairs each visual row: context with itself, k-th remove with k-th add + const render_rows = (row_lines: Array, out: Array): void => { + let i = 0; + while (i < row_lines.length) { + const line = row_lines[i]!; + if (line.type === 'same') { + out.push(render_cell(line, 'a'), render_cell(line, 'b')); + i++; + continue; + } + const removes_start = i; + while (i < row_lines.length && row_lines[i]!.type === 'remove') i++; + const adds_start = i; + while (i < row_lines.length && row_lines[i]!.type === 'add') i++; + const remove_count = adds_start - removes_start; + const add_count = i - adds_start; + for (let k = 0; k < Math.max(remove_count, add_count); k++) { + out.push( + render_cell(k < remove_count ? row_lines[removes_start + k]! : null, 'a'), + render_cell(k < add_count ? row_lines[adds_start + k]! : null, 'b'), + ); + } + } + }; + + const out: Array = []; + walk_hunks( + data, + (elided) => { + if (elide === 'omit') { + out.push(`
${elided_count(elided)}
`); + } else if (elide === 'none') { + render_rows(elided, out); + } else { + out.push(`
${elided_count(elided)}`); + out.push('
'); + render_rows(elided, out); + out.push('
'); + } + }, + (hunk_lines) => { + render_rows(hunk_lines, out); + }, + ); + return out.join(''); +}; diff --git a/src/lib/lexer.ts b/src/lib/lexer.ts index 32418f9e..92daa460 100644 --- a/src/lib/lexer.ts +++ b/src/lib/lexer.ts @@ -344,6 +344,151 @@ export const render_syntax_html = (lexed: LexedSyntax): string => { return out; }; +/** + * An absolute `[start, end)` text range wrapped with mark tags by + * `render_syntax_html_lines` — e.g. diff intra-line emphasis. + */ +export interface SyntaxHtmlMark { + start: number; + end: number; +} + +/** + * Options for `render_syntax_html_lines`. + */ +export interface RenderSyntaxHtmlLinesOptions { + /** + * Sorted, non-empty, non-overlapping `[start, end)` ranges to wrap with + * the mark tags. Marks wrap text runs only — a mark crossing token or + * line boundaries is emitted as adjacent mark spans, cut at each + * boundary, so nesting always stays valid. + */ + marks?: Array; + /** + * @default '' + */ + mark_open_tag?: string; + /** + * @default '' + */ + mark_close_tag?: string; +} + +/** + * Renders a lexed event stream to one HTML fragment per source line — the + * same single forward pass as `render_syntax_html`, but token spans left open + * at a newline are closed there and reopened on the next line (splitting + * leaves that span newlines), so every fragment is balanced HTML. Fragment + * `i` is line `i + 1` (1-based); newlines are not included, and a text + * ending in `\n` yields one final empty fragment. Optional `marks` wrap text + * ranges with mark tags. + */ +export const render_syntax_html_lines = ( + lexed: LexedSyntax, + options?: RenderSyntaxHtmlLinesOptions, +): Array => { + const {text, events, events_len} = lexed; + const {infos} = lexed.types; + const marks = options?.marks; + const mark_open = options?.mark_open_tag ?? ''; + const mark_close = options?.mark_close_tag ?? ''; + + const lines: Array = []; + let cur = ''; + // open container type ids, for reopening after a line break + const stack: Array = []; + // open tag of the in-progress leaf, '' when none + let leaf_tag = ''; + let mi = 0; + let nl_probe = -1; + + const close_spans = (): void => { + if (leaf_tag !== '') cur += ''; + for (let s = 0; s < stack.length; s++) cur += ''; + }; + const reopen_spans = (): void => { + for (let s = 0; s < stack.length; s++) cur += infos[stack[s]!]!.open_tag; + if (leaf_tag !== '') cur += leaf_tag; + }; + + // emits text[from, to) into the current line, splitting at newlines and + // mark boundaries; marks never wrap tags, so they open and close within + // one uninterrupted text run + const emit_text = (from: number, to: number): void => { + let i = from; + let in_mark = false; + while (i < to) { + nl_probe = advance_probe(text, nl_probe, i, '\n'); + if (i === nl_probe) { + close_spans(); + lines.push(cur); + cur = ''; + reopen_spans(); + i++; + continue; + } + if (marks) { + while (mi < marks.length && marks[mi]!.end <= i) mi++; + if (!in_mark && mi < marks.length && marks[mi]!.start <= i) { + cur += mark_open; + in_mark = true; + } + } + let boundary = to; + if (nl_probe < boundary) boundary = nl_probe; + if (marks && mi < marks.length) { + const m = marks[mi]!; + if (in_mark) { + if (m.end < boundary) boundary = m.end; + } else if (m.start > i && m.start < boundary) { + boundary = m.start; + } + } + cur += escape_html_slice(text, i, boundary); + i = boundary; + if (in_mark && (i >= to || i === nl_probe || marks![mi]!.end <= i)) { + cur += mark_close; + in_mark = false; + } + } + }; + + let pos = 0; + let i = 0; + while (i < events_len) { + const tag = events[i]!; + if (tag > 0) { + const start = events[i + 1]!; + const end = events[i + 2]!; + if (start > pos) emit_text(pos, start); + leaf_tag = infos[tag]!.open_tag; + cur += leaf_tag; + emit_text(start, end); + cur += ''; + leaf_tag = ''; + pos = end; + i += 3; + } else if (tag < 0) { + const start = events[i + 1]!; + if (start > pos) emit_text(pos, start); + cur += infos[-tag]!.open_tag; + stack.push(-tag); + pos = start; + i += 2; + } else { + const end = events[i + 1]!; + if (end > pos) emit_text(pos, end); + cur += ''; + stack.pop(); + pos = end; + i += 2; + } + } + if (pos < text.length) emit_text(pos, text.length); + lines.push(cur); + return lines; +}; + /** * A flattened token span, in document order with containers before their * children. Used by fixtures, tests, and range building. diff --git a/src/lib/theme_diff.css b/src/lib/theme_diff.css new file mode 100644 index 00000000..811750de --- /dev/null +++ b/src/lib/theme_diff.css @@ -0,0 +1,126 @@ +/* + +Styles for the diff viewer (`CodeDiff.svelte` unified view, +`CodeDiffSplit.svelte` side-by-side view, and the `diff_html.ts` renderers +beneath them). Import alongside theme.css. Row tints key off fuz_css's +semantic intent variables (positive/negative) through a small indirection +layer — override the four `--diff_*` custom properties to retheme. + +*/ + +.code_diff, +.code_diff_split { + --diff_add_bg: var(--positive_05); + --diff_remove_bg: var(--negative_05); + --diff_add_emph_bg: var(--positive_20); + --diff_remove_emph_bg: var(--negative_20); + font-family: var(--font_family_mono); + overflow: auto; + max-width: 100%; + padding: var(--space_xs3) 0; +} + +.code_diff:not(.nomargin):not(:last-child), +.code_diff_split:not(.nomargin):not(:last-child) { + margin-bottom: var(--space_lg); +} + +.diff_line { + display: block; +} + +/* split view: a two-column grid of half-row cells */ +.code_diff_split { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-content: start; +} + +.code_diff_split .diff_skip { + grid-column: 1 / -1; +} + +/* rows inside an expandable elision replicate the two-column layout */ +.diff_split_rows { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +.diff_cell { + overflow-x: auto; +} + +/* restore code styling on the semantic add/remove rows */ +del.diff_line, +ins.diff_line, +del.diff_cell, +ins.diff_cell { + text-decoration: none; + color: inherit; +} + +.diff_add { + background-color: var(--diff_add_bg); +} + +.diff_remove { + background-color: var(--diff_remove_bg); +} + +.diff_text { + white-space: pre; +} + +.code_diff.wrap .diff_text, +.code_diff_split.wrap .diff_text { + white-space: pre-wrap; +} + +/* the +/-/space markers are generated content with accessible alt text, + so copied text stays clean code */ +.diff_text::before { + content: ' ' / ''; +} + +.diff_add > .diff_text::before { + content: '+' / 'added'; +} + +.diff_remove > .diff_text::before { + content: '-' / 'removed'; +} + +.diff_gutter { + user-select: none; + white-space: pre; + padding-inline: var(--space_xs); + color: var(--text_50); +} + +/* intra-line emphasis — marks wrap text runs inside token spans */ +.diff_add mark { + background-color: var(--diff_add_emph_bg); + color: inherit; +} + +.diff_remove mark { + background-color: var(--diff_remove_emph_bg); + color: inherit; +} + +.diff_skip { + color: var(--text_50); + padding-inline: var(--space_xs); +} + +.diff_skip summary { + user-select: none; + cursor: pointer; +} + +/* marks the final line of a side that lacks a trailing newline */ +.diff_no_newline::before { + content: '⏎' / 'no newline at end of file'; + opacity: 0.5; + text-decoration: line-through; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index aff11a3d..ff4c1e9a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -28,8 +28,8 @@

fuz_code is a syntax highlighting library. It runs one lexer per language without regular - expressions, outputs HTML strings or Svelte components, and can compile static content at - build time. It originated as a fork and by-hand rewrite of + expressions, outputs HTML strings or Svelte components, renders syntax-highlighted diffs, + and can compile static content at build time. It originated as a fork and by-hand rewrite of Prism by Lea Verou, with a redesigned tokenizer that replaces regular expressions with lexers per language emitting a flat token event stream. diff --git a/src/routes/docs/diff/+page.svelte b/src/routes/docs/diff/+page.svelte new file mode 100644 index 00000000..5dd6712b --- /dev/null +++ b/src/routes/docs/diff/+page.svelte @@ -0,0 +1,105 @@ + + + + {#if at_root} +

+

+ A syntax-highlighted viewer backed by + and . +

+
+ {:else} +
+

+ renders a unified diff of two versions of a source text, and + renders the same diff side-by-side. Both sides are lexed + as whole documents, so the normal syntax highlighting is preserved and the diff information — + row tints, line-number gutters, intra-line <mark> emphasis — is overlaid + on top. Added and removed rows are semantic <ins>/<del> + elements, the markers and gutters stay out of copied text, and unchanged regions collapse into + expandable <details> blocks with no JavaScript. Requires + theme_diff.css. +

+ + \n\timport '@fuzdev/fuz_code/theme_diff.css';\n\timport CodeDiff from '@fuzdev/fuz_code/CodeDiff.svelte';\n\n\n`} + /> + +
+ + +
+ + {#if split} + + {:else} + + {/if} + +

Edit either version and the diff re-renders:

+ +
+ + +
+
+ {/if} + + + diff --git a/src/routes/docs/tomes.ts b/src/routes/docs/tomes.ts index 020fc484..1d67a8cd 100644 --- a/src/routes/docs/tomes.ts +++ b/src/routes/docs/tomes.ts @@ -1,6 +1,7 @@ import type {Tome} from '@fuzdev/fuz_ui/tome.ts'; import UsagePage from './usage/+page.svelte'; import SamplesPage from './samples/+page.svelte'; +import DiffPage from './diff/+page.svelte'; import TextareaPage from './textarea/+page.svelte'; import BenchmarkPage from './benchmark/+page.svelte'; import ApiPage from './api/+page.svelte'; @@ -10,7 +11,7 @@ export const tomes: Array = [ slug: 'usage', category: 'guide', Component: UsagePage, - related_tomes: ['samples', 'textarea', 'benchmark'], + related_tomes: ['samples', 'diff', 'textarea', 'benchmark'], related_modules: [ 'syntax_styler.ts', 'syntax_styler_global.ts', @@ -26,6 +27,14 @@ export const tomes: Array = [ related_modules: [], related_declarations: ['Code', 'CodeHighlight'], }, + { + slug: 'diff', + category: 'explore', + Component: DiffPage, + related_tomes: ['usage', 'samples'], + related_modules: ['diff_html.ts'], + related_declarations: ['CodeDiff', 'CodeDiffSplit'], + }, { slug: 'textarea', category: 'explore', diff --git a/src/routes/docs/usage/+page.svelte b/src/routes/docs/usage/+page.svelte index 78fb5a99..c829aa44 100644 --- a/src/routes/docs/usage/+page.svelte +++ b/src/routes/docs/usage/+page.svelte @@ -185,6 +185,15 @@ styler.add_lang(lexer_css); const html = styler.stylize('
hello
', 'html');`} /> + + +

+ and render syntax-highlighted + diffs of two versions of a source text — unified and side-by-side — keeping the normal token styling + and overlaying the diff information. Import + theme_diff.css alongside the main theme. See . +

+

diff --git a/src/test/diff_html.test.ts b/src/test/diff_html.test.ts new file mode 100644 index 00000000..ed78d3b2 --- /dev/null +++ b/src/test/diff_html.test.ts @@ -0,0 +1,217 @@ +import {describe, test, assert} from 'vitest'; + +import {render_diff_unified_html, render_diff_split_html} from '$lib/diff_html.ts'; +import {strip_tags, assert_balanced} from './html_test_helpers.ts'; + +const A = 'const a = 1;\nconst b = 2;\nconst c = 3;\n'; +const B = 'const a = 1;\nconst b = 20;\nconst c = 3;\n'; + +describe('render_diff_unified_html', () => { + test('renders semantic rows with diff classes', () => { + const html = render_diff_unified_html(A, B, {lang: 'ts'}); + assert_balanced(html); + assert.include(html, ''); + assert.include(html, ''); + assert.include(html, ''); + }); + + test('highlights rows with token spans', () => { + const html = render_diff_unified_html(A, B, {lang: 'ts'}); + assert.include(html, 'token_keyword'); + assert.include(html, 'token_number'); + }); + + test('lang null renders plain rows with diff chrome only', () => { + const html = render_diff_unified_html(A, B, {lang: null}); + assert.notInclude(html, 'token_'); + assert.include(html, 'diff_remove'); + }); + + test('unregistered lang falls back to plain rows', () => { + const html = render_diff_unified_html(A, B, {lang: 'nope'}); + assert.notInclude(html, 'token_'); + }); + + test('markers are generated content, not text — copied text stays clean', () => { + const html = render_diff_unified_html(A, B, {lang: 'ts'}); + assert.notInclude(html, '+const'); + assert.notInclude(html, '-const'); + const text = strip_tags(html); + assert.include(text, 'const b = 2;'); + assert.include(text, 'const b = 20;'); + }); + + test('gutters carry both line numbers and are aria-hidden', () => { + const html = render_diff_unified_html(A, B, {lang: 'ts'}); + assert.include(html, ''); + assert.include(html, ''); + assert.include(html, ''); + }); + + test('line_numbers false omits gutters', () => { + const html = render_diff_unified_html(A, B, {lang: 'ts', line_numbers: false}); + assert.notInclude(html, 'diff_gutter'); + }); + + test('intra-line emphasis marks the changed range', () => { + const html = render_diff_unified_html(A, B, {lang: 'ts'}); + assert.include(html, ''); + // the changed chars sit inside the token span, marked + assert.include(html, '0'); + }); + + test('intraline false disables marks', () => { + const html = render_diff_unified_html(A, B, {lang: 'ts', intraline: false}); + assert.notInclude(html, ''); + }); + + describe('elision', () => { + const make_pair = (): {a: string; b: string} => { + const a_lines = Array.from({length: 20}, (_, i) => `line ${i + 1}`); + const b_lines = [...a_lines]; + b_lines[10] = 'modified'; + return {a: a_lines.join('\n') + '\n', b: b_lines.join('\n') + '\n'}; + }; + + test('details by default, with expandable rows inside', () => { + const {a, b} = make_pair(); + const html = render_diff_unified_html(a, b); + assert_balanced(html); + assert.include(html, '

7 unchanged lines'); + assert.include(html, '6 unchanged lines'); + // the collapsed rows are present in the DOM + assert.include(strip_tags(html), 'line 1'); + assert.include(strip_tags(html), 'line 20'); + }); + + test('omit renders an inert count row', () => { + const {a, b} = make_pair(); + const html = render_diff_unified_html(a, b, {elide: 'omit'}); + assert.include(html, '
7 unchanged lines
'); + assert.notInclude(html, ' { + const {a, b} = make_pair(); + const html = render_diff_unified_html(a, b, {elide: 'none'}); + assert.notInclude(html, 'diff_skip'); + assert.include(strip_tags(html), 'line 1'); + }); + + test('identical inputs elide everything', () => { + const html = render_diff_unified_html('a\nb\nc\n', 'a\nb\nc\n'); + assert.include(html, '3 unchanged lines'); + assert.notInclude(html, 'diff_add'); + }); + + test('singular count', () => { + const html = render_diff_unified_html('a\n', 'a\n'); + assert.include(html, '1 unchanged line'); + }); + }); + + test('no_newline marker span on unterminated final lines', () => { + const html = render_diff_unified_html('x', 'y', {intraline: false}); + assert.include(html, ''); + }); + + test('multi-line constructs highlight correctly across rows', () => { + const a = 'const s = `one\ntwo`;\n'; + const b = 'const s = `one\nthree`;\n'; + const html = render_diff_unified_html(a, b, {lang: 'ts'}); + assert_balanced(html); + const text = strip_tags(html); + assert.include(text, 'two`;'); + assert.include(text, 'three`;'); + }); + + test('escapes html in content', () => { + const html = render_diff_unified_html(' + +

Hi {name}

+ + diff --git a/src/test/fixtures/diff/svelte_component/b.svelte b/src/test/fixtures/diff/svelte_component/b.svelte new file mode 100644 index 00000000..533035b9 --- /dev/null +++ b/src/test/fixtures/diff/svelte_component/b.svelte @@ -0,0 +1,12 @@ + + +

Hi {title}

+

{subtitle}

+ + diff --git a/src/test/fixtures/diff/ts_function/a.ts b/src/test/fixtures/diff/ts_function/a.ts new file mode 100644 index 00000000..9967034c --- /dev/null +++ b/src/test/fixtures/diff/ts_function/a.ts @@ -0,0 +1,7 @@ +export const greet = (name: string): string => { + const message = `Hello, ${name}! +Welcome to the app.`; + return message; +}; + +export const VERSION = 1; diff --git a/src/test/fixtures/diff/ts_function/b.ts b/src/test/fixtures/diff/ts_function/b.ts new file mode 100644 index 00000000..83f029b6 --- /dev/null +++ b/src/test/fixtures/diff/ts_function/b.ts @@ -0,0 +1,8 @@ +export const greet = (name: string, formal: boolean): string => { + const message = `Hello, ${name}! +Welcome to the application.`; + if (formal) return `Dear ${name}`; + return message; +}; + +export const VERSION = 2; diff --git a/src/test/fixtures/generated/diff/edge_empty.html b/src/test/fixtures/generated/diff/edge_empty.html new file mode 100644 index 00000000..121e39f2 --- /dev/null +++ b/src/test/fixtures/generated/diff/edge_empty.html @@ -0,0 +1 @@ +{"a": 1} \ No newline at end of file diff --git a/src/test/fixtures/generated/diff/edge_empty.split.html b/src/test/fixtures/generated/diff/edge_empty.split.html new file mode 100644 index 00000000..04292a2f --- /dev/null +++ b/src/test/fixtures/generated/diff/edge_empty.split.html @@ -0,0 +1 @@ +{"a": 1} \ No newline at end of file diff --git a/src/test/fixtures/generated/diff/edge_empty.txt b/src/test/fixtures/generated/diff/edge_empty.txt new file mode 100644 index 00000000..d3406070 --- /dev/null +++ b/src/test/fixtures/generated/diff/edge_empty.txt @@ -0,0 +1,9 @@ +=== STATS === +a: 0 chars, b: 9 chars +lines: 1 (1 changed), hunks: 1 + +=== DIFF === +--- a.json ++++ b.json +@@ -0,0 +1,1 @@ ++{"a": 1} diff --git a/src/test/fixtures/generated/diff/edge_no_newline.html b/src/test/fixtures/generated/diff/edge_no_newline.html new file mode 100644 index 00000000..79bf11ca --- /dev/null +++ b/src/test/fixtures/generated/diff/edge_no_newline.html @@ -0,0 +1 @@ +export const done = false;export const done = true; \ No newline at end of file diff --git a/src/test/fixtures/generated/diff/edge_no_newline.split.html b/src/test/fixtures/generated/diff/edge_no_newline.split.html new file mode 100644 index 00000000..d0b2faaf --- /dev/null +++ b/src/test/fixtures/generated/diff/edge_no_newline.split.html @@ -0,0 +1 @@ +export const done = false;export const done = true; \ No newline at end of file diff --git a/src/test/fixtures/generated/diff/edge_no_newline.txt b/src/test/fixtures/generated/diff/edge_no_newline.txt new file mode 100644 index 00000000..c9e49fd5 --- /dev/null +++ b/src/test/fixtures/generated/diff/edge_no_newline.txt @@ -0,0 +1,11 @@ +=== STATS === +a: 26 chars, b: 26 chars +lines: 2 (2 changed), hunks: 1 + +=== DIFF === +--- a.ts ++++ b.ts +@@ -1,1 +1,1 @@ +-export const done = false; +\ No newline at end of file ++export const done = true; diff --git a/src/test/fixtures/generated/diff/edge_whitespace.html b/src/test/fixtures/generated/diff/edge_whitespace.html new file mode 100644 index 00000000..96d81557 --- /dev/null +++ b/src/test/fixtures/generated/diff/edge_whitespace.html @@ -0,0 +1 @@ +
3 unchanged lines.a { color: red;}
\ No newline at end of file diff --git a/src/test/fixtures/generated/diff/edge_whitespace.split.html b/src/test/fixtures/generated/diff/edge_whitespace.split.html new file mode 100644 index 00000000..97e50e22 --- /dev/null +++ b/src/test/fixtures/generated/diff/edge_whitespace.split.html @@ -0,0 +1 @@ +
3 unchanged lines
.a {.a { color: red; color: red;}}
\ No newline at end of file diff --git a/src/test/fixtures/generated/diff/edge_whitespace.txt b/src/test/fixtures/generated/diff/edge_whitespace.txt new file mode 100644 index 00000000..432b6d68 --- /dev/null +++ b/src/test/fixtures/generated/diff/edge_whitespace.txt @@ -0,0 +1,7 @@ +=== STATS === +a: 20 chars, b: 20 chars +lines: 3 (0 changed), hunks: 0 + +=== DIFF === +--- a.css ++++ b.css diff --git a/src/test/fixtures/generated/diff/md_doc.html b/src/test/fixtures/generated/diff/md_doc.html new file mode 100644 index 00000000..8d966f12 --- /dev/null +++ b/src/test/fixtures/generated/diff/md_doc.html @@ -0,0 +1 @@ +# Notes# Notes on widgetsSome _text_ about the widget.Some _text_ about the widget, expanded.```tsconst a = 1;const b = a + 1;``` \ No newline at end of file diff --git a/src/test/fixtures/generated/diff/md_doc.split.html b/src/test/fixtures/generated/diff/md_doc.split.html new file mode 100644 index 00000000..dd738475 --- /dev/null +++ b/src/test/fixtures/generated/diff/md_doc.split.html @@ -0,0 +1 @@ +# Notes# Notes on widgetsSome _text_ about the widget.Some _text_ about the widget, expanded.```ts```tsconst a = 1;const a = 1;const b = a + 1;`````` \ No newline at end of file diff --git a/src/test/fixtures/generated/diff/md_doc.txt b/src/test/fixtures/generated/diff/md_doc.txt new file mode 100644 index 00000000..981912ee --- /dev/null +++ b/src/test/fixtures/generated/diff/md_doc.txt @@ -0,0 +1,18 @@ +=== STATS === +a: 63 chars, b: 101 chars +lines: 10 (5 changed), hunks: 1 + +=== DIFF === +--- a.md ++++ b.md +@@ -1,7 +1,8 @@ +-# Notes ++# Notes on widgets + +-Some _text_ about the widget. ++Some _text_ about the widget, expanded. + + ```ts + const a = 1; ++const b = a + 1; + ``` diff --git a/src/test/fixtures/generated/diff/rust_impl.html b/src/test/fixtures/generated/diff/rust_impl.html new file mode 100644 index 00000000..5072d328 --- /dev/null +++ b/src/test/fixtures/generated/diff/rust_impl.html @@ -0,0 +1 @@ +/// Adds two numbers./// Adds two numbers, saturating on overflow.pub fn add(a: i32, b: i32) -> i32 { a + b a.saturating_add(b)}#[cfg(test)]mod tests { use super::*; #[test] fn saturates() { assert_eq!(add(i32::MAX, 1), i32::MAX); }} \ No newline at end of file diff --git a/src/test/fixtures/generated/diff/rust_impl.split.html b/src/test/fixtures/generated/diff/rust_impl.split.html new file mode 100644 index 00000000..78a7fcca --- /dev/null +++ b/src/test/fixtures/generated/diff/rust_impl.split.html @@ -0,0 +1 @@ +/// Adds two numbers./// Adds two numbers, saturating on overflow.pub fn add(a: i32, b: i32) -> i32 {pub fn add(a: i32, b: i32) -> i32 { a + b a.saturating_add(b)}#[cfg(test)]mod tests { use super::*; #[test] fn saturates() { assert_eq!(add(i32::MAX, 1), i32::MAX); }}} \ No newline at end of file diff --git a/src/test/fixtures/generated/diff/rust_impl.txt b/src/test/fixtures/generated/diff/rust_impl.txt new file mode 100644 index 00000000..01c44d80 --- /dev/null +++ b/src/test/fixtures/generated/diff/rust_impl.txt @@ -0,0 +1,24 @@ +=== STATS === +a: 70 chars, b: 242 chars +lines: 16 (14 changed), hunks: 1 + +=== DIFF === +--- a.rs ++++ b.rs +@@ -1,4 +1,14 @@ +-/// Adds two numbers. ++/// Adds two numbers, saturating on overflow. + pub fn add(a: i32, b: i32) -> i32 { +- a + b ++ a.saturating_add(b) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn saturates() { ++ assert_eq!(add(i32::MAX, 1), i32::MAX); ++ } + } diff --git a/src/test/fixtures/generated/diff/svelte_component.html b/src/test/fixtures/generated/diff/svelte_component.html new file mode 100644 index 00000000..54029e46 --- /dev/null +++ b/src/test/fixtures/generated/diff/svelte_component.html @@ -0,0 +1 @@ +<script lang="ts"> const {name} = $props(); const {title, subtitle} = $props();</script><h1>Hi {name}</h1><h1>Hi {title}</h1><p>{subtitle}</p><style> h1 { color: red; color: blue; }</style> \ No newline at end of file diff --git a/src/test/fixtures/generated/diff/svelte_component.split.html b/src/test/fixtures/generated/diff/svelte_component.split.html new file mode 100644 index 00000000..aa7cbb6c --- /dev/null +++ b/src/test/fixtures/generated/diff/svelte_component.split.html @@ -0,0 +1 @@ +<script lang="ts"><script lang="ts"> const {name} = $props(); const {title, subtitle} = $props();</script></script><h1>Hi {name}</h1><h1>Hi {title}</h1><p>{subtitle}</p><style><style> h1 { h1 { color: red; color: blue; } }</style></style> \ No newline at end of file diff --git a/src/test/fixtures/generated/diff/svelte_component.txt b/src/test/fixtures/generated/diff/svelte_component.txt new file mode 100644 index 00000000..f12bcd88 --- /dev/null +++ b/src/test/fixtures/generated/diff/svelte_component.txt @@ -0,0 +1,23 @@ +=== STATS === +a: 116 chars, b: 147 chars +lines: 15 (7 changed), hunks: 1 + +=== DIFF === +--- a.svelte ++++ b.svelte +@@ -1,11 +1,12 @@ + + +-

Hi {name}

++

Hi {title}

++

{subtitle}

+ + diff --git a/src/test/fixtures/generated/diff/ts_function.html b/src/test/fixtures/generated/diff/ts_function.html new file mode 100644 index 00000000..a885b418 --- /dev/null +++ b/src/test/fixtures/generated/diff/ts_function.html @@ -0,0 +1 @@ +export const greet = (name: string): string => {export const greet = (name: string, formal: boolean): string => { const message = `Hello, ${name}!Welcome to the app.`;Welcome to the application.`; if (formal) return `Dear ${name}`; return message;};export const VERSION = 1;export const VERSION = 2; \ No newline at end of file diff --git a/src/test/fixtures/generated/diff/ts_function.split.html b/src/test/fixtures/generated/diff/ts_function.split.html new file mode 100644 index 00000000..7a6cc9c2 --- /dev/null +++ b/src/test/fixtures/generated/diff/ts_function.split.html @@ -0,0 +1 @@ +export const greet = (name: string): string => {export const greet = (name: string, formal: boolean): string => { const message = `Hello, ${name}! const message = `Hello, ${name}!Welcome to the app.`;Welcome to the application.`; if (formal) return `Dear ${name}`; return message; return message;};};export const VERSION = 1;export const VERSION = 2; \ No newline at end of file diff --git a/src/test/fixtures/generated/diff/ts_function.txt b/src/test/fixtures/generated/diff/ts_function.txt new file mode 100644 index 00000000..78b31c30 --- /dev/null +++ b/src/test/fixtures/generated/diff/ts_function.txt @@ -0,0 +1,19 @@ +=== STATS === +a: 152 chars, b: 213 chars +lines: 11 (7 changed), hunks: 1 + +=== DIFF === +--- a.ts ++++ b.ts +@@ -1,7 +1,8 @@ +-export const greet = (name: string): string => { ++export const greet = (name: string, formal: boolean): string => { + const message = `Hello, ${name}! +-Welcome to the app.`; ++Welcome to the application.`; ++ if (formal) return `Dear ${name}`; + return message; + }; + +-export const VERSION = 1; ++export const VERSION = 2; diff --git a/src/test/fixtures/helpers.ts b/src/test/fixtures/helpers.ts index a06cb1d5..2686923f 100644 --- a/src/test/fixtures/helpers.ts +++ b/src/test/fixtures/helpers.ts @@ -1,8 +1,10 @@ import {readFileSync} from 'node:fs'; import {fs_search} from '@fuzdev/fuz_util/fs.ts'; -import {basename, join, relative} from 'node:path'; +import {diff_lines, diff_hunks, format_diff} from '@fuzdev/fuz_util/diff.ts'; +import {basename, dirname, join, relative} from 'node:path'; import {syntax_styler_global} from '$lib/syntax_styler_global.ts'; import {syntax_events_to_tokens} from '$lib/lexer.ts'; +import {render_diff_unified_html, render_diff_split_html} from '$lib/diff_html.ts'; export interface SampleSpec { lang: string; @@ -85,6 +87,98 @@ export const process_sample = (sample: SampleSpec): GeneratedOutput => { }; }; +/** + * A discovered diff fixture case: an `a`/`b` source pair sharing one + * language, from `src/test/fixtures/diff/{name}/`. + */ +export interface DiffCaseSpec { + name: string; + lang: string; + a: string; + b: string; +} + +/** + * Discovers diff fixture cases in `src/test/fixtures/diff` — each case dir + * holds an `a.{ext}` + `b.{ext}` pair whose extension is the language. + * Throws on unpaired or extension-mismatched cases. + */ +export const discover_diff_cases = async (): Promise> => { + const files = await fs_search('src/test/fixtures/diff', { + file_filter: (path) => /\/[ab]\.[^./]+$/.test(path), + }); + + const by_case: Map = new Map(); + for (const file of files) { + const name = basename(dirname(file.id)); + const filename = basename(file.id); + const side = filename[0] as 'a' | 'b'; + const ext = filename.slice(2); + let entry = by_case.get(name); + if (!entry) { + entry = {}; + by_case.set(name, entry); + } + entry[side] = readFileSync(file.id, 'utf-8'); + entry[side === 'a' ? 'a_ext' : 'b_ext'] = ext; + } + + const cases: Array = []; + for (const [name, entry] of [...by_case].sort(([x], [y]) => (x < y ? -1 : 1))) { + if (entry.a === undefined || entry.b === undefined) { + throw Error(`Diff case "${name}" is missing its ${entry.a === undefined ? 'a' : 'b'} file`); + } + if (entry.a_ext !== entry.b_ext) { + throw Error( + `Diff case "${name}" has mismatched extensions: ${entry.a_ext} vs ${entry.b_ext}`, + ); + } + cases.push({name, lang: entry.a_ext!, a: entry.a, b: entry.b}); + } + return cases; +}; + +/** + * Get the generated fixture path for a diff case. + */ +export const get_diff_fixture_path = (name: string, ext: 'html' | 'split.html' | 'txt'): string => + join('src/test/fixtures/generated/diff', `${name}.${ext}`); + +export interface DiffGeneratedOutput { + spec: DiffCaseSpec; + unified_html: string; + split_html: string; +} + +/** + * Process a diff case to generate both views' HTML. + */ +export const process_diff_case = (spec: DiffCaseSpec): DiffGeneratedOutput => ({ + spec, + unified_html: render_diff_unified_html(spec.a, spec.b, {lang: spec.lang}), + split_html: render_diff_split_html(spec.a, spec.b, {lang: spec.lang}), +}); + +/** + * Generate debug text output for a diff case — stats plus the plain + * unified-diff text (the `st` seam is unconfigured here, so no ANSI). + */ +export const generate_diff_debug_text = (spec: DiffCaseSpec): string => { + const lines = diff_lines(spec.a, spec.b); + const hunks = diff_hunks(lines); + let changed = 0; + for (const line of lines) { + if (line.type !== 'same') changed++; + } + let debug = '=== STATS ===\n'; + debug += `a: ${spec.a.length} chars, b: ${spec.b.length} chars\n`; + debug += `lines: ${lines.length} (${changed} changed), hunks: ${hunks.length}\n`; + debug += '\n=== DIFF ===\n'; + debug += format_diff(hunks, `a.${spec.lang}`, `b.${spec.lang}`, {max_lines: 0}); + debug += '\n'; + return debug; +}; + /** * Generate debug text output for a sample */ diff --git a/src/test/fixtures/update.task.ts b/src/test/fixtures/update.task.ts index 1466f1b1..e15980ce 100644 --- a/src/test/fixtures/update.task.ts +++ b/src/test/fixtures/update.task.ts @@ -6,6 +6,10 @@ import { process_sample, generate_debug_text, get_fixture_path, + discover_diff_cases, + process_diff_case, + generate_diff_debug_text, + get_diff_fixture_path, } from './helpers.ts'; export const task: Task = { @@ -53,6 +57,33 @@ export const task: Task = { console.log(` → ${txt_path}`); // eslint-disable-line no-console } - console.log(`\n✓ Updated ${samples.length} samples`); // eslint-disable-line no-console + // Process diff cases + const diff_cases = await discover_diff_cases(); + const diff_dir = join(generated_fixtures_dir, 'diff'); + if (existsSync(diff_dir)) { + rmSync(diff_dir, {recursive: true, force: true}); + console.log(`Removed existing directory: ${diff_dir}`); // eslint-disable-line no-console + } + mkdirSync(diff_dir, {recursive: true}); + + for (const diff_case of diff_cases) { + console.log(`Processing diff ${diff_case.name}...`); // eslint-disable-line no-console + const output = process_diff_case(diff_case); + + const html_path = get_diff_fixture_path(diff_case.name, 'html'); + writeFileSync(html_path, output.unified_html); + console.log(` → ${html_path}`); // eslint-disable-line no-console + + const split_path = get_diff_fixture_path(diff_case.name, 'split.html'); + writeFileSync(split_path, output.split_html); + console.log(` → ${split_path}`); // eslint-disable-line no-console + + const txt_path = get_diff_fixture_path(diff_case.name, 'txt'); + writeFileSync(txt_path, generate_diff_debug_text(diff_case)); + console.log(` → ${txt_path}`); // eslint-disable-line no-console + } + + // eslint-disable-next-line no-console + console.log(`\n✓ Updated ${samples.length} samples and ${diff_cases.length} diff cases`); }, }; diff --git a/src/test/html_test_helpers.ts b/src/test/html_test_helpers.ts new file mode 100644 index 00000000..61e0f843 --- /dev/null +++ b/src/test/html_test_helpers.ts @@ -0,0 +1,54 @@ +import {assert} from 'vitest'; + +/** + * Strips tags and unescapes the entities `escape_html_slice` produces, + * recovering rendered HTML's raw text. + */ +export const strip_tags = (html: string): string => { + let out = ''; + let i = 0; + while (i < html.length) { + const c = html[i]!; + if (c === '<') { + const close = html.indexOf('>', i); + i = close === -1 ? html.length : close + 1; + } else if (c === '&' && html.startsWith('&', i)) { + out += '&'; + i += 5; + } else if (c === '&' && html.startsWith('<', i)) { + out += '<'; + i += 4; + } else { + out += c; + i++; + } + } + return out; +}; + +/** + * Asserts an HTML string's tags are balanced and properly nested. + */ +export const assert_balanced = (html: string): void => { + const stack: Array = []; + let i = 0; + while (i < html.length) { + if (html[i] === '<') { + const close = html.indexOf('>', i); + assert.notStrictEqual(close, -1, `unterminated tag in: ${html}`); + const tag = html.slice(i + 1, close); + if (tag.startsWith('/')) { + const opened = stack.pop(); + assert.isDefined(opened, `close without open in: ${html}`); + assert.strictEqual(tag.slice(1), opened, `mismatched close in: ${html}`); + } else { + const space = tag.indexOf(' '); + stack.push(space === -1 ? tag : tag.slice(0, space)); + } + i = close + 1; + } else { + i++; + } + } + assert.deepEqual(stack, [], `unclosed tags in: ${html}`); +}; diff --git a/src/test/lexer.html_lines.test.ts b/src/test/lexer.html_lines.test.ts new file mode 100644 index 00000000..06b3f505 --- /dev/null +++ b/src/test/lexer.html_lines.test.ts @@ -0,0 +1,131 @@ +import {describe, test, assert} from 'vitest'; +import {readFileSync} from 'node:fs'; + +import {render_syntax_html_lines, type SyntaxHtmlMark} from '$lib/lexer.ts'; +import {syntax_styler_global} from '$lib/syntax_styler_global.ts'; +import {sample_langs} from '$lib/code_sample.ts'; +import {strip_tags, assert_balanced} from './html_test_helpers.ts'; + +const lex = (text: string, lang: string) => syntax_styler_global.lex(text, lang); + +describe('render_syntax_html_lines', () => { + test('empty text renders one empty fragment', () => { + assert.deepEqual(render_syntax_html_lines(lex('', 'ts')), ['']); + }); + + test('trailing newline yields a final empty fragment', () => { + const fragments = render_syntax_html_lines(lex('const a = 1;\n', 'ts')); + assert.lengthOf(fragments, 2); + assert.strictEqual(fragments[1], ''); + }); + + test('fragment text reassembles the source lines', () => { + const text = 'const a = 1;\nconst b = "x\\n";\n// done'; + const fragments = render_syntax_html_lines(lex(text, 'ts')); + assert.deepEqual(fragments.map(strip_tags), text.split('\n')); + }); + + test('multi-line tokens split with spans reopened per line', () => { + const text = 'a {}\n/* one\ntwo */\nb {}'; + const fragments = render_syntax_html_lines(lex(text, 'css')); + assert.lengthOf(fragments, 4); + assert.include(fragments[1], 'token_comment'); + assert.include(fragments[2], 'token_comment'); + for (const fragment of fragments) assert_balanced(fragment); + assert.deepEqual(fragments.map(strip_tags), text.split('\n')); + }); + + test('template literals across lines stay highlighted per line', () => { + const text = 'const s = `one\ntwo ${x}\nthree`;'; + const fragments = render_syntax_html_lines(lex(text, 'ts')); + assert.lengthOf(fragments, 3); + for (const fragment of fragments) assert_balanced(fragment); + assert.deepEqual(fragments.map(strip_tags), text.split('\n')); + }); + + test('determinism', () => { + const text = 'const a = 1;\nconst b = 2;\n'; + assert.deepEqual( + render_syntax_html_lines(lex(text, 'ts')), + render_syntax_html_lines(lex(text, 'ts')), + ); + }); + + describe('samples', () => { + for (const lang of sample_langs) { + test(`sample_complex.${lang} fragments are balanced and reassemble`, () => { + const content = readFileSync(`src/test/fixtures/samples/sample_complex.${lang}`, 'utf8'); + const fragments = render_syntax_html_lines(lex(content, lang)); + const expected = content.replaceAll('\xa0', ' ').split('\n'); + assert.lengthOf(fragments, expected.length); + assert.deepEqual(fragments.map(strip_tags), expected); + for (const fragment of fragments) assert_balanced(fragment); + }); + } + }); + + describe('marks', () => { + test('wraps a range in plain text', () => { + const fragments = render_syntax_html_lines(lex('abcdef', 'plaintext'), { + marks: [{start: 2, end: 5}], + }); + assert.deepEqual(fragments, ['abcdef']); + }); + + test('multiple ranges on one line', () => { + const fragments = render_syntax_html_lines(lex('abcdef', 'plaintext'), { + marks: [ + {start: 0, end: 2}, + {start: 4, end: 6}, + ], + }); + assert.deepEqual(fragments, ['abcdef']); + }); + + test('a mark crossing a newline closes and reopens', () => { + const fragments = render_syntax_html_lines(lex('ab\ncdef', 'plaintext'), { + marks: [{start: 1, end: 5}], + }); + assert.deepEqual(fragments, ['ab', 'cdef']); + }); + + test('a mark crossing token boundaries splits into adjacent marks', () => { + const text = '{"a": 1}'; + const fragments = render_syntax_html_lines(lex(text, 'json'), { + marks: [{start: 1, end: 7}], + }); + assert.lengthOf(fragments, 1); + assert_balanced(fragments[0]!); + assert.strictEqual(strip_tags(fragments[0]!), text); + // the range spans multiple tokens, so the mark is cut into pieces + assert.isAtLeast(fragments[0]!.split('').length - 1, 2); + }); + + test('custom mark tags', () => { + const fragments = render_syntax_html_lines(lex('abc', 'plaintext'), { + marks: [{start: 1, end: 2}], + mark_open_tag: '', + mark_close_tag: '', + }); + assert.deepEqual(fragments, ['abc']); + }); + + test('marks compose with multi-line tokens', () => { + const text = '/* one\ntwo */'; + const marks: Array = [{start: 3, end: 10}]; + const fragments = render_syntax_html_lines(lex(text, 'css'), {marks}); + assert.lengthOf(fragments, 2); + for (const fragment of fragments) assert_balanced(fragment); + assert.deepEqual(fragments.map(strip_tags), text.split('\n')); + assert.include(fragments[0], ''); + assert.include(fragments[1], ''); + }); + + test('marks at text boundaries', () => { + const fragments = render_syntax_html_lines(lex('abc', 'plaintext'), { + marks: [{start: 0, end: 3}], + }); + assert.deepEqual(fragments, ['abc']); + }); + }); +});