'use strict'; 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 { isNumber, isRegExp, isString, assert } = require('../../utils/validateTypes'); const ruleName = 'max-line-length'; const messages = ruleMessages(ruleName, { expected: (max) => `Expected line length to be no more than ${max} ${max === 1 ? 'character' : 'characters'}`, }); const meta = { url: 'https://stylelint.io/user-guide/rules/max-line-length', }; /** @type {import('stylelint').Rule} */ const rule = (primary, secondaryOptions, context) => { return (root, result) => { const validOptions = validateOptions( result, ruleName, { actual: primary, possible: isNumber, }, { actual: secondaryOptions, possible: { ignore: ['non-comments', 'comments'], ignorePattern: [isString, isRegExp], }, optional: true, }, ); if (!validOptions) { return; } if (root.source == null) { throw new Error('The root node must have a source'); } const EXCLUDED_PATTERNS = [ /url\(\s*(\S.*\S)\s*\)/gi, // allow tab, whitespace in url content /@import\s+(['"].*['"])/gi, ]; const ignoreNonComments = optionsMatches(secondaryOptions, 'ignore', 'non-comments'); const ignoreComments = optionsMatches(secondaryOptions, 'ignore', 'comments'); const rootString = context.fix ? root.toString() : root.source.input.css; // Array of skipped sub strings, i.e `url(...)`, `@import "..."` /** @type {Array<[number, number]>} */ let skippedSubStrings = []; let skippedSubStringsIndex = 0; for (const pattern of EXCLUDED_PATTERNS) { for (const match of rootString.matchAll(pattern)) { const subMatch = match[1] || ''; const startOfSubString = (match.index || 0) + (match[0] || '').indexOf(subMatch); skippedSubStrings.push([startOfSubString, startOfSubString + subMatch.length]); } } skippedSubStrings = skippedSubStrings.sort((a, b) => a[0] - b[0]); // Check first line checkNewline({ endIndex: 0 }); // Check subsequent lines styleSearch({ source: rootString, target: ['\n'], comments: 'check' }, (match) => checkNewline(match), ); /** * @param {number} index */ function complain(index) { report({ index, result, ruleName, message: messages.expected(primary), node: root, }); } /** * @param {number} start * @param {number} end */ function tryToPopSubString(start, end) { const skippedSubString = skippedSubStrings[skippedSubStringsIndex]; assert(skippedSubString); const [startSubString, endSubString] = skippedSubString; // Excluded substring does not presented in current line if (end < startSubString) { return 0; } // Compute excluded substring size regarding to current line indexes const excluded = Math.min(end, endSubString) - Math.max(start, startSubString); // Current substring is out of range for next lines if (endSubString <= end) { skippedSubStringsIndex++; } return excluded; } /** * @param {import('style-search').StyleSearchMatch | { endIndex: number }} match */ function checkNewline(match) { let nextNewlineIndex = rootString.indexOf('\n', match.endIndex); if (rootString[nextNewlineIndex - 1] === '\r') { nextNewlineIndex -= 1; } // Accommodate last line if (nextNewlineIndex === -1) { nextNewlineIndex = rootString.length; } const rawLineLength = nextNewlineIndex - match.endIndex; const excludedLength = skippedSubStrings[skippedSubStringsIndex] ? tryToPopSubString(match.endIndex, nextNewlineIndex) : 0; const lineText = rootString.slice(match.endIndex, nextNewlineIndex); // Case sensitive ignorePattern match if (optionsMatches(secondaryOptions, 'ignorePattern', lineText)) { return; } // If the line's length is less than or equal to the specified // max, ignore it ... So anything below is liable to be complained about. // **Note that the length of any url arguments or import urls // are excluded from the calculation.** if (rawLineLength - excludedLength <= primary) { return; } const complaintIndex = nextNewlineIndex - 1; if (ignoreComments) { if ('insideComment' in match && match.insideComment) { return; } // This trimming business is to notice when the line starts a // comment but that comment is indented, e.g. // /* something here */ const nextTwoChars = rootString.slice(match.endIndex).trim().slice(0, 2); if (nextTwoChars === '/*' || nextTwoChars === '//') { return; } } if (ignoreNonComments) { if ('insideComment' in match && match.insideComment) { return complain(complaintIndex); } // This trimming business is to notice when the line starts a // comment but that comment is indented, e.g. // /* something here */ const nextTwoChars = rootString.slice(match.endIndex).trim().slice(0, 2); if (nextTwoChars !== '/*' && nextTwoChars !== '//') { return; } return complain(complaintIndex); } // If there are no spaces besides initial (indent) spaces, ignore it const lineString = rootString.slice(match.endIndex, nextNewlineIndex); if (!lineString.replace(/^\s+/, '').includes(' ')) { return; } return complain(complaintIndex); } }; }; rule.ruleName = ruleName; rule.messages = messages; rule.meta = meta; module.exports = rule;