npm-groovy-lint
Version:
Lint, format and auto-fix your Groovy / Jenkinsfile / Gradle files
494 lines (458 loc) • 16.9 kB
JavaScript
// Shared functions
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 { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
import find from "find-package-json";
const validErrorCombinations = {
error: ["error", "warning", "info"],
warning: ["warning", "info"],
info: ["info"],
none: ["error", "warning", "info"],
};
function addImport(allLineLs, classToImport) {
// Check if import is already there
if (allLineLs.map((line) => line.trim()).findIndex((line) => line.startsWith(`import ${classToImport}`)) > -1) {
return allLineLs;
}
// Add import after existing imports
const lastImportIndex =
allLineLs.length -
1 -
allLineLs
.slice()
.reverse()
.findIndex((line) => line.trim().startsWith("import"));
if (lastImportIndex > -1 && lastImportIndex !== allLineLs.length) {
allLineLs.splice(lastImportIndex + 1, 0, `import ${classToImport}`);
return allLineLs;
}
// Add import after package declaration
const packageIndex = allLineLs.findIndex((line) => line.trim().startsWith("package"));
if (packageIndex > -1) {
allLineLs.splice(packageIndex + 1, 0, "");
allLineLs.splice(packageIndex + 2, 0, `import ${classToImport}`);
return allLineLs;
}
// Add import at the first line not containing comments and after package if here
let addImportLinePos = 0;
for (let i = 0; i < allLineLs.length; i++) {
const line = allLineLs[i];
if (line.trim().startsWith("#") || line.trim().startsWith("/")) {
addImportLinePos = i + 1;
continue;
}
break;
}
allLineLs.splice(addImportLinePos, 0, `import ${classToImport}`);
if (addImportLinePos === 0) {
allLineLs.splice(addImportLinePos + 1, 0, "");
}
return allLineLs;
}
// Add space after a string in another string
function addSpaceAfterChar(line, char) {
if (char.length > 1 && !line.includes(char + " ")) {
return line.replace(char, char + " ");
}
let pos = -1;
const lineIndent = line.search(/\S/);
const splits = line.split(char);
const newArray = splits.map((str) => {
pos++;
if (pos === splits.length - 1) {
return str.trimStart();
} else {
return str.trim();
}
});
return " ".repeat(lineIndent) + newArray.join(char + " ").trimEnd();
}
// Add space around character
function addSpaceAroundChar(line, char, postReplaces = []) {
let pos = -1;
// split line by character except when it is inside quotes
const escapedChar = char.replace(/[-[\]{}()*+!<=:?./\\^$|#\s,]/g, "\\$&");
//const regSplit = new RegExp(escapedChar + `+(?=(?:(?:[^']*'){2})*[^']*$)`);
const regSplit = new RegExp(escapedChar + `+(?=(?:(?:[^"]*"){2})*[^"]*$)(?=(?:(?:[^']*'){2})*[^']*$)(?=(?:(?:[^\`]*\`){2})*[^\`]*$)`);
const splits = line.split(regSplit);
const newArray = splits.map((str) => {
pos++;
if (pos === 0) {
return str.trimEnd();
} else if (pos === splits.length - 1) {
return str.trimStart();
} else {
return str.trim();
}
});
line = newArray.join(" " + char + " ").trimEnd();
// If exceptions, repair the broken string :)
if (postReplaces && postReplaces.length > 0) {
for (const postReplace of postReplaces) {
line = line.replace(postReplace[0], postReplace[1]);
}
}
return line;
}
// Checks that a string contains other things than a list of strings
function containsOtherThan(str, stringArray) {
const splits = splitMulti(str, stringArray);
return splits.filter((item) => item !== "").length > 0;
}
// Get position to highlight in sources
function evaluateRange(errItem, rule, evaluatedVars, errLine, allLines) {
let range;
if (rule.range) {
if (rule.range.type === "function") {
try {
range = rule.range.func(errLine, errItem, evaluatedVars, allLines);
} catch (e) {
debug("GroovyLint: Range function error: " + e.message + " / " + JSON.stringify(rule) + "\n" + JSON.stringify(errItem));
}
}
}
return range;
}
// Get position to highlight in sources
function evaluateRangeFromLine(errItem, allLines) {
return getDefaultRange(allLines, errItem);
}
// escapeMessage escapes a value as retrieved from CodeNarc
// from the "display name" so it matches the original source.
// See issue: https://github.com/CodeNarc/CodeNarc/issues/749
// Escaping as per: https://groovy-lang.org/syntax.html#_escaping_special_characters
function escapeValue(s) {
return s
.replace(/\\/g, "\\\\")
.replace(/[\b]/g, "\\b") // Class so \b is not a word boundary.
.replace(/\f/g, "\\f")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t")
.replace(/\v/g, "\\v")
.replace(/'/g, "\\'")
.replace(/"/g, '\\"');
}
// Evaluate variables from messages
function evaluateVariables(variableDefs, msg) {
const evaluatedVars = [];
for (const varDef of variableDefs || []) {
// regex
if (varDef.regex) {
const regexRes = msg.match(varDef.regex);
if (regexRes && regexRes.length > 1) {
const regexPos = varDef.regexPos || 1;
let value = decodeHTMLEntities(regexRes[regexPos]);
value = escapeValue(value);
const varValue =
varDef.type && varDef.type === "number"
? parseInt(value, 10)
: varDef.type && varDef.type === "array"
? JSON.parse(value)
: value;
evaluatedVars.push({
name: varDef.name,
value: varValue,
});
} else {
trace(`GroovyLint: Unable to match ${varDef.regex} in ${msg}`);
}
}
}
return evaluatedVars;
}
// Find the range between two strings (included)
function findRangeBetweenStrings(allLines, errItem, strStart, strEnd) {
let range = getDefaultRange(allLines, errItem);
let pos = errItem.line - 1;
let isStartFound = false;
let isEndFound = false;
while ((isStartFound === false || isEndFound === false) && pos < allLines.length) {
if (!isStartFound && allLines[pos].indexOf(strStart) > -1 && notBetweenQuotesOrComment(allLines[pos], strStart)) {
range.start = { line: pos + 1, character: allLines[pos].indexOf(strStart) };
isStartFound = true;
}
if (
!isEndFound &&
allLines[pos].indexOf(strEnd) > -1 &&
(strStart !== strEnd || pos > range.start.line - 1) &&
notBetweenQuotesOrComment(allLines[pos], strEnd)
) {
range.end = { line: pos + 1, character: allLines[pos].indexOf(strEnd) };
isEndFound = true;
}
pos++;
}
return range;
}
// Returns default range (all line)
function getDefaultRange(allLines, errItem) {
return {
start: { line: errItem.line, character: 0 },
end: { line: errItem.line, character: allLines[errItem.line - 1].length },
};
}
// Get indent length
function getIndentLength() {
return 4;
}
// Get range of the last occurrence of a substring in a string
function getLastStringRange(errLine, str, errItem) {
const varStartPos = errLine.lastIndexOf(str);
return {
start: { line: errItem.line, character: varStartPos },
end: { line: errItem.line, character: varStartPos + str.length },
};
}
function getLastVariableRange(errLine, evaluatedVars, variable, errItem) {
const varValue = getVariable(evaluatedVars, variable);
return getLastStringRange(errLine, varValue, errItem);
}
// Returns all strings which are not inside braces
function getOutOfBracesStrings(str, exclude = []) {
let match = false;
let pos = 0;
let level = 0;
const outOfBracesStrings = [];
while (!match && pos < str.length) {
if (str[pos] === "(") {
level++;
if (level === 1 && outOfBracesStrings.length === 0) {
outOfBracesStrings.push(str.substr(0, pos).trim());
}
}
if (str[pos] === ")") {
level--;
if (level === 0 && outOfBracesStrings.length === 1) {
outOfBracesStrings.push(str.substr(pos + 1).trim());
match = true;
}
}
pos++;
}
return outOfBracesStrings.filter((item) => !exclude.includes(item) && item !== "");
}
// Split source lines to analyse
async function getSourceLines(source, fileNm) {
let fileContent =
source ||
(await fs.readFile(fileNm).catch((err) => {
throw new Error(`Unable to read source lines: ${err}`); // Ensure that we have a stack trace.
}));
return normalizeNewLines(fileContent.toString()).split(os.EOL);
}
// Get range of the first occurrence of a substring or regex in a string
function getStringRange(errLine, strOrRegex, errItem) {
// Regex matcher
if (strOrRegex instanceof RegExp) {
const match = strOrRegex.exec(errLine);
return {
start: {
line: errItem.line,
character: strOrRegex.lastIndex - match[0].length,
},
end: {
line: errItem.line,
character: strOrRegex.lastIndex - 1,
},
};
}
// String matcher
const varStartPos = errLine.indexOf(strOrRegex);
return {
start: {
line: errItem.line,
character: varStartPos,
},
end: {
line: errItem.line,
character: varStartPos + strOrRegex.length,
},
};
}
// Get range of the first occurrence of a substring or regex in a multiline string
function getStringRangeMultiline(allLines, str, errItem, levelKey) {
let range = getDefaultRange(allLines, errItem);
let pos = errItem.line - 1;
let isFound = false;
let level = 0;
while (isFound === false && pos < allLines.length) {
if (levelKey && allLines[pos].indexOf(levelKey) > -1 && notBetweenQuotesOrComment(allLines[pos], levelKey)) {
level = level + 1;
}
if (!isFound && allLines[pos].indexOf(str) > -1 && notBetweenQuotesOrComment(allLines[pos], str)) {
if (level === 1) {
const varStartPos = allLines[pos].indexOf(str);
range = {
start: { line: pos + 1, character: varStartPos },
end: { line: pos + 1, character: varStartPos + str.length },
};
isFound = true;
} else {
level = level - 1;
}
}
pos++;
}
return range;
}
// Get variable value from evaluated vars
function getVariable(evaluatedVars, name, optns = { mandatory: true, decodeHtml: false, line: "" }) {
const matchingVars = evaluatedVars.filter((evaluatedVar) => evaluatedVar.name === name);
if (matchingVars && matchingVars.length > 0) {
return optns.decodeHtml ? decodeHTMLEntities(matchingVars[0].value) : matchingVars[0].value;
} else if (optns.mandatory) {
throw new Error("GroovyLint fix: missing mandatory variable " + name + " in " + JSON.stringify(evaluatedVars)) + "for line :\n" + optns.line;
} else {
return null;
}
}
// Get range of a variable value in a string
function getVariableRange(errLine, evaluatedVars, variable, errItem) {
const varValue = getVariable(evaluatedVars, variable);
return getStringRange(errLine, varValue, errItem);
}
// Returns true or false depending if a single error level (error, warning, info) is applicable to the logged error level
function isErrorInLogLevelScope(errorLevel, logLevel) {
return validErrorCombinations[errorLevel].includes(logLevel);
}
function isValidCodeLine(line) {
return line.trim() !== "" && line.trim().split("//")[0] !== "";
}
// Move the opening bracket at the same position than its related expression
function moveOpeningBracket(allLines, variables) {
const range = getVariable(variables, "range", { mandatory: true });
const realPos = allLines[range.end.line - 1].includes("{") ? range.end.line : allLines[range.end.line].includes("{") ? range.end.line + 1 : -1;
if (realPos === -1) {
throw new Error("Unable to find opening bracket");
}
if (allLines[realPos - 1].trim().replace(/ /g, "").includes("){")) {
throw new Error("Fix not applied: probably a CodeNarc false positive");
}
// Check if the line contains a comment and insert bracket before it
const targetLine = allLines[realPos - 2];
const commentIndex = targetLine.indexOf("//");
let addedBracketLine;
if (commentIndex !== -1) {
// Insert bracket before the comment
const beforeComment = targetLine.substring(0, commentIndex).trimEnd();
const comment = targetLine.substring(commentIndex);
addedBracketLine = beforeComment + " { " + comment;
} else {
// Add bracket after if
addedBracketLine = targetLine.trimEnd() + " {";
}
allLines[realPos - 2] = addedBracketLine;
// Remove bracket which was on the wrong line
const removedBracketLine = allLines[realPos - 1].substring(allLines[realPos - 1].indexOf("{") + 1).trimEnd();
allLines[realPos - 1] = removedBracketLine;
// Remove removed bracket line if empty
if (allLines[realPos - 1].trim() === "") {
allLines.splice(realPos - 1, 1);
}
return allLines;
}
function normalizeNewLines(str) {
let normalizedString = str + "";
normalizedString = str.replace(/\r/g, "");
normalizedString = normalizedString.replace(/\n/g, os.EOL);
return normalizedString;
}
// Utils of utils :)
// Check if a substring is between quotes in a string
function notBetweenQuotesOrComment(str, substr) {
const singleQuotesMatch = str.match(/'(.*?)'/) || [];
const doubleQuotesMatch = str.match(/"(.*?)"/) || [];
const res = singleQuotesMatch.concat(doubleQuotesMatch).filter((match) => match.includes(substr));
if (res.length > 0) {
return false;
}
const splitComments = str.split("//");
if (splitComments.length > 1 && splitComments[1].includes(substr)) {
return false;
}
if (str.includes("/*")) {
return false;
}
return true;
}
function containsExceptInsideString(line, subString) {
const subStringPos = line.indexOf(subString);
if (subStringPos === -1) {
return false;
}
const lineWithoutQuotedStuff = line.replace(/(["'])(?:(?=(\\?))\2.)*?\1/g, ""); // Remove content inside quotes
return lineWithoutQuotedStuff.includes(subString);
}
// Split with multiple characters
function splitMulti(str, tokens) {
var tempChar = tokens[0]; // We can use the first token as a temporary join character
for (var i = 1; i < tokens.length; i++) {
str = str.split(tokens[i]).join(tempChar);
}
str = str.split(tempChar);
return str;
}
function getNpmGroovyLintVersion() {
let v = process.env.npm_package_version;
if (!v) {
try {
const finder = find(__dirname);
v = finder.next().value.version;
} catch {
v = "error";
}
}
return v;
}
const entities = {
amp: "&",
apos: "'",
lt: "<",
gt: ">",
quot: '"',
nbsp: "\xa0",
};
const entityPattern = /&([a-z]+);/gi;
function decodeHTMLEntities(text) {
// A single replace pass with a static RegExp is faster than a loop
return text.replace(entityPattern, function (match, entity) {
entity = entity.toLowerCase();
if (Object.prototype.hasOwnProperty.call(entities, entity)) {
return entities[entity];
}
// return original string if there is no matching entity (no replace)
return match;
});
}
export {
addImport,
addSpaceAfterChar,
addSpaceAroundChar,
containsOtherThan,
containsExceptInsideString,
decodeHTMLEntities,
evaluateRange,
evaluateRangeFromLine,
evaluateVariables,
findRangeBetweenStrings,
getIndentLength,
getLastStringRange,
getLastVariableRange,
getNpmGroovyLintVersion,
getOutOfBracesStrings,
getSourceLines,
getStringRange,
getStringRangeMultiline,
getVariable,
getVariableRange,
isErrorInLogLevelScope,
isValidCodeLine,
moveOpeningBracket,
normalizeNewLines,
};