diff options
author | bscan <10503608+bscan@users.noreply.github.com> | 2023-01-16 15:07:10 -0500 |
---|---|---|
committer | bscan <10503608+bscan@users.noreply.github.com> | 2023-01-16 15:07:10 -0500 |
commit | 432d50536a3ab5c43c195c674381329a3fa00c6c (patch) | |
tree | fe1c34b2f07146fe21a3218f28151096903a52e0 /browser-ext | |
parent | 0ff5950f5c7e513b95489372044e9d26dc5550bc (diff) | |
download | PerlNavigator-432d50536a3ab5c43c195c674381329a3fa00c6c.zip |
Web extension: adding tags, go-to definition, hover, completion, and outline view
Diffstat (limited to 'browser-ext')
-rw-r--r-- | browser-ext/src/browserServerMain.ts | 149 | ||||
-rw-r--r-- | browser-ext/src/web-completion.ts | 244 | ||||
-rw-r--r-- | browser-ext/src/web-hover.ts | 78 | ||||
-rw-r--r-- | browser-ext/src/web-navigation.ts | 95 | ||||
-rw-r--r-- | browser-ext/src/web-parse.ts | 262 | ||||
-rw-r--r-- | browser-ext/src/web-symbols.ts | 81 | ||||
-rw-r--r-- | browser-ext/src/web-types.ts | 96 | ||||
-rw-r--r-- | browser-ext/src/web-utils.ts | 157 |
8 files changed, 1100 insertions, 62 deletions
diff --git a/browser-ext/src/browserServerMain.ts b/browser-ext/src/browserServerMain.ts index 5596469..e2bf515 100644 --- a/browser-ext/src/browserServerMain.ts +++ b/browser-ext/src/browserServerMain.ts @@ -2,10 +2,19 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createConnection, BrowserMessageReader, BrowserMessageWriter } from 'vscode-languageserver/browser'; +import { createConnection, BrowserMessageReader, BrowserMessageWriter, SymbolInformation, SymbolKind, CompletionList, TextDocumentPositionParams, CompletionItem, Hover, Location } from 'vscode-languageserver/browser'; import { Color, ColorInformation, Range, InitializeParams, InitializeResult, ServerCapabilities, TextDocuments, ColorPresentation, TextEdit, TextDocumentIdentifier } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; +import { PerlDocument, PerlElem } from "./web-types"; +import { buildNav } from "./web-parse"; + +import { getDefinition } from "./web-navigation"; +import { getSymbols } from "./web-symbols"; +import { getHover } from "./web-hover"; +import { getCompletions } from './web-completion'; + +var LRU = require("lru-cache"); console.log('running server perl-navigator web'); @@ -17,95 +26,111 @@ const messageWriter = new BrowserMessageWriter(self); const connection = createConnection(messageReader, messageWriter); +// My ballpark estimate is that 350k symbols will be about 35MB. Huge map, but a reasonable limit. +const navSymbols = new LRU({max: 350000, length: function (value:PerlDocument , key:string) { return value.elems.size }}); +const timers: Map<string, ReturnType<typeof setTimeout>> = new Map(); + + /* from here on, all code is non-browser specific and could be shared with a regular extension */ connection.onInitialize((params: InitializeParams): InitializeResult => { const capabilities: ServerCapabilities = { - colorProvider: {} // provide a color provider + completionProvider: { + resolveProvider: false, + triggerCharacters: ['$','@','%','-', '>',':'] + }, + + definitionProvider: true, // goto definition + documentSymbolProvider: true, // Outline view and breadcrumbs + hoverProvider: true, }; return { capabilities }; }); + // Track open, change and close text document events const documents = new TextDocuments(TextDocument); documents.listen(connection); -// Register providers -connection.onDocumentColor(params => getColorInformation(params.textDocument)); -connection.onColorPresentation(params => getColorPresentation(params.color, params.range)); - // Listen on the connection connection.listen(); -const colorRegExp = /#([0-9A-Fa-f]{6})/g; +// Only keep symbols for open documents +documents.onDidClose(e => { + navSymbols.del(e.document.uri); +}); -function getColorInformation(textDocument: TextDocumentIdentifier) { - const colorInfos: ColorInformation[] = []; - const document = documents.get(textDocument.uri); - if (document) { - const text = document.getText(); +documents.onDidOpen(change => { + validatePerlDocument(change.document); +}); - colorRegExp.lastIndex = 0; - let match; - while ((match = colorRegExp.exec(text)) != null) { - const offset = match.index; - const length = match[0].length; - const range = Range.create(document.positionAt(offset), document.positionAt(offset + length)); - const color = parseColor(text, offset); - colorInfos.push({ color, range }); - } - } - console.log(`Found this many colors: ${colorInfos.length}`) - //return colorInfos; - return []; -} +documents.onDidSave(change => { + validatePerlDocument(change.document); +}); -function getColorPresentation(color: Color, range: Range) { - const result: ColorPresentation[] = []; - const red256 = Math.round(color.red * 255), green256 = Math.round(color.green * 255), blue256 = Math.round(color.blue * 255); +documents.onDidChangeContent(change => { + // VSCode sends a firehose of change events. Only check after it's been quiet for 1 second. + const timer = timers.get(change.document.uri) + if(timer) clearTimeout(timer); + const newTimer: ReturnType<typeof setTimeout> = setTimeout(function(){ validatePerlDocument(change.document)}, 1000); + timers.set(change.document.uri, newTimer); +}); - function toTwoDigitHex(n: number): string { - const r = n.toString(16); - return r.length !== 2 ? '0' + r : r; - } - const label = `#${toTwoDigitHex(red256)}${toTwoDigitHex(green256)}${toTwoDigitHex(blue256)}`; - result.push({ label: label, textEdit: TextEdit.replace(range, label) }); +async function validatePerlDocument(textDocument: TextDocument): Promise<void> { + console.log("Rebuilding symbols for " + textDocument.uri + ""); + const perlDoc = await buildNav(textDocument); + navSymbols.set(textDocument.uri, perlDoc); + return; - return result; } -const enum CharCode { - Digit0 = 48, - Digit9 = 57, +connection.onDocumentSymbol(params => { + console.log("Navigator: Getting document symbols"); + return getSymbols(navSymbols, params.textDocument.uri); +}); + +// This handler provides the initial list of the completion items. +connection.onCompletion((params: TextDocumentPositionParams): CompletionList | undefined => { + console.log("Navigator: Getting completion results"); - A = 65, - F = 70, + let document = documents.get(params.textDocument.uri); + let perlDoc = navSymbols.get(params.textDocument.uri); + if(!document || !perlDoc) return; - a = 97, - f = 102, -} + const completions: CompletionItem[] = getCompletions(params, perlDoc, document); -function parseHexDigit(charCode: CharCode): number { - if (charCode >= CharCode.Digit0 && charCode <= CharCode.Digit9) { - return charCode - CharCode.Digit0; - } - if (charCode >= CharCode.A && charCode <= CharCode.F) { - return charCode - CharCode.A + 10; - } - if (charCode >= CharCode.a && charCode <= CharCode.f) { - return charCode - CharCode.a + 10; - } - return 0; -} + return { + items: completions, + isIncomplete: false, + }; +}); + + +connection.onHover(params => { + console.log("Trying to hover"); + console.log(params.position); + let document = documents.get(params.textDocument.uri); + let perlDoc = navSymbols.get(params.textDocument.uri); + if(!document || !perlDoc) return; -function parseColor(content: string, offset: number): Color { - const r = (16 * parseHexDigit(content.charCodeAt(offset + 1)) + parseHexDigit(content.charCodeAt(offset + 2))) / 255; - const g = (16 * parseHexDigit(content.charCodeAt(offset + 3)) + parseHexDigit(content.charCodeAt(offset + 4))) / 255; - const b = (16 * parseHexDigit(content.charCodeAt(offset + 5)) + parseHexDigit(content.charCodeAt(offset + 6))) / 255; - return Color.create(r, g, b, 1); -}
\ No newline at end of file + return getHover(params, perlDoc, document); +}); + + +connection.onDefinition(params => { + console.log("Navigator: Getting definition results"); + + let document = documents.get(params.textDocument.uri); + let perlDoc = navSymbols.get(params.textDocument.uri); + if(!document || !perlDoc) return; + + let locOut: Location | Location[] | undefined = getDefinition(params, perlDoc, document); + console.log("Got definition results"); + console.log(locOut); + return locOut; +}); diff --git a/browser-ext/src/web-completion.ts b/browser-ext/src/web-completion.ts new file mode 100644 index 0000000..8701383 --- /dev/null +++ b/browser-ext/src/web-completion.ts @@ -0,0 +1,244 @@ +import { + TextDocumentPositionParams, + CompletionItem, + CompletionItemKind, + Range, + MarkupContent +} from 'vscode-languageserver/node'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { PerlDocument, PerlElem, CompletionPrefix, PerlSymbolKind } from "./web-types"; + + +export function getCompletions(params: TextDocumentPositionParams, perlDoc: PerlDocument, txtDoc: TextDocument): 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 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); + return matches; + +} + + +// Similar to getSymbol for navigation, but don't "move right". +function getPrefix(text: string, position: number): CompletionPrefix { + + const leftAllow = (c: string) => /[\w\:\>\-]/.exec(c); + + let left = position - 1; + + while (left >= 0 && leftAllow(text[left])) { + left -= 1; + } + left = Math.max(0, left + 1); + + let symbol = text.substring(left, position); + const lChar = left > 0 ? text[left-1] : ""; + + if(lChar === '$' || lChar === '@' || lChar === '%'){ + symbol = lChar + symbol; + left -= 1; + } + + return {symbol: symbol, charStart: left, charEnd: position}; +} + + +function getMatches(perlDoc: PerlDocument, symbol: string, replace: Range): CompletionItem[] { + + let matches: CompletionItem[] = []; + + let qualifiedSymbol = symbol.replace(/->/g, "::"); // Module->method() can be found via Module::method + qualifiedSymbol = qualifiedSymbol.replace(/-$/g, ":"); // Maybe I just started typing Module- + + 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.type); + 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 "t" here because imports clutter the list, despite perl allowing them called on $self-> + if(bSelf && ["s", "i", "o", "f"].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.replace(/([\$])/g, '\\$1'); // quotemeta for $self->FOO + let aligned = elemName.replace(new RegExp(`^${quotedSymbol}`, 'gi'), symbol); + + if(symbol.endsWith('-')) aligned = aligned.replace(new RegExp(`-:`, 'gi'), '->'); // Half-arrows count too + + // Don't send invalid constructs + if(/\-\>\w+::/.test(aligned) || // like FOO->BAR::BAZ + (/\-\>\w+$/.test(aligned) && !["s", "t", "i", "o", "x", "f", "d"].includes(element.type)) || // FOO->BAR if Bar is not a sub/method. + (!/^\$.*\-\>\w+$/.test(aligned) && ["o", "x", "f", "d"].includes(element.type)) || // FOO::BAR if Bar is a instance method or attribute (I assume them to be instance methods/attributes, not class) + (/-:/.test(aligned)) || // We look things up like this, but don't let them slip through + (/^\$.*::/.test(aligned)) // $Foo::Bar, I don't really hunt for these anyway + ) return; + + matches = matches.concat(buildMatches(aligned, element, replace)); + } + }); + + 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((/^(?!\$self)\$/.test(origSymbol) && /(?:::|->)__\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): CompletionItem[] { + + let kind: CompletionItemKind; + let detail: string | undefined = undefined; + let documentation: MarkupContent | undefined = undefined; + let docs: string[] = []; + + if (elem.type.length > 1 || ( ["v", "c"].includes(elem.type) && lookupName == '$self')) { + // We either know the object type, or it's $self + kind = CompletionItemKind.Variable; + if(elem.type.length > 1 ){ + detail = `${lookupName}: ${elem.type}`; + } else if (lookupName == '$self') { + // 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}`; + } + } else if(elem.type == PerlSymbolKind.LocalVar){ + kind = CompletionItemKind.Variable; + } else if(elem.type == PerlSymbolKind.ImportedVar){ + kind = CompletionItemKind.Constant; + // detail = elem.name; + docs.push(elem.name); + docs.push(`Value: ${elem.value}`); + } else if(elem.type == PerlSymbolKind.ImportedHash || elem.type == PerlSymbolKind.Constant) { + kind = CompletionItemKind.Constant; + } else if (elem.type == PerlSymbolKind.LocalSub){ + if(/^\$self\-/.test(lookupName)) docs.push(elem.name); // For consistency with the other $self methods. VScode seems to hide documentation if less populated? + kind = CompletionItemKind.Function; + } else if (elem.type == PerlSymbolKind.ImportedSub || elem.type == PerlSymbolKind.Inherited || elem.type == PerlSymbolKind.Method || elem.type == PerlSymbolKind.LocalMethod){ + kind = CompletionItemKind.Method; + docs.push(elem.name); + if(elem.typeDetail && elem.typeDetail != elem.name) docs.push(`\nDefined as:\n ${elem.typeDetail}`); + }else if (elem.type == PerlSymbolKind.Package || elem.type == PerlSymbolKind.Module){ + kind = CompletionItemKind.Module; + }else if (elem.type == PerlSymbolKind.Label){ // Loop labels + kind = CompletionItemKind.Reference; + } else if (elem.type == PerlSymbolKind.Class){ + kind = CompletionItemKind.Class; + } else if (elem.type == PerlSymbolKind.Role){ + kind = CompletionItemKind.Interface; + } else if (elem.type == PerlSymbolKind.Field || elem.type == PerlSymbolKind.PathedField){ + kind = CompletionItemKind.Field; + } else if (elem.type == PerlSymbolKind.Phaser){ + return []; + } else { // A sign that something needs fixing. Everything should've been enumerated. + kind = CompletionItemKind.Property; + } + + if(docs.length>0){ + documentation = {kind: "markdown", value: "```\n" + docs.join("\n") + "\n```" }; + } + + let labelsToBuild = [lookupName]; + + if(/::new$/.test(lookupName)){ + // 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 => { + matches.push({ + label: label, + textEdit: {newText: label, range}, + kind: kind, + sortText: getSortText(label), + detail: detail, + documentation: documentation, + }); + }); + + 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(/^_$/.test(label) || /(?:::|->)_\w+$/.test(label)){ + sortText = "3" + label; + } else if(/^\w$/.test(label) || /(?:::|->)\w+$/.test(label)){ + // Public methods / functions + sortText = "2"; + // Prioritize '->new' + if (/->new/.test(label)) { sortText += "1" } + sortText += label; + } else { + // Variables and regex mistakes + sortText = "1" + label; + } + return sortText; +}
\ No newline at end of file diff --git a/browser-ext/src/web-hover.ts b/browser-ext/src/web-hover.ts new file mode 100644 index 0000000..de4aac3 --- /dev/null +++ b/browser-ext/src/web-hover.ts @@ -0,0 +1,78 @@ +import { + TextDocumentPositionParams, + Hover, +} from 'vscode-languageserver/node'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { PerlDocument, PerlElem } from "./web-types"; +import { getSymbol, lookupSymbol } from "./web-utils"; + +export function getHover(params: TextDocumentPositionParams, perlDoc: PerlDocument, txtDoc: TextDocument): Hover | undefined { + + let position = params.position + const symbol = getSymbol(position, txtDoc); + + let elem = perlDoc.canonicalElems.get(symbol); + + if(!elem){ + const elems = lookupSymbol(perlDoc, symbol, position.line); + if(elems.length != 1) return; // Nothing or too many things. + elem = elems[0]; + } + + let hoverStr = buildHoverDoc(symbol, elem); + if(!hoverStr) return; // Sometimes, there's nothing worth showing. + + const documentation = {contents: hoverStr}; + + return documentation; +} + +function buildHoverDoc(symbol: string, elem: PerlElem){ + + let desc = ""; + if (elem.type.length > 1 || ( ["v", "c"].includes(elem.type) && /^\$self/.test(symbol))) { + // We either know the object type, or it's $self + desc = "(object) "; + if(elem.type.length > 1 ){ + desc += `${elem.type}`; + } else if (/^\$self/.test(symbol)) { + desc += `${elem.package}`; + } + } else if(elem.type == 'v'){ + // desc = `(variable) ${symbol}`; // Not very interesting info + } else if (elem.type == 'n'){ + desc = `(constant) ${symbol}`; + } else if(elem.type == 'c'){ + desc = `${elem.name}: ${elem.value}`; + if(elem.package) desc += ` (${elem.package})` ; // Is this ever known? + } else if(elem.type == 'h'){ + desc = `${elem.name} (${elem.package})`; + } else if (elem.type == 's'){ + desc = `(subroutine) ${symbol}`; + } else if (['o','x'].includes(elem.type)){ + desc = `(method) ${symbol}`; + } else if (['t','i'].includes(elem.type)){ // inherited methods can still be subs (e.g. new from a parent) + desc = `(subroutine) ${elem.name}`; + if(elem.typeDetail && elem.typeDetail != elem.name) desc = desc + ` (${elem.typeDetail})`; + }else if (elem.type == 'p'){ + desc = `(package) ${elem.name}`; + } else if (elem.type == 'm'){ + desc = `(module) ${elem.name}: ${elem.file}`; + } else if (elem.type == 'l'){ + desc = `(label) ${symbol}`; + } else if (elem.type == 'a'){ + desc = `(class) ${symbol}`; + } else if (elem.type == 'b'){ + desc = `(role) ${symbol}`; + } else if (elem.type == 'f' || elem.type == 'd'){ + desc = `(attribute) ${symbol}`; + } else if (elem.type == 'e'){ + desc = `(phase) ${symbol}`; + } else { + // We should never get here + desc = `Unknown: ${symbol}`; + } + + + return desc; +} diff --git a/browser-ext/src/web-navigation.ts b/browser-ext/src/web-navigation.ts new file mode 100644 index 0000000..1f27f44 --- /dev/null +++ b/browser-ext/src/web-navigation.ts @@ -0,0 +1,95 @@ +import { + DefinitionParams, + Location, + WorkspaceFolder +} from 'vscode-languageserver/browser'; +import { + TextDocument +} from 'vscode-languageserver-textdocument'; +import { PerlDocument, PerlElem, NavigatorSettings } from "./web-types"; +import { realpathSync, existsSync, realpath } from 'fs'; +import { getSymbol, lookupSymbol } from "./web-utils"; + + +export function getDefinition(params: DefinitionParams, perlDoc: PerlDocument, txtDoc: TextDocument): Location[] | undefined { + + let position = params.position + const symbol = getSymbol(position, txtDoc); + console.log("Trying to get defintion for symbol: " + symbol); + if(!symbol) return; + + const foundElems = lookupSymbol(perlDoc, symbol, position.line); + console.log("Found elements" + foundElems.length); + + if(foundElems.length == 0){ + return; + } + console.log("Still here"); + + let locationsFound: Location[] = []; + + foundElems.forEach(elem => { + const elemResolved: PerlElem | undefined = resolveElemForNav(perlDoc, elem, symbol); + if(!elemResolved) return; + + let uri: string; + if(perlDoc.uri !== elemResolved.file){ // TODO Compare URI instead + // If sending to a different file, let's make sure it exists and clean up the path + // if(!existsSync(elemResolved.file)) return; // Make sure the file exists and hasn't been deleted. + //uri = elemResolved.file + console.log("Different file"); + console.log(elemResolved.file); + console.log(perlDoc.filePath); + return; + } else { + // Sending to current file (including untitled files) + uri = perlDoc.uri; + } + + const newLoc: Location = { + uri: uri, + range: { + start: { line: elemResolved.line, character: 0 }, + end: { line: elemResolved.line, character: 500} + } + } + locationsFound.push(newLoc); + }); + return locationsFound; +} + + +function resolveElemForNav (perlDoc: PerlDocument, elem: PerlElem, symbol: string): PerlElem | undefined { + + if(elem.file && !badFile(elem.file)){ + // Have file and is good. + return elem; + } else{ + // Try looking it up by package instead of file. + // Happens with XS subs and Moo subs + if(elem.package){ + const elemResolved = perlDoc.elems.get(elem.package); + + if(elemResolved?.length && elemResolved[0].file && !badFile(elem.file)){ + return elemResolved[0]; + } + } + + // Finding the module with the stored mod didn't work. Let's try navigating to the package itself instead of Foo::Bar->method(). + // Many Moose methods end up here. + // Not very helpful, since the user can simply click on the module manually if they want + // const base_module = symbol.match(/^([\w:]+)->\w+$/); + // if(base_module){ + // const elemResolved = perlDoc.elems.get(base_module); + // if(elemResolved && elemResolved.file && !badFile(elem.file)){ + // return elemResolved; + // } + // } + } + return; +} + + +function badFile (file: string){ + return /(?:Sub[\\\/]Defer\.pm|Moo[\\\/]Object\.pm|Moose[\\\/]Object\.pm)$/.test(file); +} diff --git a/browser-ext/src/web-parse.ts b/browser-ext/src/web-parse.ts new file mode 100644 index 0000000..57842bd --- /dev/null +++ b/browser-ext/src/web-parse.ts @@ -0,0 +1,262 @@ + +import { PerlDocument, PerlElem, PerlImport, PerlSymbolKind} from "./web-types"; +import { TextDocument } from 'vscode-languageserver-textdocument'; + + +// Why is this async? It doesn't do anything async yet +export async function buildNav(textDocument: TextDocument): Promise<PerlDocument> { + + let perlDoc: PerlDocument = { + elems: new Map(), + canonicalElems: new Map(), + imported: new Map(), + parents: new Map(), + filePath: '', + uri: textDocument.uri, + }; + + buildPlTags(textDocument, perlDoc); + + return perlDoc; +} + + + +function MakeElem(name: string, type: PerlSymbolKind | 'u' | '1' | '2', typeDetail: string, file: string, pack:string, line:number, perlDoc: PerlDocument) : void{ + + if (type == '1'){ + // This object is only intended as the canonicalLookup, not for anything else. + return; + } + + if (type == 'u'){ + // Explictly loaded module. Helpful for focusing autocomplete results + perlDoc.imported.set(name, line); + // if(/\bDBI$/.exec(name)) perlDoc.imported.set(name + "::db", true); // TODO: Build mapping of common constructors to types + return; // Don't store it as an element + } + + if (type == '2'){ + perlDoc.parents.set(name, typeDetail); + return; // Don't store it as an element + } + + const newElem: PerlElem = { + name: name, + type: type, + typeDetail: typeDetail, + file: file, + package: pack, + line: line, + lineEnd: line, + value: "", + }; + + // Move fancy object types into the typeDetail field???? + if (type.length > 1){ + // We overwrite, so the last typed element is the canonical one. No reason for this. + perlDoc.canonicalElems.set(name, newElem); + } + + let array = perlDoc.elems.get(name) || []; + array.push(newElem) + perlDoc.elems.set(name, array); + + return; +} + + + +function buildPlTags(textDocument: TextDocument, perlDoc: PerlDocument) { + const codeArray = cleanCode(textDocument); + let sActiveOO: Map<string, boolean> = new Map(); // Keep track of OO frameworks in use to keep down false alarms on field vs has vs attr + // Loop through file + const file = textDocument.uri; + let package_name = ""; + let var_continues: boolean = false; + + for (let i = 0; i < codeArray.length; i++) { + let line_number = i; + let stmt = codeArray[i]; + // Nothing left? Never mind. + if (!stmt) { + continue; + } + + // TODO, allow specifying list of constructor names as config + // Declaring an object. Let's store the type + let match; + if ((match = stmt.match(/^(?:my|our|local|state)\s+(\$\w+)\s*\=\s*([\w\:]+)\-\>new\s*(?:\((?!.*\)\->)|;)/ )) || + (match = stmt.match(/^(?:my|our|local|state)\s+(\$\w+)\s*\=\s*new (\w[\w\:]+)\s*(?:\((?!.*\)\->)|;)/))) { + let varName = match[1]; + let objName = match[2]; + MakeElem(varName, PerlSymbolKind.LocalVar, objName, file, package_name, line_number, perlDoc); + + var_continues = false; // We skipped ahead of the line here. + } + // This is a variable declaration if one was started on the previous + // line, or if this line starts with my or local + else if (var_continues || (match = stmt.match(/^(?:my|our|local|state)\b/))) { + // The declaration continues if the line does not end with ; + var_continues = (!stmt.endsWith(";") && !stmt.match(/[\)\=\}\{]/)); + + // Remove my or local from statement, if present + stmt = stmt.replace(/^(my|our|local|state)\s+/, ""); + + // Remove any assignment piece + stmt = stmt.replace(/\s*=.*/, ""); + + // Remove part where sub starts (for signatures). Consider other options here. + stmt = stmt.replace(/\s*\}.*/, ""); + + // Now find all variable names, i.e. "words" preceded by $, @ or % + let vars = stmt.matchAll(/([\$\@\%][\w:]+)\b/g); + + for (let match of vars) { + MakeElem(match[1], PerlSymbolKind.LocalVar, '', file, package_name, line_number, perlDoc); + } + } + + // Lexical loop variables, potentially with labels in front. foreach my $foo + else if ((match = stmt.match(/^(?:(\w+)\s*:(?!\:))?\s*(?:for|foreach)\s+my\s+(\$[\w]+)\b/))) { + if (match[1]) { + MakeElem(match[1], PerlSymbolKind.Label, '', file, package_name, line_number, perlDoc); + } + MakeElem(match[1], PerlSymbolKind.LocalVar, '', file, package_name, line_number, perlDoc); + } + + // Lexical match variables if(my ($foo, $bar) ~= ). Optional to detect (my $newstring = $oldstring) =~ s/foo/bar/g; + else if ((match = stmt.match(/^(?:\}\s*elsif|if|unless|while|until|for)?\s*\(\s*my\b(.*)$/))) { + // Remove any assignment piece + stmt = stmt.replace(/\s*=.*/, ""); + let vars = stmt.matchAll(/([\$\@\%][\w]+)\b/g); + for (let match of vars) { + MakeElem(match[1], PerlSymbolKind.LocalVar, '', file, package_name, line_number, perlDoc); + } + } + + // This is a package declaration if the line starts with package + else if ((match = stmt.match(/^package\s+([\w:]+)/))) { + // Get name of the package + package_name = match[1]; + MakeElem(package_name, PerlSymbolKind.Package, '', file, package_name, line_number, perlDoc); + } + + // This is a class decoration for Object::Pad, Corinna, or Moops + else if((match = stmt.match(/^class\s+([\w:]+)/))){ + let class_name = match[1]; + MakeElem(class_name, PerlSymbolKind.Class, '', file, package_name, line_number, perlDoc); + } + + // This is a sub declaration if the line starts with sub + else if ((match = stmt.match(/^(?:async\s+)?(sub)\s+([\w:]+)(\s+:method)?([^{]*)/)) || + (match = stmt.match(/^(?:async\s+)?(method)\s+\$?([\w:]+)()([^{]*)/)) || + (sActiveOO.get("Function::Parameters") && (match = stmt.match(/^(fun)\s+([\w:]+)()([^{]*)/ ))) + ) { + const subName = match[2]; + const signature = match[4]; + const kind = (match[1] === 'method' || match[3]) ? PerlSymbolKind.LocalMethod : PerlSymbolKind.LocalSub; + MakeElem(subName, kind, '', file, package_name, line_number, perlDoc); + + // Match the after the sub declaration and before the start of the actual sub for signatures (if any) + const vars = signature.matchAll(/([\$\@\%][\w:]+)\b/g); + + // Define subrountine signatures, but exclude prototypes + // The declaration continues if the line does not end with ; + var_continues = !(stmt.match(/;$/) || stmt.match(/[\)\=\}\{]/)); + + for (const matchvar of vars) { + MakeElem(matchvar[1], PerlSymbolKind.LocalVar,'', file, package_name, line_number, perlDoc); + } + } + + // Phaser block + else if ((match = stmt.match(/^(BEGIN|INIT|CHECK|UNITCHECK|END)\s*\{/))) { + const phaser = match[1]; + MakeElem(phaser, PerlSymbolKind.Phaser, '', file, package_name, line_number, perlDoc); + } + + // Label line + else if ((match = stmt.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:[^:].*{\s*$/))) { + const label = match[1]; + MakeElem(label, PerlSymbolKind.Label, '', file, package_name, line_number, perlDoc); + } + + // Constants. Important because they look like subs (and technically are), so I'll tags them as such + else if ((match = stmt.match(/^use\s+constant\s+(\w+)\b/))) { + MakeElem(match[1], PerlSymbolKind.Constant, '', file, package_name, line_number, perlDoc); + MakeElem("constant", 'u', '', file, package_name, line_number, perlDoc); + } + + // Moo/Moose/Object::Pad/Moops/Corinna attributes + else if ((match = stmt.match(/^has(?:\s+|\()["']?([\$@%]?\w+)\b/))) { + const attr = match[1]; + let type; + if(attr.match(/^\w/)){ + type = PerlSymbolKind.Field; + // If you have a locally defined package/class Foo want to reference the attributes as Foo::attr or foo->attr, you need the full path. + // Subs don't need this since we find them at compile time. We also find "d" types from imported packages in Inquisitor.pm + MakeElem(package_name + "::" + attr, PerlSymbolKind.PathedField, '', file, package_name, line_number, perlDoc); + } else { + type = PerlSymbolKind.LocalVar; + } + // TODO: Define new type. Class variables should probably be shown in the Outline view even though lexical variables are not + MakeElem(attr, type, '', file, package_name, line_number, perlDoc); + } + + else if (sActiveOO.get("Object::Pad") && + (match = stmt.match(/^field\s+([\$@%]\w+)\b/))) { // Object::Pad field + const attr = match[1]; + MakeElem(attr, PerlSymbolKind.LocalVar, '', file, package_name, line_number, perlDoc); + } + + else if ((sActiveOO.get("Mars::Class") || sActiveOO.get("Venus::Class")) + && (match = stmt.match(/^attr\s+["'](\w+)\b/))) { // Mars attributes + const attr = match[1]; + MakeElem(attr, PerlSymbolKind.Field, '', file, package_name, line_number, perlDoc); + MakeElem(package_name + "::" + attr, PerlSymbolKind.PathedField, '', file, package_name, line_number, perlDoc); + } + + else if ((match = stmt.match(/^around\s+["']?(\w+)\b/))) { // Moo/Moose overriding subs. + MakeElem(match[1], PerlSymbolKind.LocalSub, '', file, package_name, line_number, perlDoc); + } + + else if ((match = stmt.match(/^use\s+([\w:]+)\b/))) { // Keep track of explicit imports for filtering + const importPkg = match[1]; + MakeElem(importPkg, "u", '', file, package_name, line_number, perlDoc); + sActiveOO.set(importPkg, true); + } + + } + +} + + +function cleanCode(textDocument: TextDocument): String[] { + const code = textDocument.getText(); + const codeArray = code.split("\n"); + const offset = textDocument.offsetAt(textDocument.positionAt(0)); + + + let line_number = -offset; + + let codeClean = []; + + for (let i=0; i<codeArray.length;i++){ + line_number++; + + let stmt = codeArray[i]; + + if (stmt.match(/^(__END__|__DATA__)\s*$/)) { + break; + } + + // Statement will be line with comments, whitespace and POD trimmed + stmt.replace(/^\s*#.*/, ""); + stmt.replace(/^\s*/, ""); + stmt.replace(/\s*$/, ""); + codeClean.push(stmt); + } + return codeClean; +} + diff --git a/browser-ext/src/web-symbols.ts b/browser-ext/src/web-symbols.ts new file mode 100644 index 0000000..0e89217 --- /dev/null +++ b/browser-ext/src/web-symbols.ts @@ -0,0 +1,81 @@ +import { + SymbolInformation, + SymbolKind, + Location, +} from 'vscode-languageserver/node'; +import { PerlDocument, PerlElem, PerlSymbolKind } from "./web-types"; + +function waitForDoc (navSymbols: any, uri: string): Promise<PerlDocument> { + let retries = 0; + + return new Promise((resolve, reject) => { + const interval = setInterval(() => { + + if (++retries > 100) { // Wait for 10 seconds looking for the document. + reject("Found no document"); + clearInterval(interval); + } + const perlDoc = navSymbols.get(uri); + + if (perlDoc) { + resolve(perlDoc); + clearInterval(interval); + }; + }, 100); + }); +} + +export function getSymbols (navSymbols: any, uri: string ): Promise<SymbolInformation[]> { + + return waitForDoc(navSymbols, uri).then((perlDoc) => { + let symbols: SymbolInformation[] = []; + perlDoc.elems?.forEach((elements: PerlElem[], elemName: string) => { + + elements.forEach(element => { + let kind: SymbolKind; + if (element.type == PerlSymbolKind.LocalSub){ + kind = SymbolKind.Function; + } else if (element.type == PerlSymbolKind.LocalMethod){ + kind = SymbolKind.Method; + } else if (element.type == PerlSymbolKind.Package){ + kind = SymbolKind.Package; + } else if (element.type == PerlSymbolKind.Class){ + kind = SymbolKind.Class; + } else if (element.type == PerlSymbolKind.Role){ + kind = SymbolKind.Interface; + } else if (element.type == PerlSymbolKind.Field){ + kind = SymbolKind.Field; + } else if (element.type == PerlSymbolKind.Label){ + kind = SymbolKind.Key; + } else if (element.type == PerlSymbolKind.Phaser){ + kind = SymbolKind.Event; + } else if (element.type == PerlSymbolKind.Constant){ + kind = SymbolKind.Constant; + } else { + return; + } + const location: Location = { + range: { + start: { line: element.line, character: 0 }, + end: { line: element.lineEnd, character: 100 } + }, + uri: uri + }; + const newSymbol: SymbolInformation = { + kind: kind, + location: location, + name: elemName + } + + symbols.push(newSymbol); + }); + }); + + return symbols; + }).catch((reason)=>{ + // TODO: Add logging back, but detect STDIO mode first + console.log("Failed in getSymbols"); + console.log(reason); + return []; + }); +} diff --git a/browser-ext/src/web-types.ts b/browser-ext/src/web-types.ts new file mode 100644 index 0000000..9515cf8 --- /dev/null +++ b/browser-ext/src/web-types.ts @@ -0,0 +1,96 @@ +// Settings for perlnavigator, +// defaults for configurable editors stored in package.json +// defaults for non-configurable editors in server.ts + +import { + Diagnostic, +} from 'vscode-languageserver/browser'; + + + +export interface NavigatorSettings { + perlPath: string; + enableWarnings: boolean; + perlcriticProfile: string; + perlcriticEnabled: boolean; + perlcriticSeverity: undefined | number; + perlcriticTheme: undefined | string; + perlcriticExclude: undefined | string; + perlcriticInclude: undefined | string; + perlimportsLintEnabled: boolean; + perlimportsTidyEnabled: boolean; + perlimportsProfile: string; + perltidyEnabled: boolean; + perltidyProfile: string; + severity5: string; + severity4: string; + severity3: string; + severity2: string; + severity1: string; + includePaths: string[]; + includeLib: boolean; + logging: boolean; + enableProgress: boolean; +} + + + +export interface PerlElem { + name: string, + type: PerlSymbolKind, + typeDetail: string, + file: string; + package: string; + line: number; + lineEnd: number; + value: string; +}; + +// Used for keeping track of what has been imported +export interface PerlImport { + mod: string; +}; + + +export interface PerlDocument { + elems: Map<string, PerlElem[]>; + canonicalElems: Map<string, PerlElem>; + imported: Map<string, number>; + parents: Map<string, string>; + filePath: string; + uri: string; +} + +export interface CompilationResults { + diags: Diagnostic[], + perlDoc: PerlDocument, +} + +export interface CompletionPrefix { + symbol: string, + charStart: number, + charEnd: number, +} + +export enum PerlSymbolKind { + Module = "m", + Package = "p", + Class = "a", + Role = "b", + ImportedSub = "t", + Inherited = "i", + Field = "f", // Instance fields + PathedField = "d", // Instance fields + LocalSub = "s", + LocalMethod = "o", // Assumed to be instance methods + Method = "x", // Assumed to be instance methods + LocalVar = "v", + Constant = "n", + Label = "l", + Phaser = "e", + Canonical = "1", + // UseStatement = "u", // Reserved: used in pltags, but removed before symbol assignment. + ImportedVar = "c", + ImportedHash = "h", +} + diff --git a/browser-ext/src/web-utils.ts b/browser-ext/src/web-utils.ts new file mode 100644 index 0000000..0ccad67 --- /dev/null +++ b/browser-ext/src/web-utils.ts @@ -0,0 +1,157 @@ +import { + WorkspaceFolder +} from 'vscode-languageserver-protocol'; + +import { + TextDocument, + Position +} from 'vscode-languageserver-textdocument'; +import { PerlDocument, PerlElem, NavigatorSettings, PerlSymbolKind } from "./web-types"; +import * as path from 'path'; + + + + +export function getSymbol(position: Position, txtDoc: TextDocument) { + // Gets symbol from text at position. + // Ignore :: going left, but stop at :: when going to the right. (e.g Foo::bar::baz should be clickable on each spot) + // Todo: Only allow -> once. + // Used for navigation and hover. + + 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 leftRg = /[\p{L}\p{N}_:>-]/u; + const rightRg = /[\p{L}\p{N}_]/u; + + const leftAllow = (c: string) => leftRg.exec(c); + const rightAllow = (c: string) => rightRg.exec(c); + + let left = index - 1; + let right = index; + + if(right < text.length && ( ["$", "%", "@"].includes(text[right]) || rightAllow(text[right])) ){ + // Handles an edge case where the cursor is on the side of a symbol. + // Note that $foo| should find $foo (where | represents cursor), but $foo|$bar should find $bar, and |mysub should find mysub + right += 1; + left += 1; + } + + while (left >= 0 && leftAllow(text[left])) { + // Allow for ->, but not => or > (e.g. $foo->bar, but not $foo=>bar or $foo>bar) + if (text[left] === ">" && left - 1 >= 0 && text[left - 1] !== "-") { break; } + left -= 1; + } + left = Math.max(0, left + 1); + while (right < text.length && rightAllow(text[right])) { + right += 1; + } + right = Math.max(left, right); + + let symbol = text.substring(left, right); + const lChar = left > 0 ? text[left-1] : ""; + const llChar = left > 1 ? text[left-2] : ""; + const rChar = right < text.length ? text[right] : ""; + + if(lChar === '$'){ + if(rChar === '[' && llChar != '$'){ + symbol = '@' + symbol; // $foo[1] -> @foo $$foo[1] -> $foo + } else if(rChar === '{' && llChar != '$'){ + symbol = '%' + symbol; // $foo{1} -> %foo $$foo{1} -> $foo + } else{ + symbol = '$' + symbol; // $foo $foo->[1] $foo->{1} -> $foo + } + }else if(['@', '%'].includes(lChar)){ + symbol = lChar + symbol; // @foo, %foo -> @foo, %foo + }else if(lChar === '{' && rChar === '}' && ["$", "%", "@"].includes(llChar)){ + symbol = llChar + symbol; // ${foo} -> $foo + } + + return symbol; +} + +function findRecent (found: PerlElem[], line: number){ + let best = found[0]; + for (var i = 0; i < found.length; i++){ + // Find the most recently declared variable. Modules and Packages are both declared at line 0, so Package is tiebreaker (better navigation; modules can be faked by Moose) + if( (found[i].line > best.line && found[i].line <= line) || (found[i].line == best.line && found[i].type == PerlSymbolKind.Package) ){ + best = found[i]; + } + }; + return best; +} + +export function lookupSymbol(perlDoc: PerlDocument, symbol: string, line: number): PerlElem[] { + + let found = perlDoc.elems.get(symbol); + if(found?.length){ + // Simple lookup worked. If we have multiple (e.g. 2 lexical variables), find the nearest earlier declaration. + const best = findRecent(found, line); + return [best]; + } + + + let qSymbol = symbol; + + let superClass = /^(\$\w+)\-\>SUPER\b/.exec(symbol); + if(superClass){ + // If looking up the superclass of $self->SUPER, we need to find the package in which $self is defined, and then find the parent + let child = perlDoc.elems.get(superClass[1]); + if(child?.length){ + const recentChild = findRecent(child, line); + if(recentChild.package){ + const parentVar = perlDoc.parents.get(recentChild.package); + if(parentVar){ + qSymbol = qSymbol.replace(/^\$\w+\-\>SUPER/, parentVar); + } + } + } + } + + let knownObject = /^(\$\w+)\->(?:\w+)$/.exec(symbol); + if(knownObject){ + const targetVar = perlDoc.canonicalElems.get(knownObject[1]); + if(targetVar) qSymbol = qSymbol.replace(/^\$\w+(?=\->)/, targetVar.type); + } + + // Add what we mean when someone wants ->new(). + let synonyms = ['_init', 'BUILD']; + for (const synonym of synonyms){ + found = perlDoc.elems.get(symbol.replace(/->new$/, "::" + synonym)); + if(found?.length) return [found[0]]; + } + found = perlDoc.elems.get(symbol.replace(/DBI->new$/, "DBI::connect")); + if(found?.length) return [found[0]]; + + + qSymbol = qSymbol.replace(/->/g, "::"); // Module->method() can be found via Module::method + found = perlDoc.elems.get(qSymbol); + if(found?.length) return [found[0]]; + + if(qSymbol.includes('::') && symbol.includes('->')){ + // Launching to the wrong explicitly stated module is a bad experience, and common with "require'd" modules + const method = qSymbol.split('::').pop(); + if(method){ + // Perhaps the method is within our current scope, or explictly imported. + found = perlDoc.elems.get(method); + if(found?.length) return [found[0]]; + // Haven't found the method yet, let's check if anything could be a possible match since you don't know the object type + let foundElems: PerlElem[] = []; + perlDoc.elems.forEach((elements: PerlElem[], elemName: string) => { + const element = elements[0]; // All Elements are with same name are normally the same. + const elemMethod = elemName.split('::').pop(); + if(elemMethod == method){ + foundElems.push(element); + } + }); + if(foundElems.length > 0) return foundElems; + } + } + + return []; +} + |