| 'use strict'; |
| |
| const execall = require('execall'); |
| 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 } = require('../../utils/validateTypes'); |
| |
| const ruleName = 'max-line-length'; |
| const EXCLUDED_PATTERNS = [ |
| /url\(\s*(\S.*\S)\s*\)/gi, // allow tab, whitespace in url content |
| /@import\s+(['"].*['"])/gi, |
| ]; |
| |
| const messages = ruleMessages(ruleName, { |
| expected: (max) => |
| `Expected line length to be no more than ${max} ${max === 1 ? 'character' : 'characters'}`, |
| }); |
| |
| /** @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 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 execall(pattern, rootString)) { |
| const subMatch = match.subMatches[0] || ''; |
| const startOfSubString = match.index + match.match.indexOf(subMatch); |
| |
| skippedSubStrings.push([startOfSubString, startOfSubString + subMatch.length]); |
| continue; |
| } |
| |
| 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 [startSubString, endSubString] = skippedSubStrings[skippedSubStringsIndex]; |
| |
| // 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; |
| module.exports = rule; |