package pythonlang_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-python/internal/pythonlang" ) // --- the editor, assembled -------------------------------------------------- func TestTheEditorCallsItselfTurboPython(t *testing.T) { editor := newTestEditor(t) if got := editor.Profile().Name; got != pythonlang.Name { t.Errorf("Profile().Name = %q, want %q", got, pythonlang.Name) } if got := editor.Profile().ProjectDir(); got != ".turbo-python" { t.Errorf("ProjectDir() = %q, want %q", got, ".turbo-python") } } func TestTheEditorColoursPythonSourceItOpens(t *testing.T) { // The whole path in one test: Register taught the library about Python, the // profile named the editor, and a .py file opened through the public API // comes out coloured. root := t.TempDir() path := filepath.Join(root, "main.py") writeFile(t, path, "def main() -> None:\n pass\n") editor := newTestEditor(t) editor.Open(path) if got := editor.ActiveView().Language(); got != pythonlang.Language { t.Fatalf("the view colours the file as %q, want %q", got, pythonlang.Language) } if spans := syntax.Highlight(pythonlang.Language, "def main():"); len(spans[0]) == 0 { t.Error("the registered Python scanner colours nothing") } } // A Python script in a bin directory has no extension at all, and its first // line is the only thing that says what it is. That is what Shebangs is for. func TestAScriptWithNoExtensionIsRecognisedByItsShebang(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "deploy") writeFile(t, path, "#!/usr/bin/env python3\nimport sys\n") editor := newTestEditor(t) editor.Open(path) if got := editor.ActiveView().Language(); got != pythonlang.Language { t.Errorf("a file starting with a python shebang is coloured as %q, want %q", got, pythonlang.Language) } } func TestTheEditorDoesNotColourRust(t *testing.T) { // "Python instead of Rust" is the whole point of this editor being a // separate one: a .rs file opens as plain text here. 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 != syntax.LanguageNone { t.Errorf("a .rs file is coloured as %q; Turbo Python registers Python, not Rust", got) } } func TestTheToolchainMenuIsCalledPythonAndNoTwoMenusShareAHotKey(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. Python takes P 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 == "Python" { 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 Python menu on the bar") } } // --- driven against a real python-lsp-server -------------------------------- // TestCompletionEndToEndWithRealPylsp 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 pylsp is not installed, and under -short. func TestCompletionEndToEndWithRealPylsp(t *testing.T) { root, editor := startRealServer(t) // 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 "json." would be answered from disk, and would pass whether or // not the editor said anything at all. path := filepath.Join(root, "main.py") view := editor.ActiveView() view.Buffer().SetCursor(buffer.Position{Line: completionLine, Col: 4}) typeText(editor, "json.") // Typing the dot asks for a completion by itself, but a server that is // still indexing answers nothing at all. Asking again until it answers is // what a person does too. if !waitForCompletion(t, editor) { t.Fatalf("no completion list opened for %s; the status bar says %q", path, editor.StatusBar().Message()) } if !completionOffers(editor, "loads") { t.Errorf("the list does not offer json.loads; it has %d entries", editor.Completion().Count()) } } // Several answers, not one. An earlier version of the library took the first // location and threw the rest away, so a name used in three places sent you to // whichever one the server happened to list first. func TestReferencesAcrossAFileWithRealPylsp(t *testing.T) { root, editor := startRealServer(t) path := filepath.Join(root, "main.py") locations := waitForLocations(t, func(ctx context.Context) ([]lsp.Location, error) { return editor.Language().References(ctx, path, helperLine, helperColumn, helperLineText) }) if len(locations) < 3 { t.Errorf("helper has %d references, want at least 3 — its declaration and its two call sites: %v", len(locations), locations) } } func TestTheSymbolsOfAFileWithRealPylsp(t *testing.T) { root, editor := startRealServer(t) path := filepath.Join(root, "main.py") 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"} { 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 TestDiagnosticsForAFileThatDoesNotParseWithRealPylsp(t *testing.T) { root, editor := startRealServer(t) broken := filepath.Join(root, "broken.py") writeFile(t, broken, "def f(:\n return 1\n") editor.Open(broken) editor.Tick() waitUntil(t, 30*time.Second, func() bool { editor.Tick() return len(editor.Language().Diagnostics(broken)) > 0 }) problems := editor.Language().Diagnostics(broken) if len(problems) == 0 { t.Fatalf("no diagnostic ever arrived for %s; the status bar says %q", broken, editor.StatusBar().Message()) } if _, ok := editor.Language().FirstError(broken); !ok { t.Errorf("the diagnostics hold no error, only %v", problems) } } // python-lsp-server advertises neither implementationProvider nor // workspaceSymbolProvider, so two 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 pylsp answers either of // them, this fails and the page gets revisited. func TestPylspAnswersNeitherImplementationsNorProjectWideSymbols(t *testing.T) { root, editor := startRealServer(t) path := filepath.Join(root, "main.py") ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) defer cancel() if found, err := editor.Language().Implementation(ctx, path, helperLine, helperColumn, helperLineText); err == nil && len(found) > 0 { t.Errorf("pylsp now answers implementations (%v); how-to/enable-completion.md says it does not", found) } if found, err := editor.Language().WorkspaceSymbols(ctx, "helper"); err == nil && len(found) > 0 { t.Errorf("pylsp now answers project-wide symbols (%v); how-to/enable-completion.md says it does not", found) } } // --- the fixtures and the waiting ------------------------------------------- // realProject is the file every language-server test works against. Line // numbers are counted from zero and are named by the two constants below, so // inserting a line here moves them and the constants have to move too. // // 0 import json // 1 // 2 // 3 def load(text: str) -> object: // 4 ← four spaces, and where the completion is typed // 5 return json.loads(text) // 6 // 7 // 8 def helper() -> int: // 9 return 1 // 10 // 11 // 12 def first() -> int: // 13 return helper() // … // // The line the completion is typed on is deliberately blank on disk but // indented, so that the cursor can sit where a statement would. const realProject = "import json\n" + "\n" + "\n" + "def load(text: str) -> object:\n" + " \n" + " return json.loads(text)\n" + "\n" + "\n" + "def helper() -> int:\n" + " return 1\n" + "\n" + "\n" + "def first() -> int:\n" + " return helper()\n" + "\n" + "\n" + "def second() -> int:\n" + " return helper() + 1\n" // Where the fixture's interesting lines are, counted from zero. const ( completionLine = 4 helperLine = 8 helperColumn = 4 helperLineText = "def helper() -> int:" ) // startRealServer writes a project, opens its file, starts pylsp and waits for // it, in the order the command does. It skips the test when pylsp is missing. func startRealServer(t *testing.T) (root string, editor *app.App) { t.Helper() if testing.Short() { t.Skip("-short: not starting a language server") } server, err := lsp.FindServer(pythonlang.Profile().Server) if errors.Is(err, lsp.ErrServerNotFound) { t.Skipf("%s is not installed; %s", pythonlang.ServerCommand, pythonlang.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", pythonlang.ServerCommand, server, pythonlang.InstallHint) } root = t.TempDir() writeFile(t, filepath.Join(root, "pyproject.toml"), "[project]\nname = \"example\"\nversion = \"0.1.0\"\n") writeFile(t, filepath.Join(root, "main.py"), realProject) editor = newTestEditor(t) // 1. Open the file, exactly as main does — before there is any server. editor.Open(filepath.Join(root, "main.py")) // 2. Start the language server, exactly as main does — afterwards. 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 Python drawing on a simulated terminal, set up // the way the command sets it up. func newTestEditor(t *testing.T) *app.App { t.Helper() pythonlang.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 := pythonlang.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 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(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 { err := exec.Command(path, "--version").Run() return err == 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) } }