perlnavigator-server
Version:
Perl language server
355 lines (313 loc) • 15 kB
text/typescript
import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver/node";
import { ParseType, NavigatorSettings, CompilationResults, PerlDocument } from "./types";
import { WorkspaceFolder } from "vscode-languageserver-protocol";
import { dirname, join } from "path";
import Uri from "vscode-uri";
import { getIncPaths, getPerlimportsProfile, async_execFile, nLog } from "./utils";
import { buildNav } from "./parseTags";
import { getPerlAssetsPath } from "./assets";
import { parseDocument } from "./parser";
import { TextDocument } from "vscode-languageserver-textdocument";
export async function perlcompile(textDocument: TextDocument, workspaceFolders: WorkspaceFolder[] | null, settings: NavigatorSettings): Promise<CompilationResults | void> {
const parsingPromise = parseDocument(textDocument, ParseType.selfNavigation);
if (!settings.perlCompileEnabled){
const parsedDoc = await parsingPromise;
return { diags: [], perlDoc: parsedDoc };
}
let perlParams: string[] = [...settings.perlParams, "-c"];
let perlEnv = settings.perlEnv;
let perlEnvAdd = settings.perlEnvAdd;
const filePath = Uri.parse(textDocument.uri).fsPath;
if (settings.enableWarnings) perlParams = perlParams.concat(["-Mwarnings", "-M-warnings=redefine"]); // Force enable some warnings.
perlParams = perlParams.concat(getIncPaths(workspaceFolders, settings));
perlParams = perlParams.concat(await getInquisitor());
nLog("Starting perl compilation check with the equivalent of: " + settings.perlPath + " " + perlParams.join(" ") + " " + filePath, settings);
let output: string;
let stdout: string;
let severity: DiagnosticSeverity;
const diagnostics: Diagnostic[] = [];
const code = getAdjustedPerlCode(textDocument, filePath);
try {
let options: {
timeout: number;
maxBuffer: number;
env?: { [key: string]: string | undefined };
} = { timeout: 10000, maxBuffer: 20 * 1024 * 1024 };
if (perlEnv) {
if (perlEnvAdd) {
options.env = { ...process.env, ...perlEnv };
} else {
options.env = perlEnv;
}
}
const perlProcess = async_execFile(settings.perlPath, perlParams, options);
perlProcess?.child?.stdin?.on("error", (error: any) => {
nLog("Perl Compilation Error Caught: ", settings);
nLog(error, settings);
});
perlProcess?.child?.stdin?.write(code);
perlProcess?.child?.stdin?.end();
const out = await perlProcess;
output = out.stderr.toString();
stdout = out.stdout.toString();
severity = DiagnosticSeverity.Warning;
} catch (error: any) {
// TODO: Check if we overflowed the buffer.
if ("stderr" in error && "stdout" in error) {
output = error.stderr.toString();
stdout = error.stdout.toString();
severity = DiagnosticSeverity.Error;
} else {
nLog("Perlcompile failed with unknown error", settings);
nLog(error, settings);
return;
}
}
const compiledDoc = buildNav(stdout, filePath, textDocument.uri);
const parsedDoc = await parsingPromise;
const mergedDoc = mergeDocs(parsedDoc, compiledDoc);
output.split("\n").forEach((violation) => {
maybeAddCompDiag(violation, severity, diagnostics, filePath, mergedDoc);
});
// If a base object throws a warning multiple times, we want to deduplicate it to declutter the problems tab.
const uniq_diagnostics = Array.from(new Set(diagnostics.map((diag) => JSON.stringify(diag)))).map((str) => JSON.parse(str));
return { diags: uniq_diagnostics, perlDoc: mergedDoc };
}
async function getInquisitor(): Promise<string[]> {
const inq_path = await getPerlAssetsPath();
let inq: string[] = ["-I", inq_path, "-MInquisitor"];
return inq;
}
function getAdjustedPerlCode(textDocument: TextDocument, filePath: string): string {
let code = textDocument.getText();
// module name regex stolen from https://metacpan.org/pod/Module::Runtime#$module_name_rx
const module_name_rx = /^\s*package[\s\n]+([A-Z_a-z][0-9A-Z_a-z]*(?:::[0-9A-Z_a-z]+)*)/gm;
let register_inc_path = "";
let module_name_match = module_name_rx.exec(code);
while (module_name_match != null) {
const module_name = module_name_match[1];
const inc_filename = module_name.replaceAll("::", "/") + ".pm";
// make sure the package found actually matches the filename
if (filePath.indexOf(inc_filename) != -1) {
register_inc_path = `\$INC{'${inc_filename}'} = '${filePath}';`;
break;
} else {
module_name_match = module_name_rx.exec(code);
}
}
code =
`local \$0; use lib_bs22::SourceStash; BEGIN { \$0 = '${filePath}'; if (\$INC{'FindBin.pm'}) { FindBin->again(); }; \$lib_bs22::SourceStash::filename = '${filePath}'; print "Setting file" . __FILE__; ${register_inc_path} }\n# line 0 \"${filePath}\"\ndie('Not needed, but die for safety');\n` +
code;
return code;
}
function maybeAddCompDiag(violation: string, severity: DiagnosticSeverity, diagnostics: Diagnostic[], filePath: string, perlDoc: PerlDocument): void {
violation = violation.replaceAll("\r", ""); // Clean up for Windows
violation = violation.replace(/, <STDIN> line 1\.$/g, ""); // Remove our stdin nonsense
let output = localizeErrors(violation, filePath, perlDoc);
if (typeof output == "undefined") return;
const lineNum = output.lineNum;
violation = output.violation;
if (violation.indexOf("=PerlWarning=") != -1) {
// Downgrade severity for explicitly marked severities
severity = DiagnosticSeverity.Warning;
violation = violation.replaceAll("=PerlWarning=", ""); // Don't display the PerlWarnings
}
diagnostics.push({
severity: severity,
range: {
start: { line: lineNum, character: 0 },
end: { line: lineNum, character: 500 },
},
message: "Syntax: " + violation,
source: "perlnavigator",
});
}
function localizeErrors(violation: string, filePath: string, perlDoc: PerlDocument): { violation: string; lineNum: number } | void {
if (violation.indexOf("Too late to run CHECK block") != -1) return;
let match = /^(.+)at\s+(.+?)\s+line\s+(\d+)/i.exec(violation);
if (match) {
if (match[2] == filePath) {
violation = match[1];
const lineNum = +match[3] - 1;
return { violation, lineNum };
} else {
// The error/warnings must be in an imported library (possibly indirectly imported).
let lineNum = 0; // If indirectly imported
const importFileName = match[2].replace(".pm", "").replace(/[\\\/]/g, "::");
perlDoc.imported.forEach((line, mod) => {
// importFileName could be something like usr::lib::perl::dir::Foo::Bar
if (importFileName.endsWith(mod)) lineNum = line;
});
return { violation, lineNum };
}
}
match = /\s+is not exported by the ([\w:]+) module$/i.exec(violation);
if (match) {
let lineNum = perlDoc.imported.get(match[1]);
if (typeof lineNum == "undefined") lineNum = 0;
return { violation, lineNum };
}
return;
}
export async function perlcritic(textDocument: TextDocument, workspaceFolders: WorkspaceFolder[] | null, settings: NavigatorSettings): Promise<Diagnostic[]> {
if (!settings.perlcriticEnabled) return [];
const critic_path = join(await getPerlAssetsPath(), "criticWrapper.pl");
let criticParams: string[] = [...settings.perlParams, critic_path].concat(getCriticProfile(workspaceFolders, settings));
criticParams = criticParams.concat(["--file", Uri.parse(textDocument.uri).fsPath]);
// Add any extra params from settings
if (settings.perlcriticSeverity) criticParams = criticParams.concat(["--severity", settings.perlcriticSeverity.toString()]);
if (settings.perlcriticTheme) criticParams = criticParams.concat(["--theme", settings.perlcriticTheme]);
if (settings.perlcriticExclude) criticParams = criticParams.concat(["--exclude", settings.perlcriticExclude]);
if (settings.perlcriticInclude) criticParams = criticParams.concat(["--include", settings.perlcriticInclude]);
nLog("Now starting perlcritic with: " + criticParams.join(" "), settings);
const code = textDocument.getText();
const diagnostics: Diagnostic[] = [];
let output: string;
try {
const process = async_execFile(settings.perlPath, criticParams, { timeout: 25000 });
process?.child?.stdin?.on("error", (error: any) => {
nLog("Perl Critic Error Caught: ", settings);
nLog(error, settings);
});
process?.child?.stdin?.write(code);
process?.child?.stdin?.end();
const out = await process;
output = out.stdout;
} catch (error: any) {
nLog("Perlcritic failed with unknown error", settings);
nLog(error, settings);
return diagnostics;
}
nLog("Critic output: " + output, settings);
output.split("\n").forEach((violation) => {
maybeAddCriticDiag(violation, diagnostics, settings);
});
return diagnostics;
}
export async function perlimports(textDocument: TextDocument, workspaceFolders: WorkspaceFolder[] | null, settings: NavigatorSettings): Promise<Diagnostic[]> {
if (!settings.perlimportsLintEnabled) return [];
const importsPath = join(await getPerlAssetsPath(), "perlimportsWrapper.pl");
const cliParams = [...settings.perlParams, importsPath, ...getPerlimportsProfile(settings), "--lint", "--json", "--filename", Uri.parse(textDocument.uri).fsPath];
nLog("Now starting perlimports with: " + cliParams.join(" "), settings);
const code = textDocument.getText();
const diagnostics: Diagnostic[] = [];
let output: string;
try {
const process = async_execFile(settings.perlPath, cliParams, { timeout: 25000 });
process?.child?.stdin?.on("error", (error: any) => {
nLog("perlimports Error Caught: " + error, settings);
});
process?.child?.stdin?.write(code);
process?.child?.stdin?.end();
const out = await process;
output = out.stdout;
} catch (error: any) {
nLog("Attempted to run perlimports lint: " + error.stdout, settings);
output = error.message;
}
// The first line will be an error message about perlimports failing.
// The last line may be blank.
output
.split("\n")
.filter((v) => v.startsWith("{"))
.forEach((violation) => {
maybeAddPerlImportsDiag(violation, diagnostics, settings);
});
return diagnostics;
}
function getCriticProfile(workspaceFolders: WorkspaceFolder[] | null, settings: NavigatorSettings): string[] {
let profileCmd: string[] = [];
if (settings.perlcriticProfile) {
let profile = settings.perlcriticProfile;
if (profile.indexOf("$workspaceFolder") != -1) {
if (workspaceFolders) {
// TODO: Fix this too. Only uses the first workspace folder
const workspaceUri = Uri.parse(workspaceFolders[0].uri).fsPath;
profileCmd.push("--profile");
profileCmd.push(profile.replaceAll("$workspaceFolder", workspaceUri));
} else {
nLog("You specified $workspaceFolder in your perlcritic path, but didn't include any workspace folders. Ignoring profile.", settings);
}
} else {
profileCmd.push("--profile");
profileCmd.push(profile);
}
}
return profileCmd;
}
function maybeAddCriticDiag(violation: string, diagnostics: Diagnostic[], settings: NavigatorSettings): void {
// Severity ~|~ Line ~|~ Column ~|~ Description ~|~ Policy ~||~ Newline
const tokens = violation.replace("~||~", "").replaceAll("\r", "").split("~|~");
if (tokens.length != 5) {
return;
}
const line_num = +tokens[1] - 1;
const col_num = +tokens[2] - 1;
const message = tokens[3] + " (" + tokens[4] + ", Severity: " + tokens[0] + ")";
const severity = getCriticDiagnosticSeverity(tokens[0], settings);
if (!severity) {
return;
}
diagnostics.push({
severity: severity,
range: {
start: { line: line_num, character: col_num },
end: { line: line_num, character: col_num + 500 }, // Arbitrarily large
},
message: "Critic: " + message,
source: "perlnavigator",
});
}
function maybeAddPerlImportsDiag(violation: string, diagnostics: Diagnostic[], settings: NavigatorSettings): void {
try {
const diag = JSON.parse(violation);
const loc = diag.location;
diagnostics.push({
message: `perlimports: ${diag.reason} \n\n ${diag.diff}`,
range: {
start: { line: Number(loc.start.line) - 1, character: Number(loc.start.column) - 1 },
end: { line: Number(loc.end.line) - 1, character: Number(loc.end.column) - 1 },
},
severity: DiagnosticSeverity.Warning,
source: "perlnavigator",
});
} catch (error: any) {
nLog(`Could not parse JSON violation ${error}`, settings);
}
}
function getCriticDiagnosticSeverity(severity_num: string, settings: NavigatorSettings): DiagnosticSeverity | undefined {
// Unknown severity gets max (should never happen)
const severity_config =
severity_num == "1"
? settings.severity1
: severity_num == "2"
? settings.severity2
: severity_num == "3"
? settings.severity3
: severity_num == "4"
? settings.severity4
: settings.severity5;
switch (severity_config) {
case "none":
return undefined;
case "hint":
return DiagnosticSeverity.Hint;
case "info":
return DiagnosticSeverity.Information;
case "warning":
return DiagnosticSeverity.Warning;
default:
return DiagnosticSeverity.Error;
}
}
function mergeDocs(doc1: PerlDocument, doc2: PerlDocument) {
// TODO: Redo this code. Instead of merging sources, you should keep track of where symbols came from
doc1.autoloads = new Map([...doc1.autoloads, ...doc2.autoloads]);
doc1.canonicalElems = new Map([...doc1.canonicalElems, ...doc2.canonicalElems]);
// TODO: Should elems be merged? Probably. Or tagged doc and compilation results are totally split
doc1.elems = new Map([...doc2.elems, ...doc1.elems]); // Tagged docs have priority?
doc1.imported = new Map([...doc1.imported, ...doc2.imported]);
doc1.parents = new Map([...doc1.parents, ...doc2.parents]);
doc1.uri = doc2.uri;
return doc1;
}