turbo-editors/turbo-pythonpublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-python.git
git clone ssh://git@rickub.com/turbo-editors/turbo-python.git

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

editor_test.go · 463 lines · 14.9 KBGo Blame HistoryRaw
📦 Turbo Python 6fc62ea k33g 8h ago1package pythonlang_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-python/internal/pythonlang"
22)
23
24// --- the editor, assembled --------------------------------------------------
25
26func TestTheEditorCallsItselfTurboPython(t *testing.T) {
27 editor := newTestEditor(t)
28
29 if got := editor.Profile().Name; got != pythonlang.Name {
30 t.Errorf("Profile().Name = %q, want %q", got, pythonlang.Name)
31 }
32 if got := editor.Profile().ProjectDir(); got != ".turbo-python" {
33 t.Errorf("ProjectDir() = %q, want %q", got, ".turbo-python")
34 }
35}
36
37func TestTheEditorColoursPythonSourceItOpens(t *testing.T) {
38 // The whole path in one test: Register taught the library about Python, the
39 // profile named the editor, and a .py file opened through the public API
40 // comes out coloured.
41 root := t.TempDir()
42 path := filepath.Join(root, "main.py")
43 writeFile(t, path, "def main() -> None:\n pass\n")
44
45 editor := newTestEditor(t)
46 editor.Open(path)
47
48 if got := editor.ActiveView().Language(); got != pythonlang.Language {
49 t.Fatalf("the view colours the file as %q, want %q", got, pythonlang.Language)
50 }
51 if spans := syntax.Highlight(pythonlang.Language, "def main():"); len(spans[0]) == 0 {
52 t.Error("the registered Python scanner colours nothing")
53 }
54}
55
56// A Python script in a bin directory has no extension at all, and its first
57// line is the only thing that says what it is. That is what Shebangs is for.
58func TestAScriptWithNoExtensionIsRecognisedByItsShebang(t *testing.T) {
59 root := t.TempDir()
60 path := filepath.Join(root, "deploy")
61 writeFile(t, path, "#!/usr/bin/env python3\nimport sys\n")
62
63 editor := newTestEditor(t)
64 editor.Open(path)
65
66 if got := editor.ActiveView().Language(); got != pythonlang.Language {
67 t.Errorf("a file starting with a python shebang is coloured as %q, want %q", got, pythonlang.Language)
68 }
69}
70
71func TestTheEditorDoesNotColourRust(t *testing.T) {
72 // "Python instead of Rust" is the whole point of this editor being a
73 // separate one: a .rs file opens as plain text here.
74 root := t.TempDir()
75 path := filepath.Join(root, "main.rs")
76 writeFile(t, path, "fn main() {}\n")
77
78 editor := newTestEditor(t)
79 editor.Open(path)
80
81 if got := editor.ActiveView().Language(); got != syntax.LanguageNone {
82 t.Errorf("a .rs file is coloured as %q; Turbo Python registers Python, not Rust", got)
83 }
84}
85
86func TestTheToolchainMenuIsCalledPythonAndNoTwoMenusShareAHotKey(t *testing.T) {
87 // The bar answers the first menu whose hot key matches, so a clash makes
88 // one of the two unreachable from the keyboard — silently, and with every
89 // other test still passing. Python takes P because none of the fixed menus
90 // does, which is exactly the sort of thing only this test notices.
91 editor := newTestEditor(t)
92
93 seen := map[rune]string{}
94 found := false
95 for _, menu := range editor.MenuBar().Menus() {
96 label, hot, _ := ui.SplitHotKey(menu.Label)
97 if label == "Python" {
98 found = true
99 }
100 if hot == 0 {
101 t.Errorf("the %q menu has no hot key", label)
102 continue
103 }
104 if other, clash := seen[hot]; clash {
105 t.Errorf("%q and %q both answer to Alt-%c", other, label, hot)
106 }
107 seen[hot] = label
108 }
109 if !found {
110 t.Error("there is no Python menu on the bar")
111 }
112}
113
114// --- driven against a real python-lsp-server --------------------------------
115
116// TestCompletionEndToEndWithRealPylsp drives the exact sequence the command
117// does at start-up: open the files first, start the language server second,
118// then ask for a completion.
119//
120// That order is the whole point, and it is the one Turbo Go got wrong once: an
121// editor that announces its open documents to a server which does not exist yet
122// and never mentions them again gets answers about a file the server has never
123// heard of — which looks, from the outside, exactly like completion not
124// working.
125//
126// It skips itself when pylsp is not installed, and under -short.
127func TestCompletionEndToEndWithRealPylsp(t *testing.T) {
128 root, editor := startRealServer(t)
129
130 // The file on disk stops short of the dot. The text the completion is about
131 // gets *typed* below, so the answer can only come from what the editor told
132 // the server — which is the whole point of this test. A fixture already
133 // containing "json." would be answered from disk, and would pass whether or
134 // not the editor said anything at all.
135 path := filepath.Join(root, "main.py")
136
137 view := editor.ActiveView()
138 view.Buffer().SetCursor(buffer.Position{Line: completionLine, Col: 4})
139 typeText(editor, "json.")
140
141 // Typing the dot asks for a completion by itself, but a server that is
142 // still indexing answers nothing at all. Asking again until it answers is
143 // what a person does too.
144 if !waitForCompletion(t, editor) {
145 t.Fatalf("no completion list opened for %s; the status bar says %q", path, editor.StatusBar().Message())
146 }
147 if !completionOffers(editor, "loads") {
148 t.Errorf("the list does not offer json.loads; it has %d entries", editor.Completion().Count())
149 }
150}
151
152// Several answers, not one. An earlier version of the library took the first
153// location and threw the rest away, so a name used in three places sent you to
154// whichever one the server happened to list first.
155func TestReferencesAcrossAFileWithRealPylsp(t *testing.T) {
156 root, editor := startRealServer(t)
157 path := filepath.Join(root, "main.py")
158
159 locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) {
160 return editor.Language().References(ctx, path, helperLine, helperColumn, helperLineText)
161 })
162
163 if len(locations) < 3 {
164 t.Errorf("helper has %d references, want at least 3 — its declaration and its two call sites: %v",
165 len(locations), locations)
166 }
167}
168
169func TestTheSymbolsOfAFileWithRealPylsp(t *testing.T) {
170 root, editor := startRealServer(t)
171 path := filepath.Join(root, "main.py")
172
173 var symbols []lsp.Symbol
174 waitUntil(t, 30*time.Second, func() bool {
175 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
176 defer cancel()
177 found, err := editor.Language().DocumentSymbols(ctx, path)
178 if err != nil {
179 return false
180 }
181 symbols = found
182 return len(symbols) > 0
183 })
184
185 names := map[string]bool{}
186 for _, symbol := range symbols {
187 names[symbol.Name] = true
188 }
189 for _, want := range []string{"helper", "first", "second"} {
190 if !names[want] {
191 t.Errorf("the file's symbols do not include %q: %v", want, names)
192 }
193 }
194}
195
196// Diagnostics are the one thing a language server sends without being asked,
197// and the only feature whose failure looks exactly like success: an editor with
198// no error to show and one that cannot find the error are the same blank
199// gutter. So this opens a file that does not parse and waits for the mark.
200func TestDiagnosticsForAFileThatDoesNotParseWithRealPylsp(t *testing.T) {
201 root, editor := startRealServer(t)
202
203 broken := filepath.Join(root, "broken.py")
204 writeFile(t, broken, "def f(:\n return 1\n")
205 editor.Open(broken)
206 editor.Tick()
207
208 waitUntil(t, 30*time.Second, func() bool {
209 editor.Tick()
210 return len(editor.Language().Diagnostics(broken)) > 0
211 })
212
213 problems := editor.Language().Diagnostics(broken)
214 if len(problems) == 0 {
215 t.Fatalf("no diagnostic ever arrived for %s; the status bar says %q", broken, editor.StatusBar().Message())
216 }
217 if _, ok := editor.Language().FirstError(broken); !ok {
218 t.Errorf("the diagnostics hold no error, only %v", problems)
219 }
220}
221
222// python-lsp-server advertises neither implementationProvider nor
223// workspaceSymbolProvider, so two of the nine questions turbo-core asks come
224// back empty. That is documented in how-to/enable-completion.md, and this test
225// is what keeps the documentation honest: if a future pylsp answers either of
226// them, this fails and the page gets revisited.
227func TestPylspAnswersNeitherImplementationsNorProjectWideSymbols(t *testing.T) {
228 root, editor := startRealServer(t)
229 path := filepath.Join(root, "main.py")
230
231 ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
232 defer cancel()
233
234 if found, err := editor.Language().Implementation(ctx, path, helperLine, helperColumn, helperLineText); err == nil && len(found) > 0 {
235 t.Errorf("pylsp now answers implementations (%v); how-to/enable-completion.md says it does not", found)
236 }
237 if found, err := editor.Language().WorkspaceSymbols(ctx, "helper"); err == nil && len(found) > 0 {
238 t.Errorf("pylsp now answers project-wide symbols (%v); how-to/enable-completion.md says it does not", found)
239 }
240}
241
242// --- the fixtures and the waiting -------------------------------------------
243
244// realProject is the file every language-server test works against. Line
245// numbers are counted from zero and are named by the two constants below, so
246// inserting a line here moves them and the constants have to move too.
247//
248// 0 import json
249// 1
250// 2
251// 3 def load(text: str) -> object:
252// 4 ← four spaces, and where the completion is typed
253// 5 return json.loads(text)
254// 6
255// 7
256// 8 def helper() -> int:
257// 9 return 1
258// 10
259// 11
260// 12 def first() -> int:
261// 13 return helper()
262// …
263//
264// The line the completion is typed on is deliberately blank on disk but
265// indented, so that the cursor can sit where a statement would.
266const realProject = "import json\n" +
267 "\n" +
268 "\n" +
269 "def load(text: str) -> object:\n" +
270 " \n" +
271 " return json.loads(text)\n" +
272 "\n" +
273 "\n" +
274 "def helper() -> int:\n" +
275 " return 1\n" +
276 "\n" +
277 "\n" +
278 "def first() -> int:\n" +
279 " return helper()\n" +
280 "\n" +
281 "\n" +
282 "def second() -> int:\n" +
283 " return helper() + 1\n"
284
285// Where the fixture's interesting lines are, counted from zero.
286const (
287 completionLine = 4
288 helperLine = 8
289 helperColumn = 4
290 helperLineText = "def helper() -> int:"
291)
292
293// startRealServer writes a project, opens its file, starts pylsp and waits for
294// it, in the order the command does. It skips the test when pylsp is missing.
295func startRealServer(t *testing.T) (root string, editor *app.App) {
296 t.Helper()
297 if testing.Short() {
298 t.Skip("-short: not starting a language server")
299 }
300
301 server, err := lsp.FindServer(pythonlang.Profile().Server)
302 if errors.Is(err, lsp.ErrServerNotFound) {
303 t.Skipf("%s is not installed; %s", pythonlang.ServerCommand, pythonlang.InstallHint)
304 }
305 // Finding it is not the same as being able to run it: a shim left behind by
306 // a tool manager whose environment has since been removed is on PATH and
307 // fails only when started.
308 if !serverRuns(server) {
309 t.Skipf("%s at %s cannot run; %s", pythonlang.ServerCommand, server, pythonlang.InstallHint)
310 }
311
312 root = t.TempDir()
313 writeFile(t, filepath.Join(root, "pyproject.toml"),
314 "[project]\nname = \"example\"\nversion = \"0.1.0\"\n")
315 writeFile(t, filepath.Join(root, "main.py"), realProject)
316
317 editor = newTestEditor(t)
318
319 // 1. Open the file, exactly as main does — before there is any server.
320 editor.Open(filepath.Join(root, "main.py"))
321
322 // 2. Start the language server, exactly as main does — afterwards.
323 ctx, cancel := context.WithCancel(t.Context())
324 t.Cleanup(cancel)
325 editor.StartLanguageServer(ctx, root)
326 t.Cleanup(func() { editor.Language().Stop(context.Background()) })
327
328 waitUntilReady(t, editor)
329
330 // 3. Let the event loop notice the server is ready, as Run does on every
331 // turn. This is what announces the file that was already open.
332 editor.Tick()
333 return root, editor
334}
335
336// newTestEditor returns Turbo Python drawing on a simulated terminal, set up
337// the way the command sets it up.
338func newTestEditor(t *testing.T) *app.App {
339 t.Helper()
340
341 pythonlang.Register()
342 screen := tcell.NewSimulationScreen("UTF-8")
343 if err := screen.Init(); err != nil {
344 t.Fatalf("initialising the simulation screen: %v", err)
345 }
346 t.Cleanup(screen.Fini)
347 screen.SetSize(80, 24)
348
349 // Never read the themes or snippets of whoever is running the tests.
350 p := pythonlang.Profile()
351 t.Setenv(p.ThemeDirEnvVar(), t.TempDir())
352 t.Setenv(p.SnippetDirEnvVar(), t.TempDir())
353
354 editor := app.New(screen, "turbo-classic", p)
355 editor.Render()
356 return editor
357}
358
359// typeText sends a run of printable characters through the whole routing chain.
360func typeText(editor *app.App, text string) {
361 for _, r := range text {
362 editor.Handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
363 }
364}
365
366// completionOffers reports whether the open popup holds an entry starting with
367// a label.
368func completionOffers(editor *app.App, label string) bool {
369 for _, item := range editor.Completion().Matches() {
370 if strings.HasPrefix(item.Label, label) {
371 return true
372 }
373 }
374 return false
375}
376
377// waitUntilReady blocks until the language server has finished starting.
378func waitUntilReady(t *testing.T, editor *app.App) {
379 t.Helper()
380
381 deadline := time.After(lsp.InitializeTimeout)
382 for !editor.Language().Ready() {
383 select {
384 case <-deadline:
385 t.Fatalf("the language server never became ready: %s", editor.Language().Status())
386 case <-time.After(10 * time.Millisecond):
387 }
388 }
389}
390
391// waitUntil polls a condition until it holds or the time runs out, and fails
392// the test if it never does.
393func waitUntil(t *testing.T, within time.Duration, done func() bool) {
394 t.Helper()
395
396 deadline := time.Now().Add(within)
397 for time.Now().Before(deadline) {
398 if done() {
399 return
400 }
401 time.Sleep(200 * time.Millisecond)
402 }
403 t.Errorf("the server never answered within %s", within)
404}
405
406// waitForLocations asks a location question until it is answered, because a
407// server that is still indexing answers an empty list rather than an error.
408func waitForLocations(t *testing.T, ask func(context.Context) ([]lsp.Location, error)) []lsp.Location {
409 t.Helper()
410
411 var found []lsp.Location
412 waitUntil(t, 30*time.Second, func() bool {
413 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
414 defer cancel()
415
416 locations, err := ask(ctx)
417 if err != nil {
418 return false
419 }
420 found = locations
421 return len(found) > 0
422 })
423 return found
424}
425
426// waitForCompletion asks for a completion until one arrives, or gives up.
427//
428// A server loads the workspace after it has finished initialising, and answers
429// an empty list until that is done. There is no notification this client reads
430// that says when — so it asks again, which is what the editor's user would do.
431func waitForCompletion(t *testing.T, editor *app.App) bool {
432 t.Helper()
433
434 deadline := time.Now().Add(60 * time.Second)
435 for time.Now().Before(deadline) {
436 if editor.Completion().Visible() {
437 return true
438 }
439 editor.RequestCompletion()
440 if editor.Completion().Visible() {
441 return true
442 }
443 time.Sleep(500 * time.Millisecond)
444 }
445 return false
446}
447
448// serverRuns reports whether the language server at path actually starts.
449func serverRuns(path string) bool {
450 err := exec.Command(path, "--version").Run()
451 return err == nil
452}
453
454// writeFile creates a file, making its directory first.
455func writeFile(t *testing.T, path, content string) {
456 t.Helper()
457 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
458 t.Fatalf("creating %s: %v", filepath.Dir(path), err)
459 }
460 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
461 t.Fatalf("writing %s: %v", path, err)
462 }
463}