333 lines
14 KiB
JavaScript
333 lines
14 KiB
JavaScript
import { restorePhantomSpaces } from "../phantom.js";
|
||
import { readPosition } from "../wire-read.js";
|
||
import { decodeColumnAlign } from "./column-align.js";
|
||
import { NodeTypeName } from "./generated/node-types.js";
|
||
import { ARENA_MAGIC, KIND_MDAST, FIELD, HEADER } from "../generated/arena-layout.js";
|
||
export { NodeType, NodeTypeName } from "./generated/node-types.js";
|
||
export class MdastReader {
|
||
#view;
|
||
#header;
|
||
#textDecoder;
|
||
#stringPoolCache = null;
|
||
constructor(buffer) {
|
||
if (buffer instanceof Uint8Array) {
|
||
this.#view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||
}
|
||
else {
|
||
this.#view = new DataView(buffer);
|
||
}
|
||
this.#textDecoder = new TextDecoder("utf-8");
|
||
this.#header = this.#readHeader();
|
||
}
|
||
#readHeader() {
|
||
const v = this.#view;
|
||
const magic = v.getUint32(HEADER.magic, true);
|
||
if (magic !== ARENA_MAGIC) {
|
||
throw new Error(`Invalid buffer: bad magic 0x${magic.toString(16)}, expected 0x${ARENA_MAGIC.toString(16)}`);
|
||
}
|
||
const kind = v.getUint32(HEADER.kind, true);
|
||
if (kind !== KIND_MDAST) {
|
||
throw new Error(`MdastReader was handed a buffer of kind ${kind} (expected ${KIND_MDAST}). ` +
|
||
`MDAST and HAST node types overlap; reading the wrong kind decodes garbage.`);
|
||
}
|
||
return {
|
||
nodeStructSize: v.getUint32(HEADER.node_struct_size, true),
|
||
nodeCount: v.getUint32(HEADER.node_count, true),
|
||
nodesOffset: v.getUint32(HEADER.nodes_offset, true),
|
||
childrenCount: v.getUint32(HEADER.children_count, true),
|
||
childrenOffset: v.getUint32(HEADER.children_offset, true),
|
||
typeDataLen: v.getUint32(HEADER.type_data_len, true),
|
||
typeDataOffset: v.getUint32(HEADER.type_data_offset, true),
|
||
stringPoolLen: v.getUint32(HEADER.string_pool_len, true),
|
||
stringPoolOffset: v.getUint32(HEADER.string_pool_offset, true),
|
||
nodeDataCount: v.getUint32(HEADER.node_data_count, true),
|
||
nodeDataOffset: v.getUint32(HEADER.node_data_offset, true),
|
||
};
|
||
}
|
||
#nodeDataTable = null;
|
||
/** Per-node JSON `data` blob (set via `Arena::set_node_data` on the Rust
|
||
* side). Lazy-builds a `Map<id, string>` on first call so materialization
|
||
* of a data-heavy tree stays O(nodes) rather than O(nodes × entries). */
|
||
getNodeData(nodeId) {
|
||
if (this.#header.nodeDataCount === 0)
|
||
return null;
|
||
if (this.#nodeDataTable === null) {
|
||
this.#nodeDataTable = new Map();
|
||
const v = this.#view;
|
||
let pos = this.#header.nodeDataOffset;
|
||
for (let i = 0; i < this.#header.nodeDataCount; i++) {
|
||
const id = v.getUint32(pos, true);
|
||
pos += 4;
|
||
const len = v.getUint32(pos, true);
|
||
pos += 4;
|
||
const slice = new Uint8Array(this.#view.buffer, this.#view.byteOffset + pos, len);
|
||
this.#nodeDataTable.set(id, this.#textDecoder.decode(slice));
|
||
pos += len;
|
||
}
|
||
}
|
||
return this.#nodeDataTable.get(nodeId) ?? null;
|
||
}
|
||
get nodeCount() {
|
||
return this.#header.nodeCount;
|
||
}
|
||
get header() {
|
||
return { ...this.#header };
|
||
}
|
||
/** The full string pool (original input + interning heap). Not the document
|
||
* source as written; for that, read `ctx.source` from a plugin. */
|
||
getStringPool() {
|
||
if (this.#stringPoolCache === null) {
|
||
const { stringPoolOffset, stringPoolLen } = this.#header;
|
||
const bytes = new Uint8Array(this.#view.buffer, this.#view.byteOffset + stringPoolOffset, stringPoolLen);
|
||
this.#stringPoolCache = this.#textDecoder.decode(bytes);
|
||
}
|
||
return this.#stringPoolCache;
|
||
}
|
||
getString(offset, len) {
|
||
if (len === 0)
|
||
return "";
|
||
const { stringPoolOffset } = this.#header;
|
||
const bytes = new Uint8Array(this.#view.buffer, this.#view.byteOffset + stringPoolOffset + offset, len);
|
||
return this.#textDecoder.decode(bytes);
|
||
}
|
||
getNode(nodeId) {
|
||
const { nodesOffset, nodeStructSize, nodeCount } = this.#header;
|
||
if (nodeId >= nodeCount) {
|
||
throw new RangeError(`Node ID ${nodeId} out of range (count: ${nodeCount})`);
|
||
}
|
||
const base = nodesOffset + nodeId * nodeStructSize;
|
||
const v = this.#view;
|
||
const type = v.getUint8(base + FIELD.node_type);
|
||
const position = readPosition(v, base + FIELD.start_offset);
|
||
return {
|
||
id: v.getUint32(base + FIELD.id, true),
|
||
type,
|
||
typeName: NodeTypeName[type] ?? `Unknown(${type})`,
|
||
parent: v.getUint32(base + FIELD.parent, true),
|
||
position,
|
||
childrenStart: v.getUint32(base + FIELD.children_start, true),
|
||
childrenCount: v.getUint32(base + FIELD.children_count, true),
|
||
dataOffset: v.getUint32(base + FIELD.data_offset, true),
|
||
dataLen: v.getUint32(base + FIELD.data_len, true),
|
||
};
|
||
}
|
||
/** Fast path: read only the type byte for a node. */
|
||
getNodeType(nodeId) {
|
||
const { nodesOffset, nodeStructSize } = this.#header;
|
||
return this.#view.getUint8(nodesOffset + nodeId * nodeStructSize + FIELD.node_type);
|
||
}
|
||
/** Fast path: read only the parent id for a node (0xffffffff at the root). */
|
||
getParentId(nodeId) {
|
||
const { nodesOffset, nodeStructSize } = this.#header;
|
||
return this.#view.getUint32(nodesOffset + nodeId * nodeStructSize + FIELD.parent, true);
|
||
}
|
||
getChildIds(nodeId) {
|
||
const { nodesOffset, nodeStructSize, childrenOffset } = this.#header;
|
||
const base = nodesOffset + nodeId * nodeStructSize;
|
||
const v = this.#view;
|
||
const childrenStart = v.getUint32(base + FIELD.children_start, true);
|
||
const childrenCount = v.getUint32(base + FIELD.children_count, true);
|
||
if (childrenCount === 0)
|
||
return [];
|
||
const ids = [];
|
||
for (let i = 0; i < childrenCount; i++) {
|
||
ids.push(v.getUint32(childrenOffset + (childrenStart + i) * 4, true));
|
||
}
|
||
return ids;
|
||
}
|
||
/** Push child node IDs directly onto a stack array (reverse order for depth-first). */
|
||
pushChildIds(nodeId, stack) {
|
||
const { nodesOffset, nodeStructSize, childrenOffset } = this.#header;
|
||
const base = nodesOffset + nodeId * nodeStructSize;
|
||
const v = this.#view;
|
||
const childrenStart = v.getUint32(base + FIELD.children_start, true);
|
||
const childrenCount = v.getUint32(base + FIELD.children_count, true);
|
||
if (childrenCount === 0)
|
||
return;
|
||
for (let i = childrenCount - 1; i >= 0; i--) {
|
||
stack.push(v.getUint32(childrenOffset + (childrenStart + i) * 4, true));
|
||
}
|
||
}
|
||
getTypeData(nodeId) {
|
||
const base = this.#header.nodesOffset + nodeId * this.#header.nodeStructSize;
|
||
const v = this.#view;
|
||
const dataOffset = v.getUint32(base + FIELD.data_offset, true);
|
||
const dataLen = v.getUint32(base + FIELD.data_len, true);
|
||
if (dataLen === 0)
|
||
return new Uint8Array(0);
|
||
return new Uint8Array(v.buffer, v.byteOffset + this.#header.typeDataOffset + dataOffset, dataLen);
|
||
}
|
||
/** Read a StringRef (offset: u32 LE, len: u32 LE) from type data. */
|
||
readStringRef(typeData, byteOffset = 0) {
|
||
const view = new DataView(typeData.buffer, typeData.byteOffset + byteOffset);
|
||
return {
|
||
offset: view.getUint32(0, true),
|
||
len: view.getUint32(4, true),
|
||
};
|
||
}
|
||
/**
|
||
* StringRef value. Valid for Text, InlineCode, Html, Yaml, Toml nodes.
|
||
* These store a single StringRef as their type data.
|
||
*/
|
||
getTextValue(nodeId) {
|
||
const data = this.getTypeData(nodeId);
|
||
const ref = this.readStringRef(data);
|
||
return this.getString(ref.offset, ref.len);
|
||
}
|
||
/**
|
||
* ListData #[repr(C)]: start(0..4), ordered(4), spread(5), _pad(6..8).
|
||
* Valid for List nodes.
|
||
*/
|
||
getListData(nodeId) {
|
||
const data = this.getTypeData(nodeId);
|
||
const view = new DataView(data.buffer, data.byteOffset);
|
||
return {
|
||
start: view.getUint32(0, true),
|
||
ordered: data[4] !== 0,
|
||
spread: data[5] !== 0,
|
||
};
|
||
}
|
||
/**
|
||
* ListItemData #[repr(C)]: checked(0), spread(1).
|
||
* checked: 0=unchecked, 1=checked, 2=not-a-task-item.
|
||
*/
|
||
getListItemData(nodeId) {
|
||
const data = this.getTypeData(nodeId);
|
||
const checkedByte = data[0];
|
||
return {
|
||
checked: checkedByte === 2 ? null : checkedByte === 1,
|
||
spread: data[1] !== 0,
|
||
};
|
||
}
|
||
/**
|
||
* TableData #[repr(C)]: align_count(0..4), then align_count bytes.
|
||
* Alignment bytes: 0=none, 1=left, 2=right, 3=center.
|
||
*/
|
||
getTableAlign(nodeId) {
|
||
const data = this.getTypeData(nodeId);
|
||
if (data.length < 4)
|
||
return [];
|
||
const view = new DataView(data.buffer, data.byteOffset);
|
||
const count = view.getUint32(0, true);
|
||
const result = [];
|
||
for (let i = 0; i < count; i++) {
|
||
result.push(decodeColumnAlign(data[4 + i]));
|
||
}
|
||
return result;
|
||
}
|
||
/**
|
||
* MdxJsxElementData: name StringRef (0..8). len===0 means fragment.
|
||
*/
|
||
getMdxJsxElementName(nodeId) {
|
||
const data = this.getTypeData(nodeId);
|
||
const nameRef = this.readStringRef(data, 0);
|
||
return nameRef.len > 0 ? this.getString(nameRef.offset, nameRef.len) : null;
|
||
}
|
||
/**
|
||
* MDX JSX element data: name + attributes.
|
||
*
|
||
* Layout:
|
||
* [name: StringRef(8B)][attr_count: u32(4B)][_pad: u32(4B)] = 16-byte header
|
||
* then attr_count * 20 bytes:
|
||
* [kind: u8(1B)][_pad: [u8;3](3B)][name: StringRef(8B)][value: StringRef(8B)]
|
||
*
|
||
* Attribute kinds: 0=boolean, 1=literal, 2=expression, 3=spread
|
||
*/
|
||
getMdxJsxElementData(nodeId) {
|
||
const data = this.getTypeData(nodeId);
|
||
if (data.length < 16) {
|
||
return { name: this.getMdxJsxElementName(nodeId), attributes: [] };
|
||
}
|
||
const nameRef = this.readStringRef(data, 0);
|
||
const name = nameRef.len > 0 ? this.getString(nameRef.offset, nameRef.len) : null;
|
||
const view = new DataView(data.buffer, data.byteOffset + 8);
|
||
const attrCount = view.getUint32(0, true);
|
||
const attributes = [];
|
||
for (let i = 0; i < attrCount; i++) {
|
||
const base = 16 + i * 20;
|
||
const kind = data[base];
|
||
const attrNameRef = this.readStringRef(data, base + 4);
|
||
const attrValueRef = this.readStringRef(data, base + 12);
|
||
switch (kind) {
|
||
case 0: // BooleanProp
|
||
attributes.push({
|
||
type: "mdxJsxAttribute",
|
||
name: this.getString(attrNameRef.offset, attrNameRef.len),
|
||
value: null,
|
||
});
|
||
break;
|
||
case 1: // LiteralProp
|
||
attributes.push({
|
||
type: "mdxJsxAttribute",
|
||
name: this.getString(attrNameRef.offset, attrNameRef.len),
|
||
value: this.getString(attrValueRef.offset, attrValueRef.len),
|
||
});
|
||
break;
|
||
case 2: // ExpressionProp
|
||
attributes.push({
|
||
type: "mdxJsxAttribute",
|
||
name: this.getString(attrNameRef.offset, attrNameRef.len),
|
||
value: {
|
||
type: "mdxJsxAttributeValueExpression",
|
||
value: restorePhantomSpaces(this.getString(attrValueRef.offset, attrValueRef.len)),
|
||
},
|
||
});
|
||
break;
|
||
case 3: // Spread
|
||
attributes.push({
|
||
type: "mdxJsxExpressionAttribute",
|
||
value: restorePhantomSpaces(this.getString(attrValueRef.offset, attrValueRef.len)),
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
return { name, attributes };
|
||
}
|
||
/**
|
||
* DirectiveData layout:
|
||
* [name: StringRef(8B)][attr_count: u32(4B)][_pad: u32(4B)] = 16-byte header
|
||
* then attr_count × 16 bytes:
|
||
* [key: StringRef(8B)][value: StringRef(8B)]
|
||
*/
|
||
getDirectiveData(nodeId) {
|
||
const data = this.getTypeData(nodeId);
|
||
if (data.length < 16) {
|
||
return { name: "", attributes: {} };
|
||
}
|
||
const nameRef = this.readStringRef(data, 0);
|
||
const name = this.getString(nameRef.offset, nameRef.len);
|
||
const view = new DataView(data.buffer, data.byteOffset + 8);
|
||
const attrCount = view.getUint32(0, true);
|
||
const attributes = {};
|
||
for (let i = 0; i < attrCount; i++) {
|
||
const base = 16 + i * 16;
|
||
const keyRef = this.readStringRef(data, base);
|
||
const valRef = this.readStringRef(data, base + 8);
|
||
const key = this.getString(keyRef.offset, keyRef.len);
|
||
const val = this.getString(valRef.offset, valRef.len);
|
||
attributes[key] = val;
|
||
}
|
||
return { name, attributes };
|
||
}
|
||
/**
|
||
* Walk the tree depth-first. Return false from visitor to skip children.
|
||
*/
|
||
walk(visitor, rootId = 0) {
|
||
const stack = [rootId];
|
||
while (stack.length > 0) {
|
||
const nodeId = stack.pop();
|
||
const nodeType = this.getNodeType(nodeId);
|
||
const result = visitor(nodeId, nodeType);
|
||
if (result !== false) {
|
||
const childIds = this.getChildIds(nodeId);
|
||
for (let i = childIds.length - 1; i >= 0; i--) {
|
||
stack.push(childIds[i]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
/** Walk depth-first with full node objects (slower, but convenient). */
|
||
walkFull(visitor, rootId = 0) {
|
||
this.walk((nodeId) => visitor(this.getNode(nodeId)), rootId);
|
||
}
|
||
}
|