@ui5/linter
Version:
A static code analysis tool for UI5
362 lines • 14.2 kB
JavaScript
import { graphFromObject } from "@ui5/project/graph";
import { createReader, createWorkspace, createReaderCollection, createFilterReader } from "@ui5/fs/resourceFactory";
import lintWorkspace from "./lintWorkspace.js";
import { taskStart } from "../utils/perf.js";
import path from "node:path";
import { stat } from "node:fs/promises";
import ConfigManager from "../utils/ConfigManager.js";
import { Minimatch } from "minimatch";
import { transformVirtualPathToFilePath } from "../utils/virtualPathToFilePath.js";
export async function lintProject({ rootDir, filePatterns, ignorePatterns, coverage, details, fix, configPath, ui5Config, noConfig, }, sharedLanguageService) {
if (!path.isAbsolute(rootDir)) {
throw new Error(`rootDir must be an absolute path. Received: ${rootDir}`);
}
let config = {};
if (noConfig !== true) {
const configMngr = new ConfigManager(rootDir, configPath);
config = await configMngr.getConfiguration();
}
// In case path is set both by CLI and config use CLI
ui5Config = ui5Config ?? config.ui5Config;
const projectGraphDone = taskStart("Project Graph creation");
const graph = await getProjectGraph(rootDir, ui5Config);
const project = graph.getRoot();
projectGraphDone();
let virBasePath = "/resources/";
if (!project._isSourceNamespaced) {
// Ensure the virtual filesystem includes the project namespace to allow relative imports
// of framework resources from the project
virBasePath += project.getNamespace() + "/";
}
const fsBasePath = project.getSourcePath();
let reader = createReader({
fsBasePath,
virBasePath,
});
let virBasePathTest;
let fsBasePathTest;
if (project._testPathExists) {
fsBasePathTest = path.join(project.getRootPath(), project._testPath);
virBasePathTest = "/test-resources/";
if (!project._isSourceNamespaced) {
// Dynamically add namespace if the physical project structure does not include it
// This logic is identical to the specification implementation in ui5-project
virBasePathTest += project.getNamespace() + "/";
}
reader = createReaderCollection({
readers: [reader, createReader({
fsBasePath: fsBasePathTest,
virBasePath: virBasePathTest,
})],
});
}
const relFsBasePath = path.relative(rootDir, fsBasePath);
const relFsBasePathTest = fsBasePathTest ? path.relative(rootDir, fsBasePathTest) : undefined;
const res = await lint(reader, {
rootDir,
namespace: project.getNamespace(),
filePatterns,
ignorePatterns,
coverage,
details,
fix,
configPath,
noConfig,
ui5Config,
relFsBasePath, virBasePath, relFsBasePathTest, virBasePathTest,
}, config, sharedLanguageService);
res.forEach((result) => {
result.filePath = transformVirtualPathToFilePath(result.filePath, {
relFsBasePath, virBasePath, relFsBasePathTest, virBasePathTest,
});
});
// Sort by filePath after the virtual path has been converted back to ensure deterministic and sorted output.
// Differences in order can happen as different linters (e.g. xml, json, html, ui5.yaml) are executed in parallel.
sortLintResults(res);
return res;
}
export async function lintFile({ rootDir, filePatterns, ignorePatterns, namespace, coverage, details, fix, configPath, noConfig, }, sharedLanguageService) {
let config = {};
if (noConfig !== true) {
const configMngr = new ConfigManager(rootDir, configPath);
config = await configMngr.getConfiguration();
}
const virBasePath = namespace ? `/resources/${namespace}/` : "/";
const reader = createReader({
fsBasePath: rootDir,
virBasePath,
});
const res = await lint(reader, {
rootDir,
namespace,
filePatterns,
ignorePatterns,
coverage,
details,
fix,
configPath,
relFsBasePath: "",
virBasePath,
}, config, sharedLanguageService);
res.forEach((result) => {
result.filePath = transformVirtualPathToFilePath(result.filePath, {
relFsBasePath: "",
virBasePath: "/",
});
});
// Sort by filePath after the virtual path has been converted back to ensure deterministic and sorted output.
// Differences in order can happen as different linters (e.g. xml, json, html, ui5.yaml) are executed in parallel.
sortLintResults(res);
return res;
}
async function lint(resourceReader, options, config, sharedLanguageService) {
const lintEnd = taskStart("Linting");
let { ignorePatterns, filePatterns } = options;
// Resolve files to include
filePatterns = filePatterns ?? config.files ?? [];
// We cannot predict the outcome of the matchers, so we remember the used patterns
// and later compare them against the provided filePatterns to find patterns
// that didn't match any file
const matchedPatterns = new Set();
ignorePatterns = mergeIgnorePatterns(options, config);
// Apply ignores to the workspace reader.
// TypeScript needs the full context to provide correct analysis.
// so, we can do filtering later via the filePathsReader
const reader = resolveReader({
patterns: ignorePatterns,
resourceReader,
patternsMatch: matchedPatterns,
fsToVirtualPathOptions: options,
});
// Apply files + ignores over the filePaths reader
let filePathsReader = resolveReader({
patterns: filePatterns,
resourceReader,
inverseResult: true,
patternsMatch: matchedPatterns,
fsToVirtualPathOptions: options,
});
filePathsReader = resolveReader({
patterns: ignorePatterns,
resourceReader: filePathsReader,
patternsMatch: matchedPatterns,
fsToVirtualPathOptions: options,
});
const filePathsWorkspace = createWorkspace({ reader: filePathsReader });
const workspace = createWorkspace({
reader,
});
const res = await lintWorkspace(workspace, filePathsWorkspace, options, config, matchedPatterns, sharedLanguageService);
checkUnmatchedPatterns(filePatterns, matchedPatterns);
lintEnd();
return res;
}
async function getProjectGraph(rootDir, ui5Config) {
let rootConfigPath, rootConfiguration;
let ui5YamlPath;
if (typeof ui5Config !== "object") {
ui5YamlPath = ui5Config ? path.join(rootDir, ui5Config) : path.join(rootDir, "ui5.yaml");
}
if (typeof ui5Config === "object") {
rootConfiguration = ui5Config;
}
else if (ui5YamlPath && await fileExists(ui5YamlPath)) {
rootConfigPath = ui5YamlPath;
}
else {
if (ui5Config)
throw new Error(`Unable to find UI5 config file '${ui5Config}'`);
const dirChecks = await Promise.all([
dirExists(path.join(rootDir, "webapp")),
dirExists(path.join(rootDir, "src", "main", "webapp")),
dirExists(path.join(rootDir, "src", "main", "jslib")),
dirExists(path.join(rootDir, "src", "main", "js")),
dirExists(path.join(rootDir, "src", "main", "uilib")),
dirExists(path.join(rootDir, "src")),
dirExists(path.join(rootDir, "WebContent")),
]);
if (dirChecks[0]) {
// Common app with webapp folder
rootConfiguration = createProjectConfig("application", "webapp");
}
else if (dirChecks[1]) {
// Legacy app with src/main/webapp folder
rootConfiguration = createProjectConfig("application", "src/main/webapp");
}
else if (dirChecks[2]) {
// Library with src/main/jslib folder
rootConfiguration = createProjectConfig("library", "src/main/jslib", "src/test/jslib");
}
else if (dirChecks[3]) {
// Library with src/main/js folder
rootConfiguration = createProjectConfig("library", "src/main/js", "src/test/js");
}
else if (dirChecks[4]) {
// Library with src/main/uilib folder
rootConfiguration = createProjectConfig("library", "src/main/uilib", "src/test/uilib");
}
else if (dirChecks[5]) {
// Library with src folder
rootConfiguration = createProjectConfig("library", "src", "test");
}
else if (dirChecks[6]) {
// Legacy app with WebContent folder
rootConfiguration = createProjectConfig("application", "WebContent");
}
}
if (!rootConfigPath && !rootConfiguration) {
throw new Error(`Unable to find a UI5 project at ${rootDir}. \n` +
`Please make sure to run "ui5lint" in the root directory of your UI5 project.`);
}
return graphFromObject({
dependencyTree: {
id: "ui5-linter-target",
version: "1.0.0",
path: rootDir,
dependencies: [],
},
rootConfigPath,
rootConfiguration,
resolveFrameworkDependencies: false,
});
}
function createProjectConfig(projectType, projectSrcPath, projectTestPath) {
let resourcesConfig = {
configuration: {
paths: {},
},
};
if (projectType === "application") {
resourcesConfig.configuration.paths.webapp = projectSrcPath ?? "webapp";
}
else if (projectType === "library") {
resourcesConfig.configuration.paths.src = projectSrcPath ?? "src";
resourcesConfig.configuration.paths.test = projectTestPath ?? "test";
}
else {
// Do not set a resources configuration for other project types
resourcesConfig = undefined;
}
return {
specVersion: "4.0",
type: projectType,
metadata: {
name: "ui5-linter-project",
},
resources: resourcesConfig,
};
}
async function fsStat(fsPath) {
try {
return await stat(fsPath);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
catch (err) {
// "File or directory does not exist"
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (err.code === "ENOENT") {
return false;
}
else {
throw err;
}
}
}
async function dirExists(dirPath) {
const stats = await fsStat(dirPath);
return stats && stats.isDirectory();
}
async function fileExists(dirPath) {
const stats = await fsStat(dirPath);
return stats && stats.isFile();
}
function sortLintResults(lintResults) {
lintResults.sort((a, b) => a.filePath.localeCompare(b.filePath));
}
function isFileIncluded(file, patterns, patternsMatch) {
let include = true;
for (const pattern of patterns) {
if (pattern.negate && pattern.match(file)) {
patternsMatch.add(pattern.pattern);
include = true; // re-include it
}
else if (pattern.match(file)) { // Handle inclusion: exclude if it matches
patternsMatch.add(pattern.pattern);
include = false;
}
}
return include;
}
function buildPatterns(patterns) {
// Patterns must be only relative (to project's root),
// otherwise throw an error
return patterns.map((pattern) => {
let notNegatedPattern = pattern;
if (pattern.startsWith("!")) {
notNegatedPattern = pattern.slice(1);
}
if (path.isAbsolute(notNegatedPattern)) {
throw Error(`Pattern must be relative to project's root folder. ` +
`"${pattern}" defines an absolute path.`);
}
if (pattern.endsWith("/")) { // Match all files in a directory
pattern += "**/*";
}
// Remove leading "./" from the pattern, as it otherwise would not match
if (pattern.startsWith("./")) {
pattern = pattern.slice(2);
}
return new Minimatch(pattern, { flipNegate: true });
});
}
export function resolveReader({ patterns, resourceReader, inverseResult = false, patternsMatch, fsToVirtualPathOptions, }) {
if (!patterns.length) {
return resourceReader;
}
const minimatchPatterns = buildPatterns(patterns);
return createFilterReader({
reader: resourceReader,
callback: (resource) => {
// Minimatch works with FS and relative paths.
// So, we need to convert virtual paths to fs
const resPath = transformVirtualPathToFilePath(resource.getPath(), fsToVirtualPathOptions);
return inverseResult ?
// When we work with files paths we actually need to limit the result to those
// matches, instead of allowing all except XYZ
!isFileIncluded(resPath, minimatchPatterns, patternsMatch) :
isFileIncluded(resPath, minimatchPatterns, patternsMatch);
},
});
}
/**
* Checks which patterns were not matched during analysis
*
* @param patterns Available patterns
* @throws Error if an unmatched pattern is found
*/
function checkUnmatchedPatterns(patterns, patternsMatch) {
const unmatchedPatterns = patterns.reduce((acc, pattern) => {
if (pattern.endsWith("/")) { // Match all files in a directory
pattern += "**/*";
}
if (!patternsMatch.has(pattern)) {
acc.push(pattern);
}
return acc;
}, []);
if (unmatchedPatterns.length) {
throw new Error(`Specified file ${unmatchedPatterns.length === 1 ? "pattern" : "patterns"}` +
` '${unmatchedPatterns.join("', '")}' did not match any resource`);
}
}
export function mergeIgnorePatterns(options, config) {
return [
...(config.ignores ?? []),
...(options.ignorePatterns ?? []), // CLI patterns go after config patterns
].filter(($) => $);
}
// Export local function for testing only
export const __localFunctions__ = (process.env.NODE_ENV === "test") ?
{ getProjectGraph } :
/* istanbul ignore next */ undefined;
//# sourceMappingURL=linter.js.map