import { describe, it, expect } from 'vitest'; import { syntaxTree } from '@codemirror/language'; import { makeState } from '../editors/automerge/livePreview/testSupport'; import { extractHeadings, findHeading, normalizeAnchor } from './headings'; const headings = (doc: string) => extractHeadings(doc.split('\n')); describe('extractHeadings', () => { it('reads all six levels', () => { const doc = ['# a', '## b', '### c', '#### d', '##### e', '###### f'].join( '\n', ); expect(headings(doc).map((h) => h.level)).toEqual([1, 2, 3, 4, 5, 6]); }); it('records text and 0-based line index', () => { expect(headings('intro\n\n## Second bit\nbody')).toEqual([ { text: 'Second bit', level: 2, line: 2 }, ]); }); it('requires a space, so `#hashtag` is not a heading', () => { expect(headings('#hashtag')).toEqual([]); }); it('ignores a heading with no text', () => { expect(headings('##\n### ')).toEqual([]); }); it('stops at six `#`', () => { expect(headings('####### seven')).toEqual([]); }); it('strips a closing sequence', () => { expect(headings('## Title ##')[0].text).toBe('Title'); expect(headings('## Title #########')[0].text).toBe('Title'); }); it('keeps a `#` that is part of the text', () => { expect(headings('## C# notes')[0].text).toBe('C# notes'); }); it('allows up to three leading spaces but not four', () => { expect(headings(' ### indented')[0].text).toBe('indented'); expect(headings(' ### code block')).toEqual([]); }); it('tolerates CRLF', () => { expect(headings('# a\r\n## b\r')).toEqual([ { text: 'a', level: 1, line: 0 }, { text: 'b', level: 2, line: 1 }, ]); }); describe('fenced code', () => { it('ignores headings inside a backtick fence', () => { expect(headings('```\n# not a heading\n```\n# real')).toEqual([ { text: 'real', level: 1, line: 3 }, ]); }); it('ignores headings inside a tilde fence', () => { expect(headings('~~~\n# nope\n~~~')).toEqual([]); }); it('does not let a tilde fence close a backtick fence', () => { expect(headings('```\n~~~\n# still inside\n```\n# out')).toEqual([ { text: 'out', level: 1, line: 4 }, ]); }); it('requires the closing fence to be at least as long', () => { expect(headings('````\n```\n# still inside\n````\n# out')).toEqual([ { text: 'out', level: 1, line: 4 }, ]); }); it('does not close on a fence carrying an info string', () => { expect(headings('```\n```ts\n# still inside\n```\n# out')).toEqual([ { text: 'out', level: 1, line: 4 }, ]); }); it('handles a fence opened with an info string', () => { expect(headings('```ts\n# nope\n```\n# out')).toEqual([ { text: 'out', level: 1, line: 3 }, ]); }); }); }); /** * The scan is a second heading implementation alongside the Lezer grammar the * editor renders with. This is the test that stops the two drifting: for a * broad corpus, both must find headings on exactly the same lines. */ describe('parity with the markdown parser the editor uses', () => { const CORPUS = [ '# one\ntext\n## two\n### three', 'intro\n\n#hashtag not a heading\n\n## real one', '```\n# fenced\n```\n# after', ' ### three spaces\n #### four spaces', '## closing ##\n## trailing spaces ', '# a\n\n> quoted text\n\n## b\n- list\n## c', '~~~js\n# in tilde fence\n~~~\n# out', '## C# and **bold**\ntext', 'para\n# h1\npara\n###### h6\npara', '````\n```\n# nested fence\n````\n# after nested', ]; const parserHeadingLines = (doc: string): number[] => { const state = makeState(doc, 0); const lines: number[] = []; syntaxTree(state).iterate({ enter(node) { if (!/^ATXHeading[1-6]$/.test(node.name)) return; lines.push(state.doc.lineAt(node.from).number - 1); }, }); return lines; }; for (const doc of CORPUS) { it(`agrees on: ${JSON.stringify(doc.slice(0, 32))}…`, () => { expect(headings(doc).map((h) => h.line)).toEqual(parserHeadingLines(doc)); }); } }); describe('normalizeAnchor', () => { it('folds case and collapses whitespace', () => { expect(normalizeAnchor(' The Big One ')).toBe('the big one'); }); }); describe('findHeading', () => { const hs = headings('# Alpha\n## Beta\n### Alpha'); it('matches case- and whitespace-insensitively', () => { expect(findHeading(hs, ' beta ')).toMatchObject({ line: 1 }); }); it('resolves a duplicate to the first in document order', () => { expect(findHeading(hs, 'Alpha')).toMatchObject({ line: 0, level: 1 }); }); it('returns null for no match and for an empty anchor', () => { expect(findHeading(hs, 'Gamma')).toBeNull(); expect(findHeading(hs, ' ')).toBeNull(); }); });