Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,273 changes: 0 additions & 1,273 deletions __tests__/source-map.spec.ts

This file was deleted.

77 changes: 77 additions & 0 deletions __tests__/source-map/ast-parity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { parseMd, parseMdWithSourceMap } from '../helpers';

describe('parseMd vs parseMdWithSourceMap: AST parity corpus', () => {
// A varied corpus exercising tokenizers / mdast decisions that the recording
// extension must not disturb: entities, escapes, autolinks, GFM (tables,
// strikethrough, task lists), directives, math, frontmatter, and mixed
// line endings / astral Unicode.
const corpus = [
'A&B',
'&©',
'A & B with *em* and [link](https://x.com?a&b).',
String.raw`\*not emphasis\* and \`code\``,
'www.example.com and <https://x.com> and <a@b.com>',
'| a | b |\n| :- | -: |\n| 1 | 2 |',
'~~struck~~ and a ~~b',
'- [ ] todo\n- [x] done',
'::name\ncontent\n::',
'a\nb\r\nc\r\nd',
'a\u{1F389}b\u{1D11E}c',
'$$x^2$$ and `inline code`',
'---\ntitle: x\n---\n# Heading',
'> quote with &amp; entity\n> second line',
'1. one &amp; two\n2. three',
'`code with &lt; tag` and > quote',
'text [a](<b &amp; c>) end',
'pre\n```js\nconst x = 1 &amp; 2;\n```\npost',
'~~~\r\na\r\n~~~',
' a\n\n b\n',
'&#0;&#128;&#xFDD0; and &amp;amp;',
'A&#x1F600;B',
];

test('AST is deeply identical to parseMd', () => {
const md = 'A &amp; B with *em* and [link](https://x.com?a&amp;b).';
const { ast } = parseMdWithSourceMap(md);
const baseline = parseMd(md);
expect(JSON.parse(JSON.stringify(ast))).toEqual(
JSON.parse(JSON.stringify(baseline)),
);
});

test('AST is deeply identical to parseMd for CR / CRLF / astral input', () => {
for (const md of ['a\rb', 'a\r\nb', 'a🎉b', 'A&lt;B\nC&#128;D']) {
const { ast } = parseMdWithSourceMap(md);
const baseline = parseMd(md);
expect(JSON.parse(JSON.stringify(ast))).toEqual(
JSON.parse(JSON.stringify(baseline)),
);
}
});

test.each(corpus)('parity for: %p', (md) => {
const { ast } = parseMdWithSourceMap(md);
const baseline = parseMd(md);
expect(JSON.parse(JSON.stringify(ast))).toEqual(
JSON.parse(JSON.stringify(baseline)),
);
});

test('every mapped literal text node is contained in the source', () => {
for (const md of corpus) {
const { ast, sourceMap } = parseMdWithSourceMap(md);
const collect = (node: any, out: string[]) => {
if (node.type === 'text' && !/[&\\]/.test(node.value)) {
out.push(sourceMap.getRaw(node));
}
for (const c of node.children || []) collect(c, out);
};
const raws: string[] = [];
collect(ast, raws);
for (const raw of raws) {
expect(md).toContain(raw);
}
expect(() => parseMd(md)).not.toThrow();
}
});
});
260 changes: 260 additions & 0 deletions __tests__/source-map/code.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { SourceMapConsistencyError, parseMdWithSourceMap } from '../helpers';

/** Collect matching nodes in document order. */
function nodesOfType(root: any, type: string): any[] {
const out: any[] = [];
(function walk(n: any) {
if (n.type === type) out.push(n);
for (const c of n.children || []) walk(c);
})(root);
return out;
}

