diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1dff0..1afd71a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- 新增 `MarkdownSourceMap.getValueSourceIndex()`。其 `sourceOffsetAt()` 方法将 value 边界映射为原始 Markdown offset。顺序查询使用游标,反向查询使用二分查找(#121)。 + ## 0.2.2 ### Changed diff --git a/README.md b/README.md index 60d6dfd..70c6195 100644 --- a/README.md +++ b/README.md @@ -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' ``` @@ -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` 判断(类本身无法保证任意序列化机制一定保留自定义属性): diff --git a/__tests__/source-map/value-source-index.spec.ts b/__tests__/source-map/value-source-index.spec.ts new file mode 100644 index 0000000..50e4b9f --- /dev/null +++ b/__tests__/source-map/value-source-index.spec.ts @@ -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); + }); +}); diff --git a/__tests__/types/package-exports.cts b/__tests__/types/package-exports.cts index 80a7b92..dd473cc 100644 --- a/__tests__/types/package-exports.cts +++ b/__tests__/types/package-exports.cts @@ -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', @@ -38,6 +42,8 @@ void inlineCodeRaw; void range; void inlineCodeRange; void codeRange; +void sourceIndex; +void sourceOffset; void urlRange; void codeRaw; void consistency; diff --git a/__tests__/types/package-exports.mts b/__tests__/types/package-exports.mts index 747f424..f90fa92 100644 --- a/__tests__/types/package-exports.mts +++ b/__tests__/types/package-exports.mts @@ -9,6 +9,8 @@ import { type SourceMapErrorCode, type ParsedMarkdownDocument, type MarkdownLinkNode, + type MarkdownTextNode, + type MarkdownValueSourceIndex, type PositionedMarkdownRoot, type PositionedMarkdownNode, } from '@lint-md/parser'; @@ -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', @@ -50,6 +56,8 @@ void nodeOffset; void markdown; void same; void doc; +void sourceIndex; +void sourceOffset; void urlRange; void consistency; void unavailable; diff --git a/etc/parser.api.md b/etc/parser.api.md index 3241f72..e0e5ac9 100644 --- a/etc/parser.api.md +++ b/etc/parser.api.md @@ -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) @@ -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; diff --git a/src/index.ts b/src/index.ts index 916b7da..afa1c9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,5 +10,6 @@ export { export * from './types'; export type { MarkdownSourceMap, + MarkdownValueSourceIndex, ParsedMarkdownDocument, } from './source-map/types'; diff --git a/src/source-map/build-source-map.ts b/src/source-map/build-source-map.ts index f77ddfe..b1930ff 100644 --- a/src/source-map/build-source-map.ts +++ b/src/source-map/build-source-map.ts @@ -23,6 +23,7 @@ import { recordingExtension } from './recording-extension'; import type { MarkdownSourceMap, MarkdownSourceMapSegment, + MarkdownValueSourceIndex, ParsedMarkdownDocument, SourceSpan, } from './types'; @@ -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 @@ -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, @@ -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 ' diff --git a/src/source-map/types.ts b/src/source-map/types.ts index 733a047..f046c1b 100644 --- a/src/source-map/types.ts +++ b/src/source-map/types.ts @@ -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. @@ -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}. *