123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732 |
- '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, assertString } = require('../../utils/validateTypes');
- 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,
- };
- /** @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) {
- 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] && 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 foundIndent = foundIndents[i];
- assertString(foundIndent);
- 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;
|