package moonbitlang_test import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/gdamore/tcell/v2" "rickub.com/turbo-editors/turbo-core/app" "rickub.com/turbo-editors/turbo-core/buffer" "rickub.com/turbo-editors/turbo-core/lsp" "rickub.com/turbo-editors/turbo-core/syntax" "rickub.com/turbo-editors/turbo-core/ui" "rickub.com/turbo-editors/turbo-moonbit/internal/moonbitlang" ) // --- the editor, assembled -------------------------------------------------- func TestTheEditorCallsItselfTurboMoonBit(t *testing.T) { editor := newTestEditor(t) if got := editor.Profile().Name; got != moonbitlang.Name { t.Errorf("Profile().Name = %q, want %q", got, moonbitlang.Name) } if got := editor.Profile().ProjectDir(); got != ".turbo-moonbit" { t.Errorf("ProjectDir() = %q, want %q", got, ".turbo-moonbit") } } func TestTheEditorColoursMoonBitSourceItOpens(t *testing.T) { // The whole path in one test: Register taught the library about MoonBit, // the profile named the editor, and a .mbt file opened through the public // API comes out coloured. root := t.TempDir() path := filepath.Join(root, "main.mbt") writeFile(t, path, "fn main {\n println(\"hi\")\n}\n") editor := newTestEditor(t) editor.Open(path) if got := editor.ActiveView().Language(); got != moonbitlang.Language { t.Fatalf("the view colours the file as %q, want %q", got, moonbitlang.Language) } if spans := syntax.Highlight(moonbitlang.Language, "fn main {"); len(spans[0]) == 0 { t.Error("the registered MoonBit scanner colours nothing") } } func TestAnInterfaceFileIsMoonBitToo(t *testing.T) { // A .mbti is generated by `moon info` and read in review. It is MoonBit // and nothing else, so it opens coloured. root := t.TempDir() path := filepath.Join(root, "pkg.generated.mbti") writeFile(t, path, "package \"example/demo\"\n\npub fn helper() -> Int\n") editor := newTestEditor(t) editor.Open(path) if got := editor.ActiveView().Language(); got != moonbitlang.Language { t.Errorf("a .mbti file is coloured as %q, want %q", got, moonbitlang.Language) } } func TestTheEditorDoesNotColourPython(t *testing.T) { // "MoonBit instead of Python" is the whole point of this editor being a // separate one: a .py file opens as plain text here. root := t.TempDir() path := filepath.Join(root, "main.py") writeFile(t, path, "def main() -> None:\n pass\n") editor := newTestEditor(t) editor.Open(path) if got := editor.ActiveView().Language(); got != syntax.LanguageNone { t.Errorf("a .py file is coloured as %q; Turbo MoonBit registers MoonBit, not Python", got) } } func TestAProjectsOwnFilesAreStillColouredByTheLibrary(t *testing.T) { // moon.pkg.json and a README are what a MoonBit project is made of besides // its source, and turbo-core colours both without this editor doing // anything. That the inherited languages survive registration is worth one // test, because syntax.Register writes into package-level state. root := t.TempDir() editor := newTestEditor(t) for name, want := range map[string]syntax.Language{ "README.md": syntax.LanguageMarkdown, "README.mbt.md": syntax.LanguageMarkdown, "ci.yml": syntax.LanguageYAML, } { path := filepath.Join(root, name) writeFile(t, path, "# heading\n") editor.Open(path) if got := editor.ActiveView().Language(); got != want { t.Errorf("%s is coloured as %q, want %q", name, got, want) } } } func TestTheToolchainMenuIsCalledMoonBitAndNoTwoMenusShareAHotKey(t *testing.T) { // The bar answers the first menu whose hot key matches, so a clash makes // one of the two unreachable from the keyboard — silently, and with every // other test still passing. MoonBit takes M because none of the fixed menus // does, which is exactly the sort of thing only this test notices. editor := newTestEditor(t) seen := map[rune]string{} found := false for _, menu := range editor.MenuBar().Menus() { label, hot, _ := ui.SplitHotKey(menu.Label) if label == "MoonBit" { found = true } if hot == 0 { t.Errorf("the %q menu has no hot key", label) continue } if other, clash := seen[hot]; clash { t.Errorf("%q and %q both answer to Alt-%c", other, label, hot) } seen[hot] = label } if !found { t.Error("there is no MoonBit menu on the bar") } } // --- driven against a real moon-lsp ----------------------------------------- // TestCompletionEndToEndWithRealMoonLSP drives the exact sequence the command // does at start-up: open the files first, start the language server second, // then ask for a completion. // // That order is the whole point, and it is the one Turbo Go got wrong once: an // editor that announces its open documents to a server which does not exist yet // and never mentions them again gets answers about a file the server has never // heard of — which looks, from the outside, exactly like completion not // working. // // It skips itself when the MoonBit toolchain is not installed, and under // -short. func TestCompletionEndToEndWithRealMoonLSP(t *testing.T) { root, editor := startRealServer(t) // The line on disk is blank. The text the completion is about gets *typed* // below, so the answer can only come from what the editor told the server — // which is the whole point of this test. A fixture already containing // "text." would be answered from disk, and would pass whether or not the // editor said anything at all. path := filepath.Join(root, "main.mbt") view := editor.ActiveView() view.Buffer().SetCursor(buffer.Position{Line: completionLine, Col: 2}) typeText(editor, "text.") // Typing the dot asks for a completion by itself, but a server that is // still indexing answers nothing at all. Asking again until it answers is // what a person does too. if !waitForCompletion(t, editor) { t.Fatalf("no completion list opened for %s; the status bar says %q", path, editor.StatusBar().Message()) } // length() is a String method, so an answer holding it is an answer about // the *type* of the name that was typed, not a list of every word in the // file. if !completionOffers(editor, "length") { t.Errorf("the list does not offer String's length; it has %d entries", editor.Completion().Count()) } } // Several answers, not one. An earlier version of the library took the first // location and threw the rest away, so a name used in three places sent you to // whichever one the server happened to list first. func TestReferencesAcrossAFileWithRealMoonLSP(t *testing.T) { root, editor := startRealServer(t) path := filepath.Join(root, "main.mbt") locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) { return editor.Language().References(ctx, path, helperLine, helperColumn, helperLineText) }) if len(locations) < 3 { t.Errorf("helper has %d references, want at least 3 — its declaration and its two call sites: %v", len(locations), locations) } } func TestGoToDefinitionWithRealMoonLSP(t *testing.T) { root, editor := startRealServer(t) path := filepath.Join(root, "main.mbt") locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) { return editor.Language().Definition(ctx, path, callLine, callColumn, callLineText) }) if len(locations) != 1 { t.Fatalf("the call to helper has %d definitions, want exactly 1: %v", len(locations), locations) } if got := locations[0].Range.Start.Line; got != helperLine { t.Errorf("the definition of helper is on line %d, want %d", got, helperLine) } } func TestTheSymbolsOfAFileWithRealMoonLSP(t *testing.T) { root, editor := startRealServer(t) path := filepath.Join(root, "main.mbt") var symbols []lsp.Symbol waitUntil(t, 30*time.Second, func() bool { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() found, err := editor.Language().DocumentSymbols(ctx, path) if err != nil { return false } symbols = found return len(symbols) > 0 }) names := map[string]bool{} for _, symbol := range symbols { names[symbol.Name] = true } for _, want := range []string{"helper", "first", "second", "main"} { if !names[want] { t.Errorf("the file's symbols do not include %q: %v", want, names) } } } func TestTheProjectsSymbolsWithRealMoonLSP(t *testing.T) { // moon-lsp advertises workspaceSymbolProvider, which pylsp does not — so // Code ▸ Symbol in project and Ctrl-T really answer here. _, editor := startRealServer(t) var symbols []lsp.Symbol waitUntil(t, 30*time.Second, func() bool { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() found, err := editor.Language().WorkspaceSymbols(ctx, "helper") if err != nil { return false } symbols = found return len(symbols) > 0 }) if len(symbols) == 0 { t.Error("moon-lsp answered no project-wide symbols for \"helper\"") } } // Diagnostics are the one thing a language server sends without being asked, // and the only feature whose failure looks exactly like success: an editor with // no error to show and one that cannot find the error are the same blank // gutter. So this opens a file that does not compile and waits for the mark. // // The file is on disk before the server starts, which is what a person actually // does — the code was already broken when they opened it. The other order does // not work, and the test below says so rather than leaving it to be discovered. func TestDiagnosticsForAFileThatDoesNotCompileWithRealMoonLSP(t *testing.T) { root, editor := startRealServerOn(t, brokenProject) path := filepath.Join(root, "main.mbt") waitUntil(t, 30*time.Second, func() bool { editor.Tick() return len(editor.Language().Diagnostics(path)) > 0 }) problems := editor.Language().Diagnostics(path) if len(problems) == 0 { t.Fatalf("no diagnostic ever arrived for %s; the status bar says %q", path, editor.StatusBar().Message()) } if _, ok := editor.Language().FirstError(path); !ok { t.Errorf("the diagnostics hold no error, only %v", problems) } } // A .mbt file that did not exist when moon-lsp first analysed the package is // diagnosed from its first save. It was not, until turbo-core v1.0.2: the // server works out which files a package holds from the directory, and a // document being open and a file existing are two different facts to it — a // file saved for the first time got no diagnostics however loudly the document // had been announced, until the editor also sent // workspace/didChangeWatchedFiles. The test that pinned that limit went red on // macOS on 2026-09-19, where moon-lsp evidently notices new files by itself; // on Linux it does not, and the notification is what makes this pass. // // The scenario is the one a person lives: `turbo-moonbit late.mbt` on a file // that is not there yet, type, and let the save happen — here automatic // saving, the one exported way to write a buffer without a dialog. func TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP(t *testing.T) { root, editor := startRealServer(t) late := filepath.Join(root, "late.mbt") editor.Open(late) // not on disk: an empty buffer with that name editor.Tick() editor.SetAutosave(true, 10*time.Millisecond) typeText(editor, "///|\nfn oops() -> Int {\n undefined_name()\n}\n") waitUntil(t, 30*time.Second, func() bool { editor.Tick() return len(editor.Language().Diagnostics(late)) > 0 }) if _, err := os.Stat(late); err != nil { t.Fatalf("the file was never written, so this proves nothing about the server: %v", err) } if !editor.Language().Knows(late) { t.Error("the editor never told the server about the new file") } if len(editor.Language().Diagnostics(late)) == 0 { t.Errorf("no diagnostic arrived for a file created after the server started; the status bar says %q", editor.StatusBar().Message()) } } // moon-lsp advertises neither typeDefinitionProvider nor implementationProvider, // so two of the nine questions turbo-core asks come back empty. That is // documented in how-to/enable-completion.md, and this test is what keeps the // documentation honest: if a future moon-lsp answers either of them, this fails // and the page gets revisited. func TestMoonLSPAnswersNeitherTypeDefinitionsNorImplementations(t *testing.T) { root, editor := startRealServer(t) path := filepath.Join(root, "main.mbt") ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) defer cancel() if found, err := editor.Language().TypeDefinition(ctx, path, helperLine, helperColumn, helperLineText); err == nil && len(found) > 0 { t.Errorf("moon-lsp now answers type definitions (%v); how-to/enable-completion.md says it does not", found) } if found, err := editor.Language().Implementation(ctx, path, helperLine, helperColumn, helperLineText); err == nil && len(found) > 0 { t.Errorf("moon-lsp now answers implementations (%v); how-to/enable-completion.md says it does not", found) } } // --- the fixtures and the waiting ------------------------------------------- // realProject is the file every language-server test works against. Line // numbers are counted from zero and are named by the constants below, so // inserting a line here moves them and the constants have to move too. // // 0 ///| // 1 fn helper() -> Int { // 2 1 // 3 } // 4 // 5 ///| // 6 fn first() -> Int { // 7 helper() // 8 } // 9 // 10 ///| // 11 fn second() -> Int { // 12 helper() + 1 // 13 } // 14 // 15 ///| // 16 fn main { // 17 let text = "hi" // 18 ← two spaces, and where the completion is typed // 19 println(first() + second() + text.length()) // 20 } // // It compiles with no errors and no warnings under `moon check`, which matters: // a fixture the toolchain complains about would make the diagnostics test pass // for the wrong reason. const realProject = "///|\n" + "fn helper() -> Int {\n" + " 1\n" + "}\n" + "\n" + "///|\n" + "fn first() -> Int {\n" + " helper()\n" + "}\n" + "\n" + "///|\n" + "fn second() -> Int {\n" + " helper() + 1\n" + "}\n" + "\n" + "///|\n" + "fn main {\n" + " let text = \"hi\"\n" + " \n" + " println(first() + second() + text.length())\n" + "}\n" // brokenProject is a project whose one file does not compile. It exists as a // second fixture rather than as a file added to the first, because a package // holding an error is a package whose *other* answers are worth nothing: the // completion test would then be measuring a broken build. const brokenProject = "///|\n" + "fn main {\n" + " undefined_name()\n" + "}\n" // Where the fixture's interesting lines are, counted from zero. const ( completionLine = 18 helperLine = 1 helperColumn = 3 helperLineText = "fn helper() -> Int {" callLine = 7 callColumn = 2 callLineText = " helper()" ) // startRealServer writes a project, opens its file, starts moon-lsp and waits // for it, in the order the command does. It skips the test when the MoonBit // toolchain is missing. func startRealServer(t *testing.T) (root string, editor *app.App) { t.Helper() return startRealServerOn(t, realProject) } // startRealServerOn is startRealServer over a chosen main.mbt. func startRealServerOn(t *testing.T, source string) (root string, editor *app.App) { t.Helper() if testing.Short() { t.Skip("-short: not starting a language server") } server, err := lsp.FindServer(moonbitlang.Profile().Server) if errors.Is(err, lsp.ErrServerNotFound) { t.Skipf("%s is not installed; %s", moonbitlang.ServerCommand, moonbitlang.InstallHint) } // Finding it is not the same as being able to run it: a shim left behind by // a tool manager whose environment has since been removed is on PATH and // fails only when started. if !serverRuns(server) { t.Skipf("%s at %s cannot run; %s", moonbitlang.ServerCommand, server, moonbitlang.InstallHint) } root = t.TempDir() writeFile(t, filepath.Join(root, "moon.mod"), "name = \"example/demo\"\nversion = \"0.1.0\"\n") writeFile(t, filepath.Join(root, "moon.pkg"), "pkgtype(kind: \"executable\")\n") writeFile(t, filepath.Join(root, "main.mbt"), source) editor = newTestEditor(t) // 1. Open the file, exactly as main does — before there is any server. editor.Open(filepath.Join(root, "main.mbt")) // 2. Start the language server, exactly as main does — afterwards. ctx, cancel := context.WithCancel(t.Context()) t.Cleanup(cancel) editor.StartLanguageServer(ctx, root) t.Cleanup(func() { editor.Language().Stop(context.Background()) }) waitUntilReady(t, editor) // 3. Let the event loop notice the server is ready, as Run does on every // turn. This is what announces the file that was already open. editor.Tick() return root, editor } // newTestEditor returns Turbo MoonBit drawing on a simulated terminal, set up // the way the command sets it up. func newTestEditor(t *testing.T) *app.App { t.Helper() moonbitlang.Register() screen := tcell.NewSimulationScreen("UTF-8") if err := screen.Init(); err != nil { t.Fatalf("initialising the simulation screen: %v", err) } t.Cleanup(screen.Fini) screen.SetSize(80, 24) // Never read the themes or snippets of whoever is running the tests. p := moonbitlang.Profile() t.Setenv(p.ThemeDirEnvVar(), t.TempDir()) t.Setenv(p.SnippetDirEnvVar(), t.TempDir()) editor := app.New(screen, "turbo-classic", p) editor.Render() return editor } // typeText sends a run of printable characters through the whole routing chain. func typeText(editor *app.App, text string) { for _, r := range text { // A newline is the Enter key, not a rune: typed as a rune it is // dropped, and a fixture meant to span four lines lands on one — // where `///|` turns the whole of it into a doc comment. if r == '\n' { editor.Handle(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) continue } editor.Handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) } } // completionOffers reports whether the open popup holds an entry starting with // a label. func completionOffers(editor *app.App, label string) bool { for _, item := range editor.Completion().Matches() { if strings.HasPrefix(item.Label, label) { return true } } return false } // waitUntilReady blocks until the language server has finished starting. func waitUntilReady(t *testing.T, editor *app.App) { t.Helper() deadline := time.After(lsp.InitializeTimeout) for !editor.Language().Ready() { select { case <-deadline: t.Fatalf("the language server never became ready: %s", editor.Language().Status()) case <-time.After(10 * time.Millisecond): } } } // waitUntil polls a condition until it holds or the time runs out, and fails // the test if it never does. func waitUntil(t *testing.T, within time.Duration, done func() bool) { t.Helper() deadline := time.Now().Add(within) for time.Now().Before(deadline) { if done() { return } time.Sleep(200 * time.Millisecond) } t.Errorf("the server never answered within %s", within) } // waitForLocations asks a location question until it is answered, because a // server that is still indexing answers an empty list rather than an error. func waitForLocations(t *testing.T, ask func(context.Context) ([]lsp.Location, error)) []lsp.Location { t.Helper() var found []lsp.Location waitUntil(t, 30*time.Second, func() bool { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() locations, err := ask(ctx) if err != nil { return false } found = locations return len(found) > 0 }) return found } // waitForCompletion asks for a completion until one arrives, or gives up. // // A server loads the workspace after it has finished initialising, and answers // an empty list until that is done. There is no notification this client reads // that says when — so it asks again, which is what the editor's user would do. func waitForCompletion(t *testing.T, editor *app.App) bool { t.Helper() deadline := time.Now().Add(60 * time.Second) for time.Now().Before(deadline) { if editor.Completion().Visible() { return true } editor.RequestCompletion() if editor.Completion().Visible() { return true } time.Sleep(500 * time.Millisecond) } return false } // serverRuns reports whether the language server at path actually starts. func serverRuns(path string) bool { return exec.Command(path, "--version").Run() == nil } // writeFile creates a file, making its directory first. func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("creating %s: %v", filepath.Dir(path), err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("writing %s: %v", path, err) } }