/** * Cutting coloured code into lines. A diff prints each line in its own row, * so a span the grammar opened across a line break has to be closed and * reopened without changing what the line says. */ import { describe, expect, it } from 'vitest'; import { diffHighlighter, highlightBlock, highlightToLines, languageFor, } from '../src/lib/highlight.js'; /** The text a browser would show for a run of HTML. */ function textOf(html) { return html .replace(/<[^>]*>/g, '') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&'); } describe('languageFor', () => { it('reads the grammar off the extension', () => { expect(languageFor('src/index.js')).toBe('javascript'); expect(languageFor('a/b/main.rs')).toBe('rust'); expect(languageFor('README.md')).toBe('markdown'); expect(languageFor('deploy.yml')).toBe('yaml'); expect(languageFor('Sources/App/main.swift')).toBe('swift'); expect(languageFor('app/src/main/Main.kt')).toBe('kotlin'); expect(languageFor('build.gradle.kts')).toBe('kotlin'); expect(languageFor('app/build.gradle')).toBe('groovy'); expect(languageFor('Main.java')).toBe('java'); expect(languageFor('AppDelegate.m')).toBe('objectivec'); expect(languageFor('Info.plist')).toBe('xml'); expect(languageFor('gradle.properties')).toBe('properties'); }); it('colours a Svelte component as markup', () => { expect(languageFor('src/App.svelte')).toBe('xml'); }); it('names a grammar for a file that has no extension', () => { expect(languageFor('Dockerfile')).toBe('dockerfile'); expect(languageFor('docker/Dockerfile')).toBe('dockerfile'); expect(languageFor('ios/Podfile')).toBe('ruby'); expect(languageFor('fastlane/Fastfile')).toBe('ruby'); }); it('leaves an unknown file plain', () => { expect(languageFor('LICENSE')).toBe(''); expect(languageFor('notes.xyz')).toBe(''); expect(languageFor('.gitignore')).toBe(''); }); }); describe('highlightBlock', () => { it('escapes the source so a file cannot become markup', () => { const html = highlightBlock('', ''); expect(html).not.toContain('\n', languageFor('App.svelte'), ); expect(html).toContain('hljs-keyword'); expect(textOf(html)).toContain('let count = 0;'); }); }); describe('highlightToLines', () => { const source = [ '/**', ' * A comment that runs', ' * across several lines.', ' */', 'const value = 1;', ].join('\n'); it('returns one entry per line', () => { expect(highlightToLines(source, 'javascript')).toHaveLength(5); }); it('says the same thing the source said, line for line', () => { const lines = highlightToLines(source, 'javascript'); expect(lines.map(textOf)).toEqual(source.split('\n')); }); it('keeps a span open across a line break on every line it covers', () => { const lines = highlightToLines(source, 'javascript'); // The comment covers the first four lines and no more. for (const line of lines.slice(0, 4)) { expect(line).toContain('hljs-comment'); } expect(lines[4]).not.toContain('hljs-comment'); }); it('closes every span it opens on each line', () => { for (const line of highlightToLines(source, 'javascript')) { const opened = (line.match(//g) ?? []).length; expect(closed, line).toBe(opened); } }); it('splits plain text with no grammar', () => { expect(highlightToLines('a\nb\nc', '').map(textOf)).toEqual([ 'a', 'b', 'c', ]); }); it('reads an empty text as one empty line', () => { expect(highlightToLines('', 'javascript')).toEqual(['']); }); }); describe('diffHighlighter', () => { /** The rows a diff would print, from a type and a text each. */ function rows(marks) { let oldLine = 0; let newLine = 0; return marks.map(([type, text]) => ({ type, text, oldLine: type === 'add' ? null : ++oldLine, newLine: type === 'delete' ? null : ++newLine, })); } // A doc comment gaining a line. The rows interleave both sides, so a row // coloured on its own would end the comment at every break. const before = [ '/**', ' * Does a thing.', ' * @param {string} a', ' */', 'function go(a, b) {}', ].join('\n'); const after = [ '/**', ' * Does a thing.', ' * @param {string} a - the thing', ' * @param {number} b', ' */', 'function go(a, b) {}', ].join('\n'); const marks = [ ['context', '/**'], ['context', ' * Does a thing.'], ['delete', ' * @param {string} a'], ['add', ' * @param {string} a - the thing'], ['add', ' * @param {number} b'], ['context', ' */'], ['context', 'function go(a, b) {}'], ]; it('gives back one row per line, saying what the line said', () => { const colour = diffHighlighter(before, after, 'javascript'); const printed = rows(marks); expect(printed.map(colour).map(textOf)).toEqual( printed.map((row) => row.text), ); }); it('keeps the comment open across the rows it covers', () => { const colour = diffHighlighter(before, after, 'javascript'); const printed = rows(marks); // Every row of the comment, added and deleted alike, reads as comment. for (const row of printed.slice(0, 6)) { expect(colour(row)).toContain('hljs-comment'); } expect(colour(printed[6])).not.toContain('hljs-comment'); }); it('leaves rows alone when the file has no grammar', () => { const colour = diffHighlighter(before, after, ''); const printed = rows(marks); expect(printed.map(colour).map(textOf)).toEqual( printed.map((row) => row.text), ); }); it('colours a JSX tag whose close tag is outside the hunk', () => { // highlight.js reads a tag as JSX only when it can see the close tag, so // this row reads as plain text when the hunk is coloured on its own. const source = [ 'function App() {', ' return (', ' ', ' ', ' ', ' ', ' ', ' );', '}', ].join('\n'); const colour = diffHighlighter(source, source, 'javascript'); const row = { type: 'add', text: ' ', oldLine: null, newLine: 4, }; expect(colour(row)).toContain('hljs-attr'); expect(textOf(colour(row))).toBe(row.text); }); it('falls back to the row text when the line is off the end', () => { const colour = diffHighlighter('', '', 'javascript'); const row = { type: 'add', text: 'const html = "";', oldLine: null, newLine: 9, }; expect(colour(row)).not.toMatch(/