package main import ( "errors" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" ) type memoryTextClipboard struct { text string written string readErr error writeErr error } func TestVimEditorUsesSharedConfiguredNavigation(t *testing.T) { editor := newTextEditor("first\nsecond", true) editor.keys.Navigation.Down = []string{"ctrl+j"} editor.keys.Navigation.Up = []string{"ctrl+k"} editor.handleKey(runeKey("j"), true) if editor.Cursor != 0 { t.Fatalf("removed default j moved cursor to %d", editor.Cursor) } editor.handleKey(tea.KeyMsg{Type: tea.KeyCtrlJ}, true) if editor.Cursor != len([]rune("first\n")) { t.Fatalf("configured ctrl+j moved cursor to %d", editor.Cursor) } } func (c *memoryTextClipboard) ReadText() (string, error) { return c.text, c.readErr } func (c *memoryTextClipboard) WriteText(value string) error { c.written = value return c.writeErr } func TestVimTextEditorWordMotions(t *testing.T) { const value = "one,two THREE four\nlast" editor := newTextEditor(value, true) editor.handleKey(runeKey("e"), true) if editor.Cursor != 2 { t.Fatalf("e cursor = %d, want 2", editor.Cursor) } editor.Cursor = 0 editor.handleKey(runeKey("E"), true) if editor.Cursor != 6 { t.Fatalf("E cursor = %d, want 6", editor.Cursor) } editor.Cursor = 0 editor.handleKey(runeKey("w"), true) if editor.Cursor != 3 { t.Fatalf("w cursor = %d, want punctuation at 3", editor.Cursor) } editor.Cursor = 0 editor.handleKey(runeKey("W"), true) if editor.Cursor != 9 { t.Fatalf("W cursor = %d, want 9", editor.Cursor) } editor.Cursor = 18 editor.handleKey(runeKey("b"), true) if editor.Cursor != 15 { t.Fatalf("b cursor = %d, want 15", editor.Cursor) } editor.Cursor = 18 editor.handleKey(runeKey("B"), true) if editor.Cursor != 15 { t.Fatalf("B cursor = %d, want 15", editor.Cursor) } } func TestVimTextEditorWordEndMotionsRepeat(t *testing.T) { const value = "one,two THREE four\nlast" editor := newTextEditor(value, true) for index, want := range []int{2, 3, 6, 13, 18, 23} { editor.handleKey(runeKey("e"), true) if editor.Cursor != want { t.Fatalf("e repetition %d cursor = %d, want %d", index+1, editor.Cursor, want) } } editor.Cursor = 0 for index, want := range []int{6, 13, 18, 23} { editor.handleKey(runeKey("E"), true) if editor.Cursor != want { t.Fatalf("E repetition %d cursor = %d, want %d", index+1, editor.Cursor, want) } } } func TestVimTextEditorWordEndRepeatsAcrossSoftWraps(t *testing.T) { editor := newTextEditor("abcdefghijklmnopqrstuv", true) for index, want := range []int{9, 19, 21} { editor.handleKeyAtWidth(runeKey("e"), true, 10) if editor.Cursor != want { t.Fatalf("soft-wrap e repetition %d cursor = %d, want %d", index+1, editor.Cursor, want) } } } func TestVimTextEditorFindAndRepeat(t *testing.T) { editor := newTextEditor("foo bar foo", true) editor.handleKey(runeKey("f"), true) editor.handleKey(runeKey("o"), true) if editor.Cursor != 1 { t.Fatalf("fo cursor = %d, want 1", editor.Cursor) } editor.handleKey(runeKey(";"), true) if editor.Cursor != 2 { t.Fatalf("first ; cursor = %d, want 2", editor.Cursor) } editor.handleKey(runeKey(";"), true) if editor.Cursor != 9 { t.Fatalf("second ; cursor = %d, want 9", editor.Cursor) } editor.handleKey(runeKey(","), true) if editor.Cursor != 2 { t.Fatalf(", cursor = %d, want 2", editor.Cursor) } } func TestVimTextEditorTillRepeatAdvancesPastPreviousTarget(t *testing.T) { editor := newTextEditor("xxa-b-c-d", true) editor.handleKey(runeKey("t"), true) editor.handleKey(runeKey("-"), true) if editor.Cursor != 2 { t.Fatalf("t- cursor = %d, want 2", editor.Cursor) } editor.handleKey(runeKey(";"), true) if editor.Cursor != 4 { t.Fatalf("; cursor = %d, want 4", editor.Cursor) } } func TestVimTextEditorSwitchesModesAndInserts(t *testing.T) { editor := newTextEditor("task", true) if editor.Mode != textEditorNormal { t.Fatalf("initial mode = %s", editor.Mode) } editor.handleKey(runeKey("i"), true) editor.handleKey(runeKey("x"), true) if editor.Text != "xtask" || editor.Mode != textEditorInsert { t.Fatalf("insert result = %q mode=%s", editor.Text, editor.Mode) } editor.handleKey(tea.KeyMsg{Type: tea.KeyEsc}, true) if editor.Mode != textEditorNormal { t.Fatalf("escape mode = %s", editor.Mode) } if editor.Cursor != 0 { t.Fatalf("escape cursor = %d, want 0", editor.Cursor) } } func TestVimTextEditorSubstituteDeletesCharacterAndEntersInsert(t *testing.T) { editor := newTextEditor("abc", true) editor.Cursor = 1 editor.handleKey(runeKey("s"), true) if editor.Text != "ac" || editor.Cursor != 1 || editor.Mode != textEditorInsert { t.Fatalf("substitute result = %#v", editor) } editor.handleKey(runeKey("X"), true) if editor.Text != "aXc" { t.Fatalf("substitute insertion result = %q", editor.Text) } } func TestVimTextEditorNormalMotionsStayOnCharactersWithinLine(t *testing.T) { editor := newTextEditor("ab\n cd\n", true) editor.Cursor = 1 editor.handleKey(runeKey("l"), true) if editor.Cursor != 1 { t.Fatalf("l crossed line at cursor %d", editor.Cursor) } editor.handleKey(runeKey("x"), true) if editor.Text != "a\n cd\n" { t.Fatalf("x result = %q", editor.Text) } editor.handleKey(runeKey("x"), true) if editor.Text != "a\n cd\n" { t.Fatalf("x deleted newline: %q", editor.Text) } editor.Cursor = 4 editor.handleKey(runeKey("j"), true) if editor.Cursor != 7 { t.Fatalf("j cursor = %d, want empty last line at 7", editor.Cursor) } editor.handleKey(runeKey("h"), true) if editor.Cursor != 7 { t.Fatalf("h crossed from empty line at cursor %d", editor.Cursor) } editor.handleKey(runeKey("X"), true) if editor.Text != "a\n cd\n" { t.Fatalf("X deleted newline: %q", editor.Text) } } func TestVimTextEditorDocumentMotionsUseFirstNonBlank(t *testing.T) { editor := newTextEditor(" first\n last", true) editor.Cursor = 10 editor.handleKey(runeKey("g"), true) editor.handleKey(runeKey("g"), true) if editor.Cursor != 2 { t.Fatalf("gg cursor = %d, want 2", editor.Cursor) } editor.handleKey(runeKey("G"), true) if editor.Cursor != 11 { t.Fatalf("G cursor = %d, want 11", editor.Cursor) } } func TestEditorHighlightsCurrentLineWithoutChangingLayout(t *testing.T) { editor := newTextEditor("short\nsecond", true) lines := renderTextEditor(editor, 20, true) if len(lines) != 2 || strings.Contains(joinEditorLines(lines), "█") { t.Fatalf("cursor changed editor layout: %#v", lines) } if width := ansi.StringWidth(lines[0].text); width != 20 { t.Fatalf("active line width = %d, want 20", width) } if width := ansi.StringWidth(lines[1].text); width != len("second") { t.Fatalf("inactive line width = %d", width) } if !lines[0].active || lines[1].active { t.Fatalf("active rows = %#v", lines) } editor.handleKey(runeKey("j"), true) lines = renderTextEditor(editor, 20, true) if width := ansi.StringWidth(lines[0].text); width != len("short") { t.Fatalf("old line remained highlighted at width %d", width) } if width := ansi.StringWidth(lines[1].text); width != 20 { t.Fatalf("new active line width = %d, want 20", width) } } func TestEditorKeepsWrappedRowsAndContextRailsVisible(t *testing.T) { editor := newTextEditor("abcdefghijklmnopqrstuv", true) editor.Cursor = 16 rendered := renderTextEditor(editor, 10, true) if len(rendered) != 3 { t.Fatalf("wrapped rows = %d, want 3", len(rendered)) } app := App{prEditField: prEditBodyField} app.prEditEditors[prEditBodyField] = editor rows := app.prEditFieldLines("description", prEditBodyField, 14) if len(rows) != 4 { t.Fatalf("field rows = %#v", rows) } want := []string{"│ abcdefghij", "│ klmnopqrst", "│ uv"} for index, expected := range want { plain := strings.TrimRight(ansi.Strip(rows[index+1]), " ") if plain != expected { t.Fatalf("wrapped row %d = %q, want %q", index, plain, expected) } if ansi.StringWidth(rows[index+1]) > 12 { t.Fatalf("wrapped row %d is too wide: %d", index, ansi.StringWidth(rows[index+1])) } } } func TestVimEditorTreatsSoftWrapsAsVisualLinesWithoutChangingText(t *testing.T) { const value = "abcdefghijklmnopqrstuv" editor := newTextEditor(value, true) editor.Cursor = 2 editor.handleKeyAtWidth(runeKey("j"), true, 10) if editor.Cursor != 12 { t.Fatalf("first visual j cursor = %d, want 12", editor.Cursor) } editor.handleKeyAtWidth(runeKey("$"), true, 10) if editor.Cursor != 19 { t.Fatalf("visual $ cursor = %d, want 19", editor.Cursor) } editor.handleKeyAtWidth(runeKey("l"), true, 10) if editor.Cursor != 19 { t.Fatalf("l crossed soft wrap at cursor %d", editor.Cursor) } editor.handleKeyAtWidth(runeKey("j"), true, 10) if editor.Cursor != 21 { t.Fatalf("second visual j cursor = %d, want 21", editor.Cursor) } editor.handleKeyAtWidth(runeKey("0"), true, 10) if editor.Cursor != 20 { t.Fatalf("visual 0 cursor = %d, want 20", editor.Cursor) } editor.handleKeyAtWidth(runeKey("k"), true, 10) if editor.Cursor != 10 { t.Fatalf("visual k cursor = %d, want 10", editor.Cursor) } if editor.Text != value { t.Fatalf("visual navigation changed stored text: %q", editor.Text) } rendered := renderTextEditor(editor, 10, true) activeRows := 0 for _, line := range rendered { if line.active { activeRows++ } } if activeRows != 1 { t.Fatalf("active visual rows = %d, want 1", activeRows) } } func TestEditorDoesNotAddPhantomRowAtExactSoftWrap(t *testing.T) { editor := newTextEditor("abcdefghijklmnopqrst", true) editor.Cursor = len([]rune(editor.Text)) rendered := renderTextEditor(editor, 10, true) if len(rendered) != 2 { t.Fatalf("rendered rows = %d, want 2: %#v", len(rendered), rendered) } if !rendered[1].active { t.Fatalf("last wrapped row is not active: %#v", rendered) } } func TestEditorLineEndingNormalizationRemovesTerminalCarriageReturns(t *testing.T) { const mixed = "first\nsecond\r\nthird\rfourth" normalized := normalizeLineEndings(mixed) if normalized != "first\nsecond\nthird\nfourth" { t.Fatalf("normalized text = %q", normalized) } editor := newTextEditor(normalized, true) for _, line := range renderTextEditor(editor, 80, true) { if strings.ContainsRune(line.text, '\r') { t.Fatalf("rendered terminal carriage return in %#v", line) } } } func TestVimVisualModeDeletesAcrossSoftWrappedRows(t *testing.T) { editor := newTextEditor("abcdefghijklmnopqrstuv", true) editor.Cursor = 2 editor.handleKeyAtWidth(runeKey("v"), true, 10) editor.handleKeyAtWidth(runeKey("j"), true, 10) editor.handleKeyAtWidth(runeKey("l"), true, 10) if editor.Mode != textEditorVisual || editor.Cursor != 13 { t.Fatalf("visual selection mode=%s cursor=%d", editor.Mode, editor.Cursor) } editor.handleKeyAtWidth(runeKey("d"), true, 10) if editor.Text != "abopqrstuv" { t.Fatalf("visual delete result = %q", editor.Text) } if editor.Mode != textEditorNormal || editor.Cursor != 2 { t.Fatalf("after visual delete mode=%s cursor=%d", editor.Mode, editor.Cursor) } } func TestVimVisualYankAndPasteUseSystemClipboardAbstraction(t *testing.T) { clipboard := &memoryTextClipboard{} editor := newTextEditor("abcdef", true) editor.clipboard = clipboard editor.handleKey(runeKey("v"), true) editor.handleKey(runeKey("l"), true) editor.handleKey(runeKey("l"), true) editor.handleKey(runeKey("y"), true) if clipboard.written != "abc" { t.Fatalf("yanked text = %q, want abc", clipboard.written) } if editor.Text != "abcdef" || editor.Mode != textEditorNormal { t.Fatalf("yank changed editor: %#v", editor) } clipboard.text = "XY" editor.Cursor = 0 editor.handleKey(runeKey("p"), true) if editor.Text != "aXYbcdef" || editor.Cursor != 2 { t.Fatalf("paste result text=%q cursor=%d", editor.Text, editor.Cursor) } } func TestVimVisualLineYankCollapsesSoftWraps(t *testing.T) { clipboard := &memoryTextClipboard{} editor := newTextEditor("abcdefghijklmnopqrstuv", true) editor.clipboard = clipboard editor.Cursor = 2 editor.handleKeyAtWidth(runeKey("V"), true, 10) editor.handleKeyAtWidth(runeKey("j"), true, 10) editor.handleKeyAtWidth(runeKey("y"), true, 10) if clipboard.written != "abcdefghijklmnopqrst" { t.Fatalf("linewise soft-wrap yank = %q", clipboard.written) } if strings.ContainsRune(clipboard.written, '\n') { t.Fatalf("soft-wrap yank introduced newline: %q", clipboard.written) } } func TestVimVisualFindAcceptsArbitraryTarget(t *testing.T) { editor := newTextEditor("one x two", true) editor.handleKey(runeKey("v"), true) editor.handleKey(runeKey("f"), true) editor.handleKey(runeKey("x"), true) if editor.Mode != textEditorVisual || editor.Cursor != 4 { t.Fatalf("visual fx mode=%s cursor=%d", editor.Mode, editor.Cursor) } } func TestVimClipboardErrorsRemainVisibleAndPreserveSelection(t *testing.T) { clipboard := &memoryTextClipboard{writeErr: errors.New("clipboard failed")} editor := newTextEditor("abc", true) editor.clipboard = clipboard editor.handleKey(runeKey("v"), true) editor.handleKey(runeKey("y"), true) if editor.err == nil || !strings.Contains(editor.err.Error(), "clipboard failed") { t.Fatalf("clipboard error = %v", editor.err) } if editor.Mode != textEditorVisual || editor.Text != "abc" { t.Fatalf("failed yank changed selection: %#v", editor) } } func TestEditorRendersModeSpecificCursorsAndVisualSelection(t *testing.T) { editor := newTextEditor("abc", true) normal := renderTextEditor(editor, 10, true) if !strings.Contains(normal[0].text, "\x1b[7m") || ansi.Strip(normal[0].text) != "abc " { t.Fatalf("normal cursor rendering = %q", normal[0].text) } editor.handleKey(runeKey("i"), true) insert := renderTextEditor(editor, 10, true) if !strings.Contains(insert[0].text, "\x1b[4m") { t.Fatalf("insert cursor rendering = %q", insert[0].text) } if width := ansi.StringWidth(insert[0].text); width != 10 { t.Fatalf("insert cursor changed row width to %d", width) } if plain := strings.TrimRight(ansi.Strip(insert[0].text), " "); plain != "abc" { t.Fatalf("insert cursor hid or shifted text: %q", plain) } editor.handleKey(tea.KeyMsg{Type: tea.KeyEsc}, true) editor.handleKey(runeKey("v"), true) editor.handleKey(runeKey("l"), true) visual := renderTextEditor(editor, 10, true) if editor.modeLabel() != "VISUAL" || !strings.Contains(visual[0].text, "\x1b[7m") { t.Fatalf("visual rendering mode=%s text=%q", editor.modeLabel(), visual[0].text) } } func TestHardwareInsertCursorDoesNotAlterRenderedText(t *testing.T) { editor := newTextEditor("abc", true) editor.hardwareCursor = true editor.handleKey(runeKey("i"), true) rendered := renderTextEditor(editor, 10, true) if plain := strings.TrimRight(ansi.Strip(rendered[0].text), " "); plain != "abc" { t.Fatalf("hardware cursor altered text: %q", plain) } if strings.Contains(rendered[0].text, "\x1b[4m") { t.Fatalf("hardware cursor retained fallback underline: %q", rendered[0].text) } } func TestMarkdownHighlightingPreservesTextWidthsAndCursorIndexes(t *testing.T) { const markdown = "# Heading\nUse `code` and [link](https://example.com)." editor := newTextEditor(markdown, true) editor.highlightMarkdown = true rendered := renderTextEditor(editor, 80, false) var lines []string for _, line := range rendered { lines = append(lines, line.text) } highlighted := strings.Join(lines, "\n") if ansi.Strip(highlighted) != markdown { t.Fatalf("highlighting changed text:\n%q\nwant:\n%q", ansi.Strip(highlighted), markdown) } if !strings.Contains(highlighted, "\x1b[") { t.Fatalf("Markdown was not highlighted: %q", highlighted) } for index, line := range rendered { if ansi.StringWidth(line.text) != ansi.StringWidth(ansi.Strip(line.text)) { t.Fatalf("highlighted line %d changed width", index) } } editor.Cursor = strings.Index(markdown, "code") editor.handleKey(runeKey("s"), true) editor.handleKey(runeKey("C"), true) if editor.Text != strings.Replace(markdown, "code", "Code", 1) { t.Fatalf("highlighted edit changed wrong rune: %q", editor.Text) } } func TestMarkdownHighlightTokenKinds(t *testing.T) { const markdown = "# Heading\nText **strong** and *emphasis* with `code` and [link](target)\n\n" styles := editorMarkdownStyles(markdown) assertStyleAt := func(fragment string, want editorMarkdownStyle) { t.Helper() index := len([]rune(markdown[:strings.Index(markdown, fragment)])) if styles[index] != want { t.Fatalf("style for %q = %d, want %d", fragment, styles[index], want) } } assertStyleAt("# Heading", editorMarkdownHeading) assertStyleAt("**strong**", editorMarkdownStrong) assertStyleAt("*emphasis*", editorMarkdownEmphasis) assertStyleAt("`code`", editorMarkdownCode) assertStyleAt("link", editorMarkdownLink) assertStyleAt("target", editorMarkdownDestination) assertStyleAt("", editorMarkdownComment) } func joinEditorLines(lines []editorRenderedLine) string { var values []string for _, line := range lines { values = append(values, line.text) } return strings.Join(values, "") } func runeKey(value string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)} }