Compare commits
2 Commits
4d96c0a531
...
64c448c639
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64c448c639 | ||
|
|
2cfa17705b |
42
.opencode/agents/janitor.md
Normal file
42
.opencode/agents/janitor.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
description: Identifies dead code, unused dependencies, and structural bloat in Go projects
|
||||||
|
mode: subagent
|
||||||
|
model: openai/gpt-5.4-mini
|
||||||
|
temperature: 0.1
|
||||||
|
permissions:
|
||||||
|
read: allow
|
||||||
|
list: allow
|
||||||
|
glob: allow
|
||||||
|
grep: allow
|
||||||
|
lsp: allow
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a ruthless but precise code janitor specializing in Go. Your sole objective is to find and flag dead weight, unnecessary abstractions, and repository bloat. You do not review for business logic flaws; you review for cleanliness and minimalism.
|
||||||
|
|
||||||
|
Scan the provided codebase or diff for the following:
|
||||||
|
|
||||||
|
- **Dead Code & Unused Types:**
|
||||||
|
- Flag unexported functions, structs, or methods that are never called.
|
||||||
|
- Identify unused parameters in function signatures (and suggest using `_` if the signature must be maintained for an interface).
|
||||||
|
- Find unused constants, variables, or redundant struct tags.
|
||||||
|
|
||||||
|
- **Dependency & Package Bloat:**
|
||||||
|
- Identify imported but unused packages.
|
||||||
|
- Flag opportunities where standard library functions (e.g., `strings`, `slices`, `maps`) can replace a third-party dependency.
|
||||||
|
- Suggest when it might be time to run `go mod tidy` if the `go.mod` file contains indirect dependencies that appear orphaned.
|
||||||
|
|
||||||
|
- **Comment & Documentation Rot:**
|
||||||
|
- Flag commented-out code blocks (code should be tracked by Git, not comments).
|
||||||
|
- Identify stale or redundant comments (e.g., `// GetUser gets the user` or comments that no longer match the function signature).
|
||||||
|
- Point out outdated `TODO` or `FIXME` comments that have been resolved or ignored for too long.
|
||||||
|
|
||||||
|
- **Structural Redundancy:**
|
||||||
|
- Flag duplicated code blocks that could easily be refactored into a single utility function.
|
||||||
|
- Identify overly complex `switch` or `if/else` chains that can be simplified.
|
||||||
|
- Look for empty or redundant `init()` functions that add unnecessary overhead.
|
||||||
|
|
||||||
|
**Output Guidelines:**
|
||||||
|
- Be direct and concise.
|
||||||
|
- Group your findings into three categories: **Dead Code**, **Dependency Bloat**, and **General Clutter**.
|
||||||
|
- Provide file names and line numbers for every flagged item.
|
||||||
|
- Do not make direct changes to the codebase; output your findings as a clear, prioritized checklist for the developer to action.
|
||||||
46
.opencode/agents/review.md
Normal file
46
.opencode/agents/review.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
description: Reviews Go code for idiomatic patterns, performance, and concurrency safety
|
||||||
|
mode: subagent
|
||||||
|
model: openai/gpt-5.4
|
||||||
|
temperature: 0.1
|
||||||
|
permission:
|
||||||
|
edit: deny
|
||||||
|
bash:
|
||||||
|
"*": ask
|
||||||
|
"git diff": allow
|
||||||
|
"git log*": allow
|
||||||
|
"grep *": allow
|
||||||
|
webfetch: deny
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a strict but constructive Principal Go Engineer performing a code review. Focus exclusively on Go-specific best practices, performance, and idiomatic patterns. Your goal is to catch bugs, race conditions, and memory inefficiencies before they are merged.
|
||||||
|
|
||||||
|
Focus your review on the following areas:
|
||||||
|
|
||||||
|
- **Idiomatic Go:**
|
||||||
|
- Ensure the code follows standard formatting (`gofmt`/`goimports`).
|
||||||
|
- Check for proper interface usage (e.g., accepting interfaces, returning structs).
|
||||||
|
- Verify that errors are handled gracefully and explicitly, using error wrapping (`fmt.Errorf("... %w", err)`) where context is needed. Avoid silent error swallowing.
|
||||||
|
|
||||||
|
- **Memory & Performance:**
|
||||||
|
- Flag unnecessary heap allocations that could trigger excessive garbage collection. Suggest value semantics where appropriate to keep variables on the stack (escape analysis).
|
||||||
|
- Look for inefficient string concatenations (suggest `strings.Builder`).
|
||||||
|
- For frequent allocation of byte slices or buffers, suggest `sync.Pool` to reuse memory.
|
||||||
|
|
||||||
|
- **Concurrency & State Management:**
|
||||||
|
- Identify potential goroutine leaks (e.g., blocking on unbuffered channels with no readers).
|
||||||
|
- Check for race conditions in shared state access. Suggest `sync.RWMutex` or channel-based synchronization where appropriate.
|
||||||
|
- Ensure `context.Context` is passed as the first argument in blocking operations and is properly checked for cancellation.
|
||||||
|
|
||||||
|
- **Bugs & Edge Cases:**
|
||||||
|
- Flag unchecked `nil` pointers or potential out-of-bounds slice accesses.
|
||||||
|
- Ensure deferred functions (like `file.Close()` or `mutex.Unlock()`) are called immediately after successful resource acquisition.
|
||||||
|
|
||||||
|
- **Testing:**
|
||||||
|
- Suggest Table-Driven Tests for complex logic.
|
||||||
|
- Point out missing coverage for edge cases or unhappy paths.
|
||||||
|
|
||||||
|
**Output Guidelines:**
|
||||||
|
- Provide feedback grouped by severity (Critical, Suggested, Nitpick).
|
||||||
|
- If you identify an anti-pattern, briefly explain *why* it is unidiomatic and provide a short snippet of the preferred Go approach.
|
||||||
|
- Do not make direct changes to the codebase; output your findings as clearly formatted review comments.
|
||||||
38
.opencode/agents/tester.md
Normal file
38
.opencode/agents/tester.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
description: Generates and reviews Go tests, specializing in table-driven patterns and teatest TUI validation
|
||||||
|
mode: subagent
|
||||||
|
model: openai/gpt-5.3-codex
|
||||||
|
temperature: 0.1
|
||||||
|
permission:
|
||||||
|
edit: allow
|
||||||
|
bash:
|
||||||
|
"go *": allow
|
||||||
|
webfetch: deny
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a rigorous Test Architect specializing in Go. Your primary focus is ensuring code reliability through deterministic, highly covered tests, particularly for Terminal User Interface (TUI) applications.
|
||||||
|
|
||||||
|
Apply the following testing philosophies to any code you review or generate:
|
||||||
|
|
||||||
|
- **Table-Driven Testing (Standard Go):**
|
||||||
|
- Enforce the use of table-driven tests for all pure functions, parsers, and logic handlers.
|
||||||
|
- Ensure test structs always include `name`, `input` (or `args`), `expected`, and `wantErr` fields.
|
||||||
|
- Verify that `t.Run()` is used to isolate each subtest for clear, organized failure reporting.
|
||||||
|
|
||||||
|
- **Interactive Flow Validation (`teatest`):**
|
||||||
|
- For UI components, utilize `charmbracelet/teatest` to simulate real terminal workflows.
|
||||||
|
- Validate keystroke handling by sending specific `tea.KeyMsg` sequences (e.g., simulating complex motions or buffer edits).
|
||||||
|
- Ensure `tm.WaitFinished()` or `teatest.WaitFor()` is used to handle the asynchronous nature of TUI state updates before making assertions.
|
||||||
|
|
||||||
|
- **Golden File Testing:**
|
||||||
|
- For complex `View()` rendering outputs, enforce the use of golden files (`.golden`).
|
||||||
|
- Suggest boilerplate for an `-update` test flag so developers can easily overwrite expected visual states when the UI intentionally changes.
|
||||||
|
|
||||||
|
- **State vs. Side-Effect Isolation:**
|
||||||
|
- Ensure the core logic is decoupled from `os` or `io` operations using interfaces, so file operations can be mocked in memory.
|
||||||
|
- Test `Model.Update()` transitions directly by asserting internal state changes (like cursor position, buffer mutations, or mode switches) independent of the visual render.
|
||||||
|
|
||||||
|
**Output Guidelines:**
|
||||||
|
- If reviewing tests, point out missing edge cases, hardcoded assertions that should be table-driven, or flaky asynchronous TUI tests.
|
||||||
|
- If writing tests, provide complete, idiomatic Go code snippets.
|
||||||
|
- Keep feedback focused on reliability, determinism, and execution speed.
|
||||||
@ -23,7 +23,6 @@ type MockModel struct {
|
|||||||
CommandHistoryList []string
|
CommandHistoryList []string
|
||||||
CommandHistoryCur int
|
CommandHistoryCur int
|
||||||
LastFindVal core.LastFindCommand
|
LastFindVal core.LastFindCommand
|
||||||
CurrentThemeName string
|
|
||||||
ThemesMap map[string]theme.EditorTheme
|
ThemesMap map[string]theme.EditorTheme
|
||||||
LastChangeKeysList []string
|
LastChangeKeysList []string
|
||||||
}
|
}
|
||||||
@ -47,7 +46,6 @@ func NewMockModel() *MockModel {
|
|||||||
SettingsVal: core.NewDefaultSettings(),
|
SettingsVal: core.NewDefaultSettings(),
|
||||||
ModeVal: core.NormalMode,
|
ModeVal: core.NormalMode,
|
||||||
RegistersMap: core.DefaultRegisters(),
|
RegistersMap: core.DefaultRegisters(),
|
||||||
CurrentThemeName: "default",
|
|
||||||
ThemesMap: map[string]theme.EditorTheme{"default": {}},
|
ThemesMap: map[string]theme.EditorTheme{"default": {}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,7 +65,6 @@ func NewMockModelWithBuffer(buf *core.Buffer) *MockModel {
|
|||||||
SettingsVal: core.NewDefaultSettings(),
|
SettingsVal: core.NewDefaultSettings(),
|
||||||
ModeVal: core.NormalMode,
|
ModeVal: core.NormalMode,
|
||||||
RegistersMap: core.DefaultRegisters(),
|
RegistersMap: core.DefaultRegisters(),
|
||||||
CurrentThemeName: "default",
|
|
||||||
ThemesMap: map[string]theme.EditorTheme{"default": {}},
|
ThemesMap: map[string]theme.EditorTheme{"default": {}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -81,7 +78,6 @@ func NewMockModelWithWindow(win *core.Window) *MockModel {
|
|||||||
SettingsVal: core.NewDefaultSettings(),
|
SettingsVal: core.NewDefaultSettings(),
|
||||||
ModeVal: core.NormalMode,
|
ModeVal: core.NormalMode,
|
||||||
RegistersMap: core.DefaultRegisters(),
|
RegistersMap: core.DefaultRegisters(),
|
||||||
CurrentThemeName: "default",
|
|
||||||
ThemesMap: map[string]theme.EditorTheme{"default": {}},
|
ThemesMap: map[string]theme.EditorTheme{"default": {}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -126,17 +122,17 @@ func (m *MockModel) Settings() core.EditorSettings { return m.SettingsVal }
|
|||||||
func (m *MockModel) SetSettings(s core.EditorSettings) { m.SettingsVal = s }
|
func (m *MockModel) SetSettings(s core.EditorSettings) { m.SettingsVal = s }
|
||||||
func (m *MockModel) Theme() (string, theme.EditorTheme) {
|
func (m *MockModel) Theme() (string, theme.EditorTheme) {
|
||||||
if m.ThemesMap != nil {
|
if m.ThemesMap != nil {
|
||||||
if t, ok := m.ThemesMap[m.CurrentThemeName]; ok {
|
if t, ok := m.ThemesMap[m.SettingsVal.CurrentTheme]; ok {
|
||||||
return m.CurrentThemeName, t
|
return m.SettingsVal.CurrentTheme, t
|
||||||
}
|
}
|
||||||
if t, ok := m.ThemesMap["default"]; ok {
|
if t, ok := m.ThemesMap["default"]; ok {
|
||||||
return "default", t
|
return "default", t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.CurrentThemeName, theme.EditorTheme{}
|
return m.SettingsVal.CurrentTheme, theme.EditorTheme{}
|
||||||
}
|
}
|
||||||
func (m *MockModel) SetTheme(name string) { m.CurrentThemeName = name }
|
func (m *MockModel) SetTheme(name string) { m.SettingsVal.CurrentTheme = name }
|
||||||
func (m *MockModel) Themes() map[string]theme.EditorTheme {
|
func (m *MockModel) Themes() map[string]theme.EditorTheme {
|
||||||
if m.ThemesMap == nil {
|
if m.ThemesMap == nil {
|
||||||
m.ThemesMap = map[string]theme.EditorTheme{}
|
m.ThemesMap = map[string]theme.EditorTheme{}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package core
|
|||||||
// EditorSettings: Configuration options for editor display and behavior.
|
// EditorSettings: Configuration options for editor display and behavior.
|
||||||
type EditorSettings struct {
|
type EditorSettings struct {
|
||||||
TabStop int
|
TabStop int
|
||||||
// TODO: Colors
|
CurrentTheme string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDefaultSettings: Creates a Settings struct with sensible defaults for
|
// NewDefaultSettings: Creates a Settings struct with sensible defaults for
|
||||||
@ -11,5 +11,7 @@ type EditorSettings struct {
|
|||||||
func NewDefaultSettings() EditorSettings {
|
func NewDefaultSettings() EditorSettings {
|
||||||
return EditorSettings{
|
return EditorSettings{
|
||||||
TabStop: 2,
|
TabStop: 2,
|
||||||
|
// TODO: This should be "default" but until we have a startup config, this is fine
|
||||||
|
CurrentTheme: "kanagawa",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,7 @@ type Model struct {
|
|||||||
registers map[rune]core.Register // name -> register
|
registers map[rune]core.Register // name -> register
|
||||||
|
|
||||||
// Visual styles
|
// Visual styles
|
||||||
currentTheme string // Name of current theme
|
// currentTheme string // Name of current theme
|
||||||
themes map[string]theme.EditorTheme
|
themes map[string]theme.EditorTheme
|
||||||
syntax syntax.Engine
|
syntax syntax.Engine
|
||||||
|
|
||||||
@ -346,15 +346,15 @@ func (m *Model) SetSettings(s core.EditorSettings) {
|
|||||||
// Themes
|
// Themes
|
||||||
// ==================================================
|
// ==================================================
|
||||||
func (m *Model) Theme() (string, theme.EditorTheme) {
|
func (m *Model) Theme() (string, theme.EditorTheme) {
|
||||||
t, ok := m.themes[m.currentTheme]
|
t, ok := m.themes[m.settings.CurrentTheme]
|
||||||
if ok {
|
if ok {
|
||||||
return m.currentTheme, t
|
return m.settings.CurrentTheme, t
|
||||||
}
|
}
|
||||||
return "default", m.themes["default"]
|
return "default", m.themes["default"]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) SetTheme(name string) {
|
func (m *Model) SetTheme(name string) {
|
||||||
m.currentTheme = name
|
m.settings.CurrentTheme = name
|
||||||
|
|
||||||
if m.syntax == nil {
|
if m.syntax == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -44,7 +44,6 @@ func NewModelBuilder() *ModelBuilder {
|
|||||||
settings: core.NewDefaultSettings(),
|
settings: core.NewDefaultSettings(),
|
||||||
registers: core.DefaultRegisters(),
|
registers: core.DefaultRegisters(),
|
||||||
syntax: syntax.NewTreeSitterEngine(editorTheme),
|
syntax: syntax.NewTreeSitterEngine(editorTheme),
|
||||||
currentTheme: "default",
|
|
||||||
themes: embededThemes,
|
themes: embededThemes,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,44 +2,109 @@ package syntax
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestLanguageRegistryResolveByFiletype(t *testing.T) {
|
func TestLanguageRegistryResolve(t *testing.T) {
|
||||||
r := newLanguageRegistry()
|
r := newLanguageRegistry()
|
||||||
|
|
||||||
res, ok, err := r.resolve("go", "")
|
tests := []struct {
|
||||||
if err != nil {
|
name string
|
||||||
t.Fatalf("resolve error: %v", err)
|
args struct {
|
||||||
}
|
filetype string
|
||||||
if !ok || res == nil {
|
filename string
|
||||||
t.Fatalf("expected go to resolve")
|
|
||||||
}
|
|
||||||
if res.id != "go" {
|
|
||||||
t.Fatalf("expected go id, got %q", res.id)
|
|
||||||
}
|
}
|
||||||
|
expected string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "resolve by filetype",
|
||||||
|
args: struct {
|
||||||
|
filetype string
|
||||||
|
filename string
|
||||||
|
}{filetype: "go"},
|
||||||
|
expected: "go",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolve by extension",
|
||||||
|
args: struct {
|
||||||
|
filetype string
|
||||||
|
filename string
|
||||||
|
}{filename: "main.js"},
|
||||||
|
expected: "javascript",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "filetype has precedence over extension",
|
||||||
|
args: struct {
|
||||||
|
filetype string
|
||||||
|
filename string
|
||||||
|
}{filetype: "python", filename: "main.go"},
|
||||||
|
expected: "python",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normalizes case and whitespace",
|
||||||
|
args: struct {
|
||||||
|
filetype string
|
||||||
|
filename string
|
||||||
|
}{filetype: " Go "},
|
||||||
|
expected: "go",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown language does not resolve",
|
||||||
|
args: struct {
|
||||||
|
filetype string
|
||||||
|
filename string
|
||||||
|
}{filetype: "txt", filename: "notes.txt"},
|
||||||
|
expected: "",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLanguageRegistryResolveByExtension(t *testing.T) {
|
for _, tc := range tests {
|
||||||
r := newLanguageRegistry()
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
res, ok, err := r.resolve(tc.args.filetype, tc.args.filename)
|
||||||
res, ok, err := r.resolve("", "main.js")
|
if (err != nil) != tc.wantErr {
|
||||||
if err != nil {
|
t.Fatalf("resolve error = %v, wantErr=%v", err, tc.wantErr)
|
||||||
t.Fatalf("resolve error: %v", err)
|
|
||||||
}
|
|
||||||
if !ok || res == nil {
|
|
||||||
t.Fatalf("expected javascript to resolve")
|
|
||||||
}
|
|
||||||
if res.id != "javascript" {
|
|
||||||
t.Fatalf("expected javascript id, got %q", res.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLanguageRegistryUnknown(t *testing.T) {
|
if tc.expected == "" {
|
||||||
r := newLanguageRegistry()
|
|
||||||
|
|
||||||
res, ok, err := r.resolve("txt", "notes.txt")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error for unknown language, got: %v", err)
|
|
||||||
}
|
|
||||||
if ok || res != nil {
|
if ok || res != nil {
|
||||||
t.Fatalf("expected unknown language to not resolve")
|
t.Fatalf("expected unresolved language, got ok=%v res=%+v", ok, res)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok || res == nil {
|
||||||
|
t.Fatalf("expected language %q to resolve", tc.expected)
|
||||||
|
}
|
||||||
|
if res.id != tc.expected {
|
||||||
|
t.Fatalf("resolved id mismatch: got %q want %q", res.id, tc.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLanguageRegistryResolveReusesCompiledAssets(t *testing.T) {
|
||||||
|
r := newLanguageRegistry()
|
||||||
|
|
||||||
|
first, ok, err := r.resolve("go", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve error: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || first == nil {
|
||||||
|
t.Fatalf("expected first resolution to succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
second, ok, err := r.resolve("golang", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve error: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || second == nil {
|
||||||
|
t.Fatalf("expected second resolution to succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if first != second {
|
||||||
|
t.Fatalf("expected compiled assets to be reused for same language id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||||
|
"git.gophernest.net/azpect/TextEditor/internal/theme"
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/theme/themes"
|
"git.gophernest.net/azpect/TextEditor/internal/theme/themes"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
@ -231,6 +232,141 @@ func TestTreeSitterEngineLastLineEditDoesNotPanicAndRebuilds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTreeSitterEngineThemeChangeRebuildsWithNewCaptureStylesAfterInvalidation(t *testing.T) {
|
||||||
|
b := core.NewBufferBuilder().
|
||||||
|
WithFilename("sample.go").
|
||||||
|
WithFiletype("go").
|
||||||
|
WithLines([]string{"package main", "func main() {", " return", "}"}).
|
||||||
|
Build()
|
||||||
|
buf := &b
|
||||||
|
|
||||||
|
themeA := themes.NewDefaultTheme()
|
||||||
|
themeB := makeThemeWithCaptureOverrides(lipgloss.Color("#ffffff"), lipgloss.Color("#ff00ff"), lipgloss.Color("#00ffaa"))
|
||||||
|
|
||||||
|
engine := NewTreeSitterEngine(themeA)
|
||||||
|
engine.PrepareBuffer(buf, themeA)
|
||||||
|
|
||||||
|
line := buf.Line(0)
|
||||||
|
keywordIdx := strings.Index(line, "package")
|
||||||
|
if keywordIdx < 0 {
|
||||||
|
t.Fatalf("test setup failed: expected package keyword")
|
||||||
|
}
|
||||||
|
|
||||||
|
before := engine.LineStyleMap(buf, 0, themeA)
|
||||||
|
beforeStyle := before[keywordIdx]
|
||||||
|
if styleEquivalent(beforeStyle, themeA.Line) {
|
||||||
|
t.Fatalf("expected keyword style to differ from base style before theme switch")
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.InvalidateBuffer(buf)
|
||||||
|
engine.PrepareBuffer(buf, themeB)
|
||||||
|
|
||||||
|
after := engine.LineStyleMap(buf, 0, themeB)
|
||||||
|
afterStyle := after[keywordIdx]
|
||||||
|
if styleEquivalent(afterStyle, themeB.Line) {
|
||||||
|
t.Fatalf("expected keyword style to differ from base style after theme switch")
|
||||||
|
}
|
||||||
|
if styleEquivalent(beforeStyle, afterStyle) {
|
||||||
|
t.Fatalf("expected keyword style to change after theme switch and invalidation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTreeSitterEngineThemeChangeRebuildsFallbackLineStylesAfterInvalidation(t *testing.T) {
|
||||||
|
b := core.NewBufferBuilder().
|
||||||
|
WithFilename("notes.txt").
|
||||||
|
WithFiletype("txt").
|
||||||
|
WithLines([]string{"plain text"}).
|
||||||
|
Build()
|
||||||
|
buf := &b
|
||||||
|
|
||||||
|
themeA := themes.NewDefaultTheme()
|
||||||
|
themeB := makeThemeWithCaptureOverrides(lipgloss.Color("#101010"), lipgloss.Color("#ff00ff"), lipgloss.Color("#00ffaa"))
|
||||||
|
|
||||||
|
engine := NewTreeSitterEngine(themeA)
|
||||||
|
mapA := engine.LineStyleMap(buf, 0, themeA)
|
||||||
|
if len(mapA) == 0 {
|
||||||
|
t.Fatalf("expected non-empty style map for text line")
|
||||||
|
}
|
||||||
|
if !styleEquivalent(mapA[0], themeA.Line) {
|
||||||
|
t.Fatalf("expected fallback style to use first theme line style")
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.InvalidateBuffer(buf)
|
||||||
|
mapB := engine.LineStyleMap(buf, 0, themeB)
|
||||||
|
if !styleEquivalent(mapB[0], themeB.Line) {
|
||||||
|
t.Fatalf("expected fallback style to use second theme line style after invalidation")
|
||||||
|
}
|
||||||
|
if styleEquivalent(mapA[0], mapB[0]) {
|
||||||
|
t.Fatalf("expected fallback style to change after theme switch and invalidation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTreeSitterEngineUnsupportedApplyEditFallsBackToDefaultStyles(t *testing.T) {
|
||||||
|
b := core.NewBufferBuilder().
|
||||||
|
WithFilename("notes.txt").
|
||||||
|
WithFiletype("txt").
|
||||||
|
WithLines([]string{"just text"}).
|
||||||
|
Build()
|
||||||
|
buf := &b
|
||||||
|
|
||||||
|
editorTheme := themes.NewDefaultTheme()
|
||||||
|
engine := NewTreeSitterEngine(editorTheme)
|
||||||
|
|
||||||
|
engine.PrepareBuffer(buf, editorTheme)
|
||||||
|
baseline := engine.LineStyleMap(buf, 0, editorTheme)
|
||||||
|
if len(baseline) == 0 {
|
||||||
|
t.Fatalf("expected baseline style map")
|
||||||
|
}
|
||||||
|
|
||||||
|
var edit *core.BufferEdit
|
||||||
|
buf.OnChange = func(change core.BufferChange) {
|
||||||
|
edit = change.Edit
|
||||||
|
}
|
||||||
|
buf.SetLine(0, "still plain text")
|
||||||
|
if edit == nil {
|
||||||
|
t.Fatalf("expected edit metadata from SetLine")
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.ApplyEdit(buf, edit)
|
||||||
|
engine.PrepareBuffer(buf, editorTheme)
|
||||||
|
|
||||||
|
after := engine.LineStyleMap(buf, 0, editorTheme)
|
||||||
|
if len(after) != len([]rune(buf.Line(0))) {
|
||||||
|
t.Fatalf("style map length mismatch after unsupported apply edit")
|
||||||
|
}
|
||||||
|
for i := range after {
|
||||||
|
if !styleEquivalent(after[i], editorTheme.Line) {
|
||||||
|
t.Fatalf("expected fallback line style at rune %d after unsupported apply edit", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeThemeWithCaptureOverrides(lineFg, keywordFg, stringFg lipgloss.Color) theme.EditorTheme {
|
||||||
|
t := themes.NewDefaultTheme()
|
||||||
|
t.Line = t.Line.Foreground(lineFg)
|
||||||
|
t.Syntax.Group = cloneStyleMap(t.Syntax.Group)
|
||||||
|
t.Syntax.Exact = cloneStyleMap(t.Syntax.Exact)
|
||||||
|
t.Syntax.Group["keyword"] = lipgloss.NewStyle().Foreground(keywordFg)
|
||||||
|
t.Syntax.Group["string"] = lipgloss.NewStyle().Foreground(stringFg)
|
||||||
|
for key := range t.Syntax.Exact {
|
||||||
|
if strings.HasPrefix(key, "keyword") {
|
||||||
|
t.Syntax.Exact[key] = lipgloss.NewStyle().Foreground(keywordFg)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(key, "string") {
|
||||||
|
t.Syntax.Exact[key] = lipgloss.NewStyle().Foreground(stringFg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStyleMap(in map[string]lipgloss.Style) map[string]lipgloss.Style {
|
||||||
|
out := make(map[string]lipgloss.Style, len(in))
|
||||||
|
for k, v := range in {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func styleEquivalent(a, b lipgloss.Style) bool {
|
func styleEquivalent(a, b lipgloss.Style) bool {
|
||||||
return styleSignature(a) == styleSignature(b)
|
return styleSignature(a) == styleSignature(b)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,48 +3,281 @@ package syntax
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestMergeRanges(t *testing.T) {
|
func TestMergeRanges(t *testing.T) {
|
||||||
in := []lineRange{{start: 5, end: 8}, {start: 1, end: 2}, {start: 2, end: 4}, {start: 10, end: 10}}
|
tests := []struct {
|
||||||
out := mergeRanges(in)
|
name string
|
||||||
|
input []lineRange
|
||||||
|
expected []lineRange
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "overlapping and unsorted ranges are merged",
|
||||||
|
input: []lineRange{{start: 5, end: 8}, {start: 1, end: 2}, {start: 2, end: 4}, {start: 10, end: 10}},
|
||||||
|
expected: []lineRange{{start: 1, end: 8}, {start: 10, end: 10}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "adjacent ranges are merged",
|
||||||
|
input: []lineRange{{start: 0, end: 1}, {start: 2, end: 3}},
|
||||||
|
expected: []lineRange{{start: 0, end: 3}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input returns nil",
|
||||||
|
input: nil,
|
||||||
|
expected: nil,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if len(out) != 2 {
|
for _, tc := range tests {
|
||||||
t.Fatalf("expected 2 merged ranges, got %d", len(out))
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := mergeRanges(tc.input)
|
||||||
|
|
||||||
|
if tc.wantErr {
|
||||||
|
t.Fatalf("unexpected wantErr=true for mergeRanges")
|
||||||
}
|
}
|
||||||
if out[0].start != 1 || out[0].end != 8 {
|
|
||||||
t.Fatalf("unexpected first merged range: %+v", out[0])
|
if len(got) != len(tc.expected) {
|
||||||
|
t.Fatalf("unexpected merged range count: got %d want %d", len(got), len(tc.expected))
|
||||||
}
|
}
|
||||||
if out[1].start != 10 || out[1].end != 10 {
|
for i := range got {
|
||||||
t.Fatalf("unexpected second merged range: %+v", out[1])
|
if got[i] != tc.expected[i] {
|
||||||
|
t.Fatalf("range %d mismatch: got %+v want %+v", i, got[i], tc.expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizedDirtyRanges(t *testing.T) {
|
func TestNormalizedDirtyRanges(t *testing.T) {
|
||||||
ranges := []lineRange{{start: -2, end: 1}, {start: 3, end: 99}}
|
tests := []struct {
|
||||||
out := normalizedDirtyRanges(ranges, 5)
|
name string
|
||||||
|
args struct {
|
||||||
|
ranges []lineRange
|
||||||
|
lineCount int
|
||||||
|
}
|
||||||
|
expected []lineRange
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "clamps negative and overflowing ranges",
|
||||||
|
args: struct {
|
||||||
|
ranges []lineRange
|
||||||
|
lineCount int
|
||||||
|
}{
|
||||||
|
ranges: []lineRange{{start: -2, end: 1}, {start: 3, end: 99}},
|
||||||
|
lineCount: 5,
|
||||||
|
},
|
||||||
|
expected: []lineRange{{start: 0, end: 1}, {start: 3, end: 4}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "drops invalid clamped ranges",
|
||||||
|
args: struct {
|
||||||
|
ranges []lineRange
|
||||||
|
lineCount int
|
||||||
|
}{
|
||||||
|
ranges: []lineRange{{start: 8, end: 9}},
|
||||||
|
lineCount: 5,
|
||||||
|
},
|
||||||
|
expected: nil,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "merges adjacent ranges after clamping",
|
||||||
|
args: struct {
|
||||||
|
ranges []lineRange
|
||||||
|
lineCount int
|
||||||
|
}{
|
||||||
|
ranges: []lineRange{{start: -3, end: 0}, {start: 1, end: 2}},
|
||||||
|
lineCount: 4,
|
||||||
|
},
|
||||||
|
expected: []lineRange{{start: 0, end: 2}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if len(out) != 2 {
|
for _, tc := range tests {
|
||||||
t.Fatalf("expected 2 normalized ranges, got %d", len(out))
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := normalizedDirtyRanges(tc.args.ranges, tc.args.lineCount)
|
||||||
|
if tc.wantErr {
|
||||||
|
t.Fatalf("unexpected wantErr=true for normalizedDirtyRanges")
|
||||||
}
|
}
|
||||||
if out[0].start != 0 || out[0].end != 1 {
|
|
||||||
t.Fatalf("unexpected first normalized range: %+v", out[0])
|
if len(got) != len(tc.expected) {
|
||||||
|
t.Fatalf("unexpected normalized range count: got %d want %d", len(got), len(tc.expected))
|
||||||
}
|
}
|
||||||
if out[1].start != 3 || out[1].end != 4 {
|
for i := range got {
|
||||||
t.Fatalf("unexpected second normalized range: %+v", out[1])
|
if got[i] != tc.expected[i] {
|
||||||
|
t.Fatalf("range %d mismatch: got %+v want %+v", i, got[i], tc.expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestByteColToRuneIndexUTF8(t *testing.T) {
|
func TestByteColToRuneIndexUTF8(t *testing.T) {
|
||||||
line := []byte("aéb")
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args struct {
|
||||||
|
line []byte
|
||||||
|
col int
|
||||||
|
}
|
||||||
|
expected int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "zero column maps to first rune",
|
||||||
|
args: struct {
|
||||||
|
line []byte
|
||||||
|
col int
|
||||||
|
}{line: []byte("aéb"), col: 0},
|
||||||
|
expected: 0,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "middle byte offset on multibyte rune",
|
||||||
|
args: struct {
|
||||||
|
line []byte
|
||||||
|
col int
|
||||||
|
}{line: []byte("aéb"), col: 1},
|
||||||
|
expected: 1,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "end of multibyte rune maps after rune",
|
||||||
|
args: struct {
|
||||||
|
line []byte
|
||||||
|
col int
|
||||||
|
}{line: []byte("aéb"), col: 3},
|
||||||
|
expected: 2,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "column at line end maps to rune length",
|
||||||
|
args: struct {
|
||||||
|
line []byte
|
||||||
|
col int
|
||||||
|
}{line: []byte("aéb"), col: len([]byte("aéb"))},
|
||||||
|
expected: 3,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if got := byteColToRuneIndex(line, 0); got != 0 {
|
for _, tc := range tests {
|
||||||
t.Fatalf("expected 0, got %d", got)
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := byteColToRuneIndex(tc.args.line, tc.args.col)
|
||||||
|
if tc.wantErr {
|
||||||
|
t.Fatalf("unexpected wantErr=true for byteColToRuneIndex")
|
||||||
}
|
}
|
||||||
if got := byteColToRuneIndex(line, 1); got != 1 {
|
if got != tc.expected {
|
||||||
t.Fatalf("expected 1, got %d", got)
|
t.Fatalf("unexpected rune index: got %d want %d", got, tc.expected)
|
||||||
}
|
}
|
||||||
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)
|
func TestAddDirtyRangeNormalizesAndMerges(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args struct {
|
||||||
|
initial []lineRange
|
||||||
|
start int
|
||||||
|
end int
|
||||||
|
}
|
||||||
|
expected []lineRange
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "swaps start and end when reversed",
|
||||||
|
args: struct {
|
||||||
|
initial []lineRange
|
||||||
|
start int
|
||||||
|
end int
|
||||||
|
}{initial: nil, start: 7, end: 3},
|
||||||
|
expected: []lineRange{{start: 3, end: 7}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clamps negative values",
|
||||||
|
args: struct {
|
||||||
|
initial []lineRange
|
||||||
|
start int
|
||||||
|
end int
|
||||||
|
}{initial: nil, start: -5, end: -1},
|
||||||
|
expected: []lineRange{{start: 0, end: 0}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "merges with existing adjacent range",
|
||||||
|
args: struct {
|
||||||
|
initial []lineRange
|
||||||
|
start int
|
||||||
|
end int
|
||||||
|
}{initial: []lineRange{{start: 1, end: 2}}, start: 3, end: 4},
|
||||||
|
expected: []lineRange{{start: 1, end: 4}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
bc := &bufferCache{dirty: append([]lineRange{}, tc.args.initial...)}
|
||||||
|
addDirtyRange(bc, tc.args.start, tc.args.end)
|
||||||
|
if tc.wantErr {
|
||||||
|
t.Fatalf("unexpected wantErr=true for addDirtyRange")
|
||||||
|
}
|
||||||
|
if len(bc.dirty) != len(tc.expected) {
|
||||||
|
t.Fatalf("unexpected dirty range count: got %d want %d", len(bc.dirty), len(tc.expected))
|
||||||
|
}
|
||||||
|
for i := range bc.dirty {
|
||||||
|
if bc.dirty[i] != tc.expected[i] {
|
||||||
|
t.Fatalf("dirty range %d mismatch: got %+v want %+v", i, bc.dirty[i], tc.expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRowInRanges(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input struct {
|
||||||
|
row int
|
||||||
|
ranges []lineRange
|
||||||
|
}
|
||||||
|
expected bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "row inside range",
|
||||||
|
input: struct {
|
||||||
|
row int
|
||||||
|
ranges []lineRange
|
||||||
|
}{row: 3, ranges: []lineRange{{start: 1, end: 4}}},
|
||||||
|
expected: true,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "row outside all ranges",
|
||||||
|
input: struct {
|
||||||
|
row int
|
||||||
|
ranges []lineRange
|
||||||
|
}{row: 8, ranges: []lineRange{{start: 1, end: 4}, {start: 10, end: 12}}},
|
||||||
|
expected: false,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := rowInRanges(tc.input.row, tc.input.ranges)
|
||||||
|
if tc.wantErr {
|
||||||
|
t.Fatalf("unexpected wantErr=true for rowInRanges")
|
||||||
|
}
|
||||||
|
if got != tc.expected {
|
||||||
|
t.Fatalf("unexpected result: got %v want %v", got, tc.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user