UNPKG

eslint-plugin-unicorn

Version:
438 lines (370 loc) • 13.1 kB
import fs from 'node:fs'; import path from 'node:path'; import {ident} from '@eslint/css-tree'; import {decodeHTMLAttribute} from 'entities'; import {getStaticStringValue, isNewExpression} from './ast/index.js'; import {isNodeMatchesNameOrPath, isVirtualFilename, unwrapTypeScriptExpression} from './utils/index.js'; import getSrcsetCandidates from './shared/get-srcset-candidates.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', 'imagesrcset', 'poster', 'src', 'srcset', ]); const imageSetFunctions = new Set(['image-set', '-webkit-image-set']); const schemePattern = /^[a-z][\d+\-.a-z]*:/i; const percentEncodedPartPattern = /(?:%[\da-f]{2})+/giu; const unquotedAttributeValuePattern = /^[^\t\n\f\r "'<>`]+/u; const textEncoder = new TextEncoder(); 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 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 !== undefined && ident.decode(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(); // Entries that differ only in case cannot exist on case-insensitive file systems like the macOS default, where coverage is collected /* node:coverage ignore next 3 */ 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 exists = resourcePath.endsWith('/') // Windows ignores a trailing separator and finds a file of that name, so check for a directory explicitly ? fs.statSync(path.resolve(directory), {throwIfNoEntry: false})?.isDirectory() === true : fs.existsSync(directory); const result = exists ? { 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('NewExpression', node => { if (!isNewExpression(node, {name: 'URL', argumentsLength: 2})) { return; } const [resourceNode, baseNode] = node.arguments.map(argument => unwrapTypeScriptExpression(argument)); if (!isNodeMatchesNameOrPath(baseNode, 'import.meta.url')) { return; } const value = getStaticStringValue(resourceNode); if (value === undefined || /[\t\n\r]/v.test(value)) { return; } const {sourceCode} = context; let valueRange; if (sourceCode.getText(resourceNode).slice(1, -1) === value) { const [start, end] = sourceCode.getRange(resourceNode); valueRange = [start + 1, end - 1]; } return getResourceProblem(resourceNode, value, valueRange, false); }); context.on(['definition', 'image', 'link'], node => getResourceProblem(node, node.url)); context.on('Atrule', node => { if (!node.name || ident.decode(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('String', node => { const parent = context.sourceCode.getParent(node); if (parent?.type !== 'Function' || !imageSetFunctions.has(ident.decode(parent.name).toLowerCase()) || !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' || name === 'imagesrcset') { 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;