package syntax import ( "fmt" "maps" "strings" "testing" "git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/theme" "git.gophernest.net/azpect/TextEditor/internal/theme/themes" "github.com/charmbracelet/lipgloss" ) func TestTreeSitterEngineHighlightsGoKeywordAndString(t *testing.T) { b := core.NewBufferBuilder(). WithFilename("sample.go"). WithFiletype("go"). WithLines([]string{ "package main", "func main() {", " s := \"hi\"", "}", }). Build() buf := &b editorTheme := themes.NewDefaultTheme() engine := NewTreeSitterEngine(editorTheme) engine.PrepareBuffer(buf, editorTheme) base := editorTheme.Line line0 := buf.Line(0) map0 := engine.LineStyleMap(buf, 0, editorTheme) if len(map0) != len([]rune(line0)) { t.Fatalf("line 0 style map length mismatch") } if len(map0) == 0 || styleEquivalent(map0[0], base) { t.Fatalf("expected 'package' keyword to be highlighted") } line2 := buf.Line(2) stringStart := strings.Index(line2, "\"hi\"") if stringStart < 0 { t.Fatalf("test setup failed: string literal not found") } map2 := engine.LineStyleMap(buf, 2, editorTheme) if styleEquivalent(map2[stringStart+1], base) { t.Fatalf("expected string contents to be highlighted") } } func TestTreeSitterEngineHighlightsMultilineRawString(t *testing.T) { b := core.NewBufferBuilder(). WithFilename("sample.go"). WithFiletype("go"). WithLines([]string{ "package main", "func main() {", " s := `hello", "world`", " println(s)", "}", }). Build() buf := &b editorTheme := themes.NewDefaultTheme() engine := NewTreeSitterEngine(editorTheme) engine.PrepareBuffer(buf, editorTheme) base := editorTheme.Line map3 := engine.LineStyleMap(buf, 3, editorTheme) if len(map3) == 0 { t.Fatalf("expected style map on multiline raw string line") } if styleEquivalent(map3[0], base) { t.Fatalf("expected multiline raw string line to be highlighted") } } func TestTreeSitterEngineApplyEditUpdatesStyleCategory(t *testing.T) { b := core.NewBufferBuilder(). WithFilename("sample.go"). WithFiletype("go"). WithLines([]string{ "package main", "func main() {", " x := 123", "}", }). Build() buf := &b editorTheme := themes.NewDefaultTheme() engine := NewTreeSitterEngine(editorTheme) engine.PrepareBuffer(buf, editorTheme) oldLine := buf.Line(2) oldIdx := strings.Index(oldLine, "123") if oldIdx < 0 { t.Fatalf("test setup failed: number not found") } oldMap := engine.LineStyleMap(buf, 2, editorTheme) oldStyle := oldMap[oldIdx] var edit *core.BufferEdit buf.OnChange = func(change core.BufferChange) { edit = change.Edit } buf.SetLine(2, " x := \"abc\"") if edit == nil { t.Fatalf("expected edit metadata from SetLine") } engine.ApplyEdit(buf, edit) engine.PrepareBuffer(buf, editorTheme) newLine := buf.Line(2) newIdx := strings.Index(newLine, "abc") if newIdx < 0 { t.Fatalf("test setup failed: string not found") } newMap := engine.LineStyleMap(buf, 2, editorTheme) newStyle := newMap[newIdx] if styleEquivalent(newStyle, editorTheme.Line) { t.Fatalf("expected updated string to be highlighted") } if styleEquivalent(oldStyle, newStyle) { t.Fatalf("expected style category to change from number to string") } } func TestTreeSitterEngineApplyEditLineCountChangeForcesFullDirty(t *testing.T) { b := core.NewBufferBuilder(). WithFilename("sample.go"). WithFiletype("go"). WithLines([]string{"package main", "func main() {}"}). Build() buf := &b editorTheme := themes.NewDefaultTheme() engine := NewTreeSitterEngine(editorTheme) engine.PrepareBuffer(buf, editorTheme) bc := engine.getCache(buf) var edit *core.BufferEdit buf.OnChange = func(change core.BufferChange) { edit = change.Edit } buf.InsertLine(1, "var x = 1") if edit == nil { t.Fatalf("expected edit metadata from InsertLine") } engine.ApplyEdit(buf, edit) if !bc.dirtyAll { t.Fatalf("expected line count change to set dirtyAll") } engine.PrepareBuffer(buf, editorTheme) if !bc.built { t.Fatalf("expected cache rebuilt after prepare") } if bc.count != buf.LineCount() { t.Fatalf("expected cache line count to match buffer") } if bc.dirtyAll { t.Fatalf("expected dirtyAll to clear after rebuild") } } func TestTreeSitterEngineUnsupportedBufferFallsBackToDefaultStyles(t *testing.T) { b := core.NewBufferBuilder(). WithFilename("notes.txt"). WithFiletype("txt"). WithLines([]string{"just text", "with no language"}). Build() buf := &b editorTheme := themes.NewDefaultTheme() engine := NewTreeSitterEngine(editorTheme) engine.PrepareBuffer(buf, editorTheme) base := editorTheme.Line line := buf.Line(0) m := engine.LineStyleMap(buf, 0, editorTheme) if len(m) != len([]rune(line)) { t.Fatalf("style map length mismatch on fallback buffer") } for i := range m { if !styleEquivalent(m[i], base) { t.Fatalf("expected default style for unsupported filetype at rune %d", i) } } } func TestTreeSitterEngineLastLineEditDoesNotPanicAndRebuilds(t *testing.T) { b := core.NewBufferBuilder(). WithFilename("sample.go"). WithFiletype("go"). WithLines([]string{"package main", "func main() {", " return", "}"}). Build() buf := &b editorTheme := themes.NewDefaultTheme() engine := NewTreeSitterEngine(editorTheme) engine.PrepareBuffer(buf, editorTheme) bc := engine.getCache(buf) var edit *core.BufferEdit buf.OnChange = func(change core.BufferChange) { edit = change.Edit } buf.SetLine(3, "// end") if edit == nil { t.Fatalf("expected edit metadata for last line change") } engine.ApplyEdit(buf, edit) engine.PrepareBuffer(buf, editorTheme) if !bc.built { t.Fatalf("expected cache built after last-line edit") } if len(bc.dirty) != 0 { t.Fatalf("expected dirty ranges cleared after rebuild") } } func TestTreeSitterEngineThemeChangeRebuildsWithNewCaptureStylesAfterInvalidation(t *testing.T) { b := core.NewBufferBuilder(). WithFilename("sample.go"). WithFiletype("go"). WithLines([]string{"package main", "func main() {", " return", "}"}). Build() buf := &b themeA := themes.NewDefaultTheme() themeB := makeThemeWithCaptureOverrides(lipgloss.Color("#ffffff"), lipgloss.Color("#ff00ff"), lipgloss.Color("#00ffaa")) engine := NewTreeSitterEngine(themeA) engine.PrepareBuffer(buf, themeA) line := buf.Line(0) keywordIdx := strings.Index(line, "package") if keywordIdx < 0 { t.Fatalf("test setup failed: expected package keyword") } before := engine.LineStyleMap(buf, 0, themeA) beforeStyle := before[keywordIdx] if styleEquivalent(beforeStyle, themeA.Line) { t.Fatalf("expected keyword style to differ from base style before theme switch") } engine.InvalidateBuffer(buf) engine.PrepareBuffer(buf, themeB) after := engine.LineStyleMap(buf, 0, themeB) afterStyle := after[keywordIdx] if styleEquivalent(afterStyle, themeB.Line) { t.Fatalf("expected keyword style to differ from base style after theme switch") } if styleEquivalent(beforeStyle, afterStyle) { t.Fatalf("expected keyword style to change after theme switch and invalidation") } } func TestTreeSitterEngineThemeChangeRebuildsFallbackLineStylesAfterInvalidation(t *testing.T) { b := core.NewBufferBuilder(). WithFilename("notes.txt"). WithFiletype("txt"). WithLines([]string{"plain text"}). Build() buf := &b themeA := themes.NewDefaultTheme() themeB := makeThemeWithCaptureOverrides(lipgloss.Color("#101010"), lipgloss.Color("#ff00ff"), lipgloss.Color("#00ffaa")) engine := NewTreeSitterEngine(themeA) mapA := engine.LineStyleMap(buf, 0, themeA) if len(mapA) == 0 { t.Fatalf("expected non-empty style map for text line") } if !styleEquivalent(mapA[0], themeA.Line) { t.Fatalf("expected fallback style to use first theme line style") } engine.InvalidateBuffer(buf) mapB := engine.LineStyleMap(buf, 0, themeB) if !styleEquivalent(mapB[0], themeB.Line) { t.Fatalf("expected fallback style to use second theme line style after invalidation") } if styleEquivalent(mapA[0], mapB[0]) { t.Fatalf("expected fallback style to change after theme switch and invalidation") } } func TestTreeSitterEngineUnsupportedApplyEditFallsBackToDefaultStyles(t *testing.T) { b := core.NewBufferBuilder(). WithFilename("notes.txt"). WithFiletype("txt"). WithLines([]string{"just text"}). Build() buf := &b editorTheme := themes.NewDefaultTheme() engine := NewTreeSitterEngine(editorTheme) engine.PrepareBuffer(buf, editorTheme) baseline := engine.LineStyleMap(buf, 0, editorTheme) if len(baseline) == 0 { t.Fatalf("expected baseline style map") } var edit *core.BufferEdit buf.OnChange = func(change core.BufferChange) { edit = change.Edit } buf.SetLine(0, "still plain text") if edit == nil { t.Fatalf("expected edit metadata from SetLine") } engine.ApplyEdit(buf, edit) engine.PrepareBuffer(buf, editorTheme) after := engine.LineStyleMap(buf, 0, editorTheme) if len(after) != len([]rune(buf.Line(0))) { t.Fatalf("style map length mismatch after unsupported apply edit") } for i := range after { if !styleEquivalent(after[i], editorTheme.Line) { t.Fatalf("expected fallback line style at rune %d after unsupported apply edit", i) } } } func makeThemeWithCaptureOverrides(lineFg, keywordFg, stringFg lipgloss.Color) theme.EditorTheme { t := themes.NewDefaultTheme() t.Line = t.Line.Foreground(lineFg) t.Syntax.Group = cloneStyleMap(t.Syntax.Group) t.Syntax.Exact = cloneStyleMap(t.Syntax.Exact) t.Syntax.Group["keyword"] = lipgloss.NewStyle().Foreground(keywordFg) t.Syntax.Group["string"] = lipgloss.NewStyle().Foreground(stringFg) for key := range t.Syntax.Exact { if strings.HasPrefix(key, "keyword") { t.Syntax.Exact[key] = lipgloss.NewStyle().Foreground(keywordFg) } if strings.HasPrefix(key, "string") { t.Syntax.Exact[key] = lipgloss.NewStyle().Foreground(stringFg) } } return t } func cloneStyleMap(in map[string]lipgloss.Style) map[string]lipgloss.Style { out := make(map[string]lipgloss.Style, len(in)) maps.Copy(out, in) return out } func styleEquivalent(a, b lipgloss.Style) bool { return styleSignature(a) == styleSignature(b) } func styleSignature(s lipgloss.Style) string { return fmt.Sprintf( "fg=%v,bg=%v,bold=%v,italic=%v,underline=%v,reverse=%v", s.GetForeground(), s.GetBackground(), s.GetBold(), s.GetItalic(), s.GetUnderline(), s.GetReverse(), ) }