feat: start TS impl

This is so vibe coded, but in the interest of time, its a bit
necessary. Plus this is a complex problem that I don't have the mental
bandwidth to invest right now.
This commit is contained in:
Hayden Hargreaves 2026-04-07 10:23:25 -07:00
parent f96c1c1302
commit 16d1318c22
19 changed files with 1908 additions and 8 deletions

View File

@ -1,9 +1,48 @@
package core
import "strings"
type BufferOptions struct {
// tabstop expandtab
}
type BufferChangeKind int
const (
BufferChangeSetLine BufferChangeKind = iota
BufferChangeInsertLine
BufferChangeDeleteLine
BufferChangeSetLines
)
type BufferChange struct {
Kind BufferChangeKind
StartLine int
EndLine int
Edit *BufferEdit
}
// TextPoint is a byte-oriented row/column point.
//
// Column is counted in bytes (Tree-sitter compatible), not runes.
type TextPoint struct {
Row uint
Column uint
}
// BufferEdit represents a text edit in byte offsets and points.
//
// These fields map directly to Tree-sitter incremental edit inputs.
type BufferEdit struct {
StartByte uint
OldEndByte uint
NewEndByte uint
StartPoint TextPoint
OldEndPoint TextPoint
NewEndPoint TextPoint
}
type BufferType int
const (
@ -30,6 +69,9 @@ type Buffer struct {
// Options BufferOptions
UndoStack *UndoStack
// Optional change callback used by higher layers (editor/syntax) to react to edits.
OnChange func(change BufferChange)
}
// ==================================================
@ -48,20 +90,33 @@ func (b *Buffer) Line(idx int) string {
// Buffer.SetLine: Set the content of the line at an index. Does nothing if the
// index is out of bounds. This function sets the modified flag.
func (b *Buffer) SetLine(idx int, content string) {
oldSource := b.sourceString()
changed := false
if idx >= 0 && idx < len(b.Lines) {
// Record set line in undo stack
if b.UndoStack != nil {
b.UndoStack.RecordSetLine(idx, b.Lines[idx].String(), content)
}
b.Lines[idx].Set(content)
changed = true
}
b.Modified = true
if changed {
newSource := b.sourceString()
edit, ok := computeBufferEdit(oldSource, newSource)
change := BufferChange{Kind: BufferChangeSetLine, StartLine: idx, EndLine: idx}
if ok {
change.Edit = &edit
}
b.notifyChange(change)
}
}
// Buffer.InsertLine: Insert a line with content at an index. The index is clamped
// to valid bounds (0 to len(Lines)). The new line is inserted before the line at
// the given index. This function sets the modified flag.
func (b *Buffer) InsertLine(idx int, content string) {
oldSource := b.sourceString()
if idx < 0 {
idx = 0
}
@ -77,19 +132,39 @@ func (b *Buffer) InsertLine(idx int, content string) {
newLine := NewGapBuffer(content)
b.Lines = append(b.Lines[:idx], append([]*GapBuffer{newLine}, b.Lines[idx:]...)...)
b.Modified = true
newSource := b.sourceString()
edit, ok := computeBufferEdit(oldSource, newSource)
change := BufferChange{Kind: BufferChangeInsertLine, StartLine: idx, EndLine: len(b.Lines) - 1}
if ok {
change.Edit = &edit
}
b.notifyChange(change)
}
// Buffer.DeleteLine: Delete a line at an index. Does nothing if the index is out
// of bounds. This function sets the modified flag.
func (b *Buffer) DeleteLine(idx int) {
oldSource := b.sourceString()
changed := false
if idx >= 0 && idx < len(b.Lines) {
// Record delete line in undo stack
if b.UndoStack != nil {
b.UndoStack.RecordDeleteLine(idx, b.Lines[idx].String())
}
b.Lines = append(b.Lines[:idx], b.Lines[idx+1:]...)
changed = true
}
b.Modified = true
if changed {
newSource := b.sourceString()
edit, ok := computeBufferEdit(oldSource, newSource)
change := BufferChange{Kind: BufferChangeDeleteLine, StartLine: idx, EndLine: len(b.Lines) - 1}
if ok {
change.Edit = &edit
}
b.notifyChange(change)
}
}
// Buffer.LineCount: Get the number of lines in the buffer.
@ -105,6 +180,8 @@ func (b *Buffer) Undo(w *Window) bool {
return false
}
oldSource := b.sourceString()
block := b.UndoStack.Undo()
if block == nil {
return false
@ -144,6 +221,16 @@ func (b *Buffer) Undo(w *Window) bool {
w.SetCursorLine(block.OldCursor.Line)
w.SetCursorCol(block.OldCursor.Col)
newSource := b.sourceString()
if edit, ok := computeBufferEdit(oldSource, newSource); ok {
b.notifyChange(BufferChange{
Kind: BufferChangeSetLines,
StartLine: 0,
EndLine: max(0, len(b.Lines)-1),
Edit: &edit,
})
}
return true
}
@ -152,6 +239,8 @@ func (b *Buffer) Redo(w *Window) bool {
return false
}
oldSource := b.sourceString()
block := b.UndoStack.Redo()
if block == nil {
return false
@ -189,6 +278,16 @@ func (b *Buffer) Redo(w *Window) bool {
w.SetCursorLine(block.NewCursor.Line)
w.SetCursorCol(block.NewCursor.Col)
newSource := b.sourceString()
if edit, ok := computeBufferEdit(oldSource, newSource); ok {
b.notifyChange(BufferChange{
Kind: BufferChangeSetLines,
StartLine: 0,
EndLine: max(0, len(b.Lines)-1),
Edit: &edit,
})
}
return true
}
@ -211,10 +310,93 @@ func (b *Buffer) SetFiletype(filetype string) {
// Buffer.SetLines: Replace all lines in the buffer with the provided lines.
// This is useful when loading a file or resetting buffer content.
func (b *Buffer) SetLines(lines []string) {
oldSource := b.sourceString()
b.Lines = make([]*GapBuffer, len(lines))
for i, line := range lines {
b.Lines[i] = NewGapBuffer(line)
}
newSource := b.sourceString()
edit, ok := computeBufferEdit(oldSource, newSource)
change := BufferChange{Kind: BufferChangeSetLines, StartLine: 0, EndLine: len(lines) - 1}
if ok {
change.Edit = &edit
}
b.notifyChange(change)
}
func (b *Buffer) notifyChange(change BufferChange) {
if b.OnChange != nil {
b.OnChange(change)
}
}
func (b *Buffer) sourceString() string {
if len(b.Lines) == 0 {
return ""
}
parts := make([]string, len(b.Lines))
for i := range b.Lines {
parts[i] = b.Lines[i].String()
}
return strings.Join(parts, "\n")
}
func computeBufferEdit(oldSource, newSource string) (BufferEdit, bool) {
if oldSource == newSource {
return BufferEdit{}, false
}
oldBytes := []byte(oldSource)
newBytes := []byte(newSource)
prefix := 0
maxPrefix := min(len(oldBytes), len(newBytes))
for prefix < maxPrefix && oldBytes[prefix] == newBytes[prefix] {
prefix++
}
oldEnd := len(oldBytes)
newEnd := len(newBytes)
for oldEnd > prefix && newEnd > prefix && oldBytes[oldEnd-1] == newBytes[newEnd-1] {
oldEnd--
newEnd--
}
edit := BufferEdit{
StartByte: uint(prefix),
OldEndByte: uint(oldEnd),
NewEndByte: uint(newEnd),
StartPoint: byteOffsetToPoint(oldBytes, prefix),
OldEndPoint: byteOffsetToPoint(oldBytes, oldEnd),
NewEndPoint: byteOffsetToPoint(newBytes, newEnd),
}
return edit, true
}
func byteOffsetToPoint(src []byte, offset int) TextPoint {
if offset < 0 {
offset = 0
}
if offset > len(src) {
offset = len(src)
}
var row uint
var col uint
for i := 0; i < offset; i++ {
if src[i] == '\n' {
row++
col = 0
} else {
col++
}
}
return TextPoint{Row: row, Column: col}
}
// Buffer.SetModified: Set the modified flag for this buffer. A modified buffer

View File

@ -0,0 +1,62 @@
package core
import "testing"
func FuzzComputeBufferEditInvariants(f *testing.F) {
f.Add("abc\ndef", "abc\nxyz")
f.Add("", "x")
f.Add("same", "same")
f.Add("hello", "")
f.Fuzz(func(t *testing.T, oldSource, newSource string) {
edit, ok := computeBufferEdit(oldSource, newSource)
if oldSource == newSource {
if ok {
t.Fatalf("expected no edit when strings are equal")
}
return
}
if !ok {
t.Fatalf("expected edit for differing strings")
}
oldBytes := []byte(oldSource)
newBytes := []byte(newSource)
start := int(edit.StartByte)
oldEnd := int(edit.OldEndByte)
newEnd := int(edit.NewEndByte)
if start < 0 || start > len(oldBytes) || start > len(newBytes) {
t.Fatalf("invalid start byte: %d", start)
}
if oldEnd < start || oldEnd > len(oldBytes) {
t.Fatalf("invalid old end byte: %d", oldEnd)
}
if newEnd < start || newEnd > len(newBytes) {
t.Fatalf("invalid new end byte: %d", newEnd)
}
if string(oldBytes[:start]) != string(newBytes[:start]) {
t.Fatalf("prefix before edit start must match")
}
if string(oldBytes[oldEnd:]) != string(newBytes[newEnd:]) {
t.Fatalf("suffix after edit end must match")
}
sp := byteOffsetToPoint(oldBytes, start)
op := byteOffsetToPoint(oldBytes, oldEnd)
np := byteOffsetToPoint(newBytes, newEnd)
if sp != edit.StartPoint {
t.Fatalf("start point mismatch: got %+v want %+v", edit.StartPoint, sp)
}
if op != edit.OldEndPoint {
t.Fatalf("old end point mismatch: got %+v want %+v", edit.OldEndPoint, op)
}
if np != edit.NewEndPoint {
t.Fatalf("new end point mismatch: got %+v want %+v", edit.NewEndPoint, np)
}
})
}

View File

@ -0,0 +1,103 @@
package core
import "testing"
func TestComputeBufferEditReplaceLine(t *testing.T) {
oldSource := "abc\ndef"
newSource := "abc\nxyz"
edit, ok := computeBufferEdit(oldSource, newSource)
if !ok {
t.Fatalf("expected edit to be detected")
}
if edit.StartPoint.Row != 1 || edit.StartPoint.Column != 0 {
t.Fatalf("unexpected start point: %+v", edit.StartPoint)
}
if edit.OldEndPoint.Row != 1 || edit.OldEndPoint.Column != 3 {
t.Fatalf("unexpected old end point: %+v", edit.OldEndPoint)
}
if edit.NewEndPoint.Row != 1 || edit.NewEndPoint.Column != 3 {
t.Fatalf("unexpected new end point: %+v", edit.NewEndPoint)
}
}
func TestComputeBufferEditInsertAtEnd(t *testing.T) {
oldSource := "a\nb"
newSource := "a\nbb"
edit, ok := computeBufferEdit(oldSource, newSource)
if !ok {
t.Fatalf("expected edit to be detected")
}
if edit.StartByte != 3 || edit.OldEndByte != 3 || edit.NewEndByte != 4 {
t.Fatalf("unexpected byte offsets: %+v", edit)
}
if edit.StartPoint.Row != 1 || edit.StartPoint.Column != 1 {
t.Fatalf("unexpected start point: %+v", edit.StartPoint)
}
}
func TestByteOffsetToPoint(t *testing.T) {
src := []byte("ab\ncd\nef")
p := byteOffsetToPoint(src, 0)
if p.Row != 0 || p.Column != 0 {
t.Fatalf("offset 0 mismatch: %+v", p)
}
p = byteOffsetToPoint(src, 4) // right after 'c'
if p.Row != 1 || p.Column != 1 {
t.Fatalf("offset 4 mismatch: %+v", p)
}
p = byteOffsetToPoint(src, len(src))
if p.Row != 2 || p.Column != 2 {
t.Fatalf("end offset mismatch: %+v", p)
}
}
func TestUndoRedoEmitBufferChange(t *testing.T) {
b := NewBufferBuilder().WithFiletype("go").WithLines([]string{"one", "two"}).Build()
buf := &b
win := NewWindowBuilder().WithBuffer(buf).Build()
w := &win
buf.UndoStack.BeginBlock(Position{Line: 0, Col: 0})
buf.SetLine(0, "ONE")
buf.UndoStack.EndBlock(Position{Line: 0, Col: 3})
changes := []BufferChange{}
buf.OnChange = func(change BufferChange) {
changes = append(changes, change)
}
if !buf.Undo(w) {
t.Fatalf("expected undo to succeed")
}
if len(changes) != 1 {
t.Fatalf("expected one change notification on undo, got %d", len(changes))
}
if changes[0].Edit == nil {
t.Fatalf("expected undo change to include edit metadata")
}
if got := buf.Line(0); got != "one" {
t.Fatalf("undo did not restore content, got %q", got)
}
changes = nil
if !buf.Redo(w) {
t.Fatalf("expected redo to succeed")
}
if len(changes) != 1 {
t.Fatalf("expected one change notification on redo, got %d", len(changes))
}
if changes[0].Edit == nil {
t.Fatalf("expected redo change to include edit metadata")
}
if got := buf.Line(0); got != "ONE" {
t.Fatalf("redo did not reapply content, got %q", got)
}
}

View File

@ -8,6 +8,7 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/input"
"git.gophernest.net/azpect/TextEditor/internal/style"
"git.gophernest.net/azpect/TextEditor/internal/syntax"
tea "github.com/charmbracelet/bubbletea"
)
@ -51,6 +52,7 @@ type Model struct {
// Visual styles
styles style.Styles
syntax syntax.Engine
// Dot operator state
lastChangeKeys []string
@ -87,6 +89,7 @@ func (m *Model) Buffers() []*core.Buffer {
func (m *Model) SetBuffers(bufs []*core.Buffer) {
m.buffers = bufs
m.bindBufferSyntaxHooks(bufs)
}
func (m *Model) ActiveBuffer() *core.Buffer {
@ -348,6 +351,42 @@ func (m *Model) SetStyles(s style.Styles) {
m.styles = s
}
func (m *Model) Syntax() syntax.Engine {
return m.syntax
}
func (m *Model) SetSyntax(s syntax.Engine) {
m.syntax = s
m.bindBufferSyntaxHooks(m.buffers)
}
func (m *Model) bindBufferSyntaxHooks(bufs []*core.Buffer) {
if m.syntax == nil {
return
}
for _, buf := range bufs {
if buf == nil {
continue
}
b := buf
b.OnChange = func(change core.BufferChange) {
if change.Edit != nil {
m.syntax.ApplyEdit(b, change.Edit)
return
}
switch change.Kind {
case core.BufferChangeSetLine:
m.syntax.InvalidateLines(b, change.StartLine, change.EndLine)
default:
m.syntax.InvalidateBuffer(b)
}
}
}
}
// ==================================================
// Registers
// ==================================================

View File

@ -4,6 +4,7 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/input"
"git.gophernest.net/azpect/TextEditor/internal/style"
"git.gophernest.net/azpect/TextEditor/internal/syntax"
"github.com/alecthomas/chroma/v2/styles"
)
@ -14,6 +15,7 @@ type ModelBuilder struct {
// NewModelBuilder: Builds and returns a new model, using the default color scheme (kanagawa-wave).
func NewModelBuilder() *ModelBuilder {
chromaStyle := styles.Get("kanagawa-wave")
editorStyles := style.ChromaStyles(chromaStyle)
return &ModelBuilder{
model: Model{
@ -32,7 +34,8 @@ func NewModelBuilder() *ModelBuilder {
commandOutput: nil,
settings: core.NewDefaultSettings(),
registers: core.DefaultRegisters(),
styles: style.ChromaStyles(chromaStyle),
styles: editorStyles,
syntax: syntax.NewTreeSitterEngine(editorStyles),
},
}
}
@ -133,5 +136,7 @@ func (mb *ModelBuilder) WithStyles(styles style.Styles) *ModelBuilder {
// ModelBuilder.Build: Build and return the configured Model instance.
func (mb *ModelBuilder) Build() *Model {
return &mb.model
m := &mb.model
m.bindBufferSyntaxHooks(m.buffers)
return m
}

View File

@ -7,6 +7,7 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
"git.gophernest.net/azpect/TextEditor/internal/syntax"
"github.com/charmbracelet/lipgloss"
)
@ -28,7 +29,7 @@ func (m Model) View() string {
options.GutterSize = max(options.GutterSize, maxLineLen+2)
// Draw window
view := viewWindow(win, styles, options, m.Mode())
view := viewWindow(win, styles, options, m.Mode(), m.Syntax())
// Command bar is seperate
cmdBar := drawCommandBar(m)
@ -46,21 +47,24 @@ func (m Model) View() string {
// viewWindow: Renders a single window's content including line numbers and buffer text.
// Each window has its own line numbers, gutter, and viewport dimensions.
func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode) string {
func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode, sx syntax.Engine) string {
buf := w.Buffer
var view strings.Builder
if sx != nil {
sx.PrepareBuffer(buf)
}
// Compute window size (y)
start := w.ScrollY
end := w.ScrollY + w.ViewportHeight()
// Chroma stuff
lexer := style.GetLexer(buf)
// Draw buffer lines
for lineNum := start; lineNum < end; lineNum++ {
if lineNum < buf.LineCount() {
styleMap := styles.MakeStyleMap(lexer, buf.Line(lineNum))
styleMap := make([]lipgloss.Style, len([]rune(buf.Line(lineNum))))
if sx != nil {
styleMap = sx.LineStyleMap(buf, lineNum)
}
line := drawLine(w, styles, options, mode, buf.Line(lineNum), lineNum, styleMap)
view.WriteString(line)
} else {

View File

@ -0,0 +1,52 @@
package style
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
func CaptureStyle(base lipgloss.Style, capture string) lipgloss.Style {
full := strings.ToLower(strings.TrimSpace(capture))
baseName := strings.Split(full, ".")[0]
switch full {
case "keyword", "keyword.type", "keyword.function", "keyword.coroutine", "keyword.repeat", "keyword.import", "keyword.conditional":
return base.Foreground(lipgloss.Color("#c678dd"))
case "function", "function.call", "function.method", "function.method.call":
return base.Foreground(lipgloss.Color("#61afef"))
case "function.builtin", "constructor", "keyword.return":
return base.Foreground(lipgloss.Color("#ff5f5f"))
case "type", "type.builtin", "type.definition":
return base.Foreground(lipgloss.Color("#e5c07b"))
case "string", "string.escape":
return base.Foreground(lipgloss.Color("#98c379"))
case "number", "number.float", "boolean", "constant", "constant.builtin":
return base.Foreground(lipgloss.Color("#56b6c2"))
case "operator", "punctuation.delimiter", "punctuation.bracket":
return base.Foreground(lipgloss.Color("#d19a66"))
case "comment", "comment.documentation":
return base.Foreground(lipgloss.Color("#7f848e"))
case "variable.parameter":
return base.Foreground(lipgloss.Color("#dcdfe4")).Italic(true)
case "module", "label", "property", "variable.member", "variable":
return base.Foreground(lipgloss.Color("#dcdfe4"))
}
switch baseName {
case "keyword":
return base.Foreground(lipgloss.Color("#c678dd"))
case "function":
return base.Foreground(lipgloss.Color("#61afef"))
case "type":
return base.Foreground(lipgloss.Color("#e5c07b"))
case "string":
return base.Foreground(lipgloss.Color("#98c379"))
case "number", "boolean", "constant":
return base.Foreground(lipgloss.Color("#56b6c2"))
case "comment":
return base.Foreground(lipgloss.Color("#7f848e"))
default:
return base
}
}

27
internal/syntax/engine.go Normal file
View File

@ -0,0 +1,27 @@
package syntax
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"github.com/charmbracelet/lipgloss"
)
// Engine provides syntax highlight data for buffers.
//
// The renderer should consume this interface rather than doing parse/token work
// directly.
type Engine interface {
// Engine.PrepareBuffer: Ensure syntax state for a buffer is ready.
PrepareBuffer(buf *core.Buffer)
// Engine.ApplyEdit: Apply an incremental text edit to syntax state.
ApplyEdit(buf *core.Buffer, edit *core.BufferEdit)
// Engine.LineStyleMap: Returns per-rune styles for a line.
LineStyleMap(buf *core.Buffer, line int) []lipgloss.Style
// Engine.InvalidateBuffer: Marks all syntax state for a buffer as stale.
InvalidateBuffer(buf *core.Buffer)
// Engine.InvalidateLines: Marks a line range as stale.
InvalidateLines(buf *core.Buffer, startLine, endLine int)
}

40
internal/syntax/plain.go Normal file
View File

@ -0,0 +1,40 @@
package syntax
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
"github.com/charmbracelet/lipgloss"
)
// PlainEngine is a no-op syntax engine.
// It exists to establish the architecture boundary before Tree-sitter is wired in.
type PlainEngine struct {
styles style.Styles
}
func NewPlainEngine(styles style.Styles) *PlainEngine {
return &PlainEngine{styles: styles}
}
func (e *PlainEngine) PrepareBuffer(buf *core.Buffer) {}
func (e *PlainEngine) ApplyEdit(buf *core.Buffer, edit *core.BufferEdit) {}
func (e *PlainEngine) LineStyleMap(buf *core.Buffer, line int) []lipgloss.Style {
if buf == nil {
return nil
}
text := buf.Line(line)
runes := []rune(text)
styleMap := make([]lipgloss.Style, len(runes))
for i := range styleMap {
styleMap[i] = e.styles.LineStyle
}
return styleMap
}
func (e *PlainEngine) InvalidateBuffer(buf *core.Buffer) {}
func (e *PlainEngine) InvalidateLines(buf *core.Buffer, startLine, endLine int) {}

View File

@ -0,0 +1,254 @@
; Forked from tree-sitter-go
; Copyright (c) 2014 Max Brunsfeld (The MIT License)
;
; Identifiers
(type_identifier) @type
(type_spec
name: (type_identifier) @type.definition)
(field_identifier) @property
(identifier) @variable
(package_identifier) @module
(parameter_declaration
(identifier) @variable.parameter)
(variadic_parameter_declaration
(identifier) @variable.parameter)
(label_name) @label
(const_spec
name: (identifier) @constant)
; Function calls
(call_expression
function: (identifier) @function.call)
(call_expression
function: (selector_expression
field: (field_identifier) @function.method.call))
; Function definitions
(function_declaration
name: (identifier) @function)
(method_declaration
name: (field_identifier) @function.method)
(method_elem
name: (field_identifier) @function.method)
; Constructors
((call_expression
(identifier) @constructor)
(#lua-match? @constructor "^[nN]ew.+$"))
((call_expression
(identifier) @constructor)
(#lua-match? @constructor "^[mM]ake.+$"))
; Operators
[
"--"
"-"
"-="
":="
"!"
"!="
"..."
"*"
"*"
"*="
"/"
"/="
"&"
"&&"
"&="
"&^"
"&^="
"%"
"%="
"^"
"^="
"+"
"++"
"+="
"<-"
"<"
"<<"
"<<="
"<="
"="
"=="
">"
">="
">>"
">>="
"|"
"|="
"||"
"~"
] @operator
; Keywords
[
"break"
"const"
"continue"
"default"
"defer"
"goto"
"range"
"select"
"var"
"fallthrough"
] @keyword
[
"type"
"struct"
"interface"
] @keyword.type
"func" @keyword.function
"return" @keyword.return
"go" @keyword.coroutine
"for" @keyword.repeat
[
"import"
"package"
] @keyword.import
[
"else"
"case"
"switch"
"if"
] @keyword.conditional
; Builtin types
[
"chan"
"map"
] @type.builtin
((type_identifier) @type.builtin
(#any-of? @type.builtin
"any" "bool" "byte" "comparable" "complex128" "complex64" "error" "float32" "float64" "int"
"int16" "int32" "int64" "int8" "rune" "string" "uint" "uint16" "uint32" "uint64" "uint8"
"uintptr"))
; Builtin functions
((identifier) @function.builtin
(#any-of? @function.builtin
"append" "cap" "clear" "close" "complex" "copy" "delete" "imag" "len" "make" "max" "min" "new"
"panic" "print" "println" "real" "recover"))
; Delimiters
"." @punctuation.delimiter
"," @punctuation.delimiter
":" @punctuation.delimiter
";" @punctuation.delimiter
"(" @punctuation.bracket
")" @punctuation.bracket
"{" @punctuation.bracket
"}" @punctuation.bracket
"[" @punctuation.bracket
"]" @punctuation.bracket
; Literals
(interpreted_string_literal) @string
(raw_string_literal) @string
(rune_literal) @string
(escape_sequence) @string.escape
(int_literal) @number
(float_literal) @number.float
(imaginary_literal) @number
[
(true)
(false)
] @boolean
[
(nil)
(iota)
] @constant.builtin
(keyed_element
.
(literal_element
(identifier) @variable.member))
(field_declaration
name: (field_identifier) @variable.member)
; Comments
(comment) @comment @spell
; Doc Comments
(source_file
.
(comment)+ @comment.documentation)
(source_file
(comment)+ @comment.documentation
.
(const_declaration))
(source_file
(comment)+ @comment.documentation
.
(function_declaration))
(source_file
(comment)+ @comment.documentation
.
(type_declaration))
(source_file
(comment)+ @comment.documentation
.
(var_declaration))
; Spell
((interpreted_string_literal) @spell
(#not-has-parent? @spell import_spec))
; Regex
(call_expression
(selector_expression) @_function
(#any-of? @_function
"regexp.Match" "regexp.MatchReader" "regexp.MatchString" "regexp.Compile" "regexp.CompilePOSIX"
"regexp.MustCompile" "regexp.MustCompilePOSIX")
(argument_list
.
[
(raw_string_literal
(raw_string_literal_content) @string.regexp)
(interpreted_string_literal
(interpreted_string_literal_content) @string.regexp)
]))

View File

@ -0,0 +1,10 @@
package syntax
import _ "embed"
//go:embed queries/go/highlights.scm
var goHighlightsQuery string
func loadGoHighlightsQuery() ([]byte, error) {
return []byte(goHighlightsQuery), nil
}

View File

@ -0,0 +1,25 @@
package syntax
import (
"testing"
sitter "github.com/tree-sitter/go-tree-sitter"
ts_go "github.com/tree-sitter/tree-sitter-go/bindings/go"
)
func TestEmbeddedGoQueryCompiles(t *testing.T) {
b, err := loadGoHighlightsQuery()
if err != nil {
t.Fatalf("failed loading embedded query: %v", err)
}
if len(b) == 0 {
t.Fatalf("embedded query is empty")
}
lang := sitter.NewLanguage(ts_go.Language())
q, qErr := sitter.NewQuery(lang, string(b))
if qErr != nil {
t.Fatalf("embedded go query failed to compile: %v", qErr)
}
q.Close()
}

View File

@ -0,0 +1,537 @@
package syntax
import (
"bytes"
"sort"
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
"github.com/charmbracelet/lipgloss"
sitter "github.com/tree-sitter/go-tree-sitter"
ts_go "github.com/tree-sitter/tree-sitter-go/bindings/go"
)
type TreeSitterEngine struct {
styles style.Styles
goLanguage *sitter.Language
goQuery *sitter.Query
queryLoaded bool
cache map[*core.Buffer]*bufferCache
}
type bufferCache struct {
built bool
lines map[int][]lipgloss.Style
count int
parser *sitter.Parser
tree *sitter.Tree
source []byte
dirtyAll bool
dirty []lineRange
}
type lineRange struct {
start int
end int
}
type captureRange struct {
startRow uint
startCol uint
endRow uint
endCol uint
name string
}
// NewTreeSitterEngine: Creates a new tree sitter engine with the styles
// provided attached.
//
// Currently, this engine only support GoLang. But more languages can be
// added with easy.
func NewTreeSitterEngine(styles style.Styles) *TreeSitterEngine {
return &TreeSitterEngine{
styles: styles,
cache: map[*core.Buffer]*bufferCache{},
}
}
func (e *TreeSitterEngine) PrepareBuffer(buf *core.Buffer) {
// Cannot prepare a nil buffer
if buf == nil {
return
}
// Get the buffers cache and return if we are already "built" (ready to render).
bc := e.getCache(buf)
if bc.count != buf.LineCount() {
bc.dirtyAll = true
}
if bc.dirtyAll {
bc.built = false
}
if bc.built {
return
}
// If we do no support the buffer, load empty styles into the cache
if !e.supportsBuffer(buf) {
bc.lines = map[int][]lipgloss.Style{}
bc.built = true
return
}
// Load the query. If we cannot, load empty styles into the cache
if err := e.ensureGoQuery(); err != nil {
bc.lines = map[int][]lipgloss.Style{}
bc.built = true
return
}
e.buildFullBuffer(buf, bc)
}
func (e *TreeSitterEngine) LineStyleMap(buf *core.Buffer, line int) []lipgloss.Style {
if buf == nil {
return nil
}
e.PrepareBuffer(buf)
bc := e.getCache(buf)
if s, ok := bc.lines[line]; ok {
return s
}
runes := []rune(buf.Line(line))
out := make([]lipgloss.Style, len(runes))
for i := range out {
out[i] = e.styles.LineStyle
}
bc.lines[line] = out
return out
}
func (e *TreeSitterEngine) ApplyEdit(buf *core.Buffer, edit *core.BufferEdit) {
if buf == nil || edit == nil {
return
}
bc := e.getCache(buf)
if !e.supportsBuffer(buf) {
bc.built = false
bc.dirtyAll = true
return
}
if err := e.ensureGoQuery(); err != nil {
bc.dirtyAll = true
return
}
if bc.parser == nil {
bc.parser = sitter.NewParser()
bc.parser.SetLanguage(e.goLanguage)
}
if bc.tree == nil || len(bc.source) == 0 {
bc.dirtyAll = true
return
}
bc.tree.Edit(&sitter.InputEdit{
StartByte: edit.StartByte,
OldEndByte: edit.OldEndByte,
NewEndByte: edit.NewEndByte,
StartPosition: sitter.NewPoint(edit.StartPoint.Row, edit.StartPoint.Column),
OldEndPosition: sitter.NewPoint(edit.OldEndPoint.Row, edit.OldEndPoint.Column),
NewEndPosition: sitter.NewPoint(edit.NewEndPoint.Row, edit.NewEndPoint.Column),
})
newSource := buildBufferSource(buf)
newTree := bc.parser.Parse(newSource, bc.tree)
if newTree == nil {
bc.dirtyAll = true
return
}
changed := bc.tree.ChangedRanges(newTree)
newLineCount := buf.LineCount()
if newLineCount != bc.count {
bc.dirtyAll = true
bc.dirty = nil
} else {
startRow := int(edit.StartPoint.Row)
endRow := int(max(edit.OldEndPoint.Row, edit.NewEndPoint.Row))
addDirtyRange(bc, startRow, endRow)
for _, r := range changed {
addDirtyRange(bc, int(r.StartPoint.Row), int(r.EndPoint.Row))
}
}
bc.source = newSource
bc.tree.Close()
bc.tree = newTree
bc.built = false
}
// TreeSitterEngine.InvalidateBuffer: Deletes the entire buffers cache from the engine. If the
// buffer provided is nil, this function does nothing.
func (e *TreeSitterEngine) InvalidateBuffer(buf *core.Buffer) {
if buf == nil {
return
}
bc := e.getCache(buf)
bc.built = false
bc.dirtyAll = true
bc.dirty = nil
}
// TreeSitterEngine.InvalidateLines: Deletes lines between start and end (inclusive) from the
// buffers cache. Then marks the cache as "unbuilt." If the buffer provided is nil, this function
// does nothing.
func (e *TreeSitterEngine) InvalidateLines(buf *core.Buffer, startLine, endLine int) {
if buf == nil {
return
}
bc := e.getCache(buf)
addDirtyRange(bc, startLine, endLine)
bc.built = false
}
// TreeSitterEngine.supportsBuffer: Returns whether the buffer can be parsed and highlighted
// by the engine. When false, there should be a fallback.
func (e *TreeSitterEngine) supportsBuffer(buf *core.Buffer) bool {
ft := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(buf.Filetype)), ".")
if ft == "go" {
return true
}
if strings.HasSuffix(strings.ToLower(buf.Filename), ".go") {
return true
}
return false
}
// TreeSitterEngine.ensureGoQuery: Loads the highlight (.scm) file from the query dir and
// attaches it to the engine. If the query is already loaded, this function does nothing.
func (e *TreeSitterEngine) ensureGoQuery() error {
if e.queryLoaded {
return nil
}
e.goLanguage = sitter.NewLanguage(ts_go.Language())
qBytes, err := loadGoHighlightsQuery()
if err != nil {
return err
}
q, qErr := sitter.NewQuery(e.goLanguage, string(qBytes))
if qErr != nil {
return qErr
}
e.goQuery = q
e.queryLoaded = true
return nil
}
// TreeSitterEngine.getCache: Returns the buffers cache. If the cache does not exist, a new one
// is created and applied to the engines cache map.
func (e *TreeSitterEngine) getCache(buf *core.Buffer) *bufferCache {
if bc, ok := e.cache[buf]; ok {
return bc
}
bc := &bufferCache{lines: map[int][]lipgloss.Style{}}
e.cache[buf] = bc
return bc
}
func (e *TreeSitterEngine) buildFullBuffer(buf *core.Buffer, bc *bufferCache) {
lineCount := buf.LineCount()
// Load the lines into memory. There is no method for this due to the buffers
// internal implementation using a gap buffer. So the "Lines" property is of
// type []*GapBuffer.
lines := make([]string, lineCount)
for i := range lineCount {
lines[i] = buf.Line(i)
}
fullRebuild := bc.dirtyAll || len(bc.lines) == 0 || len(bc.dirty) == 0
if fullRebuild {
bc.lines = map[int][]lipgloss.Style{}
for i := range lineCount {
bc.lines[i] = defaultLineStyles(lines[i], e.styles.LineStyle)
}
} else {
dirty := normalizedDirtyRanges(bc.dirty, lineCount)
for _, r := range dirty {
for i := r.start; i <= r.end; i++ {
bc.lines[i] = defaultLineStyles(lines[i], e.styles.LineStyle)
}
}
}
source := buildBufferSource(buf)
useCurrentTree := bc.tree != nil && bytes.Equal(bc.source, source)
if bc.parser == nil {
bc.parser = sitter.NewParser()
bc.parser.SetLanguage(e.goLanguage)
}
if !useCurrentTree {
var baseTree *sitter.Tree
if bc.tree != nil {
baseTree = bc.tree
}
tree := bc.parser.Parse(source, baseTree)
if tree == nil {
bc.built = true
return
}
if bc.tree != nil {
bc.tree.Close()
}
bc.tree = tree
bc.source = source
}
root := bc.tree.RootNode()
cursor := sitter.NewQueryCursor()
defer cursor.Close()
var captures []captureRange
if fullRebuild {
iter := cursor.Captures(e.goQuery, root, source)
captures = append(captures, collectCaptures(iter, e.goQuery)...)
} else {
dirty := normalizedDirtyRanges(bc.dirty, lineCount)
for _, r := range dirty {
queryStart := max(0, r.start-1)
queryEnd := min(lineCount-1, r.end+1)
rangeCursor := sitter.NewQueryCursor()
rangeCursor.SetPointRange(
sitter.NewPoint(uint(queryStart), 0),
sitter.NewPoint(uint(queryEnd+1), 0),
)
iter := rangeCursor.Captures(e.goQuery, root, source)
captures = append(captures, collectCaptures(iter, e.goQuery)...)
rangeCursor.Close()
}
}
// Sort the captures in order of their character occurrence in the file
sort.Slice(captures, func(i, j int) bool {
if captures[i].startRow == captures[j].startRow {
if captures[i].startCol == captures[j].startCol {
if captures[i].endRow == captures[j].endRow {
return captures[i].endCol > captures[j].endCol
}
return captures[i].endRow > captures[j].endRow
}
return captures[i].startCol < captures[j].startCol
}
return captures[i].startRow < captures[j].startRow
})
// Basically, this code works by rewriting the same range and the last capture wins.
// This is a great spot for optimization: No need to draw many times, just pick the best one.
// Or maybe when we sort, if we find ones that are the same, remove the first one, and then
// we just keep the last one. Then this code can stay the same but will not suffer so many
// rewrites.
targetDirty := normalizedDirtyRanges(bc.dirty, lineCount)
for _, c := range captures {
sty := style.CaptureStyle(e.styles.LineStyle, c.name)
for row := c.startRow; row <= c.endRow; row++ {
if int(row) >= len(lines) {
break
}
if !fullRebuild && !rowInRanges(int(row), targetDirty) {
continue
}
lineBytes := []byte(lines[row])
startByteCol := uint(0)
if row == c.startRow {
startByteCol = c.startCol
}
endByteCol := uint(len(lineBytes))
if row == c.endRow {
endByteCol = min(c.endCol, uint(len(lineBytes)))
}
startRune := byteColToRuneIndex(lineBytes, int(startByteCol))
endRune := byteColToRuneIndex(lineBytes, int(endByteCol))
rowStyles := bc.lines[int(row)]
if startRune < 0 {
startRune = 0
}
if endRune > len(rowStyles) {
endRune = len(rowStyles)
}
if startRune >= endRune {
continue
}
for i := startRune; i < endRune; i++ {
rowStyles[i] = sty
}
bc.lines[int(row)] = rowStyles
}
}
bc.dirtyAll = false
bc.dirty = nil
bc.count = lineCount
bc.built = true
}
func addDirtyRange(bc *bufferCache, start, end int) {
if bc == nil {
return
}
if end < start {
start, end = end, start
}
if start < 0 {
start = 0
}
if end < 0 {
end = 0
}
bc.dirty = append(bc.dirty, lineRange{start: start, end: end})
bc.dirty = mergeRanges(bc.dirty)
}
func normalizedDirtyRanges(ranges []lineRange, lineCount int) []lineRange {
if lineCount <= 0 || len(ranges) == 0 {
return nil
}
clamped := make([]lineRange, 0, len(ranges))
for _, r := range ranges {
start := max(0, r.start)
end := min(lineCount-1, r.end)
if start > end {
continue
}
clamped = append(clamped, lineRange{start: start, end: end})
}
return mergeRanges(clamped)
}
func mergeRanges(ranges []lineRange) []lineRange {
if len(ranges) == 0 {
return nil
}
sort.Slice(ranges, func(i, j int) bool {
if ranges[i].start == ranges[j].start {
return ranges[i].end < ranges[j].end
}
return ranges[i].start < ranges[j].start
})
merged := make([]lineRange, 0, len(ranges))
cur := ranges[0]
for i := 1; i < len(ranges); i++ {
n := ranges[i]
if n.start <= cur.end+1 {
if n.end > cur.end {
cur.end = n.end
}
continue
}
merged = append(merged, cur)
cur = n
}
merged = append(merged, cur)
return merged
}
func rowInRanges(row int, ranges []lineRange) bool {
for _, r := range ranges {
if row >= r.start && row <= r.end {
return true
}
}
return false
}
func defaultLineStyles(line string, base lipgloss.Style) []lipgloss.Style {
runes := []rune(line)
row := make([]lipgloss.Style, len(runes))
for i := range row {
row[i] = base
}
return row
}
func collectCaptures(iter sitter.QueryCaptures, query *sitter.Query) []captureRange {
if query == nil {
return nil
}
names := query.CaptureNames()
out := []captureRange{}
for match, captureIdx := iter.Next(); match != nil; match, captureIdx = iter.Next() {
capture := match.Captures[captureIdx]
if int(capture.Index) >= len(names) {
continue
}
name := names[capture.Index]
if name == "spell" {
continue
}
node := capture.Node
start := node.StartPosition()
end := node.EndPosition()
out = append(out, captureRange{
startRow: start.Row,
startCol: start.Column,
endRow: end.Row,
endCol: end.Column,
name: name,
})
}
return out
}
func buildBufferSource(buf *core.Buffer) []byte {
lineCount := buf.LineCount()
if lineCount == 0 {
return []byte{}
}
lines := make([]string, lineCount)
for i := range lineCount {
lines[i] = buf.Line(i)
}
return []byte(strings.Join(lines, "\n"))
}
func byteColToRuneIndex(line []byte, byteCol int) int {
if byteCol <= 0 {
return 0
}
if byteCol >= len(line) {
return len([]rune(string(line)))
}
prefix := line[:byteCol]
return len([]rune(string(prefix)))
}

View File

@ -0,0 +1,242 @@
package syntax
import (
"fmt"
"strings"
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
"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
engine := NewTreeSitterEngine(style.DefaultStyles())
engine.PrepareBuffer(buf)
base := engine.styles.LineStyle
line0 := buf.Line(0)
map0 := engine.LineStyleMap(buf, 0)
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)
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
engine := NewTreeSitterEngine(style.DefaultStyles())
engine.PrepareBuffer(buf)
base := engine.styles.LineStyle
map3 := engine.LineStyleMap(buf, 3)
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
engine := NewTreeSitterEngine(style.DefaultStyles())
engine.PrepareBuffer(buf)
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)
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)
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)
newStyle := newMap[newIdx]
if styleEquivalent(newStyle, engine.styles.LineStyle) {
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
engine := NewTreeSitterEngine(style.DefaultStyles())
engine.PrepareBuffer(buf)
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)
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
engine := NewTreeSitterEngine(style.DefaultStyles())
engine.PrepareBuffer(buf)
base := engine.styles.LineStyle
line := buf.Line(0)
m := engine.LineStyleMap(buf, 0)
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
engine := NewTreeSitterEngine(style.DefaultStyles())
engine.PrepareBuffer(buf)
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)
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 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(),
)
}

View File

@ -0,0 +1,37 @@
package syntax
import (
"fmt"
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
)
func BenchmarkTreeSitterPrepareAndIncrementalEdit(b *testing.B) {
lines := make([]string, 0, 2000)
lines = append(lines, "package main", "", "func main() {")
for i := 0; i < 1990; i++ {
lines = append(lines, fmt.Sprintf(" v%d := %d", i, i))
}
lines = append(lines, "}")
bld := core.NewBufferBuilder().WithFilename("bench.go").WithFiletype("go").WithLines(lines).Build()
buf := &bld
eng := NewTreeSitterEngine(style.DefaultStyles())
eng.PrepareBuffer(buf)
b.ResetTimer()
for i := 0; i < b.N; i++ {
lineIdx := 10 + (i % 1000)
buf.SetLine(lineIdx, fmt.Sprintf(" v%d := %d", lineIdx, i))
oldSource := buildBufferSource(buf)
_ = oldSource
// Synthetic direct invalidate path benchmark (current API entrypoints)
eng.InvalidateLines(buf, lineIdx, lineIdx)
eng.PrepareBuffer(buf)
}
}

View File

@ -0,0 +1,84 @@
package syntax
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
)
func TestTreeSitterEngineApplyEditMarksDirtyWithoutFullInvalidation(t *testing.T) {
b := core.NewBufferBuilder().
WithFilename("x.go").
WithFiletype("go").
WithLines([]string{"package main", "func main() {", "println(1)", "}"}).
Build()
buf := &b
engine := NewTreeSitterEngine(style.DefaultStyles())
engine.PrepareBuffer(buf)
bc := engine.getCache(buf)
if !bc.built {
t.Fatalf("expected cache to be built after prepare")
}
var edit *core.BufferEdit
buf.OnChange = func(change core.BufferChange) {
edit = change.Edit
}
buf.SetLine(2, "println(22)")
if edit == nil {
t.Fatalf("expected setline to emit edit metadata")
}
engine.ApplyEdit(buf, edit)
if bc.built {
t.Fatalf("expected cache to become unbuilt after apply edit")
}
if bc.dirtyAll {
t.Fatalf("expected non-structural edit to avoid dirtyAll")
}
if len(bc.dirty) == 0 {
t.Fatalf("expected dirty ranges after apply edit")
}
engine.PrepareBuffer(buf)
if !bc.built {
t.Fatalf("expected cache rebuilt after prepare")
}
if len(bc.dirty) != 0 {
t.Fatalf("expected dirty ranges cleared after rebuild")
}
}
func TestTreeSitterEngineInvalidateLinesAndBuffer(t *testing.T) {
b := core.NewBufferBuilder().
WithFilename("x.go").
WithFiletype("go").
WithLines([]string{"package main", "func main() {}"}).
Build()
buf := &b
engine := NewTreeSitterEngine(style.DefaultStyles())
engine.PrepareBuffer(buf)
bc := engine.getCache(buf)
engine.InvalidateLines(buf, 1, 1)
if bc.built {
t.Fatalf("expected invalidate lines to unset built")
}
if bc.dirtyAll {
t.Fatalf("expected line invalidation to avoid dirtyAll")
}
if len(bc.dirty) == 0 {
t.Fatalf("expected dirty line ranges")
}
engine.InvalidateBuffer(buf)
if !bc.dirtyAll {
t.Fatalf("expected invalidate buffer to set dirtyAll")
}
}

View File

@ -0,0 +1,35 @@
package syntax
import "testing"
func FuzzByteColToRuneIndexInvariants(f *testing.F) {
f.Add("abc", 0)
f.Add("aéb", 1)
f.Add("こんにちは", 5)
f.Add("", 0)
f.Fuzz(func(t *testing.T, s string, col int) {
line := []byte(s)
idx := byteColToRuneIndex(line, col)
runes := []rune(s)
if idx < 0 || idx > len(runes) {
t.Fatalf("rune index out of bounds: idx=%d len=%d", idx, len(runes))
}
if col <= 0 && idx != 0 {
t.Fatalf("expected idx 0 for non-positive col, got %d", idx)
}
if col >= len(line) && idx != len(runes) {
t.Fatalf("expected idx len(runes) for col>=len(bytes), got %d", idx)
}
if col > 0 && col < len(line) {
expected := len([]rune(string(line[:col])))
if idx != expected {
t.Fatalf("expected idx %d got %d", expected, idx)
}
}
})
}

View File

@ -0,0 +1,50 @@
package syntax
import "testing"
func TestMergeRanges(t *testing.T) {
in := []lineRange{{start: 5, end: 8}, {start: 1, end: 2}, {start: 2, end: 4}, {start: 10, end: 10}}
out := mergeRanges(in)
if len(out) != 2 {
t.Fatalf("expected 2 merged ranges, got %d", len(out))
}
if out[0].start != 1 || out[0].end != 8 {
t.Fatalf("unexpected first merged range: %+v", out[0])
}
if out[1].start != 10 || out[1].end != 10 {
t.Fatalf("unexpected second merged range: %+v", out[1])
}
}
func TestNormalizedDirtyRanges(t *testing.T) {
ranges := []lineRange{{start: -2, end: 1}, {start: 3, end: 99}}
out := normalizedDirtyRanges(ranges, 5)
if len(out) != 2 {
t.Fatalf("expected 2 normalized ranges, got %d", len(out))
}
if out[0].start != 0 || out[0].end != 1 {
t.Fatalf("unexpected first normalized range: %+v", out[0])
}
if out[1].start != 3 || out[1].end != 4 {
t.Fatalf("unexpected second normalized range: %+v", out[1])
}
}
func TestByteColToRuneIndexUTF8(t *testing.T) {
line := []byte("aéb")
if got := byteColToRuneIndex(line, 0); got != 0 {
t.Fatalf("expected 0, got %d", got)
}
if got := byteColToRuneIndex(line, 1); got != 1 {
t.Fatalf("expected 1, got %d", got)
}
if got := byteColToRuneIndex(line, 3); got != 2 {
t.Fatalf("expected 2, got %d", got)
}
if got := byteColToRuneIndex(line, len(line)); got != 3 {
t.Fatalf("expected 3, got %d", got)
}
}

View File

@ -0,0 +1,112 @@
package syntax
import (
"fmt"
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
)
type seqOp func(*core.Buffer, *core.Window)
func TestTreeSitterEngineEditSequences(t *testing.T) {
cases := []struct {
name string
lines []string
opList []seqOp
}{
{
name: "setline and insertline",
lines: []string{"package main", "func main() {", " x := 1", "}"},
opList: []seqOp{
func(b *core.Buffer, _ *core.Window) { b.SetLine(2, " x := 2") },
func(b *core.Buffer, _ *core.Window) { b.InsertLine(3, " println(x)") },
},
},
{
name: "delete middle line",
lines: []string{"package main", "func main() {", " x := 1", " y := 2", "}"},
opList: []seqOp{
func(b *core.Buffer, _ *core.Window) { b.DeleteLine(3) },
},
},
{
name: "multiline string evolve",
lines: []string{"package main", "func main() {", " s := `a", "b`", " _ = s", "}"},
opList: []seqOp{
func(b *core.Buffer, _ *core.Window) { b.SetLine(2, " s := `alpha") },
func(b *core.Buffer, _ *core.Window) { b.SetLine(3, "beta`") },
},
},
{
name: "undo redo sequence",
lines: []string{"package main", "func main() {", " v := 3", "}"},
opList: []seqOp{
func(b *core.Buffer, w *core.Window) {
b.UndoStack.BeginBlock(w.Cursor)
b.SetLine(2, " v := 9")
b.UndoStack.EndBlock(core.Position{Line: 2, Col: 8})
},
func(b *core.Buffer, w *core.Window) { _ = b.Undo(w) },
func(b *core.Buffer, w *core.Window) { _ = b.Redo(w) },
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
b := core.NewBufferBuilder().
WithFilename("seq.go").
WithFiletype("go").
WithLines(tc.lines).
Build()
buf := &b
w := core.NewWindowBuilder().WithBuffer(buf).WithDimensions(120, 40).Build()
win := &w
engine := NewTreeSitterEngine(style.DefaultStyles())
buf.OnChange = func(change core.BufferChange) {
if change.Edit != nil {
engine.ApplyEdit(buf, change.Edit)
} else {
engine.InvalidateBuffer(buf)
}
}
engine.PrepareBuffer(buf)
assertEngineInvariants(t, engine, buf, "initial")
for i, op := range tc.opList {
op(buf, win)
engine.PrepareBuffer(buf)
assertEngineInvariants(t, engine, buf, fmt.Sprintf("after op %d", i+1))
}
})
}
}
func assertEngineInvariants(t *testing.T, engine *TreeSitterEngine, buf *core.Buffer, phase string) {
t.Helper()
bc := engine.getCache(buf)
if !bc.built {
t.Fatalf("%s: expected built cache", phase)
}
if bc.dirtyAll {
t.Fatalf("%s: expected dirtyAll=false after prepare", phase)
}
if len(bc.dirty) != 0 {
t.Fatalf("%s: expected no pending dirty ranges", phase)
}
for i := 0; i < buf.LineCount(); i++ {
line := buf.Line(i)
m := engine.LineStyleMap(buf, i)
if len(m) != len([]rune(line)) {
t.Fatalf("%s: line %d style length mismatch: got %d want %d", phase, i, len(m), len([]rune(line)))
}
}
}