UNPKG

@ui5/linter

Version:

A static code analysis tool for UI5

415 lines 20 kB
import ts from "typescript"; import path from "node:path/posix"; import { getLogger } from "@ui5/logger"; import { ChangeAction } from "./autofix.js"; import { getPropertyNameText } from "../linter/ui5Types/utils/utils.js"; const log = getLogger("linter:autofix"); const LINE_LENGTH_LIMIT = 200; function resolveRelativeDependency(dependency, moduleName) { return path.resolve("/" + path.dirname(moduleName), dependency).substring(1); } export const NO_PARAM_FOR_DEPENDENCY = Symbol("noParamForDependency"); export const UNSUPPORTED_PARAM_FOR_DEPENDENCY = Symbol("unsupportedParamForDependency"); export function hasBody(moduleDeclaration) { if ("factory" in moduleDeclaration) { return !!moduleDeclaration.factory; } else if ("callback" in moduleDeclaration) { return !!moduleDeclaration.callback; } log.verbose(`Encountered a module declaration with no factory or callback`); return false; } export function getFactoryBody(moduleDeclaration) { const { moduleDeclaration: declaration } = moduleDeclaration; if ("factory" in declaration && declaration.factory && "body" in declaration.factory) { return declaration.factory.body; } else if ("callback" in declaration && declaration.callback) { return declaration.callback.body; } log.verbose(`Encountered a module declaration with no factory or callback`); return undefined; } export function getDependencies(moduleDeclaration, resourcePath) { const dependencies = new Map(); // Module name to identifier if (!moduleDeclaration.dependencies) { return dependencies; } const factory = "factory" in moduleDeclaration ? moduleDeclaration.factory : moduleDeclaration.callback; if (!factory || !ts.isFunctionLike(factory)) { throw new Error("Invalid factory function in module declaration"); } const dependencyArray = moduleDeclaration.dependencies; const moduleName = resourcePath.startsWith("/resources/") ? resourcePath.substring("/resources/".length) : undefined; for (let i = 0; i < dependencyArray.elements.length; i++) { const element = dependencyArray.elements[i]; if (!ts.isStringLiteralLike(element)) { continue; } let dependencyText = element.text; if (moduleName && dependencyText.startsWith(".")) { dependencyText = resolveRelativeDependency(dependencyText, moduleName); } if (!dependencies.has(dependencyText)) { const param = factory.parameters[i]; let identifier; if (!param) { identifier = NO_PARAM_FOR_DEPENDENCY; } else if (ts.isIdentifier(param.name)) { identifier = param.name.text; } else { // Some sort of binding pattern, e.g. ({foo, bar}) => { or ([foo, bar]) => { identifier = UNSUPPORTED_PARAM_FOR_DEPENDENCY; } dependencies.set(dependencyText, identifier); } } return dependencies; } export function createDependencyInfo(dependencyArray, resourcePath) { const moduleName = resourcePath.startsWith("/resources/") ? resourcePath.substring("/resources/".length) : undefined; const dependencyMap = new Map(); const quoteStyleCount = { "`": 0, "'": 0, "\"": 0, // Double quotes will be prioritized if tied }; if (dependencyArray) { let syntaxList; for (const child of dependencyArray.getChildren()) { if (child.kind === ts.SyntaxKind.SyntaxList) { syntaxList = child; break; } } if (!syntaxList) { throw new Error("SyntaxList not found in dependencies array"); } let previousComma; let previousDependency; for (const child of syntaxList.getChildren()) { if (child.kind === ts.SyntaxKind.CommaToken) { if (previousDependency) { previousDependency.trailingComma = child; previousDependency = undefined; } previousComma = child; } if (!ts.isStringLiteralLike(child)) { continue; } // In case of duplicate imports, we only use the first one. // As the map is only used for a lookup for reusing existing imports and not // as a exhaustive list of dependencies, this is fine. let dependencyText = child.text; if (moduleName && dependencyText.startsWith(".")) { dependencyText = resolveRelativeDependency(dependencyText, moduleName); } if (!dependencyMap.has(dependencyText)) { previousDependency = { node: child, index: dependencyArray.elements.indexOf(child) }; if (previousComma) { previousDependency.leadingComma = previousComma; previousComma = undefined; } dependencyMap.set(dependencyText, previousDependency); } // Check which quote style format is used for existing dependencies // and use the same for the new dependencies (majority wins): if (child.getText().startsWith("'")) { quoteStyleCount["'"]++; } else if (child.getText().startsWith("\"")) { quoteStyleCount["\""]++; } else if (ts.isNoSubstitutionTemplateLiteral(child)) { quoteStyleCount["`"]++; } } } const mostUsedQuoteStyle = Object.entries(quoteStyleCount).reduce((a, b) => a[1] > b[1] ? a : b)[0]; return { dependencyMap, mostUsedQuoteStyle }; } function getParameterSyntax(node) { let syntaxList; let hasParens = false; for (const child of node.getChildren()) { if (child.kind === ts.SyntaxKind.SyntaxList) { syntaxList = child; } if (child.kind === ts.SyntaxKind.OpenParenToken || child.kind === ts.SyntaxKind.CloseParenToken) { hasParens = true; } } if (syntaxList) { return { syntaxList, hasParens }; } else { throw new Error("SyntaxList not found in factory function"); } } export function addDependencies(defineCall, moduleDeclarationInfo, changeSet, resourcePath, removedDependencies) { const { moduleDeclaration, importRequests } = moduleDeclarationInfo; if (importRequests.size === 0) { return; } const factory = "factory" in moduleDeclaration ? moduleDeclaration.factory : moduleDeclaration.callback; if (!factory || !ts.isFunctionLike(factory)) { throw new Error("Invalid factory function"); } const moduleName = "moduleName" in moduleDeclaration ? moduleDeclaration.moduleName : undefined; const dependencies = moduleDeclaration.dependencies?.elements; const remainingDependencies = []; const { dependencyMap, mostUsedQuoteStyle } = createDependencyInfo(moduleDeclaration.dependencies, resourcePath); const removedDepsIndices = dependencies?.reduce((acc, dep, index) => { if (ts.isPropertyName(dep) && removedDependencies.has(getPropertyNameText(dep) ?? "")) { acc.push(index); } else { // If the dependency is not removed, we need to keep it remainingDependencies.push(dep); } return acc; }, []) ?? []; let numberOfDependencies = remainingDependencies.length; const parameters = factory.parameters.filter((_dep, index) => !removedDepsIndices.includes(index)); const parameterSyntax = getParameterSyntax(factory); const newDependencies = []; // Prefer using the second element for separator detection as the first one might not have a line-break before it const depsSeparator = extractIdentifierSeparator(dependencies?.[1]?.getFullText() ?? dependencies?.[0]?.getFullText() ?? ""); const identifiersSeparator = extractIdentifierSeparator(factory.parameters[1]?.getFullText() ?? factory.parameters[0]?.getFullText() ?? ""); // Calculate after which index we can add new dependencies / parameters let insertDependenciesAfterIndex = Math.min(parameters.length, remainingDependencies.length) - 1; const insertParametersAfterIndex = insertDependenciesAfterIndex; // Check whether requested imports are already available in the list of dependencies for (const [dependencyModuleName, importRequest] of importRequests) { const existingDependency = removedDependencies.has(dependencyModuleName) ? undefined : dependencyMap.get(dependencyModuleName); // Add a new dependency newDependencies.push({ existingDependency, moduleName: dependencyModuleName, identifier: importRequest.identifier, }); } // Sort by existing dependency index, then by module name // Sorting by index is needed to properly check for re-using existing dependencies newDependencies.sort((a, b) => { if (a.existingDependency && b.existingDependency) { return a.existingDependency.index - b.existingDependency.index; } else if (a.existingDependency) { return -1; } else if (b.existingDependency) { return 1; } else { return a.moduleName.localeCompare(b.moduleName); } }); // Check whether existing dependencies without factory parameters can be reused or need to be re-added newDependencies.forEach((newDependency) => { const existingDependency = newDependency.existingDependency; if (!existingDependency) { return; } if (existingDependency.index === insertDependenciesAfterIndex + 1) { // The dependency is already in the correct position, so we can skip adding it again insertDependenciesAfterIndex++; newDependency.dependencyExists = true; } else { // We need to remove the existing dependency and re-add it at the correct position let start = existingDependency.node.getFullStart(); let end = existingDependency.node.getEnd(); // Leading comma means, it's not the first dependency, so we also remove the comma before // the dependency if (existingDependency.leadingComma) { start = existingDependency.leadingComma.getFullStart(); } else if (existingDependency.trailingComma) { // First dependency, but there might be more, so we also remove the comma after the dependency end = existingDependency.trailingComma.getEnd(); } changeSet.push({ action: ChangeAction.DELETE, start, end }); dependencyMap.delete(newDependency.moduleName); // Update number of dependencies numberOfDependencies--; remainingDependencies.splice(existingDependency.index, 1); // Ensure that the new dependency will be the same, e.g. in case it is a relative path newDependency.moduleName = existingDependency.node.text; } }); // Add new dependencies if (newDependencies.length) { const newDependencyValue = newDependencies.filter(($) => !$.dependencyExists).map((newDependency) => { return `${mostUsedQuoteStyle}${newDependency.moduleName}${mostUsedQuoteStyle}`; }).join(depsSeparator.trailing); if (newDependencyValue) { const insertAfterDependencyElement = remainingDependencies[insertDependenciesAfterIndex]; if (insertAfterDependencyElement || (dependencies && insertDependenciesAfterIndex === -1)) { const existingDependenciesLeft = insertDependenciesAfterIndex > -1 && numberOfDependencies > 0; const existingDependenciesRight = insertDependenciesAfterIndex === -1 && numberOfDependencies > 0; let value = existingDependenciesLeft ? depsSeparator.trailing : depsSeparator.leading; value += newDependencyValue; if (existingDependenciesRight) { if (depsSeparator.trailing.includes("\n")) { value += ","; } else { // Ensure to add a new space in case there are no line-breaks for separation value += ", "; } } const start = insertAfterDependencyElement?.getEnd() ?? dependencies?.pos; if (start === undefined) { throw new Error("Cannot determine start position for inserting dependencies"); } changeSet.push({ action: ChangeAction.INSERT, start, value: formatDependencies(value, depsSeparator.trailing, { pos: start, node: defineCall }), }); } else { // No dependencies array found, add a new one right before the factory function const start = (moduleName ? defineCall.arguments[1] : defineCall.arguments[0]).getStart(); changeSet.push({ action: ChangeAction.INSERT, start, value: `[${formatDependencies(newDependencyValue, depsSeparator.trailing, { pos: start, node: defineCall })}], `, }); } } let newParametersValue = newDependencies.map((newDependency) => { return newDependency.identifier; }).join(identifiersSeparator.trailing); if (!parameterSyntax.hasParens) { changeSet.push({ action: ChangeAction.INSERT, start: parameterSyntax.syntaxList.getStart(), value: "(", }); newParametersValue += ")"; } const insertAfterParameterDeclaration = parameters[insertParametersAfterIndex]; const existingParameterLeft = insertParametersAfterIndex > -1 && parameters.length > 0; const existingParameterRight = insertParametersAfterIndex === -1 && parameters.length > 0; let value = existingParameterLeft ? (identifiersSeparator.trailing + newParametersValue) : newParametersValue; value += existingParameterRight ? ", " : ""; const start = insertAfterParameterDeclaration?.getEnd() ?? parameterSyntax.syntaxList.getStart(); changeSet.push({ action: ChangeAction.INSERT, start, value: formatDependencies(value, identifiersSeparator.trailing, { pos: start, node: defineCall }), }); } } export function removeDependencies(dependenciesToRemove, moduleDeclarationInfo, changeSet, resourcePath, declaredIdentifiers) { const { moduleDeclaration } = moduleDeclarationInfo; const { dependencies } = moduleDeclaration; const factory = "factory" in moduleDeclaration ? moduleDeclaration.factory : moduleDeclaration.callback; const { dependencyMap } = createDependencyInfo(dependencies, resourcePath); [...dependencyMap] .filter(([moduleName]) => dependenciesToRemove.has(moduleName)) .forEach(([, existingDependency]) => { // Update module dependencies removal const { index } = existingDependency; if (index >= 0 && dependencies?.elements[index]) { // Update node information const newElements = [...dependencies.elements]; newElements.splice(index, 1); ts.factory.updateArrayLiteralExpression(dependencies, ts.factory.createNodeArray(newElements)); // Update the source let start = existingDependency.node.getFullStart(); let end = existingDependency.node.getEnd(); if (existingDependency.leadingComma) { start = existingDependency.leadingComma.getFullStart(); } else if (existingDependency.trailingComma) { end = existingDependency.trailingComma.getEnd(); } changeSet.push({ action: ChangeAction.DELETE, start, end }); } // Update identifiers if (index >= 0 && factory && (ts.isFunctionExpression(factory) || ts.isFunctionDeclaration(factory) || ts.isArrowFunction(factory)) && factory.parameters[index]) { const syntaxList = getParameterSyntax(factory); const syntaxChildren = syntaxList.syntaxList.getChildren(); const identifier = factory.parameters[index].getText(); const newParams = [...factory.parameters]; const [removedParam] = newParams.splice(index, 1); const removedParamIndex = syntaxChildren.indexOf(removedParam); let start = removedParam.getFullStart(); let end = removedParam.getEnd(); if (syntaxChildren[removedParamIndex - 1]?.kind === ts.SyntaxKind.CommaToken) { start = syntaxChildren[removedParamIndex - 1].getFullStart(); } else if (syntaxChildren[removedParamIndex + 1]?.kind === ts.SyntaxKind.CommaToken) { end = syntaxChildren[removedParamIndex + 1].getEnd(); } // Update the source changeSet.push({ action: ChangeAction.DELETE, start, end }); // Update the factory function node info if (ts.isFunctionExpression(factory)) { ts.factory.updateFunctionExpression(factory, factory.modifiers, factory.asteriskToken, factory.name, factory.typeParameters, ts.factory.createNodeArray(newParams), factory.type, factory.body); } else if (ts.isFunctionDeclaration(factory)) { ts.factory.updateFunctionDeclaration(factory, factory.modifiers, factory.asteriskToken, factory.name, factory.typeParameters, ts.factory.createNodeArray(newParams), factory.type, factory.body); } else if (ts.isArrowFunction(factory)) { ts.factory.updateArrowFunction(factory, factory.modifiers, factory.typeParameters, ts.factory.createNodeArray(newParams), factory.type, factory.equalsGreaterThanToken, factory.body); } // Update identifiers Set if (identifier && declaredIdentifiers.has(identifier)) { declaredIdentifiers.delete(identifier); } } }); } function extractIdentifierSeparator(input) { const match = /^(\s)+/.exec(input); let leading = ""; if (match?.[0].includes("\n")) { // Only use the leading whitespace if it contains a line-break // Otherwise a single space might be used because the extraction is // done on the 2nd element in the array, if it exists leading = match[0]; } return { leading, trailing: match ? `,${match[0]}` : ", ", }; } function formatDependencies(input, depsSeparator, posInfo) { const { node, pos } = posInfo; const { character } = node.getSourceFile().getLineAndCharacterOfPosition(pos); let offset = character + 1; // If the input is multiline, we don't need to format it if (depsSeparator.includes("\n") || (input.length + offset) <= LINE_LENGTH_LIMIT) { return input; } const inputChunks = input.split(","); let finalString = ""; let curLineLength = 0; inputChunks.forEach((chunk) => { if ((curLineLength + chunk.length + offset) > LINE_LENGTH_LIMIT) { curLineLength = 0; finalString += `\n`; // The offset is important only for the first line offset = 0; } finalString += `${chunk},`; curLineLength += chunk.length; }); return finalString.substring(0, finalString.length - 1); // Cut the last comma } //# sourceMappingURL=amdImports.js.map