Skip to content

Commit 4652011

Browse files
Wain-PCPavel Dranichnikov
andauthored
add support for endIndex in node metadata (#850)
Co-authored-by: Pavel Dranichnikov <pdranichnikov@bhft.com>
1 parent 2ce7191 commit 4652011

6 files changed

Lines changed: 154 additions & 17 deletions

File tree

docs/v4, v5/2.XMLparseOptions.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,12 +1515,15 @@ The MetaData object is not available for nodes that resolve as strings or arrays
15151515

15161516
```js
15171517
const parser = new XMLParser({ignoreAttributes: false, captureMetaData: true});
1518-
const jsonObj = parser.parse(`<root><thing name="zero"/><thing name="one"/></root>`);
1518+
const xml = `<root><thing name="zero"/><thing name="one"/></root>`;
1519+
const jsonObj = parser.parse(xml);
15191520
const META_DATA_SYMBOL = XMLParser.getMetaDataSymbol();
1520-
// get the char offset of the start of the tag for <thing name="zero"/>
1521+
// get the char offsets of the tag <thing name="zero"/>
15211522
const thingZero = jsonObj.root.thing[0];
15221523
const thingZeroMetaData = thingZero[META_DATA_SYMBOL];
15231524
const thingZeroStartIndex = thingZeroMetaData.startIndex; // 6
1525+
const thingZeroEndIndex = thingZeroMetaData.endIndex; // 26
1526+
xml.slice(thingZeroStartIndex, thingZeroEndIndex); // '<thing name="zero"/>'
15241527
```
15251528

15261529
[> Next: XmlBuilder](./3.XMLBuilder.md)

spec/endIndex_spec.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
2+
import { XMLParser } from "../src/fxp.js";
3+
4+
const XML_METADATA = XMLParser.getMetaDataSymbol();
5+
6+
/** Collect [rawTagName, metadata] for every node carrying metadata, in document order. */
7+
function collectMeta(node, out = []) {
8+
if (node === null || typeof node !== "object") return out;
9+
if (node[XML_METADATA]) {
10+
const tag = Object.keys(node).find((k) => k !== ":@" && k !== "#text");
11+
out.push([tag, node[XML_METADATA]]);
12+
}
13+
if (Array.isArray(node)) {
14+
node.forEach((n) => collectMeta(n, out));
15+
} else {
16+
for (const k of Object.keys(node)) collectMeta(node[k], out);
17+
}
18+
return out;
19+
}
20+
21+
/** Parse xml with metadata capture on and return a Map of rawTagName -> raw text span. */
22+
function parseSpans(xml) {
23+
const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false, captureMetaData: true });
24+
const result = parser.parse(xml);
25+
return new Map(collectMeta(result).map(([tag, m]) => [tag, xml.slice(m.startIndex, m.endIndex)]));
26+
}
27+
28+
describe("XMLParser captureMetaData endIndex", function () {
29+
it("does not add metadata (start or end) when captureMetaData is off", function () {
30+
const xml = `<root><child/></root>`;
31+
const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false });
32+
const result = parser.parse(xml);
33+
expect(collectMeta(result).length).toBe(0);
34+
});
35+
36+
it("records an exclusive endIndex", function () {
37+
const xml = `<root><foo/><bar type="quux"/><baz type="foo">FOO</baz></root>`;
38+
const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false, captureMetaData: true });
39+
const result = parser.parse(xml);
40+
41+
const meta = collectMeta(result);
42+
const spans = meta.map(([tag, m]) => [tag, xml.slice(m.startIndex, m.endIndex)]);
43+
44+
expect(spans).toEqual([
45+
["root", `<root><foo/><bar type="quux"/><baz type="foo">FOO</baz></root>`],
46+
["foo", `<foo/>`],
47+
["bar", `<bar type="quux"/>`],
48+
["baz", `<baz type="foo">FOO</baz>`],
49+
]);
50+
});
51+
52+
it("covers self-closing elements", function () {
53+
const xml = `<a><c/></a>`;
54+
expect(parseSpans(xml).get("c")).toBe(`<c/>`);
55+
});
56+
57+
it("covers paired elements with text content", function () {
58+
const xml = `<a><d>text</d></a>`;
59+
expect(parseSpans(xml).get("d")).toBe(`<d>text</d>`);
60+
});
61+
62+
it("covers elements with inline attributes", function () {
63+
const xml = `<a><e id="1" name="foo"/><f class="bar">text</f></a>`;
64+
const byTag = parseSpans(xml);
65+
expect(byTag.get("e")).toBe(`<e id="1" name="foo"/>`);
66+
expect(byTag.get("f")).toBe(`<f class="bar">text</f>`);
67+
});
68+
69+
it("covers deeply nested elements", function () {
70+
const xml = `<a><b><c/></b><d>text</d></a>`;
71+
const byTag = parseSpans(xml);
72+
expect(byTag.get("a")).toBe(`<a><b><c/></b><d>text</d></a>`);
73+
expect(byTag.get("b")).toBe(`<b><c/></b>`);
74+
});
75+
76+
it("covers processing-instruction nodes", function () {
77+
const xml = `<?xml version="1.0"?><a><?pi target?><c/></a>`;
78+
const byTag = parseSpans(xml);
79+
expect(byTag.get("?xml")).toBe(`<?xml version="1.0"?>`);
80+
expect(byTag.get("?pi")).toBe(`<?pi target?>`);
81+
});
82+
83+
it("does not corrupt a sibling's endIndex when updateTag drops a node", function () {
84+
const xml = `<a><b>x</b><skip/><?skip pi?><skip>y</skip></a>`;
85+
const parser = new XMLParser({
86+
preserveOrder: true,
87+
ignoreAttributes: false,
88+
captureMetaData: true,
89+
updateTag: (tagName) => (tagName === "skip" || tagName === "?skip" ? false : tagName),
90+
});
91+
const result = parser.parse(xml);
92+
93+
const byTag = new Map(collectMeta(result).map(([tag, m]) => [tag, xml.slice(m.startIndex, m.endIndex)]));
94+
expect(byTag.get("b")).toBe(`<b>x</b>`);
95+
expect(byTag.get("a")).toBe(xml);
96+
});
97+
});

