/**
* 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(/