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:
parent
f96c1c1302
commit
16d1318c22
@ -1,9 +1,48 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
type BufferOptions struct {
|
type BufferOptions struct {
|
||||||
// tabstop expandtab
|
// 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
|
type BufferType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -30,6 +69,9 @@ type Buffer struct {
|
|||||||
|
|
||||||
// Options BufferOptions
|
// Options BufferOptions
|
||||||
UndoStack *UndoStack
|
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
|
// 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.
|
// index is out of bounds. This function sets the modified flag.
|
||||||
func (b *Buffer) SetLine(idx int, content string) {
|
func (b *Buffer) SetLine(idx int, content string) {
|
||||||
|
oldSource := b.sourceString()
|
||||||
|
changed := false
|
||||||
if idx >= 0 && idx < len(b.Lines) {
|
if idx >= 0 && idx < len(b.Lines) {
|
||||||
// Record set line in undo stack
|
// Record set line in undo stack
|
||||||
if b.UndoStack != nil {
|
if b.UndoStack != nil {
|
||||||
b.UndoStack.RecordSetLine(idx, b.Lines[idx].String(), content)
|
b.UndoStack.RecordSetLine(idx, b.Lines[idx].String(), content)
|
||||||
}
|
}
|
||||||
b.Lines[idx].Set(content)
|
b.Lines[idx].Set(content)
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
b.Modified = 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
|
// 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
|
// 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.
|
// the given index. This function sets the modified flag.
|
||||||
func (b *Buffer) InsertLine(idx int, content string) {
|
func (b *Buffer) InsertLine(idx int, content string) {
|
||||||
|
oldSource := b.sourceString()
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
idx = 0
|
idx = 0
|
||||||
}
|
}
|
||||||
@ -77,19 +132,39 @@ func (b *Buffer) InsertLine(idx int, content string) {
|
|||||||
newLine := NewGapBuffer(content)
|
newLine := NewGapBuffer(content)
|
||||||
b.Lines = append(b.Lines[:idx], append([]*GapBuffer{newLine}, b.Lines[idx:]...)...)
|
b.Lines = append(b.Lines[:idx], append([]*GapBuffer{newLine}, b.Lines[idx:]...)...)
|
||||||
b.Modified = true
|
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
|
// Buffer.DeleteLine: Delete a line at an index. Does nothing if the index is out
|
||||||
// of bounds. This function sets the modified flag.
|
// of bounds. This function sets the modified flag.
|
||||||
func (b *Buffer) DeleteLine(idx int) {
|
func (b *Buffer) DeleteLine(idx int) {
|
||||||
|
oldSource := b.sourceString()
|
||||||
|
changed := false
|
||||||
if idx >= 0 && idx < len(b.Lines) {
|
if idx >= 0 && idx < len(b.Lines) {
|
||||||
// Record delete line in undo stack
|
// Record delete line in undo stack
|
||||||
if b.UndoStack != nil {
|
if b.UndoStack != nil {
|
||||||
b.UndoStack.RecordDeleteLine(idx, b.Lines[idx].String())
|
b.UndoStack.RecordDeleteLine(idx, b.Lines[idx].String())
|
||||||
}
|
}
|
||||||
b.Lines = append(b.Lines[:idx], b.Lines[idx+1:]...)
|
b.Lines = append(b.Lines[:idx], b.Lines[idx+1:]...)
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
b.Modified = 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.
|
// Buffer.LineCount: Get the number of lines in the buffer.
|
||||||
@ -105,6 +180,8 @@ func (b *Buffer) Undo(w *Window) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldSource := b.sourceString()
|
||||||
|
|
||||||
block := b.UndoStack.Undo()
|
block := b.UndoStack.Undo()
|
||||||
if block == nil {
|
if block == nil {
|
||||||
return false
|
return false
|
||||||
@ -144,6 +221,16 @@ func (b *Buffer) Undo(w *Window) bool {
|
|||||||
w.SetCursorLine(block.OldCursor.Line)
|
w.SetCursorLine(block.OldCursor.Line)
|
||||||
w.SetCursorCol(block.OldCursor.Col)
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,6 +239,8 @@ func (b *Buffer) Redo(w *Window) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldSource := b.sourceString()
|
||||||
|
|
||||||
block := b.UndoStack.Redo()
|
block := b.UndoStack.Redo()
|
||||||
if block == nil {
|
if block == nil {
|
||||||
return false
|
return false
|
||||||
@ -189,6 +278,16 @@ func (b *Buffer) Redo(w *Window) bool {
|
|||||||
w.SetCursorLine(block.NewCursor.Line)
|
w.SetCursorLine(block.NewCursor.Line)
|
||||||
w.SetCursorCol(block.NewCursor.Col)
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,10 +310,93 @@ func (b *Buffer) SetFiletype(filetype string) {
|
|||||||
// Buffer.SetLines: Replace all lines in the buffer with the provided lines.
|
// Buffer.SetLines: Replace all lines in the buffer with the provided lines.
|
||||||
// This is useful when loading a file or resetting buffer content.
|
// This is useful when loading a file or resetting buffer content.
|
||||||
func (b *Buffer) SetLines(lines []string) {
|
func (b *Buffer) SetLines(lines []string) {
|
||||||
|
oldSource := b.sourceString()
|
||||||
b.Lines = make([]*GapBuffer, len(lines))
|
b.Lines = make([]*GapBuffer, len(lines))
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
b.Lines[i] = NewGapBuffer(line)
|
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
|
// Buffer.SetModified: Set the modified flag for this buffer. A modified buffer
|
||||||
|
|||||||
62
internal/core/buffer_edit_fuzz_test.go
Normal file
62
internal/core/buffer_edit_fuzz_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
103
internal/core/buffer_edit_test.go
Normal file
103
internal/core/buffer_edit_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/input"
|
"git.gophernest.net/azpect/TextEditor/internal/input"
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/style"
|
"git.gophernest.net/azpect/TextEditor/internal/style"
|
||||||
|
"git.gophernest.net/azpect/TextEditor/internal/syntax"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -51,6 +52,7 @@ type Model struct {
|
|||||||
|
|
||||||
// Visual styles
|
// Visual styles
|
||||||
styles style.Styles
|
styles style.Styles
|
||||||
|
syntax syntax.Engine
|
||||||
|
|
||||||
// Dot operator state
|
// Dot operator state
|
||||||
lastChangeKeys []string
|
lastChangeKeys []string
|
||||||
@ -87,6 +89,7 @@ func (m *Model) Buffers() []*core.Buffer {
|
|||||||
|
|
||||||
func (m *Model) SetBuffers(bufs []*core.Buffer) {
|
func (m *Model) SetBuffers(bufs []*core.Buffer) {
|
||||||
m.buffers = bufs
|
m.buffers = bufs
|
||||||
|
m.bindBufferSyntaxHooks(bufs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) ActiveBuffer() *core.Buffer {
|
func (m *Model) ActiveBuffer() *core.Buffer {
|
||||||
@ -348,6 +351,42 @@ func (m *Model) SetStyles(s style.Styles) {
|
|||||||
m.styles = s
|
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
|
// Registers
|
||||||
// ==================================================
|
// ==================================================
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/input"
|
"git.gophernest.net/azpect/TextEditor/internal/input"
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/style"
|
"git.gophernest.net/azpect/TextEditor/internal/style"
|
||||||
|
"git.gophernest.net/azpect/TextEditor/internal/syntax"
|
||||||
"github.com/alecthomas/chroma/v2/styles"
|
"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).
|
// NewModelBuilder: Builds and returns a new model, using the default color scheme (kanagawa-wave).
|
||||||
func NewModelBuilder() *ModelBuilder {
|
func NewModelBuilder() *ModelBuilder {
|
||||||
chromaStyle := styles.Get("kanagawa-wave")
|
chromaStyle := styles.Get("kanagawa-wave")
|
||||||
|
editorStyles := style.ChromaStyles(chromaStyle)
|
||||||
|
|
||||||
return &ModelBuilder{
|
return &ModelBuilder{
|
||||||
model: Model{
|
model: Model{
|
||||||
@ -32,7 +34,8 @@ func NewModelBuilder() *ModelBuilder {
|
|||||||
commandOutput: nil,
|
commandOutput: nil,
|
||||||
settings: core.NewDefaultSettings(),
|
settings: core.NewDefaultSettings(),
|
||||||
registers: core.DefaultRegisters(),
|
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.
|
// ModelBuilder.Build: Build and return the configured Model instance.
|
||||||
func (mb *ModelBuilder) Build() *Model {
|
func (mb *ModelBuilder) Build() *Model {
|
||||||
return &mb.model
|
m := &mb.model
|
||||||
|
m.bindBufferSyntaxHooks(m.buffers)
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/style"
|
"git.gophernest.net/azpect/TextEditor/internal/style"
|
||||||
|
"git.gophernest.net/azpect/TextEditor/internal/syntax"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -28,7 +29,7 @@ func (m Model) View() string {
|
|||||||
options.GutterSize = max(options.GutterSize, maxLineLen+2)
|
options.GutterSize = max(options.GutterSize, maxLineLen+2)
|
||||||
|
|
||||||
// Draw window
|
// Draw window
|
||||||
view := viewWindow(win, styles, options, m.Mode())
|
view := viewWindow(win, styles, options, m.Mode(), m.Syntax())
|
||||||
|
|
||||||
// Command bar is seperate
|
// Command bar is seperate
|
||||||
cmdBar := drawCommandBar(m)
|
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.
|
// viewWindow: Renders a single window's content including line numbers and buffer text.
|
||||||
// Each window has its own line numbers, gutter, and viewport dimensions.
|
// 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
|
buf := w.Buffer
|
||||||
var view strings.Builder
|
var view strings.Builder
|
||||||
|
if sx != nil {
|
||||||
|
sx.PrepareBuffer(buf)
|
||||||
|
}
|
||||||
|
|
||||||
// Compute window size (y)
|
// Compute window size (y)
|
||||||
start := w.ScrollY
|
start := w.ScrollY
|
||||||
end := w.ScrollY + w.ViewportHeight()
|
end := w.ScrollY + w.ViewportHeight()
|
||||||
|
|
||||||
// Chroma stuff
|
|
||||||
lexer := style.GetLexer(buf)
|
|
||||||
|
|
||||||
// Draw buffer lines
|
// Draw buffer lines
|
||||||
for lineNum := start; lineNum < end; lineNum++ {
|
for lineNum := start; lineNum < end; lineNum++ {
|
||||||
if lineNum < buf.LineCount() {
|
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)
|
line := drawLine(w, styles, options, mode, buf.Line(lineNum), lineNum, styleMap)
|
||||||
view.WriteString(line)
|
view.WriteString(line)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
52
internal/style/capture_theme.go
Normal file
52
internal/style/capture_theme.go
Normal 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
27
internal/syntax/engine.go
Normal 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
40
internal/syntax/plain.go
Normal 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) {}
|
||||||
254
internal/syntax/queries/go/highlights.scm
Normal file
254
internal/syntax/queries/go/highlights.scm
Normal 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)
|
||||||
|
]))
|
||||||
10
internal/syntax/query_assets.go
Normal file
10
internal/syntax/query_assets.go
Normal 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
|
||||||
|
}
|
||||||
25
internal/syntax/query_assets_test.go
Normal file
25
internal/syntax/query_assets_test.go
Normal 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()
|
||||||
|
}
|
||||||
537
internal/syntax/treesitter.go
Normal file
537
internal/syntax/treesitter.go
Normal 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)))
|
||||||
|
}
|
||||||
242
internal/syntax/treesitter_behavior_test.go
Normal file
242
internal/syntax/treesitter_behavior_test.go
Normal 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
37
internal/syntax/treesitter_bench_test.go
Normal file
37
internal/syntax/treesitter_bench_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
84
internal/syntax/treesitter_engine_test.go
Normal file
84
internal/syntax/treesitter_engine_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
35
internal/syntax/treesitter_fuzz_test.go
Normal file
35
internal/syntax/treesitter_fuzz_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
50
internal/syntax/treesitter_internal_test.go
Normal file
50
internal/syntax/treesitter_internal_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
112
internal/syntax/treesitter_sequences_test.go
Normal file
112
internal/syntax/treesitter_sequences_test.go
Normal 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user