| 📦 Turbo Go 3d7798b k33g 9h ago | 1 | package golang_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/gdamore/tcell/v2" |
| 13 | |
| 14 | "rickub.com/turbo-editors/turbo-core/app" |
| 15 | "rickub.com/turbo-editors/turbo-core/buffer" |
| 16 | "rickub.com/turbo-editors/turbo-core/lsp" |
| 17 | "rickub.com/turbo-editors/turbo-core/syntax" |
| 18 | |
| 19 | "rickub.com/turbo-editors/turbo-go/internal/golang" |
| 20 | ) |
| 21 | |
| 22 | // TestCompletionEndToEndWithRealGopls drives the exact sequence the command |
| 23 | // does at start-up: open the files first, start the language server second, |
| 24 | // then ask for a completion. |
| 25 | // |
| 26 | // That order is the whole point. The editor's earlier version announced its |
| 27 | // open documents to a server that did not exist yet and never mentioned them |
| 28 | // again, so gopls answered every completion about a file it had never heard |
| 29 | // of — which looks, from the outside, exactly like completion not working. |
| 30 | // |
| 31 | // It lives in Turbo Go rather than in turbo-core because gopls is Turbo Go's |
| 32 | // server: the library has no language server of its own to be driven against. |
| 33 | // |
| 34 | // It skips itself when gopls is not installed, and under -short. |
| 35 | func TestCompletionEndToEndWithRealGopls(t *testing.T) { |
| 36 | if testing.Short() { |
| 37 | t.Skip("-short: not starting a language server") |
| 38 | } |
| 39 | if _, err := lsp.FindServer(golang.Profile().Server); errors.Is(err, lsp.ErrServerNotFound) { |
| 40 | t.Skipf("%s is not installed; %s", golang.ServerCommand, golang.InstallHint) |
| 41 | } |
| 42 | |
| 43 | root := t.TempDir() |
| 44 | writeFile(t, filepath.Join(root, "go.mod"), "module example.test\n\ngo 1.24\n") |
| 45 | |
| 46 | // The file on disk stops short of the dot. The text the completion is |
| 47 | // about gets *typed* below, so the answer can only come from what the |
| 48 | // editor told the server — which is the whole point of this test. A |
| 49 | // fixture already containing "strings." would be answered from disk, and |
| 50 | // would pass whether or not the editor said anything at all. |
| 51 | source := "package main\n\nimport \"strings\"\n\nfunc main() {\n\t\n}\n" |
| 52 | path := filepath.Join(root, "main.go") |
| 53 | writeFile(t, path, source) |
| 54 | |
| 55 | editor := newTestEditor(t) |
| 56 | |
| 57 | // 1. Open the file, exactly as main does — before there is any server. |
| 58 | editor.Open(path) |
| 59 | |
| 60 | // 2. Start the language server, exactly as main does — afterwards. |
| 61 | ctx, cancel := context.WithCancel(t.Context()) |
| 62 | defer cancel() |
| 63 | editor.StartLanguageServer(ctx, root) |
| 64 | t.Cleanup(func() { editor.Language().Stop(context.Background()) }) |
| 65 | |
| 66 | waitUntilReady(t, editor) |
| 67 | |
| 68 | // 3. Let the event loop notice the server is ready, as Run does on every |
| 69 | // turn. This is what announces the file that was already open. |
| 70 | editor.Tick() |
| 71 | |
| 72 | // 4. Type "strings." into the buffer, so that only the editor knows it is |
| 73 | // there, then ask for a completion. |
| 74 | view := editor.ActiveView() |
| 75 | view.Buffer().SetCursor(buffer.Position{Line: 5, Col: 1}) |
| 76 | typeText(editor, "strings.") |
| 77 | |
| 78 | if !editor.Completion().Visible() { |
| 79 | // Typing the dot asks for a completion by itself; ask again explicitly |
| 80 | // so a failure reports the status rather than the popup's absence. |
| 81 | editor.RequestCompletion() |
| 82 | } |
| 83 | if !editor.Completion().Visible() { |
| 84 | t.Fatalf("no completion list opened; the status bar says %q", editor.StatusBar().Message()) |
| 85 | } |
| 86 | if !completionOffers(editor, "Contains") { |
| 87 | t.Errorf("the list does not offer strings.Contains; it has %d entries", editor.Completion().Count()) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func TestTheEditorColoursGoSourceItOpens(t *testing.T) { |
| 92 | // The whole path in one test: Register taught the library about Go, the |
| 93 | // profile named the editor, and a .go file opened through the public API |
| 94 | // comes out coloured. |
| 95 | root := t.TempDir() |
| 96 | path := filepath.Join(root, "main.go") |
| 97 | writeFile(t, path, "package main\n") |
| 98 | |
| 99 | editor := newTestEditor(t) |
| 100 | editor.Open(path) |
| 101 | |
| 102 | if got := editor.ActiveView().Language(); got != golang.Language { |
| 103 | t.Fatalf("the view colours the file as %q, want %q", got, golang.Language) |
| 104 | } |
| 105 | if spans := syntax.Highlight(golang.Language, "package main"); len(spans[0]) == 0 { |
| 106 | t.Error("the registered Go scanner colours nothing") |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestTheEditorCallsItselfTurboGo(t *testing.T) { |
| 111 | editor := newTestEditor(t) |
| 112 | |
| 113 | if got := editor.Profile().Name; got != golang.Name { |
| 114 | t.Errorf("Profile().Name = %q, want %q", got, golang.Name) |
| 115 | } |
| 116 | if got := editor.Profile().ProjectDir(); got != ".turbo-go" { |
| 117 | t.Errorf("ProjectDir() = %q, want %q", got, ".turbo-go") |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // newTestEditor returns Turbo Go drawing on a simulated terminal, set up the |
| 122 | // way the command sets it up. |
| 123 | func newTestEditor(t *testing.T) *app.App { |
| 124 | t.Helper() |
| 125 | |
| 126 | golang.Register() |
| 127 | screen := tcell.NewSimulationScreen("UTF-8") |
| 128 | if err := screen.Init(); err != nil { |
| 129 | t.Fatalf("initialising the simulation screen: %v", err) |
| 130 | } |
| 131 | t.Cleanup(screen.Fini) |
| 132 | screen.SetSize(80, 24) |
| 133 | |
| 134 | // Never read the themes or snippets of whoever is running the tests. |
| 135 | p := golang.Profile() |
| 136 | t.Setenv(p.ThemeDirEnvVar(), t.TempDir()) |
| 137 | t.Setenv(p.SnippetDirEnvVar(), t.TempDir()) |
| 138 | |
| 139 | editor := app.New(screen, "turbo-classic", p) |
| 140 | editor.Render() |
| 141 | return editor |
| 142 | } |
| 143 | |
| 144 | // typeText sends a run of printable characters through the whole routing chain. |
| 145 | func typeText(editor *app.App, text string) { |
| 146 | for _, r := range text { |
| 147 | editor.Handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // completionOffers reports whether the open popup holds an entry starting with |
| 152 | // a label. |
| 153 | func completionOffers(editor *app.App, label string) bool { |
| 154 | for _, item := range editor.Completion().Matches() { |
| 155 | if strings.HasPrefix(item.Label, label) { |
| 156 | return true |
| 157 | } |
| 158 | } |
| 159 | return false |
| 160 | } |
| 161 | |
| 162 | // waitUntilReady blocks until the language server has finished starting. |
| 163 | func waitUntilReady(t *testing.T, editor *app.App) { |
| 164 | t.Helper() |
| 165 | |
| 166 | deadline := time.After(lsp.InitializeTimeout) |
| 167 | for !editor.Language().Ready() { |
| 168 | select { |
| 169 | case <-deadline: |
| 170 | t.Fatalf("the language server never became ready: %s", editor.Language().Status()) |
| 171 | case <-time.After(10 * time.Millisecond): |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // writeFile creates a file, making its directory first. |
| 177 | func writeFile(t *testing.T, path, content string) { |
| 178 | t.Helper() |
| 179 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 180 | t.Fatalf("creating %s: %v", filepath.Dir(path), err) |
| 181 | } |
| 182 | if err := os.WriteFile(path, []byte(content), 0o644); err != nil { |
| 183 | t.Fatalf("writing %s: %v", path, err) |
| 184 | } |
| 185 | } |