diff --git a/docs/v4, v5/2.XMLparseOptions.md b/docs/v4, v5/2.XMLparseOptions.md
index b1079bbd..872c81cf 100644
--- a/docs/v4, v5/2.XMLparseOptions.md
+++ b/docs/v4, v5/2.XMLparseOptions.md
@@ -1515,12 +1515,15 @@ The MetaData object is not available for nodes that resolve as strings or arrays
```js
const parser = new XMLParser({ignoreAttributes: false, captureMetaData: true});
-const jsonObj = parser.parse(``);
+const xml = ``;
+const jsonObj = parser.parse(xml);
const META_DATA_SYMBOL = XMLParser.getMetaDataSymbol();
-// get the char offset of the start of the tag for
+// get the char offsets of the tag
const thingZero = jsonObj.root.thing[0];
const thingZeroMetaData = thingZero[META_DATA_SYMBOL];
const thingZeroStartIndex = thingZeroMetaData.startIndex; // 6
+const thingZeroEndIndex = thingZeroMetaData.endIndex; // 26
+xml.slice(thingZeroStartIndex, thingZeroEndIndex); // ''
```
[> Next: XmlBuilder](./3.XMLBuilder.md)
diff --git a/spec/endIndex_spec.js b/spec/endIndex_spec.js
new file mode 100644
index 00000000..4f14eff8
--- /dev/null
+++ b/spec/endIndex_spec.js
@@ -0,0 +1,97 @@
+
+import { XMLParser } from "../src/fxp.js";
+
+const XML_METADATA = XMLParser.getMetaDataSymbol();
+
+/** Collect [rawTagName, metadata] for every node carrying metadata, in document order. */
+function collectMeta(node, out = []) {
+ if (node === null || typeof node !== "object") return out;
+ if (node[XML_METADATA]) {
+ const tag = Object.keys(node).find((k) => k !== ":@" && k !== "#text");
+ out.push([tag, node[XML_METADATA]]);
+ }
+ if (Array.isArray(node)) {
+ node.forEach((n) => collectMeta(n, out));
+ } else {
+ for (const k of Object.keys(node)) collectMeta(node[k], out);
+ }
+ return out;
+}
+
+/** Parse xml with metadata capture on and return a Map of rawTagName -> raw text span. */
+function parseSpans(xml) {
+ const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false, captureMetaData: true });
+ const result = parser.parse(xml);
+ return new Map(collectMeta(result).map(([tag, m]) => [tag, xml.slice(m.startIndex, m.endIndex)]));
+}
+
+describe("XMLParser captureMetaData endIndex", function () {
+ it("does not add metadata (start or end) when captureMetaData is off", function () {
+ const xml = ``;
+ const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false });
+ const result = parser.parse(xml);
+ expect(collectMeta(result).length).toBe(0);
+ });
+
+ it("records an exclusive endIndex", function () {
+ const xml = `FOO`;
+ const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false, captureMetaData: true });
+ const result = parser.parse(xml);
+
+ const meta = collectMeta(result);
+ const spans = meta.map(([tag, m]) => [tag, xml.slice(m.startIndex, m.endIndex)]);
+
+ expect(spans).toEqual([
+ ["root", `FOO`],
+ ["foo", ``],
+ ["bar", ``],
+ ["baz", `FOO`],
+ ]);
+ });
+
+ it("covers self-closing elements", function () {
+ const xml = ``;
+ expect(parseSpans(xml).get("c")).toBe(``);
+ });
+
+ it("covers paired elements with text content", function () {
+ const xml = `text`;
+ expect(parseSpans(xml).get("d")).toBe(`text`);
+ });
+
+ it("covers elements with inline attributes", function () {
+ const xml = `text`;
+ const byTag = parseSpans(xml);
+ expect(byTag.get("e")).toBe(``);
+ expect(byTag.get("f")).toBe(`text`);
+ });
+
+ it("covers deeply nested elements", function () {
+ const xml = `text`;
+ const byTag = parseSpans(xml);
+ expect(byTag.get("a")).toBe(`text`);
+ expect(byTag.get("b")).toBe(``);
+ });
+
+ it("covers processing-instruction nodes", function () {
+ const xml = ``;
+ const byTag = parseSpans(xml);
+ expect(byTag.get("?xml")).toBe(``);
+ expect(byTag.get("?pi")).toBe(``);
+ });
+
+ it("does not corrupt a sibling's endIndex when updateTag drops a node", function () {
+ const xml = `xy`;
+ const parser = new XMLParser({
+ preserveOrder: true,
+ ignoreAttributes: false,
+ captureMetaData: true,
+ updateTag: (tagName) => (tagName === "skip" || tagName === "?skip" ? false : tagName),
+ });
+ const result = parser.parse(xml);
+
+ const byTag = new Map(collectMeta(result).map(([tag, m]) => [tag, xml.slice(m.startIndex, m.endIndex)]));
+ expect(byTag.get("b")).toBe(`x`);
+ expect(byTag.get("a")).toBe(xml);
+ });
+});
diff --git a/spec/startIndex_spec.js b/spec/startIndex_spec.js
index 62ab9723..a5d2ec89 100644
--- a/spec/startIndex_spec.js
+++ b/spec/startIndex_spec.js
@@ -9,22 +9,22 @@ describe("XMLParser", function () {
it("should support captureMetadata && !preserveOrder", function () {
const expected = {
root: {
- [XML_METADATA]: { startIndex: 0 },
+ [XML_METADATA]: { startIndex: 0, endIndex: 79 },
foo: '',
bar: [
{
- [XML_METADATA]: { startIndex: 12 },
+ [XML_METADATA]: { startIndex: 12, endIndex: 30 },
"@_type": 'quux'
},
{
- [XML_METADATA]: { startIndex: 30 },
+ [XML_METADATA]: { startIndex: 30, endIndex: 47 },
"@_type": 'bat'
},
],
baz: {
'@_type': 'foo',
'#text': 'FOO',
- [XML_METADATA]: {startIndex: 47},
+ [XML_METADATA]: { startIndex: 47, endIndex: 72 },
}
}
};
@@ -38,24 +38,24 @@ describe("XMLParser", function () {
const expected = [
{
root: [
- { foo: [], [XML_METADATA]: { startIndex: 6 } },
+ { foo: [], [XML_METADATA]: { startIndex: 6, endIndex: 12 } },
{
bar: [],
':@': { "@_type": 'quux' },
- [XML_METADATA]: { startIndex: 12 },
+ [XML_METADATA]: { startIndex: 12, endIndex: 30 },
},
{
bar: [],
':@': { "@_type": 'bat' },
- [XML_METADATA]: { startIndex: 30 },
+ [XML_METADATA]: { startIndex: 30, endIndex: 47 },
},
{
baz: [{ '#text': 'FOO' }],
':@': { '@_type': 'foo' },
- [XML_METADATA]: {startIndex: 47},
+ [XML_METADATA]: { startIndex: 47, endIndex: 72 },
},
],
- [XML_METADATA]: { startIndex: 0 },
+ [XML_METADATA]: { startIndex: 0, endIndex: 79 },
}
];
@@ -69,28 +69,28 @@ describe("XMLParser", function () {
it("should support captureMetadata && isArray && stopNodes && unpairedTags && updateTag", function () {
const expected = {
ROOT: {
- [XML_METADATA]: { startIndex: 0 },
+ [XML_METADATA]: { startIndex: 0, endIndex: 138 },
foo: [''],
bar: [
{
- [XML_METADATA]: { startIndex: 12 },
+ [XML_METADATA]: { startIndex: 12, endIndex: 30 },
"@_type": 'quux'
},
{
- [XML_METADATA]: { startIndex: 30 },
+ [XML_METADATA]: { startIndex: 30, endIndex: 47 },
"@_type": 'bat'
},
],
baz: {
'#text': 'FOO',
'@_type': 'foo',
- [XML_METADATA]: { startIndex: 47 },
+ [XML_METADATA]: { startIndex: 47, endIndex: 72 },
},
// no metadata on stop nodes.
stop: 'This is a stop node.',
unpaired: {
'@_attr': '1',
- [XML_METADATA]: { startIndex: 112 },
+ [XML_METADATA]: { startIndex: 112, endIndex: 131 },
}
}
};
@@ -100,7 +100,7 @@ describe("XMLParser", function () {
isArray(tagName) {
return (tagName == 'foo');
},
- stopNodes: [ 'root.stop' ], unpairedTags: ['unpaired'],
+ stopNodes: [ 'root.stop' ], unpairedTags: ['unpaired'],
updateTag(tagName) {
if (tagName === 'root') {
tagName = 'ROOT';
diff --git a/src/fxp.d.ts b/src/fxp.d.ts
index 167ff4a1..e340d71f 100644
--- a/src/fxp.d.ts
+++ b/src/fxp.d.ts
@@ -749,4 +749,6 @@ export class XMLBuilder {
export interface XMLMetaData {
/** The index, if available, of the character where the XML node began in the input stream. */
startIndex?: number;
+ /** The index, if available, of the character where the XML node ended in the input stream. */
+ endIndex?: number;
}
\ No newline at end of file
diff --git a/src/xmlparser/OrderedObjParser.js b/src/xmlparser/OrderedObjParser.js
index 2bf5c187..9a32cd00 100644
--- a/src/xmlparser/OrderedObjParser.js
+++ b/src/xmlparser/OrderedObjParser.js
@@ -335,6 +335,10 @@ const parseXml = function (xmlData) {
this.isCurrentNodeStopNode = false; // Reset flag when closing tag
currentNode = this.tagsNodeStack.pop();//avoid recursion, set the parent tag scope
+
+ if (options.captureMetaData && currentNode) {
+ currentNode.addEndIndex(closeIndex + 1);
+ }
textData = "";
i = closeIndex;
} else if (c1 === 63) { //'?'
@@ -360,6 +364,11 @@ const parseXml = function (xmlData) {
childNode[":@"] = attsMap
}
this.addChild(currentNode, childNode, this.readonlyMatcher, i);
+
+ if (options.captureMetaData) {
+ // closeIndex points at '?' of the closing '?>'
+ currentNode.addEndIndex(tagData.closeIndex + 2);
+ }
}
@@ -522,6 +531,10 @@ const parseXml = function (xmlData) {
this.isCurrentNodeStopNode = false; // Reset flag
this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
+
+ if (options.captureMetaData) {
+ currentNode.addEndIndex(i + 1);
+ }
} else {
//selfClosing tag
if (isSelfClosing) {
@@ -532,6 +545,10 @@ const parseXml = function (xmlData) {
childNode[":@"] = prefixedAttrs;
}
this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
+
+ if (options.captureMetaData) {
+ currentNode.addEndIndex(closeIndex + 1);
+ }
this.matcher.pop(); // Pop self-closing tag
this.isCurrentNodeStopNode = false; // Reset flag
}
@@ -541,6 +558,10 @@ const parseXml = function (xmlData) {
childNode[":@"] = prefixedAttrs;
}
this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
+
+ if (options.captureMetaData) {
+ currentNode.addEndIndex(result.closeIndex + 1);
+ }
this.matcher.pop(); // Pop unpaired tag
this.isCurrentNodeStopNode = false; // Reset flag
i = result.closeIndex;
diff --git a/src/xmlparser/xmlNode.js b/src/xmlparser/xmlNode.js
index 4223a8d3..a8b46394 100644
--- a/src/xmlparser/xmlNode.js
+++ b/src/xmlparser/xmlNode.js
@@ -27,12 +27,26 @@ export default class XmlNode {
this.child.push({ [node.tagname]: node.child });
}
// if requested, add the startIndex
+ this.addStartIndex(startIndex);
+ }
+
+ addStartIndex(startIndex) {
if (startIndex !== undefined) {
// Note: for now we just overwrite the metadata. If we had more complex metadata,
// we might need to do an object append here: metadata = { ...metadata, startIndex }
this.child[this.child.length - 1][METADATA_SYMBOL] = { startIndex };
}
}
+
+ addEndIndex(endIndex) {
+ const lastChild = this.child[this.child.length - 1];
+ // endIndex is write-once: when updateTag drops a node, the last child is a
+ // previously completed sibling whose endIndex must not be overwritten
+ if (lastChild !== undefined && lastChild[METADATA_SYMBOL] !== undefined
+ && lastChild[METADATA_SYMBOL].endIndex === undefined) {
+ lastChild[METADATA_SYMBOL].endIndex = endIndex;
+ }
+ }
/** symbol used for metadata */
static getMetaDataSymbol() {
return METADATA_SYMBOL;