| 📦 Turbo Rust 713ea5c k33g 12h ago | 1 | package rustlang_test |
| 2 | |
| 3 | import ( |
| 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-rust/internal/rustlang" |
| 22 | ) |
| 23 | |
| 24 | // TestCompletionEndToEndWithRealRustAnalyzer drives the exact sequence the |
| 25 | // command does at start-up: open the files first, start the language server |
| 26 | // second, then ask for a completion. |
| 27 | // |
| 28 | // That order is the whole point, and it is the one Turbo Go got wrong once: an |
| 29 | // editor that announces its open documents to a server which does not exist yet |
| 30 | // and never mentions them again gets answers about a file the server has never |
| 31 | // heard of — which looks, from the outside, exactly like completion not |
| 32 | // working. |
| 33 | // |
| 34 | // It skips itself when rust-analyzer is not installed, and under -short. |
| 35 | func TestCompletionEndToEndWithRealRustAnalyzer(t *testing.T) { |
| 36 | if testing.Short() { |
| 37 | t.Skip("-short: not starting a language server") |
| 38 | } |
| 39 | server, err := lsp.FindServer(rustlang.Profile().Server) |
| 40 | if errors.Is(err, lsp.ErrServerNotFound) { |
| 41 | t.Skipf("%s is not installed; %s", rustlang.ServerCommand, rustlang.InstallHint) |
| 42 | } |
| 43 | // Finding it is not the same as being able to run it. rustup installs a |
| 44 | // *shim* called rust-analyzer whether or not the component is there, and |
| 45 | // the shim exits with "Unknown binary 'rust-analyzer' in official |
| 46 | // toolchain" — after the editor has already started talking to it. The |
| 47 | // editor reports that on its status bar; a test has nothing to prove |
| 48 | // against it, so it skips. |
| 49 | if !serverRuns(server) { |
| 50 | t.Skipf("%s at %s cannot run; %s", rustlang.ServerCommand, server, rustlang.InstallHint) |
| 51 | } |
| 52 | |
| 53 | root := t.TempDir() |
| 54 | writeFile(t, filepath.Join(root, "Cargo.toml"), |
| 55 | "[package]\nname = \"example\"\nversion = \"0.1.0\"\nedition = \"2021\"\n") |
| 56 | |
| 57 | // The file on disk stops short of the dot. The text the completion is about |
| 58 | // gets *typed* below, so the answer can only come from what the editor told |
| 59 | // the server — which is the whole point of this test. A fixture already |
| 60 | // containing "s." would be answered from disk, and would pass whether or |
| 61 | // not the editor said anything at all. |
| 62 | source := "fn main() {\n let s = String::new();\n \n}\n" |
| 63 | path := filepath.Join(root, "src", "main.rs") |
| 64 | writeFile(t, path, source) |
| 65 | |
| 66 | editor := newTestEditor(t) |
| 67 | |
| 68 | // 1. Open the file, exactly as main does — before there is any server. |
| 69 | editor.Open(path) |
| 70 | |
| 71 | // 2. Start the language server, exactly as main does — afterwards. |
| 72 | ctx, cancel := context.WithCancel(t.Context()) |
| 73 | defer cancel() |
| 74 | editor.StartLanguageServer(ctx, root) |
| 75 | t.Cleanup(func() { editor.Language().Stop(context.Background()) }) |
| 76 | |
| 77 | waitUntilReady(t, editor) |
| 78 | |
| 79 | // 3. Let the event loop notice the server is ready, as Run does on every |
| 80 | // turn. This is what announces the file that was already open. |
| 81 | editor.Tick() |
| 82 | |
| 83 | // 4. Type "s." into the buffer, so that only the editor knows it is there, |
| 84 | // then ask for a completion. |
| 85 | view := editor.ActiveView() |
| 86 | view.Buffer().SetCursor(buffer.Position{Line: 2, Col: 4}) |
| 87 | typeText(editor, "s.") |
| 88 | |
| 89 | // Typing the dot asks for a completion by itself, but rust-analyzer answers |
| 90 | // nothing at all until it has finished loading the workspace — and it says |
| 91 | // so with a $/progress notification this client does not read. Asking again |
| 92 | // until it answers is what a person does too. |
| 93 | if !waitForCompletion(t, editor) { |
| 94 | t.Fatalf("no completion list opened; the status bar says %q", editor.StatusBar().Message()) |
| 95 | } |
| 96 | if !completionOffers(editor, "len") { |
| 97 | t.Errorf("the list does not offer String::len; it has %d entries", editor.Completion().Count()) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestTheEditorColoursRustSourceItOpens(t *testing.T) { |
| 102 | // The whole path in one test: Register taught the library about Rust, the |
| 103 | // profile named the editor, and a .rs file opened through the public API |
| 104 | // comes out coloured. |
| 105 | root := t.TempDir() |
| 106 | path := filepath.Join(root, "main.rs") |
| 107 | writeFile(t, path, "fn main() {}\n") |
| 108 | |
| 109 | editor := newTestEditor(t) |
| 110 | editor.Open(path) |
| 111 | |
| 112 | if got := editor.ActiveView().Language(); got != rustlang.Language { |
| 113 | t.Fatalf("the view colours the file as %q, want %q", got, rustlang.Language) |
| 114 | } |
| 115 | if spans := syntax.Highlight(rustlang.Language, "fn main() {}"); len(spans[0]) == 0 { |
| 116 | t.Error("the registered Rust scanner colours nothing") |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func TestTheEditorCallsItselfTurboRust(t *testing.T) { |
| 121 | editor := newTestEditor(t) |
| 122 | |
| 123 | if got := editor.Profile().Name; got != rustlang.Name { |
| 124 | t.Errorf("Profile().Name = %q, want %q", got, rustlang.Name) |
| 125 | } |
| 126 | if got := editor.Profile().ProjectDir(); got != ".turbo-rust" { |
| 127 | t.Errorf("ProjectDir() = %q, want %q", got, ".turbo-rust") |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func TestTheToolchainMenuIsCalledRustAndNoTwoMenusShareAHotKey(t *testing.T) { |
| 132 | // The bar answers the first menu whose hot key matches, so a clash makes |
| 133 | // one of the two unreachable from the keyboard — silently, and with every |
| 134 | // other test still passing. Rust takes T because R is Run's and S is |
| 135 | // Search's, which is exactly the sort of thing only this test notices. |
| 136 | editor := newTestEditor(t) |
| 137 | |
| 138 | seen := map[rune]string{} |
| 139 | found := false |
| 140 | for _, menu := range editor.MenuBar().Menus() { |
| 141 | label, hot, _ := ui.SplitHotKey(menu.Label) |
| 142 | if label == "Rust" { |
| 143 | found = true |
| 144 | } |
| 145 | if hot == 0 { |
| 146 | t.Errorf("the %q menu has no hot key", label) |
| 147 | continue |
| 148 | } |
| 149 | if other, clash := seen[hot]; clash { |
| 150 | t.Errorf("%q and %q both answer to Alt-%c", other, label, hot) |
| 151 | } |
| 152 | seen[hot] = label |
| 153 | } |
| 154 | if !found { |
| 155 | t.Error("there is no Rust menu on the bar") |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestTheEditorDoesNotColourGo(t *testing.T) { |
| 160 | // "Rust instead of Go" is the whole point of this editor being a separate |
| 161 | // one: a .go file opens as plain text here. |
| 162 | root := t.TempDir() |
| 163 | path := filepath.Join(root, "main.go") |
| 164 | writeFile(t, path, "package main\n") |
| 165 | |
| 166 | editor := newTestEditor(t) |
| 167 | editor.Open(path) |
| 168 | |
| 169 | if got := editor.ActiveView().Language(); got != syntax.LanguageNone { |
| 170 | t.Errorf("a .go file is coloured as %q; Turbo Rust registers Rust, not Go", got) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | // newTestEditor returns Turbo Rust drawing on a simulated terminal, set up the |
| 175 | // way the command sets it up. |
| 176 | func newTestEditor(t *testing.T) *app.App { |
| 177 | t.Helper() |
| 178 | |
| 179 | rustlang.Register() |
| 180 | screen := tcell.NewSimulationScreen("UTF-8") |
| 181 | if err := screen.Init(); err != nil { |
| 182 | t.Fatalf("initialising the simulation screen: %v", err) |
| 183 | } |
| 184 | t.Cleanup(screen.Fini) |
| 185 | screen.SetSize(80, 24) |
| 186 | |
| 187 | // Never read the themes or snippets of whoever is running the tests. |
| 188 | p := rustlang.Profile() |
| 189 | t.Setenv(p.ThemeDirEnvVar(), t.TempDir()) |
| 190 | t.Setenv(p.SnippetDirEnvVar(), t.TempDir()) |
| 191 | |
| 192 | editor := app.New(screen, "turbo-classic", p) |
| 193 | editor.Render() |
| 194 | return editor |
| 195 | } |
| 196 | |
| 197 | // typeText sends a run of printable characters through the whole routing chain. |
| 198 | func typeText(editor *app.App, text string) { |
| 199 | for _, r := range text { |
| 200 | editor.Handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // completionOffers reports whether the open popup holds an entry starting with |
| 205 | // a label. |
| 206 | func completionOffers(editor *app.App, label string) bool { |
| 207 | for _, item := range editor.Completion().Matches() { |
| 208 | if strings.HasPrefix(item.Label, label) { |
| 209 | return true |
| 210 | } |
| 211 | } |
| 212 | return false |
| 213 | } |
| 214 | |
| 215 | // waitUntilReady blocks until the language server has finished starting. |
| 216 | func waitUntilReady(t *testing.T, editor *app.App) { |
| 217 | t.Helper() |
| 218 | |
| 219 | deadline := time.After(lsp.InitializeTimeout) |
| 220 | for !editor.Language().Ready() { |
| 221 | select { |
| 222 | case <-deadline: |
| 223 | t.Fatalf("the language server never became ready: %s", editor.Language().Status()) |
| 224 | case <-time.After(10 * time.Millisecond): |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | // waitForCompletion asks for a completion until one arrives, or gives up. |
| 230 | // |
| 231 | // rust-analyzer loads the workspace after it has finished initialising, and |
| 232 | // answers an empty list until that is done. There is no notification this |
| 233 | // client reads that says when — so it asks again, which is what the editor's |
| 234 | // user would do. |
| 235 | func waitForCompletion(t *testing.T, editor *app.App) bool { |
| 236 | t.Helper() |
| 237 | |
| 238 | deadline := time.Now().Add(90 * time.Second) |
| 239 | for time.Now().Before(deadline) { |
| 240 | if editor.Completion().Visible() { |
| 241 | return true |
| 242 | } |
| 243 | editor.RequestCompletion() |
| 244 | if editor.Completion().Visible() { |
| 245 | return true |
| 246 | } |
| 247 | time.Sleep(500 * time.Millisecond) |
| 248 | } |
| 249 | return false |
| 250 | } |
| 251 | |
| 252 | // serverRuns reports whether the language server at path actually starts. |
| 253 | // |
| 254 | // rustup's shim exists on every machine that has rustup, and fails only when |
| 255 | // it is run, so "the file is there" is not the question worth asking. |
| 256 | func serverRuns(path string) bool { |
| 257 | out, err := exec.Command(path, "--version").CombinedOutput() |
| 258 | return err == nil && !strings.Contains(string(out), "Unknown binary") |
| 259 | } |
| 260 | |
| 261 | // writeFile creates a file, making its directory first. |
| 262 | func writeFile(t *testing.T, path, content string) { |
| 263 | t.Helper() |
| 264 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 265 | t.Fatalf("creating %s: %v", filepath.Dir(path), err) |
| 266 | } |
| 267 | if err := os.WriteFile(path, []byte(content), 0o644); err != nil { |
| 268 | t.Fatalf("writing %s: %v", path, err) |
| 269 | } |
| 270 | } |