eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
545 lines (454 loc) • 14.6 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import {decodeHTMLAttribute} from 'entities';
import {isVirtualFilename} from './utils/index.js';
const MESSAGE_ID_MISSING = 'missing';
const MESSAGE_ID_INCORRECT_CASE = 'incorrect-case';
const messages = {
[MESSAGE_ID_MISSING]: 'Resource `{{resource}}` does not exist.',
[MESSAGE_ID_INCORRECT_CASE]: 'Resource path has incorrect casing. Use `{{resource}}`.',
};
const resourceAttributes = new Set([
'href',
'poster',
'src',
'srcset',
]);
const schemePattern = /^[a-z][\d+\-.a-z]*:/i;
const maximumNamedHtmlCharacterReferenceLength = 64;
const decimalDigitPattern = /^\d$/u;
const hexadecimalDigitPattern = /^[\da-f]$/iu;
const percentEncodedPartPattern = /(?:%[\da-f]{2})+/giu;
const unquotedAttributeValuePattern = /^[^\t\n\f\r "'<>`]+/u;
const textEncoder = new TextEncoder();
const isSrcsetWhitespace = character => /^[\t\n\f\r ]$/u.test(character);
function getTrimmedUrlRange(value) {
let start = 0;
let end = value.length;
while (start < end && value.codePointAt(start) <= 32) {
start++;
}
while (end > start && value.codePointAt(end - 1) <= 32) {
end--;
}
return [start, end];
}
function decodePercentEncoded(value) {
try {
return decodeURIComponent(value);
} catch {
return value.replaceAll(/(?:%[\da-f]{2})+/giu, encoded => {
try {
return decodeURIComponent(encoded);
} catch {
return encoded;
}
});
}
}
function encodePercentEncoded(value) {
return Array.from(textEncoder.encode(value), byte => '%' + byte.toString(16).padStart(2, '0').toUpperCase()).join('');
}
function getNumericCharacterReferenceEnd(value, index) {
let end = index + 2;
const isHexadecimal = value[end]?.toLowerCase() === 'x';
const digitPattern = isHexadecimal
? hexadecimalDigitPattern
: decimalDigitPattern;
if (isHexadecimal) {
end++;
}
const digitStart = end;
while (digitPattern.test(value[end])) {
end++;
}
if (end === digitStart) {
return;
}
return value[end] === ';' ? end + 1 : end;
}
function getSrcsetCharacter(value, index) {
if (value[index] !== '&') {
return {
value: value[index],
end: index + 1,
};
}
let end;
if (value[index + 1] === '#') {
end = getNumericCharacterReferenceEnd(value, index);
} else {
const possibleCharacterReference = value.slice(index, index + maximumNamedHtmlCharacterReferenceLength);
const semicolonIndex = possibleCharacterReference.indexOf(';');
end = semicolonIndex === -1 ? undefined : index + semicolonIndex + 1;
}
if (end === undefined) {
return {
value: value[index],
end: index + 1,
};
}
const source = value.slice(index, end);
const decodedValue = decodeHTMLAttribute(source);
if (decodedValue === source) {
return {
value: value[index],
end: index + 1,
};
}
return {
value: decodedValue,
end,
};
}
function skipSrcsetSeparators(value, index) {
while (index < value.length) {
const character = getSrcsetCharacter(value, index);
if (character.value !== ',' && !isSrcsetWhitespace(character.value)) {
return index;
}
index = character.end;
}
return value.length;
}
function getSrcsetCandidate(value, index) {
let end = index;
let trailingCommaStart;
while (index < value.length) {
const character = getSrcsetCharacter(value, index);
if (isSrcsetWhitespace(character.value)) {
return {
end: trailingCommaStart ?? end,
next: index,
hasTrailingComma: trailingCommaStart !== undefined,
};
}
if (character.value === ',') {
trailingCommaStart ??= index;
} else {
trailingCommaStart = undefined;
}
end = character.end;
index = character.end;
}
return {
end: trailingCommaStart ?? end,
next: index,
hasTrailingComma: trailingCommaStart !== undefined,
};
}
function getNextSrcsetCandidateStart(value, index) {
let parenthesisDepth = 0;
while (index < value.length) {
const character = getSrcsetCharacter(value, index);
index = character.end;
if (character.value === '(') {
parenthesisDepth++;
} else if (character.value === ')' && parenthesisDepth > 0) {
parenthesisDepth--;
} else if (character.value === ',' && parenthesisDepth === 0) {
return index;
}
}
return value.length;
}
function getSrcsetCandidates(value) {
const candidates = [];
let index = 0;
while (index < value.length) {
index = skipSrcsetSeparators(value, index);
const start = index;
const candidate = getSrcsetCandidate(value, index);
if (candidate.end > start) {
candidates.push({
value: value.slice(start, candidate.end),
offsets: [start, candidate.end],
});
}
index = candidate.hasTrailingComma
? candidate.next
: getNextSrcsetCandidateStart(value, candidate.next);
}
return candidates;
}
function getLocalResource(value, decodeHtmlCharacterReferences = true) {
const decodedValue = decodeHtmlCharacterReferences ? decodeHTMLAttribute(value) : value;
const [resourceStart, resourceEnd] = getTrimmedUrlRange(decodedValue);
const trimmedValue = decodedValue.slice(resourceStart, resourceEnd);
let pathEnd = trimmedValue.length;
for (const separator of ['?', '#']) {
const index = trimmedValue.indexOf(separator);
if (index !== -1) {
pathEnd = Math.min(pathEnd, index);
}
}
const resourcePath = trimmedValue.slice(0, pathEnd);
const normalizedResourcePath = resourcePath.replaceAll('\\', '/');
if (
normalizedResourcePath.length === 0
|| normalizedResourcePath.startsWith('/')
|| schemePattern.test(normalizedResourcePath)
) {
return;
}
const decodedParts = normalizedResourcePath.split('/').map(part => decodePercentEncoded(part));
if (decodedParts.some(part => part.includes('/') || part.includes('\\'))) {
return;
}
const decodedPath = decodedParts.join('/');
const normalizedPath = path.posix.normalize(decodedPath);
const resolvedPath = resourcePath.startsWith('./') && !normalizedPath.startsWith('.')
? './' + normalizedPath
: normalizedPath;
const isNormalized = normalizedResourcePath !== resourcePath || resolvedPath !== decodedPath;
return {
path: resolvedPath,
rawPath: resourcePath,
suffix: trimmedValue.slice(pathEnd),
canFix: decodedValue === value && !isNormalized,
isNormalized,
};
}
function getCorrectedResourcePart(rawPart, correctedPart) {
const decodedPart = decodePercentEncoded(rawPart);
if (decodedPart === correctedPart) {
return rawPart;
}
if (decodedPart.length !== correctedPart.length) {
return encodeURIComponent(correctedPart);
}
let result = correctedPart;
for (const encodedPart of rawPart.matchAll(percentEncodedPartPattern).toArray().toReversed()) {
const [encodedValue] = encodedPart;
const start = decodePercentEncoded(rawPart.slice(0, encodedPart.index)).length;
const decodedValue = decodePercentEncoded(encodedValue);
const end = start + decodedValue.length;
const correctedValue = correctedPart.slice(start, end);
const replacement = correctedValue === decodedValue ? encodedValue : encodePercentEncoded(correctedValue);
result = result.slice(0, start) + replacement + result.slice(end);
}
return result;
}
function getCorrectedResourcePath(rawPath, correctedPath) {
const correctedParts = correctedPath.split('/');
return rawPath.split('/').map((part, index) => getCorrectedResourcePart(part, correctedParts[index])).join('/');
}
function hasLengthChangingCaseMapping(rawPath, correctedPath) {
const correctedParts = correctedPath.split('/');
return rawPath.split('/').some((part, index) => decodePercentEncoded(part).length !== correctedParts[index].length);
}
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
if (
context.filename !== context.physicalFilename
|| isVirtualFilename(context.physicalFilename)
) {
return;
}
const documentDirectory = path.dirname(context.physicalFilename);
const resourceCache = new Map();
const directoryEntriesCache = new Map();
const templateDelimiters = Object.keys(context.languageOptions?.templateEngineSyntax ?? {});
const getHtmlAttributeValue = attribute => {
const range = context.sourceCode.getRange(attribute.value);
if (attribute.startWrapper) {
return {value: attribute.value.value, valueRange: range};
}
const value = context.sourceCode.text.slice(range[0]).match(unquotedAttributeValuePattern)?.[0];
if (!value) {
return;
}
return {
value,
valueRange: [range[0], range[0] + value.length],
};
};
const getCssValueRange = (node, value) => {
const range = context.sourceCode.getRange(node);
const text = context.sourceCode.text.slice(...range);
let valueOffset = node.type === 'Url' ? text.indexOf('(') + 1 : 0;
valueOffset += getTrimmedUrlRange(text.slice(valueOffset))[0];
const quote = text[valueOffset];
if (quote === '"' || quote === '\'') {
valueOffset++;
if (text[valueOffset + value.length] !== quote) {
return;
}
}
if (!text.startsWith(value, valueOffset)) {
return;
}
return [range[0] + valueOffset, range[0] + valueOffset + value.length];
};
const isCssResourceUrl = node => {
const ancestors = context.sourceCode.getAncestors(node);
const preludeIndex = ancestors.findLastIndex(ancestor => ancestor.type === 'AtrulePrelude');
if (preludeIndex === -1) {
return true;
}
const atRule = ancestors[preludeIndex - 1];
return atRule?.name?.toLowerCase() === 'import' && atRule.prelude.children.at(0) === node;
};
const getDirectoryEntries = directory => {
if (directoryEntriesCache.has(directory)) {
return directoryEntriesCache.get(directory);
}
let result;
try {
const exact = new Set();
const insensitive = new Map();
for (const entry of fs.readdirSync(directory)) {
exact.add(entry);
const normalizedEntry = entry.toLowerCase();
if (insensitive.has(normalizedEntry)) {
insensitive.set(normalizedEntry, undefined);
} else {
insensitive.set(normalizedEntry, entry);
}
}
result = {exact, insensitive};
} catch {}
directoryEntriesCache.set(directory, result);
return result;
};
const getResourceResult = resourcePath => {
if (resourceCache.has(resourcePath)) {
return resourceCache.get(resourcePath);
}
const resource = path.resolve(documentDirectory, resourcePath);
let directory = documentDirectory;
let hasIncorrectCase = false;
const correctedParts = [];
for (const part of resourcePath.split('/')) {
if (['', '.', '..'].includes(part)) {
directory += path.sep + part;
correctedParts.push(part);
continue;
}
const entries = getDirectoryEntries(directory);
if (!entries) {
const result = {exists: fs.existsSync(resource)};
resourceCache.set(resourcePath, result);
return result;
}
const correctedPart = entries.exact.has(part) ? part : entries.insensitive.get(part.toLowerCase());
if (!correctedPart) {
const result = {exists: false};
resourceCache.set(resourcePath, result);
return result;
}
hasIncorrectCase ||= correctedPart !== part;
correctedParts.push(correctedPart);
directory += path.sep + correctedPart;
}
const result = fs.existsSync(directory)
? {
exists: true,
correctedPath: hasIncorrectCase ? correctedParts.join('/') : undefined,
}
: {exists: false};
resourceCache.set(resourcePath, result);
return result;
};
const getResourceProblem = (node, value, valueRange, decodeHtmlCharacterReferences = true) => {
if (templateDelimiters.some(delimiter => value.includes(delimiter))) {
return;
}
const [leadingWhitespaceLength, resourceEnd] = getTrimmedUrlRange(value);
const resource = value.slice(leadingWhitespaceLength, resourceEnd);
const localResource = getLocalResource(resource, decodeHtmlCharacterReferences);
if (!localResource) {
return;
}
const resourceRange = valueRange && [valueRange[0] + leadingWhitespaceLength, valueRange[0] + leadingWhitespaceLength + resource.length];
const resourceResult = getResourceResult(localResource.path);
const loc = resourceRange && {
start: context.sourceCode.getLocFromIndex(resourceRange[0]),
end: context.sourceCode.getLocFromIndex(resourceRange[1]),
};
if (!resourceResult.exists) {
return {
node,
...(loc && {loc}),
messageId: MESSAGE_ID_MISSING,
data: {resource},
};
}
if (!resourceResult.correctedPath) {
return;
}
const correctedPath = localResource.isNormalized
? resourceResult.correctedPath
: getCorrectedResourcePath(localResource.rawPath, resourceResult.correctedPath);
const correctedResource = correctedPath + localResource.suffix;
const canFix = localResource.canFix && !hasLengthChangingCaseMapping(localResource.rawPath, resourceResult.correctedPath);
return {
node,
...(loc && {loc}),
messageId: MESSAGE_ID_INCORRECT_CASE,
data: {resource: correctedResource},
...(canFix && resourceRange && {
fix: fixer => fixer.replaceTextRange(resourceRange, correctedResource),
}),
};
};
context.on(['definition', 'image', 'link'], node => getResourceProblem(node, node.url));
context.on('Atrule', node => {
if (node.name?.toLowerCase() !== 'import') {
return;
}
const stringNode = node.prelude?.children?.at(0);
if (stringNode?.type !== 'String') {
return;
}
return getResourceProblem(stringNode, stringNode.value, getCssValueRange(stringNode, stringNode.value), false);
});
context.on('Url', node => {
if (!isCssResourceUrl(node)) {
return;
}
return getResourceProblem(node, node.value, getCssValueRange(node, node.value), false);
});
context.on('Attribute', attribute => {
const name = attribute.key?.value?.toLowerCase();
if (
!resourceAttributes.has(name)
|| attribute.key.parts.length > 0
|| !attribute.value
|| attribute.value.parts.length > 0
) {
return;
}
const htmlAttributeValue = getHtmlAttributeValue(attribute);
if (!htmlAttributeValue) {
return;
}
const {value, valueRange} = htmlAttributeValue;
if (name === 'srcset') {
return getSrcsetCandidates(value).map(candidate => getResourceProblem(attribute, candidate.value, [valueRange[0] + candidate.offsets[0], valueRange[0] + candidate.offsets[1]]));
}
return getResourceProblem(attribute, value, valueRange);
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Disallow references to missing local resources.',
recommended: false,
},
fixable: 'code',
schema: [],
messages,
languages: [
'js/js',
'css/css',
'html/html',
'markdown/commonmark',
'markdown/gfm',
],
},
};
export default config;