| 'use strict'; |
| |
| const beforeBlockString = require('../../utils/beforeBlockString'); |
| const hasBlock = require('../../utils/hasBlock'); |
| const optionsMatches = require('../../utils/optionsMatches'); |
| const report = require('../../utils/report'); |
| const ruleMessages = require('../../utils/ruleMessages'); |
| const styleSearch = require('style-search'); |
| const validateOptions = require('../../utils/validateOptions'); |
| const { isAtRule, isDeclaration, isRoot, isRule } = require('../../utils/typeGuards'); |
| const { isBoolean, isNumber, isString } = require('../../utils/validateTypes'); |
| |
| const ruleName = 'indentation'; |
| const messages = ruleMessages(ruleName, { |
| expected: (x) => `Expected indentation of ${x}`, |
| }); |
| |
| /** @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 = ('after' in node.raws && 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) { |
| 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<number, number>} */ |
| 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].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(/^[^\r\n]+/, (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 current = getIndentLevel(foundIndents[i]); |
| |
| 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; |
| module.exports = rule; |