package app import ( "path/filepath" "strings" "testing" "github.com/gdamore/tcell/v2" "rickub.com/turbo-editors/turbo-core/lsp" "rickub.com/turbo-editors/turbo-core/ui" ) // sampleItems is a completion list shaped like one gopls returns for "fmt.". func sampleItems() []lsp.CompletionItem { return []lsp.CompletionItem{ {Label: "Println", Kind: lsp.KindFunction, Detail: "func(a ...any)"}, {Label: "Printf", Kind: lsp.KindFunction}, {Label: "Print", Kind: lsp.KindFunction}, {Label: "Sprintf", Kind: lsp.KindFunction}, {Label: "Errorf", Kind: lsp.KindFunction}, } } func TestShowFiltersOnThePrefix(t *testing.T) { var box CompletionBox box.Show(sampleItems(), "Print", 10, 5, testScreen()) if !box.Visible() { t.Fatal("Visible() = false after Show with matching items") } if got := box.Count(); got != 3 { t.Errorf("Count() = %d, want the three items starting with Print", got) } } func TestFilteringIgnoresCase(t *testing.T) { var box CompletionBox box.Show(sampleItems(), "prin", 10, 5, testScreen()) if got := box.Count(); got != 3 { t.Errorf("Count() = %d, want a lower-case prefix to match capitals", got) } } func TestShowWithNothingMatchingStaysClosed(t *testing.T) { var box CompletionBox box.Show(sampleItems(), "zzz", 10, 5, testScreen()) if box.Visible() { t.Error("a popup with no entries was opened; it must stay shut") } } func TestShowWithNoItemsStaysClosed(t *testing.T) { var box CompletionBox box.Show(nil, "", 10, 5, testScreen()) if box.Visible() { t.Error("a popup with no items was opened") } } func TestSetPrefixNarrowsThenClosesTheList(t *testing.T) { var box CompletionBox box.Show(sampleItems(), "", 10, 5, testScreen()) box.SetPrefix("Print") if got := box.Count(); got != 3 { t.Errorf("Count() = %d, want 3", got) } box.SetPrefix("Printl") if got := box.Count(); got != 1 { t.Errorf("Count() = %d, want 1", got) } box.SetPrefix("Printlz") if box.Visible() { t.Error("the popup stayed open with nothing left to show") } } func TestSetPrefixOnAClosedPopupDoesNothing(t *testing.T) { var box CompletionBox box.SetPrefix("anything") // must not panic if box.Visible() { t.Error("SetPrefix opened a popup that was never shown") } } func TestArrowsWalkTheListAndStopAtItsEnds(t *testing.T) { var box CompletionBox box.Show(sampleItems(), "", 10, 5, testScreen()) box.HandleKey(keyOf(tcell.KeyUp)) if item, _ := box.Selected(); item.Label != "Println" { t.Errorf("the selection is %q, want it to stay on the first item", item.Label) } for range 20 { box.HandleKey(keyOf(tcell.KeyDown)) } if item, _ := box.Selected(); item.Label != "Errorf" { t.Errorf("the selection is %q, want it to stop at the last item", item.Label) } } func TestEnterAcceptsTheSelectionAndClosesThePopup(t *testing.T) { var accepted lsp.CompletionItem var box CompletionBox box.OnAccept = func(item lsp.CompletionItem) { accepted = item } box.Show(sampleItems(), "", 10, 5, testScreen()) box.HandleKey(keyOf(tcell.KeyDown)) box.HandleKey(keyOf(tcell.KeyEnter)) if accepted.Label != "Printf" { t.Errorf("the accepted item is %q, want Printf", accepted.Label) } if box.Visible() { t.Error("the popup stayed open after an item was accepted") } } func TestTabAcceptsToo(t *testing.T) { accepted := "" var box CompletionBox box.OnAccept = func(item lsp.CompletionItem) { accepted = item.Label } box.Show(sampleItems(), "", 10, 5, testScreen()) box.HandleKey(keyOf(tcell.KeyTab)) if accepted != "Println" { t.Errorf("the accepted item is %q", accepted) } } func TestEscapeDismissesThePopupWithoutAccepting(t *testing.T) { accepted := false var box CompletionBox box.OnAccept = func(lsp.CompletionItem) { accepted = true } box.Show(sampleItems(), "", 10, 5, testScreen()) box.HandleKey(keyOf(tcell.KeyEscape)) if box.Visible() { t.Error("Escape did not close the popup") } if accepted { t.Error("Escape accepted an item") } } func TestTypingFallsThroughToTheEditor(t *testing.T) { var box CompletionBox box.Show(sampleItems(), "", 10, 5, testScreen()) if box.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'l', tcell.ModNone)) { t.Error("the popup swallowed a printable character; typing must keep reaching the editor") } } func TestAClosedPopupHandlesNothing(t *testing.T) { var box CompletionBox if box.HandleKey(keyOf(tcell.KeyDown)) { t.Error("a closed popup claimed a key") } if box.HandleMouse(tcell.NewEventMouse(1, 1, tcell.Button1, tcell.ModNone)) { t.Error("a closed popup claimed a click") } } func TestThePopupOpensBelowTheCursorAndFlipsAboveWhenItWouldNotFit(t *testing.T) { var box CompletionBox box.Show(sampleItems(), "", 10, 5, testScreen()) if got := box.Bounds().Y; got <= 5 { t.Errorf("the popup is at row %d, want it below the cursor at row 5", got) } box.Show(sampleItems(), "", 10, 22, testScreen()) if got := box.Bounds(); got.Bottom() > 24 { t.Errorf("the popup is %+v, want it kept on screen", got) } } func TestThePopupStaysOnScreenNearTheRightEdge(t *testing.T) { var box CompletionBox box.Show(sampleItems(), "", 78, 5, testScreen()) if got := box.Bounds(); got.Right() > 80 { t.Errorf("the popup is %+v, want it pulled back inside the screen", got) } } func TestClickingAnEntryAcceptsIt(t *testing.T) { accepted := "" var box CompletionBox box.OnAccept = func(item lsp.CompletionItem) { accepted = item.Label } box.Show(sampleItems(), "", 10, 5, testScreen()) bounds := box.Bounds() box.HandleMouse(tcell.NewEventMouse(bounds.X+2, bounds.Y+2, tcell.Button1, tcell.ModNone)) if accepted != "Printf" { t.Errorf("the accepted item is %q, want the second one", accepted) } } func TestClickingOutsideDismissesThePopup(t *testing.T) { var box CompletionBox box.Show(sampleItems(), "", 10, 5, testScreen()) box.HandleMouse(tcell.NewEventMouse(0, 0, tcell.Button1, tcell.ModNone)) if box.Visible() { t.Error("a click elsewhere did not dismiss the popup") } } func TestThePopupDrawsItsEntriesAndKinds(t *testing.T) { a, screen := newTestApp(t) a.NewFile() a.Completion().Show(sampleItems(), "", 10, 5, a.screenRect()) lines := render(t, a, screen) found := false for _, line := range lines { if strings.Contains(line, "Println") && strings.Contains(line, "func") { found = true } } if !found { t.Errorf("the popup does not show an entry with its kind:\n%s", strings.Join(lines[4:14], "\n")) } } func TestAcceptingACompletionReplacesTheHalfTypedWord(t *testing.T) { a, _ := newTestApp(t) a.NewFile() typeText(a, "fmt.Prin") a.acceptCompletion(lsp.CompletionItem{Label: "Println"}) if got := activeBuffer(t, a).Text(); got != "fmt.Println" { t.Errorf("the buffer holds %q, want %q", got, "fmt.Println") } } func TestAskingForCompletionWithoutALanguageServerSaysSo(t *testing.T) { a, _ := newTestApp(t) a.NewFile() a.RequestCompletion() if !strings.Contains(a.StatusBar().Message(), "LSP") { t.Errorf("the status bar says %q, want the language server's state", a.StatusBar().Message()) } if a.Completion().Visible() { t.Error("a popup opened with no language server to fill it") } } func TestTheEditorKeepsWorkingWithoutALanguageServer(t *testing.T) { a, _ := newTestApp(t) a.NewFile() // Every one of these goes through the Language wrapper, which must be a // no-op rather than a nil dereference when nothing is connected. typeText(a, "package main") a.Language().DidOpen("main.go", "package main") a.Language().DidChange("main.go", "package main\n") a.Language().DidSave("main.go", "package main\n") a.Language().DidClose("main.go") a.GoToDefinition() a.DescribeSymbol() if got := activeBuffer(t, a).Text(); got != "package main" { t.Errorf("the buffer holds %q, want editing to have carried on regardless", got) } if a.Language().Ready() { t.Error("Ready() = true with no server connected") } } func TestDiagnosticsAreRememberedPerFile(t *testing.T) { language := NewLanguage(testProfile().Server, testProfile().Name) language.receiveDiagnostics("main.go", []lsp.Diagnostic{ {Message: "undefined: foo", Severity: lsp.SeverityError}, {Message: "unused variable", Severity: lsp.SeverityWarning}, }) if got := len(language.Diagnostics("main.go")); got != 2 { t.Errorf("Diagnostics() returned %d, want 2", got) } if got := len(language.Diagnostics("other.go")); got != 0 { t.Errorf("Diagnostics() for another file returned %d, want none", got) } first, ok := language.FirstError("main.go") if !ok || first.Message != "undefined: foo" { t.Errorf("FirstError() = %+v, want the error rather than the warning", first) } } func TestClosingAFileForgetsItsDiagnostics(t *testing.T) { language := NewLanguage(testProfile().Server, testProfile().Name) language.receiveDiagnostics("main.go", []lsp.Diagnostic{{Message: "x", Severity: lsp.SeverityError}}) language.DidClose("main.go") if got := len(language.Diagnostics("main.go")); got != 0 { t.Errorf("Diagnostics() returned %d after the file was closed, want none", got) } } func TestAnErrorIsShownOnTheStatusBar(t *testing.T) { a, screen := newTestApp(t) path := "/tmp/turbo-go-test-main.go" a.NewFile() activeBuffer(t, a).SetPath(path) a.Language().receiveDiagnostics(path, []lsp.Diagnostic{ {Message: "undefined: foo", Severity: lsp.SeverityError}, }) lines := render(t, a, screen) if !strings.Contains(lines[23], "undefined: foo") { t.Errorf("the status bar is %q, want the error on it", lines[23]) } } // testScreen returns an 80×24 terminal rectangle for the popup to fit into. func testScreen() ui.Rect { return ui.Rect{W: 80, H: 24} } // keyOf builds a bare key press. func keyOf(key tcell.Key) *tcell.EventKey { return tcell.NewEventKey(key, 0, tcell.ModNone) } func TestFilesOpenedBeforeTheServerAreAnnouncedWhenItBecomesReady(t *testing.T) { // The regression this pins: main() opens the files named on the command // line and *then* starts gopls, so DidOpen at that moment reaches nothing. // Without a second announcement the server never learns the file exists, // and every completion comes back empty. a, _ := newTestApp(t) client, server := newFakeLanguage(t, a) path := filepath.Join(t.TempDir(), "main.go") writeTestFile(t, path, "package main\n") a.Open(path) // opened while a.language has no client at all if got := server.methodCount("textDocument/didOpen"); got != 0 { t.Fatalf("the server saw %d didOpen before it existed, want 0", got) } connectLanguage(a, client) // No event is delivered here on purpose. The announcement must not depend // on one: tcell's queue is bounded, PostEvent drops what does not fit, and // start-up is exactly when it is fullest. a.announceOpenDocuments() waitForMethod(t, server, "textDocument/didOpen") } func TestTheAnnouncementHappensOnceAndOnlyOnce(t *testing.T) { a, _ := newTestApp(t) client, server := newFakeLanguage(t, a) path := filepath.Join(t.TempDir(), "main.go") writeTestFile(t, path, "package main\n") a.Open(path) connectLanguage(a, client) for range 5 { a.announceOpenDocuments() } waitForMethod(t, server, "textDocument/didOpen") if got := server.methodCount("textDocument/didOpen"); got != 1 { t.Errorf("the server saw %d didOpen, want exactly 1", got) } } func TestNothingIsAnnouncedWhileThereIsNoServer(t *testing.T) { a, _ := newTestApp(t) a.NewFile() a.announceOpenDocuments() // must not mark the work as done if a.announced { t.Error("the editor recorded an announcement it never made") } } func TestAnEmptyCompletionExplainsItselfWhenTheFileDoesNotCompile(t *testing.T) { a, _ := newTestApp(t) path := "/tmp/turbo-go-test-clash.go" a.NewFile() activeBuffer(t, a).SetPath(path) a.Language().receiveDiagnostics(path, []lsp.Diagnostic{ {Message: "main redeclared in this block", Severity: lsp.SeverityError}, }) got := a.noCompletionsReason(path) if !strings.Contains(got, "main redeclared") { t.Errorf("the message is %q, want it to name the problem the server reported", got) } if got := a.noCompletionsReason("/tmp/some-other-file.go"); got != "No completions here" { t.Errorf("with nothing reported the message is %q", got) } } func TestTheStatusBoxExplainsAFileThatDoesNotCompile(t *testing.T) { a, _ := newTestApp(t) client, _ := newFakeLanguage(t, a) path := filepath.Join(t.TempDir(), "clash.go") writeTestFile(t, path, "package main\n") a.Open(path) connectLanguage(a, client) a.announceOpenDocuments() a.Language().receiveDiagnostics(path, []lsp.Diagnostic{ {Message: "main redeclared in this block", Severity: lsp.SeverityError}, }) report := strings.Join(a.languageReport(), "\n") if !strings.Contains(report, "clash.go") { t.Errorf("the report does not name the file:\n%s", report) } if !strings.Contains(report, "main redeclared") { t.Errorf("the report does not give the reason:\n%s", report) } if !strings.Contains(report, "package to compile") { t.Errorf("the report does not say what completion needs:\n%s", report) } } func TestTheStatusBoxSaysWhenAWindowHasNoFileYet(t *testing.T) { a, _ := newTestApp(t) client, _ := newFakeLanguage(t, a) a.NewFile() connectLanguage(a, client) report := strings.Join(a.languageReport(), "\n") if !strings.Contains(report, "no file yet") { t.Errorf("the report does not explain an untitled window:\n%s", report) } } func TestAnnouncingSkipsWindowsWithNoFile(t *testing.T) { a, _ := newTestApp(t) client, server := newFakeLanguage(t, a) a.NewFile() // untitled: it has no path to announce connectLanguage(a, client) a.announceOpenDocuments() if got := server.methodCount("textDocument/didOpen"); got != 0 { t.Errorf("the server saw %d didOpen for an untitled window, want 0", got) } } func TestTheCompletionPopupIsAnchoredWhereTheCursorReallyIs(t *testing.T) { a, screen := newTestApp(t) a.NewFile() typeText(a, "package main") render(t, a, screen) // The ground truth is where tcell put the terminal cursor. An anchor that // forgot the gutter, or the horizontal scroll, would not match it. wantX, wantY, visible := screen.GetCursor() if !visible { t.Fatal("the terminal cursor was not placed") } x, y := a.activeView().CursorScreenPosition() if x != wantX || y != wantY { t.Errorf("the popup anchors at (%d, %d), but the cursor is at (%d, %d)", x, y, wantX, wantY) } if x <= a.activeView().Bounds().X { t.Errorf("the anchor is at column %d, at or left of the view's edge %d — the gutter was not counted", x, a.activeView().Bounds().X) } }