diff --git a/.eslintrc.js b/.eslintrc.js index 28fb1cc..021b3b1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,6 +16,8 @@ module.exports = { rules: { "require-jsdoc": "off", "no-warning-comments": "warn", + "func-style": "off", + complexity: "off", "spaced-comment": ["error", "always", { markers: ["/"] }], }, globals: { diff --git a/.github/workflows/NodeCI.yml b/.github/workflows/NodeCI.yml index 0b2d998..7f05d00 100644 --- a/.github/workflows/NodeCI.yml +++ b/.github/workflows/NodeCI.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [12.x, 14.x, 16.x, 18.x] + node-version: [12.x, 14.x, 16.x, 18.x, 20.x] steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} @@ -38,8 +38,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [16.x] - stylelint: ['14'] + node-version: [18.x] + stylelint: ['14', '15'] steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} diff --git a/.vscode/settings.json b/.vscode/settings.json index adf57a9..5b12b90 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,17 +1,17 @@ { "editor.codeActionsOnSave": { - "source.fixAll.eslint": true, - "source.fixAll.stylelint": false, + "source.fixAll.eslint": "explicit", + "source.fixAll.stylelint": "never" }, "[stylus]": { "editor.codeActionsOnSave": { - "source.fixAll.stylelint": false, + "source.fixAll.stylelint": "never" } }, "[vue]": { "editor.codeActionsOnSave": { - "source.fixAll.stylelint": false, - "source.fixAll.eslint": false, + "source.fixAll.stylelint": "never", + "source.fixAll.eslint": "never" } }, "css.validate": false, diff --git a/docs/.vuepress/components/playground.vue b/docs/.vuepress/components/playground.vue index c89870b..b1d3f91 100644 --- a/docs/.vuepress/components/playground.vue +++ b/docs/.vuepress/components/playground.vue @@ -79,7 +79,7 @@ button { const CONFIG_DEFAULT = `{ "plugins": ["stylelint-stylus"], "extends": [ - "stylelint-config-standard", + "stylelint-config-recommended", "stylelint-stylus/standard" ] }`; diff --git a/docs/stylelint.config.js b/docs/stylelint.config.js index e9255ce..176b036 100644 --- a/docs/stylelint.config.js +++ b/docs/stylelint.config.js @@ -1,7 +1,7 @@ "use strict" module.exports = { - extends: ["stylelint-config-standard", "stylelint-stylus/standard"], + extends: ["stylelint-config-recommended", "stylelint-stylus/standard"], rules: { "no-duplicate-selectors": null, "selector-class-pattern": null, diff --git a/lib/rules/at-extend-style.js b/lib/rules/at-extend-style.js index 3da051f..48f5d82 100644 --- a/lib/rules/at-extend-style.js +++ b/lib/rules/at-extend-style.js @@ -3,8 +3,8 @@ const { utils: { ruleMessages, validateOptions, report }, } = require("stylelint") -const hasBlock = require("stylelint/lib/utils/hasBlock") const { inCssLiteral } = require("../utils/nodes") +const { hasBlock } = require("../utils/ast") const ruleName = "stylus/at-extend-style" diff --git a/lib/rules/at-rule-empty-line-before.js b/lib/rules/at-rule-empty-line-before.js index 79283e9..0658e84 100644 --- a/lib/rules/at-rule-empty-line-before.js +++ b/lib/rules/at-rule-empty-line-before.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/at-rule-empty-line-before") +const coreRule = require("../utils/stylelint-v15/rules/at-rule-empty-line-before") const { wrap } = require("../utils/wrapper-rules/atrule-name-rules") const ruleName = "stylus/at-rule-empty-line-before" diff --git a/lib/rules/at-rule-name-space-after.js b/lib/rules/at-rule-name-space-after.js index 9dd0f6c..b375977 100644 --- a/lib/rules/at-rule-name-space-after.js +++ b/lib/rules/at-rule-name-space-after.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/at-rule-name-space-after") +const coreRule = require("../utils/stylelint-v15/rules/at-rule-name-space-after") const { wrap } = require("../utils/wrapper-rules/atrule-name-rules") const ruleName = "stylus/at-rule-name-space-after" diff --git a/lib/rules/at-rule-no-unknown.js b/lib/rules/at-rule-no-unknown.js index 766b867..c2812b1 100644 --- a/lib/rules/at-rule-no-unknown.js +++ b/lib/rules/at-rule-no-unknown.js @@ -1,6 +1,7 @@ "use strict" -const coreRule = require("stylelint/lib/rules/at-rule-no-unknown") +const { loadCoreRule } = require("../utils/stylelint") +const coreRule = loadCoreRule("at-rule-no-unknown") const { transformResult } = require("../utils/proxy") const { withWalkerIgnoresNonStandardAtRule, @@ -32,13 +33,13 @@ const STYLUS_AT_NAMES = [ ] function rule(expectation, options, context) { - return (root, result) => { + return async (root, result) => { let verify = null if (root.source.lang !== "stylus") { - verify = coreRule(expectation, options, context) + verify = (await coreRule)(expectation, options, context) } else { const ignoreAtRules = (options && options.ignoreAtRules) || [] - verify = coreRule( + verify = (await coreRule)( expectation, { ...(options || {}), diff --git a/lib/rules/block-closing-brace-empty-line-before.js b/lib/rules/block-closing-brace-empty-line-before.js index c96de62..0f62ab7 100644 --- a/lib/rules/block-closing-brace-empty-line-before.js +++ b/lib/rules/block-closing-brace-empty-line-before.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/block-closing-brace-empty-line-before") +const coreRule = require("../utils/stylelint-v15/rules/block-closing-brace-empty-line-before") const { wrap } = require("../utils/wrapper-rules/brace-rules") const ruleName = "stylus/block-closing-brace-empty-line-before" diff --git a/lib/rules/block-closing-brace-newline-after.js b/lib/rules/block-closing-brace-newline-after.js index 6897ccd..e22b765 100644 --- a/lib/rules/block-closing-brace-newline-after.js +++ b/lib/rules/block-closing-brace-newline-after.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/block-closing-brace-newline-after") +const coreRule = require("../utils/stylelint-v15/rules/block-closing-brace-newline-after") const { wrap } = require("../utils/wrapper-rules/brace-rules") const ruleName = "stylus/block-closing-brace-newline-after" diff --git a/lib/rules/block-closing-brace-newline-before.js b/lib/rules/block-closing-brace-newline-before.js index f99ce5c..b606690 100644 --- a/lib/rules/block-closing-brace-newline-before.js +++ b/lib/rules/block-closing-brace-newline-before.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/block-closing-brace-newline-before") +const coreRule = require("../utils/stylelint-v15/rules/block-closing-brace-newline-before") const { wrap } = require("../utils/wrapper-rules/brace-rules") const ruleName = "stylus/block-closing-brace-newline-before" diff --git a/lib/rules/block-closing-brace-space-after.js b/lib/rules/block-closing-brace-space-after.js index 7bd2300..57e8060 100644 --- a/lib/rules/block-closing-brace-space-after.js +++ b/lib/rules/block-closing-brace-space-after.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/block-closing-brace-space-after") +const coreRule = require("../utils/stylelint-v15/rules/block-closing-brace-space-after") const { wrap } = require("../utils/wrapper-rules/brace-rules") const ruleName = "stylus/block-closing-brace-space-after" diff --git a/lib/rules/block-closing-brace-space-before.js b/lib/rules/block-closing-brace-space-before.js index 11711f7..4547de8 100644 --- a/lib/rules/block-closing-brace-space-before.js +++ b/lib/rules/block-closing-brace-space-before.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/block-closing-brace-space-before") +const coreRule = require("../utils/stylelint-v15/rules/block-closing-brace-space-before") const { wrap } = require("../utils/wrapper-rules/brace-rules") const ruleName = "stylus/block-closing-brace-space-before" diff --git a/lib/rules/block-opening-brace-newline-after.js b/lib/rules/block-opening-brace-newline-after.js index ea5a923..b202ac7 100644 --- a/lib/rules/block-opening-brace-newline-after.js +++ b/lib/rules/block-opening-brace-newline-after.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/block-opening-brace-newline-after") +const coreRule = require("../utils/stylelint-v15/rules/block-opening-brace-newline-after") const { wrap } = require("../utils/wrapper-rules/brace-rules") const ruleName = "stylus/block-opening-brace-newline-after" diff --git a/lib/rules/block-opening-brace-space-after.js b/lib/rules/block-opening-brace-space-after.js index 2beec8d..01e45c3 100644 --- a/lib/rules/block-opening-brace-space-after.js +++ b/lib/rules/block-opening-brace-space-after.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/block-opening-brace-space-after") +const coreRule = require("../utils/stylelint-v15/rules/block-opening-brace-space-after") const { wrap } = require("../utils/wrapper-rules/brace-rules") const { newProxy } = require("../utils/proxy") const ruleName = "stylus/block-opening-brace-space-after" diff --git a/lib/rules/block-opening-brace-space-before.js b/lib/rules/block-opening-brace-space-before.js index 2f02ba6..b6b0cd0 100644 --- a/lib/rules/block-opening-brace-space-before.js +++ b/lib/rules/block-opening-brace-space-before.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/block-opening-brace-space-before") +const coreRule = require("../utils/stylelint-v15/rules/block-opening-brace-space-before") const { wrap } = require("../utils/wrapper-rules/brace-rules") const ruleName = "stylus/block-opening-brace-space-before" diff --git a/lib/rules/color-hex-case.js b/lib/rules/color-hex-case.js index 167eba5..9b222ea 100644 --- a/lib/rules/color-hex-case.js +++ b/lib/rules/color-hex-case.js @@ -4,7 +4,7 @@ const { utils: { ruleMessages, validateOptions, report }, } = require("stylelint") const styleSearch = require("style-search") -const coreRule = require("stylelint/lib/rules/color-hex-case") +const coreRule = require("../utils/stylelint-v15/rules/color-hex-case") const { transformResult } = require("../utils/proxy") const { scopedTokens, isSkipToken } = require("../utils/tokens") diff --git a/lib/rules/indentation.js b/lib/rules/indentation.js index 5b29b01..ebd167d 100644 --- a/lib/rules/indentation.js +++ b/lib/rules/indentation.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/indentation") +const coreRule = require("../utils/stylelint-v15/rules/indentation") const { transformResult, newProxy } = require("../utils/proxy") const ruleName = "stylus/indentation" diff --git a/lib/rules/no-at-require.js b/lib/rules/no-at-require.js index b3d0b67..92bc94a 100644 --- a/lib/rules/no-at-require.js +++ b/lib/rules/no-at-require.js @@ -3,8 +3,8 @@ const { utils: { ruleMessages, validateOptions, report }, } = require("stylelint") -const hasBlock = require("stylelint/lib/utils/hasBlock") const { inCssLiteral } = require("../utils/nodes") +const { hasBlock } = require("../utils/ast") const ruleName = "stylus/no-at-require" diff --git a/lib/rules/no-eol-whitespace.js b/lib/rules/no-eol-whitespace.js index e111e57..2ea1777 100644 --- a/lib/rules/no-eol-whitespace.js +++ b/lib/rules/no-eol-whitespace.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/no-eol-whitespace") +const coreRule = require("../utils/stylelint-v15/rules/no-eol-whitespace") const { transformResult } = require("../utils/proxy") const ruleName = "stylus/no-eol-whitespace" const originalRuleName = "no-eol-whitespace" diff --git a/lib/rules/number-leading-zero.js b/lib/rules/number-leading-zero.js index 6de5226..30ec3ce 100644 --- a/lib/rules/number-leading-zero.js +++ b/lib/rules/number-leading-zero.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/number-leading-zero") +const coreRule = require("../utils/stylelint-v15/rules/number-leading-zero") const { wrap } = require("../utils/wrapper-rules/number-rules") const ruleName = "stylus/number-leading-zero" diff --git a/lib/rules/number-no-trailing-zeros.js b/lib/rules/number-no-trailing-zeros.js index 25bc007..512d93f 100644 --- a/lib/rules/number-no-trailing-zeros.js +++ b/lib/rules/number-no-trailing-zeros.js @@ -1,6 +1,6 @@ "use strict" -const coreRule = require("stylelint/lib/rules/number-no-trailing-zeros") +const coreRule = require("../utils/stylelint-v15/rules/number-no-trailing-zeros") const { wrap } = require("../utils/wrapper-rules/number-rules") const ruleName = "stylus/number-no-trailing-zeros" diff --git a/lib/rules/property-no-unknown.js b/lib/rules/property-no-unknown.js index 46d72b0..7a58110 100644 --- a/lib/rules/property-no-unknown.js +++ b/lib/rules/property-no-unknown.js @@ -1,6 +1,7 @@ "use strict" -const coreRule = require("stylelint/lib/rules/property-no-unknown") +const { loadCoreRule } = require("../utils/stylelint") +const coreRule = loadCoreRule("property-no-unknown") const { transformResult, newProxy } = require("../utils/proxy") const { inCssLiteral, isObjectProperty } = require("../utils/nodes") @@ -19,8 +20,8 @@ module.exports = { } function rule(expectation, options, context) { - const verify = coreRule(expectation, options, context) - return (root, result) => { + return async (root, result) => { + const verify = (await coreRule)(expectation, options, context) if (root.source.lang !== "stylus") { verify( root, diff --git a/lib/rules/pythonic.js b/lib/rules/pythonic.js index 38ed699..ec788ee 100644 --- a/lib/rules/pythonic.js +++ b/lib/rules/pythonic.js @@ -4,8 +4,8 @@ const _ = require("lodash") const { utils: { ruleMessages, validateOptions, report }, } = require("stylelint") -const hasBlock = require("stylelint/lib/utils/hasBlock") const { inCssLiteral } = require("../utils/nodes") +const { hasBlock } = require("../utils/ast") const ruleName = "stylus/pythonic" diff --git a/lib/rules/selector-list-comma-newline-after.js b/lib/rules/selector-list-comma-newline-after.js index c2b1a9b..fa2c2e7 100644 --- a/lib/rules/selector-list-comma-newline-after.js +++ b/lib/rules/selector-list-comma-newline-after.js @@ -3,7 +3,7 @@ const { utils: { ruleMessages, validateOptions, report }, } = require("stylelint") -const coreRule = require("stylelint/lib/rules/selector-list-comma-newline-after") +const coreRule = require("../utils/stylelint-v15/rules/selector-list-comma-newline-after") const { transformResult } = require("../utils/proxy") const { isLinebreak, isSkipToken } = require("../utils/tokens") const { @@ -11,7 +11,7 @@ const { isSelectorToken, setSelector, } = require("../utils/selector") -const { isSingleLineString } = require("../utils") +const { isSingleLineString } = require("../utils/text") const ruleName = "stylus/selector-list-comma-newline-after" diff --git a/lib/rules/selector-list-comma-newline-before.js b/lib/rules/selector-list-comma-newline-before.js index 9ec6ee4..480eea4 100644 --- a/lib/rules/selector-list-comma-newline-before.js +++ b/lib/rules/selector-list-comma-newline-before.js @@ -3,11 +3,11 @@ const { utils: { ruleMessages, validateOptions, report }, } = require("stylelint") -const coreRule = require("stylelint/lib/rules/selector-list-comma-newline-before") +const coreRule = require("../utils/stylelint-v15/rules/selector-list-comma-newline-before") const { transformResult } = require("../utils/proxy") const { isLinebreak, isSkipToken, isWhitespace } = require("../utils/tokens") const { getSelectorTokens, setSelector } = require("../utils/selector") -const { isSingleLineString } = require("../utils") +const { isSingleLineString } = require("../utils/text") const ruleName = "stylus/selector-list-comma-newline-before" diff --git a/lib/rules/selector-list-comma-space-after.js b/lib/rules/selector-list-comma-space-after.js index cab571c..dc13d95 100644 --- a/lib/rules/selector-list-comma-space-after.js +++ b/lib/rules/selector-list-comma-space-after.js @@ -3,11 +3,11 @@ const { utils: { ruleMessages, validateOptions, report }, } = require("stylelint") -const coreRule = require("stylelint/lib/rules/selector-list-comma-space-after") +const coreRule = require("../utils/stylelint-v15/rules/selector-list-comma-space-after") const { transformResult } = require("../utils/proxy") const { isSkipToken, isWhitespace } = require("../utils/tokens") const { getSelectorTokens, setSelector } = require("../utils/selector") -const { isSingleLineString } = require("../utils") +const { isSingleLineString } = require("../utils/text") const ruleName = "stylus/selector-list-comma-space-after" diff --git a/lib/rules/selector-list-comma-space-before.js b/lib/rules/selector-list-comma-space-before.js index cec0950..e7c3773 100644 --- a/lib/rules/selector-list-comma-space-before.js +++ b/lib/rules/selector-list-comma-space-before.js @@ -3,11 +3,11 @@ const { utils: { ruleMessages, validateOptions, report }, } = require("stylelint") -const coreRule = require("stylelint/lib/rules/selector-list-comma-space-before") +const coreRule = require("../utils/stylelint-v15/rules/selector-list-comma-space-before") const { transformResult } = require("../utils/proxy") const { isWhitespace } = require("../utils/tokens") const { getSelectorTokens, setSelector } = require("../utils/selector") -const { isSingleLineString } = require("../utils") +const { isSingleLineString } = require("../utils/text") const ruleName = "stylus/selector-list-comma-space-before" diff --git a/lib/rules/selector-pseudo-class-case.js b/lib/rules/selector-pseudo-class-case.js index 4e0d03f..aeb2da7 100644 --- a/lib/rules/selector-pseudo-class-case.js +++ b/lib/rules/selector-pseudo-class-case.js @@ -3,10 +3,10 @@ const { utils: { ruleMessages, validateOptions, report }, } = require("stylelint") -const coreRule = require("stylelint/lib/rules/selector-pseudo-class-case") +const coreRule = require("../utils/stylelint-v15/rules/selector-pseudo-class-case") const { transformResult } = require("../utils/proxy") const { parseSelector } = require("../utils/selector") -const { selectors } = require("../utils/stylelint-internal") +const { levelOneAndTwoPseudoElements } = require("../utils/reference/selector") const ruleName = "stylus/selector-pseudo-class-case" @@ -78,7 +78,7 @@ function rule(expectation, options, context) { if ( pseudo.includes("::") || - selectors.levelOneAndTwoPseudoElements.has( + levelOneAndTwoPseudoElements.has( pseudo.toLowerCase().slice(1), ) ) { diff --git a/lib/rules/selector-type-no-unknown.js b/lib/rules/selector-type-no-unknown.js index 1ed57dc..aa980e2 100644 --- a/lib/rules/selector-type-no-unknown.js +++ b/lib/rules/selector-type-no-unknown.js @@ -1,16 +1,11 @@ "use strict" const _ = require("lodash") +const { loadCoreRule } = require("../utils/stylelint") const { utils: { ruleMessages, validateOptions, report }, } = require("stylelint") -const coreRule = require("stylelint/lib/rules/selector-type-no-unknown") -const { - isKeyframeSelector, - optionsMatches, - isCustomElement, - selectors: { htmlTypeSelectors }, -} = require("../utils/stylelint-internal") +const coreRule = loadCoreRule("selector-type-no-unknown") const { transformResult } = require("../utils/proxy") const { parseSelector, @@ -18,6 +13,9 @@ const { } = require("../utils/selector") const svgTags = require("svg-tags") const mathMLTags = require("mathml-tag-names") +const { isKeyframeSelector, isCustomElement } = require("../utils/text") +const { optionsMatches } = require("../utils/option") +const { htmlTypeSelectors } = require("../utils/reference/selector") const ruleName = "stylus/selector-type-no-unknown" @@ -36,8 +34,8 @@ const messages = ruleMessages(ruleName, { rejected: (selector) => `Unexpected unknown type selector "${selector}"`, }) -function verifyCore(expectation, options, context, root, result) { - const verify = coreRule(expectation, options, context) +async function verifyCore(expectation, options, context, root, result) { + const verify = (await coreRule)(expectation, options, context) verify( root, transformResult(result, { @@ -48,9 +46,9 @@ function verifyCore(expectation, options, context, root, result) { } function rule(expectation, options, context) { - return (root, result) => { + return async (root, result) => { if (root.source.lang !== "stylus") { - verifyCore(expectation, options, context, root, result) + await verifyCore(expectation, options, context, root, result) return } const validOptions = validateOptions( diff --git a/lib/rules/semicolon.js b/lib/rules/semicolon.js index 943e246..17c52e3 100644 --- a/lib/rules/semicolon.js +++ b/lib/rules/semicolon.js @@ -3,8 +3,8 @@ const { utils: { ruleMessages, validateOptions, report }, } = require("stylelint") -const hasBlock = require("stylelint/lib/utils/hasBlock") const { inCssLiteral, isObjectProperty } = require("../utils/nodes") +const { hasBlock } = require("../utils/ast") const ruleName = "stylus/semicolon" diff --git a/lib/utils/ast.js b/lib/utils/ast.js new file mode 100644 index 0000000..c708ccd --- /dev/null +++ b/lib/utils/ast.js @@ -0,0 +1,596 @@ +"use strict" + +const { isStandardSyntaxSelector } = require("./text") + +/** @typedef {import('postcss').Node} Node */ +/** @typedef {import('postcss').Source} NodeSource */ + +const HAS_EMPTY_LINE = /\n[\t\r ]*\n/ + +module.exports = { + hasBlock, + hasEmptyBlock, + getPreviousNonSharedLineCommentNode, + hasEmptyLine, + isAfterComment, + isSharedLineComment, + getNextNonSharedLineCommentNode, + isBlocklessAtRuleAfterBlocklessAtRule, + isBlocklessAtRuleAfterSameNameBlocklessAtRule, + isFirstNested, + isFirstNodeOfRoot, + isStandardSyntaxAtRule, + isStandardSyntaxRule, + declarationValueIndex, + isStandardSyntaxComment, + atRuleParamIndex, + + blockString, + rawNodeString, + beforeBlockString, + getDeclarationValue, + + isRoot, + isRule, + isAtRule, + isComment, + isDeclaration, + isDocument, + isValueFunction, + hasSource, +} + +/** + * Check if a statement has an block (empty or otherwise). + * + * @param {import('postcss').Container} statement + * @return {boolean} True if `statement` has a block (empty or otherwise) + */ +function hasBlock(statement) { + return statement.nodes !== undefined +} + +/** + * Check if a statement has an empty block. + * + * @param {import('postcss').Rule | import('postcss').AtRule} statement - postcss rule or at-rule node + * @return {boolean} True if the statement has a block and it is empty + */ +function hasEmptyBlock(statement) { + return hasBlock(statement) && statement.nodes.length === 0 +} + +/** + * Check if a string contains at least one empty line + * + * @param {string | undefined} string + * @returns {boolean} + */ +function hasEmptyLine(string) { + if (string === "" || string === undefined) return false + + return HAS_EMPTY_LINE.test(string) +} + +/** + * @param {Node} node + */ +function getNodeLine(node) { + return node.source && node.source.start && node.source.start.line +} + +/** + * @param {Node | undefined} node + * @returns {Node | undefined} + */ +function getPreviousNonSharedLineCommentNode(node) { + if (node === undefined) { + return undefined + } + + const previousNode = node.prev() + + if (!previousNode || previousNode.type !== "comment") { + return previousNode + } + + if (getNodeLine(node) === getNodeLine(previousNode)) { + return getPreviousNonSharedLineCommentNode(previousNode) + } + + const previousNode2 = previousNode.prev() + + if ( + previousNode2 && + getNodeLine(previousNode) === getNodeLine(previousNode2) + ) { + return getPreviousNonSharedLineCommentNode(previousNode) + } + + return previousNode +} + +/** + * @param {import('postcss').Node} node + */ +function isAfterComment(node) { + const previousNode = node.prev() + + if (!previousNode || previousNode.type !== "comment") { + return false + } + + return !isSharedLineComment(previousNode) +} + +/** @typedef {import('postcss').Node} PostcssNode */ + +/** + * + * @param {PostcssNode | void} a + * @param {PostcssNode | void} b + */ +function nodesShareLines(a, b) { + const endLine = a && a.source && a.source.end && a.source.end.line + const startLine = b && b.source && b.source.start && b.source.start.line + + return endLine === startLine +} + +/** + * @param {PostcssNode} node + * @returns {boolean} + */ +function isSharedLineComment(node) { + if (!isComment(node)) { + return false + } + + const previousNonSharedLineCommentNode = + getPreviousNonSharedLineCommentNode(node) + + if (nodesShareLines(previousNonSharedLineCommentNode, node)) { + return true + } + + const nextNonSharedLineCommentNode = getNextNonSharedLineCommentNode(node) + + if ( + nextNonSharedLineCommentNode && + nodesShareLines(node, nextNonSharedLineCommentNode) + ) { + return true + } + + const parentNode = node.parent + + // It's a first child and located on the same line as block start + if ( + parentNode !== undefined && + !isRoot(parentNode) && + parentNode.index(node) === 0 && + node.raws.before !== undefined && + !node.raws.before.includes("\n") + ) { + return true + } + + return false +} + +/** + * @param {Node | void} node + * @returns {Node | void} + */ +function getNextNonSharedLineCommentNode(node) { + if (node === undefined) { + return undefined + } + + /** @type {Node | void} */ + const nextNode = node.next() + + if (!nextNode || nextNode.type !== "comment") { + return nextNode + } + + if ( + getNodeLine(node) === getNodeLine(nextNode) || + getNodeLine(nextNode) === getNodeLine(nextNode.next()) + ) { + return getNextNonSharedLineCommentNode(nextNode) + } + + return nextNode +} + +/** + * @param {Node} node + * @returns {node is import('postcss').Root} + */ +function isRoot(node) { + return node.type === "root" +} + +/** + * @param {Node} node + * @returns {node is import('postcss').Rule} + */ +function isRule(node) { + return node.type === "rule" +} + +/** + * @param {Node} node + * @returns {node is import('postcss').AtRule} + */ +function isAtRule(node) { + return node.type === "atrule" +} + +/** + * @param {Node} node + * @returns {node is import('postcss').Comment} + */ +function isComment(node) { + return node.type === "comment" +} + +/** + * @param {Node} node + * @returns {node is import('postcss').Declaration} + */ +function isDeclaration(node) { + return node.type === "decl" +} + +/** + * @param {Node} node + * @returns {node is import('postcss').Document} + */ +function isDocument(node) { + return node.type === "document" +} + +/** + * @param {import('postcss-value-parser').Node} node + * @returns {node is import('postcss-value-parser').FunctionNode} + */ +function isValueFunction(node) { + return node.type === "function" +} + +/** + * @param {Node} node + * @returns {node is (Node & {source: NodeSource})} + */ +function hasSource(node) { + return Boolean(node.source) +} + +/** + * @param {import('postcss').AtRule} atRule + * @returns {boolean} + */ +function isBlocklessAtRuleAfterBlocklessAtRule(atRule) { + if (atRule.type !== "atrule") { + return false + } + + const previousNode = getPreviousNonSharedLineCommentNode(atRule) + + if (previousNode === undefined) { + return false + } + + return ( + isAtRule(previousNode) && !hasBlock(previousNode) && !hasBlock(atRule) + ) +} + +/** + * @param {import('postcss').AtRule} atRule + * @returns {boolean} + */ +function isBlocklessAtRuleAfterSameNameBlocklessAtRule(atRule) { + if (!isBlocklessAtRuleAfterBlocklessAtRule(atRule)) { + return false + } + + const previousNode = getPreviousNonSharedLineCommentNode(atRule) + + if (previousNode && isAtRule(previousNode)) { + return previousNode.name === atRule.name + } + + return false +} + +/** + * @param {import('postcss').Node} statement + * @returns {boolean} + */ +function isFirstNested(statement) { + const parentNode = statement.parent + + if (parentNode === undefined) { + return false + } + + if (isRoot(parentNode) && !isInDocument(parentNode)) { + return false + } + + if (statement === parentNode.first) { + return true + } + + // Search for the statement in the parent's nodes, ignoring comment + // nodes on the same line as the parent's opening brace. + + const parentNodes = parentNode.nodes + + if (!parentNodes) { + return false + } + + const firstNode = parentNodes[0] + + if (!firstNode) { + return false + } + + if ( + !isComment(firstNode) || + (typeof firstNode.raws.before === "string" && + firstNode.raws.before.includes("\n")) + ) { + return false + } + + if (!hasSource(firstNode) || !firstNode.source.start) { + return false + } + + const openingBraceLine = firstNode.source.start.line + + if ( + !firstNode.source.end || + openingBraceLine !== firstNode.source.end.line + ) { + return false + } + + for (const [index, node] of parentNodes.entries()) { + if (index === 0) { + continue + } + + if (node === statement) { + return true + } + + if ( + !isComment(node) || + (hasSource(node) && + node.source.end && + node.source.end.line !== openingBraceLine) + ) { + return false + } + } + + /* istanbul ignore next: Should always return in the loop */ + return false +} + +/** + * @param {import('postcss').Node} node + * @returns {boolean} + */ +function isInDocument({ parent }) { + return Boolean(parent && isDocument(parent)) +} + +/** + * @param {import('postcss').Node} node + * @returns {boolean} + */ +function isFirstNodeOfRoot(node) { + if (isRoot(node)) return false + + const parentNode = node.parent + + if (!parentNode) { + return false + } + + return isRoot(parentNode) && node === parentNode.first +} + +/** + * Check whether a at-rule is standard + * + * @param {import('postcss').AtRule | import('postcss-less').AtRule} atRule postcss at-rule node + * @return {boolean} If `true`, the declaration is standard + */ +function isStandardSyntaxAtRule(atRule) { + // Ignore scss `@content` inside mixins + if (!atRule.nodes && atRule.params === "") { + return false + } + + // Ignore Less mixins + if ("mixin" in atRule && atRule.mixin) { + return false + } + + // Ignore Less detached ruleset `@detached-ruleset: { background: red; }; .top { @detached-ruleset(); }` + if ( + ("variable" in atRule && atRule.variable) || + (!atRule.nodes && + atRule.raws.afterName === "" && + atRule.params[0] === "(") + ) { + return false + } + + return true +} + +/** + * Check whether a Node is a standard rule + * + * @param {import('postcss').Rule | import('postcss-less').Rule} rule + * @returns {boolean} + */ +function isStandardSyntaxRule(rule) { + if (rule.type !== "rule") { + return false + } + + // Ignore Less &:extend rule + if ("extend" in rule && rule.extend) { + return false + } + + if (!isStandardSyntaxSelector(rule.selector)) { + return false + } + + return true +} + +/** + * Return a CSS statement's block -- the string that starts and `{` and ends with `}`. + * + * If the statement has no block (e.g. `@import url(foo.css);`), returns an empty string. + * + * @param {import('postcss').Container} statement + * @returns {string} + */ +function blockString(statement) { + if (!hasBlock(statement)) { + return "" + } + + return rawNodeString(statement).slice(beforeBlockString(statement).length) +} + +/** + * Stringify PostCSS node including its raw "before" string. + * + * @param {import('postcss').Node} node + * + * @returns {string} + */ +function rawNodeString(node) { + let result = "" + + if (node.raws.before) { + result += node.raws.before + } + + result += node.toString() + + return result +} + +/** + * @param {import('postcss').Container} statement + * @returns {string} + */ +function beforeBlockString( + statement, + { noRawBefore } = { noRawBefore: false }, +) { + let result = "" + + const before = statement.raws.before || "" + + if (!noRawBefore) { + result += before + } + + if (isRule(statement)) { + result += statement.selector + } else if (isAtRule(statement)) { + result += `@${statement.name}${statement.raws.afterName || ""}${ + statement.params + }` + } else { + return "" + } + + result += statement.raws.between || "" + + return result +} + +/** + * @param {import('postcss').Declaration} decl + * @returns {string} + */ +function getDeclarationValue(decl) { + const raws = decl.raws + + return (raws.value && raws.value.raw) || decl.value +} + +function isObject(value) { + return value !== null && typeof value === "object" +} + +/** + * Get the index of a declaration's value + * + * @param {import('postcss').Declaration} decl + * @returns {number} + */ +function declarationValueIndex(decl) { + const raws = decl.raws + const prop = raws.prop + + return [ + isObject(prop) && "prefix" in prop && prop.prefix, + (isObject(prop) && "raw" in prop && prop.raw) || decl.prop, + isObject(prop) && "suffix" in prop && prop.suffix, + raws.between || ":", + raws.value && "prefix" in raws.value && raws.value.prefix, + ].reduce((/** @type {number} */ count, str) => { + if (typeof str === "string") { + return count + str.length + } + + return count + }, 0) +} + +/** + * @param {import('postcss').Comment} comment + * @returns {boolean} + */ +function isStandardSyntaxComment(comment) { + // We check both here because the Sass parser uses `raws.inline` to indicate + // inline comments, while the Less parser uses `inline`. + if ("inline" in comment) return false + + if ("inline" in comment.raws) return false + + return true +} + +/** + * @param {import('postcss').AtRule} atRule + * @returns {number} + */ +function atRuleParamIndex(atRule) { + // Initial 1 is for the `@` + let index = 1 + atRule.name.length + + if (atRule.raws.afterName) { + index += atRule.raws.afterName.length + } + + return index +} diff --git a/lib/utils/fix.js b/lib/utils/fix.js new file mode 100644 index 0000000..7936c27 --- /dev/null +++ b/lib/utils/fix.js @@ -0,0 +1,108 @@ +"use strict" +/** @typedef {import('postcss').Declaration} Declaration */ + +module.exports = { + addEmptyLineBefore, + removeEmptyLinesBefore, + addEmptyLineAfter, + removeEmptyLinesAfter, + setDeclarationValue, +} + +/** + * Add an empty line before a node. Mutates the node. + * + * @template {import('postcss').ChildNode} T + * @param {T} node + * @param {string} newline + * @returns {T} + */ +function addEmptyLineBefore(node, newline) { + const { raws } = node + + if (typeof raws.before !== "string") { + return node + } + + raws.before = !/\r?\n/.test(raws.before) + ? newline.repeat(2) + raws.before + : raws.before.replace(/(\r?\n)/, `${newline}$1`) + + return node +} + +/** + * Remove empty lines before a node. Mutates the node. + * + * @template {import('postcss').Node} T + * @param {T} node + * @param {string} newline + * @returns {T} + */ +function removeEmptyLinesBefore(node, newline) { + node.raws.before = node.raws.before + ? node.raws.before.replace(/(\r?\n\s*\n)+/g, newline) + : "" + + return node +} + +/** + * Add an empty line after a node. Mutates the node. + * + * @template {import('postcss').Rule | import('postcss').AtRule} T + * @param {T} node + * @param {string} newline + * @returns {T} + */ +function addEmptyLineAfter(node, newline) { + const { raws } = node + + if (typeof raws.after !== "string") { + return node + } + + const spaces = raws.after.split(";") + const after = spaces[spaces.length - 1] || "" + + if (!/\r?\n/.test(after)) { + raws.after += newline.repeat(2) + } else { + raws.after = raws.after.replace(/(\r?\n)/, `${newline}$1`) + } + + return node +} + +/** + * Remove empty lines before a node. Mutates the node. + * + * @template {import('postcss').Rule | import('postcss').AtRule} T + * @param {T} node + * @param {string} newline + * @returns {T} + */ +function removeEmptyLinesAfter(node, newline) { + node.raws.after = node.raws.after + ? node.raws.after.replace(/(\r?\n\s*\n)+/g, newline) + : "" + + return node +} + +/** + * @param {Declaration} decl + * @param {string} value + * @returns {Declaration} The declaration that was passed in. + */ +function setDeclarationValue(decl, value) { + const raws = decl.raws + + if (raws.value) { + raws.value.raw = value + } else { + decl.value = value + } + + return decl +} diff --git a/lib/utils/index.js b/lib/utils/index.js deleted file mode 100644 index ceb28af..0000000 --- a/lib/utils/index.js +++ /dev/null @@ -1,9 +0,0 @@ -"use strict" - -module.exports = { - isSingleLineString, -} - -function isSingleLineString(input) { - return !/[\n\r]/u.test(input) -} diff --git a/lib/utils/option.js b/lib/utils/option.js new file mode 100644 index 0000000..7e5b7e1 --- /dev/null +++ b/lib/utils/option.js @@ -0,0 +1,158 @@ +"use strict" + +module.exports = { optionsMatches, isString, isRegExp, isNumber, isBoolean } + +/** + * Checks if the value is a string or a String object. + * @param {unknown} value + * @returns {value is string} + */ +function isString(value) { + return typeof value === "string" +} + +/** + * Checks if the value is a number or a Number object. + * @param {unknown} value + * @returns {value is number} + */ +function isNumber(value) { + return typeof value === "number" +} + +/** + * Checks if the value is a boolean or a Boolean object. + * @param {unknown} value + * @returns {value is boolean} + */ +function isBoolean(value) { + return typeof value === "boolean" +} + +/** + * Checks if the value is a regular expression. + * @param {unknown} value + * @returns {value is RegExp} + */ +function isRegExp(value) { + return value instanceof RegExp +} + +/** + * Check if an options object's propertyName contains a user-defined string or + * regex that matches the passed in input. + * + * @param {{ [x: string]: any; }} options + * @param {string} propertyName + * @param {unknown} input + * + * @returns {boolean} + */ +function optionsMatches(options, propertyName, input) { + return Boolean( + options && + options[propertyName] && + typeof input === "string" && + matchesStringOrRegExp(input, options[propertyName]), + ) +} + +/** + * Compares a string to a second value that, if it fits a certain convention, + * is converted to a regular expression before the comparison. + * If it doesn't fit the convention, then two strings are compared. + * + * Any strings starting and ending with `/` are interpreted + * as regular expressions. + * + * @param {string | Array} input + * @param {string | RegExp | Array} comparison + * + * @returns {false | {match: string, pattern: (string | RegExp), substring: string}} + */ +function matchesStringOrRegExp(input, comparison) { + if (!Array.isArray(input)) { + return testAgainstStringOrRegExpOrArray(input, comparison) + } + + for (const inputItem of input) { + const testResult = testAgainstStringOrRegExpOrArray( + inputItem, + comparison, + ) + + if (testResult) { + return testResult + } + } + + return false +} + +/** + * @param {string} value + * @param {string | RegExp | Array} comparison + */ +function testAgainstStringOrRegExpOrArray(value, comparison) { + if (!Array.isArray(comparison)) { + return testAgainstStringOrRegExp(value, comparison) + } + + for (const comparisonItem of comparison) { + const testResult = testAgainstStringOrRegExp(value, comparisonItem) + + if (testResult) { + return testResult + } + } + + return false +} + +/** + * @param {string} value + * @param {string | RegExp} comparison + */ +function testAgainstStringOrRegExp(value, comparison) { + // If it's a RegExp, test directly + if (comparison instanceof RegExp) { + const match = comparison.exec(value) + + return match + ? { match: value, pattern: comparison, substring: match[0] || "" } + : false + } + + // Check if it's RegExp in a string + const firstComparisonChar = comparison[0] + const lastComparisonChar = comparison[comparison.length - 1] + const secondToLastComparisonChar = comparison[comparison.length - 2] + + const comparisonIsRegex = + firstComparisonChar === "/" && + (lastComparisonChar === "/" || + (secondToLastComparisonChar === "/" && lastComparisonChar === "i")) + + const hasCaseInsensitiveFlag = + comparisonIsRegex && lastComparisonChar === "i" + + // If so, create a new RegExp from it + if (comparisonIsRegex) { + const valueMatch = hasCaseInsensitiveFlag + ? new RegExp(comparison.slice(1, -2), "i").exec(value) + : new RegExp(comparison.slice(1, -1)).exec(value) + + return valueMatch + ? { + match: value, + pattern: comparison, + substring: valueMatch[0] || "", + } + : false + } + + // Otherwise, it's a string. Do a strict comparison + return value === comparison + ? { match: value, pattern: comparison, substring: value } + : false +} diff --git a/lib/utils/reference/selector.js b/lib/utils/reference/selector.js new file mode 100644 index 0000000..2d8f948 --- /dev/null +++ b/lib/utils/reference/selector.js @@ -0,0 +1,66 @@ +"use strict" + +const htmlTags = require("html-tags") + +function uniteSets(...args) { + return new Set([...args].reduce((result, set) => [...result, ...set], [])) +} + +const deprecatedHtmlTypeSelectors = new Set([ + "acronym", + "applet", + "basefont", + "big", + "blink", + "center", + "content", + "dir", + "font", + "frame", + "frameset", + "hgroup", + "isindex", + "keygen", + "listing", + "marquee", + "nobr", + "noembed", + "plaintext", + "spacer", + "strike", + "tt", + "xmp", +]) + +/** @type {Set} */ +const standardHtmlTypeSelectors = new Set(htmlTags) + +const htmlTypeSelectors = uniteSets( + deprecatedHtmlTypeSelectors, + standardHtmlTypeSelectors, +) +// These are the ones that can have single-colon notation +const levelOneAndTwoPseudoElements = new Set([ + "before", + "after", + "first-line", + "first-letter", +]) +const shadowTreePseudoElements = new Set(["part"]) +const aNPlusBNotationPseudoClasses = new Set([ + "nth-column", + "nth-last-column", + "nth-last-of-type", + "nth-of-type", +]) +const aNPlusBOfSNotationPseudoClasses = new Set(["nth-child", "nth-last-child"]) +const linguisticPseudoClasses = new Set(["dir", "lang"]) + +module.exports = { + htmlTypeSelectors, + levelOneAndTwoPseudoElements, + shadowTreePseudoElements, + aNPlusBNotationPseudoClasses, + aNPlusBOfSNotationPseudoClasses, + linguisticPseudoClasses, +} diff --git a/lib/utils/selector/selector-util.js b/lib/utils/selector/selector-util.js index 2c8a82b..b43d5f0 100644 --- a/lib/utils/selector/selector-util.js +++ b/lib/utils/selector/selector-util.js @@ -2,7 +2,12 @@ const { scopedTokens, isSkipToken } = require("../tokens") const { getLang } = require("../nodes") -const { selectors: selectorReferences } = require("../stylelint-internal") +const { + aNPlusBNotationPseudoClasses, + aNPlusBOfSNotationPseudoClasses, + linguisticPseudoClasses, + shadowTreePseudoElements, +} = require("../reference/selector") module.exports = { getSelectorTokens, @@ -92,18 +97,10 @@ function isStandardSyntaxTypeSelector(node) { .replace(/^:+/u, "") if ( - selectorReferences.aNPlusBNotationPseudoClasses.has( - normalizedParentName, - ) || - selectorReferences.aNPlusBOfSNotationPseudoClasses.has( - normalizedParentName, - ) || - selectorReferences.linguisticPseudoClasses.has( - normalizedParentName, - ) || - selectorReferences.shadowTreePseudoElements.has( - normalizedParentName, - ) + aNPlusBNotationPseudoClasses.has(normalizedParentName) || + aNPlusBOfSNotationPseudoClasses.has(normalizedParentName) || + linguisticPseudoClasses.has(normalizedParentName) || + shadowTreePseudoElements.has(normalizedParentName) ) { return false } diff --git a/lib/utils/stylelint-internal.js b/lib/utils/stylelint-internal.js deleted file mode 100644 index e813417..0000000 --- a/lib/utils/stylelint-internal.js +++ /dev/null @@ -1,35 +0,0 @@ -"use strict" - -const isKeyframeSelector = require("stylelint/lib/utils/isKeyframeSelector") -const optionsMatches = require("stylelint/lib/utils/optionsMatches") -const isCustomElement = require("stylelint/lib/utils/isCustomElement") -let selectors -try { - selectors = require("stylelint/lib/reference/selectors") -} catch (_e) { - // ignore -} -if (!selectors) { - // eslint-disable-next-line node/no-missing-require -- stylelint<14.10 - const keywordSets = require("stylelint/lib/reference/keywordSets") - const htmlTags = require("html-tags") - selectors = { - levelOneAndTwoPseudoElements: keywordSets.levelOneAndTwoPseudoElements, - htmlTypeSelectors: new Set([ - ...keywordSets.nonStandardHtmlTags, - ...htmlTags, - ]), - aNPlusBNotationPseudoClasses: keywordSets.aNPlusBNotationPseudoClasses, - aNPlusBOfSNotationPseudoClasses: - keywordSets.aNPlusBOfSNotationPseudoClasses, - linguisticPseudoClasses: keywordSets.linguisticPseudoClasses, - shadowTreePseudoElements: keywordSets.shadowTreePseudoElements, - } -} - -module.exports = { - isKeyframeSelector, - optionsMatches, - isCustomElement, - selectors, -} diff --git a/lib/utils/stylelint-v15/rules/at-rule-empty-line-before.js b/lib/utils/stylelint-v15/rules/at-rule-empty-line-before.js new file mode 100644 index 0000000..85e57e0 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/at-rule-empty-line-before.js @@ -0,0 +1,212 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const { + isFirstNodeOfRoot, + isStandardSyntaxAtRule, + isBlocklessAtRuleAfterBlocklessAtRule, + isFirstNested, + isBlocklessAtRuleAfterSameNameBlocklessAtRule, + isAfterComment, + hasEmptyLine, + getPreviousNonSharedLineCommentNode, + isAtRule, +} = require("../../ast") +const { optionsMatches, isString } = require("../../option") +const { removeEmptyLinesBefore, addEmptyLineBefore } = require("../../fix") + +const ruleName = "at-rule-empty-line-before" + +const messages = ruleMessages(ruleName, { + expected: "Expected empty line before at-rule", + rejected: "Unexpected empty line before at-rule", +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/at-rule-empty-line-before", + fixable: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, secondaryOptions, context) => { + return (root, result) => { + const validOptions = validateOptions( + result, + ruleName, + { + actual: primary, + possible: ["always", "never"], + }, + { + actual: secondaryOptions, + possible: { + except: [ + "after-same-name", + "inside-block", + "blockless-after-same-name-blockless", + "blockless-after-blockless", + "first-nested", + ], + ignore: [ + "after-comment", + "first-nested", + "inside-block", + "blockless-after-same-name-blockless", + "blockless-after-blockless", + ], + ignoreAtRules: [isString], + }, + optional: true, + }, + ) + + if (!validOptions) { + return + } + + /** @type {'always' | 'never'} */ + const expectation = primary + + root.walkAtRules((atRule) => { + const isNested = atRule.parent && atRule.parent.type !== "root" + + // Ignore the first node + if (isFirstNodeOfRoot(atRule)) { + return + } + + if (!isStandardSyntaxAtRule(atRule)) { + return + } + + // Return early if at-rule is to be ignored + if ( + optionsMatches(secondaryOptions, "ignoreAtRules", atRule.name) + ) { + return + } + + // Optionally ignore the expectation if the node is blockless + if ( + optionsMatches( + secondaryOptions, + "ignore", + "blockless-after-blockless", + ) && + isBlocklessAtRuleAfterBlocklessAtRule(atRule) + ) { + return + } + + // Optionally ignore the node if it is the first nested + if ( + optionsMatches(secondaryOptions, "ignore", "first-nested") && + isFirstNested(atRule) + ) { + return + } + + // Optionally ignore the expectation if the node is blockless + // and following another blockless at-rule with the same name + if ( + optionsMatches( + secondaryOptions, + "ignore", + "blockless-after-same-name-blockless", + ) && + isBlocklessAtRuleAfterSameNameBlocklessAtRule(atRule) + ) { + return + } + + // Optionally ignore the expectation if the node is inside a block + if ( + optionsMatches(secondaryOptions, "ignore", "inside-block") && + isNested + ) { + return + } + + // Optionally ignore the expectation if a comment precedes this node + if ( + optionsMatches(secondaryOptions, "ignore", "after-comment") && + isAfterComment(atRule) + ) { + return + } + + const hasEmptyLineBefore = hasEmptyLine(atRule.raws.before) + let expectEmptyLineBefore = expectation === "always" + + // Optionally reverse the expectation if any exceptions apply + if ( + (optionsMatches( + secondaryOptions, + "except", + "after-same-name", + ) && + isAtRuleAfterSameNameAtRule(atRule)) || + (optionsMatches(secondaryOptions, "except", "inside-block") && + isNested) || + (optionsMatches(secondaryOptions, "except", "first-nested") && + isFirstNested(atRule)) || + (optionsMatches( + secondaryOptions, + "except", + "blockless-after-blockless", + ) && + isBlocklessAtRuleAfterBlocklessAtRule(atRule)) || + (optionsMatches( + secondaryOptions, + "except", + "blockless-after-same-name-blockless", + ) && + isBlocklessAtRuleAfterSameNameBlocklessAtRule(atRule)) + ) { + expectEmptyLineBefore = !expectEmptyLineBefore + } + + // Return if the expectation is met + if (expectEmptyLineBefore === hasEmptyLineBefore) { + return + } + + // Fix + if (context.fix && context.newline) { + if (expectEmptyLineBefore) { + addEmptyLineBefore(atRule, context.newline) + } else { + removeEmptyLinesBefore(atRule, context.newline) + } + + return + } + + const message = expectEmptyLineBefore + ? messages.expected + : messages.rejected + + report({ message, node: atRule, result, ruleName }) + }) + } +} + +/** + * @param {import('postcss').AtRule} atRule + */ +function isAtRuleAfterSameNameAtRule(atRule) { + const previousNode = getPreviousNonSharedLineCommentNode(atRule) + + return ( + previousNode && + isAtRule(previousNode) && + previousNode.name === atRule.name + ) +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/at-rule-name-space-after.js b/lib/utils/stylelint-v15/rules/at-rule-name-space-after.js new file mode 100644 index 0000000..07ebf38 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/at-rule-name-space-after.js @@ -0,0 +1,60 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions }, +} = require("stylelint") +const { + whitespaceChecker, + atRuleNameSpaceChecker, +} = require("../../whitespace-checker") + +const ruleName = "at-rule-name-space-after" + +const messages = ruleMessages(ruleName, { + expectedAfter: (name) => + `Expected single space after at-rule name "${name}"`, +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/at-rule-name-space-after", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, _secondary, context) => { + const checker = whitespaceChecker("space", primary, messages) + + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: ["always", "always-single-line"], + }) + + if (!validOptions) { + return + } + + atRuleNameSpaceChecker({ + root, + result, + locationChecker: checker.after, + checkedRuleName: ruleName, + fix: context.fix + ? (atRule) => { + if (typeof atRule.raws.afterName === "string") { + atRule.raws.afterName = atRule.raws.afterName.replace( + /^\s*/, + " ", + ) + } + } + : null, + }) + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/block-closing-brace-empty-line-before.js b/lib/utils/stylelint-v15/rules/block-closing-brace-empty-line-before.js new file mode 100644 index 0000000..6f677b3 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/block-closing-brace-empty-line-before.js @@ -0,0 +1,139 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const { + hasBlock, + hasEmptyBlock, + blockString, + hasEmptyLine, +} = require("../../ast") +const { optionsMatches } = require("../../option") +const { isSingleLineString } = require("../../text") +const { addEmptyLineAfter, removeEmptyLinesAfter } = require("../../fix") + +const ruleName = "block-closing-brace-empty-line-before" + +const messages = ruleMessages(ruleName, { + expected: "Expected empty line before closing brace", + rejected: "Unexpected empty line before closing brace", +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/block-closing-brace-empty-line-before", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, secondaryOptions, context) => { + return (root, result) => { + const validOptions = validateOptions( + result, + ruleName, + { + actual: primary, + possible: ["always-multi-line", "never"], + }, + { + actual: secondaryOptions, + possible: { + except: ["after-closing-brace"], + }, + optional: true, + }, + ) + + if (!validOptions) { + return + } + + // Check both kinds of statements: rules and at-rules + root.walkRules(check) + root.walkAtRules(check) + + /** + * @param {import('postcss').Rule | import('postcss').AtRule} statement + */ + function check(statement) { + // Return early if blockless or has empty block + if (!hasBlock(statement) || hasEmptyBlock(statement)) { + return + } + + // Get whitespace after ""}", ignoring extra semicolon + const before = (statement.raws.after || "").replace(/;+/, "") + + // Calculate index + const statementString = statement.toString() + let index = statementString.length - 1 + + if (statementString[index - 1] === "\r") { + index -= 1 + } + + // Set expectation + const expectEmptyLineBefore = (() => { + const childNodeTypes = statement.nodes.map((item) => item.type) + + // Reverse the primary options if `after-closing-brace` is set + if ( + optionsMatches( + secondaryOptions, + "except", + "after-closing-brace", + ) && + statement.type === "atrule" && + !childNodeTypes.includes("decl") + ) { + return primary === "never" + } + + return ( + primary === "always-multi-line" && + !isSingleLineString(blockString(statement)) + ) + })() + + // Check for at least one empty line + const hasEmptyLineBefore = hasEmptyLine(before) + + // Return if the expectation is met + if (expectEmptyLineBefore === hasEmptyLineBefore) { + return + } + + if (context.fix) { + const { newline } = context + + if (typeof newline !== "string") return + + if (expectEmptyLineBefore) { + addEmptyLineAfter(statement, newline) + } else { + removeEmptyLinesAfter(statement, newline) + } + + return + } + + const message = expectEmptyLineBefore + ? messages.expected + : messages.rejected + + report({ + message, + result, + ruleName, + node: statement, + index, + }) + } + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/block-closing-brace-newline-after.js b/lib/utils/stylelint-v15/rules/block-closing-brace-newline-after.js new file mode 100644 index 0000000..5fa5d40 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/block-closing-brace-newline-after.js @@ -0,0 +1,159 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const { whitespaceChecker } = require("../../whitespace-checker") +const { isString, optionsMatches } = require("../../option") +const { hasBlock, rawNodeString, blockString } = require("../../ast") + +const ruleName = "block-closing-brace-newline-after" + +const messages = ruleMessages(ruleName, { + expectedAfter: () => 'Expected newline after "}"', + expectedAfterSingleLine: () => + 'Expected newline after "}" of a single-line block', + rejectedAfterSingleLine: () => + 'Unexpected whitespace after "}" of a single-line block', + expectedAfterMultiLine: () => + 'Expected newline after "}" of a multi-line block', + rejectedAfterMultiLine: () => + 'Unexpected whitespace after "}" of a multi-line block', +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/block-closing-brace-newline-after", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, secondaryOptions, context) => { + const checker = whitespaceChecker("newline", primary, messages) + + return (root, result) => { + const validOptions = validateOptions( + result, + ruleName, + { + actual: primary, + possible: [ + "always", + "always-single-line", + "never-single-line", + "always-multi-line", + "never-multi-line", + ], + }, + { + actual: secondaryOptions, + possible: { + ignoreAtRules: [isString], + }, + optional: true, + }, + ) + + if (!validOptions) { + return + } + + // Check both kinds of statements: rules and at-rules + root.walkRules(check) + root.walkAtRules(check) + + /** + * @param {import('postcss').Rule | import('postcss').AtRule} statement + */ + function check(statement) { + if (!hasBlock(statement)) { + return + } + + if ( + statement.type === "atrule" && + optionsMatches( + secondaryOptions, + "ignoreAtRules", + statement.name, + ) + ) { + return + } + + const nextNode = statement.next() + + if (!nextNode) { + return + } + + // Allow an end-of-line comment x spaces after the brace + const nextNodeIsSingleLineComment = + nextNode.type === "comment" && + !/[^ ]/.test(nextNode.raws.before || "") && + !nextNode.toString().includes("\n") + + const nodeToCheck = nextNodeIsSingleLineComment + ? nextNode.next() + : nextNode + + if (!nodeToCheck) { + return + } + + let reportIndex = statement.toString().length + let source = rawNodeString(nodeToCheck) + + // Skip a semicolon at the beginning, if any + if (source && source.startsWith(";")) { + source = source.slice(1) + reportIndex++ + } + + // Only check one after, because there might be other + // spaces handled by the indentation rule + checker.afterOneOnly({ + source, + index: -1, + lineCheckStr: blockString(statement), + err: (msg) => { + if (context.fix) { + const nodeToCheckRaws = nodeToCheck.raws + + if (typeof nodeToCheckRaws.before !== "string") return + + if (primary.startsWith("always")) { + const index = nodeToCheckRaws.before.search(/\r?\n/) + + nodeToCheckRaws.before = + index >= 0 + ? nodeToCheckRaws.before.slice(index) + : context.newline + nodeToCheckRaws.before + + return + } + + if (primary.startsWith("never")) { + nodeToCheckRaws.before = "" + + return + } + } + + report({ + message: msg, + node: statement, + index: reportIndex, + result, + ruleName, + }) + }, + }) + } + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/block-closing-brace-newline-before.js b/lib/utils/stylelint-v15/rules/block-closing-brace-newline-before.js new file mode 100644 index 0000000..6995dc5 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/block-closing-brace-newline-before.js @@ -0,0 +1,151 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const { hasBlock, hasEmptyBlock, blockString } = require("../../ast") +const { isSingleLineString } = require("../../text") + +const ruleName = "block-closing-brace-newline-before" + +const messages = ruleMessages(ruleName, { + expectedBefore: 'Expected newline before "}"', + expectedBeforeMultiLine: + 'Expected newline before "}" of a multi-line block', + rejectedBeforeMultiLine: + 'Unexpected whitespace before "}" of a multi-line block', +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/block-closing-brace-newline-before", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, _secondaryOptions, context) => { + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: ["always", "always-multi-line", "never-multi-line"], + }) + + if (!validOptions) { + return + } + + // Check both kinds of statements: rules and at-rules + root.walkRules(check) + root.walkAtRules(check) + + /** + * @param {import('postcss').Rule | import('postcss').AtRule} statement + */ + function check(statement) { + // Return early if blockless or has empty block + if (!hasBlock(statement) || hasEmptyBlock(statement)) { + return + } + + // Ignore extra semicolon + const after = (statement.raws.after || "").replace(/;+/, "") + + if (after === undefined) { + return + } + + const blockIsMultiLine = !isSingleLineString(blockString(statement)) + const statementString = statement.toString() + + let index = statementString.length - 2 + + if (statementString[index - 1] === "\r") { + index -= 1 + } + + // We're really just checking whether a + // newline *starts* the block's final space -- between + // the last declaration and the closing brace. We can + // ignore any other whitespace between them, because that + // will be checked by the indentation rule. + if (!after.startsWith("\n") && !after.startsWith("\r\n")) { + if (primary === "always") { + complain(messages.expectedBefore) + } else if ( + blockIsMultiLine && + primary === "always-multi-line" + ) { + complain(messages.expectedBeforeMultiLine) + } + } + + if ( + after !== "" && + blockIsMultiLine && + primary === "never-multi-line" + ) { + complain(messages.rejectedBeforeMultiLine) + } + + /** + * @param {string} message + */ + function complain(message) { + if (context.fix) { + const statementRaws = statement.raws + + if (typeof statementRaws.after !== "string") return + + if (primary.startsWith("always")) { + const firstWhitespaceIndex = + statementRaws.after.search(/\s/) + const newlineBefore = + firstWhitespaceIndex >= 0 + ? statementRaws.after.slice( + 0, + firstWhitespaceIndex, + ) + : statementRaws.after + const newlineAfter = + firstWhitespaceIndex >= 0 + ? statementRaws.after.slice( + firstWhitespaceIndex, + ) + : "" + const newlineIndex = newlineAfter.search(/\r?\n/) + + statementRaws.after = + newlineIndex >= 0 + ? newlineBefore + + newlineAfter.slice(newlineIndex) + : newlineBefore + context.newline + newlineAfter + + return + } + + if (primary === "never-multi-line") { + statementRaws.after = statementRaws.after.replace( + /\s/g, + "", + ) + + return + } + } + + report({ + message, + result, + ruleName, + node: statement, + index, + }) + } + } + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/block-closing-brace-space-after.js b/lib/utils/stylelint-v15/rules/block-closing-brace-space-after.js new file mode 100644 index 0000000..d1feece --- /dev/null +++ b/lib/utils/stylelint-v15/rules/block-closing-brace-space-after.js @@ -0,0 +1,98 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const { whitespaceChecker } = require("../../whitespace-checker") +const { hasBlock, rawNodeString, blockString } = require("../../ast") + +const ruleName = "block-closing-brace-space-after" + +const messages = ruleMessages(ruleName, { + expectedAfter: () => 'Expected single space after "}"', + rejectedAfter: () => 'Unexpected whitespace after "}"', + expectedAfterSingleLine: () => + 'Expected single space after "}" of a single-line block', + rejectedAfterSingleLine: () => + 'Unexpected whitespace after "}" of a single-line block', + expectedAfterMultiLine: () => + 'Expected single space after "}" of a multi-line block', + rejectedAfterMultiLine: () => + 'Unexpected whitespace after "}" of a multi-line block', +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/block-closing-brace-space-after", + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary) => { + const checker = whitespaceChecker("space", primary, messages) + + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: [ + "always", + "never", + "always-single-line", + "never-single-line", + "always-multi-line", + "never-multi-line", + ], + }) + + if (!validOptions) { + return + } + + // Check both kinds of statements: rules and at-rules + root.walkRules(check) + root.walkAtRules(check) + + /** + * @param {import('postcss').Rule | import('postcss').AtRule} statement + */ + function check(statement) { + const nextNode = statement.next() + + if (!nextNode) { + return + } + + if (!hasBlock(statement)) { + return + } + + let reportIndex = statement.toString().length + let source = rawNodeString(nextNode) + + // Skip a semicolon at the beginning, if any + if (source && source.startsWith(";")) { + source = source.slice(1) + reportIndex++ + } + + checker.after({ + source, + index: -1, + lineCheckStr: blockString(statement), + err: (msg) => { + report({ + message: msg, + node: statement, + index: reportIndex, + result, + ruleName, + }) + }, + }) + } + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/block-closing-brace-space-before.js b/lib/utils/stylelint-v15/rules/block-closing-brace-space-before.js new file mode 100644 index 0000000..5662bcf --- /dev/null +++ b/lib/utils/stylelint-v15/rules/block-closing-brace-space-before.js @@ -0,0 +1,117 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const { whitespaceChecker } = require("../../whitespace-checker") +const { hasBlock, hasEmptyBlock, blockString } = require("../../ast") + +const ruleName = "block-closing-brace-space-before" + +const messages = ruleMessages(ruleName, { + expectedBefore: () => 'Expected single space before "}"', + rejectedBefore: () => 'Unexpected whitespace before "}"', + expectedBeforeSingleLine: () => + 'Expected single space before "}" of a single-line block', + rejectedBeforeSingleLine: () => + 'Unexpected whitespace before "}" of a single-line block', + expectedBeforeMultiLine: () => + 'Expected single space before "}" of a multi-line block', + rejectedBeforeMultiLine: () => + 'Unexpected whitespace before "}" of a multi-line block', +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/block-closing-brace-space-before", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, _secondaryOptions, context) => { + const checker = whitespaceChecker("space", primary, messages) + + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: [ + "always", + "never", + "always-single-line", + "never-single-line", + "always-multi-line", + "never-multi-line", + ], + }) + + if (!validOptions) { + return + } + + // Check both kinds of statement: rules and at-rules + root.walkRules(check) + root.walkAtRules(check) + + /** + * @param {import('postcss').Rule | import('postcss').AtRule} statement + */ + function check(statement) { + // Return early if blockless or has empty block + if (!hasBlock(statement) || hasEmptyBlock(statement)) { + return + } + + const source = blockString(statement) + const statementString = statement.toString() + + let index = statementString.length - 2 + + if (statementString[index - 1] === "\r") { + index -= 1 + } + + checker.before({ + source, + index: source.length - 1, + err: (msg) => { + if (context.fix) { + const statementRaws = statement.raws + + if (typeof statementRaws.after !== "string") return + + if (primary.startsWith("always")) { + statementRaws.after = statementRaws.after.replace( + /\s*$/, + " ", + ) + + return + } + + if (primary.startsWith("never")) { + statementRaws.after = statementRaws.after.replace( + /\s*$/, + "", + ) + + return + } + } + + report({ + message: msg, + node: statement, + index, + result, + ruleName, + }) + }, + }) + } + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/block-opening-brace-newline-after.js b/lib/utils/stylelint-v15/rules/block-opening-brace-newline-after.js new file mode 100644 index 0000000..ceafb91 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/block-opening-brace-newline-after.js @@ -0,0 +1,209 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const { whitespaceChecker } = require("../../whitespace-checker") +const { optionsMatches } = require("../../option") +const { + hasBlock, + hasEmptyBlock, + rawNodeString, + blockString, + beforeBlockString, +} = require("../../ast") + +const ruleName = "block-opening-brace-newline-after" + +const messages = ruleMessages(ruleName, { + expectedAfter: () => 'Expected newline after "{"', + expectedAfterMultiLine: () => + 'Expected newline after "{" of a multi-line block', + rejectedAfterMultiLine: () => + 'Unexpected whitespace after "{" of a multi-line block', +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/block-opening-brace-newline-after", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, secondaryOptions, context) => { + const checker = whitespaceChecker("newline", primary, messages) + + return (root, result) => { + const validOptions = validateOptions( + result, + ruleName, + { + actual: primary, + possible: [ + "always", + "rules", + "always-multi-line", + "never-multi-line", + ], + }, + { + actual: secondaryOptions, + possible: { + ignore: ["rules"], + }, + optional: true, + }, + ) + + if (!validOptions) { + return + } + + // Check both kinds of statement: rules and at-rules + if (!optionsMatches(secondaryOptions, "ignore", "rules")) { + root.walkRules(check) + } + + root.walkAtRules(check) + + /** + * @param {import('postcss').Rule | import('postcss').AtRule} statement + */ + function check(statement) { + // Return early if blockless or has an empty block + if (!hasBlock(statement) || hasEmptyBlock(statement)) { + return + } + + const backupCommentNextBefores = new Map() + + /** + * next node with checking newlines after comment + * + * @param {import('postcss').ChildNode | undefined} startNode + * @returns {import('postcss').ChildNode | undefined} + */ + function nextNode(startNode) { + if (!startNode || !startNode.next) return undefined + + if (startNode.type === "comment") { + const reNewLine = /\r?\n/ + const newLineMatch = reNewLine.test( + startNode.raws.before || "", + ) + + const next = startNode.next() + + if ( + next && + newLineMatch && + !reNewLine.test(next.raws.before || "") + ) { + backupCommentNextBefores.set(next, next.raws.before) + next.raws.before = startNode.raws.before + } + + return nextNode(next) + } + + return startNode + } + + // Allow an end-of-line comment + const nodeToCheck = nextNode(statement.first) + + if (!nodeToCheck) { + return + } + + checker.afterOneOnly({ + source: rawNodeString(nodeToCheck), + index: -1, + lineCheckStr: blockString(statement), + err: (m) => { + if (context.fix) { + const nodeToCheckRaws = nodeToCheck.raws + + if (typeof nodeToCheckRaws.before !== "string") return + + if (primary.startsWith("always")) { + const index = nodeToCheckRaws.before.search(/\r?\n/) + + nodeToCheckRaws.before = + index >= 0 + ? nodeToCheckRaws.before.slice(index) + : context.newline + nodeToCheckRaws.before + + backupCommentNextBefores.delete(nodeToCheck) + + return + } + + if (primary === "never-multi-line") { + // Restore the `before` of the node next to the comment node. + for (const [ + node, + before, + ] of backupCommentNextBefores.entries()) { + node.raws.before = before + } + + backupCommentNextBefores.clear() + + // Fix + const reNewLine = /\r?\n/ + let fixTarget = statement.first + + while (fixTarget) { + const fixTargetRaws = fixTarget.raws + + if (typeof fixTargetRaws.before !== "string") + continue + + if ( + reNewLine.test(fixTargetRaws.before || "") + ) { + fixTargetRaws.before = + fixTargetRaws.before.replace( + /\r?\n/g, + "", + ) + } + + if (fixTarget.type !== "comment") { + break + } + + fixTarget = fixTarget.next() + } + + nodeToCheckRaws.before = "" + + return + } + } + + report({ + message: m, + node: statement, + index: + beforeBlockString(statement, { noRawBefore: true }) + .length + 1, + result, + ruleName, + }) + }, + }) + + // Restore the `before` of the node next to the comment node. + for (const [node, before] of backupCommentNextBefores.entries()) { + node.raws.before = before + } + } + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/block-opening-brace-space-after.js b/lib/utils/stylelint-v15/rules/block-opening-brace-space-after.js new file mode 100644 index 0000000..fca80b2 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/block-opening-brace-space-after.js @@ -0,0 +1,124 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const { whitespaceChecker } = require("../../whitespace-checker") +const { optionsMatches } = require("../../option") +const { + hasBlock, + hasEmptyBlock, + blockString, + beforeBlockString, +} = require("../../ast") + +const ruleName = "block-opening-brace-space-after" + +const messages = ruleMessages(ruleName, { + expectedAfter: () => 'Expected single space after "{"', + rejectedAfter: () => 'Unexpected whitespace after "{"', + expectedAfterSingleLine: () => + 'Expected single space after "{" of a single-line block', + rejectedAfterSingleLine: () => + 'Unexpected whitespace after "{" of a single-line block', + expectedAfterMultiLine: () => + 'Expected single space after "{" of a multi-line block', + rejectedAfterMultiLine: () => + 'Unexpected whitespace after "{" of a multi-line block', +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/block-opening-brace-space-after", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, secondaryOptions, context) => { + const checker = whitespaceChecker("space", primary, messages) + + return (root, result) => { + const validOptions = validateOptions( + result, + ruleName, + { + actual: primary, + possible: [ + "always", + "never", + "always-single-line", + "never-single-line", + "always-multi-line", + "never-multi-line", + ], + }, + { + actual: secondaryOptions, + possible: { + ignore: ["at-rules"], + }, + optional: true, + }, + ) + + if (!validOptions) { + return + } + + // Check both kinds of statements: rules and at-rules + root.walkRules(check) + + if (!optionsMatches(secondaryOptions, "ignore", "at-rules")) { + root.walkAtRules(check) + } + + /** + * @param {import('postcss').Rule | import('postcss').AtRule} statement + */ + function check(statement) { + // Return early if blockless or has an empty block + if (!hasBlock(statement) || hasEmptyBlock(statement)) { + return + } + + checker.after({ + source: blockString(statement), + index: 0, + err: (m) => { + if (context.fix) { + const statementFirst = statement.first + + if (statementFirst == null) return + + if (primary.startsWith("always")) { + statementFirst.raws.before = " " + + return + } + + if (primary.startsWith("never")) { + statementFirst.raws.before = "" + + return + } + } + + report({ + message: m, + node: statement, + index: + beforeBlockString(statement, { noRawBefore: true }) + .length + 1, + result, + ruleName, + }) + }, + }) + } + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/block-opening-brace-space-before.js b/lib/utils/stylelint-v15/rules/block-opening-brace-space-before.js new file mode 100644 index 0000000..26d57dd --- /dev/null +++ b/lib/utils/stylelint-v15/rules/block-opening-brace-space-before.js @@ -0,0 +1,152 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const { whitespaceChecker } = require("../../whitespace-checker") +const { isString, isRegExp, optionsMatches } = require("../../option") +const { + hasBlock, + hasEmptyBlock, + beforeBlockString, + blockString, +} = require("../../ast") + +const ruleName = "block-opening-brace-space-before" + +const messages = ruleMessages(ruleName, { + expectedBefore: () => 'Expected single space before "{"', + rejectedBefore: () => 'Unexpected whitespace before "{"', + expectedBeforeSingleLine: () => + 'Expected single space before "{" of a single-line block', + rejectedBeforeSingleLine: () => + 'Unexpected whitespace before "{" of a single-line block', + expectedBeforeMultiLine: () => + 'Expected single space before "{" of a multi-line block', + rejectedBeforeMultiLine: () => + 'Unexpected whitespace before "{" of a multi-line block', +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/block-opening-brace-space-before", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, secondaryOptions, context) => { + const checker = whitespaceChecker("space", primary, messages) + + return (root, result) => { + const validOptions = validateOptions( + result, + ruleName, + { + actual: primary, + possible: [ + "always", + "never", + "always-single-line", + "never-single-line", + "always-multi-line", + "never-multi-line", + ], + }, + { + actual: secondaryOptions, + possible: { + ignoreAtRules: [isString, isRegExp], + ignoreSelectors: [isString, isRegExp], + }, + optional: true, + }, + ) + + if (!validOptions) { + return + } + + // Check both kinds of statements: rules and at-rules + root.walkRules(check) + root.walkAtRules(check) + + /** + * @param {import('postcss').Rule | import('postcss').AtRule} statement + */ + function check(statement) { + // Return early if blockless or has an empty block + if (!hasBlock(statement) || hasEmptyBlock(statement)) { + return + } + + // Return early if at-rule is to be ignored + if ( + statement.type === "atrule" && + optionsMatches( + secondaryOptions, + "ignoreAtRules", + statement.name, + ) + ) { + return + } + + // Return early if selector is to be ignored + if ( + statement.type === "rule" && + optionsMatches( + secondaryOptions, + "ignoreSelectors", + statement.selector, + ) + ) { + return + } + + const source = beforeBlockString(statement) + const beforeBraceNoRaw = beforeBlockString(statement, { + noRawBefore: true, + }) + + let index = beforeBraceNoRaw.length - 1 + + if (beforeBraceNoRaw[index - 1] === "\r") { + index -= 1 + } + + checker.before({ + source, + index: source.length, + lineCheckStr: blockString(statement), + err: (m) => { + if (context.fix) { + if (primary.startsWith("always")) { + statement.raws.between = " " + + return + } + + if (primary.startsWith("never")) { + statement.raws.between = "" + + return + } + } + + report({ + message: m, + node: statement, + index, + result, + ruleName, + }) + }, + }) + } + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/color-hex-case.js b/lib/utils/stylelint-v15/rules/color-hex-case.js new file mode 100644 index 0000000..c40ba66 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/color-hex-case.js @@ -0,0 +1,99 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const valueParser = require("postcss-value-parser") +const { getDeclarationValue, declarationValueIndex } = require("../../ast") +const { setDeclarationValue } = require("../../fix") + +const ruleName = "color-hex-case" + +const messages = ruleMessages(ruleName, { + expected: (actual, expected) => `Expected "${actual}" to be "${expected}"`, +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/color-hex-case", + fixable: true, + deprecated: true, +} + +const HEX = /^#[\da-z]+/i +const CONTAINS_HEX = /#[\da-z]+/i +const IGNORED_FUNCTIONS = new Set(["url"]) + +/** @type {import('stylelint').Rule} */ +const rule = (primary, _secondaryOptions, context) => { + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: ["lower", "upper"], + }) + + if (!validOptions) { + return + } + + root.walkDecls((decl) => { + if (!CONTAINS_HEX.test(decl.value)) return + + const parsedValue = valueParser(getDeclarationValue(decl)) + let needsFix = false + + parsedValue.walk((node) => { + const { value } = node + + if (isIgnoredFunction(node)) return false + + if (!isHexColor(node)) return undefined + + const expected = + primary === "lower" + ? value.toLowerCase() + : value.toUpperCase() + + if (value === expected) return undefined + + if (context.fix) { + node.value = expected + needsFix = true + + return undefined + } + + report({ + message: messages.expected(value, expected), + node: decl, + index: declarationValueIndex(decl) + node.sourceIndex, + result, + ruleName, + }) + return undefined + }) + + if (needsFix) { + setDeclarationValue(decl, parsedValue.toString()) + } + }) + } +} + +/** + * @param {import('postcss-value-parser').Node} node + */ +function isIgnoredFunction({ type, value }) { + return type === "function" && IGNORED_FUNCTIONS.has(value.toLowerCase()) +} + +/** + * @param {import('postcss-value-parser').Node} node + */ +function isHexColor({ type, value }) { + return type === "word" && HEX.test(value) +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/indentation.js b/lib/utils/stylelint-v15/rules/indentation.js new file mode 100644 index 0000000..2f70708 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/indentation.js @@ -0,0 +1,846 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const styleSearch = require("style-search") +const { + isNumber, + isBoolean, + isString, + optionsMatches, +} = require("../../option") +const { + isRoot, + isRule, + isAtRule, + hasBlock, + isDeclaration, + beforeBlockString, +} = require("../../ast") + +const ruleName = "indentation" +const messages = ruleMessages(ruleName, { + expected: (x) => `Expected indentation of ${x}`, +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/indentation", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, secondaryOptions = {}, context = {}) => { + return (root, result) => { + const validOptions = validateOptions( + result, + ruleName, + { + actual: primary, + possible: [isNumber, "tab"], + }, + { + actual: secondaryOptions, + possible: { + baseIndentLevel: [isNumber, "auto"], + except: ["block", "value", "param"], + ignore: ["value", "param", "inside-parens"], + indentInsideParens: [ + "twice", + "once-at-root-twice-in-block", + ], + indentClosingBrace: [isBoolean], + }, + optional: true, + }, + ) + + if (!validOptions) { + return + } + + const spaceCount = isNumber(primary) ? primary : null + const indentChar = spaceCount == null ? "\t" : " ".repeat(spaceCount) + const warningWord = primary === "tab" ? "tab" : "space" + + /** @type {number | 'auto'} */ + const baseIndentLevel = secondaryOptions.baseIndentLevel + /** @type {boolean} */ + const indentClosingBrace = secondaryOptions.indentClosingBrace + + /** + * @param {number} level + */ + const legibleExpectation = (level) => { + const count = spaceCount == null ? level : level * spaceCount + const quantifiedWarningWord = + count === 1 ? warningWord : `${warningWord}s` + + return `${count} ${quantifiedWarningWord}` + } + + // Cycle through all nodes using walk. + root.walk((node) => { + if (isRoot(node)) { + // Ignore nested template literals root in css-in-js lang + return + } + + const nodeLevel = indentationLevel(node) + + // Cut out any * and _ hacks from `before` + const before = (node.raws.before || "").replace(/[*_]$/, "") + const after = + typeof node.raws.after === "string" ? node.raws.after : "" + const parent = node.parent + + if (!parent) throw new Error("A parent node must be present") + + const expectedOpeningBraceIndentation = indentChar.repeat(nodeLevel) + + // Only inspect the spaces before the node + // if this is the first node in root + // or there is a newline in the `before` string. + // (If there is no newline before a node, + // there is no "indentation" to check.) + const isFirstChild = parent.type === "root" && parent.first === node + const lastIndexOfNewline = before.lastIndexOf("\n") + + // Inspect whitespace in the `before` string that is + // *after* the *last* newline character, + // because anything besides that is not indentation for this node: + // it is some other kind of separation, checked by some separate rule + if ( + (lastIndexOfNewline !== -1 || + (isFirstChild && + (!getDocument(parent) || + (parent.raws.codeBefore && + parent.raws.codeBefore.endsWith("\n"))))) && + before.slice(lastIndexOfNewline + 1) !== + expectedOpeningBraceIndentation + ) { + if (context.fix) { + if (isFirstChild && isString(node.raws.before)) { + node.raws.before = node.raws.before.replace( + /^[\t ]*(?=\S|$)/, + expectedOpeningBraceIndentation, + ) + } + + node.raws.before = fixIndentation( + node.raws.before, + expectedOpeningBraceIndentation, + ) + } else { + report({ + message: messages.expected( + legibleExpectation(nodeLevel), + ), + node, + result, + ruleName, + }) + } + } + + // Only blocks have the `after` string to check. + // Only inspect `after` strings that start with a newline; + // otherwise there's no indentation involved. + // And check `indentClosingBrace` to see if it should be indented an extra level. + const closingBraceLevel = indentClosingBrace + ? nodeLevel + 1 + : nodeLevel + const expectedClosingBraceIndentation = + indentChar.repeat(closingBraceLevel) + + if ( + (isRule(node) || isAtRule(node)) && + hasBlock(node) && + after && + after.includes("\n") && + after.slice(after.lastIndexOf("\n") + 1) !== + expectedClosingBraceIndentation + ) { + if (context.fix) { + node.raws.after = fixIndentation( + node.raws.after, + expectedClosingBraceIndentation, + ) + } else { + report({ + message: messages.expected( + legibleExpectation(closingBraceLevel), + ), + node, + index: node.toString().length - 1, + result, + ruleName, + }) + } + } + + // If this is a declaration, check the value + if (isDeclaration(node)) { + checkValue(node, nodeLevel) + } + + // If this is a rule, check the selector + if (isRule(node)) { + checkSelector(node, nodeLevel) + } + + // If this is an at rule, check the params + if (isAtRule(node)) { + checkAtRuleParams(node, nodeLevel) + } + }) + + /** + * @param {import('postcss').Node} node + * @param {number} level + * @returns {number} + */ + function indentationLevel(node, level = 0) { + if (!node.parent) throw new Error("A parent node must be present") + + if (isRoot(node.parent)) { + return ( + level + + getRootBaseIndentLevel( + node.parent, + baseIndentLevel, + primary, + ) + ) + } + + let calculatedLevel + + // Indentation level equals the ancestor nodes + // separating this node from root; so recursively + // run this operation + calculatedLevel = indentationLevel(node.parent, level + 1) + + // If `secondaryOptions.except` includes "block", + // blocks are taken down one from their calculated level + // (all blocks are the same level as their parents) + if ( + optionsMatches(secondaryOptions, "except", "block") && + (isRule(node) || isAtRule(node)) && + hasBlock(node) + ) { + calculatedLevel-- + } + + return calculatedLevel + } + + /** + * @param {import('postcss').Declaration} decl + * @param {number} declLevel + */ + function checkValue(decl, declLevel) { + if (!decl.value.includes("\n")) { + return + } + + if (optionsMatches(secondaryOptions, "ignore", "value")) { + return + } + + const declString = decl.toString() + const valueLevel = optionsMatches( + secondaryOptions, + "except", + "value", + ) + ? declLevel + : declLevel + 1 + + checkMultilineBit(declString, valueLevel, decl) + } + + /** + * @param {import('postcss').Rule} ruleNode + * @param {number} ruleLevel + */ + function checkSelector(ruleNode, ruleLevel) { + const selector = ruleNode.selector + + // Less mixins have params, and they should be indented extra + // @ts-expect-error -- TS2339: Property 'params' does not exist on type 'Rule'. + if (ruleNode.params) { + // eslint-disable-next-line no-param-reassign -- ignore + ruleLevel += 1 + } + + checkMultilineBit(selector, ruleLevel, ruleNode) + } + + /** + * @param {import('postcss').AtRule} atRule + * @param {number} ruleLevel + */ + function checkAtRuleParams(atRule, ruleLevel) { + if (optionsMatches(secondaryOptions, "ignore", "param")) { + return + } + + // @nest and SCSS's @at-root rules should be treated like regular rules, not expected + // to have their params (selectors) indented + const paramLevel = + optionsMatches(secondaryOptions, "except", "param") || + atRule.name === "nest" || + atRule.name === "at-root" + ? ruleLevel + : ruleLevel + 1 + + checkMultilineBit( + beforeBlockString(atRule).trim(), + paramLevel, + atRule, + ) + } + + /** + * @param {string} source + * @param {number} newlineIndentLevel + * @param {import('postcss').Node} node + */ + function checkMultilineBit(source, newlineIndentLevel, node) { + if (!source.includes("\n")) { + return + } + + // Data for current node fixing + /** @type {Array<{ expectedIndentation: string, currentIndentation: string, startIndex: number }>} */ + const fixPositions = [] + + // `outsideParens` because function arguments and also non-standard parenthesized stuff like + // Sass maps are ignored to allow for arbitrary indentation + let parentheticalDepth = 0 + + const ignoreInsideParans = optionsMatches( + secondaryOptions, + "ignore", + "inside-parens", + ) + + styleSearch( + { + source, + target: "\n", + // @ts-expect-error -- The `outsideParens` option is unsupported. Why? + outsideParens: ignoreInsideParans, + }, + (match, matchCount) => { + const precedesClosingParenthesis = /^[\t ]*\)/.test( + source.slice(match.startIndex + 1), + ) + + if ( + ignoreInsideParans && + (precedesClosingParenthesis || match.insideParens) + ) { + return + } + + let expectedIndentLevel = newlineIndentLevel + + // Modififications for parenthetical content + if (!ignoreInsideParans && match.insideParens) { + // If the first match in is within parentheses, reduce the parenthesis penalty + if (matchCount === 1) parentheticalDepth -= 1 + + // Account for windows line endings + let newlineIndex = match.startIndex + + if (source[match.startIndex - 1] === "\r") { + newlineIndex-- + } + + const followsOpeningParenthesis = /\([\t ]*$/.test( + source.slice(0, newlineIndex), + ) + + if (followsOpeningParenthesis) { + parentheticalDepth += 1 + } + + const followsOpeningBrace = /\{[\t ]*$/.test( + source.slice(0, newlineIndex), + ) + + if (followsOpeningBrace) { + parentheticalDepth += 1 + } + + const startingClosingBrace = /^[\t ]*\}/.test( + source.slice(match.startIndex + 1), + ) + + if (startingClosingBrace) { + parentheticalDepth -= 1 + } + + expectedIndentLevel += parentheticalDepth + + // Past this point, adjustments to parentheticalDepth affect next line + + if (precedesClosingParenthesis) { + parentheticalDepth -= 1 + } + + switch (secondaryOptions.indentInsideParens) { + case "twice": + if ( + !precedesClosingParenthesis || + indentClosingBrace + ) { + expectedIndentLevel += 1 + } + + break + case "once-at-root-twice-in-block": + if (node.parent === node.root()) { + if ( + precedesClosingParenthesis && + !indentClosingBrace + ) { + expectedIndentLevel -= 1 + } + + break + } + + if ( + !precedesClosingParenthesis || + indentClosingBrace + ) { + expectedIndentLevel += 1 + } + + break + default: + if ( + precedesClosingParenthesis && + !indentClosingBrace + ) { + expectedIndentLevel -= 1 + } + } + } + + // Starting at the index after the newline, we want to + // check that the whitespace characters (excluding newlines) before the first + // non-whitespace character equal the expected indentation + const afterNewlineSpaceMatches = /^([\t ]*)\S/.exec( + source.slice(match.startIndex + 1), + ) + + if (!afterNewlineSpaceMatches) { + return + } + + const afterNewlineSpace = afterNewlineSpaceMatches[1] || "" + const expectedIndentation = indentChar.repeat( + expectedIndentLevel > 0 ? expectedIndentLevel : 0, + ) + + if (afterNewlineSpace !== expectedIndentation) { + if (context.fix) { + // Adding fixes position in reverse order, because if we change indent in the beginning of the string it will break all following fixes for that string + fixPositions.unshift({ + expectedIndentation, + currentIndentation: afterNewlineSpace, + startIndex: match.startIndex, + }) + } else { + report({ + message: messages.expected( + legibleExpectation(expectedIndentLevel), + ), + node, + index: + match.startIndex + + afterNewlineSpace.length + + 1, + result, + ruleName, + }) + } + } + }, + ) + + if (fixPositions.length) { + if (isRule(node)) { + for (const fixPosition of fixPositions) { + node.selector = replaceIndentation( + node.selector, + fixPosition.currentIndentation, + fixPosition.expectedIndentation, + fixPosition.startIndex, + ) + } + } + + if (isDeclaration(node)) { + const declProp = node.prop + const declBetween = node.raws.between + + if (!isString(declBetween)) { + throw new TypeError( + "The `between` property must be a string", + ) + } + + for (const fixPosition of fixPositions) { + if ( + fixPosition.startIndex < + declProp.length + declBetween.length + ) { + node.raws.between = replaceIndentation( + declBetween, + fixPosition.currentIndentation, + fixPosition.expectedIndentation, + fixPosition.startIndex - declProp.length, + ) + } else { + node.value = replaceIndentation( + node.value, + fixPosition.currentIndentation, + fixPosition.expectedIndentation, + fixPosition.startIndex - + declProp.length - + declBetween.length, + ) + } + } + } + + if (isAtRule(node)) { + const atRuleName = node.name + const atRuleAfterName = node.raws.afterName + const atRuleParams = node.params + + if (!isString(atRuleAfterName)) { + throw new TypeError( + "The `afterName` property must be a string", + ) + } + + for (const fixPosition of fixPositions) { + // 1 — it's a @ length + if ( + fixPosition.startIndex < + 1 + atRuleName.length + atRuleAfterName.length + ) { + node.raws.afterName = replaceIndentation( + atRuleAfterName, + fixPosition.currentIndentation, + fixPosition.expectedIndentation, + fixPosition.startIndex - atRuleName.length - 1, + ) + } else { + node.params = replaceIndentation( + atRuleParams, + fixPosition.currentIndentation, + fixPosition.expectedIndentation, + fixPosition.startIndex - + atRuleName.length - + atRuleAfterName.length - + 1, + ) + } + } + } + } + } + } +} + +/** + * @param {import('postcss').Root} root + * @param {number | 'auto'} baseIndentLevel + * @param {string} space + * @returns {number} + */ +function getRootBaseIndentLevel(root, baseIndentLevel, space) { + const document = getDocument(root) + + if (!document) { + return 0 + } + + if (!root.source) { + throw new Error("The root node must have a source") + } + + /** @type {import('postcss').Source & { baseIndentLevel?: number }} */ + const source = root.source + + const indentLevel = source.baseIndentLevel + + if (isNumber(indentLevel) && Number.isSafeInteger(indentLevel)) { + return indentLevel + } + + const newIndentLevel = inferRootIndentLevel(root, baseIndentLevel, () => + inferDocIndentSize(document, space), + ) + + source.baseIndentLevel = newIndentLevel + + return newIndentLevel +} + +/** + * @param {import('postcss').Node} node + */ +function getDocument(node) { + // @ts-expect-error -- TS2339: Property 'document' does not exist on type 'Node'. + const document = node.document + + if (document) { + return document + } + + const root = node.root() + + // @ts-expect-error -- TS2339: Property 'document' does not exist on type 'Node'. + return root && root.document +} + +/** + * @param {import('postcss').Document} document + * @param {string} space + * returns {number} + */ +function inferDocIndentSize(document, space) { + if (!document.source) + throw new Error("The document node must have a source") + + /** @type {import('postcss').Source & { indentSize?: number }} */ + const docSource = document.source + + let indentSize = docSource.indentSize + + if (isNumber(indentSize) && Number.isSafeInteger(indentSize)) { + return indentSize + } + + const source = document.source.input.css + const indents = source.match(/^ *(?=\S)/gm) + + if (indents) { + /** @type {Map} */ + const scores = new Map() + let lastIndentSize = 0 + let lastLeadingSpacesLength = 0 + + /** + * @param {number} leadingSpacesLength + */ + const vote = (leadingSpacesLength) => { + if (leadingSpacesLength) { + lastIndentSize = + Math.abs(leadingSpacesLength - lastLeadingSpacesLength) || + lastIndentSize + + if (lastIndentSize > 1) { + const score = scores.get(lastIndentSize) + + if (score) { + scores.set(lastIndentSize, score + 1) + } else { + scores.set(lastIndentSize, 1) + } + } + } else { + lastIndentSize = 0 + } + + lastLeadingSpacesLength = leadingSpacesLength + } + + for (const leadingSpaces of indents) { + vote(leadingSpaces.length) + } + + let bestScore = 0 + + for (const [indentSizeDate, score] of scores.entries()) { + if (score > bestScore) { + bestScore = score + indentSize = indentSizeDate + } + } + } + + indentSize = + Number(indentSize) || + (indents && indents[0] && indents[0].length) || + Number(space) || + 2 + docSource.indentSize = indentSize + + return indentSize +} + +/** + * @param {import('postcss').Root} root + * @param {number | 'auto'} baseIndentLevel + * @param {() => number} indentSize + * @returns {number} + */ +function inferRootIndentLevel(root, baseIndentLevel, indentSize) { + /** + * @param {string} indent + */ + function getIndentLevel(indent) { + const tabMatch = indent.match(/\t/g) + const tabCount = tabMatch ? tabMatch.length : 0 + + const spaceMatch = indent.match(/ /g) + const spaceCount = spaceMatch + ? Math.round(spaceMatch.length / indentSize()) + : 0 + + return tabCount + spaceCount + } + + let newBaseIndentLevel = 0 + + if (!isNumber(baseIndentLevel) || !Number.isSafeInteger(baseIndentLevel)) { + if (!root.source) throw new Error("The root node must have a source") + + let source = root.source.input.css + + source = source.replace(/^[^\n\r]+/, (firstLine) => { + const match = + root.raws.codeBefore && + /(?:^|\n)([\t ]*)$/.exec(root.raws.codeBefore) + + if (match) { + return match[1] + firstLine + } + + return "" + }) + + const indents = source.match(/^[\t ]*(?=\S)/gm) + + if (indents) { + return Math.min(...indents.map((indent) => getIndentLevel(indent))) + } + + newBaseIndentLevel = 1 + } else { + newBaseIndentLevel = baseIndentLevel + } + + const indents = [] + const foundIndents = + root.raws.codeBefore && /(?:^|\n)([\t ]*)\S/m.exec(root.raws.codeBefore) + + // The indent level of the CSS code block in non-CSS-like files is determined by the shortest indent of non-empty line. + if (foundIndents) { + let shortest = Number.MAX_SAFE_INTEGER + let i = 0 + + while (++i < foundIndents.length) { + const foundIndent = foundIndents[i] + + const current = getIndentLevel(foundIndent) + + if (current < shortest) { + shortest = current + + if (shortest === 0) { + break + } + } + } + + if (shortest !== Number.MAX_SAFE_INTEGER) { + indents.push(new Array(shortest).fill(" ").join("")) + } + } + + const after = root.raws.after + + if (after) { + let afterEnd + + if (after.endsWith("\n")) { + // @ts-expect-error -- TS2339: Property 'document' does not exist on type 'Root'. + const document = root.document + + if (document) { + const nextRoot = + document.nodes[document.nodes.indexOf(root) + 1] + + afterEnd = nextRoot + ? nextRoot.raws.codeBefore + : document.raws.codeAfter + } else { + // Nested root node in css-in-js lang + const parent = root.parent + + if (!parent) throw new Error("The root node must have a parent") + + const nextRoot = parent.nodes[parent.nodes.indexOf(root) + 1] + + afterEnd = nextRoot + ? nextRoot.raws.codeBefore + : root.raws.codeAfter + } + } else { + afterEnd = after + } + + if (afterEnd) indents.push(afterEnd.match(/^[\t ]*/)[0]) + } + + if (indents.length) { + return ( + Math.max(...indents.map((indent) => getIndentLevel(indent))) + + newBaseIndentLevel + ) + } + + return newBaseIndentLevel +} + +/** + * @param {string | undefined} str + * @param {string} whitespace + */ +function fixIndentation(str, whitespace) { + if (!isString(str)) { + return str + } + + return str.replace(/\n[\t ]*(?=\S|$)/g, `\n${whitespace}`) +} + +/** + * @param {string} input + * @param {string} searchString + * @param {string} replaceString + * @param {number} startIndex + */ +function replaceIndentation(input, searchString, replaceString, startIndex) { + const offset = startIndex + 1 + const stringStart = input.slice(0, offset) + const stringEnd = input.slice(offset + searchString.length) + + return stringStart + replaceString + stringEnd +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/no-eol-whitespace.js b/lib/utils/stylelint-v15/rules/no-eol-whitespace.js new file mode 100644 index 0000000..20f8eda --- /dev/null +++ b/lib/utils/stylelint-v15/rules/no-eol-whitespace.js @@ -0,0 +1,319 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const styleSearch = require("style-search") +const { isOnlyWhitespace } = require("../../text") +const { optionsMatches } = require("../../option") +const { + isAtRule, + isRule, + isDeclaration, + isComment, + isStandardSyntaxComment, +} = require("../../ast") + +const ruleName = "no-eol-whitespace" + +const messages = ruleMessages(ruleName, { + rejected: "Unexpected whitespace at end of line", +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/no-eol-whitespace", + fixable: true, + deprecated: true, +} + +const whitespacesToReject = new Set([" ", "\t"]) + +/** + * @param {string} str + * @returns {string} + */ +function fixString(str) { + return str.replace(/[\t ]+$/, "") +} + +/** + * @param {number} lastEOLIndex + * @param {string} string + * @param {{ ignoreEmptyLines: boolean, isRootFirst: boolean }} options + * @returns {number} + */ +function findErrorStartIndex( + lastEOLIndex, + string, + { ignoreEmptyLines, isRootFirst }, +) { + const eolWhitespaceIndex = lastEOLIndex - 1 + + // If the character before newline is not whitespace, ignore + if (!whitespacesToReject.has(string.charAt(eolWhitespaceIndex))) { + return -1 + } + + if (ignoreEmptyLines) { + // If there is only whitespace between the previous newline and + // this newline, ignore + const beforeNewlineIndex = string.lastIndexOf("\n", eolWhitespaceIndex) + + if (beforeNewlineIndex >= 0 || isRootFirst) { + const line = string.substring( + beforeNewlineIndex, + eolWhitespaceIndex, + ) + + if (isOnlyWhitespace(line)) { + return -1 + } + } + } + + return eolWhitespaceIndex +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, secondaryOptions, context) => { + return (root, result) => { + const validOptions = validateOptions( + result, + ruleName, + { + actual: primary, + }, + { + optional: true, + actual: secondaryOptions, + possible: { + ignore: ["empty-lines"], + }, + }, + ) + + if (!validOptions) { + return + } + + const ignoreEmptyLines = optionsMatches( + secondaryOptions, + "ignore", + "empty-lines", + ) + + if (context.fix) { + fix(root) + } + + const rootString = context.fix + ? root.toString() + : (root.source && root.source.input.css) || "" + + /** + * @param {number} index + */ + const reportFromIndex = (index) => { + report({ + message: messages.rejected, + node: root, + index, + result, + ruleName, + }) + } + + eachEolWhitespace(rootString, reportFromIndex, true) + + const errorIndex = findErrorStartIndex(rootString.length, rootString, { + ignoreEmptyLines, + isRootFirst: true, + }) + + if (errorIndex > -1) { + reportFromIndex(errorIndex) + } + + /** + * Iterate each whitespace at the end of each line of the given string. + * @param {string} string - the source code string + * @param {(index: number) => void} callback - callback the whitespace index at the end of each line. + * @param {boolean} isRootFirst - set `true` if the given string is the first token of the root. + * @returns {void} + */ + function eachEolWhitespace(string, callback, isRootFirst) { + styleSearch( + { + source: string, + target: ["\n", "\r"], + comments: "check", + }, + (match) => { + const index = findErrorStartIndex( + match.startIndex, + string, + { + ignoreEmptyLines, + isRootFirst, + }, + ) + + if (index > -1) { + callback(index) + } + }, + ) + } + + /** + * @param {import('postcss').Root} rootNode + */ + function fix(rootNode) { + let isRootFirst = true + + rootNode.walk((node) => { + fixText( + node.raws.before, + (fixed) => { + node.raws.before = fixed + }, + isRootFirst, + ) + isRootFirst = false + + if (isAtRule(node)) { + fixText(node.raws.afterName, (fixed) => { + node.raws.afterName = fixed + }) + + const rawsParams = node.raws.params + + if (rawsParams) { + fixText(rawsParams.raw, (fixed) => { + rawsParams.raw = fixed + }) + } else { + fixText(node.params, (fixed) => { + node.params = fixed + }) + } + } + + if (isRule(node)) { + const rawsSelector = node.raws.selector + + if (rawsSelector) { + fixText(rawsSelector.raw, (fixed) => { + rawsSelector.raw = fixed + }) + } else { + fixText(node.selector, (fixed) => { + node.selector = fixed + }) + } + } + + if (isAtRule(node) || isRule(node) || isDeclaration(node)) { + fixText(node.raws.between, (fixed) => { + node.raws.between = fixed + }) + } + + if (isDeclaration(node)) { + const rawsValue = node.raws.value + + if (rawsValue) { + fixText(rawsValue.raw, (fixed) => { + rawsValue.raw = fixed + }) + } else { + fixText(node.value, (fixed) => { + node.value = fixed + }) + } + } + + if (isComment(node)) { + fixText(node.raws.left, (fixed) => { + node.raws.left = fixed + }) + + if (!isStandardSyntaxComment(node)) { + node.raws.right = + node.raws.right && fixString(node.raws.right) + } else { + fixText(node.raws.right, (fixed) => { + node.raws.right = fixed + }) + } + + fixText(node.text, (fixed) => { + node.text = fixed + }) + } + + if (isAtRule(node) || isRule(node)) { + fixText(node.raws.after, (fixed) => { + node.raws.after = fixed + }) + } + }) + + fixText( + rootNode.raws.after, + (fixed) => { + rootNode.raws.after = fixed + }, + isRootFirst, + ) + + if (typeof rootNode.raws.after === "string") { + const lastEOL = Math.max( + rootNode.raws.after.lastIndexOf("\n"), + rootNode.raws.after.lastIndexOf("\r"), + ) + + if (lastEOL !== rootNode.raws.after.length - 1) { + rootNode.raws.after = + rootNode.raws.after.slice(0, lastEOL + 1) + + fixString(rootNode.raws.after.slice(lastEOL + 1)) + } + } + } + + /** + * @param {string | undefined} value + * @param {(text: string) => void} fixFn + * @param {boolean} isRootFirst + */ + function fixText(value, fixFn, isRootFirst = false) { + if (!value) { + return + } + + let fixed = "" + let lastIndex = 0 + + eachEolWhitespace( + value, + (index) => { + const newlineIndex = index + 1 + + fixed += fixString(value.slice(lastIndex, newlineIndex)) + lastIndex = newlineIndex + }, + isRootFirst, + ) + + if (lastIndex) { + fixed += value.slice(lastIndex) + fixFn(fixed) + } + } + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/number-leading-zero.js b/lib/utils/stylelint-v15/rules/number-leading-zero.js new file mode 100644 index 0000000..48eabb2 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/number-leading-zero.js @@ -0,0 +1,227 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const valueParser = require("postcss-value-parser") +const { + isAtRule, + atRuleParamIndex, + declarationValueIndex, +} = require("../../ast") + +const ruleName = "number-leading-zero" + +const messages = ruleMessages(ruleName, { + expected: "Expected a leading zero", + rejected: "Unexpected leading zero", +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/number-leading-zero", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, _secondaryOptions, context) => { + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: ["always", "never"], + }) + + if (!validOptions) { + return + } + + root.walkAtRules((atRule) => { + if (atRule.name.toLowerCase() === "import") { + return + } + + check(atRule, atRule.params) + }) + + root.walkDecls((decl) => check(decl, decl.value)) + + /** + * @param {import('postcss').AtRule | import('postcss').Declaration} node + * @param {string} value + */ + function check(node, value) { + /** @type {Array<{ startIndex: number, endIndex: number }>} */ + const neverFixPositions = [] + /** @type {Array<{ index: number }>} */ + const alwaysFixPositions = [] + + // Get out quickly if there are no periods + if (!value.includes(".")) { + return + } + + valueParser(value).walk((valueNode) => { + // Ignore `url` function + if ( + valueNode.type === "function" && + valueNode.value.toLowerCase() === "url" + ) { + return false + } + + // Ignore strings, comments, etc + if (valueNode.type !== "word") { + return undefined + } + + // Check leading zero + if (primary === "always") { + const match = /(?:\D|^)(\.\d+)/.exec(valueNode.value) + + if (match == null || match[0] == null || match[1] == null) { + return undefined + } + + // The regexp above consists of 2 capturing groups (or capturing parentheses). + // We need the index of the second group. This makes sanse when we have "-.5" as an input + // for regex. And we need the index of ".5". + const capturingGroupIndex = + match[0].length - match[1].length + + const index = + valueNode.sourceIndex + + match.index + + capturingGroupIndex + + if (context.fix) { + alwaysFixPositions.unshift({ + index, + }) + + return undefined + } + + const baseIndex = isAtRule(node) + ? atRuleParamIndex(node) + : declarationValueIndex(node) + + complain(messages.expected, node, baseIndex + index) + } + + if (primary === "never") { + const match = /(?:\D|^)(0+)(\.\d+)/.exec(valueNode.value) + + if ( + match == null || + match[0] == null || + match[1] == null || + match[2] == null + ) { + return undefined + } + + // The regexp above consists of 3 capturing groups (or capturing parentheses). + // We need the index of the second group. This makes sanse when we have "-00.5" + // as an input for regex. And we need the index of "00". + const capturingGroupIndex = + match[0].length - (match[1].length + match[2].length) + + const index = + valueNode.sourceIndex + + match.index + + capturingGroupIndex + + if (context.fix) { + neverFixPositions.unshift({ + startIndex: index, + // match[1].length is the length of our matched zero(s) + endIndex: index + match[1].length, + }) + + return undefined + } + + const baseIndex = isAtRule(node) + ? atRuleParamIndex(node) + : declarationValueIndex(node) + + complain(messages.rejected, node, baseIndex + index) + } + + return undefined + }) + + if (alwaysFixPositions.length) { + for (const fixPosition of alwaysFixPositions) { + const index = fixPosition.index + + if (isAtRule(node)) { + node.params = addLeadingZero(node.params, index) + } else { + node.value = addLeadingZero(node.value, index) + } + } + } + + if (neverFixPositions.length) { + for (const fixPosition of neverFixPositions) { + const startIndex = fixPosition.startIndex + const endIndex = fixPosition.endIndex + + if (isAtRule(node)) { + node.params = removeLeadingZeros( + node.params, + startIndex, + endIndex, + ) + } else { + node.value = removeLeadingZeros( + node.value, + startIndex, + endIndex, + ) + } + } + } + } + + /** + * @param {string} message + * @param {import('postcss').Node} node + * @param {number} index + */ + function complain(message, node, index) { + report({ + result, + ruleName, + message, + node, + index, + }) + } + } +} + +/** + * @param {string} input + * @param {number} index + * @returns {string} + */ +function addLeadingZero(input, index) { + return `${input.slice(0, index)}0${input.slice(index)}` +} + +/** + * @param {string} input + * @param {number} startIndex + * @param {number} endIndex + * @returns {string} + */ +function removeLeadingZeros(input, startIndex, endIndex) { + return input.slice(0, startIndex) + input.slice(endIndex) +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/number-no-trailing-zeros.js b/lib/utils/stylelint-v15/rules/number-no-trailing-zeros.js new file mode 100644 index 0000000..9285b66 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/number-no-trailing-zeros.js @@ -0,0 +1,159 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const valueParser = require("postcss-value-parser") +const { + isAtRule, + atRuleParamIndex, + declarationValueIndex, +} = require("../../ast") + +const ruleName = "number-no-trailing-zeros" + +const messages = ruleMessages(ruleName, { + rejected: "Unexpected trailing zero(s)", +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/number-no-trailing-zeros", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, _secondaryOptions, context) => { + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + }) + + if (!validOptions) { + return + } + + root.walkAtRules((atRule) => { + if (atRule.name.toLowerCase() === "import") { + return + } + + check(atRule, atRule.params) + }) + + root.walkDecls((decl) => check(decl, decl.value)) + + /** + * @param {import('postcss').AtRule | import('postcss').Declaration} node + * @param {string} value + */ + function check(node, value) { + /** @type {Array<{ startIndex: number, endIndex: number }>} */ + const fixPositions = [] + + // Get out quickly if there are no periods + if (!value.includes(".")) { + return + } + + valueParser(value).walk((valueNode) => { + // Ignore `url` function + if ( + valueNode.type === "function" && + valueNode.value.toLowerCase() === "url" + ) { + return false + } + + // Ignore strings, comments, etc + if (valueNode.type !== "word") { + return undefined + } + + const match = /\.(\d{0,100}?)(0+)(?:\D|$)/.exec(valueNode.value) + + // match[1] is any numbers between the decimal and our trailing zero, could be empty + // match[2] is our trailing zero(s) + if (match == null || match[1] == null || match[2] == null) { + return undefined + } + + // our index is: + // the index of our valueNode + + // the index of our match + + // 1 for our decimal + + // the length of our potential non-zero number match (match[1]) + const index = + valueNode.sourceIndex + match.index + 1 + match[1].length + + // our startIndex is identical to our index except when we have only + // trailing zeros after our decimal. in that case we don't need the decimal + // either so we move our index back by 1. + const startIndex = match[1].length > 0 ? index : index - 1 + + // our end index is our original index + the length of our trailing zeros + const endIndex = index + match[2].length + + if (context.fix) { + fixPositions.unshift({ + startIndex, + endIndex, + }) + + return undefined + } + + const baseIndex = isAtRule(node) + ? atRuleParamIndex(node) + : declarationValueIndex(node) + + report({ + message: messages.rejected, + node, + // this is the index of the _first_ trailing zero + index: baseIndex + index, + result, + ruleName, + }) + + return undefined + }) + + if (fixPositions.length) { + for (const fixPosition of fixPositions) { + const startIndex = fixPosition.startIndex + const endIndex = fixPosition.endIndex + + if (isAtRule(node)) { + node.params = removeTrailingZeros( + node.params, + startIndex, + endIndex, + ) + } else { + node.value = removeTrailingZeros( + node.value, + startIndex, + endIndex, + ) + } + } + } + } + } +} + +/** + * @param {string} input + * @param {number} startIndex + * @param {number} endIndex + * @returns {string} + */ +function removeTrailingZeros(input, startIndex, endIndex) { + return input.slice(0, startIndex) + input.slice(endIndex) +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/selector-list-comma-newline-after.js b/lib/utils/stylelint-v15/rules/selector-list-comma-newline-after.js new file mode 100644 index 0000000..9bc9e34 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/selector-list-comma-newline-after.js @@ -0,0 +1,126 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const styleSearch = require("style-search") +const { whitespaceChecker } = require("../../whitespace-checker") +const { isStandardSyntaxAtRule } = require("../../ast") + +const ruleName = "selector-list-comma-newline-after" + +const messages = ruleMessages(ruleName, { + expectedAfter: () => 'Expected newline after ","', + expectedAfterMultiLine: () => + 'Expected newline after "," in a multi-line list', + rejectedAfterMultiLine: () => + 'Unexpected whitespace after "," in a multi-line list', +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/selector-list-comma-newline-after", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, _secondaryOptions, context) => { + const checker = whitespaceChecker("newline", primary, messages) + + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: ["always", "always-multi-line", "never-multi-line"], + }) + + if (!validOptions) { + return + } + + root.walkRules((ruleNode) => { + if (!isStandardSyntaxAtRule(ruleNode)) { + return + } + + // Get raw selector so we can allow end-of-line comments, e.g. + // a, /* comment */ + // b {} + const selector = ruleNode.raws.selector + ? ruleNode.raws.selector.raw + : ruleNode.selector + + /** @type {number[]} */ + const fixIndices = [] + + styleSearch( + { + source: selector, + target: ",", + functionArguments: "skip", + }, + (match) => { + const nextChars = selector.slice(match.endIndex) + + // If there's a // comment, that means there has to be a newline + // ending the comment so we're fine + if (/^\s+\/\//.test(nextChars)) { + return + } + + // If there are spaces and then a comment begins, look for the newline + const indextoCheckAfter = /^\s+\/\*/.test(nextChars) + ? selector.indexOf("*/", match.endIndex) + 1 + : match.startIndex + + checker.afterOneOnly({ + source: selector, + index: indextoCheckAfter, + err: (m) => { + if (context.fix) { + fixIndices.push(indextoCheckAfter + 1) + + return + } + + report({ + message: m, + node: ruleNode, + index: match.startIndex, + result, + ruleName, + }) + }, + }) + }, + ) + + if (fixIndices.length) { + let fixedSelector = selector + + for (const index of fixIndices.sort((a, b) => b - a)) { + const beforeSelector = fixedSelector.slice(0, index) + let afterSelector = fixedSelector.slice(index) + + if (primary.startsWith("always")) { + afterSelector = context.newline + afterSelector + } else if (primary.startsWith("never-multi-line")) { + afterSelector = afterSelector.replace(/^\s*/, "") + } + + fixedSelector = beforeSelector + afterSelector + } + + if (ruleNode.raws.selector) { + ruleNode.raws.selector.raw = fixedSelector + } else { + ruleNode.selector = fixedSelector + } + } + }) + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/selector-list-comma-newline-before.js b/lib/utils/stylelint-v15/rules/selector-list-comma-newline-before.js new file mode 100644 index 0000000..4f804ac --- /dev/null +++ b/lib/utils/stylelint-v15/rules/selector-list-comma-newline-before.js @@ -0,0 +1,103 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions }, +} = require("stylelint") +const { + whitespaceChecker, + selectorListCommaWhitespaceChecker, +} = require("../../whitespace-checker") + +const ruleName = "selector-list-comma-newline-before" + +const messages = ruleMessages(ruleName, { + expectedBefore: () => 'Expected newline before ","', + expectedBeforeMultiLine: () => + 'Expected newline before "," in a multi-line list', + rejectedBeforeMultiLine: () => + 'Unexpected whitespace before "," in a multi-line list', +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/selector-list-comma-newline-before", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, _secondaryOptions, context) => { + const checker = whitespaceChecker("newline", primary, messages) + + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: ["always", "always-multi-line", "never-multi-line"], + }) + + if (!validOptions) { + return + } + + /** @type {Map | undefined} */ + let fixData + + selectorListCommaWhitespaceChecker({ + root, + result, + locationChecker: checker.beforeAllowingIndentation, + checkedRuleName: ruleName, + fix: context.fix + ? (ruleNode, index) => { + fixData = fixData || new Map() + const commaIndices = fixData.get(ruleNode) || [] + + commaIndices.push(index) + fixData.set(ruleNode, commaIndices) + + return true + } + : null, + }) + + if (fixData) { + for (const [ruleNode, commaIndices] of fixData.entries()) { + let selector = ruleNode.raws.selector + ? ruleNode.raws.selector.raw + : ruleNode.selector + + for (const index of commaIndices.sort((a, b) => b - a)) { + let beforeSelector = selector.slice(0, index) + const afterSelector = selector.slice(index) + + if (primary.startsWith("always")) { + const spaceIndex = beforeSelector.search(/\s+$/) + + if (spaceIndex >= 0) { + beforeSelector = + beforeSelector.slice(0, spaceIndex) + + context.newline + + beforeSelector.slice(spaceIndex) + } else { + beforeSelector += context.newline + } + } else if (primary === "never-multi-line") { + beforeSelector = beforeSelector.replace(/\s*$/, "") + } + + selector = beforeSelector + afterSelector + } + + if (ruleNode.raws.selector) { + ruleNode.raws.selector.raw = selector + } else { + ruleNode.selector = selector + } + } + } + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/selector-list-comma-space-after.js b/lib/utils/stylelint-v15/rules/selector-list-comma-space-after.js new file mode 100644 index 0000000..f5aec97 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/selector-list-comma-space-after.js @@ -0,0 +1,100 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions }, +} = require("stylelint") +const { + whitespaceChecker, + selectorListCommaWhitespaceChecker, +} = require("../../whitespace-checker") + +const ruleName = "selector-list-comma-space-after" + +const messages = ruleMessages(ruleName, { + expectedAfter: () => 'Expected single space after ","', + rejectedAfter: () => 'Unexpected whitespace after ","', + expectedAfterSingleLine: () => + 'Expected single space after "," in a single-line list', + rejectedAfterSingleLine: () => + 'Unexpected whitespace after "," in a single-line list', +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/selector-list-comma-space-after", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, _secondaryOptions, context) => { + const checker = whitespaceChecker("space", primary, messages) + + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: [ + "always", + "never", + "always-single-line", + "never-single-line", + ], + }) + + if (!validOptions) { + return + } + + /** @type {Map | undefined} */ + let fixData + + selectorListCommaWhitespaceChecker({ + root, + result, + locationChecker: checker.after, + checkedRuleName: ruleName, + fix: context.fix + ? (ruleNode, index) => { + fixData = fixData || new Map() + const commaIndices = fixData.get(ruleNode) || [] + + commaIndices.push(index) + fixData.set(ruleNode, commaIndices) + + return true + } + : null, + }) + + if (fixData) { + for (const [ruleNode, commaIndices] of fixData.entries()) { + let selector = ruleNode.raws.selector + ? ruleNode.raws.selector.raw + : ruleNode.selector + + for (const index of commaIndices.sort((a, b) => b - a)) { + const beforeSelector = selector.slice(0, index + 1) + let afterSelector = selector.slice(index + 1) + + if (primary.startsWith("always")) { + afterSelector = afterSelector.replace(/^\s*/, " ") + } else if (primary.startsWith("never")) { + afterSelector = afterSelector.replace(/^\s*/, "") + } + + selector = beforeSelector + afterSelector + } + + if (ruleNode.raws.selector) { + ruleNode.raws.selector.raw = selector + } else { + ruleNode.selector = selector + } + } + } + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/selector-list-comma-space-before.js b/lib/utils/stylelint-v15/rules/selector-list-comma-space-before.js new file mode 100644 index 0000000..d0d8693 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/selector-list-comma-space-before.js @@ -0,0 +1,100 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions }, +} = require("stylelint") +const { + whitespaceChecker, + selectorListCommaWhitespaceChecker, +} = require("../../whitespace-checker") + +const ruleName = "selector-list-comma-space-before" + +const messages = ruleMessages(ruleName, { + expectedBefore: () => 'Expected single space before ","', + rejectedBefore: () => 'Unexpected whitespace before ","', + expectedBeforeSingleLine: () => + 'Expected single space before "," in a single-line list', + rejectedBeforeSingleLine: () => + 'Unexpected whitespace before "," in a single-line list', +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/selector-list-comma-space-before", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, _secondaryOptions, context) => { + const checker = whitespaceChecker("space", primary, messages) + + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: [ + "always", + "never", + "always-single-line", + "never-single-line", + ], + }) + + if (!validOptions) { + return + } + + /** @type {Map | undefined} */ + let fixData + + selectorListCommaWhitespaceChecker({ + root, + result, + locationChecker: checker.before, + checkedRuleName: ruleName, + fix: context.fix + ? (ruleNode, index) => { + fixData = fixData || new Map() + const commaIndices = fixData.get(ruleNode) || [] + + commaIndices.push(index) + fixData.set(ruleNode, commaIndices) + + return true + } + : null, + }) + + if (fixData) { + for (const [ruleNode, commaIndices] of fixData.entries()) { + let selector = ruleNode.raws.selector + ? ruleNode.raws.selector.raw + : ruleNode.selector + + for (const index of commaIndices.sort((a, b) => b - a)) { + let beforeSelector = selector.slice(0, index) + const afterSelector = selector.slice(index) + + if (primary.includes("always")) { + beforeSelector = beforeSelector.replace(/\s*$/, " ") + } else if (primary.includes("never")) { + beforeSelector = beforeSelector.replace(/\s*$/, "") + } + + selector = beforeSelector + afterSelector + } + + if (ruleNode.raws.selector) { + ruleNode.raws.selector.raw = selector + } else { + ruleNode.selector = selector + } + } + } + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint-v15/rules/selector-pseudo-class-case.js b/lib/utils/stylelint-v15/rules/selector-pseudo-class-case.js new file mode 100644 index 0000000..7134b03 --- /dev/null +++ b/lib/utils/stylelint-v15/rules/selector-pseudo-class-case.js @@ -0,0 +1,109 @@ +"use strict" + +const { + utils: { ruleMessages, validateOptions, report }, +} = require("stylelint") +const { isStandardSyntaxRule } = require("../../ast") +const { parseSelector } = require("../../selector") +const { isStandardSyntaxSelector } = require("../../text") +const { levelOneAndTwoPseudoElements } = require("../../reference/selector") + +const ruleName = "selector-pseudo-class-case" + +const messages = ruleMessages(ruleName, { + expected: (actual, expected) => `Expected "${actual}" to be "${expected}"`, +}) + +const meta = { + url: "https://stylelint.io/user-guide/rules/selector-pseudo-class-case", + fixable: true, + deprecated: true, +} + +/** @type {import('stylelint').Rule} */ +const rule = (primary, _secondaryOptions, context) => { + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: ["lower", "upper"], + }) + + if (!validOptions) { + return + } + + root.walkRules((ruleNode) => { + if (!isStandardSyntaxRule(ruleNode)) { + return + } + + const selector = ruleNode.selector + + if (!selector.includes(":")) { + return + } + + const fixedSelector = parseSelector( + ruleNode.raws.selector + ? ruleNode.raws.selector.raw + : ruleNode.selector, + result, + ruleNode, + (selectorTree) => { + selectorTree.walkPseudos((pseudoNode) => { + const pseudo = pseudoNode.value + + if (!isStandardSyntaxSelector(pseudo)) { + return + } + + if ( + pseudo.includes("::") || + levelOneAndTwoPseudoElements.has( + pseudo.toLowerCase().slice(1), + ) + ) { + return + } + + const expectedPseudo = + primary === "lower" + ? pseudo.toLowerCase() + : pseudo.toUpperCase() + + if (pseudo === expectedPseudo) { + return + } + + if (context.fix) { + pseudoNode.value = expectedPseudo + + return + } + + report({ + message: messages.expected(pseudo, expectedPseudo), + node: ruleNode, + index: pseudoNode.sourceIndex, + ruleName, + result, + }) + }) + }, + ) + + if (context.fix && fixedSelector) { + if (ruleNode.raws.selector) { + ruleNode.raws.selector.raw = fixedSelector + } else { + ruleNode.selector = fixedSelector + } + } + }) + } +} + +rule.ruleName = ruleName +rule.messages = messages +rule.meta = meta +module.exports = rule diff --git a/lib/utils/stylelint.js b/lib/utils/stylelint.js new file mode 100644 index 0000000..84fb97e --- /dev/null +++ b/lib/utils/stylelint.js @@ -0,0 +1,17 @@ +"use strict" + +module.exports = { + loadCoreRule, +} + +async function loadCoreRule(name) { + // eslint-disable-next-line node/no-unsupported-features/es-syntax -- ignore + const stylelint = await import("stylelint").then( + (mod) => mod.default || mod, + ) + const rule = stylelint.rules?.[name] + if (rule) { + return rule + } + return require(`stylelint/lib/rules/${name}`) +} diff --git a/lib/utils/text.js b/lib/utils/text.js new file mode 100644 index 0000000..540d0de --- /dev/null +++ b/lib/utils/text.js @@ -0,0 +1,213 @@ +"use strict" + +const mathMLTags = require("mathml-tag-names") +const svgTags = require("svg-tags") +const { htmlTypeSelectors } = require("./reference/selector") +const keyframeSelectorKeywords = new Set(["from", "to"]) + +const HAS_LESS_INTERPOLATION = /@\{.+?\}/ +const HAS_PSV_INTERPOLATION = /\$\(.+?\)/ +const HAS_SCSS_INTERPOLATION = /#\{.+?\}/ +const HAS_TPL_INTERPOLATION = /\{.+?\}/ + +module.exports = { + isSingleLineString, + isWhitespace, + isOnlyWhitespace, + isKeyframeSelector, + isCustomElement, + isStandardSyntaxSelector, + hasInterpolation, +} + +function isSingleLineString(input) { + return !/[\n\r]/u.test(input) +} + +/** + * Returns a Boolean indicating whether the input string is only whitespace. + * + * @param {string} input + * @returns {boolean} + */ +function isOnlyWhitespace(input) { + for (const element of input) { + if (!isWhitespace(element)) { + return false + } + } + + return true +} + +/** + * Check if a character is whitespace. + * + * @param {string} char + * @returns {boolean} + */ +function isWhitespace(char) { + return [" ", "\n", "\t", "\r", "\f"].includes(char) +} + +/** + * Check whether a string is a keyframe selector. + * + * @param {string} selector + * @returns {boolean} + */ +function isKeyframeSelector(selector) { + if (keyframeSelectorKeywords.has(selector)) { + return true + } + + // Percentages + if (/^(?:\d+|\d*\.\d+)%$/.test(selector)) { + return true + } + + return false +} + +function isCustomElement(selector) { + if (!/^[a-z]/.test(selector)) { + return false + } + + if (!selector.includes("-")) { + return false + } + + const selectorLowerCase = selector.toLowerCase() + + if (selectorLowerCase !== selector) { + return false + } + + if (svgTags.includes(selectorLowerCase)) { + return false + } + + if (htmlTypeSelectors.has(selectorLowerCase)) { + return false + } + + if (mathMLTags.includes(selectorLowerCase)) { + return false + } + + return true +} + +/** + * Check whether a selector is standard + * + * @param {string} selector + * @returns {boolean} + */ +function isStandardSyntaxSelector(selector) { + // SCSS or Less interpolation + if (hasInterpolation(selector)) { + return false + } + + // SCSS placeholder selectors + if (selector.startsWith("%")) { + return false + } + + // SCSS nested properties + if (selector.endsWith(":")) { + return false + } + + // Less :extend() + if (/:extend(?:\(.*?\))?/.test(selector)) { + return false + } + + // Less mixin with resolved nested selectors (e.g. .foo().bar or .foo(@a, @b)[bar]) + if (/\.[\w-]+\(.*\).+/.test(selector)) { + return false + } + + // Less non-outputting mixin definition (e.g. .mixin() {}) + if (selector.endsWith(")") && !selector.includes(":")) { + return false + } + + // Less Parametric mixins (e.g. .mixin(@variable: x) {}) + if (/\(@.*\)$/.test(selector)) { + return false + } + + // ERB template tags + if (selector.includes("<%") || selector.includes("%>")) { + return false + } + + // SCSS and Less comments + if (selector.includes("//")) { + return false + } + + return true +} + +/** + * Check whether a string has interpolation + * + * @param {string} string + * @return {boolean} If `true`, a string has interpolation + */ +function hasInterpolation(string) { + // SCSS or Less interpolation + if ( + hasLessInterpolation(string) || + hasScssInterpolation(string) || + hasTplInterpolation(string) || + hasPsvInterpolation(string) + ) { + return true + } + + return false +} + +/** + * Check whether a string has less interpolation + * + * @param {string} string + * @return {boolean} If `true`, a string has less interpolation + */ +function hasLessInterpolation(string) { + return HAS_LESS_INTERPOLATION.test(string) +} + +/** + * Check whether a string has postcss-simple-vars interpolation + * + * @param {string} string + */ +function hasPsvInterpolation(string) { + return HAS_PSV_INTERPOLATION.test(string) +} + +/** + * Check whether a string has scss interpolation + * + * @param {string} string + */ +function hasScssInterpolation(string) { + return HAS_SCSS_INTERPOLATION.test(string) +} + +/** + * Check whether a string has JS template literal interpolation or HTML-like template + * + * @param {string} string + * @return {boolean} If `true`, a string has template literal interpolation + */ +function hasTplInterpolation(string) { + return HAS_TPL_INTERPOLATION.test(string) +} diff --git a/lib/utils/whitespace-checker.js b/lib/utils/whitespace-checker.js new file mode 100644 index 0000000..3831103 --- /dev/null +++ b/lib/utils/whitespace-checker.js @@ -0,0 +1,491 @@ +"use strict" + +const { + utils: { report }, +} = require("stylelint") +const styleSearch = require("style-search") +const { isStandardSyntaxAtRule, isStandardSyntaxRule } = require("./ast") +const { isSingleLineString, isWhitespace } = require("./text") + +module.exports = { + whitespaceChecker, + atRuleNameSpaceChecker, + selectorListCommaWhitespaceChecker, +} + +/** + * Create configurationError from text and set CLI exit code. + * + * @param {string} text + * @returns {ConfigurationError} + */ +function configurationError(text) { + const err = /** @type {ConfigurationError} */ (new Error(text)) + + err.code = 78 + + return err +} + +/** + * @typedef {(message: string) => string} MessageFunction + */ + +/** + * @typedef {Object} Messages + * @property {MessageFunction} [expectedBefore] + * @property {MessageFunction} [rejectedBefore] + * @property {MessageFunction} [expectedAfter] + * @property {MessageFunction} [rejectedAfter] + * @property {MessageFunction} [expectedBeforeSingleLine] + * @property {MessageFunction} [rejectedBeforeSingleLine] + * @property {MessageFunction} [expectedBeforeMultiLine] + * @property {MessageFunction} [rejectedBeforeMultiLine] + * @property {MessageFunction} [expectedAfterSingleLine] + * @property {MessageFunction} [rejectedAfterSingleLine] + * @property {MessageFunction} [expectedAfterMultiLine] + * @property {MessageFunction} [rejectedAfterMultiLine] + */ + +/** + * @typedef {Object} WhitespaceCheckerArgs + * @property {string} source - The source string + * @property {number} index - The index of the character to check before + * @property {(message: string) => void} err - If a problem is found, this callback + * will be invoked with the relevant warning message. + * Typically this callback will report() the problem. + * @property {string} [errTarget] - If a problem is found, this string + * will be sent to the relevant warning message. + * @property {string} [lineCheckStr] - Single- and multi-line checkers + * will use this string to determine whether they should proceed, + * i.e. if this string is one line only, single-line checkers will check, + * multi-line checkers will ignore. + * If none is passed, they will use `source`. + * @property {boolean} [onlyOneChar=false] - Only check *one* character before. + * By default, "always-*" checks will look for the `targetWhitespace` one + * before and then ensure there is no whitespace two before. This option + * bypasses that second check. + * @property {boolean} [allowIndentation=false] - Allow arbitrary indentation + * between the `targetWhitespace` (almost definitely a newline) and the `index`. + * With this option, the checker will see if a newline *begins* the whitespace before + * the `index`. + */ + +/** + * @typedef {(args: WhitespaceCheckerArgs) => void} WhitespaceChecker + */ + +/** + * @typedef {{ + * before: WhitespaceChecker, + * beforeAllowingIndentation: WhitespaceChecker, + * after: WhitespaceChecker, + * afterOneOnly: WhitespaceChecker, + * }} WhitespaceCheckers + */ + +/** + * Create a whitespaceChecker, which exposes the following functions: + * - `before()` + * - `beforeAllowingIndentation()` + * - `after()` + * - `afterOneOnly()` + * + * @param {"space" | "newline"} targetWhitespace - This is a keyword instead + * of the actual character (e.g. " ") in order to accommodate + * different styles of newline ("\n" vs "\r\n") + * @param {"always" | "never" | "always-single-line" | "always-multi-line" | "never-single-line" | "never-multi-line"} expectation + * @param {Messages} messages - An object of message functions; + * calling `before*()` or `after*()` and the `expectation` that is passed + * determines which message functions are required + * + * @returns {WhitespaceCheckers} The checker, with its exposed checking functions + */ +function whitespaceChecker(targetWhitespace, expectation, messages) { + // Keep track of active arguments in order to avoid passing + // too much stuff around, making signatures long and confusing. + // This variable gets reset anytime a checking function is called. + /** @type {WhitespaceCheckerArgs} */ + let activeArgs + + /** + * Check for whitespace *before* a character. + * @type {WhitespaceChecker} + */ + function before({ + source, + index, + err, + errTarget, + lineCheckStr, + onlyOneChar = false, + allowIndentation = false, + }) { + activeArgs = { + source, + index, + err, + errTarget, + onlyOneChar, + allowIndentation, + } + + switch (expectation) { + case "always": + expectBefore() + break + case "never": + rejectBefore() + break + case "always-single-line": + if (!isSingleLineString(lineCheckStr || source)) { + return + } + + expectBefore(messages.expectedBeforeSingleLine) + break + case "never-single-line": + if (!isSingleLineString(lineCheckStr || source)) { + return + } + + rejectBefore(messages.rejectedBeforeSingleLine) + break + case "always-multi-line": + if (isSingleLineString(lineCheckStr || source)) { + return + } + + expectBefore(messages.expectedBeforeMultiLine) + break + case "never-multi-line": + if (isSingleLineString(lineCheckStr || source)) { + return + } + + rejectBefore(messages.rejectedBeforeMultiLine) + break + default: + throw configurationError(`Unknown expectation "${expectation}"`) + } + } + + /** + * Check for whitespace *after* a character. + * @type {WhitespaceChecker} + */ + function after({ + source, + index, + err, + errTarget, + lineCheckStr, + onlyOneChar = false, + }) { + activeArgs = { source, index, err, errTarget, onlyOneChar } + + switch (expectation) { + case "always": + expectAfter() + break + case "never": + rejectAfter() + break + case "always-single-line": + if (!isSingleLineString(lineCheckStr || source)) { + return + } + + expectAfter(messages.expectedAfterSingleLine) + break + case "never-single-line": + if (!isSingleLineString(lineCheckStr || source)) { + return + } + + rejectAfter(messages.rejectedAfterSingleLine) + break + case "always-multi-line": + if (isSingleLineString(lineCheckStr || source)) { + return + } + + expectAfter(messages.expectedAfterMultiLine) + break + case "never-multi-line": + if (isSingleLineString(lineCheckStr || source)) { + return + } + + rejectAfter(messages.rejectedAfterMultiLine) + break + default: + throw configurationError(`Unknown expectation "${expectation}"`) + } + } + + /** + * @type {WhitespaceChecker} + */ + function beforeAllowingIndentation(obj) { + before({ ...obj, allowIndentation: true }) + } + + function expectBefore(messageFunc = messages.expectedBefore) { + if (activeArgs.allowIndentation) { + expectBeforeAllowingIndentation(messageFunc) + + return + } + + const _activeArgs = activeArgs + const source = _activeArgs.source + const index = _activeArgs.index + + const oneCharBefore = source[index - 1] + const twoCharsBefore = source[index - 2] + + if (oneCharBefore == null) { + return + } + + if ( + targetWhitespace === "space" && + oneCharBefore === " " && + (activeArgs.onlyOneChar || + twoCharsBefore == null || + !isWhitespace(twoCharsBefore)) + ) { + return + } + + activeArgs.err( + messageFunc(activeArgs.errTarget || source.charAt(index)), + ) + } + + function expectBeforeAllowingIndentation( + messageFunc = messages.expectedBefore, + ) { + const _activeArgs2 = activeArgs + const source = _activeArgs2.source + const index = _activeArgs2.index + const err = _activeArgs2.err + + const expectedChar = targetWhitespace === "newline" ? "\n" : undefined + let i = index - 1 + + while (source[i] !== expectedChar) { + if (source[i] === "\t" || source[i] === " ") { + i-- + continue + } + + err(messageFunc(activeArgs.errTarget || source.charAt(index))) + + return + } + } + + function rejectBefore(messageFunc = messages.rejectedBefore) { + const _activeArgs3 = activeArgs + const source = _activeArgs3.source + const index = _activeArgs3.index + + const oneCharBefore = source[index - 1] + + if (oneCharBefore != null && isWhitespace(oneCharBefore)) { + activeArgs.err( + messageFunc(activeArgs.errTarget || source.charAt(index)), + ) + } + } + + /** + * @type {WhitespaceChecker} + */ + function afterOneOnly(obj) { + after({ ...obj, onlyOneChar: true }) + } + + function expectAfter(messageFunc = messages.expectedAfter) { + const _activeArgs4 = activeArgs + const source = _activeArgs4.source + const index = _activeArgs4.index + + const oneCharAfter = source[index + 1] + const twoCharsAfter = source[index + 2] + const threeCharsAfter = source[index + 3] + + if (oneCharAfter == null) { + return + } + + if (targetWhitespace === "newline") { + // If index is followed by a Windows CR-LF ... + if ( + oneCharAfter === "\r" && + twoCharsAfter === "\n" && + (activeArgs.onlyOneChar || + threeCharsAfter == null || + !isWhitespace(threeCharsAfter)) + ) { + return + } + + // If index is followed by a Unix LF ... + if ( + oneCharAfter === "\n" && + (activeArgs.onlyOneChar || + twoCharsAfter == null || + !isWhitespace(twoCharsAfter)) + ) { + return + } + } + + if ( + targetWhitespace === "space" && + oneCharAfter === " " && + (activeArgs.onlyOneChar || + twoCharsAfter == null || + !isWhitespace(twoCharsAfter)) + ) { + return + } + + activeArgs.err( + messageFunc(activeArgs.errTarget || source.charAt(index)), + ) + } + + function rejectAfter(messageFunc = messages.rejectedAfter) { + const _activeArgs5 = activeArgs + const source = _activeArgs5.source + const index = _activeArgs5.index + + const oneCharAfter = source[index + 1] + + if (oneCharAfter != null && isWhitespace(oneCharAfter)) { + activeArgs.err( + messageFunc(activeArgs.errTarget || source.charAt(index)), + ) + } + } + + return { + before, + beforeAllowingIndentation, + after, + afterOneOnly, + } +} + +/** + * @param {{ + * root: import('postcss').Root, + * locationChecker: (opts: { source: string, index: number, err: (msg: string) => void, errTarget: string }) => void, + * result: import('stylelint').PostcssResult, + * checkedRuleName: string, + * fix?: ((atRule: import('postcss').AtRule) => void) | null, + * }} options + */ +function atRuleNameSpaceChecker(options) { + options.root.walkAtRules((atRule) => { + if (!isStandardSyntaxAtRule(atRule)) { + return + } + + checkColon( + `@${atRule.name}${atRule.raws.afterName || ""}${atRule.params}`, + atRule.name.length, + atRule, + ) + }) + + /** + * @param {string} source + * @param {number} index + * @param {import('postcss').AtRule} node + */ + function checkColon(source, index, node) { + options.locationChecker({ + source, + index, + err: (m) => { + if (options.fix) { + options.fix(node) + + return + } + + report({ + message: m, + node, + index, + result: options.result, + ruleName: options.checkedRuleName, + }) + }, + errTarget: `@${node.name}`, + }) + } +} + +/** + * @param {{ + * root: import('postcss').Root, + * result: import('stylelint').PostcssResult, + * locationChecker: (opts: { source: string, index: number, err: (msg: string) => void }) => void, + * checkedRuleName: string, + * fix: ((rule: import('postcss').Rule, index: number) => boolean) | null, + * }} opts + * @returns {void} + */ +function selectorListCommaWhitespaceChecker(opts) { + opts.root.walkRules((rule) => { + if (!isStandardSyntaxRule(rule)) { + return + } + + const selector = rule.raws.selector + ? rule.raws.selector.raw + : rule.selector + + styleSearch( + { + source: selector, + target: ",", + functionArguments: "skip", + }, + (match) => { + checkDelimiter(selector, match.startIndex, rule) + }, + ) + }) + + /** + * @param {string} source + * @param {number} index + * @param {import('postcss').Rule} node + */ + function checkDelimiter(source, index, node) { + opts.locationChecker({ + source, + index, + err: (message) => { + if (opts.fix && opts.fix(node, index)) { + return + } + + report({ + message, + node, + index, + result: opts.result, + ruleName: opts.checkedRuleName, + }) + }, + }) + } +} diff --git a/package.json b/package.json index e64d839..d9aba36 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "homepage": "https://stylus.github.io/stylelint-stylus/", "peerDependencies": { "postcss-syntax": "^0.36.2", - "stylelint": "^13.2.1 || ^14 || ^15" + "stylelint": "^13.2.1 || ^14 || ^15 || ^16" }, "peerDependenciesMeta": { "postcss-syntax": { @@ -77,9 +77,8 @@ "postcss-scss": "^4.0.1", "postcss-syntax": "^0.36.2", "prettier": "^2.2.1", - "semver": "^7.3.7", - "stylelint": "^15.0.0", - "stylelint-config-standard": "^30.0.1", + "stylelint": "^16.0.0", + "stylelint-config-recommended": "^14.0.0", "stylelint-stylus": "file:.", "stylelint4b": "^14.15.1-0", "vue-stylelint-editor": "^0.6.0", @@ -93,6 +92,8 @@ "postcss-media-query-parser": "^0.2.3", "postcss-selector-parser": "^6.0.2", "postcss-styl": "^0.12.2", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4", "style-search": "^0.1.0", "stylelint-config-html": "^1.0.0", "svg-tags": "^1.0.0" diff --git a/recommended/index.js b/recommended/index.js index c5bccb2..d91646c 100644 --- a/recommended/index.js +++ b/recommended/index.js @@ -1,5 +1,8 @@ "use strict" +const { version: stylelintVersion } = require("stylelint/package.json") +const semver = require("semver") + module.exports = { extends: [require.resolve("../base-config")], rules: { @@ -10,48 +13,54 @@ module.exports = { "stylus/selector-type-no-unknown": true, "stylus/property-no-unknown": true, - // List of core rules that cannot be used with the Stylus. - // - The Stylus can also be separated by newlines without using comma separators. - "selector-list-comma-newline-after": null, - "selector-list-comma-newline-before": null, - "selector-list-comma-space-after": null, - "selector-list-comma-space-before": null, - - // - The Stylus can omit the braces. - "block-closing-brace-empty-line-before": null, - "block-closing-brace-newline-after": null, - "block-closing-brace-newline-before": null, - "block-closing-brace-space-after": null, - "block-closing-brace-space-before": null, - "block-opening-brace-newline-after": null, - "block-opening-brace-newline-before": null, - "block-opening-brace-space-after": null, - "block-opening-brace-space-before": null, - - // - The Stylus can omit the semicolons. And you can use "stylus/semicolon" rules instead. - "declaration-block-trailing-semicolon": null, - - // - This rule breaks the Stylus syntax. - "at-rule-name-newline-after": null, - // - The `postcss-styl` atrule AST contains function calls. - "at-rule-name-space-after": null, - // - The `postcss-styl` atrule AST contains if, for and function calls. - "at-rule-empty-line-before": null, + ...(semver.gte(stylelintVersion, "16.0.0") + ? {} + : { + // List of core rules that cannot be used with the Stylus. + // - The Stylus can also be separated by newlines without using comma separators. + "selector-list-comma-newline-after": null, + "selector-list-comma-newline-before": null, + "selector-list-comma-space-after": null, + "selector-list-comma-space-before": null, + + // - The Stylus can omit the braces. + "block-closing-brace-empty-line-before": null, + "block-closing-brace-newline-after": null, + "block-closing-brace-newline-before": null, + "block-closing-brace-space-after": null, + "block-closing-brace-space-before": null, + "block-opening-brace-newline-after": null, + "block-opening-brace-newline-before": null, + "block-opening-brace-space-after": null, + "block-opening-brace-space-before": null, + + // - The Stylus can omit the semicolons. And you can use "stylus/semicolon" rules instead. + "declaration-block-trailing-semicolon": null, + + // - This rule breaks the Stylus syntax. + "at-rule-name-newline-after": null, + // - The `postcss-styl` atrule AST contains function calls. + "at-rule-name-space-after": null, + // - The `postcss-styl` atrule AST contains if, for and function calls. + "at-rule-empty-line-before": null, + + // - False positives the Range Operator of the Stylus. + "number-leading-zero": null, + "number-no-trailing-zeros": null, + + // - Wrong autofix on the Stylus. + "color-hex-case": null, + "selector-pseudo-class-case": null, + }), + // - Don't understand the Stylus at-rules. And the `postcss-styl` atrule AST contains if, for and function calls. "at-rule-no-unknown": null, - // - False positives the Range Operator of the Stylus. - "number-leading-zero": null, - "number-no-trailing-zeros": null, // - False positives in variables and interpolations of the Stylus. "property-no-unknown": null, // - Don't understand the Stylus selectors. "selector-type-no-unknown": null, "no-duplicate-selectors": null, - - // - Wrong autofix on the Stylus. - "color-hex-case": null, - "selector-pseudo-class-case": null, }, } diff --git a/standard/index.js b/standard/index.js index 1385bcd..7bc4671 100644 --- a/standard/index.js +++ b/standard/index.js @@ -1,5 +1,8 @@ "use strict" +const { version: stylelintVersion } = require("stylelint/package.json") +const semver = require("semver") + module.exports = { extends: [require.resolve("../recommended")], rules: { @@ -16,9 +19,13 @@ module.exports = { // wrapper core rules "stylus/indentation": 2, - // The "indentation" rule is not turned off with "recommended", but is turned off with "standard". - // Because the problem with "indentation" rule is only `postfix` problem, and the effect of the problem is small. - indentation: null, + ...(semver.gte(stylelintVersion, "16.0.0") + ? {} + : { + // The "indentation" rule is not turned off with "recommended", but is turned off with "standard". + // Because the problem with "indentation" rule is only `postfix` problem, and the effect of the problem is small. + indentation: null, + }), // - brace "stylus/block-closing-brace-empty-line-before": "never", "stylus/block-closing-brace-newline-after": "always", @@ -49,8 +56,12 @@ module.exports = { // - error remains with autofix "stylus/no-eol-whitespace": true, - // The "no-eol-whitespace" rule is not turned off with "recommended", but is turned off with "standard". - // Because the problem with "no-eol-whitespace" rule is only location and autofix problem, and the effect of the problem is small. - "no-eol-whitespace": null, + ...(semver.gte(stylelintVersion, "16.0.0") + ? {} + : { + // The "no-eol-whitespace" rule is not turned off with "recommended", but is turned off with "standard". + // Because the problem with "no-eol-whitespace" rule is only location and autofix problem, and the effect of the problem is small. + "no-eol-whitespace": null, + }), }, } diff --git a/test-file/stylelint.config.js b/test-file/stylelint.config.js index 700b36f..f6dd3c8 100644 --- a/test-file/stylelint.config.js +++ b/test-file/stylelint.config.js @@ -1,5 +1,5 @@ "use strict" module.exports = { - extends: ["stylelint-config-standard", "stylelint-stylus/standard"], + extends: ["stylelint-config-recommended", "stylelint-stylus/standard"], } diff --git a/tests/fixtures/standard/css-literal/output.styl b/tests/fixtures/standard/css-literal/output.styl index 6e2ee59..acaed92 100644 --- a/tests/fixtures/standard/css-literal/output.styl +++ b/tests/fixtures/standard/css-literal/output.styl @@ -1,6 +1,6 @@ @css { .ie-opacity, foo { - filter: progid:dximagetransform.microsoft.alpha(opacity=25); - filter: "progid:DXImageTransform.Microsoft.Alpha(opacity=25)"; + filter: progid:DXImageTransform.Microsoft.Alpha(opacity=25); + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(opacity=25)"; } -} +} \ No newline at end of file diff --git a/tests/fixtures/standard/css-literal/warnings.json b/tests/fixtures/standard/css-literal/warnings.json index c3a0986..b6a6246 100644 --- a/tests/fixtures/standard/css-literal/warnings.json +++ b/tests/fixtures/standard/css-literal/warnings.json @@ -16,41 +16,5 @@ "rule": "stylus/selector-type-no-unknown", "severity": "error", "text": "Unexpected unknown type selector \"foo\" (stylus/selector-type-no-unknown)" - }, - { - "line": 3, - "column": 20, - "endLine": 3, - "endColumn": 21, - "rule": "function-name-case", - "severity": "error", - "text": "Expected \"DXImageTransform.Microsoft.Alpha\" to be \"dximagetransform.microsoft.alpha\" (function-name-case)" - }, - { - "line": 3, - "column": 20, - "endLine": 3, - "endColumn": 52, - "rule": "function-no-unknown", - "severity": "error", - "text": "Unexpected unknown function \"DXImageTransform.Microsoft.Alpha\" (function-no-unknown)" - }, - { - "line": 4, - "column": 5, - "endLine": 4, - "endColumn": 15, - "rule": "property-no-vendor-prefix", - "severity": "error", - "text": "Unexpected vendor-prefix \"-ms-filter\" (property-no-vendor-prefix)" - }, - { - "line": 6, - "column": 1, - "endLine": 6, - "endColumn": 2, - "rule": "no-missing-end-of-source-newline", - "severity": "error", - "text": "Unexpected missing end-of-source newline (no-missing-end-of-source-newline)" } ] \ No newline at end of file diff --git a/tests/fixtures/standard/object/output.styl b/tests/fixtures/standard/object/output.styl index f116ee5..57a48f1 100644 --- a/tests/fixtures/standard/object/output.styl +++ b/tests/fixtures/standard/object/output.styl @@ -14,7 +14,6 @@ f = { bar: { baz: 2 }, - qux: 3 } g = { @@ -22,6 +21,5 @@ g = { bar: { baz: 2 }, - qux: 3 } diff --git a/tests/fixtures/standard/object/warnings.json b/tests/fixtures/standard/object/warnings.json index 2d481d0..eaa7290 100644 --- a/tests/fixtures/standard/object/warnings.json +++ b/tests/fixtures/standard/object/warnings.json @@ -17,15 +17,6 @@ "severity": "error", "text": "Expected comma (stylus/hash-object-property-comma)" }, - { - "line": 17, - "column": 3, - "endLine": 17, - "endColumn": 9, - "rule": "declaration-empty-line-before", - "severity": "error", - "text": "Expected empty line before declaration (declaration-empty-line-before)" - }, { "line": 20, "column": 9, @@ -43,14 +34,5 @@ "rule": "stylus/hash-object-property-comma", "severity": "error", "text": "Expected comma (stylus/hash-object-property-comma)" - }, - { - "line": 24, - "column": 3, - "endLine": 24, - "endColumn": 9, - "rule": "declaration-empty-line-before", - "severity": "error", - "text": "Expected empty line before declaration (declaration-empty-line-before)" } ] \ No newline at end of file diff --git a/tests/fixtures/standard/styl-warn/output.styl b/tests/fixtures/standard/styl-warn/output.styl index e4d2ee6..5815704 100644 --- a/tests/fixtures/standard/styl-warn/output.styl +++ b/tests/fixtures/standard/styl-warn/output.styl @@ -18,30 +18,25 @@ body color red - .bar color red - .baz color red - .qux color red -@import url("foo.styl") +@import "foo.styl" fn(0.2) .warning @extend .message - color #e2e21e .warning @extend .message - color #e2e21e @@ -58,22 +53,18 @@ body font 14px/1.5 Helvetica, arial, sans-serif button - button.button - - input[type="button"] - - input[type="submit"] + input[type='button'] + input[type='submit'] border-radius 5px body font 14px/1.5 Helvetica, arial, sans-serif - body button body button.button -body input[type="button"] -body input[type="submit"] +body input[type='button'] +body input[type='submit'] border-radius 5px @@ -89,4 +80,4 @@ hash = { .foo color #000 -@import url("./foo.styl") +@import './foo.styl' diff --git a/tests/fixtures/standard/styl-warn/warnings.json b/tests/fixtures/standard/styl-warn/warnings.json index 3358beb..8c530bf 100644 --- a/tests/fixtures/standard/styl-warn/warnings.json +++ b/tests/fixtures/standard/styl-warn/warnings.json @@ -44,15 +44,6 @@ "severity": "error", "text": "Unexpected whitespace after \"//\" (stylus/single-line-comment-double-slash-space-after)" }, - { - "line": 7, - "column": 1, - "endLine": 7, - "endColumn": 14, - "rule": "comment-empty-line-before", - "severity": "error", - "text": "Expected empty line before comment (comment-empty-line-before)" - }, { "line": 7, "column": 1, @@ -134,15 +125,6 @@ "severity": "error", "text": "Unexpected empty line before closing brace (stylus/block-closing-brace-empty-line-before)" }, - { - "line": 19, - "column": 2, - "endLine": 20, - "endColumn": 13, - "rule": "rule-empty-line-before", - "severity": "error", - "text": "Expected empty line before rule (rule-empty-line-before)" - }, { "line": 19, "column": 2, @@ -215,15 +197,6 @@ "severity": "error", "text": "Unexpected colon (stylus/declaration-colon)" }, - { - "line": 21, - "column": 12, - "endLine": 21, - "endColumn": 13, - "rule": "declaration-colon-space-after", - "severity": "error", - "text": "Expected single space after \":\" with a single-line declaration (declaration-colon-space-after)" - }, { "line": 21, "column": 14, @@ -233,15 +206,6 @@ "severity": "error", "text": "Expected single space before \"}\" of a single-line block (stylus/block-closing-brace-space-before)" }, - { - "line": 22, - "column": 1, - "endLine": 23, - "endColumn": 2, - "rule": "rule-empty-line-before", - "severity": "error", - "text": "Expected empty line before rule (rule-empty-line-before)" - }, { "line": 22, "column": 1, @@ -278,15 +242,6 @@ "severity": "error", "text": "Unexpected colon (stylus/declaration-colon)" }, - { - "line": 22, - "column": 12, - "endLine": 22, - "endColumn": 13, - "rule": "declaration-colon-space-after", - "severity": "error", - "text": "Expected single space after \":\" with a single-line declaration (declaration-colon-space-after)" - }, { "line": 22, "column": 15, @@ -323,15 +278,6 @@ "severity": "error", "text": "Expected single space after at-rule name \"@import\" (stylus/at-rule-name-space-after)" }, - { - "line": 24, - "column": 8, - "endLine": 24, - "endColumn": 18, - "rule": "import-notation", - "severity": "error", - "text": "Expected \"\"foo.styl\"\" to be \"url(\"foo.styl\")\" (import-notation)" - }, { "line": 26, "column": 5, @@ -368,15 +314,6 @@ "severity": "error", "text": "Unexpected semicolon (stylus/semicolon)" }, - { - "line": 30, - "column": 3, - "endLine": 30, - "endColumn": 18, - "rule": "declaration-empty-line-before", - "severity": "error", - "text": "Expected empty line before declaration (declaration-empty-line-before)" - }, { "line": 30, "column": 8, @@ -431,15 +368,6 @@ "severity": "error", "text": "Unexpected semicolon (stylus/semicolon)" }, - { - "line": 35, - "column": 3, - "endLine": 35, - "endColumn": 18, - "rule": "declaration-empty-line-before", - "severity": "error", - "text": "Expected empty line before declaration (declaration-empty-line-before)" - }, { "line": 35, "column": 8, @@ -530,15 +458,6 @@ "severity": "error", "text": "Expected indentation of 2 spaces (stylus/indentation)" }, - { - "line": 52, - "column": 16, - "endLine": 52, - "endColumn": 17, - "rule": "string-quotes", - "severity": "error", - "text": "Expected double quotes (string-quotes)" - }, { "line": 53, "column": 4, @@ -548,15 +467,6 @@ "severity": "error", "text": "Expected indentation of 2 spaces (stylus/indentation)" }, - { - "line": 53, - "column": 16, - "endLine": 53, - "endColumn": 17, - "rule": "string-quotes", - "severity": "error", - "text": "Expected double quotes (string-quotes)" - }, { "line": 54, "column": 7, @@ -602,15 +512,6 @@ "severity": "error", "text": "Unexpected semicolon (stylus/semicolon)" }, - { - "line": 59, - "column": 1, - "endLine": 64, - "endColumn": 2, - "rule": "rule-empty-line-before", - "severity": "error", - "text": "Expected empty line before rule (rule-empty-line-before)" - }, { "line": 59, "column": 1, @@ -638,15 +539,6 @@ "severity": "error", "text": "Unexpected comma (stylus/selector-list-comma)" }, - { - "line": 61, - "column": 17, - "endLine": 61, - "endColumn": 18, - "rule": "string-quotes", - "severity": "error", - "text": "Expected double quotes (string-quotes)" - }, { "line": 61, "column": 26, @@ -656,15 +548,6 @@ "severity": "error", "text": "Unexpected comma (stylus/selector-list-comma)" }, - { - "line": 62, - "column": 17, - "endLine": 62, - "endColumn": 18, - "rule": "string-quotes", - "severity": "error", - "text": "Expected double quotes (string-quotes)" - }, { "line": 63, "column": 4, @@ -736,14 +619,5 @@ "rule": "stylus/no-at-require", "severity": "error", "text": "Unexpected '@require', use '@import' instead. (stylus/no-at-require)" - }, - { - "line": 78, - "column": 10, - "endLine": 78, - "endColumn": 11, - "rule": "string-quotes", - "severity": "error", - "text": "Expected double quotes (string-quotes)" } ] \ No newline at end of file diff --git a/tests/fixtures/standard/stylelint.config.js b/tests/fixtures/standard/stylelint.config.js index b9d0b64..57f6c78 100644 --- a/tests/fixtures/standard/stylelint.config.js +++ b/tests/fixtures/standard/stylelint.config.js @@ -1,12 +1,12 @@ "use strict"; module.exports = { - extends: ["stylelint-config-standard", "stylelint-stylus/standard"], + extends: ["stylelint-config-recommended", "stylelint-stylus/standard"], rules: { - "no-missing-end-of-source-newline": true, - "string-quotes": "double", - "declaration-colon-space-after": "always-single-line", - "declaration-colon-space-before": "never", - "declaration-block-semicolon-newline-after": "always-multi-line", + // "no-missing-end-of-source-newline": true, + // "string-quotes": "double", + // "declaration-colon-space-after": "always-single-line", + // "declaration-colon-space-before": "never", + // "declaration-block-semicolon-newline-after": "always-multi-line", }, }; diff --git a/tests/utils/tester.js b/tests/utils/tester.js index d82151c..049d491 100644 --- a/tests/utils/tester.js +++ b/tests/utils/tester.js @@ -2,7 +2,6 @@ const path = require("path") const assert = require("assert") -const stylelint = require("stylelint") const { listupFixtures, assertNonFile, @@ -183,7 +182,7 @@ function runFixtures( function autofix(code, repeat = 0) { return lintCode(code, fixture.input, { fix: true }) .then((r) => ({ - output: r.output, + output: r.code ?? r.output, result: r.results[0], })) .then(({ output, result }) => { @@ -206,8 +205,10 @@ function lintFixture(fixture, options = {}) { return lintCode(code, fixture.input, options) } -function lintCode(code, codeFilename, options = {}) { - return stylelint.lint({ +async function lintCode(code, codeFilename, options = {}) { + // eslint-disable-next-line node/no-unsupported-features/es-syntax -- ignore + const stylelint = await import("stylelint") + return (stylelint.default || stylelint).lint({ code, codeFilename, ...options,