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
|
||||
|
||||
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
|
||||
|
||||
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/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
|
||||
// ==================================================
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
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