@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
451 lines (412 loc) • 15.1 kB
JavaScript
const term = require("../util/term");
const io = require("./io");
const checks = require("./checks");
const generators = require("./generators");
const cds = require("../../lib/cds");
const { path, exists, isdir, isfile, readdir } = cds.utils
const { exit } = require("process");
const LOG = cds.debug("lint");
const LOG_CONFIG = cds.debug("lint:config");
const GENERAL_ERROR = 1;
const FATAL_ERROR = 2;
/**
* Safely require a module, return empty object if module not found
* @param {string} module - module to require
* @param {boolean} esm - whether to use dynamic import to accomodate ESM
* @returns {Promise<ReturnType<NodeRequire> | {}>} module or empty object
*/
async function tryRequire(module, esm = false) {
try {
return esm ? import(module) : require(module)
} catch (e) {
if (e.code === 'MODULE_NOT_FOUND' && e.message.includes(module)) return {}
// target module is either .mjs (etc) or user project is ESM
if (['ERR_REQUIRE_ASYNC_MODULE', 'ERR_REQUIRE_ESM'].includes(e.code)) return tryRequire(module, true)
throw e
}
}
/**
* Load ESLint module
* @param {string} currentDir - current directory to start search
* @returns {Promise<ReturnType<NodeRequire>>} - ESLint module
*/
async function _loadEslint(currentDir) {
const { loadESLint, ESLint } = require('eslint')
const DefaultESLint = loadESLint
? await loadESLint({ cwd: currentDir }) // ESLint 9
: new ESLint({ cwd: currentDir }) // ESLint 8
return DefaultESLint;
}
/**
* @param {string[]} configFiles - possible config file paths
* @param {string} currentDir - current directory to start search
* @returns {string} - path to config file
*/
async function findConfigPath (configFiles, currentDir = '.') {
let configDir = path.resolve(currentDir)
while (configDir && configDir !== path.dirname(configDir)) {
for (const configFile of configFiles) {
const configPath = path.join(configDir, configFile)
const config = await tryRequire(configPath)
if (configFile !== 'package.json' || config?.eslintConfig) {
if (exists(configPath) && isfile(configPath)) { // TODO exists neccessary ?
return configPath
}
}
}
configDir = path.dirname(configDir);
}
return ''
}
/**
* This class and its subclasses are an attempt to smoothen out some of the
* workflow in the Linter class. It gets rid of some of the isESLintLegacy ? ... : ...
* assignments, although not all. The remaining branching is too closely coupled
* to some properties of the Linter class to be easily abstracted away.
* @abstract
*/
class ESLintLoader {
/**
* Searches for ESLint config file types (in order or precedence)
* and returns corresponding directory (usually project's root dir)
* https://eslint.org/docs/user-guide/configuring#configuration-file-formats
* @param {string} currentDir start here and search until root dir
* @returns {string} dir containing ESLint config file (empty if not exists)
* @abstract
*/
// eslint-disable-next-line no-unused-vars
async getConfigPath (currentDir = '.') { throw new Error("Not implemented") }
/** @type {import('./io').sanitizeEslintConfig} */
// eslint-disable-next-line no-unused-vars
sanitizeEslintConfig (configIn, customRuleExample, logger) { throw new Error("Not implemented") }
/** @type {import('./io').readEslintConfig} */
// eslint-disable-next-line no-unused-vars
readEslintConfig(configPath) { throw new Error("Not implemented") }
}
class ESLint8Loader extends ESLintLoader {
async getConfigPath(currentDir = '.') {
return findConfigPath([
".eslintrc.js",
".eslintrc.cjs",
".eslintrc.yaml",
".eslintrc.yml",
".eslintrc.json",
".eslintrc",
"package.json",
], currentDir)
}
sanitizeEslintConfig (configIn, customRuleExample, logger) {
return io.sanitizeEslintConfigLegacy(configIn, customRuleExample, logger)
}
readEslintConfig(configPath) {
return io.readEslintConfigLegacy(configPath)
}
}
class ESLint9Loader extends ESLintLoader {
async getConfigPath(currentDir = '.') {
return findConfigPath([
"eslint.config.js",
"eslint.config.cjs",
"eslint.config.mjs",
// the following 3 formats require additional setup by the user as of today,
// see https://eslint.org/docs/latest/use/configure/configuration-files#typescript-configuration-files
"eslint.config.ts",
"eslint.config.mts",
"eslint.config.cts",
], currentDir)
}
sanitizeEslintConfig (configIn, customRuleExample, logger) {
return io.sanitizeEslintConfig(configIn, customRuleExample, logger)
}
readEslintConfig(configPath) {
return io.readEslintConfig(configPath)
}
}
class Linter {
/** @type {string | undefined} */
eslintCmd = "";
/** @type {string} */
eslintCmdShort = "eslint";
/** @type {string | string[]} */
eslintCmdFileExpr = "";
/** @type {boolean} */
isFile = false;
/** @type {string} */
help = "";
/** @type {string} */
debug = "";
/** @type {string[]} */
flags = [];
/** @type {string} */
configPath = "";
/** @type {object} */
configContents = {};
/** @type {import("fs").PathLike} */
cdsdkPath = path.join(__dirname, "../..");
/** @type {import("fs").PathLike} */
globalPath = path.join(this.cdsdkPath, "../..");
/** @type {boolean} */
extendsPlugin = false;
/** @type {string[]} */
fileExtensions = [];
/** @type {string} */
pluginPath = "";
/** @type {object} */
pluginApi = {};
/** @type {'global' | 'local'} */
lintType = "global";
/** @type {object} */
ruleOpts = {};
/** @type {array} */
customRulesOpts = [];
/** @type {array} */
pluginRules = [];
/** @type {ESLintLoader | undefined} */
#loader = undefined;
get loader () {
return this.#loader ??= this.isESLintLegacy
? new ESLint8Loader()
: new ESLint9Loader()
};
/**
* Initializes `cds lint` call and generates the required content
* object for `cds` executable
* @returns object containing help, options, flags and shortcuts
*/
init(help = "", options = [], flags = [], shortcuts = []) {
this.eslintCmd = checks.resolveEslint(this.cdsdkPath, this.globalPath);
if (this.eslintCmd) {
help = generators.genEslintHelp(this.eslintCmd);
flags = generators.genEslintFlags(help);
[help, options, shortcuts, flags] = generators.genEslintShortcutsAndOpts(help, flags);
this.help = help;
this.flags = flags;
} else {
console.log(`${term.error("Cannot call 'eslint -h', install and try again.")}\n`);
}
return { help, options, flags, shortcuts };
}
/**
* Runner for 'cds lint' which is a wrapper for eslint cmd calls
* that detects and adds the required cmd line arguments
* @param {string[]} args files/globs for eslint to lint
* @param {{[key: string]: unknown}} options options/flag passed by user
*/
async lint(args, options) {
// Check for ESLint (legacy)
const currentDir = process.cwd();
const DefaultESLint = await _loadEslint(currentDir)
this.isESLintLegacy = DefaultESLint.configType === "eslintrc";
this.eslintCmdOpts = options; // these should probably be passed on in #runEsLint!
this.eslintCmdFileExpr = args.length ? args : ["."];
if (this.eslintCmdOpts.help) {
this.#printHelp();
return
}
// Determine if case "local" or "global" and resolve plugin accordingly
await this.#getConfigFileAndSetProjectPath(args);
if (this.pluginPath) {
const overwriteRules = checks.hasEslintConfigContent(this.configContents, "rules");
// Overwrite rules severities
if (overwriteRules) {
await this.#overwriteRuleSeverities();
}
// Limit to CDS file extensions
this.#addExtensions();
}
// Run ESLint with collected options
try {
await this.#runEslint(currentDir, DefaultESLint);
} catch (err) {
term.error(err);
}
}
/**
* Prints help message
*/
#printHelp() {
console.log(this.help.replace(/ \*([^*]+)\*/g, ` ${term.codes.bold}$1${term.codes.reset}`));
}
#addExtensions() {
// Add CDS file extensions to lint
this.fileExtensions = this.pluginApi.getFileExtensions()
.map((ext) => path.extname(ext));
// Only lint file extensions prescribed by plugin
this.ignorePatterns = this.fileExtensions
.map(ext => `!${ext}`)
}
async #runEslint(currentDir, DefaultESLint) {
try {
const eslintOpts = this.lintType === "global"
? {
cwd: process.cwd(),
overrideConfig: {},
}
: {
cwd: currentDir,
overrideConfig: {...this.configContents},
}
if (this.isESLintLegacy) {
LOG?.("Using legacy ESLint configuration");
eslintOpts.extensions = this.fileExtensions;
eslintOpts.overrideConfig.extends = "plugin:@sap/cds/recommended-legacy"
eslintOpts.overrideConfig.plugins = ["@sap/eslint-plugin-cds"]
eslintOpts.useEslintrc = false
if (this.lintType === "global") {
eslintOpts.resolvePluginsRelativeTo = this.cdsdkPath
}
} else {
LOG?.("Using flat ESLint configuration");
const cdsPlugin = require(this.pluginPath)
// Exclude our own apis from plugin to avoid wrapper errors
delete cdsPlugin.configs.recommended?.plugins?.['@sap/cds']
eslintOpts.overrideConfig = [cdsPlugin.configs.recommended]
}
if (this.customRulesOpts && this.customRulesOpts.length > 0) {
eslintOpts.rulePaths = [this.customRulesOpts]
}
LOG_CONFIG?.(eslintOpts);
if (LOG) {
let lintString = `eslint`;
if (this.fileExtensions) {
lintString += ` --ext "${this.fileExtensions.join(",")}"`
}
for (const [name, rule] of Object.entries(this.ruleOpts)) {
lintString += ` --rule ${name}:${rule}`
}
if (this.customRulesOpts && this.customRulesOpts.length > 0) {
lintString += ` --rulesdir "${this.customRulesOpts}"`
}
LOG(lintString);
}
if (!process.env.isTest) {
const eslint = new DefaultESLint(eslintOpts);
const formatter = await eslint.loadFormatter("stylish");
let results = (await eslint.lintText("")).map((result) => {
result.filePath = path.resolve(currentDir);
return result;
}).filter(result => result.messages.length > 0);
const files = await readdir(currentDir);
const hasFiles = files.some(file => isfile(file));
if (hasFiles) {
const resultsModel = await eslint.lintFiles(this.eslintCmdFileExpr);
results = results.concat(resultsModel);
}
if (results?.length > 0) {
console.log(formatter.format(results));
const { errorCount, fatalErrorCount } = this.#countErrors(results);
if (fatalErrorCount > 0) {
exit(FATAL_ERROR);
} else if (errorCount > 0) {
exit(GENERAL_ERROR);
}
}
}
} catch (err) {
// Report identically to ESLint CLI
if (typeof err.messageTemplate === "string") {
try {
const eslintBase = `${require.resolve('eslint').split('eslint')[0]}/eslint`;
const template = require(path.join(eslintBase, `messages/${err.messageTemplate}.js`));
console.log(template(err.messageData || {}));
} catch {
// Ignore template error then fallback to use `error.stack`.
console.log(err.stack);
}
}
exit(GENERAL_ERROR); // TODO use error constant
}
}
#countErrors(results) {
let errorCount = 0;
let fatalErrorCount = 0;
for (const result of results) {
errorCount += result.errorCount;
fatalErrorCount += result.fatalErrorCount;
}
return { errorCount, fatalErrorCount };
}
async #getConfigFileAndSetProjectPath(args) {
// Get config path
if (args.length > 0) {
const firstArg = args[0];
this.isFile = firstArg === "." || isfile(firstArg) || isdir(firstArg)
}
this.configPath = await this.loader.getConfigPath(process.cwd())
if (!this.configPath) {
this.configContents = this.loader.sanitizeEslintConfig({}, false, LOG)
} else {
const configContents = this.loader.readEslintConfig(this.configPath)
this.configContents = configContents;
if (this.isESLintLegacy) {
this.configContents = await io.sanitizeEslintConfig(configContents, false, LOG);
if (this.configContents) {
if ("extends" in this.configContents) {
this.extendsPlugin = checks.hasEslintConfigContent(
this.configContents,
"extends"
);
if (this.extendsPlugin) {
this.lintType = "local";
try {
this.pluginPath = require.resolve("@sap/eslint-plugin-cds", {
paths: [path.dirname(this.configPath)],
});
this.pluginApi = require(this.pluginPath.replace("index.js", "api"));
} catch {
// CLI will report (no locally installed plugin)
}
}
}
}
} else {
const cdsConfig = Array.isArray(configContents)
? configContents?.find(c => c.files?.includes("*.cds")) ?? {}
: configContents
this.configContents = cdsConfig.files
? {
...cdsConfig.plugins.configs?.recommended,
languageOptions: cdsConfig.languageOptions,
files: cdsConfig.files,
rules: cdsConfig.rules,
}
: {};
}
}
if (!this.pluginPath) {
try {
this.pluginPath = require.resolve("@sap/eslint-plugin-cds", {
paths: [this.cdsdkPath],
});
this.pluginApi = require(this.pluginPath.replace("index.js", "api"));
} catch {
// CLI will report (no globally installed plugin)
}
}
LOG?.(`Lint type: ${this.lintType}`);
LOG?.(`ESLint CDS plugin resolved: ${this.pluginPath}`);
}
async #overwriteRuleSeverities() {
let configContents = {};
if (this.configPath) {
configContents = await io.readEslintConfigLegacy(this.configPath);
} else {
configContents = this.loader.readEslintConfig(await this.loader.getConfigPath(this.cdsdkPath))
}
const rules = configContents?.rules ?? {};
// Allow recommended plugin rules in cds-dk to be overwritten
// by user by adding rule to cmd line (because of precedence)
const pluginRules = require(this.pluginPath).configs.recommended.rules;
if (rules && pluginRules) {
for (const [name, rule] of Object.entries(rules)) {
if (typeof rule !== "undefined" && rule != pluginRules[name] ||
isfile(path.relative(".", ".eslint", "rules", name))) {
pluginRules[name] = rule;
this.ruleOpts[name] = rule;
this.pluginRules = pluginRules;
}
}
}
}
}
module.exports = Linter;