package rustlang_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-rust/internal/rustlang" ) // TestCompletionEndToEndWithRealRustAnalyzer 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 rust-analyzer is not installed, and under -short. func TestCompletionEndToEndWithRealRustAnalyzer(t *testing.T) { if testing.Short() { t.Skip("-short: not starting a language server") } server, err := lsp.FindServer(rustlang.Profile().Server) if errors.Is(err, lsp.ErrServerNotFound) { t.Skipf("%s is not installed; %s", rustlang.ServerCommand, rustlang.InstallHint) } // Finding it is not the same as being able to run it. rustup installs a // *shim* called rust-analyzer whether or not the component is there, and // the shim exits with "Unknown binary 'rust-analyzer' in official // toolchain" — after the editor has already started talking to it. The // editor reports that on its status bar; a test has nothing to prove // against it, so it skips. if !serverRuns(server) { t.Skipf("%s at %s cannot run; %s", rustlang.ServerCommand, server, rustlang.InstallHint) } root := t.TempDir() writeFile(t, filepath.Join(root, "Cargo.toml"), "[package]\nname = \"example\"\nversion = \"0.1.0\"\nedition = \"2021\"\n") // The file on disk stops short of the dot. 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 "s." would be answered from disk, and would pass whether or // not the editor said anything at all. source := "fn main() {\n let s = String::new();\n \n}\n" path := filepath.Join(root, "src", "main.rs") writeFile(t, path, source) editor := newTestEditor(t) // 1. Open the file, exactly as main does — before there is any server. editor.Open(path) // 2. Start the language server, exactly as main does — afterwards. ctx, cancel := context.WithCancel(t.Context()) defer 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() // 4. Type "s." into the buffer, so that only the editor knows it is there, // then ask for a completion. view := editor.ActiveView() view.Buffer().SetCursor(buffer.Position{Line: 2, Col: 4}) typeText(editor, "s.") // Typing the dot asks for a completion by itself, but rust-analyzer answers // nothing at all until it has finished loading the workspace — and it says // so with a $/progress notification this client does not read. Asking again // until it answers is what a person does too. if !waitForCompletion(t, editor) { t.Fatalf("no completion list opened; the status bar says %q", editor.StatusBar().Message()) } if !completionOffers(editor, "len") { t.Errorf("the list does not offer String::len; it has %d entries", editor.Completion().Count()) } } func TestTheEditorColoursRustSourceItOpens(t *testing.T) { // The whole path in one test: Register taught the library about Rust, the // profile named the editor, and a .rs file opened through the public API // comes out coloured. root := t.TempDir() path := filepath.Join(root, "main.rs") writeFile(t, path, "fn main() {}\n") editor := newTestEditor(t) editor.Open(path) if got := editor.ActiveView().Language(); got != rustlang.Language { t.Fatalf("the view colours the file as %q, want %q", got, rustlang.Language) } if spans := syntax.Highlight(rustlang.Language, "fn main() {}"); len(spans[0]) == 0 { t.Error("the registered Rust scanner colours nothing") } } func TestTheEditorCallsItselfTurboRust(t *testing.T) { editor := newTestEditor(t) if got := editor.Profile().Name; got != rustlang.Name { t.Errorf("Profile().Name = %q, want %q", got, rustlang.Name) } if got := editor.Profile().ProjectDir(); got != ".turbo-rust" { t.Errorf("ProjectDir() = %q, want %q", got, ".turbo-rust") } } func TestTheToolchainMenuIsCalledRustAndNoTwoMenusShareAHotKey(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. Rust takes T because R is Run's and S is // Search's, 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 == "Rust" { 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 Rust menu on the bar") } } func TestTheEditorDoesNotColourGo(t *testing.T) { // "Rust instead of Go" is the whole point of this editor being a separate // one: a .go file opens as plain text here. root := t.TempDir() path := filepath.Join(root, "main.go") writeFile(t, path, "package main\n") editor := newTestEditor(t) editor.Open(path) if got := editor.ActiveView().Language(); got != syntax.LanguageNone { t.Errorf("a .go file is coloured as %q; Turbo Rust registers Rust, not Go", got) } } // newTestEditor returns Turbo Rust drawing on a simulated terminal, set up the // way the command sets it up. func newTestEditor(t *testing.T) *app.App { t.Helper() rustlang.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 := rustlang.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): } } } // waitForCompletion asks for a completion until one arrives, or gives up. // // rust-analyzer 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(90 * 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. // // rustup's shim exists on every machine that has rustup, and fails only when // it is run, so "the file is there" is not the question worth asking. func serverRuns(path string) bool { out, err := exec.Command(path, "--version").CombinedOutput() return err == nil && !strings.Contains(string(out), "Unknown binary") } // 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) } }