turbo-editors/turbo-rustpublic Fork 0
v1.0.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-rust.git
git clone ssh://git@rickub.com/turbo-editors/turbo-rust.git

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

editor_test.go · 270 lines · 9.0 KBGo Blame HistoryRaw
📦 Turbo Rust 713ea5c k33g 12h ago1package rustlang_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-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.
35func 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
101func 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
120func 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
131func 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
159func 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.
176func 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.
198func 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.
206func 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.
216func 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.
235func 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.
256func 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.
262func 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}