package gololang_test import ( "context" "errors" "os" "os/exec" "path/filepath" "slices" "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-golo/internal/gololang" ) // --- the editor, assembled -------------------------------------------------- func TestTheEditorCallsItselfTurboGolo(t *testing.T) { editor := newTestEditor(t) if got := editor.Profile().Name; got != gololang.Name { t.Errorf("Profile().Name = %q, want %q", got, gololang.Name) } if got := editor.Profile().ProjectDir(); got != ".turbo-golo" { t.Errorf("ProjectDir() = %q, want %q", got, ".turbo-golo") } } func TestTheEditorColoursGoloSourceItOpens(t *testing.T) { // The whole path in one test: Register taught the library about Golo, the // profile named the editor, and a .golo file opened through the public // API comes out coloured. root := t.TempDir() path := filepath.Join(root, "main.golo") writeFile(t, path, "module demo.Main\n\nfunction main = |args| {\n println(\"hi\")\n}\n") editor := newTestEditor(t) editor.Open(path) if got := editor.ActiveView().Language(); got != gololang.Language { t.Fatalf("the view colours the file as %q, want %q", got, gololang.Language) } if spans := syntax.Highlight(gololang.Language, "function main = |args| {"); len(spans[0]) == 0 { t.Error("the registered Golo scanner colours nothing") } } func TestAScriptWithAShebangAndNoExtensionIsGoloToo(t *testing.T) { // A script run as a command has no extension; its first line is what // identifies it, and the editor reads that line before choosing a scanner. root := t.TempDir() path := filepath.Join(root, "greet") writeFile(t, path, "#!/usr/bin/env golo\nmodule Greet\n\nfunction main = |args| {\n println(\"hi\")\n}\n") editor := newTestEditor(t) editor.Open(path) if got := editor.ActiveView().Language(); got != gololang.Language { t.Errorf("a script opening with a golo shebang is coloured as %q, want %q", got, gololang.Language) } } func TestTheEditorDoesNotColourMoonBit(t *testing.T) { // "Golo instead of MoonBit" is the whole point of this editor being a // separate one: a .mbt file opens as plain text here. 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 != syntax.LanguageNone { t.Errorf("a .mbt file is coloured as %q; Turbo Golo registers Golo, not MoonBit", got) } } func TestAProjectsOwnFilesAreStillColouredByTheLibrary(t *testing.T) { // A README, a compose file and a Dockerfile are what a Golo project is // made of besides its scripts, and turbo-core colours all three 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, "compose.yaml": syntax.LanguageYAML, "Dockerfile": syntax.LanguageDockerfile, } { 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 TestTheToolchainMenuIsCalledGoloAndNoTwoMenusShareAHotKey(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. Golo takes G 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 == "Golo" { 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 Golo menu on the bar") } } // --- driven against a real golo lsp ----------------------------------------- // TestCompletionEndToEndWithRealGoloLSP drives the exact sequence the command // does at start-up: open the file 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 golo is not installed, and under -short. func TestCompletionEndToEndWithRealGoloLSP(t *testing.T) { root, editor := startRealServer(t) path := filepath.Join(root, "main.golo") // Both lines on disk are blank. A function is *declared* by typing, at top // level, then its name is typed inside main, so the answer can only come from // what the editor told the server — which is the whole point of this // test. golo lsp offers keywords and builtins for any file at all, so a // completion holding println would prove nothing; a completion holding a // function that exists only in the buffer proves the buffer was sent. view := editor.ActiveView() view.Buffer().SetCursor(buffer.Position{Line: declarationLine, Col: 0}) typeText(editor, "function zorglub = |x| -> x + 1") view.Buffer().SetCursor(buffer.Position{Line: completionLine, Col: 2}) typeText(editor, "zorg") if !waitForCompletion(t, editor) { t.Fatalf("no completion list opened for %s; the status bar says %q", path, editor.StatusBar().Message()) } if !completionOffers(editor, "zorglub") { t.Errorf("the list does not offer the function typed into the buffer; it has %d entries", editor.Completion().Count()) } } // The scanner's builtin table is read out of GoloScript rather than remembered, // and this is where that claim is checked against the binary itself: the // completion golo lsp offers for an empty prefix lists every keyword and every // builtin it knows, so the two tables can be compared in both directions. func TestTheScannersTablesMatchWhatTheServerOffers(t *testing.T) { root, editor := startRealServer(t) path := filepath.Join(root, "main.golo") var offered []lsp.CompletionItem waitUntil(t, 30*time.Second, func() bool { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() items, err := editor.Language().Complete(ctx, path, completionLine, 2, " ") if err != nil { return false } offered = items return len(offered) > 0 }) labels := map[string]bool{} for _, item := range offered { labels[item.Label] = true } for _, builtin := range gololang.Builtins() { if !labels[builtin] { t.Errorf("the scanner colours %q as a builtin, and golo lsp does not offer it", builtin) } } for _, keyword := range gololang.Keywords() { if !labels[keyword] { t.Errorf("the scanner colours %q as a keyword, and golo lsp does not offer it", keyword) } } // The other direction: everything the server offers that is not a keyword, // a literal or a function the fixture declares must be in the builtin // table, or the table has fallen behind the interpreter. known := map[string]bool{"true": true, "false": true, "null": true} for _, word := range append(gololang.Keywords(), gololang.Builtins()...) { known[word] = true } for _, declared := range []string{"helper", "first", "second", "main"} { known[declared] = true } for label := range labels { if !known[label] { t.Errorf("golo lsp offers %q, which the scanner knows nothing about", label) } } } func TestGoToDefinitionWithRealGoloLSP(t *testing.T) { root, editor := startRealServer(t) path := filepath.Join(root, "main.golo") 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 TestHoverShowsTheCommentAboveADeclarationWithRealGoloLSP(t *testing.T) { // A block of # comments above a declaration is its documentation, and the // server shows it on hover. This is what F1 — Code ▸ Describe symbol — // draws, and it is the one answer here that carries prose a person wrote. root, editor := startRealServer(t) path := filepath.Join(root, "main.golo") var text string waitUntil(t, 30*time.Second, func() bool { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() answer, err := editor.Language().Hover(ctx, path, callLine, callColumn, callLineText) if err != nil { return false } text = answer return text != "" }) if !strings.Contains(text, "Adds one") { t.Errorf("hovering helper gave %q, want the comment written above its declaration", text) } } func TestTheSymbolsOfAFileWithRealGoloLSP(t *testing.T) { root, editor := startRealServer(t) path := filepath.Join(root, "main.golo") 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) } } } // 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 parse and waits for the mark. func TestDiagnosticsForAFileThatDoesNotParseWithRealGoloLSP(t *testing.T) { root, editor := startRealServerOn(t, brokenScript) path := filepath.Join(root, "main.golo") 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 C-style comment is the mistake everybody coming from another language // makes, and golo lsp lints it rather than merely failing to parse it. It is // the diagnostic a new Golo programmer meets first, so it is the one checked // by name. func TestACStyleCommentIsDiagnosedWithRealGoloLSP(t *testing.T) { root, editor := startRealServerOn(t, "module demo.Lint\n\n// not a Golo comment\nfunction main = |args| {\n println(\"hi\")\n}\n") path := filepath.Join(root, "main.golo") waitUntil(t, 30*time.Second, func() bool { editor.Tick() return len(editor.Language().Diagnostics(path)) > 0 }) var messages []string for _, problem := range editor.Language().Diagnostics(path) { messages = append(messages, problem.Message) } if !slices.ContainsFunc(messages, func(m string) bool { return strings.Contains(m, "#") }) { t.Errorf("the C-style comment was not diagnosed as one; the server said %v", messages) } } // golo lsp advertises neither referencesProvider, typeDefinitionProvider, // implementationProvider nor workspaceSymbolProvider, so four 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 golo answers any of them, this fails and the page gets // revisited. func TestFindReferencesWithRealGoloLSP(t *testing.T) { // GoloScript v0.2.0 started answering references. Asked from a call, the // answer is the declaration and every call within the file; a use in // another file of the same project is not found, because the server // resolves nothing across files. root, editor := startRealServer(t) path := filepath.Join(root, "main.golo") locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) { return editor.Language().References(ctx, path, callLine, callColumn, callLineText) }) lines := map[int]bool{} for _, location := range locations { if !strings.HasSuffix(location.URI, "/main.golo") { t.Errorf("a reference points outside the file: %v", location) } lines[location.Range.Start.Line] = true } for _, want := range []int{helperLine, callLine, secondCallLine} { if !lines[want] { t.Errorf("the references to helper miss line %d: %v", want, locations) } } if len(locations) != 3 { t.Errorf("helper has %d references, want 3 (the declaration and two calls): %v", len(locations), locations) } } func TestFindImplementationsWithRealGoloLSP(t *testing.T) { // GoloScript v0.2.0 started answering implementations, with the function's // declaration: Golo has no interfaces, so a function is its own // implementation, and the answer is the same place F12 goes. root, editor := startRealServer(t) path := filepath.Join(root, "main.golo") locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) { return editor.Language().Implementation(ctx, path, callLine, callColumn, callLineText) }) if len(locations) != 1 { t.Fatalf("the call to helper has %d implementations, want exactly 1: %v", len(locations), locations) } if got := locations[0].Range.Start.Line; got != helperLine { t.Errorf("the implementation of helper is on line %d, want its declaration on %d", got, helperLine) } } func TestSymbolsAcrossTheProjectWithRealGoloLSP(t *testing.T) { // GoloScript v0.2.0 started answering workspace/symbol. It searches every // .golo file under the root, not only the ones the editor has opened, so // the second file here is written and never announced to the server. root, editor := startRealServer(t) writeFile(t, filepath.Join(root, "other.golo"), "module demo.Other\n\nfunction elsewhere = |x| {\n return x\n}\n") find := func(query string) []lsp.Symbol { 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, query) if err != nil { return false } symbols = found return len(symbols) > 0 }) return symbols } if symbols := find("helper"); len(symbols) == 0 || symbols[0].Name != "helper" { t.Errorf("searching the project for helper gave %v", symbols) } if symbols := find("elsewhere"); len(symbols) == 0 || symbols[0].Name != "elsewhere" { t.Errorf("searching the project for a function in a file the editor never opened gave %v", symbols) } } func TestGoloLSPDoesNotAnswerTypeDefinitionWithRealGoloLSP(t *testing.T) { // The one question of the Code menu the server does not advertise. The // documentation says so, and this fails the day a future golo answers it — // which is how the three tests above came to exist: until GoloScript // v0.2.0 references, implementations and project-wide symbols were refused // too, and the test that pinned all four went red on 2026-09-19. root, editor := startRealServer(t) path := filepath.Join(root, "main.golo") 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("golo lsp now answers type definitions (%v); how-to/enable-completion.md says it does not", found) } } // --- the fixtures and the waiting ------------------------------------------- // realScript 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 module demo.Main // 1 // 2 # Adds one. // 3 function helper = |x| { // 4 return x + 1 // 5 } // 6 // 7 function first = |x| { // 8 return helper(x) // 9 } // 10 // 11 function second = |x| { // 12 return helper(x) * 2 // 13 } // 14 ← where the declaration is typed, at top level: golo lsp offers only // 15 top-level functions, so one typed inside main would never appear // 16 function main = |args| { // 17 let text = "hi" // 18 ← two spaces, and where the completion is typed // 19 println(first(1) + second(2) + text) // 20 } // // It runs under golo with no error, which matters: a fixture the interpreter // complains about would make the diagnostics test pass for the wrong reason. const realScript = "module demo.Main\n" + "\n" + "# Adds one.\n" + "function helper = |x| {\n" + " return x + 1\n" + "}\n" + "\n" + "function first = |x| {\n" + " return helper(x)\n" + "}\n" + "\n" + "function second = |x| {\n" + " return helper(x) * 2\n" + "}\n" + "\n" + "\n" + "function main = |args| {\n" + " let text = \"hi\"\n" + " \n" + " println(first(1) + second(2) + text)\n" + "}\n" // brokenScript is a file that does not parse: the closing brace of main is // missing. It exists as a second fixture rather than as a line added to the // first, because a file holding a syntax error is a file whose *other* answers // are worth nothing: the completion test would then be measuring a parse that // never finished. const brokenScript = "module demo.Broken\n" + "\n" + "function main = |args| {\n" + " let x = (1 +\n" // Where the fixture's interesting lines are, counted from zero. const ( declarationLine = 14 completionLine = 18 helperLine = 3 helperColumn = 9 helperLineText = "function helper = |x| {" callLine = 8 callColumn = 9 callLineText = " return helper(x)" secondCallLine = 12 ) // startRealServer writes a script, opens it, starts golo lsp and waits for it, // in the order the command does. It skips the test when golo is missing. func startRealServer(t *testing.T) (root string, editor *app.App) { t.Helper() return startRealServerOn(t, realScript) } // startRealServerOn is startRealServer over a chosen main.golo. 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(gololang.Profile().Server) if errors.Is(err, lsp.ErrServerNotFound) { t.Skipf("%s is not installed; %s", gololang.ServerCommand, gololang.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", gololang.ServerCommand, server, gololang.InstallHint) } root = t.TempDir() writeFile(t, filepath.Join(root, "main.golo"), source) editor = newTestEditor(t) // 1. Open the file, exactly as main does — before there is any server. editor.Open(filepath.Join(root, "main.golo")) // 2. Start the language server, exactly as main does — afterwards, in the // file's own directory, which is what ProjectRoot answers with no // markers. 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 Golo drawing on a simulated terminal, set up the // way the command sets it up. func newTestEditor(t *testing.T) *app.App { t.Helper() gololang.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 := gololang.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 { 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 may load its state after it has finished initialising, and answer // 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) } }