perlnavigator-server
Version:
Perl language server
346 lines (299 loc) • 15.2 kB
text/typescript
import { TextDocumentPositionParams, CompletionItem, CompletionItemKind, Range, MarkupContent } from "vscode-languageserver/node";
import { TextDocument } from "vscode-languageserver-textdocument";
import { PerlDocument, PerlElem, CompletionPrefix, PerlSymbolKind, completionElem, ElemSource} from "./types";
import { getPod } from "./pod";
import Uri from "vscode-uri";
export function getCompletions(
params: TextDocumentPositionParams,
perlDoc: PerlDocument,
txtDoc: TextDocument,
modMap: Map<string, string>
): CompletionItem[] {
let position = params.position;
const start = { line: position.line, character: 0 };
const end = { line: position.line + 1, character: 0 };
const text = txtDoc.getText({ start, end });
const index = txtDoc.offsetAt(position) - txtDoc.offsetAt(start);
const imPrefix = getImportPrefix(text, index);
if (imPrefix) {
const replace: Range = {
start: { line: position.line, character: imPrefix.charStart },
end: { line: position.line, character: imPrefix.charEnd },
};
const matches = getImportMatches(modMap, imPrefix.symbol, replace, perlDoc);
return matches;
} else {
const prefix = getPrefix(text, index);
if (!prefix.symbol) return [];
const replace: Range = {
start: { line: position.line, character: prefix.charStart },
end: { line: position.line, character: prefix.charEnd },
};
const matches = getMatches(perlDoc, prefix.symbol, replace, prefix.stripPackage);
return matches;
}
}
export async function getCompletionDoc(elem: PerlElem, perlDoc: PerlDocument, modMap: Map<string, string>): Promise<string | undefined> {
let docs = await getPod(elem, perlDoc, modMap);
return docs;
}
// Similar to getSymbol for navigation, but don't "move right".
function getPrefix(text: string, position: number): CompletionPrefix {
const canShift = (c: string) => /[\w\:\>\-]/.exec(c);
let l = position - 1; // left
for (; l >= 0 && canShift(text[l]); --l);
if (l < 0 || text[l] != "$" && text[l] != "@" && text[l] != "%") ++l;
let symbol = text.substring(l, position);
const prefix = text.substring(0, l);
let stripPackage = false;
if (symbol.match(/^-(?:>\w*)?$/)) { // Matches - or -> or ->\w
// If you have Foo::Bar->new(...)->func, the extracted symbol will be ->func
// We can special case this to Foo::Bar->func. The regex allows arguments to new(), including params with matched ()
let match = prefix.match(/(\w(?:\w|::\w)*)->new\((?:\([^()]*\)|[^()])*\)$/);
if (match){
symbol = match[1] + symbol;
stripPackage = true
}
}
return { symbol: symbol, charStart: l, charEnd: position, stripPackage: stripPackage};
}
// First we check if it's an import statement, which is a special type of autocomplete with far more options
function getImportPrefix(text: string, position: number): CompletionPrefix | undefined {
text = text.substring(0, position);
let partialImport = /^\s*(?:use|require)\s+([\w:]+)$/.exec(text);
if (!partialImport) return;
const symbol = partialImport[1];
return { symbol: symbol, charStart: position - symbol.length, charEnd: position, stripPackage: false };
}
function getImportMatches(modMap: Map<string, string>, symbol: string, replace: Range, perlDoc: PerlDocument): CompletionItem[] {
const matches: CompletionItem[] = [];
const mods = Array.from(modMap.keys());
const lcSymbol = symbol.toLowerCase();
modMap.forEach((modFile, mod) => {
if (mod.toLowerCase().startsWith(lcSymbol)) {
const modUri = Uri.parse(modFile).toString();
const modElem: PerlElem = {
name: symbol,
type: PerlSymbolKind.Module,
typeDetail: "",
uri: modUri,
package: symbol,
line: 0,
lineEnd: 0,
value: "",
source: ElemSource.modHunter,
};
const newElem: completionElem = {perlElem: modElem, docUri: perlDoc.uri}
matches.push({
label: mod,
textEdit: { newText: mod, range: replace },
kind: CompletionItemKind.Module,
data: newElem
});
}
});
return matches;
}
function getMatches(perlDoc: PerlDocument, symbol: string, replace: Range, stripPackage: boolean): CompletionItem[] {
let matches: CompletionItem[] = [];
let qualifiedSymbol = symbol.replaceAll("->", "::"); // Module->method() can be found via Module::method
if (qualifiedSymbol.endsWith('-'))
qualifiedSymbol = qualifiedSymbol.replace('-', ':');
let bKnownObj = false;
// Check if we know the type of this object
let knownObject = /^(\$\w+):(?::\w*)?$/.exec(qualifiedSymbol);
if (knownObject) {
const targetVar = perlDoc.canonicalElems.get(knownObject[1]);
if (targetVar) {
qualifiedSymbol = qualifiedSymbol.replace(/^\$\w+(?=:)/, targetVar.typeDetail);
bKnownObj = true;
}
}
// If the magic variable $self->, then autocomplete to everything in main.
const bSelf = /^(\$self):(?::\w*)?$/.exec(qualifiedSymbol);
if (bSelf) bKnownObj = true;
// const lcQualifiedSymbol = qualifiedSymbol.toLowerCase(); Case insensitive matches are hard since we restore what you originally matched on
perlDoc.elems.forEach((elements: PerlElem[], elemName: string) => {
if (/^[\$\@\%].$/.test(elemName)) return; // Remove single character magic perl variables. Mostly clutter the list
let element = perlDoc.canonicalElems.get(elemName) || elements[0]; // Get the canonical (typed) element, otherwise just grab the first one.
// All plain and inherited subroutines should match with $self. We're excluding PerlSymbolKind.ImportedSub here because imports clutter the list, despite perl allowing them called on $self->
if (bSelf && [PerlSymbolKind.LocalSub, PerlSymbolKind.Inherited, PerlSymbolKind.LocalMethod, PerlSymbolKind.Field].includes(element.type))
elemName = `$self::${elemName}`;
if (goodMatch(perlDoc, elemName, qualifiedSymbol, symbol, bKnownObj)) {
// Hooray, it's a match!
// You may have asked for FOO::BAR->BAZ or $qux->BAZ and I found FOO::BAR::BAZ. Let's put back the arrow or variable before sending
const quotedSymbol = qualifiedSymbol.replaceAll("$", "\\$"); // quotemeta for $self->FOO
let aligned = elemName.replace(new RegExp(`^${quotedSymbol}`, "gi"), symbol);
if (symbol.endsWith("-")) aligned = aligned.replaceAll('-:', "->"); // Half-arrows count too
// Don't send invalid constructs
// like FOO->BAR::BAZ
if (/\-\>\w+::/.test(aligned)) return;
// FOO->BAR if Bar is not a sub/method.
if (
/\-\>\w+$/.test(aligned) &&
![
PerlSymbolKind.LocalSub,
PerlSymbolKind.ImportedSub,
PerlSymbolKind.Inherited,
PerlSymbolKind.LocalMethod,
PerlSymbolKind.Method,
PerlSymbolKind.Field,
PerlSymbolKind.PathedField,
].includes(element.type)
)
return;
// FOO::BAR if Bar is a instance method or attribute (I assume them to be instance methods/attributes, not class)
if (
!/^\$.*\-\>\w+$/.test(aligned) &&
[PerlSymbolKind.LocalMethod, PerlSymbolKind.Method, PerlSymbolKind.Field, PerlSymbolKind.PathedField].includes(element.type)
)
return;
if (
aligned.indexOf("-:") != -1 || // We look things up like this, but don't let them slip through
aligned.startsWith('$') && aligned.indexOf("::", 1) != -1
)
// $Foo::Bar, I don't really hunt for these anyway
return;
matches = matches.concat(buildMatches(aligned, element, replace, stripPackage, perlDoc));
}
});
return matches;
}
// TODO: preprocess all "allowed" matches so we don't waste time iterating over them for every autocomplete.
function goodMatch(perlDoc: PerlDocument, elemName: string, qualifiedSymbol: string, origSymbol: string, bKnownObj: boolean): boolean {
if (!elemName.startsWith(qualifiedSymbol)) return false;
// All uppercase methods are generally private or autogenerated and unhelpful
if (/(?:::|->)[A-Z][A-Z_]+$/.test(elemName)) return false;
if (bKnownObj) {
// If this is a known object type, we probably aren't importing the package or building a new one.
if (/(?:::|->)(?:new|import)$/.test(elemName)) return false;
// If we known the object type (and variable name is not $self), then exclude the double underscore private variables (rare anyway. single underscore kept, but ranked last in the autocomplete)
if (origSymbol.startsWith('$') && !origSymbol.startsWith("$self") && /(?:::|->)__\w+$/.test(elemName)) return false;
// Otherwise, always autocomplete, even if the module has not been explicitly imported.
return true;
}
// Get the module name to see if it's been imported. Otherwise, don't allow it.
let modRg = /^(.+)::.*?$/;
var match = modRg.exec(elemName);
if (match && !perlDoc.imported.has(match[1])) {
// TODO: Allow completion on packages/class defined within the file itself (e.g. Foo->new, $foo->new already works)
// Thing looks like a module, but was not explicitly imported
return false;
} else {
// Thing was either explictly imported or not a module function
return true;
}
}
function buildMatches(lookupName: string, elem: PerlElem, range: Range, stripPackage: boolean, perlDoc: PerlDocument): CompletionItem[] {
let kind: CompletionItemKind;
let detail: string | undefined = undefined;
let documentation: MarkupContent | undefined = undefined;
let docs: string[] = [];
if ([PerlSymbolKind.LocalVar, PerlSymbolKind.ImportedVar, PerlSymbolKind.Canonical].includes(elem.type)) {
if (elem.typeDetail.length > 0) {
kind = CompletionItemKind.Variable;
detail = `${lookupName}: ${elem.typeDetail}`;
} else if (lookupName == "$self") {
kind = CompletionItemKind.Variable;
// elem.package can be misleading if you use $self in two different packages in the same module. Get scoped matches will address this
detail = `${lookupName}: ${elem.package}`;
}
}
if (!detail) {
switch (elem.type) {
case PerlSymbolKind.LocalVar:
kind = CompletionItemKind.Variable;
break;
case PerlSymbolKind.ImportedVar:
kind = CompletionItemKind.Constant;
// detail = elem.name;
docs.push(elem.name);
docs.push(`Value: ${elem.value}`);
break;
case PerlSymbolKind.ImportedHash:
case PerlSymbolKind.Constant:
kind = CompletionItemKind.Constant;
break;
case PerlSymbolKind.LocalSub:
if (lookupName.startsWith("$self-")) docs.push(elem.name); // For consistency with the other $self methods. VScode seems to hide documentation if less populated?
kind = CompletionItemKind.Function;
break;
case PerlSymbolKind.ImportedSub:
case PerlSymbolKind.Inherited:
case PerlSymbolKind.Method:
case PerlSymbolKind.LocalMethod:
kind = CompletionItemKind.Method;
docs.push(elem.name);
if (elem.typeDetail && elem.typeDetail != elem.name) docs.push(`\nDefined as:\n ${elem.typeDetail}`);
break;
case PerlSymbolKind.Package:
case PerlSymbolKind.Module:
kind = CompletionItemKind.Module;
break;
case PerlSymbolKind.Label: // Loop labels
kind = CompletionItemKind.Reference;
break;
case PerlSymbolKind.Class:
kind = CompletionItemKind.Class;
break;
case PerlSymbolKind.Role:
kind = CompletionItemKind.Interface;
break;
case PerlSymbolKind.Field:
case PerlSymbolKind.PathedField:
kind = CompletionItemKind.Field;
break;
case PerlSymbolKind.Phaser:
case PerlSymbolKind.HttpRoute:
case PerlSymbolKind.OutlineOnlySub:
return [];
default: // A sign that something needs fixing. Everything should've been enumerated.
kind = CompletionItemKind.Property;
break;
}
}
if (docs.length > 0) documentation = { kind: "markdown", value: "```\n" + docs.join("\n") + "\n```" };
let labelsToBuild = [lookupName];
if (lookupName.endsWith("::new"))
// Having ->new at the top (- sorts before :) is the more common way to call packages (although you can call it either way).
labelsToBuild.push(lookupName.replace(/::new$/, "->new"));
let matches: CompletionItem[] = [];
labelsToBuild.forEach((label) => {
let replaceText = label;
if(stripPackage)
// When autocompleting Foo->new(...)->, we need the dropdown to show Foo->func, but the replacement only to be ->func
replaceText = replaceText.replace(/^(\w(?:\w|::\w)*)(?=->)/, '');
const newElem: completionElem = {perlElem: elem, docUri: perlDoc.uri}
matches.push({
label: label,
textEdit: { newText: replaceText, range },
kind: kind,
sortText: getSortText(label),
detail: detail,
documentation: documentation,
data: newElem,
});
});
return matches;
}
function getSortText(label: string): string {
// Ensure sorting has public methods up front, followed by private and then capital. (private vs somewhat capital is arbitrary, but public makes sense).
// Variables will still be higher when relevant.
// use English puts a lot of capital variables, so these will end up lower as well (including Hungarian notation capitals)
let sortText: string;
if (/^[@\$%]?[a-z]?[a-z]?[A-Z][A-Z_]*$/.test(label) || /(?:::|->)[A-Z][A-Z_]+$/.test(label)) {
sortText = "4" + label;
} else if (label == "_" || /(?:::|->)_\w+$/.test(label)) {
sortText = "3" + label;
} else if (/^\w$/.test(label) || /(?:::|->)\w+$/.test(label)) {
// Public methods / functions
sortText = "2";
// Prioritize '->new'
if (label.indexOf("->new") != -1) sortText += "1";
sortText += label;
} else {
// Variables and regex mistakes
sortText = "1" + label;
}
return sortText;
}