npm-groovy-lint
Version:
Lint, format and auto-fix your Groovy / Jenkinsfile / Gradle files
545 lines (505 loc) • 24.3 kB
JavaScript
// Imports
import Debug from "debug";
const debug = Debug("npm-groovy-lint");
const trace = Debug("npm-groovy-lint-trace");
import fs from "fs-extra";
import * as os from "os";
import * as path from "path";
import { performance } from "node:perf_hooks";
import { NpmGroovyLintFix } from "./groovy-lint-fix.js";
import { CodeNarcCaller } from "./codenarc-caller.js";
import { prepareCodeNarcCall, parseCodeNarcResult } from "./codenarc-factory.js";
import { NPM_GROOVY_LINT_CONSTANTS, loadConfig, getConfigFileName } from "./config.js";
import { optionsDefinition } from "./options.js";
import { computeStats, processOutput } from "./output.js";
import { getNpmGroovyLintVersion, getSourceLines, isErrorInLogLevelScope } from "./utils.js";
class NpmGroovyLint {
options = {}; // NpmGroovyLint options
args = []; // Command line arguments
// Internal
origin = "initialCall";
requestKey;
parseOptions;
tmpGroovyFileName;
// Codenarc / CodeNarcServer
codenarcArgs = [];
codeNarcBaseDir;
codeNarcIncludes;
codeNarcExcludes;
codeNarcStdOut;
codeNarcStdErr;
codeNarcJsonResult;
fileList;
inputFileList;
parseErrors = [];
// npm-groovy-lint
serverStatus = "unknown";
outputType;
output;
onlyCodeNarc = false;
lintResult = {};
outputString = "";
status = 0; // 0 if ok, 1 if expected error, 2 if unexpected error, 9 if cancelled request
error; //
fixer;
startElapse;
// Construction: initialize options & args
constructor(argsIn, internalOpts = { parseOptions: true }) {
if (argsIn) {
this.args = argsIn;
}
this.parseOptions = internalOpts.parseOptions !== false;
this.origin = internalOpts.origin || this.origin;
this.requestKey = internalOpts.requestKey;
}
// Run linting (and fixing if --fix)
async run() {
debug(`<<< NpmGroovyLint.run START >>>`);
const doProcess = await this.preProcess();
if (doProcess) {
await this.callCodeNarc();
await this.postProcess();
}
debug(`>>> NpmGroovyLint.run END <<<`);
return this;
}
// Call an existing NpmGroovyLint instance to request fix of errors
async fixErrors(errorIds, optns = {}) {
// Create and run fixer
debug(`Fix errors for ${JSON.stringify(errorIds)} on existing NpmGroovyLint instance`);
await this.preProcess();
this.fixer = new NpmGroovyLintFix(
this.lintResult,
{
verbose: optns.verbose || this.options.verbose,
fixrules: optns.fixrules || this.options.fixrules,
fixrulesexclude: optns.fixrulesexclude || this.options.fixrulesexclude,
source: optns.source || this.options.source,
save: this.tmpGroovyFileName ? false : true,
},
{ origin: "externalCallToFix" },
);
await this.fixer.run({ errorIds: errorIds, propagate: true });
this.lintResult = this.fixer.updatedLintResult;
// Lint again after fix if requested (for the moment we prefer to trigger that from VsCode, for better UX)
if (optns.nolintafter !== true) {
// Control fix result by calling a new lint
await this.lintAgainAfterFix();
}
// Compute stats & build output result
this.lintResult = computeStats(this.lintResult);
this.outputString = await processOutput(this.outputType, this.output, this.lintResult, this.options, this.fixer);
// Delete Tmp file if existing
await this.manageDeleteTmpFiles();
}
// Delete tmp file if needed.
async manageDeleteTmpFiles() {
if (!this.tmpGroovyFileName) {
return;
}
await fs.remove(this.tmpGroovyFileName);
debug(`Removed temp file ${this.tmpGroovyFileName} as it is not longer used :)`);
const tmpDir = path.dirname(this.tmpGroovyFileName);
if (tmpDir.includes("codeNarcTmpDir_") && fs.readdirSync(tmpDir).length === 0) {
await fs.remove(tmpDir);
debug(`Removed temp dir ${tmpDir} as it is not longer used :)`);
}
this.tmpGroovyFileName = null;
}
// Returns the full path of the configuration file
async getConfigFilePath(path) {
return await getConfigFileName(path || this.options.path || this.options.config, this.options.sourcefilepath);
}
// Returns the loaded config
async loadConfig(configFilePath, mode = "lint") {
return await loadConfig(configFilePath, mode, null, []);
}
//////////////////////////////////////////////////////////////////////
// Below this point, methods should be called only by NpmGroovyLint //
//////////////////////////////////////////////////////////////////////
// Actions before call to CodeNarc
async preProcess() {
// Reset status so we don't get stale results.
this.status = 0;
// Manage when the user wants to use only codenarc args
if (Array.isArray(this.args) && this.args.includes("--codenarcargs")) {
this.codenarcArgs = this.args
.slice(2)
.filter((userArg) => userArg !== "--codenarcargs") // Strip codenarcargs.
.map((userArg) => userArg.replace(/^-(\w+)="(.*)"$/, "-$1=$2").replace(/^-(\w+)='(.*)'$/, "-$1=$2")); // Strip quotes around values which CodeNarc doesn't support.
this.onlyCodeNarc = true;
return true;
}
// Parse options (or force them if coming from lint re-run after fix)
// Mix between command-line options and .groovyLint file options (priority is given to .groovyLint file)
if (this.parseOptions) {
try {
this.options = optionsDefinition.parse(this.args);
// Strip quotes around values which CodeNarc doesn't support.
for (const [key, value] of Object.entries(this.options)) {
if (typeof value === "string") {
this.options[key] = value.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
}
}
const configProperties = await loadConfig(
this.options.config || this.options.path,
this.options.format ? "format" : "lint",
this.options.sourcefilepath || this.options.path,
);
for (const configProp of Object.keys(configProperties)) {
if (this.options[configProp] == null) {
this.options[configProp] = configProperties[configProp];
}
}
// Try to catch input from stdin. if found, use it as groovy source
if (this.options._ && this.options._[0] === "-") {
const stdInData = fs.readFileSync(0, "utf-8");
this.options.source = stdInData;
this.options._ = [];
this.options.sourcefilepath = this.options.sourcefilepath || process.cwd();
if (this.options.format || this.options.fix) {
this.options.output = "stdout";
}
}
} catch (err) {
this.status = 2;
this.error = {
msg: `Parse options error: ${err.message}`,
stack: err.stack,
};
console.error(this.error.msg);
return false;
}
// Check options consistency: failon & loglevel
if (this.options.failon && this.options.loglevel) {
if (!isErrorInLogLevelScope(this.options.failon, this.options.loglevel)) {
this.status = 2;
this.error = {
msg: `failon option (${this.options.failon}) must be > loglevel option (${this.options.loglevel})`,
};
console.error(this.error.msg);
return false;
}
}
} else {
this.options = this.args;
}
// Manage anonymous stats
if (["initialCall", "index"].includes(this.origin) && this.options.insight === true) {
this.startElapse = performance.now();
}
// Kill running CodeNarcServer
if (this.options.killserver) {
const startPerf = performance.now();
const codeNarcCaller = new CodeNarcCaller(this.codenarcArgs, this.serverStatus, this.args, this.options, {
groovyFileName: this.tmpGroovyFileName,
});
this.outputString = await codeNarcCaller.killCodeNarcServer();
console.info(this.outputString);
this.manageStats(startPerf);
return false;
}
// Show version
if (this.options.version) {
const v = getNpmGroovyLintVersion();
const codeNarcVersionLinter = await new NpmGroovyLint([process.execPath, "", "--codenarcargs", "-version"], {}).run();
const codeNarcVersionLines = [(await getSourceLines(codeNarcVersionLinter.codeNarcStdOut || "Error collecting CodeNarc version"))[0]];
const versions = [];
versions.push(`npm-groovy-lint version ${v}`);
versions.push("");
versions.push("Embeds:");
versions.push(...codeNarcVersionLines);
versions.push(`- Groovy version ${NPM_GROOVY_LINT_CONSTANTS["GroovyVersion"]} (superlite)`);
const versionsOut = versions.join(os.EOL);
console.info(versionsOut);
this.outputString = versionsOut;
return false;
}
// Show help ( index or for an options)
if (this.options.help) {
if (this.options._.length) {
this.outputString = optionsDefinition.generateHelpForOption(this.options._[0]);
} else {
this.outputString = optionsDefinition.generateHelp();
}
console.info(this.outputString);
return false;
}
// Prepare CodeNarc call then set result on NpmGroovyLint instance
const codeNarcFactoryResult = await prepareCodeNarcCall(this.options);
this.setMethodResult(codeNarcFactoryResult);
return this.error == null ? true : false;
}
/* Order of attempts (supposed to work on every config):
- Call CodeNarcServer via Http (except if --noserver)
- Launch CodeNarcServer using com.nvuillam.CodeNarcServer, then call CodeNarcServer via Http (except if --noserver)
- Call CodeNarc java using com.nvuillam.CodeNarcServer (without launching server)
- Call CodeNarc java using org.codenarc.CodeNarc
*/
async callCodeNarc() {
const startPerf = performance.now();
let serverCallResult = { status: null };
const codeNarcCaller = new CodeNarcCaller(this.codenarcArgs, this.serverStatus, this.args, this.options, {
groovyFileName: this.tmpGroovyFileName ? this.tmpGroovyFileName : null,
requestKey: this.requestKey || null,
codeNarcBaseDir: this.codeNarcBaseDir,
codeNarcIncludes: this.codeNarcIncludes,
codeNarcExcludes: this.codeNarcExcludes,
onlyCodeNarc: this.onlyCodeNarc,
inputFileList: this.inputFileList,
});
if (!this.options.noserver) {
serverCallResult = await codeNarcCaller.callCodeNarcServer();
}
if ([1, 2, null].includes(serverCallResult.status)) {
serverCallResult = await codeNarcCaller.callCodeNarcJava();
}
this.setMethodResult(serverCallResult);
this.manageStats(startPerf);
}
// After CodeNarc call
async postProcess() {
// Cancelled request
if (this.status === 9) {
console.info(`GroovyLint: Request cancelled by duplicate call on requestKey ${this.requestKey}`);
}
// CodeNarc error
else if ((this.codeNarcStdErr && [null, "", undefined].includes(this.codeNarcStdOut)) || this.status > 0) {
this.status = 2;
console.error("GroovyLint: Error running CodeNarc: \n" + this.codeNarcStdErr);
}
// only --codenarcargs arguments
else if (this.onlyCodeNarc) {
console.log("GroovyLint: Successfully processed CodeNarc: \n" + this.codeNarcStdOut);
}
// process npm-groovy-lint options ( output, fix, formatting ...)
else {
// Parse XML result as js object
this.lintResult = await parseCodeNarcResult(
this.options,
this.codeNarcBaseDir,
this.codeNarcJsonResult,
this.tmpGroovyFileName,
this.parseErrors,
);
// Fix all found errors if requested
if (this.options.fix || this.options.format) {
this.fixer = new NpmGroovyLintFix(this.lintResult, {
format: this.options.format === true,
fixrules: this.options.fixrules,
fixrulesexclude: this.options.fixrulesexclude,
source: this.options.source,
save: this.tmpGroovyFileName ? false : true,
origin: this.origin,
rules: this.options.rules,
verbose: this.options.verbose,
});
await this.fixer.run();
this.lintResult = this.fixer.updatedLintResult;
// Post actions
const checkIfFixAgainRequiredRes = this.checkIfFixAgainRequired();
if (checkIfFixAgainRequiredRes.runAgain === true && this.origin !== "fixAgainAfterFix") {
await this.fixAgainAfterFix(checkIfFixAgainRequiredRes.files);
}
// If there has been fixes, call CodeNarc again to get updated error list
if (this.fixer.fixedErrorsNumber > 0 && this.options.nolintafter !== true && this.origin !== "fixAgainAfterFix") {
await this.lintAgainAfterFix();
}
}
// Output result
this.lintResult = computeStats(this.lintResult);
this.outputString = await processOutput(this.outputType, this.output, this.lintResult, this.options, this.fixer);
}
await this.manageDeleteTmpFiles();
// Manage return code in case failonerror, failonwarning or failoninfo is called
this.manageReturnCode();
}
// Check if fixed errors required a new lint & fix
checkIfFixAgainRequired() {
let runAgain = false;
const runAgainOnFiles = {};
for (const file of Object.keys(this.lintResult.files)) {
for (const err of this.lintResult.files[file].errors) {
if (err.fixed === true && err.triggersAgainAfterFix && err.triggersAgainAfterFix.length > 0) {
runAgain = true;
runAgainOnFiles[file] = runAgainOnFiles[file] ? runAgainOnFiles[file] : { fixrules: [] };
runAgainOnFiles[file].fixrules.push(...err.triggersAgainAfterFix);
}
}
}
return { runAgain: runAgain, files: runAgainOnFiles };
}
// Lint again after fixes and merge in existing results
async lintAgainAfterFix() {
// same Options except fix = false & output = none
const lintAgainOptions = JSON.parse(JSON.stringify(this.options));
if (this.options.source) {
lintAgainOptions.source = this.lintResult.files[0].updatedSource;
}
lintAgainOptions.fix = false;
lintAgainOptions.output = "none";
trace(`Fix is done, lint again with options ${JSON.stringify(lintAgainOptions)}`);
const newLinter = new NpmGroovyLint(lintAgainOptions, {
parseOptions: false,
origin: "lintAgainAfterFix",
});
// Run linter
await newLinter.run();
// Merge new linter results in existing results
this.lintResult = this.mergeResults(this.lintResult, newLinter.lintResult);
this.status = newLinter.status;
}
// Fix again after fix because fixed rules contained triggersAgainAfterFix property (for the moment, only Indentation rule)
async fixAgainAfterFix(filesAndRulesToProcess) {
const fixAgainOptions = JSON.parse(JSON.stringify(this.options));
// Gather rules to lint & fix again
let fixRules = [];
for (const file of Object.keys(filesAndRulesToProcess)) {
fixRules.push(...filesAndRulesToProcess[file].fixrules);
}
fixRules = [...new Set(fixRules)]; // Remove duplicates
// If there are excluded rules, filter them out from the fixRules list
if (this.options.fixrulesexclude) {
const excludedRules = this.options.fixrulesexclude.split(",").map(s => s.trim()).filter(Boolean);
fixRules = fixRules.filter(rule => !excludedRules.includes(rule));
}
// If all rules were excluded, don't run fixAgainAfterFix
if (fixRules.length === 0) {
debug(`All triggered rules are excluded, skipping fixAgainAfterFix`);
return;
}
if (this.options.source) {
fixAgainOptions.source = this.lintResult.files[0].updatedSource;
}
// Lint & fix again only the requested rules for better performances
delete fixAgainOptions.format;
delete fixAgainOptions.rules;
delete fixAgainOptions.overriddenRules;
fixAgainOptions.rulesets = Object.keys(this.options.rules)
.filter((ruleName) => fixRules.includes(ruleName))
.map((ruleName) => `${ruleName}${JSON.stringify(this.options.rules[ruleName])}`)
.join(",");
fixAgainOptions.fix = true;
fixAgainOptions.fixrules = fixRules.join(",");
fixAgainOptions.output = "none";
// Process lint & fix
debug(`Fix triggered rule requiring another lint & fix, do it again with options ${JSON.stringify(fixAgainOptions)}`);
const newLinter = new NpmGroovyLint(fixAgainOptions, { origin: "fixAgainAfterFix" });
await newLinter.run();
// Merge new linter & fixer results in existing results
this.lintResult = this.mergeFixAgainResults(this.lintResult, newLinter.lintResult);
}
// Merge results after control lint after fixing
mergeResults(lintResAfterFix, lintResControl) {
const mergedLintResults = { files: {} };
for (const afterFixResFileNm of Object.keys(lintResAfterFix.files)) {
// Append fixed errors to errors found via control lint (post fix)
const afterFixFileErrors = lintResAfterFix.files[afterFixResFileNm].errors;
const fixedErrors = afterFixFileErrors.filter((err) => err.fixed === true);
const controlFileErrors =
lintResControl.files && lintResControl.files[afterFixResFileNm] ? lintResControl.files[afterFixResFileNm].errors || [] : [];
const mergedFileErrors = controlFileErrors.concat(fixedErrors);
mergedLintResults.files[afterFixResFileNm] = { errors: mergedFileErrors };
// Set updatedSource in results in provided
if (lintResAfterFix.files[afterFixResFileNm].updatedSource) {
mergedLintResults.files[afterFixResFileNm].updatedSource = lintResAfterFix.files[afterFixResFileNm].updatedSource;
}
}
return mergedLintResults;
}
// Merge results after second fix performed (only get updated source)
mergeFixAgainResults(lintResToUpdate, lintResAfterNewFix) {
if (lintResToUpdate.files && lintResToUpdate.files[0]) {
if (Object.keys(lintResAfterNewFix.files).length > 0) {
const key = Object.keys(lintResAfterNewFix.files)[0];
const updtSource = lintResAfterNewFix.files[key].updatedSource;
if (updtSource) {
lintResToUpdate.files[0] = Object.assign(lintResToUpdate.files[0], { updatedSource: updtSource });
}
}
} else if (lintResAfterNewFix && lintResAfterNewFix.files) {
for (const afterNewFixResFileNm of Object.keys(lintResAfterNewFix.files)) {
// Set updatedSource in results in provided
if (lintResAfterNewFix.files[afterNewFixResFileNm].updatedSource) {
lintResToUpdate.files[afterNewFixResFileNm].updatedSource = lintResAfterNewFix.files[afterNewFixResFileNm].updatedSource;
}
}
}
return lintResToUpdate;
}
// Set lib results on this NpmGroovyLint instance
setMethodResult(libResult) {
for (const propName of Object.keys(libResult)) {
this[propName] = libResult[propName];
}
}
// Increment stats for test classes in necessary
manageStats(startPerf) {
if (globalThis && globalThis.codeNarcCallsCounter >= 0) {
globalThis.codeNarcCallsCounter++;
const optionsLog = JSON.parse(JSON.stringify(this.options));
for (const prop of ["source", "rules", "verbose", "loglevel", "serverhost", "serverport", "_"]) {
delete optionsLog[prop];
}
globalThis.codeNarcCalls.push({
origin: this.origin,
elapse: parseInt(performance.now() - startPerf),
options: optionsLog,
args: this.codenarcArgs,
});
}
}
// Exit with code 1 if failon, failonerror, failonwarning or failoninfo is set
manageReturnCode() {
if (this.status > 1) {
// There has been a fatal error before, so there are no results
return;
}
const failureLevel =
this.options.failon && this.options.failon !== "none"
? this.options.failon
: this.options.failonerror
? "error"
: this.options.failonwarning
? "warning"
: this.options.failoninfo
? "info"
: "none";
if (failureLevel === "none") {
return;
}
if (this.lintResult.summary == null) {
// Special case like --codenarcargs (should not be used in thee future)
return;
}
const errorNb = this.lintResult.summary.totalRemainingErrorNumber;
const warningNb = this.lintResult.summary.totalRemainingWarningNumber;
const infoNb = this.lintResult.summary.totalRemainingInfoNumber;
// Fail on error
if (failureLevel === "error" && errorNb > 0) {
if (!["json", "sarif", "stdout"].includes(this.outputType)) {
console.error(`Failure: ${this.lintResult.summary.totalRemainingErrorNumber} error(s) have been found`);
}
this.status = 1;
}
// Fail on warning
else if (failureLevel === "warning" && (errorNb > 0 || warningNb > 0)) {
if (!["json", "sarif", "stdout"].includes(this.outputType)) {
console.error(
`Failure: ${this.lintResult.summary.totalRemainingErrorNumber} error(s) have been found \n ${this.lintResult.summary.totalRemainingWarningNumber} warning(s) have been found`,
);
}
this.status = 1;
}
// Fail on info
else if (failureLevel === "info" && (errorNb > 0 || warningNb > 0 || infoNb > 0)) {
if (!["json", "sarif", "stdout"].includes(this.outputType)) {
console.error(
`Failure: ${this.lintResult.summary.totalRemainingErrorNumber} error(s) have been found \n ${this.lintResult.summary.totalRemainingWarningNumber} warning(s) have been found \n ${this.lintResult.summary.totalRemainingInfoNumber} info(s) have been found`,
);
}
this.status = 1;
}
}
}
export default NpmGroovyLint;