Gim/internal/syntax/treesitter_behavior_test.go
2026-04-08 17:28:01 -07:00

384 lines
10 KiB
Go

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(),
)
}