turbo-editors/turbo-moonbitpublic Fork 0
v1.0.2
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-moonbit.git
git clone ssh://git@rickub.com/turbo-editors/turbo-moonbit.git

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

editor_test.go · 601 lines · 20.2 KBGo Blame HistoryRaw
📦 Turbo MoonBit cc1f595 k33g 14h ago1package moonbitlang_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-moonbit/internal/moonbitlang"
22)
23
24// --- the editor, assembled --------------------------------------------------
25
26func TestTheEditorCallsItselfTurboMoonBit(t *testing.T) {
27 editor := newTestEditor(t)
28
29 if got := editor.Profile().Name; got != moonbitlang.Name {
30 t.Errorf("Profile().Name = %q, want %q", got, moonbitlang.Name)
31 }
32 if got := editor.Profile().ProjectDir(); got != ".turbo-moonbit" {
33 t.Errorf("ProjectDir() = %q, want %q", got, ".turbo-moonbit")
34 }
35}
36
37func TestTheEditorColoursMoonBitSourceItOpens(t *testing.T) {
38 // The whole path in one test: Register taught the library about MoonBit,
39 // the profile named the editor, and a .mbt file opened through the public
40 // API comes out coloured.
41 root := t.TempDir()
42 path := filepath.Join(root, "main.mbt")
43 writeFile(t, path, "fn main {\n println(\"hi\")\n}\n")
44
45 editor := newTestEditor(t)
46 editor.Open(path)
47
48 if got := editor.ActiveView().Language(); got != moonbitlang.Language {
49 t.Fatalf("the view colours the file as %q, want %q", got, moonbitlang.Language)
50 }
51 if spans := syntax.Highlight(moonbitlang.Language, "fn main {"); len(spans[0]) == 0 {
52 t.Error("the registered MoonBit scanner colours nothing")
53 }
54}
55
56func TestAnInterfaceFileIsMoonBitToo(t *testing.T) {
57 // A .mbti is generated by `moon info` and read in review. It is MoonBit
58 // and nothing else, so it opens coloured.
59 root := t.TempDir()
60 path := filepath.Join(root, "pkg.generated.mbti")
61 writeFile(t, path, "package \"example/demo\"\n\npub fn helper() -> Int\n")
62
63 editor := newTestEditor(t)
64 editor.Open(path)
65
66 if got := editor.ActiveView().Language(); got != moonbitlang.Language {
67 t.Errorf("a .mbti file is coloured as %q, want %q", got, moonbitlang.Language)
68 }
69}
70
71func TestTheEditorDoesNotColourPython(t *testing.T) {
72 // "MoonBit instead of Python" is the whole point of this editor being a
73 // separate one: a .py file opens as plain text here.
74 root := t.TempDir()
75 path := filepath.Join(root, "main.py")
76 writeFile(t, path, "def main() -> None:\n pass\n")
77
78 editor := newTestEditor(t)
79 editor.Open(path)
80
81 if got := editor.ActiveView().Language(); got != syntax.LanguageNone {
82 t.Errorf("a .py file is coloured as %q; Turbo MoonBit registers MoonBit, not Python", got)
83 }
84}
85
86func TestAProjectsOwnFilesAreStillColouredByTheLibrary(t *testing.T) {
87 // moon.pkg.json and a README are what a MoonBit project is made of besides
88 // its source, and turbo-core colours both without this editor doing
89 // anything. That the inherited languages survive registration is worth one
90 // test, because syntax.Register writes into package-level state.
91 root := t.TempDir()
92 editor := newTestEditor(t)
93
94 for name, want := range map[string]syntax.Language{
95 "README.md": syntax.LanguageMarkdown,
96 "README.mbt.md": syntax.LanguageMarkdown,
97 "ci.yml": syntax.LanguageYAML,
98 } {
99 path := filepath.Join(root, name)
100 writeFile(t, path, "# heading\n")
101 editor.Open(path)
102
103 if got := editor.ActiveView().Language(); got != want {
104 t.Errorf("%s is coloured as %q, want %q", name, got, want)
105 }
106 }
107}
108
109func TestTheToolchainMenuIsCalledMoonBitAndNoTwoMenusShareAHotKey(t *testing.T) {
110 // The bar answers the first menu whose hot key matches, so a clash makes
111 // one of the two unreachable from the keyboard — silently, and with every
112 // other test still passing. MoonBit takes M because none of the fixed menus
113 // does, which is exactly the sort of thing only this test notices.
114 editor := newTestEditor(t)
115
116 seen := map[rune]string{}
117 found := false
118 for _, menu := range editor.MenuBar().Menus() {
119 label, hot, _ := ui.SplitHotKey(menu.Label)
120 if label == "MoonBit" {
121 found = true
122 }
123 if hot == 0 {
124 t.Errorf("the %q menu has no hot key", label)
125 continue
126 }
127 if other, clash := seen[hot]; clash {
128 t.Errorf("%q and %q both answer to Alt-%c", other, label, hot)
129 }
130 seen[hot] = label
131 }
132 if !found {
133 t.Error("there is no MoonBit menu on the bar")
134 }
135}
136
137// --- driven against a real moon-lsp -----------------------------------------
138
139// TestCompletionEndToEndWithRealMoonLSP drives the exact sequence the command
140// does at start-up: open the files first, start the language server second,
141// then ask for a completion.
142//
143// That order is the whole point, and it is the one Turbo Go got wrong once: an
144// editor that announces its open documents to a server which does not exist yet
145// and never mentions them again gets answers about a file the server has never
146// heard of — which looks, from the outside, exactly like completion not
147// working.
148//
149// It skips itself when the MoonBit toolchain is not installed, and under
150// -short.
151func TestCompletionEndToEndWithRealMoonLSP(t *testing.T) {
152 root, editor := startRealServer(t)
153
154 // The line on disk is blank. The text the completion is about gets *typed*
155 // below, so the answer can only come from what the editor told the server —
156 // which is the whole point of this test. A fixture already containing
157 // "text." would be answered from disk, and would pass whether or not the
158 // editor said anything at all.
159 path := filepath.Join(root, "main.mbt")
160
161 view := editor.ActiveView()
162 view.Buffer().SetCursor(buffer.Position{Line: completionLine, Col: 2})
163 typeText(editor, "text.")
164
165 // Typing the dot asks for a completion by itself, but a server that is
166 // still indexing answers nothing at all. Asking again until it answers is
167 // what a person does too.
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 // length() is a String method, so an answer holding it is an answer about
172 // the *type* of the name that was typed, not a list of every word in the
173 // file.
174 if !completionOffers(editor, "length") {
175 t.Errorf("the list does not offer String's length; it has %d entries", editor.Completion().Count())
176 }
177}
178
179// Several answers, not one. An earlier version of the library took the first
180// location and threw the rest away, so a name used in three places sent you to
181// whichever one the server happened to list first.
182func TestReferencesAcrossAFileWithRealMoonLSP(t *testing.T) {
183 root, editor := startRealServer(t)
184 path := filepath.Join(root, "main.mbt")
185
186 locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) {
187 return editor.Language().References(ctx, path, helperLine, helperColumn, helperLineText)
188 })
189
190 if len(locations) < 3 {
191 t.Errorf("helper has %d references, want at least 3 — its declaration and its two call sites: %v",
192 len(locations), locations)
193 }
194}
195
196func TestGoToDefinitionWithRealMoonLSP(t *testing.T) {
197 root, editor := startRealServer(t)
198 path := filepath.Join(root, "main.mbt")
199
200 locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) {
201 return editor.Language().Definition(ctx, path, callLine, callColumn, callLineText)
202 })
203
204 if len(locations) != 1 {
205 t.Fatalf("the call to helper has %d definitions, want exactly 1: %v", len(locations), locations)
206 }
207 if got := locations[0].Range.Start.Line; got != helperLine {
208 t.Errorf("the definition of helper is on line %d, want %d", got, helperLine)
209 }
210}
211
212func TestTheSymbolsOfAFileWithRealMoonLSP(t *testing.T) {
213 root, editor := startRealServer(t)
214 path := filepath.Join(root, "main.mbt")
215
216 var symbols []lsp.Symbol
217 waitUntil(t, 30*time.Second, func() bool {
218 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
219 defer cancel()
220 found, err := editor.Language().DocumentSymbols(ctx, path)
221 if err != nil {
222 return false
223 }
224 symbols = found
225 return len(symbols) > 0
226 })
227
228 names := map[string]bool{}
229 for _, symbol := range symbols {
230 names[symbol.Name] = true
231 }
232 for _, want := range []string{"helper", "first", "second", "main"} {
233 if !names[want] {
234 t.Errorf("the file's symbols do not include %q: %v", want, names)
235 }
236 }
237}
238
239func TestTheProjectsSymbolsWithRealMoonLSP(t *testing.T) {
240 // moon-lsp advertises workspaceSymbolProvider, which pylsp does not — so
241 // Code ▸ Symbol in project and Ctrl-T really answer here.
242 _, editor := startRealServer(t)
243
244 var symbols []lsp.Symbol
245 waitUntil(t, 30*time.Second, func() bool {
246 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
247 defer cancel()
248 found, err := editor.Language().WorkspaceSymbols(ctx, "helper")
249 if err != nil {
250 return false
251 }
252 symbols = found
253 return len(symbols) > 0
254 })
255
256 if len(symbols) == 0 {
257 t.Error("moon-lsp answered no project-wide symbols for \"helper\"")
258 }
259}
260
261// Diagnostics are the one thing a language server sends without being asked,
262// and the only feature whose failure looks exactly like success: an editor with
263// no error to show and one that cannot find the error are the same blank
264// gutter. So this opens a file that does not compile and waits for the mark.
265//
266// The file is on disk before the server starts, which is what a person actually
267// does — the code was already broken when they opened it. The other order does
268// not work, and the test below says so rather than leaving it to be discovered.
269func TestDiagnosticsForAFileThatDoesNotCompileWithRealMoonLSP(t *testing.T) {
270 root, editor := startRealServerOn(t, brokenProject)
271 path := filepath.Join(root, "main.mbt")
272
273 waitUntil(t, 30*time.Second, func() bool {
274 editor.Tick()
275 return len(editor.Language().Diagnostics(path)) > 0
276 })
277
278 problems := editor.Language().Diagnostics(path)
279 if len(problems) == 0 {
280 t.Fatalf("no diagnostic ever arrived for %s; the status bar says %q", path, editor.StatusBar().Message())
281 }
282 if _, ok := editor.Language().FirstError(path); !ok {
283 t.Errorf("the diagnostics hold no error, only %v", problems)
284 }
285}
286
287// A .mbt file that did not exist when moon-lsp first analysed the package is
288// diagnosed from its first save. It was not, until turbo-core v1.0.2: the
289// server works out which files a package holds from the directory, and a
290// document being open and a file existing are two different facts to it — a
291// file saved for the first time got no diagnostics however loudly the document
292// had been announced, until the editor also sent
293// workspace/didChangeWatchedFiles. The test that pinned that limit went red on
294// macOS on 2026-09-19, where moon-lsp evidently notices new files by itself;
295// on Linux it does not, and the notification is what makes this pass.
296//
297// The scenario is the one a person lives: `turbo-moonbit late.mbt` on a file
298// that is not there yet, type, and let the save happen — here automatic
299// saving, the one exported way to write a buffer without a dialog.
300func TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP(t *testing.T) {
301 root, editor := startRealServer(t)
302
303 late := filepath.Join(root, "late.mbt")
304 editor.Open(late) // not on disk: an empty buffer with that name
305 editor.Tick()
306 editor.SetAutosave(true, 10*time.Millisecond)
307 typeText(editor, "///|\nfn oops() -> Int {\n undefined_name()\n}\n")
308
309 waitUntil(t, 30*time.Second, func() bool {
310 editor.Tick()
311 return len(editor.Language().Diagnostics(late)) > 0
312 })
313
314 if _, err := os.Stat(late); err != nil {
315 t.Fatalf("the file was never written, so this proves nothing about the server: %v", err)
316 }
317 if !editor.Language().Knows(late) {
318 t.Error("the editor never told the server about the new file")
319 }
320 if len(editor.Language().Diagnostics(late)) == 0 {
321 t.Errorf("no diagnostic arrived for a file created after the server started; the status bar says %q", editor.StatusBar().Message())
322 }
323}
324
325// moon-lsp advertises neither typeDefinitionProvider nor implementationProvider,
326// so two of the nine questions turbo-core asks come back empty. That is
327// documented in how-to/enable-completion.md, and this test is what keeps the
328// documentation honest: if a future moon-lsp answers either of them, this fails
329// and the page gets revisited.
330func TestMoonLSPAnswersNeitherTypeDefinitionsNorImplementations(t *testing.T) {
331 root, editor := startRealServer(t)
332 path := filepath.Join(root, "main.mbt")
333
334 ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
335 defer cancel()
336
337 if found, err := editor.Language().TypeDefinition(ctx, path, helperLine, helperColumn, helperLineText); err == nil && len(found) > 0 {
338 t.Errorf("moon-lsp now answers type definitions (%v); how-to/enable-completion.md says it does not", found)
339 }
340 if found, err := editor.Language().Implementation(ctx, path, helperLine, helperColumn, helperLineText); err == nil && len(found) > 0 {
341 t.Errorf("moon-lsp now answers implementations (%v); how-to/enable-completion.md says it does not", found)
342 }
343}
344
345// --- the fixtures and the waiting -------------------------------------------
346
347// realProject is the file every language-server test works against. Line
348// numbers are counted from zero and are named by the constants below, so
349// inserting a line here moves them and the constants have to move too.
350//
351// 0 ///|
352// 1 fn helper() -> Int {
353// 2 1
354// 3 }
355// 4
356// 5 ///|
357// 6 fn first() -> Int {
358// 7 helper()
359// 8 }
360// 9
361// 10 ///|
362// 11 fn second() -> Int {
363// 12 helper() + 1
364// 13 }
365// 14
366// 15 ///|
367// 16 fn main {
368// 17 let text = "hi"
369// 18 ← two spaces, and where the completion is typed
370// 19 println(first() + second() + text.length())
371// 20 }
372//
373// It compiles with no errors and no warnings under `moon check`, which matters:
374// a fixture the toolchain complains about would make the diagnostics test pass
375// for the wrong reason.
376const realProject = "///|\n" +
377 "fn helper() -> Int {\n" +
378 " 1\n" +
379 "}\n" +
380 "\n" +
381 "///|\n" +
382 "fn first() -> Int {\n" +
383 " helper()\n" +
384 "}\n" +
385 "\n" +
386 "///|\n" +
387 "fn second() -> Int {\n" +
388 " helper() + 1\n" +
389 "}\n" +
390 "\n" +
391 "///|\n" +
392 "fn main {\n" +
393 " let text = \"hi\"\n" +
394 " \n" +
395 " println(first() + second() + text.length())\n" +
396 "}\n"
397
398// brokenProject is a project whose one file does not compile. It exists as a
399// second fixture rather than as a file added to the first, because a package
400// holding an error is a package whose *other* answers are worth nothing: the
401// completion test would then be measuring a broken build.
402const brokenProject = "///|\n" +
403 "fn main {\n" +
404 " undefined_name()\n" +
405 "}\n"
406
407// Where the fixture's interesting lines are, counted from zero.
408const (
409 completionLine = 18
410 helperLine = 1
411 helperColumn = 3
412 helperLineText = "fn helper() -> Int {"
413 callLine = 7
414 callColumn = 2
415 callLineText = " helper()"
416)
417
418// startRealServer writes a project, opens its file, starts moon-lsp and waits
419// for it, in the order the command does. It skips the test when the MoonBit
420// toolchain is missing.
421func startRealServer(t *testing.T) (root string, editor *app.App) {
422 t.Helper()
423 return startRealServerOn(t, realProject)
424}
425
426// startRealServerOn is startRealServer over a chosen main.mbt.
427func startRealServerOn(t *testing.T, source string) (root string, editor *app.App) {
428 t.Helper()
429 if testing.Short() {
430 t.Skip("-short: not starting a language server")
431 }
432
433 server, err := lsp.FindServer(moonbitlang.Profile().Server)
434 if errors.Is(err, lsp.ErrServerNotFound) {
435 t.Skipf("%s is not installed; %s", moonbitlang.ServerCommand, moonbitlang.InstallHint)
436 }
437 // Finding it is not the same as being able to run it: a shim left behind by
438 // a tool manager whose environment has since been removed is on PATH and
439 // fails only when started.
440 if !serverRuns(server) {
441 t.Skipf("%s at %s cannot run; %s", moonbitlang.ServerCommand, server, moonbitlang.InstallHint)
442 }
443
444 root = t.TempDir()
445 writeFile(t, filepath.Join(root, "moon.mod"), "name = \"example/demo\"\nversion = \"0.1.0\"\n")
446 writeFile(t, filepath.Join(root, "moon.pkg"), "pkgtype(kind: \"executable\")\n")
447 writeFile(t, filepath.Join(root, "main.mbt"), source)
448
449 editor = newTestEditor(t)
450
451 // 1. Open the file, exactly as main does — before there is any server.
452 editor.Open(filepath.Join(root, "main.mbt"))
453
454 // 2. Start the language server, exactly as main does — afterwards.
455 ctx, cancel := context.WithCancel(t.Context())
456 t.Cleanup(cancel)
457 editor.StartLanguageServer(ctx, root)
458 t.Cleanup(func() { editor.Language().Stop(context.Background()) })
459
460 waitUntilReady(t, editor)
461
462 // 3. Let the event loop notice the server is ready, as Run does on every
463 // turn. This is what announces the file that was already open.
464 editor.Tick()
465 return root, editor
466}
467
468// newTestEditor returns Turbo MoonBit drawing on a simulated terminal, set up
469// the way the command sets it up.
470func newTestEditor(t *testing.T) *app.App {
471 t.Helper()
472
473 moonbitlang.Register()
474 screen := tcell.NewSimulationScreen("UTF-8")
475 if err := screen.Init(); err != nil {
476 t.Fatalf("initialising the simulation screen: %v", err)
477 }
478 t.Cleanup(screen.Fini)
479 screen.SetSize(80, 24)
480
481 // Never read the themes or snippets of whoever is running the tests.
482 p := moonbitlang.Profile()
483 t.Setenv(p.ThemeDirEnvVar(), t.TempDir())
484 t.Setenv(p.SnippetDirEnvVar(), t.TempDir())
485
486 editor := app.New(screen, "turbo-classic", p)
487 editor.Render()
488 return editor
489}
490
491// typeText sends a run of printable characters through the whole routing chain.
492func typeText(editor *app.App, text string) {
493 for _, r := range text {
494 // A newline is the Enter key, not a rune: typed as a rune it is
495 // dropped, and a fixture meant to span four lines lands on one —
496 // where `///|` turns the whole of it into a doc comment.
497 if r == '\n' {
498 editor.Handle(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))
499 continue
500 }
501 editor.Handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
502 }
503}
504
505// completionOffers reports whether the open popup holds an entry starting with
506// a label.
507func completionOffers(editor *app.App, label string) bool {
508 for _, item := range editor.Completion().Matches() {
509 if strings.HasPrefix(item.Label, label) {
510 return true
511 }
512 }
513 return false
514}
515
516// waitUntilReady blocks until the language server has finished starting.
517func waitUntilReady(t *testing.T, editor *app.App) {
518 t.Helper()
519
520 deadline := time.After(lsp.InitializeTimeout)
521 for !editor.Language().Ready() {
522 select {
523 case <-deadline:
524 t.Fatalf("the language server never became ready: %s", editor.Language().Status())
525 case <-time.After(10 * time.Millisecond):
526 }
527 }
528}
529
530// waitUntil polls a condition until it holds or the time runs out, and fails
531// the test if it never does.
532func waitUntil(t *testing.T, within time.Duration, done func() bool) {
533 t.Helper()
534
535 deadline := time.Now().Add(within)
536 for time.Now().Before(deadline) {
537 if done() {
538 return
539 }
540 time.Sleep(200 * time.Millisecond)
541 }
542 t.Errorf("the server never answered within %s", within)
543}
544
545// waitForLocations asks a location question until it is answered, because a
546// server that is still indexing answers an empty list rather than an error.
547func waitForLocations(t *testing.T, ask func(context.Context) ([]lsp.Location, error)) []lsp.Location {
548 t.Helper()
549
550 var found []lsp.Location
551 waitUntil(t, 30*time.Second, func() bool {
552 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
553 defer cancel()
554
555 locations, err := ask(ctx)
556 if err != nil {
557 return false
558 }
559 found = locations
560 return len(found) > 0
561 })
562 return found
563}
564
565// waitForCompletion asks for a completion until one arrives, or gives up.
566//
567// A server loads the workspace after it has finished initialising, and answers
568// an empty list until that is done. There is no notification this client reads
569// that says when — so it asks again, which is what the editor's user would do.
570func waitForCompletion(t *testing.T, editor *app.App) bool {
571 t.Helper()
572
573 deadline := time.Now().Add(60 * time.Second)
574 for time.Now().Before(deadline) {
575 if editor.Completion().Visible() {
576 return true
577 }
578 editor.RequestCompletion()
579 if editor.Completion().Visible() {
580 return true
581 }
582 time.Sleep(500 * time.Millisecond)
583 }
584 return false
585}
586
587// serverRuns reports whether the language server at path actually starts.
588func serverRuns(path string) bool {
589 return exec.Command(path, "--version").Run() == nil
590}
591
592// writeFile creates a file, making its directory first.
593func writeFile(t *testing.T, path, content string) {
594 t.Helper()
595 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
596 t.Fatalf("creating %s: %v", filepath.Dir(path), err)
597 }
598 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
599 t.Fatalf("writing %s: %v", path, err)
600 }
601}