@thejaredwilcurt/csslop 0.0.38 → 0.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,11 @@ import {
6
6
  expandToLeafProperties,
7
7
  getOverridesOf
8
8
  } from '../declarations/config.js';
9
+ import {
10
+ findMatchingBracket,
11
+ findMatchingParenthesis,
12
+ splitTopLevelCommaList
13
+ } from '../parser/source-search.js';
9
14
  import { escapeRegexString } from '../utilities.js';
10
15
 
11
16
  import {
@@ -562,88 +567,187 @@ function deduplicateSelectors (selectors) {
562
567
  }
563
568
 
564
569
  /**
565
- * Merges consecutive rules whose declarations are a subset of the following rule, combining their selectors and splitting out any extra declarations.
570
+ * The rule body entries that style nothing themselves, and so have no say in
571
+ * whether one rule's body is carried by another's.
566
572
  *
567
- * @param {Array} rules The AST rule nodes to merge.
568
- * @return {Array} A new array of rules with declaration-compatible consecutive rules combined.
573
+ * @type {Set<string>}
569
574
  */
570
- function mergeByDeclarations (rules) {
571
- const result = [];
572
- for (const rule of rules) {
573
- if (rule.type !== 'rule' || !rule.selectors?.length) {
574
- result.push(rule);
575
+ const UNSTYLED_BODY_ENTRY_TYPES = new Set(['whitespace', 'comment']);
576
+
577
+ /**
578
+ * Splits a rule body into the declarations it sets directly and the blocks
579
+ * nested inside it, leaving out the entries that style nothing.
580
+ *
581
+ * @param {Array} declarations The rule body entries.
582
+ * @return {object} The body's `plainDeclarations` and `nestedBlocks`.
583
+ */
584
+ function partitionRuleBody (declarations) {
585
+ const plainDeclarations = [];
586
+ const nestedBlocks = [];
587
+ for (const entry of declarations || []) {
588
+ if (UNSTYLED_BODY_ENTRY_TYPES.has(entry.type)) {
575
589
  continue;
576
590
  }
577
- const previousRule = result[result.length - 1];
578
- if (previousRule && previousRule.type === 'rule' && previousRule.selectors?.length) {
579
- const previousDeclarations = (previousRule.declarations || []).filter((declaration) => {
580
- return declaration.type !== 'whitespace' && declaration.property;
581
- });
582
- const currentDeclarations = (rule.declarations || []).filter((declaration) => {
583
- return declaration.type !== 'whitespace' && declaration.property;
584
- });
585
- if (previousDeclarations.length > 0 && currentDeclarations.length > 0) {
586
- const currentDeclarationMap = new Map(
587
- currentDeclarations.map((declaration) => {
588
- return [declaration.property, (declaration.value || '').trim()];
589
- })
590
- );
591
- const previousIsSubset = previousDeclarations.every((declaration) => {
592
- return currentDeclarationMap.get(declaration.property) === (declaration.value || '').trim();
593
- });
594
- const currentHasAllProperty = currentDeclarations.some((declaration) => {
595
- return declaration.property === 'all';
596
- });
597
- if (previousIsSubset && !currentHasAllProperty) {
598
- const commonProperties = new Set(
599
- previousDeclarations.map((declaration) => {
600
- return declaration.property;
601
- })
602
- );
603
- const currentOnlyDeclarations = currentDeclarations.filter((declaration) => {
604
- return !commonProperties.has(declaration.property);
605
- });
606
- result.pop();
607
- const combinedSelectors = deduplicateSelectors([...previousRule.selectors, ...rule.selectors]);
608
- result.push({ ...previousRule, selectors: combinedSelectors });
609
- if (currentOnlyDeclarations.length > 0) {
610
- result.push({ ...rule, declarations: currentOnlyDeclarations });
611
- }
612
- continue;
613
- }
614
- }
591
+ if (entry.property) {
592
+ plainDeclarations.push(entry);
593
+ } else {
594
+ nestedBlocks.push(entry);
615
595
  }
616
- result.push(rule);
617
596
  }
618
- return result;
597
+ return {
598
+ nestedBlocks,
599
+ plainDeclarations
600
+ };
619
601
  }
620
602
 
621
603
  /**
622
- * Splits a selector list on top-level commas, respecting parentheses and
623
- * brackets so commas inside `:is(...)` or `[attr="a,b"]` are not split.
604
+ * Describes a block nested inside a rule precisely enough to tell whether two
605
+ * rules nest the very same thing. Only plain nested rules can be described: an
606
+ * at-rule nested in a rule carries a prelude that decides when its body applies,
607
+ * so it is reported as indescribable rather than guessed at.
624
608
  *
625
- * @param {string} selectorList The selector list string.
626
- * @return {Array} The individual selector strings.
609
+ * @param {object} block A block nested inside a rule body.
610
+ * @return {string|null} The signature, or null when the block cannot be described.
627
611
  */
628
- function splitSelectorListTopLevel (selectorList) {
629
- const selectors = [];
630
- let current = '';
631
- let depth = 0;
632
- for (const character of selectorList) {
633
- if (character === '(' || character === '[') {
634
- depth++;
635
- } else if (character === ')' || character === ']') {
636
- depth--;
637
- }
638
- if (character === ',' && depth === 0) {
639
- selectors.push(current.trim());
640
- current = '';
641
- } else {
642
- current += character;
612
+ function nestedBlockSignature (block) {
613
+ if (block.type !== 'rule' || !block.selectors?.length) {
614
+ return null;
615
+ }
616
+ const {
617
+ nestedBlocks,
618
+ plainDeclarations
619
+ } = partitionRuleBody(block.declarations);
620
+ const declarationParts = plainDeclarations.map((declaration) => {
621
+ return declaration.property + ':' + (declaration.value || '').trim();
622
+ });
623
+ const nestedParts = nestedBlocksSignature(nestedBlocks);
624
+ if (nestedParts === null) {
625
+ return null;
626
+ }
627
+ const selectors = block.selectors.map(normalizeSelector).join(',');
628
+ return selectors + '{' + declarationParts.join(';') + nestedParts + '}';
629
+ }
630
+
631
+ /**
632
+ * Describes every block nested in a rule body as one comparable string, in the
633
+ * order the blocks are written, since that order decides which of them wins a
634
+ * conflict.
635
+ *
636
+ * @param {Array} nestedBlocks The blocks nested in a rule body.
637
+ * @return {string|null} The combined signature, or null when any block cannot be described.
638
+ */
639
+ function nestedBlocksSignature (nestedBlocks) {
640
+ let combined = '';
641
+ for (const block of nestedBlocks) {
642
+ const signature = nestedBlockSignature(block);
643
+ if (signature === null) {
644
+ return null;
645
+ }
646
+ combined += signature;
647
+ }
648
+ return combined;
649
+ }
650
+
651
+ /**
652
+ * Reports whether a rule that nests blocks may take on further selectors. A
653
+ * nested selector resolves against its parent's whole selector list as `:is()`,
654
+ * which matches with the specificity of the strongest selector in that list, so
655
+ * the list may only grow while every selector in it weighs the same.
656
+ *
657
+ * @param {Array} selectors The selector list the merged rule would carry.
658
+ * @return {boolean} Whether the nested blocks keep their current weight.
659
+ */
660
+ function selectorsShareSpecificity (selectors) {
661
+ const strongest = maximumRuleSpecificity(selectors);
662
+ const weakest = minimumRuleSpecificity(selectors);
663
+ return compareSpecificity(strongest, weakest) === 0;
664
+ }
665
+
666
+ /**
667
+ * Combines an earlier rule with the rule that follows it when everything the
668
+ * earlier one styles is restated by the later one, so the earlier selector can
669
+ * simply join the later rule's shared part and the remainder stays behind in a
670
+ * rule of its own.
671
+ *
672
+ * Both rules have to nest the same blocks. A block nested in only one of them
673
+ * would either be lost or start matching the other rule's selector, neither of
674
+ * which the two rules said.
675
+ *
676
+ * @param {object} previousRule The rule immediately in front of `rule`.
677
+ * @param {object} rule The rule being folded into its predecessor.
678
+ * @return {Array|null} The rules that replace the pair, or null when they cannot be combined.
679
+ */
680
+ function mergeRuleIntoPredecessor (previousRule, rule) {
681
+ const isMergeableRulePair = (
682
+ previousRule?.type === 'rule' &&
683
+ previousRule.selectors?.length &&
684
+ rule.type === 'rule' &&
685
+ rule.selectors?.length
686
+ );
687
+ if (!isMergeableRulePair) {
688
+ return null;
689
+ }
690
+ const previousBody = partitionRuleBody(previousRule.declarations);
691
+ const currentBody = partitionRuleBody(rule.declarations);
692
+ if (!previousBody.plainDeclarations.length || !currentBody.plainDeclarations.length) {
693
+ return null;
694
+ }
695
+ const previousNesting = nestedBlocksSignature(previousBody.nestedBlocks);
696
+ const currentNesting = nestedBlocksSignature(currentBody.nestedBlocks);
697
+ if (previousNesting === null || previousNesting !== currentNesting) {
698
+ return null;
699
+ }
700
+ const currentValueByProperty = new Map(
701
+ currentBody.plainDeclarations.map((declaration) => {
702
+ return [declaration.property, (declaration.value || '').trim()];
703
+ })
704
+ );
705
+ const previousIsRestated = previousBody.plainDeclarations.every((declaration) => {
706
+ return currentValueByProperty.get(declaration.property) === (declaration.value || '').trim();
707
+ });
708
+ // `all` rewrites every other property, so the later rule restating a value is
709
+ // no promise that it applies the same way once `all` has run.
710
+ const currentResetsEverything = currentValueByProperty.has('all');
711
+ if (!previousIsRestated || currentResetsEverything) {
712
+ return null;
713
+ }
714
+ const combinedSelectors = deduplicateSelectors([...previousRule.selectors, ...rule.selectors]);
715
+ if (previousBody.nestedBlocks.length && !selectorsShareSpecificity(combinedSelectors)) {
716
+ return null;
717
+ }
718
+ const restatedProperties = new Set(
719
+ previousBody.plainDeclarations.map((declaration) => {
720
+ return declaration.property;
721
+ })
722
+ );
723
+ const remainingDeclarations = currentBody.plainDeclarations.filter((declaration) => {
724
+ return !restatedProperties.has(declaration.property);
725
+ });
726
+ const merged = [{ ...previousRule, selectors: combinedSelectors }];
727
+ if (remainingDeclarations.length) {
728
+ merged.push({ ...rule, declarations: remainingDeclarations });
729
+ }
730
+ return merged;
731
+ }
732
+
733
+ /**
734
+ * Merges consecutive rules whose declarations are a subset of the following rule, combining their selectors and splitting out any extra declarations.
735
+ *
736
+ * @param {Array} rules The AST rule nodes to merge.
737
+ * @return {Array} A new array of rules with declaration-compatible consecutive rules combined.
738
+ */
739
+ function mergeByDeclarations (rules) {
740
+ const result = [];
741
+ for (const rule of rules) {
742
+ const merged = mergeRuleIntoPredecessor(result[result.length - 1], rule);
743
+ if (merged) {
744
+ result.pop();
745
+ result.push(...merged);
746
+ continue;
643
747
  }
748
+ result.push(rule);
644
749
  }
645
- selectors.push(current.trim());
646
- return selectors;
750
+ return result;
647
751
  }
648
752
 
649
753
  /**
@@ -662,28 +766,6 @@ function skipSelectorName (text, start) {
662
766
  return index;
663
767
  }
664
768
 
665
- /**
666
- * Finds the index of the closing parenthesis matching the one at openIndex.
667
- *
668
- * @param {string} text The text to scan.
669
- * @param {number} openIndex Index of the opening parenthesis.
670
- * @return {number} Index of the matching close parenthesis, or text length.
671
- */
672
- function findMatchingParenthesisIndex (text, openIndex) {
673
- let depth = 0;
674
- for (let index = openIndex; index < text.length; index++) {
675
- if (text[index] === '(') {
676
- depth++;
677
- } else if (text[index] === ')') {
678
- depth--;
679
- if (depth === 0) {
680
- return index;
681
- }
682
- }
683
- }
684
- return text.length;
685
- }
686
-
687
769
  /**
688
770
  * Compares two specificity tuples [ids, classes, types] lexicographically.
689
771
  *
@@ -724,20 +806,9 @@ function computeSpecificity (selector) {
724
806
  index = skipSelectorName(text, index + 1);
725
807
  } else if (character === '[') {
726
808
  specificity[1]++;
727
- // Advance to just past the matching closing bracket
728
- let bracketDepth = 0;
729
- while (index < text.length) {
730
- if (text[index] === '[') {
731
- bracketDepth++;
732
- } else if (text[index] === ']') {
733
- bracketDepth--;
734
- if (bracketDepth === 0) {
735
- index++;
736
- break;
737
- }
738
- }
739
- index++;
740
- }
809
+ // An attribute selector that never closes runs to the end of the selector
810
+ const closeBracketIndex = findMatchingBracket(text, index);
811
+ index = closeBracketIndex === -1 ? text.length : closeBracketIndex + 1;
741
812
  } else if (character === ':') {
742
813
  if (text[index + 1] === ':') {
743
814
  specificity[2]++;
@@ -747,8 +818,10 @@ function computeSpecificity (selector) {
747
818
  const nameEnd = skipSelectorName(text, nameStart);
748
819
  const name = text.slice(nameStart, nameEnd).toLowerCase();
749
820
  if (text[nameEnd] === '(') {
750
- const closeIndex = findMatchingParenthesisIndex(text, nameEnd);
751
- const inner = text.slice(nameEnd + 1, closeIndex);
821
+ // An argument list that never closes runs to the end of the selector
822
+ const closeIndex = findMatchingParenthesis(text, nameEnd);
823
+ const argumentsEnd = closeIndex === -1 ? text.length : closeIndex;
824
+ const inner = text.slice(nameEnd + 1, argumentsEnd);
752
825
  if (name === 'where') {
753
826
  // :where() contributes zero specificity
754
827
  } else if (name === 'is' || name === 'not' || name === 'has' || name === 'matches') {
@@ -759,7 +832,7 @@ function computeSpecificity (selector) {
759
832
  } else {
760
833
  specificity[1]++;
761
834
  }
762
- index = closeIndex + 1;
835
+ index = argumentsEnd + 1;
763
836
  } else {
764
837
  // Legacy single-colon pseudo-elements count as pseudo-elements
765
838
  if (name === 'before' || name === 'after' || name === 'first-line' || name === 'first-letter') {
@@ -793,7 +866,7 @@ function computeSpecificity (selector) {
793
866
  */
794
867
  function maxSelectorSpecificity (selectorList) {
795
868
  let maximum = [0, 0, 0];
796
- for (const selector of splitSelectorListTopLevel(selectorList)) {
869
+ for (const selector of splitTopLevelCommaList(selectorList)) {
797
870
  const specificity = computeSpecificity(selector);
798
871
  if (compareSpecificity(specificity, maximum) > 0) {
799
872
  maximum = specificity;
@@ -824,7 +897,7 @@ function maximumRuleSpecificity (selectors) {
824
897
  */
825
898
  function minimumRuleSpecificity (selectors) {
826
899
  let minimum = null;
827
- for (const selector of splitSelectorListTopLevel((selectors || []).join(','))) {
900
+ for (const selector of splitTopLevelCommaList((selectors || []).join(','))) {
828
901
  const specificity = computeSpecificity(selector);
829
902
  if (!minimum || compareSpecificity(specificity, minimum) < 0) {
830
903
  minimum = specificity;
@@ -1278,6 +1351,74 @@ const MERGE_AT_LATER_RULE = 'later';
1278
1351
  */
1279
1352
  const MERGE_AT_EARLIER_RULE = 'earlier';
1280
1353
 
1354
+ /**
1355
+ * Reports whether an entry of a rule's body is a declaration whose standing in
1356
+ * the cascade can be read off the property it writes. Anything else a body may
1357
+ * hold, such as a nested rule, is never shadowed by a declaration beside it.
1358
+ *
1359
+ * @param {object} entry An entry of a rule's declaration list.
1360
+ * @return {boolean} True when the entry is a property declaration.
1361
+ */
1362
+ function isPropertyDeclaration (entry) {
1363
+ return entry.type === 'declaration' && Boolean(entry.property);
1364
+ }
1365
+
1366
+ /**
1367
+ * Finds which declaration decides each leaf property once a list of
1368
+ * declarations is read as one rule's body. Within a rule, an important
1369
+ * declaration beats a non-important one, and between two of equal weight the
1370
+ * last one written wins.
1371
+ *
1372
+ * @param {Array} declarations The declarations of the combined rule, in the order they would be written.
1373
+ * @return {Map} Map of leaf property name to the declaration that decides it.
1374
+ */
1375
+ function findDecidingDeclarations (declarations) {
1376
+ const decidingDeclarations = new Map();
1377
+ const importantDeclarations = new Set();
1378
+ for (const declaration of declarations) {
1379
+ if (!isPropertyDeclaration(declaration)) {
1380
+ continue;
1381
+ }
1382
+ const isImportant = isImportantDeclaration(declaration);
1383
+ if (isImportant) {
1384
+ importantDeclarations.add(declaration);
1385
+ }
1386
+ for (const leafProperty of expandToOverridableProperties(declaration.property)) {
1387
+ const decidingDeclaration = decidingDeclarations.get(leafProperty);
1388
+ const outranksIt = (
1389
+ !decidingDeclaration ||
1390
+ isImportant ||
1391
+ !importantDeclarations.has(decidingDeclaration)
1392
+ );
1393
+ if (outranksIt) {
1394
+ decidingDeclarations.set(leafProperty, declaration);
1395
+ }
1396
+ }
1397
+ }
1398
+ return decidingDeclarations;
1399
+ }
1400
+
1401
+ /**
1402
+ * Reports whether anything a rule brings to a merge still decides a property
1403
+ * of the combined rule. A declaration that loses every property it writes to
1404
+ * another declaration of the same rule is dropped from the merged rule, so
1405
+ * where the merged rule sits cannot change how it reads.
1406
+ *
1407
+ * @param {object} rule The rule contributing the entries.
1408
+ * @param {Map} decidingDeclarations The deciding declaration of each leaf property of the merged rule.
1409
+ * @return {boolean} True when the rule still decides something after the merge.
1410
+ */
1411
+ function decidesAnythingAfterMerge (rule, decidingDeclarations) {
1412
+ return (rule.declarations || []).some((entry) => {
1413
+ if (!isPropertyDeclaration(entry)) {
1414
+ return entry.type !== 'whitespace' && entry.type !== 'comment';
1415
+ }
1416
+ return [...expandToOverridableProperties(entry.property)].some((leafProperty) => {
1417
+ return decidingDeclarations.get(leafProperty) === entry;
1418
+ });
1419
+ });
1420
+ }
1421
+
1281
1422
  /**
1282
1423
  * Decides where two rules with the same selector can be combined. Merging them
1283
1424
  * always makes one set of declarations cross whatever separates the two rules,
@@ -1285,6 +1426,12 @@ const MERGE_AT_EARLIER_RULE = 'earlier';
1285
1426
  * the merged rule goes wherever the set that moved keeps its old standing.
1286
1427
  * Nothing separates adjacent rules, so those always merge.
1287
1428
  *
1429
+ * The declarations one rule loses to the other are the exception: they are
1430
+ * dropped from the merged rule, so the merged rule belongs wherever the
1431
+ * declarations that outlive the merge already were, and nothing has to cross
1432
+ * anything at all. That is what lets `.a{color:red!important}` swallow a later
1433
+ * `.a{color:tan}` across a rule that also writes `color`.
1434
+ *
1288
1435
  * @param {object} overrideIndex The index of what each position writes to.
1289
1436
  * @param {object} earlierRule The first of the two rules with this selector.
1290
1437
  * @param {object} laterRule The second of the two rules with this selector.
@@ -1294,7 +1441,20 @@ const MERGE_AT_EARLIER_RULE = 'earlier';
1294
1441
  */
1295
1442
  function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPosition, laterPosition) {
1296
1443
  const rulesAreAdjacent = earlierPosition === laterPosition - 1;
1297
- if (rulesAreAdjacent || !overrideIndex.blocksRelocation(earlierRule, earlierPosition)) {
1444
+ if (rulesAreAdjacent) {
1445
+ return MERGE_AT_LATER_RULE;
1446
+ }
1447
+ const decidingDeclarations = findDecidingDeclarations([
1448
+ ...(earlierRule.declarations || []),
1449
+ ...(laterRule.declarations || [])
1450
+ ]);
1451
+ if (!decidesAnythingAfterMerge(laterRule, decidingDeclarations)) {
1452
+ return MERGE_AT_EARLIER_RULE;
1453
+ }
1454
+ if (!decidesAnythingAfterMerge(earlierRule, decidingDeclarations)) {
1455
+ return MERGE_AT_LATER_RULE;
1456
+ }
1457
+ if (!overrideIndex.blocksRelocation(earlierRule, earlierPosition)) {
1298
1458
  return MERGE_AT_LATER_RULE;
1299
1459
  }
1300
1460
  if (!overrideIndex.blocksRelocation(laterRule, earlierPosition)) {