@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
326 lines (294 loc) • 10.4 kB
JavaScript
const term = require('../util/term')
const io = require('./io')
const cds = require('../../lib/cds')
const { path, isfile, readdir } = cds.utils
const { exit } = require('node:process')
const { pathToFileURL } = require('node:url')
const LOG = cds.debug('lint')
const LOG_CONFIG = cds.debug('lint:config')
const GENERAL_ERROR = 1
const FATAL_ERROR = 2
/**
* @param {{errorCount: number, fatalErrorCount: number}[]} results
*/
const countErrors = results => results.reduce((acc, result) => {
acc.errorCount += result.errorCount
acc.fatalErrorCount += result.fatalErrorCount
return acc
}, { errorCount: 0, fatalErrorCount: 0 })
/**
* 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 {
const modulePath = require.resolve(module, { paths: [process.cwd()] })
const result = esm
// pathToFileURL is used to convert the path to a file URL on Windows
? (await import(pathToFileURL(modulePath))).default
: require(modulePath)
return result
} 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
}
}
/**
* Ascends through directory tree to find the first config file.
* @param {string} currentDir - current directory to start search
* @returns {string} - path to config file
*/
async function findConfigPath (currentDir = '.') {
// possible config file paths
const configFiles = [
'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',
]
let configDir = path.resolve(currentDir)
while (configDir && configDir !== path.dirname(configDir)) {
for (const configFile of configFiles) {
try {
const configPath = path.join(configDir, configFile)
const config = await tryRequire(configPath)
if ((configFile !== 'package.json' || config?.eslintConfig) && isfile(configPath)) {
return configPath
}
} catch {
// attempting to load config files may fail for various reasons we can not easily verify by error code, for example
// "Cannot use import statement outside a module" in a TS context, or attempting to read .mts files without properly setting them up.
// We just ignore these errors and treat them as "no valid config found so far, continue searching", lest the exception flys up.
}
}
configDir = path.dirname(configDir)
}
return ''
}
class Linter {
/** @type {string} */
eslintCmd = 'npx eslint'
/** @type {string | string[]} */
eslintCmdFileExpr = ''
/** @type {string} */
help = ''
/** @type {string} */
debug = ''
/** @type {string[]} */
flags = []
/** @type {import('fs').PathLike} */
#configPath
async #getConfigPath () {
return this.#configPath ??= await findConfigPath(process.cwd())
}
/** @type {object} */
#configContents
async #getConfigContents () {
if (!this.#configContents) {
if (!(await this.#getConfigPath())) {
this.#configContents = io.sanitizeEslintConfig({}, LOG)
} else {
const cc = await io.readEslintConfig(await this.#getConfigPath())
this.#configContents = cc
const cdsConfig = Array.isArray(cc)
? cc?.find(c => c.files?.some(f => f.includes('*.cds'))) ?? {}
: cc
this.#configContents = cdsConfig.files
? {
...cdsConfig.plugins.configs?.recommended,
languageOptions: cdsConfig.languageOptions,
files: cdsConfig.files,
rules: cdsConfig.rules,
}
: {}
}
}
return this.#configContents
}
/** @type {string[]} */
fileExtensions = []
/** @type {string} */
#pluginPath
async #getPluginPath () {
if (this.#pluginPath === undefined) {
try {
this.#pluginPath = require.resolve('@sap/eslint-plugin-cds', {
paths: [path.dirname(await this.#getConfigPath())],
})
} catch {
// CLI will report (plugin not installed)
this.#pluginPath = null
}
}
return this.#pluginPath
}
/** @type {NodeJS.Module} */
#pluginApi
async #getPluginApi () {
return this.#pluginApi ??= require((await this.#getPluginPath()).replace('index.js', 'api'))
}
/** @type {object} */
ruleOpts = {}
/** @type {array} */
customRulesOpts = []
/** @type {array} */
pluginRules = []
/**
* Initializes `cds lint` call and generates the required content
* object for `cds` executable
* @returns {{
* help: string,
* options: string[],
* flags: unknown[],
* shortcuts: unknown[],
* }} object containing help, options, flags and shortcuts
*/
init() {
const { help, options, shortcuts, flags } = require('./eslintHelp.json')
this.help = help
this.flags = flags
return { help, flags, options, 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) {
// user should install eslint locally
const { loadESLint } = await tryRequire('eslint')
if (!loadESLint)
throw 'ESLint is not installed. Please install ESLint in your project using \'npm i -D eslint\'.'
const DefaultESLint = await loadESLint()
if (options?.version) {
return this.#printVersion(DefaultESLint.version)
}
this.eslintCmdOpts = options
this.eslintCmdFileExpr = args.length ? args : ['.']
if (this.eslintCmdOpts.help)
return this.#printHelp()
if (await this.#getPluginPath()) {
await this.#overwriteRuleSeverities()
// Limit to CDS file extensions
await this.#addExtensions()
}
// Run ESLint with collected options
try {
await this.#runEslint(DefaultESLint)
} catch (err) {
term.error(err)
}
}
/**
* Prints help message
*/
#printHelp() {
console.log(this.help.replace(/ \*([^*]+)\*/g, ` ${term.codes.bold}$1${term.codes.reset}`))
}
#printVersion(version) {
console.log(`Using ESLint version ${term.codes.bold}${version}${term.codes.reset}`)
}
async #addExtensions() {
// Add CDS file extensions to lint
this.fileExtensions = (await this.#getPluginApi()).getFileExtensions()
.map(ext => path.extname(ext))
// Only lint file extensions prescribed by plugin
this.ignorePatterns = this.fileExtensions
.map(ext => `!${ext}`)
}
async #runEslint(DefaultESLint) {
try {
const eslintOpts = {
cwd: process.cwd(),
overrideConfig: await this.#getConfigContents(),
}
const cdsPlugin = require(await this.#getPluginPath())
// 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?.length) {
eslintOpts.rulePaths = [this.customRulesOpts]
}
LOG_CONFIG?.(eslintOpts)
if (LOG) {
const lintString = ['npx eslint']
if (this.fileExtensions) {
lintString.push(`--ext "${this.fileExtensions.join(',')}"`)
}
for (const [name, rule] of Object.entries(this.ruleOpts)) {
lintString.push(`--rule ${name}:${rule}`)
}
if (this.customRulesOpts?.length) {
lintString.push(`--rulesdir "${this.customRulesOpts}"`)
}
LOG(lintString.join(' '))
}
if (process.env.isTest)
// only log config and hypothetical lintString, do not actually run
return
/** @type {import('eslint').ESLint} */
const eslint = new DefaultESLint(eslintOpts)
const formatter = await eslint.loadFormatter('stylish')
// environment rules
const results = (await eslint.lintText(''))
.filter(result => result.messages.length)
.map((result) => {
result.filePath = path.resolve(process.cwd())
return result
})
// lint files in current dir
const files = await readdir(process.cwd())
if (files.some(file => isfile(file))) {
results.push(...await eslint.lintFiles(this.eslintCmdFileExpr))
}
if (results?.length) {
console.log(formatter.format(results))
const { errorCount, fatalErrorCount } = countErrors(results)
if (fatalErrorCount) {
exit(FATAL_ERROR)
}
if (errorCount) {
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)
}
}
async #overwriteRuleSeverities() {
const rules = this.#getConfigContents().rules ?? {}
// Allow recommended plugin rules to be overwritten
// by user by adding rule to cmd line (because of precedence)
const pluginRules = require(await this.#getPluginPath()).configs.recommended.rules ?? {}
for (const [name, rule] of Object.entries(rules)) {
if (rule && rule !== pluginRules[name] || isfile(path.relative('.', '.eslint', 'rules', name))) {
pluginRules[name] = rule
this.ruleOpts[name] = rule
this.pluginRules = pluginRules
}
}
}
}
module.exports = Linter