UNPKG

@sap/cds-dk

Version:

Command line client and development toolkit for the SAP Cloud Application Programming Model

451 lines (412 loc) 15.1 kB
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;