Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revert "fix: Incorrect parsing of functional pseudo class css selector (#169)" #182

Merged
merged 4 commits into from
Apr 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/modern-doors-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rrweb-snapshot': patch
---

better nested css selector splitting when commas or brackets happen to be in quoted text
5 changes: 5 additions & 0 deletions .changeset/rich-dots-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rrweb-snapshot': patch
---

Fix css parsing errors
137 changes: 58 additions & 79 deletions packages/rrweb-snapshot/src/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,102 +431,81 @@ export function parse(css: string, options: ParserOptions = {}) {
*/

function selector() {
const m = match(/^([^{]+)/);
whitespace();
while (css[0] == '}') {
error('extra closing bracket');
css = css.slice(1);
whitespace();
}

// Use match logic from https://github.com/NxtChg/pieces/blob/3eb39c8287a97632e9347a24f333d52d916bc816/js/css_parser/css_parse.js#L46C1-L47C1
const m = match(/^(("(?:\\"|[^"])*"|'(?:\\'|[^'])*'|[^{])+)/);
if (!m) {
return;
}

/* @fix Remove all comments from selectors
* http://ostermiller.org/findcomment.html */
const splitSelectors = trim(m[0])
const cleanedInput = m[0]
.trim()
.replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '')

// Handle strings by replacing commas inside them
.replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, (m) => {
return m.replace(/,/g, '\u200C');
})
.split(/\s*(?![^(]*\)),\s*/);

if (splitSelectors.length <= 1) {
return splitSelectors.map((s) => {
return s.replace(/\u200C/g, ',');
});
}

// For each selector, need to check if we properly split on `,`
// Example case where selector is:
// .bar:has(input:is(:disabled), button:is(:disabled))
let i = 0;
let j = 0;
const len = splitSelectors.length;
const finalSelectors = [];
while (i < len) {
// Look for selectors with opening parens - `(` and search rest of
// selectors for the first one with matching number of closing
// parens `)`
const openingParensCount = (splitSelectors[i].match(/\(/g) || []).length;
const closingParensCount = (splitSelectors[i].match(/\)/g) || []).length;
let unbalancedParens = openingParensCount - closingParensCount;

if (unbalancedParens >= 1) {
// At least one opening parens was found, prepare to look through
// rest of selectors
let foundClosingSelector = false;

// Loop starting with next item in array, until we find matching
// number of ending parens
j = i + 1;
while (j < len) {
// peek into next item to count the number of closing brackets
const nextOpeningParensCount = (splitSelectors[j].match(/\(/g) || [])
.length;
const nextClosingParensCount = (splitSelectors[j].match(/\)/g) || [])
.length;
const nextUnbalancedParens =
nextClosingParensCount - nextOpeningParensCount;

if (nextUnbalancedParens === unbalancedParens) {
// Matching # of closing parens was found, join all elements
// from i to j
finalSelectors.push(splitSelectors.slice(i, j + 1).join(','));

// we will want to skip the items that we have joined together
i = j + 1;

// Use to continue the outer loop
foundClosingSelector = true;

// break out of inner loop so we found matching closing parens
break;
}

// No matching closing parens found, keep moving through index, but
// update the # of unbalanced parents still outstanding
j++;
unbalancedParens -= nextUnbalancedParens;
}
// Split using a custom function and restore commas in strings
return customSplit(cleanedInput).map((s) =>
s.replace(/\u200C/g, ',').trim(),
);
}

/**
* Split selector correctly, ensuring not to split on comma if inside ().
*/

function customSplit(input: string) {
const result = [];
let currentSegment = '';
let depthParentheses = 0; // Track depth of parentheses
let depthBrackets = 0; // Track depth of square brackets
let currentStringChar = null;

if (foundClosingSelector) {
// Matching closing selector was found, move to next selector
continue;
for (const char of input) {
const hasStringEscape = currentSegment.endsWith('\\');

if (currentStringChar) {
if (currentStringChar === char && !hasStringEscape) {
currentStringChar = null;
}
} else if (char === '(') {
depthParentheses++;
} else if (char === ')') {
depthParentheses--;
} else if (char === '[') {
depthBrackets++;
} else if (char === ']') {
depthBrackets--;
} else if ('\'"'.includes(char)) {
currentStringChar = char;
}

// No matching closing selector was found, either invalid CSS,
// or unbalanced number of opening parens were used as CSS
// selectors. Assume that rest of the list of selectors are
// selectors and break to avoid iterating through the list of
// selectors again.
splitSelectors
.slice(i, len)
.forEach((selector) => selector && finalSelectors.push(selector));
break;
// Split point is a comma that is not inside parentheses or square brackets
if (char === ',' && depthParentheses === 0 && depthBrackets === 0) {
result.push(currentSegment);
currentSegment = '';
} else {
currentSegment += char;
}
}

// No opening parens found, contiue looking through list
splitSelectors[i] && finalSelectors.push(splitSelectors[i]);
i++;
// Add the last segment
if (currentSegment) {
result.push(currentSegment);
}

return finalSelectors.map((s) => {
return s.replace(/\u200C/g, ',');
});
return result;
}

/**
Expand Down
87 changes: 79 additions & 8 deletions packages/rrweb-snapshot/test/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,35 @@ describe('css parser', () => {
expect(errors[0].filename).toEqual('foo.css');
});

it('should parse selector with comma nested inside ()', () => {
const result = parse(
'[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }',
);

expect(result.parent).toEqual(null);

const rules = result.stylesheet!.rules;
expect(rules.length).toEqual(1);

let rule = rules[0] as Rule;
expect(rule.parent).toEqual(result);
expect(rule.selectors?.length).toEqual(1);

let decl = rule.declarations![0];
expect(decl.parent).toEqual(rule);
});

it('parses { and } in attribute selectors correctly', () => {
const result = parse('foo[someAttr~="{someId}"] { color: red; }');
const rules = result.stylesheet!.rules;

expect(rules.length).toEqual(1);

const rule = rules[0] as Rule;

expect(rule.selectors![0]).toEqual('foo[someAttr~="{someId}"]');
});

it('should set parent property', () => {
const result = parse(
'thing { test: value; }\n' +
Expand Down Expand Up @@ -119,6 +148,50 @@ describe('css parser', () => {
expect(out3).toEqual('[data-aa\\:other] { color: red; }');
});

it('parses nested commas in selectors correctly', () => {
const result = parse(
`
body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a) {
background: red;
}
`,
);
expect((result.stylesheet!.rules[0] as Rule)!.selectors!.length).toEqual(1);

const trickresult = parse(
`
li[attr="weirdly("] a:hover, li[attr="weirdly)"] a {
background-color: red;
}
`,
);
expect(
(trickresult.stylesheet!.rules[0] as Rule)!.selectors!.length,
).toEqual(2);

const weirderresult = parse(
`
li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a {
background-color: red;
}
`,
);
expect(
(weirderresult.stylesheet!.rules[0] as Rule)!.selectors!.length,
).toEqual(2);

const commainstrresult = parse(
`
li[attr="has,comma"] a:hover {
background-color: red;
}
`,
);
expect(
(commainstrresult.stylesheet!.rules[0] as Rule)!.selectors!.length,
).toEqual(1);
});

it.each([
['.foo,.bar {}', ['.foo', '.bar']],
['.bar:has(:disabled) {}', ['.bar:has(:disabled)']],
Expand All @@ -129,11 +202,11 @@ describe('css parser', () => {
],
[
'.bar:has(div, input:is(:disabled), button) {}',
['.bar:has(div,input:is(:disabled), button)'],
['.bar:has(div, input:is(:disabled), button)'],
],
[
'.bar:has(div, input:is(:disabled),button:has(:disabled,.baz)) {}',
['.bar:has(div,input:is(:disabled),button:has(:disabled,.baz))'],
['.bar:has(div, input:is(:disabled),button:has(:disabled,.baz))'],
],
[
'.bar:has(input), .foo:has(input, button), .baz {}',
Expand All @@ -142,17 +215,15 @@ describe('css parser', () => {
[
'.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz)){color: red;}',
[
'.bar:has(input:is(:disabled),button:has(:disabled,.baz),div:has(:disabled,.baz))',
'.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz))',
],
],
['.bar((( {}', ['.bar(((']],
[
'.bar:has(:has(:has(a), :has(:has(:has(b, :has(a), c), e))), input:is(:disabled), button) {}',
[
'.bar:has(:has(:has(a),:has(:has(:has(b,:has(a), c), e))),input:is(:disabled), button)',
'.bar:has(:has(:has(a), :has(:has(:has(b, :has(a), c), e))), input:is(:disabled), button)',
],
],
['.foo,.bar(((,.baz {}', ['.foo', '.bar(((', '.baz']],
[
'.foo,.bar:has(input:is(:disabled)){color: red;}',
['.foo', '.bar:has(input:is(:disabled))'],
Expand All @@ -165,14 +236,14 @@ describe('css parser', () => {
'.foo,.bar:has(input:is(:disabled),button:has(:disabled), div:has(:disabled,.baz)){color: red;}',
[
'.foo',
'.bar:has(input:is(:disabled),button:has(:disabled),div:has(:disabled,.baz))',
'.bar:has(input:is(:disabled),button:has(:disabled), div:has(:disabled,.baz))',
],
],
[
'.foo,.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz)){color: red;}',
[
'.foo',
'.bar:has(input:is(:disabled),button:has(:disabled,.baz),div:has(:disabled,.baz))',
'.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz))',
],
],
['.bar:has(:disabled), .foo {}', ['.bar:has(:disabled)', '.foo']],
Expand Down
Loading