spec/startIndex_spec.js

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,22 @@ describe("XMLParser", function () {
99
it("should support captureMetadata && !preserveOrder", function () {
1010
const expected = {
1111
root: {
12-
[XML_METADATA]: { startIndex: 0 },
12+
[XML_METADATA]: { startIndex: 0, endIndex: 79 },
1313
foo: '',
1414
bar: [
1515
{
16-
[XML_METADATA]: { startIndex: 12 },
16+
[XML_METADATA]: { startIndex: 12, endIndex: 30 },
1717
"@_type": 'quux'
1818
},
1919
{
20-
[XML_METADATA]: { startIndex: 30 },
20+
[XML_METADATA]: { startIndex: 30, endIndex: 47 },
2121
"@_type": 'bat'
2222
},
2323
],
2424
baz: {
2525
'@_type': 'foo',
2626
'#text': 'FOO',
27-
[XML_METADATA]: {startIndex: 47},
27+
[XML_METADATA]: { startIndex: 47, endIndex: 72 },
2828
}
2929
}
3030
};
@@ -38,24 +38,24 @@ describe("XMLParser", function () {
3838
const expected = [
3939
{
4040
root: [
41-
{ foo: [], [XML_METADATA]: { startIndex: 6 } },
41+
{ foo: [], [XML_METADATA]: { startIndex: 6, endIndex: 12 } },
4242
{
4343
bar: [],
4444
':@': { "@_type": 'quux' },
45-
[XML_METADATA]: { startIndex: 12 },
45+
[XML_METADATA]: { startIndex: 12, endIndex: 30 },
4646
},
4747
{
4848
bar: [],
4949
':@': { "@_type": 'bat' },
50-
[XML_METADATA]: { startIndex: 30 },
50+
[XML_METADATA]: { startIndex: 30, endIndex: 47 },
5151
},
5252
{
5353
baz: [{ '#text': 'FOO' }],
5454
':@': { '@_type': 'foo' },
55-
[XML_METADATA]: {startIndex: 47},
55+
[XML_METADATA]: { startIndex: 47, endIndex: 72 },
5656
},
5757
],
58-
[XML_METADATA]: { startIndex: 0 },
58+
[XML_METADATA]: { startIndex: 0, endIndex: 79 },
5959
}
6060
];
6161

@@ -69,28 +69,28 @@ describe("XMLParser", function () {
6969
it("should support captureMetadata && isArray && stopNodes && unpairedTags && updateTag", function () {
7070
const expected = {
7171
ROOT: {
72-
[XML_METADATA]: { startIndex: 0 },
72+
[XML_METADATA]: { startIndex: 0, endIndex: 138 },
7373
foo: [''],
7474
bar: [
7575
{
76-
[XML_METADATA]: { startIndex: 12 },
76+
[XML_METADATA]: { startIndex: 12, endIndex: 30 },
7777
"@_type": 'quux'
7878
},
7979
{
80-
[XML_METADATA]: { startIndex: 30 },
80+
[XML_METADATA]: { startIndex: 30, endIndex: 47 },
8181
"@_type": 'bat'
8282
},
8383
],
8484
baz: {
8585
'#text': 'FOO',
8686
'@_type': 'foo',
87-
[XML_METADATA]: { startIndex: 47 },
87+
[XML_METADATA]: { startIndex: 47, endIndex: 72 },
8888
},
8989
// no metadata on stop nodes.
9090
stop: 'This is a <b>stop</b> node.',
9191
unpaired: {
9292
'@_attr': '1',
93-
[XML_METADATA]: { startIndex: 112 },
93+
[XML_METADATA]: { startIndex: 112, endIndex: 131 },
9494
}
9595
}
9696
};
@@ -100,7 +100,7 @@ describe("XMLParser", function () {
100100
isArray(tagName) {
101101
return (tagName == 'foo');
102102
},
103-
stopNodes: [ 'root.stop' ], unpairedTags: ['unpaired'],
103+
stopNodes: [ 'root.stop' ], unpairedTags: ['unpaired'],
104104
updateTag(tagName) {
105105
if (tagName === 'root') {
106106
tagName = 'ROOT';

src/fxp.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,4 +749,6 @@ export class XMLBuilder {
749749
export interface XMLMetaData {
750750
/** The index, if available, of the character where the XML node began in the input stream. */
751751
startIndex?: number;
752+
/** The index, if available, of the character where the XML node ended in the input stream. */
753+
endIndex?: number;
752754
}

src/xmlparser/OrderedObjParser.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,10 @@ const parseXml = function (xmlData) {
337337
this.isCurrentNodeStopNode = false; // Reset flag when closing tag
338338

339339
currentNode = this.tagsNodeStack.pop();//avoid recursion, set the parent tag scope
340+
341+
if (options.captureMetaData && currentNode) {
342+
currentNode.addEndIndex(closeIndex + 1);
343+
}
340344
textData = "";
341345
i = closeIndex;
342346
} else if (c1 === 63) { //'?'
@@ -362,6 +366,11 @@ const parseXml = function (xmlData) {
362366
childNode[":@"] = attsMap
363367
}
364368
this.addChild(currentNode, childNode, this.readonlyMatcher, i);
369+
370+
if (options.captureMetaData) {
371+
// closeIndex points at '?' of the closing '?>'
372+
currentNode.addEndIndex(tagData.closeIndex + 2);
373+
}
365374
}
366375

367376

@@ -526,6 +535,10 @@ const parseXml = function (xmlData) {
526535
this.isCurrentNodeStopNode = false; // Reset flag
527536

528537
this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
538+
539+
if (options.captureMetaData) {
540+
currentNode.addEndIndex(i + 1);
541+
}
529542
} else {
530543
//selfClosing tag
531544
if (isSelfClosing) {
@@ -536,6 +549,10 @@ const parseXml = function (xmlData) {
536549
childNode[":@"] = prefixedAttrs;
537550
}
538551
this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
552+
553+
if (options.captureMetaData) {
554+
currentNode.addEndIndex(closeIndex + 1);
555+
}
539556
this.matcher.pop(); // Pop self-closing tag
540557
this.isCurrentNodeStopNode = false; // Reset flag
541558
}
@@ -545,6 +562,10 @@ const parseXml = function (xmlData) {
545562
childNode[":@"] = prefixedAttrs;
546563
}
547564
this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
565+
566+
if (options.captureMetaData) {
567+
currentNode.addEndIndex(result.closeIndex + 1);
568+
}
548569
this.matcher.pop(); // Pop unpaired tag
549570
this.isCurrentNodeStopNode = false; // Reset flag
550571
i = result.closeIndex;

src/xmlparser/xmlNode.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,26 @@ export default class XmlNode {
2727
this.child.push({ [node.tagname]: node.child });
2828
}
2929
// if requested, add the startIndex
30+
this.addStartIndex(startIndex);
31+
}
32+
33+
addStartIndex(startIndex) {
3034
if (startIndex !== undefined) {
3135
// Note: for now we just overwrite the metadata. If we had more complex metadata,
3236
// we might need to do an object append here: metadata = { ...metadata, startIndex }
3337
this.child[this.child.length - 1][METADATA_SYMBOL] = { startIndex };
3438
}
3539
}
40+
41+
addEndIndex(endIndex) {
42+
const lastChild = this.child[this.child.length - 1];
43+
// endIndex is write-once: when updateTag drops a node, the last child is a
44+
// previously completed sibling whose endIndex must not be overwritten
45+
if (lastChild !== undefined && lastChild[METADATA_SYMBOL] !== undefined
46+
&& lastChild[METADATA_SYMBOL].endIndex === undefined) {
47+
lastChild[METADATA_SYMBOL].endIndex = endIndex;
48+
}
49+
}
3650
/** symbol used for metadata */
3751
static getMetaDataSymbol() {
3852
return METADATA_SYMBOL;

0 commit comments

Comments
 (0)