summaryrefslogtreecommitdiff
path: root/server/src/completion.ts
blob: 2ec8c67ebb8f295e923636864bd3e6272a977b11 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
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;
}