From 8afdfbc13b2b7c0ca6a08e29de4c24833c7059f7 Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Sun, 8 Nov 2026 22:21:42 +0000 Subject: [PATCH 1/2] fix: start a namespaced tag or universal at its namespace The node for `ns|a` prints as `ns|a`, but its `sourceIndex` and `source.start` pointed at the `a`, so slicing the input from `sourceIndex` did not give the node back. The same held for `*|*`, `|a` and `&|a`. The selector around it already started at the namespace. The node now starts at its prefix, or at the `|` when the namespace is empty. Where it ends is unchanged. --- src/__tests__/sourceIndex.mjs | 30 ++++++++++++++++++++++++++++ src/parser.js | 37 +++++++++++++++++++++++++---------- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/__tests__/sourceIndex.mjs b/src/__tests__/sourceIndex.mjs index 916458d..1010157 100644 --- a/src/__tests__/sourceIndex.mjs +++ b/src/__tests__/sourceIndex.mjs @@ -333,3 +333,33 @@ test("pseudo with arguments spanning multiple lines", "h1:not(\n\t.one,\n\t.two\ t.deepEqual(tree.nodes[0].nodes[1].nodes[1].nodes[0].source.end.column, 5, ".two end column"); t.deepEqual(tree.nodes[0].nodes[1].nodes[1].nodes[0].sourceIndex, 16, ".two sourceIndex"); }); + +test("namespaced tag starts at its namespace", "a ns|b", (t, tree) => { + const node = tree.nodes[0].nodes[2]; + t.deepEqual(node.sourceIndex, 2); + t.deepEqual(node.source.start.column, 3); + t.deepEqual(node.source.end.column, 6); +}); + +test("namespaced universal starts at its namespace", "*|*", (t, tree) => { + const node = tree.nodes[0].nodes[0]; + t.deepEqual(node.sourceIndex, 0); + t.deepEqual(node.source.start.column, 1); + t.deepEqual(node.source.end.column, 3); +}); + +test("tag with an empty namespace starts at the pipe", "|b.c", (t, tree) => { + const [tag, cls] = tree.nodes[0].nodes; + t.deepEqual(tag.sourceIndex, 0); + t.deepEqual(tag.source.start.column, 1); + t.deepEqual(tag.source.end.column, 2); + t.deepEqual(cls.sourceIndex, 2); + t.deepEqual(cls.source.start.column, 3); +}); + +test("namespaced tag on a later line starts at its namespace", "a,\n ns|b", (t, tree) => { + const node = tree.nodes[1].nodes[0]; + t.deepEqual(node.sourceIndex, 5); + t.deepEqual(node.source.start.line, 2); + t.deepEqual(node.source.start.column, 3); +}); diff --git a/src/parser.js b/src/parser.js index 24d35be..40aec14 100644 --- a/src/parser.js +++ b/src/parser.js @@ -843,6 +843,8 @@ export default class Parser { prev[TOKEN.TYPE] === tokens.ampersand) ? this.content(prev) : true; + // The node starts at its prefix, or at the `|` when there is none. + const start = before === true ? this.currToken : prev; if (!this.nextToken) { // A trailing `|` with nothing after it. `unexpectedPipe` reports against // `currToken`, which is the pipe itself and always present here. @@ -850,10 +852,10 @@ export default class Parser { } if (this.nextToken[TOKEN.TYPE] === tokens.word) { this.position++; - return this.word(before); + return this.word(before, start); } else if (this.nextToken[TOKEN.TYPE] === tokens.asterisk) { this.position++; - return this.universal(before); + return this.universal(before, start); } this.unexpectedPipe(); @@ -1069,7 +1071,12 @@ export default class Parser { this.position++; } - universal(namespace) { + /** + * @param {string | true} [namespace] + * @param {Array} [start] the token the node starts at when it has a + * namespace, which is its prefix or the `|` + */ + universal(namespace, start) { const nextToken = this.nextToken; if (nextToken && this.content(nextToken) === "|") { this.position++; @@ -1079,15 +1086,21 @@ export default class Parser { this.newNode( new Universal({ value: this.content(), - source: getTokenSource(current), - sourceIndex: current[TOKEN.START_POS], + source: getTokenSourceSpan(start || current, current), + sourceIndex: (start || current)[TOKEN.START_POS], }), namespace, ); this.position++; } - splitWord(namespace, firstCallback) { + /** + * @param {string | true | false | null} [namespace] + * @param {Function} [firstCallback] + * @param {Array} [start] the token the first node starts at when it has a + * namespace, which is its prefix or the `|` + */ + splitWord(namespace, firstCallback, start) { let nextToken = this.nextToken; let word = this.content(); while ( @@ -1135,8 +1148,12 @@ export default class Parser { } let node; const current = this.currToken; - const sourceIndex = current[TOKEN.START_POS] + indices[i]; - const source = getSource(current[1], current[2] + ind, current[3], current[2] + (index - 1)); + let sourceIndex = current[TOKEN.START_POS] + indices[i]; + let source = getSource(current[1], current[2] + ind, current[3], current[2] + (index - 1)); + if (i === 0 && start) { + sourceIndex = start[TOKEN.START_POS]; + source.start = { line: start[TOKEN.START_LINE], column: start[TOKEN.START_COL] }; + } if (classIndexes.has(ind)) { let classNameOpts = { value: value.slice(1), @@ -1172,13 +1189,13 @@ export default class Parser { this.position++; } - word(namespace) { + word(namespace, start) { const nextToken = this.nextToken; if (nextToken && this.content(nextToken) === "|") { this.position++; return this.namespace(); } - return this.splitWord(namespace); + return this.splitWord(namespace, undefined, start); } loop() { From 912767ce65b4ef9bca020de38f31681959c8ed73 Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Thu, 12 Nov 2026 20:52:31 +0000 Subject: [PATCH 2/2] refactor: set the namespaced node's start inside namespace() Instead of passing a start token through word(), splitWord() and universal(), namespace() now adjusts the first node they create. --- src/parser.js | 60 +++++++++++++++++++++++---------------------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/src/parser.js b/src/parser.js index 40aec14..c5f14ec 100644 --- a/src/parser.js +++ b/src/parser.js @@ -843,22 +843,31 @@ export default class Parser { prev[TOKEN.TYPE] === tokens.ampersand) ? this.content(prev) : true; - // The node starts at its prefix, or at the `|` when there is none. - const start = before === true ? this.currToken : prev; if (!this.nextToken) { // A trailing `|` with nothing after it. `unexpectedPipe` reports against // `currToken`, which is the pipe itself and always present here. return this.unexpectedPipe(); } - if (this.nextToken[TOKEN.TYPE] === tokens.word) { - this.position++; - return this.word(before, start); - } else if (this.nextToken[TOKEN.TYPE] === tokens.asterisk) { - this.position++; - return this.universal(before, start); + const type = this.nextToken[TOKEN.TYPE]; + if (type !== tokens.word && type !== tokens.asterisk) { + return this.unexpectedPipe(); + } + // The node starts at its prefix, or at the `|` when there is none, but + // `word()` and `universal()` only see the name after the `|`. + const start = before === true ? this.currToken : prev; + const nodes = this.current.nodes; + const index = nodes.length; + this.position++; + if (type === tokens.word) { + this.word(before); + } else { + this.universal(before); + } + const node = nodes[index]; + if (node) { + node.sourceIndex = start[TOKEN.START_POS]; + node.source.start = { line: start[TOKEN.START_LINE], column: start[TOKEN.START_COL] }; } - - this.unexpectedPipe(); } nesting() { @@ -1071,12 +1080,7 @@ export default class Parser { this.position++; } - /** - * @param {string | true} [namespace] - * @param {Array} [start] the token the node starts at when it has a - * namespace, which is its prefix or the `|` - */ - universal(namespace, start) { + universal(namespace) { const nextToken = this.nextToken; if (nextToken && this.content(nextToken) === "|") { this.position++; @@ -1086,21 +1090,15 @@ export default class Parser { this.newNode( new Universal({ value: this.content(), - source: getTokenSourceSpan(start || current, current), - sourceIndex: (start || current)[TOKEN.START_POS], + source: getTokenSource(current), + sourceIndex: current[TOKEN.START_POS], }), namespace, ); this.position++; } - /** - * @param {string | true | false | null} [namespace] - * @param {Function} [firstCallback] - * @param {Array} [start] the token the first node starts at when it has a - * namespace, which is its prefix or the `|` - */ - splitWord(namespace, firstCallback, start) { + splitWord(namespace, firstCallback) { let nextToken = this.nextToken; let word = this.content(); while ( @@ -1148,12 +1146,8 @@ export default class Parser { } let node; const current = this.currToken; - let sourceIndex = current[TOKEN.START_POS] + indices[i]; - let source = getSource(current[1], current[2] + ind, current[3], current[2] + (index - 1)); - if (i === 0 && start) { - sourceIndex = start[TOKEN.START_POS]; - source.start = { line: start[TOKEN.START_LINE], column: start[TOKEN.START_COL] }; - } + const sourceIndex = current[TOKEN.START_POS] + indices[i]; + const source = getSource(current[1], current[2] + ind, current[3], current[2] + (index - 1)); if (classIndexes.has(ind)) { let classNameOpts = { value: value.slice(1), @@ -1189,13 +1183,13 @@ export default class Parser { this.position++; } - word(namespace, start) { + word(namespace) { const nextToken = this.nextToken; if (nextToken && this.content(nextToken) === "|") { this.position++; return this.namespace(); } - return this.splitWord(namespace, undefined, start); + return this.splitWord(namespace); } loop() {