@stylistic/stylelint-plugin
Version:
A collection of stylistic/formatting Stylelint rules
691 lines (542 loc) • 23.2 kB
JavaScript
import styleSearch from "style-search"
import stylelint from "stylelint"
import { addNamespace } from "../../utils/addNamespace/index.js"
import { beforeBlockString } from "../../utils/beforeBlockString/index.js"
import { getRuleDocUrl } from "../../utils/getRuleDocUrl/index.js"
import { hasBlock } from "../../utils/hasBlock/index.js"
import { isStyledSyntaxDeclaration } from "../../utils/isStyledSyntaxDeclaration/index.js"
import { isStyledSyntaxNode } from "../../utils/isStyledSyntaxNode/index.js"
import { optionsMatches } from "../../utils/optionsMatches/index.js"
import { isAtRule, isDeclaration, isRoot, isRule } from "../../utils/typeGuards/index.js"
import { assertString, isBoolean, isNumber, isString } from "../../utils/validateTypes/index.js"
let { utils: { report, ruleMessages, validateOptions } } = stylelint
let shortName = `indentation`
export let ruleName = addNamespace(shortName)
export let messages = ruleMessages(ruleName, {
expected: (x) => `Expected indentation of ${x}`,
})
export let meta = {
url: getRuleDocUrl(shortName),
fixable: true,
}
/**
* Specifies indentation.
* @type {import('stylelint').Rule}
*/
function rule (primary, secondaryOptions = {}) {
return (root, result) => {
let 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
let spaceCount = isNumber(primary) ? primary : null
let indentChar = spaceCount === null ? `\t` : ` `.repeat(spaceCount)
let warningWord = primary === `tab` ? `tab` : `space`
/** @type {number | 'auto'} */
let baseIndentLevel = secondaryOptions.baseIndentLevel
/** @type {boolean} */
let indentClosingBrace = secondaryOptions.indentClosingBrace
/**
* Returns a human-readable expectation string based on indentation level.
* @param {number} level - The indentation level.
* @returns {string} The formatted expectation string.
*/
function legibleExpectation (level) {
let count = spaceCount === null ? level : level * spaceCount
let 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
}
let nodeLevel = indentationLevel(node)
let styledDeclarationLevel = isStyledSyntaxNode(node) ? getStyledDeclarationLevel(node) : 0
// Cut out any * and _ hacks from `before`
let before = (node.raws.before || ``).replace(/[*_]$/u, ``)
let after = typeof node.raws.after === `string` ? node.raws.after : ``
let parent = node.parent
if (!parent) throw new Error(`A parent node must be present`)
let 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.)
let isFirstChild = parent.type === `root` && parent.first === node
let 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) {
report({
message: messages.expected,
messageArgs: [legibleExpectation(nodeLevel - styledDeclarationLevel)],
node,
result,
ruleName,
fix () {
if (isFirstChild && isString(node.raws.before)) node.raws.before = node.raws.before.replace(/^[ \t]*(?=\S|$)/u, expectedOpeningBraceIndentation)
node.raws.before = fixIndentation(node.raws.before, expectedOpeningBraceIndentation)
},
})
}
// 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.
let closingBraceLevel = indentClosingBrace ? nodeLevel + 1 : nodeLevel
let expectedClosingBraceIndentation = indentChar.repeat(closingBraceLevel)
if ((isRule(node) || isAtRule(node)) && hasBlock(node) && after && after.includes(`\n`) && after.slice(after.lastIndexOf(`\n`) + 1) !== expectedClosingBraceIndentation) {
const problemIndex = node.toString().length - 1
report({
message: messages.expected,
messageArgs: [legibleExpectation(closingBraceLevel - styledDeclarationLevel)],
node,
index: problemIndex,
endIndex: problemIndex,
result,
ruleName,
fix () {
node.raws.after = fixIndentation(node.raws.after, expectedClosingBraceIndentation)
},
})
}
// 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)
})
/**
* Roughly calculates the indentation level of the line where styled expression starts.
* Required to format the error text relative to this level, not the beginning of a line.
* @param {import('postcss').Node} node - The node to calculate level for.
* @returns {number} The calculated indentation level.
*/
function getStyledDeclarationLevel (node) {
// Content of the line where styled expressions starts
const expressionStartLine = node.parent.parent.source.input.css.split(`\n`)[node.parent.source.start.line - 1]
// Indent characters (spaces/tabs) before the content of the line where the styled expressions starts
const indentCharacters = expressionStartLine.match(/^[ \t]*/gu)?.[0] ?? ``
return Math.ceil(indentCharacters.length / indentChar.length)
}
/**
* Calculates the indentation level for a node.
* @param {import('postcss').Node} node - The node to calculate level for.
* @param {number} [level] - The current level.
* @returns {number} The calculated indentation level.
*/
function indentationLevel (node, level = 0) {
if (!node.parent) throw new Error(`A parent node must be present`)
let calculatedLevel = level
if (isStyledSyntaxNode(node)) {
const isMultilineDeclaration = !!node.parent.source?.input.css.includes(`\n`)
if (isMultilineDeclaration) calculatedLevel += 1
calculatedLevel += getStyledDeclarationLevel(node)
}
if (isRoot(node.parent)) return calculatedLevel + getRootBaseIndentLevel(node.parent, baseIndentLevel, primary)
// Indentation level equals the ancestor nodes
// separating this node from root; so recursively
// run this operation
calculatedLevel = indentationLevel(node.parent, calculatedLevel + 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 -= 1
return calculatedLevel
}
/**
* Checks the value of a declaration for proper indentation.
* @param {import('postcss').Declaration} decl - The declaration to check.
* @param {number} declLevel - The indentation level of the declaration.
*/
function checkValue (decl, declLevel) {
if (!decl.value.includes(`\n`)) return
if (isStyledSyntaxDeclaration(decl) && decl.value.includes(`\${`)) return
if (optionsMatches(secondaryOptions, `ignore`, `value`)) return
let declString = decl.toString()
let valueLevel = optionsMatches(secondaryOptions, `except`, `value`) ? declLevel : declLevel + 1
checkMultilineBit(declString, valueLevel, decl)
}
/**
* Checks a selector for proper indentation.
* @param {import('postcss').Rule} ruleNode - The rule node to check.
* @param {number} ruleLevel - The indentation level of the rule.
*/
function checkSelector (ruleNode, ruleLevel) {
let selector = ruleNode.selector
let level = ruleLevel
// 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) level += 1
// Add extra indentation level for multiline pseudos
// https://github.com/stylelint-stylistic/stylelint-stylistic/issues/30
if ((/:\w+\(\s*\r?\n/u).test(selector)) level += 1
checkMultilineBit(selector, level, ruleNode)
}
/**
* Checks at-rule parameters for proper indentation.
* @param {import('postcss').AtRule} atRule - The at-rule to check.
* @param {number} ruleLevel - The indentation level of the rule.
*/
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
let paramLevel = optionsMatches(secondaryOptions, `except`, `param`) || atRule.name === `nest` || atRule.name === `at-root` ? ruleLevel : ruleLevel + 1
checkMultilineBit(beforeBlockString(atRule).trim(), paramLevel, atRule)
}
/**
* Checks a multiline bit for proper newline indentation.
* @param {string} source - The source string to check.
* @param {number} newlineIndentLevel - The expected indentation level.
* @param {import('postcss').Node} node - The node being checked.
*/
function checkMultilineBit (source, newlineIndentLevel, node) {
if (!source.includes(`\n`)) return
// Data for current node fixing
/** @type {Array<{ expectedIndentation: string, currentIndentation: string, startIndex: number }>} */
let fixPositions = []
// `outsideParens` because function arguments and also non-standard parenthesized stuff like
// Sass maps are ignored to allow for arbitrary indentation
let parentheticalDepth = 0
let ignoreInsideParans = optionsMatches(secondaryOptions, `ignore`, `inside-parens`)
styleSearch(
{
source,
target: `\n`,
// @ts-expect-error -- The `outsideParens` option is unsupported. Why?
outsideParens: ignoreInsideParans,
},
(match, matchCount) => {
let precedesClosingParenthesis = (/^[ \t]*\)/u).test(source.slice(match.startIndex + 1))
if (ignoreInsideParans && (precedesClosingParenthesis || match.insideParens)) return
let expectedIndentLevel = newlineIndentLevel
// Modifications 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 -= 1
let followsOpeningParenthesis = (/\([ \t]*$/u).test(source.slice(0, newlineIndex))
if (followsOpeningParenthesis) parentheticalDepth += 1
let followsOpeningBrace = (/\{[ \t]*$/u).test(source.slice(0, newlineIndex))
if (followsOpeningBrace) parentheticalDepth += 1
let startingClosingBrace = (/^[ \t]*\}/u).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
let afterNewlineSpaceMatches = (/^([ \t]*)\S/u).exec(source.slice(match.startIndex + 1))
if (!afterNewlineSpaceMatches) return
let afterNewlineSpace = afterNewlineSpaceMatches[1] || ``
let expectedIndentation = indentChar.repeat(Math.max(expectedIndentLevel, 0))
if (afterNewlineSpace !== expectedIndentation) {
const problemIndex = match.startIndex + afterNewlineSpace.length + 1
report({
message: messages.expected,
messageArgs: [legibleExpectation(expectedIndentLevel)],
node,
index: problemIndex,
endIndex: problemIndex,
result,
ruleName,
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,
})
},
})
}
},
)
if (fixPositions.length > 0) {
if (isRule(node)) {
for (let fixPosition of fixPositions) {
node.selector = replaceIndentation(
node.selector,
fixPosition.currentIndentation,
fixPosition.expectedIndentation,
fixPosition.startIndex,
)
}
}
if (isDeclaration(node)) {
let declProp = node.prop
let declBetween = node.raws.between
if (!isString(declBetween)) throw new TypeError(`The \`between\` property must be a string`)
for (let 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)) {
let atRuleName = node.name
let atRuleAfterName = node.raws.afterName
let atRuleParams = node.params
if (!isString(atRuleAfterName)) throw new TypeError(`The \`afterName\` property must be a string`)
for (let 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,
)
}
}
}
}
}
}
}
/**
* Gets the base indentation level for the root node.
* @param {import('postcss').Root} root - The root node.
* @param {number | 'auto'} baseIndentLevel - The base indent level option.
* @param {string} space - The space character.
* @returns {number} The calculated base indentation level.
*/
function getRootBaseIndentLevel (root, baseIndentLevel, space) {
let 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 }} */
let source = root.source
let indentLevel = source.baseIndentLevel
if (isNumber(indentLevel) && Number.isSafeInteger(indentLevel)) return indentLevel
let newIndentLevel = inferRootIndentLevel(root, baseIndentLevel, () => inferDocIndentSize(document, space))
source.baseIndentLevel = newIndentLevel
return newIndentLevel
}
/**
* Gets the document node from a PostCSS node.
* @param {import('postcss').Node} node - The node to get document from.
* @returns {import('postcss').Document | undefined} The document node or undefined.
*/
function getDocument (node) {
// @ts-expect-error -- TS2339: Property 'document' does not exist on type 'Node'.
let document = node.document
if (document) return document
let root = node.root()
// @ts-expect-error -- TS2339: Property 'document' does not exist on type 'Node'.
return root && root.document
}
/**
* Infers the document indent size from the source.
* @param {import('postcss').Document} document - The document node.
* @param {string} space - The space character.
* @returns {number} The inferred indent size.
*/
function inferDocIndentSize (document, space) {
if (!document.source) throw new Error(`The document node must have a source`)
/** @type {import('postcss').Source & { indentSize?: number }} */
let docSource = document.source
let indentSize = docSource.indentSize
if (isNumber(indentSize) && Number.isSafeInteger(indentSize)) return indentSize
let source = document.source.input.css
let indents = source.match(/^ *(?=\S)/gmu)
/** @type {Map<number, number>} */
let scores = (new Map())
let lastIndentSize = 0
let lastLeadingSpacesLength = 0
/**
* Records a vote for an indent size based on leading spaces length.
*
* @param {number} leadingSpacesLength - The length of leading spaces.
*/
function vote (leadingSpacesLength) {
if (leadingSpacesLength) {
lastIndentSize = Math.abs(leadingSpacesLength - lastLeadingSpacesLength) || lastIndentSize
if (lastIndentSize > 1) {
let score = scores.get(lastIndentSize)
if (score) scores.set(lastIndentSize, score + 1)
else scores.set(lastIndentSize, 1)
}
}
else lastIndentSize = 0
lastLeadingSpacesLength = leadingSpacesLength
}
if (indents) {
for (let leadingSpaces of indents) vote(leadingSpaces.length)
let bestScore = 0
for (let [indentSizeDate, score] of scores.entries()) {
if (score > bestScore) {
bestScore = score
indentSize = indentSizeDate
}
}
}
indentSize = Number(indentSize) || (indents && indents[0] && indents[0].length > 0) || Number(space) || 2
docSource.indentSize = indentSize
return indentSize
}
/**
* Infers the root indentation level from the source.
* @param {import('postcss').Root} root - The root node.
* @param {number | 'auto'} baseIndentLevel - The base indent level option.
* @param {() => number} indentSize - Function to get the indent size.
* @returns {number} The inferred root indentation level.
*/
function inferRootIndentLevel (root, baseIndentLevel, indentSize) {
/**
* Gets the indentation level from a string.
*
* @param {string} indent - The indentation string.
* @returns {number} The calculated indentation level.
*/
function getIndentLevel (indent) {
let tabMatch = indent.match(/\t/gu)
let tabCount = tabMatch ? tabMatch.length : 0
let spaceMatch = indent.match(/ /gu)
let spaceCount = spaceMatch ? Math.round(spaceMatch.length / indentSize()) : 0
return tabCount + spaceCount
}
let newBaseIndentLevel
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]+/u, (firstLine) => {
let match = root.raws.codeBefore && (/(?:^|\n)([ \t]*)$/u).exec(root.raws.codeBefore)
if (match) return match[1] + firstLine
return ``
})
let indentions = source.match(/^[ \t]*(?=\S)/gmu)
if (indentions) return Math.min(...indentions.map((indent) => getIndentLevel(indent)))
newBaseIndentLevel = 1
}
else newBaseIndentLevel = baseIndentLevel
let indents = []
let foundIndents = root.raws.codeBefore?.match(/(?:^|\n)([\t ]*)\S/gmu)
// The indent level of the CSS code block in non-CSS-like files is determined by the indent of first non-empty line before it.
if (foundIndents) {
let i = foundIndents.length - 1
while (i >= 0) {
let foundIndent = foundIndents[i]
assertString(foundIndent)
if ((/^\s*</u).test(foundIndent)) {
let current = getIndentLevel(foundIndent)
indents.push(Array.from({ length: current }).fill(` `).join(``))
break
}
i -= 1
}
}
let after = root.raws.after
if (after) {
let afterEnd
if (after.endsWith(`\n`)) {
// @ts-expect-error -- TS2339: Property 'document' does not exist on type 'Root'.
let document = root.document
if (document) {
let 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
let parent = root.parent
if (!parent) throw new Error(`The root node must have a parent`)
let 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]*/u)[0])
}
if (indents.length > 0) return Math.max(...indents.map((indent) => getIndentLevel(indent))) + newBaseIndentLevel
return newBaseIndentLevel
}
/**
* Fixes indentation in a string by replacing newlines followed by whitespace.
* @param {string | undefined} str - The string to fix.
* @param {string} whitespace - The whitespace to use for indentation.
* @returns {string | undefined} The fixed string.
*/
function fixIndentation (str, whitespace) {
if (!isString(str)) return str
return str.replaceAll(/\n[ \t]*(?=\S|$)/gu, `\n${whitespace}`)
}
/**
* Replaces indentation in a string at the specified position.
* @param {string} input - The input string.
* @param {string} searchString - The string to search for.
* @param {string} replaceString - The string to replace with.
* @param {number} startIndex - The index to start at.
* @returns {string} The modified string.
*/
function replaceIndentation (input, searchString, replaceString, startIndex) {
let offset = startIndex + 1
let stringStart = input.slice(0, offset)
let stringEnd = input.slice(offset + searchString.length)
return stringStart + replaceString + stringEnd
}
rule.ruleName = ruleName
rule.messages = messages
rule.meta = meta
export default rule