@ui5/linter
Version:
A static code analysis tool for UI5
228 lines • 10.8 kB
JavaScript
import ts from "typescript";
import path from "node:path";
import posixPath from "node:path/posix";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import transpileAmdToEsm from "./amdTranspiler/transpiler.js";
import { getLogger } from "@ui5/logger";
const log = getLogger("linter:ui5Types:host");
const require = createRequire(import.meta.url);
function addPathMappingForPackage(pkgName, pathMapping) {
const pkgDir = path.dirname(require.resolve(`${pkgName}/package.json`));
pathMapping.set(pkgName, pkgDir);
}
async function collectTransitiveDependencies(pkgName, deps) {
const pkgJsonPath = require.resolve(`${pkgName}/package.json`);
const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, "utf8"));
if (pkgJson.dependencies) {
await Promise.all(Object.keys(pkgJson.dependencies).map(async (depName) => {
deps.add(depName);
const depDeps = await collectTransitiveDependencies(depName, deps);
depDeps.forEach((dep) => deps.add(dep));
}));
}
return deps;
}
async function collectSapui5TypesFiles() {
const typesDir = path.dirname(require.resolve("@sapui5/types/package.json"));
const allFiles = await fs.readdir(path.join(typesDir, "types"), { withFileTypes: true });
const typesFiles = [];
for (const entry of allFiles) {
if (entry.isFile() && entry.name.endsWith(".d.ts") && entry.name !== "index.d.ts") {
typesFiles.push(entry.name);
}
}
return typesFiles;
}
function addSapui5TypesMappingToCompilerOptions(sapui5TypesFiles, options, context, libraryDependencies) {
const projectNamespace = context.getNamespace();
const applyAutofix = context.getApplyAutofix();
const paths = options.paths ?? (options.paths = {});
sapui5TypesFiles.forEach((fileName) => {
// Reference custom linter type file instead of the @sapui5/types file as it also takes care of
// loading additional types, e.g. for pseudo modules
const dtsPath = `/types/@ui5/linter/types/sapui5/${fileName}`;
const libraryName = posixPath.basename(fileName, ".d.ts");
const libraryNamespace = libraryName.replace(/\./g, "/");
if (applyAutofix ||
libraryNamespace === "sap/ui/core" ||
libraryNamespace === projectNamespace ||
libraryDependencies?.[libraryName]) {
// Special cases:
// - Autofix needs all types to be able to lookup the modules that can be imported when replacing globals
// - sap.ui.core needs to be loaded by default as it provides general API that must always be available
// - When linting a framework library, the corresponding types should be loaded by default as they
// might not get loaded by the compiler otherwise, as the actual sources are available in the project.
// - When a library is listed in the manifest dependencies, it should be loaded by default as well to ensure
// deprecations of globals are detected.
options.types?.push(dtsPath);
}
// In every case, add a paths mapping to load them on demand and ensure loading them
// when the framework library itself is linted
const mappingNamespace = libraryNamespace + "/*";
const pathsEntry = paths[mappingNamespace] ?? (paths[mappingNamespace] = []);
pathsEntry.push(dtsPath);
});
}
const DEFAULT_COMPILER_OPTIONS = {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ES2022,
// Skip lib check to speed up linting. Libs should generally be fine,
// we might want to add a unit test doing the check during development
skipLibCheck: true,
// Include standard typescript libraries for ES2022 and DOM support
lib: ["lib.es2022.d.ts", "lib.dom.d.ts"],
// Disable lib replacement lookup as we don't rely on it
libReplacement: false,
// Allow and check JavaScript files since this is everything we'll do here
allowJs: true,
checkJs: false,
strict: true,
noImplicitAny: false,
strictNullChecks: false,
strictPropertyInitialization: false,
rootDir: "/",
// Library modules (e.g. sap/ui/core/library.js) do not have a default export but
// instead have named exports e.g. for enums defined in the module.
// However, in current JavaScript code (UI5 AMD) the whole object is exported, as there are no
// named exports outside of ES Modules / TypeScript.
// This property compensates this gap and tries to all usage of default imports where actually
// no default export is defined.
// NOTE: This setting should not be used when analyzing TypeScript code, as it would allow
// using an default import on library modules, which is not intended.
// A better solution:
// During transpilation, for every library module (where no default export exists),
// an "import * as ABC" instead of a default import is created.
// This logic needs to be in sync with the generator for UI5 TypeScript definitions.
allowSyntheticDefaultImports: true,
};
export async function createVirtualLanguageServiceHost(options, files, sourceMaps, context, projectScriptVersion, libraryDependencies) {
const compilerOptions = {
...DEFAULT_COMPILER_OPTIONS,
...options,
};
const silly = log.isLevelEnabled("silly");
compilerOptions.typeRoots = ["/types"];
compilerOptions.types = [];
const typePathMappings = new Map();
addPathMappingForPackage("typescript", typePathMappings);
const typePackages = new Set(["@sapui5/types"]);
await collectTransitiveDependencies("@sapui5/types", typePackages);
// Remove dependencies that are not needed for UI5 linter type checking:
typePackages.delete("@types/three"); // Used in sap.ui.vk, but is not needed for linter checks.
typePackages.delete("@types/offscreencanvas"); // Used by @types/three
typePackages.forEach((pkgName) => {
addPathMappingForPackage(pkgName, typePathMappings);
});
const typePackageDirs = Array.from(typePackages.keys()).map((pkgName) => `/types/${pkgName}/`);
typePackageDirs.push("/types/@ui5/linter/types");
typePathMappings.set("@ui5/linter/types", path.dirname(require.resolve("../../../resources/types/package.json")));
// Add all types except @sapui5/types which will be handled below
compilerOptions.types.push(...typePackageDirs.filter((dir) => dir !== "/types/@sapui5/types/"));
// Adds types / mappings for all @sapui5/types
addSapui5TypesMappingToCompilerOptions(await collectSapui5TypesFiles(), compilerOptions, context, libraryDependencies);
// Create regex matching all path mapping keys
const pathMappingRegex = new RegExp(`^\\/types\\/(${Array.from(typePathMappings.keys()).join("|").replaceAll("/", "\\/")})\\/(.*)`);
function mapToTypePath(fileName) {
const pkgName = fileName.match(pathMappingRegex);
if (pkgName && pkgName.length === 3) {
const mappedPath = typePathMappings.get(pkgName[1]);
if (mappedPath) {
return path.join(mappedPath, pkgName[2]);
}
}
}
function getFile(resourcePath) {
// NOTE: This function should be kept in sync with "fileExists"
if (files.has(resourcePath)) {
let fileContent = files.get(resourcePath);
if (fileContent && resourcePath.endsWith(".js") && !sourceMaps.get(resourcePath)) {
// No source map indicates no transpilation was done yet
const res = transpileAmdToEsm(resourcePath, fileContent, context);
files.set(resourcePath, res.source);
sourceMaps.set(resourcePath, res.map);
fileContent = res.source;
}
return fileContent;
}
if (resourcePath.startsWith("/types/")) {
const fsPath = mapToTypePath(resourcePath);
if (fsPath) {
if (silly) {
log.silly(`Reading type file from fs: ${fsPath}`);
}
return ts.sys.readFile(fsPath);
}
}
// console.log("Not found " + fileName);
}
if (silly) {
log.silly(`compilerOptions: ${JSON.stringify(compilerOptions, null, 2)}`);
}
return {
getCompilationSettings: () => {
return compilerOptions;
},
getScriptFileNames: () => {
if (silly) {
log.silly(`getScriptFileNames`);
}
return Array.from(files.keys());
},
getScriptVersion: (fileName) => {
if (silly) {
log.silly(`getScriptVersion: ${fileName}`);
}
// Note: The script version for the common files at /types/ is handled within the LanguageServiceHostProxy
// Currently we don't use incremental compilation within a project, so
// updating the script version is not necessary.
// However, as the language service is shared across multiple projects, we need
// to provide a version that is unique for each project to avoid impacting other
// projects that might use the same file path.
return projectScriptVersion;
},
getScriptSnapshot: (fileName) => {
if (silly) {
log.silly(`getScriptSnapshot: ${fileName}`);
}
const fileContent = getFile(fileName);
if (typeof fileContent === "string") {
return ts.ScriptSnapshot.fromString(fileContent);
}
return undefined;
},
fileExists: (fileName) => {
// NOTE: This function should be kept in sync with "getFile"
if (silly) {
log.silly(`fileExists: ${fileName}`);
}
if (files.has(fileName)) {
return true;
}
if (fileName.startsWith("/types/")) {
const fsPath = mapToTypePath(fileName);
if (fsPath) {
return ts.sys.fileExists(fsPath);
}
}
return false;
},
getCurrentDirectory: () => {
if (silly) {
log.silly(`getCurrentDirectory`);
}
return compilerOptions.rootDir ?? "/";
},
readFile: (fileName) => {
if (silly) {
log.silly(`readFile: ${fileName}`);
}
return getFile(fileName);
},
getDefaultLibFileName: (defaultLibOptions) => {
const defaultLibFileName = ts.getDefaultLibFileName(defaultLibOptions);
return "/types/typescript/lib/" + defaultLibFileName;
},
};
}
//# sourceMappingURL=host.js.map