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

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

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