diff --git a/internal/core/buffer.go b/internal/core/buffer.go index 98be447..58bbbab 100644 --- a/internal/core/buffer.go +++ b/internal/core/buffer.go @@ -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 diff --git a/internal/core/buffer_edit_fuzz_test.go b/internal/core/buffer_edit_fuzz_test.go new file mode 100644 index 0000000..329691e --- /dev/null +++ b/internal/core/buffer_edit_fuzz_test.go @@ -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) + } + }) +} diff --git a/internal/core/buffer_edit_test.go b/internal/core/buffer_edit_test.go new file mode 100644 index 0000000..a2053d9 --- /dev/null +++ b/internal/core/buffer_edit_test.go @@ -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) + } +} diff --git a/internal/editor/model.go b/internal/editor/model.go index 15dfe99..451678d 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -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 // ================================================== diff --git a/internal/editor/model_builder.go b/internal/editor/model_builder.go index d70c048..e77a2d9 100644 --- a/internal/editor/model_builder.go +++ b/internal/editor/model_builder.go @@ -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 } diff --git a/internal/editor/view.go b/internal/editor/view.go index 7f5a701..a09fae9 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -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 { diff --git a/internal/style/capture_theme.go b/internal/style/capture_theme.go new file mode 100644 index 0000000..1b4b2ff --- /dev/null +++ b/internal/style/capture_theme.go @@ -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 + } +} diff --git a/internal/syntax/engine.go b/internal/syntax/engine.go new file mode 100644 index 0000000..674af74 --- /dev/null +++ b/internal/syntax/engine.go @@ -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) +} diff --git a/internal/syntax/plain.go b/internal/syntax/plain.go new file mode 100644 index 0000000..0910acd --- /dev/null +++ b/internal/syntax/plain.go @@ -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) {} diff --git a/internal/syntax/queries/go/highlights.scm b/internal/syntax/queries/go/highlights.scm new file mode 100644 index 0000000..7675cb7 --- /dev/null +++ b/internal/syntax/queries/go/highlights.scm @@ -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) + ])) diff --git a/internal/syntax/query_assets.go b/internal/syntax/query_assets.go new file mode 100644 index 0000000..3694311 --- /dev/null +++ b/internal/syntax/query_assets.go @@ -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 +} diff --git a/internal/syntax/query_assets_test.go b/internal/syntax/query_assets_test.go new file mode 100644 index 0000000..9217cb4 --- /dev/null +++ b/internal/syntax/query_assets_test.go @@ -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() +} diff --git a/internal/syntax/treesitter.go b/internal/syntax/treesitter.go new file mode 100644 index 0000000..5b26def --- /dev/null +++ b/internal/syntax/treesitter.go @@ -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))) +} diff --git a/internal/syntax/treesitter_behavior_test.go b/internal/syntax/treesitter_behavior_test.go new file mode 100644 index 0000000..9b00ff8 --- /dev/null +++ b/internal/syntax/treesitter_behavior_test.go @@ -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(), + ) +} diff --git a/internal/syntax/treesitter_bench_test.go b/internal/syntax/treesitter_bench_test.go new file mode 100644 index 0000000..c4f481f --- /dev/null +++ b/internal/syntax/treesitter_bench_test.go @@ -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) + } +} diff --git a/internal/syntax/treesitter_engine_test.go b/internal/syntax/treesitter_engine_test.go new file mode 100644 index 0000000..871ff00 --- /dev/null +++ b/internal/syntax/treesitter_engine_test.go @@ -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") + } +} diff --git a/internal/syntax/treesitter_fuzz_test.go b/internal/syntax/treesitter_fuzz_test.go new file mode 100644 index 0000000..e6ff382 --- /dev/null +++ b/internal/syntax/treesitter_fuzz_test.go @@ -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) + } + } + }) +} diff --git a/internal/syntax/treesitter_internal_test.go b/internal/syntax/treesitter_internal_test.go new file mode 100644 index 0000000..60f8a05 --- /dev/null +++ b/internal/syntax/treesitter_internal_test.go @@ -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) + } +} diff --git a/internal/syntax/treesitter_sequences_test.go b/internal/syntax/treesitter_sequences_test.go new file mode 100644 index 0000000..8c57fda --- /dev/null +++ b/internal/syntax/treesitter_sequences_test.go @@ -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))) + } + } +}