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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- 新增 `MarkdownSourceMap.getValueSourceIndex()`。其 `sourceOffsetAt()` 方法将 value 边界映射为原始 Markdown offset。顺序查询使用游标,反向查询使用二分查找(#121)。

## 0.2.2

### Changed
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const textNode = ast.children[0].children[0]; // text value "A&B"
const range = sourceMap.getSourceRange(textNode, 1, 2);
// range.start.offset === 1, range.end.offset === 6

// 为顺序扫描建立轻量索引。结果是原始 Markdown 的绝对 offset。
const sourceIndex = sourceMap.getValueSourceIndex(textNode);
sourceIndex.sourceOffsetAt(1); // 1

// 取回该 text 节点对应的原始 Markdown 子串
sourceMap.getRaw(textNode); // 'A&B'
```
Expand All @@ -87,6 +91,7 @@ sourceMap.getRaw(textNode); // 'A&B'
### 契约

- `getSourceRange(node, valueStart, valueEnd)` 的索引与 JavaScript 字符串下标一致,范围均为半开区间 `[start, end)`;当前支持 `text.value`、`inlineCode.value` 与 block `code.value`。`getFieldSourceRange(node, 'url', valueStart, valueEnd)` 当前支持 inline resource link 与 definition 的 destination;autolink 和 GFM autolink literal 暂不包含。
- `getValueSourceIndex(node).sourceOffsetAt(valueIndex)` 返回原始 Markdown 的绝对 offset。该接口为顺序边界查询保留游标。反向和随机查询使用二分查找。
- 映射覆盖受支持节点的整个 `value`,segment 之间无空洞、无重叠。
- 当 value 对应的原始源码连续时,`getSourceRange(node, 0, node.value.length)` 覆盖该节点 value 的完整原始来源范围。当 blockquote marker、list indentation 等容器语法将来源分隔开时,单个连续的 `ParsedPosition` 无法准确表达该范围,`getSourceRange()` 会抛出 `RangeError`。
- 错误分为三条路径,专属错误均继承 `RangeError`(现有 `catch (RangeError)` 不受影响),并带稳定的 `code` 字段;当跨边界传递时(如跨 CJS/ESM 实例、重复安装、worker 边界),只要错误被显式序列化且 `code` 字段被保留,即可用 `code` 而非 `instanceof` 判断(类本身无法保证任意序列化机制一定保留自定义属性):
Expand Down
103 changes: 103 additions & 0 deletions __tests__/source-map/value-source-index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
parseMdWithSourceMap,
SourceMapConsistencyError,
SourceMapUnavailableError,
} from '../helpers';

function nodesWithValue(root: any): any[] {
const nodes: any[] = [];
(function walk(node: any) {
if (node.type === 'text' || node.type === 'inlineCode' || node.type === 'code')
nodes.push(node);
for (const child of node.children || []) walk(child);
})(root);
return nodes;
}

function expectOffsetParity(markdown: string): void {
const { ast, sourceMap } = parseMdWithSourceMap(markdown);
for (const node of nodesWithValue(ast)) {
const index = sourceMap.getValueSourceIndex(node);
const boundaries = Array.from(
{ length: node.value.length + 1 },
(_, valueIndex) => valueIndex,
);
for (const valueIndex of [...boundaries, ...boundaries.reverse()]) {
let expected: number;
try {
expected = sourceMap.getSourceRange(
node,
valueIndex,
valueIndex,
).start.offset;
}
catch (error) {
expect(() => index.sourceOffsetAt(valueIndex)).toThrow(
(error as Error).constructor as ErrorConstructor,
);
continue;
}
expect(index.sourceOffsetAt(valueIndex)).toBe(expected);
}
}
}

describe('MarkdownValueSourceIndex', () => {
test.each([
'plain\ntext',
'one space \nnext',
'a&b',
'a
b',
String.raw`a\(b`,
'> first\n> second',
'` padded code `',
'```js\nconst value = 1\n```',
'𝔄',
'�',
'```\n```',
])('matches empty range queries for %p', (markdown) => {
expectOffsetParity(markdown);
});

test('maps a normalized newline after a removed trailing space', () => {
const markdown = 'one space \nnext';
const { ast, sourceMap } = parseMdWithSourceMap(markdown);
const node = nodesWithValue(ast)[0];
const valueIndex = node.value.indexOf('\n');

expect(valueIndex).toBe(9);
expect(sourceMap.getValueSourceIndex(node).sourceOffsetAt(valueIndex)).toBe(
markdown.indexOf('\n'),
);
});

test('rejects invalid and atomic boundaries', () => {
const { ast, sourceMap } = parseMdWithSourceMap('𝔄');
const node = nodesWithValue(ast)[0];
const index = sourceMap.getValueSourceIndex(node);

expect(() => index.sourceOffsetAt(0.5)).toThrow(RangeError);
expect(() => index.sourceOffsetAt(-1)).toThrow(RangeError);
expect(() => index.sourceOffsetAt(node.value.length + 1)).toThrow(RangeError);
expect(() => index.sourceOffsetAt(1)).toThrow(RangeError);
});

test('checks node ownership', () => {
const first = parseMdWithSourceMap('first');
const second = parseMdWithSourceMap('second');
const foreign = nodesWithValue(second.ast)[0];

expect(() => first.sourceMap.getValueSourceIndex(foreign)).toThrow(
SourceMapUnavailableError,
);
});

test('checks value mutations after index creation', () => {
const { ast, sourceMap } = parseMdWithSourceMap('text');
const node = nodesWithValue(ast)[0];
const index = sourceMap.getValueSourceIndex(node);
node.value = 'changed';

expect(() => index.sourceOffsetAt(0)).toThrow(SourceMapConsistencyError);
});
});
6 changes: 6 additions & 0 deletions __tests__/types/package-exports.cts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const codeRange = doc.sourceMap.getSourceRange(
0,
1,
);
const sourceIndex: parser.MarkdownValueSourceIndex = doc.sourceMap.getValueSourceIndex(
doc.ast.children[0] as parser.MarkdownTextNode,
);
const sourceOffset: number = sourceIndex.sourceOffsetAt(0);
const urlRange = doc.sourceMap.getFieldSourceRange(
doc.ast.children[0] as parser.MarkdownLinkNode,
'url',
Expand All @@ -38,6 +42,8 @@ void inlineCodeRaw;
void range;
void inlineCodeRange;
void codeRange;
void sourceIndex;
void sourceOffset;
void urlRange;
void codeRaw;
void consistency;
Expand Down
8 changes: 8 additions & 0 deletions __tests__/types/package-exports.mts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
type SourceMapErrorCode,
type ParsedMarkdownDocument,
type MarkdownLinkNode,
type MarkdownTextNode,
type MarkdownValueSourceIndex,
type PositionedMarkdownRoot,
type PositionedMarkdownNode,
} from '@lint-md/parser';
Expand All @@ -25,6 +27,10 @@ const markdown: string = revertMdAstNode(root);
const same: boolean = stringifyMdAst === revertMdAstNode;

const doc: ParsedMarkdownDocument = parseMdWithSourceMap('# ESM');
const sourceIndex: MarkdownValueSourceIndex = doc.sourceMap.getValueSourceIndex(
doc.ast.children[0] as MarkdownTextNode,
);
const sourceOffset: number = sourceIndex.sourceOffsetAt(0);
const urlRange = doc.sourceMap.getFieldSourceRange(
doc.ast.children[0] as MarkdownLinkNode,
'url',
Expand All @@ -50,6 +56,8 @@ void nodeOffset;
void markdown;
void same;
void doc;
void sourceIndex;
void sourceOffset;
void urlRange;
void consistency;
void unavailable;
Expand Down
6 changes: 6 additions & 0 deletions etc/parser.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export interface MarkdownSourceMap {
getFieldSourceRange(node: MarkdownLinkNode | MarkdownDefinitionNode, field: 'url', valueStart: number, valueEnd: number): ParsedPosition;
getRaw(node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode | MarkdownLinkNode | MarkdownDefinitionNode): string;
getSourceRange(node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode, valueStart: number, valueEnd: number): ParsedPosition;
getValueSourceIndex(node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode): MarkdownValueSourceIndex;
}

// @public (undocumented)
Expand All @@ -101,6 +102,11 @@ export interface MarkdownTextDirective extends Parent, MarkdownDirectiveFields {
// @public (undocumented)
export type MarkdownTextNode = Text_2;

// @public
export interface MarkdownValueSourceIndex {
sourceOffsetAt(valueIndex: number): number;
}

// @public
export interface ParsedMarkdownDocument {
ast: PositionedMarkdownRoot;
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export {
export * from './types';
export type {
MarkdownSourceMap,
MarkdownValueSourceIndex,
ParsedMarkdownDocument,
} from './source-map/types';
115 changes: 108 additions & 7 deletions src/source-map/build-source-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { recordingExtension } from './recording-extension';
import type {
MarkdownSourceMap,
MarkdownSourceMapSegment,
MarkdownValueSourceIndex,
ParsedMarkdownDocument,
SourceSpan,
} from './types';
Expand Down Expand Up @@ -644,6 +645,16 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => {
}
};

const getValueSegments = (
node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode,
): MarkdownSourceMapSegment[] | undefined => {
if (node.type === 'text')
return state.segments.get(node);
if (node.type === 'inlineCode')
return state.inlineCodeSegments.get(node);
return state.codeSegments.get(node);
};

const sourceMap: MarkdownSourceMap = {
getRaw(
node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode
Expand Down Expand Up @@ -685,6 +696,102 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => {
return md.slice(offsets[0], offsets[1]);
},

getValueSourceIndex(
node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode,
): MarkdownValueSourceIndex {
if (!owned.has(node as object)) {
throw new SourceMapUnavailableError(
'getValueSourceIndex: the given node does not belong to this '
+ 'document; pass a node from the tree returned by the same '
+ 'parseMdWithSourceMap() call',
);
}
const segs = getValueSegments(node);
if (!segs) {
throw new SourceMapUnavailableError(
'getValueSourceIndex: no source mapping is available for the given '
+ 'node; it was generated, added after parsing, or is not a '
+ 'supported text, inlineCode, or code node',
);
}
assertUnmodified(node as object);

const valueLength = node.value.length;
const emptyOffset = node.type === 'code'
? state.emptyCodeOffsets.get(node)
: undefined;
let segmentIndex = 0;

return {
sourceOffsetAt(valueIndex: number): number {
assertUnmodified(node as object);
if (!Number.isInteger(valueIndex) || !Number.isFinite(valueIndex)) {
throw new RangeError(
'sourceOffsetAt: valueIndex must be a finite integer, '
+ `got ${valueIndex}`,
);
}
if (valueIndex < 0 || valueIndex > valueLength) {
throw new RangeError(
`sourceOffsetAt: valueIndex ${valueIndex} is out of bounds for `
+ `a mapped node of length ${valueLength}`,
);
}
if (segs.length === 0) {
if (valueLength === 0 && valueIndex === 0 && emptyOffset !== undefined)
return emptyOffset;
throw new RangeError(
'sourceOffsetAt: value boundary is not covered by the source map',
);
}
if (valueIndex === 0)
return segs[0].sourceStart;
if (valueIndex === valueLength)
return segs[segs.length - 1].sourceEnd;

let segment = segs[segmentIndex];
if (
!segment
|| valueIndex < segment.valueStart
|| valueIndex >= segment.valueEnd
) {
if (segment && valueIndex >= segment.valueEnd) {
while (
segmentIndex + 1 < segs.length
&& valueIndex >= segs[segmentIndex].valueEnd
) {
segmentIndex++;
}
segment = segs[segmentIndex];
}
if (
!segment
|| valueIndex < segment.valueStart
|| valueIndex >= segment.valueEnd
) {
const index = findSegmentIndexAt(segs, valueIndex);
if (index === undefined) {
throw new RangeError(
'sourceOffsetAt: value boundary is not covered by the source map',
);
}
segmentIndex = index;
segment = segs[index];
}
}

if (valueIndex === segment.valueStart)
return segment.sourceStart;
if (segment.kind === 'literal')
return segment.sourceStart + valueIndex - segment.valueStart;
throw new RangeError(
'sourceOffsetAt: value boundary falls inside an atomic construct '
+ '(escape / character reference / normalization)',
);
},
};
},

getSourceRange(
node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode,
valueStart: number,
Expand All @@ -702,13 +809,7 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => {
valueEnd,
sourceRangeMessages,
);
let segs: MarkdownSourceMapSegment[] | undefined;
if (node.type === 'text')
segs = state.segments.get(node);
else if (node.type === 'inlineCode')
segs = state.inlineCodeSegments.get(node);
else if (node.type === 'code')
segs = state.codeSegments.get(node);
const segs = getValueSegments(node);
if (!segs) {
throw new SourceMapUnavailableError(
'getSourceRange: no source mapping is available for the given '
Expand Down
33 changes: 33 additions & 0 deletions src/source-map/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ export interface MarkdownSourceMapSegment {
* @public
*/
export interface MarkdownSourceMap {
/**
* Creates an index for repeated source-offset queries on a normalized value.
*
* The index supports `text`, `inlineCode`, and block `code` nodes. It keeps a
* cursor for forward scans and uses binary search for other access patterns.
* Each query returns an absolute UTF-16 offset into the original Markdown.
*
* @param node - A supported value node from this parsed document.
* @returns An index for the node's normalized `value`.
*/
getValueSourceIndex(
node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode,
): MarkdownValueSourceIndex

/**
* Returns the raw Markdown substring that produced the given node's
* normalized value.
Expand Down Expand Up @@ -153,6 +167,25 @@ export interface MarkdownSourceMap {
): ParsedPosition
}

/**
* Maps normalized value boundaries to offsets in the original Markdown.
*
* @public
*/
export interface MarkdownValueSourceIndex {
/**
* Returns the source offset for a boundary in the normalized value.
*
* The index uses JavaScript string indices. A boundary inside an atomic
* escape, character reference, or normalization has no exact source offset.
* The method throws `RangeError` for such a boundary.
*
* @param valueIndex - A boundary in the normalized value.
* @returns The absolute UTF-16 offset in the original Markdown.
*/
sourceOffsetAt(valueIndex: number): number
}

/**
* Result of {@link parseMdWithSourceMap}.
*
Expand Down
Loading