package app import ( "os" "path/filepath" "strings" "testing" "github.com/gdamore/tcell/v2" "codeberg.org/turbo-editors/turbo-core/buffer" "codeberg.org/turbo-editors/turbo-core/editor" "codeberg.org/turbo-editors/turbo-core/lsp" "codeberg.org/turbo-editors/turbo-core/ui" ) // newCodeApp returns an editor with a file open, a fake language server // attached, and a second file on disk for a location to point into. func newCodeApp(t *testing.T) (*App, *fakeLSP, string) { t.Helper() project := t.TempDir() t.Chdir(project) t.Setenv(testProfile().SnippetDirEnvVar(), t.TempDir()) a, _ := newTestApp(t) client, server := newFakeLanguage(t, a) connectLanguage(a, client) writeTestFile(t, filepath.Join(project, "main.go"), "package main\n\nfunc main() {}\n") writeTestFile(t, filepath.Join(project, "other.go"), "package main\n\n// here it is\nfunc Other() {}\n") a.Open(filepath.Join(project, "main.go")) return a, server, project } // locationIn builds a location pointing at a line of a file in the project. func locationIn(project, name string, line int) lsp.Location { return lsp.Location{ URI: lsp.PathToURI(filepath.Join(project, name)), Range: lsp.Range{Start: lsp.Position{Line: line}}, } } // codeQuestions are the four requests that answer with places in the code: the // method each sends, and how to ask it of an app. var codeQuestions = []struct { method string ask func(*App) absent string }{ {"textDocument/definition", (*App).GoToDefinition, "No definition found"}, {"textDocument/typeDefinition", (*App).GoToTypeDefinition, "No type definition found"}, {"textDocument/implementation", (*App).FindImplementations, "No implementations found"}, {"textDocument/references", (*App).FindReferences, "No references found"}, } func TestOneResultJumpsStraightThere(t *testing.T) { // A list of one is a dialog nobody wants: it asks a question with one // answer already in it. for _, question := range codeQuestions { t.Run(question.method, func(t *testing.T) { a, server, project := newCodeApp(t) server.setAnswer(t, question.method, []lsp.Location{locationIn(project, "other.go", 3)}) question.ask(a) if a.Modals() != 0 { t.Errorf("a single result opened a dialog") } if got := activeBuffer(t, a).Path(); filepath.Base(got) != "other.go" { t.Errorf("the front window holds %q, want other.go", got) } if got := activeBuffer(t, a).Cursor().Line; got != 3 { t.Errorf("the cursor is on line %d, want 3", got) } }) } } func TestSeveralResultsOfferTheChoice(t *testing.T) { // The defect this whole step exists for: GoToDefinition took locations[0] // and threw the rest away, so an interface with four implementations sent // you to one of them, chosen by the server's ordering. for _, question := range codeQuestions { t.Run(question.method, func(t *testing.T) { a, server, project := newCodeApp(t) server.setAnswer(t, question.method, []lsp.Location{ locationIn(project, "other.go", 3), locationIn(project, "main.go", 2), }) question.ask(a) if a.Modals() != 1 { t.Fatalf("Modals() = %d, want the list of places", a.Modals()) } }) } } func TestChoosingAPlaceGoesToIt(t *testing.T) { a, server, project := newCodeApp(t) server.setAnswer(t, "textDocument/references", []lsp.Location{ locationIn(project, "main.go", 2), locationIn(project, "other.go", 3), }) a.FindReferences() press(a, tcell.KeyDown, 0, tcell.ModNone) press(a, tcell.KeyEnter, 0, tcell.ModNone) if got := activeBuffer(t, a).Path(); filepath.Base(got) != "other.go" { t.Errorf("the front window holds %q, want the second entry's file", got) } if got := activeBuffer(t, a).Cursor().Line; got != 3 { t.Errorf("the cursor is on line %d, want 3", got) } } func TestNoResultsSaysSoInTheQuestionsOwnWords(t *testing.T) { // "Nothing found" for every question would leave a user unsure which // question was even asked. for _, question := range codeQuestions { t.Run(question.method, func(t *testing.T) { a, _, _ := newCodeApp(t) question.ask(a) if got := a.StatusBar().Message(); got != question.absent { t.Errorf("the status bar says %q, want %q", got, question.absent) } }) } } func TestAServerThatIsNotReadySaysThatRatherThanNothingFound(t *testing.T) { // The most confusing way completion fails, inherited here for free if // nobody tells the two apart: a server still indexing answers nothing, and // "no references" is a lie that makes a user stop looking. a, _ := newTestApp(t) writeTestFile(t, filepath.Join(t.TempDir(), "main.go"), "package main\n") a.NewFile() a.FindReferences() if got := a.StatusBar().Message(); strings.Contains(got, "No references found") { t.Errorf("the status bar says %q; the server was never connected", got) } } func TestTheListShowsTheLineSoTheEntriesCanBeToldApart(t *testing.T) { // Twelve entries reading "handler.go:42" say nothing about which one // anybody wants. a, _, project := newCodeApp(t) labels := a.locationLabels([]lsp.Location{locationIn(project, "other.go", 2)}) if len(labels) != 1 { t.Fatalf("got %d labels", len(labels)) } if !strings.Contains(labels[0], "other.go:3") { t.Errorf("label = %q, want the file and the one-based line", labels[0]) } if !strings.Contains(labels[0], "here it is") { t.Errorf("label = %q, want the text of the line", labels[0]) } } func TestTheListPrefersAnOpenWindowOverTheDisk(t *testing.T) { // A file edited and not saved would otherwise be listed with text it no // longer has, beside line numbers that follow the edits. a, _, project := newCodeApp(t) activeBuffer(t, a).SetText("package main\n\nfunc changed() {}\n") labels := a.locationLabels([]lsp.Location{locationIn(project, "main.go", 2)}) if !strings.Contains(labels[0], "func changed()") { t.Errorf("label = %q, want the unsaved text from the window", labels[0]) } } func TestAFileThatCannotBeReadStillGetsALine(t *testing.T) { // A dialog that refuses to open because one of forty files moved is worse // than one with a bare line number in it. a, _, project := newCodeApp(t) gone := filepath.Join(project, "gone.go") if err := os.Remove(filepath.Join(project, "other.go")); err != nil { t.Fatalf("cannot remove the file: %v", err) } labels := a.locationLabels([]lsp.Location{ {URI: lsp.PathToURI(gone), Range: lsp.Range{Start: lsp.Position{Line: 7}}}, locationIn(project, "other.go", 1), }) if len(labels) != 2 { t.Fatalf("got %d labels, want one per location", len(labels)) } if !strings.Contains(labels[0], "gone.go:8") { t.Errorf("label = %q, want the file and line even with nothing to read", labels[0]) } } // menuLabelsOf returns the plain labels of one menu on the bar. func menuLabelsOf(t *testing.T, a *App, name string) []string { t.Helper() for _, menu := range a.menu.Menus() { if ui.PlainLabel(menu.Label) == name { if menu.OnOpen != nil { menu.OnOpen() } return labels(menu.Items) } } t.Fatalf("there is no %q menu on the bar; the bar is %v", name, barLabels(a)) return nil } func TestTheCodeMenuHoldsEverythingAskedOfTheServer(t *testing.T) { a, _ := newTestApp(t) got := menuLabelsOf(t, a, "Code") for _, want := range []string{ "Describe symbol", "Go to definition", "Go to type definition", "Find implementations…", "Find references…", "Symbol in file…", "Symbol in project…", "Problems…", } { if !containsString(got, want) { t.Errorf("the Code menu has no %q: %v", want, got) } } } func TestTheTwoMovedItemsLeftTheMenusTheyWereIn(t *testing.T) { // Listed twice is worse than moved: two paths to one action, and a reader // who finds one of them stops looking for the menu that has the rest. a, _ := newTestApp(t) if got := menuLabelsOf(t, a, "Run"); containsString(got, "Describe symbol") { t.Errorf("Describe symbol is still in Run: %v", got) } if got := menuLabelsOf(t, a, "Search"); containsString(got, "Go to definition") { t.Errorf("Go to definition is still in Search: %v", got) } } func TestTheMovedItemsKeptTheirKeys(t *testing.T) { // Where an item is listed may change; what a user's fingers do may not. a, _ := newTestApp(t) items := menuLabelsOf(t, a, "Code") _ = items shortcuts := map[string]string{} for _, menu := range a.menu.Menus() { if ui.PlainLabel(menu.Label) != "Code" { continue } for _, item := range menu.Items { shortcuts[ui.PlainLabel(item.Label)] = item.Shortcut } } for label, want := range map[string]string{ "Describe symbol": "F1", "Go to definition": "F12", "Find references…": "Shift-F12", "Symbol in project…": "Ctrl-T", "Symbol in file…": "", } { if shortcuts[label] != want { t.Errorf("%q shows %q, want %q", label, shortcuts[label], want) } } } func TestEveryCodeItemNeedsAFileExceptTheProjectWideOnes(t *testing.T) { // Asking about the symbol under the cursor with no cursor is not a // question. Searching the project, and listing its problems, are. a, _ := newTestApp(t) needsFile := map[string]bool{ "Describe symbol": true, "Go to definition": true, "Go to type definition": true, "Find implementations…": true, "Find references…": true, "Symbol in file…": true, "Symbol in project…": false, "Problems…": false, } for _, menu := range a.menu.Menus() { if ui.PlainLabel(menu.Label) != "Code" { continue } for _, item := range menu.Items { if item.Separator { continue } label := ui.PlainLabel(item.Label) available := item.Enabled == nil || item.Enabled() if want, known := needsFile[label]; known && available == want { t.Errorf("%q is available=%v with no window open, want %v", label, available, !want) } } } } func TestSymbolInFileListsWhatTheFileDeclares(t *testing.T) { a, server, _ := newCodeApp(t) server.setAnswer(t, "textDocument/documentSymbol", []map[string]any{{ "name": "main", "kind": 12, "range": map[string]any{"start": map[string]int{"line": 2, "character": 0}}, "selectionRange": map[string]any{"start": map[string]int{"line": 2, "character": 5}}, }}) a.SymbolInFile() if a.Modals() != 1 { t.Fatalf("Modals() = %d, want the list of symbols", a.Modals()) } } func TestASingleSymbolStillGetsTheList(t *testing.T) { // Unlike a single definition: one match for a name is an answer worth // reading — it says which thing has that name — where a single definition // is somewhere you simply wanted to be taken. a, server, project := newCodeApp(t) server.setAnswer(t, "workspace/symbol", []map[string]any{{ "name": "Other", "kind": 12, "containerName": "main", "location": map[string]any{"uri": lsp.PathToURI(filepath.Join(project, "other.go"))}, }}) a.SymbolInProject() typeInto(a, "Other") press(a, tcell.KeyEnter, 0, tcell.ModNone) if a.Modals() != 1 { t.Fatalf("Modals() = %d, want the list of matches", a.Modals()) } } func TestAnEmptySymbolQueryAsksForNothing(t *testing.T) { // Some servers answer an empty query with the whole project and some with // nothing. Neither is what was meant. a, server, _ := newCodeApp(t) a.SymbolInProject() press(a, tcell.KeyEnter, 0, tcell.ModNone) if got := server.methodCount("workspace/symbol"); got != 0 { t.Errorf("the editor asked %d times with an empty query", got) } if got := a.StatusBar().Message(); got != "Nothing to look for" { t.Errorf("the status bar says %q", got) } } func TestProblemsListsEveryFileTheServerSpokeAbout(t *testing.T) { // The file with the error is very often not the file being edited, which // is exactly when a list is worth having. a, _, project := newCodeApp(t) a.language.receiveDiagnostics(filepath.Join(project, "other.go"), []lsp.Diagnostic{ {Message: "undefined: x", Severity: lsp.SeverityError, Range: lsp.Range{Start: lsp.Position{Line: 3}}}, }) a.language.receiveDiagnostics(filepath.Join(project, "main.go"), []lsp.Diagnostic{ {Message: "unused import", Severity: lsp.SeverityWarning, Range: lsp.Range{Start: lsp.Position{Line: 1}}}, }) problems := a.language.AllDiagnostics() if len(problems) != 2 { t.Fatalf("got %d problems, want one per file", len(problems)) } if filepath.Base(problems[0].Path) != "main.go" { t.Errorf("the list starts with %q, want it sorted by file", problems[0].Path) } } func TestProblemsWithNothingToSaySaysWhichNothing(t *testing.T) { // "No problems" and "the server has not looked yet" are the same empty // list and very different news. a, _ := newTestApp(t) a.ShowProblems() if got := a.StatusBar().Message(); got == "No problems reported" { t.Errorf("the status bar says %q with no server connected", got) } } func TestAProblemLabelKeepsItsWholeMessage(t *testing.T) { // A borrow-checker error runs to a paragraph, and the part that names the // variable is not the first part. long := "cannot borrow `config` as mutable more than once at a time\nsecond mutable borrow occurs here" label := problemLabel(FileDiagnostic{ Path: "/tmp/p/main.rs", Diagnostic: lsp.Diagnostic{Message: long, Severity: lsp.SeverityError, Range: lsp.Range{Start: lsp.Position{Line: 9}}}, }) if !strings.Contains(label, "second mutable borrow occurs here") { t.Errorf("label = %q, want the whole message", label) } if strings.Contains(label, "\n") { t.Errorf("label = %q, want it on one line", label) } if !strings.Contains(label, "main.rs:10") { t.Errorf("label = %q, want the file and the one-based line", label) } } func TestTheCodeKeysReachTheirActions(t *testing.T) { // A menu that shows a key which does nothing is a menu that lies. F1 and // F12 were already wired; Shift-F12 and Ctrl-T are new, and Shift-F12 sits // in front of F12 in the same switch — the order matters. a, server, project := newCodeApp(t) server.setAnswer(t, "textDocument/references", []lsp.Location{ locationIn(project, "other.go", 3), locationIn(project, "main.go", 1), }) press(a, tcell.KeyF12, 0, tcell.ModShift) if a.Modals() != 1 { t.Fatalf("Shift-F12 opened %d modals, want the references list", a.Modals()) } press(a, tcell.KeyEscape, 0, tcell.ModNone) press(a, tcell.KeyCtrlT, 0, tcell.ModNone) if a.Modals() != 1 { t.Errorf("Ctrl-T opened %d modals, want the symbol prompt", a.Modals()) } } func TestShiftF12DoesNotAlsoGoToTheDefinition(t *testing.T) { // The two cases share a key and are told apart by the modifier alone. With // them the other way round, Shift-F12 would jump instead of listing and // nothing would look broken. a, server, project := newCodeApp(t) server.setAnswer(t, "textDocument/definition", []lsp.Location{locationIn(project, "other.go", 3)}) server.setAnswer(t, "textDocument/references", []lsp.Location{}) press(a, tcell.KeyF12, 0, tcell.ModShift) if got := filepath.Base(activeBuffer(t, a).Path()); got != "main.go" { t.Errorf("Shift-F12 moved to %q; it asked for the definition", got) } if got := a.StatusBar().Message(); got != "No references found" { t.Errorf("the status bar says %q, want the references answer", got) } } func TestALineWithSeveralProblemsIsMarkedWithItsWorst(t *testing.T) { // The gutter has one column, and a line that is both an error and a hint // is a line you want to know is an error. marks := marksFor([]lsp.Diagnostic{ {Severity: lsp.SeverityHint, Range: lsp.Range{Start: lsp.Position{Line: 4}}}, {Severity: lsp.SeverityError, Range: lsp.Range{Start: lsp.Position{Line: 4}}}, {Severity: lsp.SeverityWarning, Range: lsp.Range{Start: lsp.Position{Line: 4}}}, }) if got := marks[4]; got != editor.MarkError { t.Errorf("line 5 is marked %v, want the error", got) } } func TestADiagnosticWithNoSeverityIsAnError(t *testing.T) { // The specification leaves it to the client, and a problem nobody graded // is not one to draw quietly. marks := marksFor([]lsp.Diagnostic{{Range: lsp.Range{Start: lsp.Position{Line: 0}}}}) if got := marks[0]; got != editor.MarkError { t.Errorf("an ungraded diagnostic is marked %v, want the error", got) } } func TestAFileWithNoProblemsHasNoMarks(t *testing.T) { if got := marksFor(nil); got != nil { t.Errorf("marksFor(nil) = %v, want no map at all", got) } } func TestTheMarksFollowTheDiagnosticsToTheWindow(t *testing.T) { // The wiring: diagnostics arrive off the read loop, and the window in // front has to end up showing them without anyone asking it to. a, _, project := newCodeApp(t) a.language.receiveDiagnostics(filepath.Join(project, "main.go"), []lsp.Diagnostic{ {Message: "undefined: x", Severity: lsp.SeverityError, Range: lsp.Range{Start: lsp.Position{Line: 2}}}, }) a.Tick() marks := a.ActiveView().Marks() if got := marks[2]; got != editor.MarkError { t.Errorf("line 3 of the front window is marked %v, want the error the server reported", got) } } func TestTheEditMenuHoldsTheLineCommands(t *testing.T) { a, _ := newTestApp(t) shortcuts := map[string]string{} for _, menu := range a.menu.Menus() { if ui.PlainLabel(menu.Label) != "Edit" { continue } for _, item := range menu.Items { shortcuts[ui.PlainLabel(item.Label)] = item.Shortcut } } for label, want := range map[string]string{ "Insert line": "Ctrl-N", "Delete line": "Ctrl-Y", "Redo": "Ctrl-R", "Undo": "Ctrl-Z", } { got, listed := shortcuts[label] if !listed { t.Errorf("the Edit menu has no %q", label) continue } if got != want { t.Errorf("%q shows %q, want %q", label, got, want) } } } func TestTheLineCommandsReachTheBuffer(t *testing.T) { a, _ := newTestApp(t) a.NewFile() activeBuffer(t, a).SetText("one\ntwo\nthree\n") activeBuffer(t, a).SetCursor(buffer.Position{Line: 1}) a.InsertLine() if got := activeBuffer(t, a).Text(); got != "one\n\ntwo\nthree\n" { t.Fatalf("after InsertLine Text() = %q", got) } a.DeleteLine() if got := activeBuffer(t, a).Text(); got != "one\n\nthree\n" { t.Errorf("after DeleteLine Text() = %q", got) } }