Repository navigation
Expand file tree
/
Copy pathsymbols.test.mjs
More file actions
333 lines (310 loc) · 16.9 KB
/
Copy pathsymbols.test.mjs
File metadata and controls
333 lines (310 loc) · 16.9 KB
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
// P5 in WIP.HelpAddin.md, measured rather than read off the IDE's code: what
// the compiler's language service says about the name under the cursor. The
// project in probes/symbols is opened, never built, and each question is put
// the way the IDE's own code puts it (main.js): hover and Go To
// Definition as its Monaco providers ask, over lspSocket, and signature help
// as the code editor's intellisense does (getLiveDebugIntellisenseMonaco).
// Only page script can ask any of them; the add-in API has no such call.
//
// Each test states what BETA 1010 does. If one fails after an IDE update, the
// IDE has changed: update P5 in WIP.HelpAddin.md, and then this file.
//
// Run it with addin-test.bat, which gives it a lane; on its own it is skipped.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { before, test } from "node:test";
import { fileURLToPath } from "node:url";
import { openFile } from "../../scripts/lib/tb-operate.mjs";
import { scenario } from "./scenario.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PROJECT = path.join(HERE, "probes", "symbols");
const FILE = "/SymbolsProbe/Sources/Symbols.twin";
const SOURCE = readFileSync(path.join(PROJECT, "Sources", "Symbols.twin"), "utf8").split(/\r?\n/);
// Where a name is, as LSP counts, 0-based: the first whole `word` from where
// `text` starts, on the first line holding `text`; its middle character.
function at(text, word) {
const line = SOURCE.findIndex((s) => s.includes(text));
assert.ok(line >= 0, `no line of Symbols.twin holds ${JSON.stringify(text)}`);
const re = new RegExp(`\\b${word}\\b`, "g");
re.lastIndex = SOURCE[line].indexOf(text);
const i = re.exec(SOURCE[line])?.index;
assert.ok(i !== undefined, `no ${word} in ${JSON.stringify(text)}`);
return { line, character: i + Math.floor(word.length / 2) };
}
// One request over the compiler's language socket, and its answer's result.
async function lsp(c, method, params) {
const m = await c.evaluate(
`new Promise((resolve) => {
const timer = setTimeout(() => resolve({ timedOut: true }), 10000);
lspSocket.request(${JSON.stringify(method)}, ${JSON.stringify(params)}, (m) => { clearTimeout(timer); resolve(m); });
})`,
{ awaitPromise: true, timeout: 15000 },
);
assert.ok(!m.timedOut, `${method} had no answer`);
return m.result;
}
const doc = { uri: `twinbasic:${FILE}` };
const hover = async (c, text, word) =>
(await lsp(c, "textDocument/hover", { textDocument: doc, position: at(text, word) }))?.contents.value ?? null;
const definition = (c, text, word) =>
lsp(c, "textDocument/definition", { textDocument: doc, position: at(text, word) });
// P15: the names of Described.twin, asked about without opening it, since hover
// takes any file of the project.
const DESCRIBED = readFileSync(path.join(PROJECT, "Sources", "Described.twin"), "utf8").split(/\r?\n/);
function atDescribed(text, word) {
const line = DESCRIBED.findIndex((s) => s.includes(text));
assert.ok(line >= 0, `no line of Described.twin holds ${JSON.stringify(text)}`);
const re = new RegExp(`\\b${word}\\b`, "g");
re.lastIndex = DESCRIBED[line].indexOf(text);
const i = re.exec(DESCRIBED[line])?.index;
assert.ok(i !== undefined, `no ${word} in ${JSON.stringify(text)}`);
return { line, character: i + Math.floor(word.length / 2) };
}
const hoverDescribed = async (c, text, word) =>
(
await lsp(c, "textDocument/hover", {
textDocument: { uri: "twinbasic:/SymbolsProbe/Sources/Described.twin" },
position: atDescribed(text, word),
})
)?.contents.value ?? null;
// The heading a procedure's documentation starts with names where it is
// declared: "## **MsgBox** ... `in VBA.Interaction`".
const declaredIn = (markdown) => /^## \*\*\w+\$?\*\*[^`\r\n]*`in ([\w.]+)`/m.exec(markdown ?? "")?.[1] ?? null;
const firstLine = (markdown) => (markdown ?? "").split(/\r?\n/)[0];
// The first "`in ...`" anywhere, whatever comes before it.
const whereIn = (markdown) => /`in ([^`\r\n]+)`/.exec(markdown ?? "")?.[1] ?? null;
// Signature help for the call whose argument list starts just after `after`
// on the line holding `text`: the request the code editor makes when the
// cursor is there, the character before it being the trigger.
async function signatures(c, text, after) {
const line = SOURCE.findIndex((s) => s.includes(text));
const character = SOURCE[line].indexOf(after) + after.length; // the cursor, 0-based
const r = await c.evaluate(
`new Promise((resolve) => {
const node = openEditors.selectedEditorNode.fileNode;
const timer = setTimeout(() => resolve(null), 10000);
lspSocket.request("textDocument/completion", {
textDocument: { uri: node.getFullPath(), version: node.ideVersionId },
position: { line: ${line}, character: ${character} },
context: { triggerCharacter: ${JSON.stringify(after.at(-1))} },
origCaretLine: ${line + 1}, origCaretColumn: ${character + 1},
}, (m) => { clearTimeout(timer); resolve(m.result); });
})`,
{ awaitPromise: true, timeout: 15000 },
);
assert.ok(r, `no completion answer after ${JSON.stringify(after)}`);
return r.signatures ?? [];
}
// A package's own source, as the IDE's file system names it. The packages a
// project references are under <Project>/Packages, and each package's own
// references under its Packages again, so VBA is in the tree twice here: as
// the project's, and as tbIDE's. Definition named tbIDE's copy.
const packageFile = (pkg, file) =>
new RegExp(`^twinbasic:/SymbolsProbe/Packages/(?:[^/]+/Packages/)*${pkg}/Sources/${file}$`);
scenario("P5: what the compiler says about the name under the cursor", (lane) => {
let c;
before(async () => {
c = await lane.open(PROJECT);
await openFile(c, FILE, { line: 1, column: 1 });
});
test("hover on a procedure names its kind, and the package and module or interface it is declared in", async () => {
const cases = [
['MsgBox "hello"', "MsgBox", "Function MsgBox (", "VBA.Interaction"],
["Return CStr(k)", "CStr", "Function CStr (", "VBA.Conversion"],
["c.Add", "Add", "Sub Add (", "VBA._Collection"],
["Host.ToolWindows", "ToolWindows", "Property Get ToolWindows (", "tbIDE.IHostV1"],
["ToolWindows.Add", "Add", "Function Add (", "tbIDE.IToolWindowsV1"],
["Debug.Print FindTheNeedle", "FindTheNeedle", "Function FindTheNeedle (", "SymbolsProbe.Symbols"],
];
for (const [text, word, kind, where] of cases) {
const h = await hover(c, text, word);
assert.ok(firstLine(h).startsWith(kind), `${word} in ${text}: ${JSON.stringify(h)}`);
assert.equal(declaredIn(h), where, `${word} in ${text}: ${JSON.stringify(h)}`);
}
});
test("hover on a class names its package and its default interface, which is what its members are named by", async () => {
const collection = await hover(c, "New Collection", "Collection");
assert.match(collection, /^\*class\* \*\*Collection\*\* [^\r\n]*`in package VBA`/);
assert.match(collection, /\*\[default\]\* VBA\._Collection\b/);
const toolWindow = await hover(c, "As ToolWindow", "ToolWindow");
assert.match(toolWindow, /^\*class\* \*\*ToolWindow\*\* [^\r\n]*`in package tbIDE`/);
assert.match(toolWindow, /\*\[default\]\* tbIDE\.IToolWindowV1\b/);
});
test("hover on an enumeration, its value and a constant names where each is declared in forms of their own", async () => {
// The add-in's Declared.ReadHover reads these forms.
const cases = [
["Debug.Print vbOKOnly", "vbOKOnly", "*enum-value* **vbOKOnly** ", "VBA.Constants.VbMsgBoxStyle"],
["Dim t As VbMsgBoxStyle", "VbMsgBoxStyle", "*enum* **VbMsgBoxStyle** ", "component VBA.Constants"],
["vbCrLf, Err", "vbCrLf", "*constant* **vbCrLf As String** ", "VBA.Constants"],
];
for (const [text, word, start, where] of cases) {
const h = await hover(c, text, word);
assert.ok(firstLine(h).startsWith(start), `${word}: ${JSON.stringify(h)}`);
assert.equal(whereIn(h), where, `${word}: ${JSON.stringify(h)}`);
}
});
test("hover can name the procedure behind a member", async () => {
// Err.Number is VBA's hidden GetErrNumber (and, in the help lane's host,
// App.Path VB's GetAppPath); a PropertyBag's ReadProperty is on the
// class's default interface, VBRUN._PropertyBag. A member inside With
// names its interface as c2.Add would.
const cases = [
["Err.Number", "Number", "Function GetErrNumber (", "VBA._HiddenModule"],
["pb.ReadProperty", "ReadProperty", "Function ReadProperty (", "VBRUN._PropertyBag"],
[".Add 2", "Add", "Sub Add (", "VBA._Collection"],
['Left$("abc"', "Left", "Function Left$ (", "VBA.Strings"],
];
for (const [text, word, kind, where] of cases) {
const h = await hover(c, text, word);
assert.ok(firstLine(h).startsWith(kind), `${word} in ${text}: ${JSON.stringify(h)}`);
assert.equal(declaredIn(h), where, `${word} in ${text}: ${JSON.stringify(h)}`);
}
});
test("hover on a variable gives its declaration, and on Debug.Print and a statement nothing", async () => {
assert.equal(await hover(c, "c.Add", "c"), "*local variable* Dim c As Collection");
assert.equal(await hover(c, "Nothing, count", "count"), "*parameter* ByVal count As Long");
// The hover here is empty.
const nothing = [
["Debug.Print FindTheNeedle", "Debug"],
["Debug.Print FindTheNeedle", "Print"],
["Dim c As New", "Dim"],
];
for (const [text, word] of nothing) {
const h = await hover(c, text, word);
assert.ok(h === null || h === "", `${word} in ${text}: ${JSON.stringify(h)}`);
}
assert.match(await hover(c, "ByVal n As Long", "Long"), /^\*\*Long\*\*/);
});
test("hover on a declared parameter or variable says nothing about Option Explicit", async () => {
// The project has project.optionExplicit true. The hover on a ByVal
// parameter of String, Variant, Object or a class does not mention it.
const byVal = {
"Debug.Print h Is": "h",
"count, col Is": "col",
"Nothing, o Is": "o",
"IsEmpty(v)": "v",
"Nothing, s,": "s",
"Host.ToolWindows": "Host",
};
for (const [text, word] of Object.entries(byVal)) {
const h = await hover(c, text, word);
assert.match(h, /^\*parameter\* ByVal /, `${word}: ${JSON.stringify(h)}`);
assert.ok(!h.includes("Option Explicit"), `${word}: ${JSON.stringify(h)}`);
}
const plain = {
"Nothing, count": "count",
"Debug.Print n": "n",
"Return CStr(k)": "k",
"(v), r Is": "r",
"s, d Is": "d",
};
for (const [text, word] of Object.entries(plain)) {
const h = await hover(c, text, word);
assert.ok(!h.includes("Option Explicit"), `${word}: ${JSON.stringify(h)}`);
}
});
test("Go To Definition of a package's symbol is its declaration in the package's own source, which the IDE opens", async () => {
const cases = [
['MsgBox "hello"', "MsgBox", packageFile("VBA", "Interaction\\.twin")],
["New Collection", "Collection", packageFile("VBA", "Collection\\.twin")],
["c.Add", "Add", packageFile("VBA", "Collection\\.twin")],
["Host.ToolWindows", "ToolWindows", packageFile("tbIDE", "Host\\.twin")],
["ToolWindows.Add", "Add", packageFile("tbIDE", "ToolWindows\\.twin")],
];
for (const [text, word, file] of cases) {
const d = await definition(c, text, word);
assert.match(d?.uri ?? "", file, `${word} in ${text}: ${JSON.stringify(d)}`);
assert.equal(await c.evaluate(`!!fs.tree.resolvePath(${JSON.stringify(d.uri)})`), true, `${d.uri} does not open`);
}
const own = await definition(c, "Debug.Print FindTheNeedle", "FindTheNeedle");
assert.equal(own.uri, `twinbasic:${FILE}`);
assert.equal(
own.range.start.line,
SOURCE.findIndex((s) => s.includes("Function FindTheNeedle")),
);
assert.equal(await definition(c, "Debug.Print FindTheNeedle", "Print"), null);
});
test("signature help's documentation starts with the same heading", async () => {
const cases = [
['MsgBox "hello"', "MsgBox ", "Function MsgBox(", "VBA.Interaction"],
["c.Add 1", "c.Add ", "Sub Add(", "VBA._Collection"],
["ToolWindows.Add(", "ToolWindows.Add(", "Function Add(", "tbIDE.IToolWindowsV1"],
["FindTheNeedle(3)", "FindTheNeedle(", "Function FindTheNeedle(", "SymbolsProbe.Symbols"],
];
for (const [text, after, label, where] of cases) {
const [s] = await signatures(c, text, after);
assert.ok(s?.label.startsWith(label), `after ${JSON.stringify(after)}: ${JSON.stringify(s)}`);
assert.equal(declaredIn(s.doc), where, `after ${JSON.stringify(after)}: ${JSON.stringify(s.doc)}`);
}
});
test("a completion's details give the file and line of its declaration, as Go To Definition does", async () => {
const line = SOURCE.findIndex((s) => s.includes("c.Add 1"));
const character = SOURCE[line].indexOf("c.") + 2;
const r = await c.evaluate(
`new Promise((resolve) => {
const node = openEditors.selectedEditorNode.fileNode;
lspSocket.request("textDocument/completion", {
textDocument: { uri: node.getFullPath(), version: node.ideVersionId },
position: { line: ${line}, character: ${character} }, context: { triggerCharacter: "." },
origCaretLine: ${line + 1}, origCaretColumn: ${character + 1},
}, (m) => {
const item = (m.result?.items ?? []).find((x) => x.l === "Add");
if (!item) return resolve(null);
lspSocket.request("textDocument/lazyCompletion", { lazyRequestId: m.result.lazyRequestId,
requests: [{ x: 0, i: item.i }] }, (z) => resolve(z.results?.[0] ?? null));
});
})`,
{ awaitPromise: true, timeout: 15000 },
);
assert.ok(r, "no Add among the completions after c.");
const d = await definition(c, "c.Add", "Add");
// The same file as the definition's, without "twinbasic:", on the same line.
assert.equal(`twinbasic:${r.uri}`, d.uri);
assert.equal(r.line, d.range.start.line);
});
// P15. The help add-in shows this text for a name the documentation has no
// page for (ReadSummary in add-in/Sources/Declared.twin).
test("hover gives a name's [Description] after where it is declared, whatever kind of name it is", async () => {
const cases = [
["Debug.Print AddTwo", "AddTwo", "SymbolsProbe.Described", "Adds two numbers and returns the sum."],
["AddTwo(1, 2), WidgetCount", "WidgetCount", "SymbolsProbe.Described", "The number of widgets."],
["WidgetCount, Counter", "Counter", "SymbolsProbe.Described", "The module's counter."],
["Debug.Print Shade.Light", "Shade", "component SymbolsProbe.Described", "A colour."],
["Dim p As Point", "Point", "component SymbolsProbe.Described", "A point."],
["Dim w As New Widget", "Widget", "library SymbolsProbe", "A widget class."],
["w.Resize", "Resize", "SymbolsProbe._Widget", "Changes the widget's size."],
["Module Described", "Described", "library SymbolsProbe", "A module that holds the probe's procedures."],
];
for (const [text, word, where, description] of cases) {
const h = await hoverDescribed(c, text, word);
assert.equal(whereIn(h), where, `${word} in ${text}: ${JSON.stringify(h)}`);
const after = h.slice(h.indexOf(`\`in ${where}\``));
assert.ok(after.includes(description), `${word} in ${text}: ${JSON.stringify(h)}`);
}
});
test("hover gives a type library's help string as a [Description]", async () => {
const h = await hoverDescribed(c, "d.Add", "Add");
assert.equal(declaredIn(h), "Scripting.IDictionary", JSON.stringify(h));
assert.match(h, /`in Scripting\.IDictionary`\s+Add a new key and item to the dictionary\.$/, JSON.stringify(h));
});
test("hover on a procedure with no [Description] gives the IDE's tip in its place", async () => {
const h = await hoverDescribed(c, "Counter, NoDescription", "NoDescription");
assert.equal(declaredIn(h), "SymbolsProbe.Described", JSON.stringify(h));
assert.match(h, /\n\*no further info available\. .*\[Description\(""\)\].*\*$/, JSON.stringify(h));
});
test("hover on a procedure's own name where it is declared gives code-generation details instead", async () => {
const h = await hoverDescribed(c, "Public Function AddTwo", "AddTwo");
assert.match(h, /TB-DEBUG CODEGEN SIZE/, JSON.stringify(h));
assert.equal(whereIn(h), null, JSON.stringify(h));
});
// Not a defect (twinbasic/twinbasic#2465): inside an Enum, a [Description(...)]
// line is a member named by a bracketed identifier, as VB6 allows, not an attribute.
test("a [Description(...)] line in an enumeration is a member of its own, and the next member has no description", async () => {
const shade = await hoverDescribed(c, "Debug.Print Shade.Light", "Shade");
assert.match(shade, /\n - Description\("The light one\."\)/, JSON.stringify(shade));
const light = await hoverDescribed(c, "Shade.Light, p.X", "Light");
assert.equal(whereIn(light), "SymbolsProbe.Described.Shade", JSON.stringify(light));
assert.ok(!light.includes("The light one."), JSON.stringify(light));
});
});