turbo-editors/turbo-golopublic Fork 0
v1.0.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-golo.git
git clone ssh://git@rickub.com/turbo-editors/turbo-golo.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

editor_test.go · 691 lines · 23.5 KBGo Blame HistoryRaw
📦 Turbo Golo d710c1b k33g 15h ago1package gololang_test
2
3import (
4 "context"
5 "errors"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "slices"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/gdamore/tcell/v2"
15
16 "rickub.com/turbo-editors/turbo-core/app"
17 "rickub.com/turbo-editors/turbo-core/buffer"
18 "rickub.com/turbo-editors/turbo-core/lsp"
19 "rickub.com/turbo-editors/turbo-core/syntax"
20 "rickub.com/turbo-editors/turbo-core/ui"
21
22 "rickub.com/turbo-editors/turbo-golo/internal/gololang"
23)
24
25// --- the editor, assembled --------------------------------------------------
26
27func TestTheEditorCallsItselfTurboGolo(t *testing.T) {
28 editor := newTestEditor(t)
29
30 if got := editor.Profile().Name; got != gololang.Name {
31 t.Errorf("Profile().Name = %q, want %q", got, gololang.Name)
32 }
33 if got := editor.Profile().ProjectDir(); got != ".turbo-golo" {
34 t.Errorf("ProjectDir() = %q, want %q", got, ".turbo-golo")
35 }
36}
37
38func TestTheEditorColoursGoloSourceItOpens(t *testing.T) {
39 // The whole path in one test: Register taught the library about Golo, the
40 // profile named the editor, and a .golo file opened through the public
41 // API comes out coloured.
42 root := t.TempDir()
43 path := filepath.Join(root, "main.golo")
44 writeFile(t, path, "module demo.Main\n\nfunction main = |args| {\n println(\"hi\")\n}\n")
45
46 editor := newTestEditor(t)
47 editor.Open(path)
48
49 if got := editor.ActiveView().Language(); got != gololang.Language {
50 t.Fatalf("the view colours the file as %q, want %q", got, gololang.Language)
51 }
52 if spans := syntax.Highlight(gololang.Language, "function main = |args| {"); len(spans[0]) == 0 {
53 t.Error("the registered Golo scanner colours nothing")
54 }
55}
56
57func TestAScriptWithAShebangAndNoExtensionIsGoloToo(t *testing.T) {
58 // A script run as a command has no extension; its first line is what
59 // identifies it, and the editor reads that line before choosing a scanner.
60 root := t.TempDir()
61 path := filepath.Join(root, "greet")
62 writeFile(t, path, "#!/usr/bin/env golo\nmodule Greet\n\nfunction main = |args| {\n println(\"hi\")\n}\n")
63
64 editor := newTestEditor(t)
65 editor.Open(path)
66
67 if got := editor.ActiveView().Language(); got != gololang.Language {
68 t.Errorf("a script opening with a golo shebang is coloured as %q, want %q", got, gololang.Language)
69 }
70}
71
72func TestTheEditorDoesNotColourMoonBit(t *testing.T) {
73 // "Golo instead of MoonBit" is the whole point of this editor being a
74 // separate one: a .mbt file opens as plain text here.
75 root := t.TempDir()
76 path := filepath.Join(root, "main.mbt")
77 writeFile(t, path, "fn main {\n println(\"hi\")\n}\n")
78
79 editor := newTestEditor(t)
80 editor.Open(path)
81
82 if got := editor.ActiveView().Language(); got != syntax.LanguageNone {
83 t.Errorf("a .mbt file is coloured as %q; Turbo Golo registers Golo, not MoonBit", got)
84 }
85}
86
87func TestAProjectsOwnFilesAreStillColouredByTheLibrary(t *testing.T) {
88 // A README, a compose file and a Dockerfile are what a Golo project is
89 // made of besides its scripts, and turbo-core colours all three without
90 // this editor doing anything. That the inherited languages survive
91 // registration is worth one test, because syntax.Register writes into
92 // package-level state.
93 root := t.TempDir()
94 editor := newTestEditor(t)
95
96 for name, want := range map[string]syntax.Language{
97 "README.md": syntax.LanguageMarkdown,
98 "compose.yaml": syntax.LanguageYAML,
99 "Dockerfile": syntax.LanguageDockerfile,
100 } {
101 path := filepath.Join(root, name)
102 writeFile(t, path, "# heading\n")
103 editor.Open(path)
104
105 if got := editor.ActiveView().Language(); got != want {
106 t.Errorf("%s is coloured as %q, want %q", name, got, want)
107 }
108 }
109}
110
111func TestTheToolchainMenuIsCalledGoloAndNoTwoMenusShareAHotKey(t *testing.T) {
112 // The bar answers the first menu whose hot key matches, so a clash makes
113 // one of the two unreachable from the keyboard — silently, and with every
114 // other test still passing. Golo takes G because none of the fixed menus
115 // does, which is exactly the sort of thing only this test notices.
116 editor := newTestEditor(t)
117
118 seen := map[rune]string{}
119 found := false
120 for _, menu := range editor.MenuBar().Menus() {
121 label, hot, _ := ui.SplitHotKey(menu.Label)
122 if label == "Golo" {
123 found = true
124 }
125 if hot == 0 {
126 t.Errorf("the %q menu has no hot key", label)
127 continue
128 }
129 if other, clash := seen[hot]; clash {
130 t.Errorf("%q and %q both answer to Alt-%c", other, label, hot)
131 }
132 seen[hot] = label
133 }
134 if !found {
135 t.Error("there is no Golo menu on the bar")
136 }
137}
138
139// --- driven against a real golo lsp -----------------------------------------
140
141// TestCompletionEndToEndWithRealGoloLSP drives the exact sequence the command
142// does at start-up: open the file first, start the language server second,
143// then ask for a completion.
144//
145// That order is the whole point, and it is the one Turbo Go got wrong once: an
146// editor that announces its open documents to a server which does not exist yet
147// and never mentions them again gets answers about a file the server has never
148// heard of — which looks, from the outside, exactly like completion not
149// working.
150//
151// It skips itself when golo is not installed, and under -short.
152func TestCompletionEndToEndWithRealGoloLSP(t *testing.T) {
153 root, editor := startRealServer(t)
154 path := filepath.Join(root, "main.golo")
155
156 // Both lines on disk are blank. A function is *declared* by typing, at top
157 // level, then its name is typed inside main, so the answer can only come from
158 // what the editor told the server — which is the whole point of this
159 // test. golo lsp offers keywords and builtins for any file at all, so a
160 // completion holding println would prove nothing; a completion holding a
161 // function that exists only in the buffer proves the buffer was sent.
162 view := editor.ActiveView()
163 view.Buffer().SetCursor(buffer.Position{Line: declarationLine, Col: 0})
164 typeText(editor, "function zorglub = |x| -> x + 1")
165 view.Buffer().SetCursor(buffer.Position{Line: completionLine, Col: 2})
166 typeText(editor, "zorg")
167
168 if !waitForCompletion(t, editor) {
169 t.Fatalf("no completion list opened for %s; the status bar says %q", path, editor.StatusBar().Message())
170 }
171 if !completionOffers(editor, "zorglub") {
172 t.Errorf("the list does not offer the function typed into the buffer; it has %d entries", editor.Completion().Count())
173 }
174}
175
176// The scanner's builtin table is read out of GoloScript rather than remembered,
177// and this is where that claim is checked against the binary itself: the
178// completion golo lsp offers for an empty prefix lists every keyword and every
179// builtin it knows, so the two tables can be compared in both directions.
180func TestTheScannersTablesMatchWhatTheServerOffers(t *testing.T) {
181 root, editor := startRealServer(t)
182 path := filepath.Join(root, "main.golo")
183
184 var offered []lsp.CompletionItem
185 waitUntil(t, 30*time.Second, func() bool {
186 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
187 defer cancel()
188 items, err := editor.Language().Complete(ctx, path, completionLine, 2, " ")
189 if err != nil {
190 return false
191 }
192 offered = items
193 return len(offered) > 0
194 })
195
196 labels := map[string]bool{}
197 for _, item := range offered {
198 labels[item.Label] = true
199 }
200
201 for _, builtin := range gololang.Builtins() {
202 if !labels[builtin] {
203 t.Errorf("the scanner colours %q as a builtin, and golo lsp does not offer it", builtin)
204 }
205 }
206 for _, keyword := range gololang.Keywords() {
207 if !labels[keyword] {
208 t.Errorf("the scanner colours %q as a keyword, and golo lsp does not offer it", keyword)
209 }
210 }
211
212 // The other direction: everything the server offers that is not a keyword,
213 // a literal or a function the fixture declares must be in the builtin
214 // table, or the table has fallen behind the interpreter.
215 known := map[string]bool{"true": true, "false": true, "null": true}
216 for _, word := range append(gololang.Keywords(), gololang.Builtins()...) {
217 known[word] = true
218 }
219 for _, declared := range []string{"helper", "first", "second", "main"} {
220 known[declared] = true
221 }
222 for label := range labels {
223 if !known[label] {
224 t.Errorf("golo lsp offers %q, which the scanner knows nothing about", label)
225 }
226 }
227}
228
229func TestGoToDefinitionWithRealGoloLSP(t *testing.T) {
230 root, editor := startRealServer(t)
231 path := filepath.Join(root, "main.golo")
232
233 locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) {
234 return editor.Language().Definition(ctx, path, callLine, callColumn, callLineText)
235 })
236
237 if len(locations) != 1 {
238 t.Fatalf("the call to helper has %d definitions, want exactly 1: %v", len(locations), locations)
239 }
240 if got := locations[0].Range.Start.Line; got != helperLine {
241 t.Errorf("the definition of helper is on line %d, want %d", got, helperLine)
242 }
243}
244
245func TestHoverShowsTheCommentAboveADeclarationWithRealGoloLSP(t *testing.T) {
246 // A block of # comments above a declaration is its documentation, and the
247 // server shows it on hover. This is what F1 — Code ▸ Describe symbol —
248 // draws, and it is the one answer here that carries prose a person wrote.
249 root, editor := startRealServer(t)
250 path := filepath.Join(root, "main.golo")
251
252 var text string
253 waitUntil(t, 30*time.Second, func() bool {
254 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
255 defer cancel()
256 answer, err := editor.Language().Hover(ctx, path, callLine, callColumn, callLineText)
257 if err != nil {
258 return false
259 }
260 text = answer
261 return text != ""
262 })
263
264 if !strings.Contains(text, "Adds one") {
265 t.Errorf("hovering helper gave %q, want the comment written above its declaration", text)
266 }
267}
268
269func TestTheSymbolsOfAFileWithRealGoloLSP(t *testing.T) {
270 root, editor := startRealServer(t)
271 path := filepath.Join(root, "main.golo")
272
273 var symbols []lsp.Symbol
274 waitUntil(t, 30*time.Second, func() bool {
275 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
276 defer cancel()
277 found, err := editor.Language().DocumentSymbols(ctx, path)
278 if err != nil {
279 return false
280 }
281 symbols = found
282 return len(symbols) > 0
283 })
284
285 names := map[string]bool{}
286 for _, symbol := range symbols {
287 names[symbol.Name] = true
288 }
289 for _, want := range []string{"helper", "first", "second", "main"} {
290 if !names[want] {
291 t.Errorf("the file's symbols do not include %q: %v", want, names)
292 }
293 }
294}
295
296// Diagnostics are the one thing a language server sends without being asked,
297// and the only feature whose failure looks exactly like success: an editor with
298// no error to show and one that cannot find the error are the same blank
299// gutter. So this opens a file that does not parse and waits for the mark.
300func TestDiagnosticsForAFileThatDoesNotParseWithRealGoloLSP(t *testing.T) {
301 root, editor := startRealServerOn(t, brokenScript)
302 path := filepath.Join(root, "main.golo")
303
304 waitUntil(t, 30*time.Second, func() bool {
305 editor.Tick()
306 return len(editor.Language().Diagnostics(path)) > 0
307 })
308
309 problems := editor.Language().Diagnostics(path)
310 if len(problems) == 0 {
311 t.Fatalf("no diagnostic ever arrived for %s; the status bar says %q", path, editor.StatusBar().Message())
312 }
313 if _, ok := editor.Language().FirstError(path); !ok {
314 t.Errorf("the diagnostics hold no error, only %v", problems)
315 }
316}
317
318// A C-style comment is the mistake everybody coming from another language
319// makes, and golo lsp lints it rather than merely failing to parse it. It is
320// the diagnostic a new Golo programmer meets first, so it is the one checked
321// by name.
322func TestACStyleCommentIsDiagnosedWithRealGoloLSP(t *testing.T) {
323 root, editor := startRealServerOn(t, "module demo.Lint\n\n// not a Golo comment\nfunction main = |args| {\n println(\"hi\")\n}\n")
324 path := filepath.Join(root, "main.golo")
325
326 waitUntil(t, 30*time.Second, func() bool {
327 editor.Tick()
328 return len(editor.Language().Diagnostics(path)) > 0
329 })
330
331 var messages []string
332 for _, problem := range editor.Language().Diagnostics(path) {
333 messages = append(messages, problem.Message)
334 }
335 if !slices.ContainsFunc(messages, func(m string) bool { return strings.Contains(m, "#") }) {
336 t.Errorf("the C-style comment was not diagnosed as one; the server said %v", messages)
337 }
338}
339
340// golo lsp advertises neither referencesProvider, typeDefinitionProvider,
341// implementationProvider nor workspaceSymbolProvider, so four of the nine
342// questions turbo-core asks come back empty. That is documented in
343// how-to/enable-completion.md, and this test is what keeps the documentation
344// honest: if a future golo answers any of them, this fails and the page gets
345// revisited.
346func TestFindReferencesWithRealGoloLSP(t *testing.T) {
347 // GoloScript v0.2.0 started answering references. Asked from a call, the
348 // answer is the declaration and every call within the file; a use in
349 // another file of the same project is not found, because the server
350 // resolves nothing across files.
351 root, editor := startRealServer(t)
352 path := filepath.Join(root, "main.golo")
353
354 locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) {
355 return editor.Language().References(ctx, path, callLine, callColumn, callLineText)
356 })
357
358 lines := map[int]bool{}
359 for _, location := range locations {
360 if !strings.HasSuffix(location.URI, "/main.golo") {
361 t.Errorf("a reference points outside the file: %v", location)
362 }
363 lines[location.Range.Start.Line] = true
364 }
365 for _, want := range []int{helperLine, callLine, secondCallLine} {
366 if !lines[want] {
367 t.Errorf("the references to helper miss line %d: %v", want, locations)
368 }
369 }
370 if len(locations) != 3 {
371 t.Errorf("helper has %d references, want 3 (the declaration and two calls): %v", len(locations), locations)
372 }
373}
374
375func TestFindImplementationsWithRealGoloLSP(t *testing.T) {
376 // GoloScript v0.2.0 started answering implementations, with the function's
377 // declaration: Golo has no interfaces, so a function is its own
378 // implementation, and the answer is the same place F12 goes.
379 root, editor := startRealServer(t)
380 path := filepath.Join(root, "main.golo")
381
382 locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) {
383 return editor.Language().Implementation(ctx, path, callLine, callColumn, callLineText)
384 })
385
386 if len(locations) != 1 {
387 t.Fatalf("the call to helper has %d implementations, want exactly 1: %v", len(locations), locations)
388 }
389 if got := locations[0].Range.Start.Line; got != helperLine {
390 t.Errorf("the implementation of helper is on line %d, want its declaration on %d", got, helperLine)
391 }
392}
393
394func TestSymbolsAcrossTheProjectWithRealGoloLSP(t *testing.T) {
395 // GoloScript v0.2.0 started answering workspace/symbol. It searches every
396 // .golo file under the root, not only the ones the editor has opened, so
397 // the second file here is written and never announced to the server.
398 root, editor := startRealServer(t)
399 writeFile(t, filepath.Join(root, "other.golo"), "module demo.Other\n\nfunction elsewhere = |x| {\n return x\n}\n")
400
401 find := func(query string) []lsp.Symbol {
402 var symbols []lsp.Symbol
403 waitUntil(t, 30*time.Second, func() bool {
404 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
405 defer cancel()
406 found, err := editor.Language().WorkspaceSymbols(ctx, query)
407 if err != nil {
408 return false
409 }
410 symbols = found
411 return len(symbols) > 0
412 })
413 return symbols
414 }
415
416 if symbols := find("helper"); len(symbols) == 0 || symbols[0].Name != "helper" {
417 t.Errorf("searching the project for helper gave %v", symbols)
418 }
419 if symbols := find("elsewhere"); len(symbols) == 0 || symbols[0].Name != "elsewhere" {
420 t.Errorf("searching the project for a function in a file the editor never opened gave %v", symbols)
421 }
422}
423
424func TestGoloLSPDoesNotAnswerTypeDefinitionWithRealGoloLSP(t *testing.T) {
425 // The one question of the Code menu the server does not advertise. The
426 // documentation says so, and this fails the day a future golo answers it —
427 // which is how the three tests above came to exist: until GoloScript
428 // v0.2.0 references, implementations and project-wide symbols were refused
429 // too, and the test that pinned all four went red on 2026-09-19.
430 root, editor := startRealServer(t)
431 path := filepath.Join(root, "main.golo")
432
433 ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
434 defer cancel()
435
436 if found, err := editor.Language().TypeDefinition(ctx, path, helperLine, helperColumn, helperLineText); err == nil && len(found) > 0 {
437 t.Errorf("golo lsp now answers type definitions (%v); how-to/enable-completion.md says it does not", found)
438 }
439}
440
441// --- the fixtures and the waiting -------------------------------------------
442
443// realScript is the file every language-server test works against. Line
444// numbers are counted from zero and are named by the constants below, so
445// inserting a line here moves them and the constants have to move too.
446//
447// 0 module demo.Main
448// 1
449// 2 # Adds one.
450// 3 function helper = |x| {
451// 4 return x + 1
452// 5 }
453// 6
454// 7 function first = |x| {
455// 8 return helper(x)
456// 9 }
457// 10
458// 11 function second = |x| {
459// 12 return helper(x) * 2
460// 13 }
461// 14 ← where the declaration is typed, at top level: golo lsp offers only
462// 15 top-level functions, so one typed inside main would never appear
463// 16 function main = |args| {
464// 17 let text = "hi"
465// 18 ← two spaces, and where the completion is typed
466// 19 println(first(1) + second(2) + text)
467// 20 }
468//
469// It runs under golo with no error, which matters: a fixture the interpreter
470// complains about would make the diagnostics test pass for the wrong reason.
471const realScript = "module demo.Main\n" +
472 "\n" +
473 "# Adds one.\n" +
474 "function helper = |x| {\n" +
475 " return x + 1\n" +
476 "}\n" +
477 "\n" +
478 "function first = |x| {\n" +
479 " return helper(x)\n" +
480 "}\n" +
481 "\n" +
482 "function second = |x| {\n" +
483 " return helper(x) * 2\n" +
484 "}\n" +
485 "\n" +
486 "\n" +
487 "function main = |args| {\n" +
488 " let text = \"hi\"\n" +
489 " \n" +
490 " println(first(1) + second(2) + text)\n" +
491 "}\n"
492
493// brokenScript is a file that does not parse: the closing brace of main is
494// missing. It exists as a second fixture rather than as a line added to the
495// first, because a file holding a syntax error is a file whose *other* answers
496// are worth nothing: the completion test would then be measuring a parse that
497// never finished.
498const brokenScript = "module demo.Broken\n" +
499 "\n" +
500 "function main = |args| {\n" +
501 " let x = (1 +\n"
502
503// Where the fixture's interesting lines are, counted from zero.
504const (
505 declarationLine = 14
506 completionLine = 18
507 helperLine = 3
508 helperColumn = 9
509 helperLineText = "function helper = |x| {"
510 callLine = 8
511 callColumn = 9
512 callLineText = " return helper(x)"
513 secondCallLine = 12
514)
515
516// startRealServer writes a script, opens it, starts golo lsp and waits for it,
517// in the order the command does. It skips the test when golo is missing.
518func startRealServer(t *testing.T) (root string, editor *app.App) {
519 t.Helper()
520 return startRealServerOn(t, realScript)
521}
522
523// startRealServerOn is startRealServer over a chosen main.golo.
524func startRealServerOn(t *testing.T, source string) (root string, editor *app.App) {
525 t.Helper()
526 if testing.Short() {
527 t.Skip("-short: not starting a language server")
528 }
529
530 server, err := lsp.FindServer(gololang.Profile().Server)
531 if errors.Is(err, lsp.ErrServerNotFound) {
532 t.Skipf("%s is not installed; %s", gololang.ServerCommand, gololang.InstallHint)
533 }
534 // Finding it is not the same as being able to run it: a shim left behind by
535 // a tool manager whose environment has since been removed is on PATH and
536 // fails only when started.
537 if !serverRuns(server) {
538 t.Skipf("%s at %s cannot run; %s", gololang.ServerCommand, server, gololang.InstallHint)
539 }
540
541 root = t.TempDir()
542 writeFile(t, filepath.Join(root, "main.golo"), source)
543
544 editor = newTestEditor(t)
545
546 // 1. Open the file, exactly as main does — before there is any server.
547 editor.Open(filepath.Join(root, "main.golo"))
548
549 // 2. Start the language server, exactly as main does — afterwards, in the
550 // file's own directory, which is what ProjectRoot answers with no
551 // markers.
552 ctx, cancel := context.WithCancel(t.Context())
553 t.Cleanup(cancel)
554 editor.StartLanguageServer(ctx, root)
555 t.Cleanup(func() { editor.Language().Stop(context.Background()) })
556
557 waitUntilReady(t, editor)
558
559 // 3. Let the event loop notice the server is ready, as Run does on every
560 // turn. This is what announces the file that was already open.
561 editor.Tick()
562 return root, editor
563}
564
565// newTestEditor returns Turbo Golo drawing on a simulated terminal, set up the
566// way the command sets it up.
567func newTestEditor(t *testing.T) *app.App {
568 t.Helper()
569
570 gololang.Register()
571 screen := tcell.NewSimulationScreen("UTF-8")
572 if err := screen.Init(); err != nil {
573 t.Fatalf("initialising the simulation screen: %v", err)
574 }
575 t.Cleanup(screen.Fini)
576 screen.SetSize(80, 24)
577
578 // Never read the themes or snippets of whoever is running the tests.
579 p := gololang.Profile()
580 t.Setenv(p.ThemeDirEnvVar(), t.TempDir())
581 t.Setenv(p.SnippetDirEnvVar(), t.TempDir())
582
583 editor := app.New(screen, "turbo-classic", p)
584 editor.Render()
585 return editor
586}
587
588// typeText sends a run of printable characters through the whole routing chain.
589func typeText(editor *app.App, text string) {
590 for _, r := range text {
591 editor.Handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
592 }
593}
594
595// completionOffers reports whether the open popup holds an entry starting with
596// a label.
597func completionOffers(editor *app.App, label string) bool {
598 for _, item := range editor.Completion().Matches() {
599 if strings.HasPrefix(item.Label, label) {
600 return true
601 }
602 }
603 return false
604}
605
606// waitUntilReady blocks until the language server has finished starting.
607func waitUntilReady(t *testing.T, editor *app.App) {
608 t.Helper()
609
610 deadline := time.After(lsp.InitializeTimeout)
611 for !editor.Language().Ready() {
612 select {
613 case <-deadline:
614 t.Fatalf("the language server never became ready: %s", editor.Language().Status())
615 case <-time.After(10 * time.Millisecond):
616 }
617 }
618}
619
620// waitUntil polls a condition until it holds or the time runs out, and fails
621// the test if it never does.
622func waitUntil(t *testing.T, within time.Duration, done func() bool) {
623 t.Helper()
624
625 deadline := time.Now().Add(within)
626 for time.Now().Before(deadline) {
627 if done() {
628 return
629 }
630 time.Sleep(200 * time.Millisecond)
631 }
632 t.Errorf("the server never answered within %s", within)
633}
634
635// waitForLocations asks a location question until it is answered, because a
636// server that is still indexing answers an empty list rather than an error.
637func waitForLocations(t *testing.T, ask func(context.Context) ([]lsp.Location, error)) []lsp.Location {
638 t.Helper()
639
640 var found []lsp.Location
641 waitUntil(t, 30*time.Second, func() bool {
642 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
643 defer cancel()
644
645 locations, err := ask(ctx)
646 if err != nil {
647 return false
648 }
649 found = locations
650 return len(found) > 0
651 })
652 return found
653}
654
655// waitForCompletion asks for a completion until one arrives, or gives up.
656//
657// A server may load its state after it has finished initialising, and answer
658// an empty list until that is done. There is no notification this client reads
659// that says when — so it asks again, which is what the editor's user would do.
660func waitForCompletion(t *testing.T, editor *app.App) bool {
661 t.Helper()
662
663 deadline := time.Now().Add(60 * time.Second)
664 for time.Now().Before(deadline) {
665 if editor.Completion().Visible() {
666 return true
667 }
668 editor.RequestCompletion()
669 if editor.Completion().Visible() {
670 return true
671 }
672 time.Sleep(500 * time.Millisecond)
673 }
674 return false
675}
676
677// serverRuns reports whether the language server at path actually starts.
678func serverRuns(path string) bool {
679 return exec.Command(path, "--version").Run() == nil
680}
681
682// writeFile creates a file, making its directory first.
683func writeFile(t *testing.T, path, content string) {
684 t.Helper()
685 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
686 t.Fatalf("creating %s: %v", filepath.Dir(path), err)
687 }
688 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
689 t.Fatalf("writing %s: %v", path, err)
690 }
691}