describe('parseMdWithSourceMap: code.value → raw source', () => {
function expectPerCodeUnitRanges(
md: string,
node: any,
sourceMap: any,
): void {
let previousStart = node.position.start.offset;
let previousEnd = node.position.start.offset;
for (let i = 0; i < node.value.length; i++) {
const range = sourceMap.getSourceRange(node, i, i + 1);
expect(range.start.offset).toBeGreaterThanOrEqual(0);
expect(range.end.offset).toBeGreaterThanOrEqual(range.start.offset);
expect(range.end.offset).toBeLessThanOrEqual(md.length);
expect(range.start.offset).toBeGreaterThanOrEqual(node.position.start.offset);
expect(range.end.offset).toBeLessThanOrEqual(node.position.end.offset);
expect(range.start.offset).toBeGreaterThanOrEqual(previousStart);
expect(range.end.offset).toBeGreaterThanOrEqual(previousEnd);
previousStart = range.start.offset;
previousEnd = range.end.offset;
}
}

test.each(['\n', '\r', '\r\n'])(
'maps fenced code with %p line endings',
(lineEnding) => {
const md = `\`\`\`ts meta${lineEnding}a${lineEnding}b${lineEnding}\`\`\``;
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe(`a${lineEnding}b`);
expect(sourceMap.getRaw(node)).toBe(md);
const whole = sourceMap.getSourceRange(node, 0, node.value.length);
expect(whole.start.offset).toBe(
md.indexOf('a', md.indexOf(lineEnding) + lineEnding.length),
);
expect(whole.end.offset).toBe(md.indexOf('b') + 1);
expectPerCodeUnitRanges(md, node, sourceMap);
},
);

test('maps indented code line by line, including a blank line', () => {
const md = ' a\r\n\r\n b\r\n';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('a\r\n\r\nb');
expect(sourceMap.getRaw(node)).toBe(' a\r\n\r\n b');
expect(() => sourceMap.getSourceRange(node, 0, node.value.length)).toThrow(
'getSourceRange: value range crosses non-contiguous source segments',
);
expectPerCodeUnitRanges(md, node, sourceMap);
});

test.each(['\t', ' \t', ' \t', ' \t', '\t\t'])(
'maps a tab-indented code line with prefix %p',
(indentation) => {
const md = indentation + 'a\n';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
const value = indentation === '\t\t' ? '\ta' : 'a';
expect(node.value).toBe(value);
const range = sourceMap.getSourceRange(node, 0, node.value.length);
expect(md.slice(range.start.offset, range.end.offset)).toBe(value);
},
);

test('maps tilde-fenced code', () => {
const md = '~~~\r\nvalue\r\n~~~';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('value');
expect(sourceMap.getRaw(node)).toBe(md);
expect(sourceMap.getSourceRange(node, 0, node.value.length)).toEqual({
start: { line: 2, column: 1, offset: 5 },
end: { line: 2, column: 6, offset: 10 },
});
});

test('maps fenced code inside a blockquote', () => {
const md = '> ```js\n> const x = 1\n> ```';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('const x = 1');
const range = sourceMap.getSourceRange(node, 0, node.value.length);
expect(md.slice(range.start.offset, range.end.offset)).toContain('const x = 1');
});

test.each(['```', '~~~'])(
'maps a fenced code block after a blockquote tab prefix: %p',
(fence) => {
const md = '> \t' + fence + '\n> \tcode\n> \t' + fence;
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('code');
const range = sourceMap.getSourceRange(node, 0, node.value.length);
expect(md.slice(range.start.offset, range.end.offset)).toBe('code');
},
);

test('maps a fenced code block with equivalent tab and space indentation', () => {
const md = '> \t```\n> \tcode\n> ```';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('code');
expectPerCodeUnitRanges(md, node, sourceMap);
});

test('maps indented code inside a blockquote', () => {
const md = '> indented\n> code';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('indented\ncode');
expectPerCodeUnitRanges(md, node, sourceMap);
});

test('maps fenced code nested in a list', () => {
const md = '- item\n ```\n value\n ```';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('value');
const range = sourceMap.getSourceRange(node, 0, node.value.length);
expect(md.slice(range.start.offset, range.end.offset)).toBe('value');
});

test('maps fenced code in a list with equivalent tab and space indentation', () => {
const md = '- item\n\t```\n code\n\t```';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('code');
expectPerCodeUnitRanges(md, node, sourceMap);
});

test('maps multi-line indented code inside a list', () => {
const md = '- Foo\n\n bar\n baz';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('bar\nbaz');
expect(() => sourceMap.getSourceRange(node, 0, node.value.length)).toThrow(
'getSourceRange: value range crosses non-contiguous source segments',
);
expectPerCodeUnitRanges(md, node, sourceMap);
});

test.each(['\n', '\r', '\r\n'])(
'maps multi-line indented code after a tab list continuation prefix with %p',
(lineEnding) => {
const md = '- Foo' + lineEnding + lineEnding
+ '\t bar' + lineEnding + '\t baz';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('bar' + lineEnding + 'baz');
expectPerCodeUnitRanges(md, node, sourceMap);
},
);

test('maps multi-line indented code inside a blockquote list', () => {
const md = '> - Foo\n>\n> bar\n> baz';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('bar\nbaz');
expect(() => sourceMap.getSourceRange(node, 0, node.value.length)).toThrow(
'getSourceRange: value range crosses non-contiguous source segments',
);
expectPerCodeUnitRanges(md, node, sourceMap);
});

test('maps multi-line indented code with a tab inside a blockquote list', () => {
const md = '> - Foo\n>\n> \tbar\n> \tbaz';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('bar\nbaz');
expectPerCodeUnitRanges(md, node, sourceMap);
});

test('maps empty fenced code inside a blockquote', () => {
const md = '> ```\n> ```';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('');
const point = sourceMap.getSourceRange(node, 0, 0);
expect(point.start.offset).toBe(md.lastIndexOf('```'));
});

test('maps empty fenced code inside a list', () => {
const md = '- item\n ```\n ```';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
const point = sourceMap.getSourceRange(node, 0, 0);
expect(point.start.offset).toBe(md.lastIndexOf('```'));
});

test('excludes fenced delimiters and their indentation from code ranges', () => {
const md = ' ```\n a\n b\n ```';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('a\nb');
expect(sourceMap.getRaw(node)).toBe('```\n a\n b\n ```');
expect(sourceMap.getSourceRange(node, 0, 1).start.offset).toBe(md.indexOf('a'));
expect(sourceMap.getSourceRange(node, 2, 3).end.offset).toBe(md.indexOf('b') + 1);
});

test('maps an empty fenced code value to its content boundary', () => {
const md = '```\n\n```';
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('');
expect(sourceMap.getRaw(node)).toBe(md);
expect(sourceMap.getSourceRange(node, 0, 0).start.offset).toBe(4);
});

test.each([
['```', 3],
['~~~', 3],
['``` ', 6],
['```\n', 4],
['```\r', 4],
['```\r\n', 5],
])('maps an unclosed empty fence to EOF: %p', (md, expectedOffset) => {
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('');
const point = sourceMap.getSourceRange(node, 0, 0);
expect(point.start.offset).toBe(expectedOffset);
expect(point.end.offset).toBe(expectedOffset);
});

test.each([
['> ```', 5],
['> ```\n', 6],
['> ```\r', 6],
['> ```\r\n', 7],
])('maps an unclosed empty fence inside a blockquote to EOF: %p', (md, expectedOffset) => {
const { ast, sourceMap } = parseMdWithSourceMap(md);
const node = nodesOfType(ast, 'code')[0];
expect(node.value).toBe('');
const point = sourceMap.getSourceRange(node, 0, 0);
expect(point.start.offset).toBe(expectedOffset);
expect(point.end.offset).toBe(expectedOffset);
});

test('rejects a code value modified after parsing', () => {
const { ast, sourceMap } = parseMdWithSourceMap('```\nvalue\n```');
const node = nodesOfType(ast, 'code')[0];
node.value = 'changed';
expect(() => sourceMap.getSourceRange(node, 0, 1)).toThrow(
SourceMapConsistencyError,
);
expect(() => sourceMap.getRaw(node)).toThrow(SourceMapConsistencyError);
});
});
Loading