Compare commits

...

31 Commits

Author SHA1 Message Date
Hayden Hargreaves
501d15e410 WIP: working on search execution
Some checks failed
Run Test Suite / test (push) Failing after 29s
2026-04-09 15:17:07 -07:00
Hayden Hargreaves
514c77c1af feat: added search actions and motions: 2026-04-09 14:36:18 -07:00
Hayden Hargreaves
1aa1954d35 feat: implemented the % motion! tested as well
All checks were successful
Run Test Suite / test (push) Successful in 42s
2026-04-09 09:32:25 -07:00
Hayden Hargreaves
b808e75a38 doc: updated v0.1.md 2026-04-08 21:07:52 -07:00
Hayden Hargreaves
31629d1908 feat: implemented horizontal scroll, tested
All checks were successful
Run Test Suite / test (push) Successful in 42s
Updated lots of pieces with this, but it looks good.
2026-04-08 21:06:27 -07:00
Hayden Hargreaves
9899d1af1f doc: renamed it
All checks were successful
Run Test Suite / test (push) Successful in 33s
2026-04-08 17:39:49 -07:00
Hayden Hargreaves
718e6022da doc: updated V0.1.md 2026-04-08 17:39:27 -07:00
Hayden Hargreaves
9926252bf8 chore: removed old, testing file
All checks were successful
Run Test Suite / test (push) Successful in 36s
2026-04-08 17:29:33 -07:00
Hayden Hargreaves
d270927ff7 chore: agents are primary now, I like this more
All checks were successful
Run Test Suite / test (push) Successful in 36s
2026-04-08 17:28:47 -07:00
Hayden Hargreaves
e27c7560a2 fix: fixed issues the @janitor found 2026-04-08 17:28:01 -07:00
Hayden Hargreaves
64c448c639 test: updated tests and pulled theme into EditorSettings.
All checks were successful
Run Test Suite / test (push) Successful in 37s
2026-04-08 17:19:32 -07:00
Hayden Hargreaves
2cfa17705b feat: created some agents :) 2026-04-08 17:06:55 -07:00
Hayden Hargreaves
4d96c0a531 test: updated colorscheme related tests. tested loader
All checks were successful
Run Test Suite / test (push) Successful in 35s
2026-04-08 12:57:33 -07:00
Hayden Hargreaves
197cc7681d feat: last few themes for now 2026-04-08 12:33:24 -07:00
Hayden Hargreaves
b9b973925d feat: tons of themes! 2026-04-08 12:25:20 -07:00
Hayden Hargreaves
273be90d42 feat: HUGE refactor of colorschemes, untested.
Now we can load them in via JSON files at launch time. They are embded
in the final exe though...
2026-04-08 11:59:49 -07:00
Hayden Hargreaves
be13f8838d fix: remove the styles package, fixed tests
All checks were successful
Run Test Suite / test (push) Successful in 31s
However, the colorscheme functions and tests do not work anymore, they
need to be rebuilt.
2026-04-07 22:43:00 -07:00
Hayden Hargreaves
1c2585b8d9 feat: Implemented syntax styles
Treesitter integration implemented! But tests are failing, need to
resolve that.
2026-04-07 22:34:42 -07:00
Hayden Hargreaves
77416bc0a4 feat: created theme module
This means we can finally create some themes! But the treesitter mapping
is not complete.
2026-04-07 21:44:34 -07:00
Hayden Hargreaves
760770c564 doc: cleaned up a bit. 2026-04-07 20:21:41 -07:00
Hayden Hargreaves
76f949a6b2 fix: fixed the html highlights 2026-04-07 19:37:27 -07:00
Hayden Hargreaves
6034e44364 feat: implemented more languages
All checks were successful
Run Test Suite / test (push) Successful in 35s
HTML isnt great, but I guess there isnt much to color, or maybe its just
the styles. The next step is making the colorschemes.
2026-04-07 11:40:34 -07:00
Hayden Hargreaves
760b7dd15a fix: this got left in 2026-04-07 11:08:24 -07:00
Hayden Hargreaves
624439a0cf feat: implemented abstraction for the queries and regs 2026-04-07 11:01:07 -07:00
Hayden Hargreaves
7c15f41ab1 fix: removed chroma from project 2026-04-07 10:35:04 -07:00
Hayden Hargreaves
16d1318c22 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.
2026-04-07 10:23:25 -07:00
Hayden Hargreaves
f96c1c1302 init: starting up the treesitter parsing 2026-04-06 22:31:40 -07:00
Hayden Hargreaves
bc08213f07 doc: updated doc status 2026-04-06 21:17:58 -07:00
Hayden Hargreaves
32fe3f1edd fix: fixed replace action (r) in visual mode, tested
All checks were successful
Run Test Suite / test (push) Successful in 18s
2026-04-06 21:14:56 -07:00
Hayden Hargreaves
7a7472fd12 feat: implemtned H, M, L screen actions, tested
All checks were successful
Run Test Suite / test (push) Successful in 17s
2026-04-06 21:04:51 -07:00
Hayden Hargreaves
f17573edd2 feat: impl and tested J and gJ 2026-04-06 19:24:47 -07:00
109 changed files with 14383 additions and 870 deletions

View File

@ -0,0 +1,42 @@
---
description: Identifies dead code, unused dependencies, and structural bloat in Go projects
mode: primary
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.

View File

@ -0,0 +1,46 @@
---
description: Reviews Go code for idiomatic patterns, performance, and concurrency safety
mode: primary
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.

View File

@ -0,0 +1,38 @@
---
description: Generates and reviews Go tests, specializing in table-driven patterns and teatest TUI validation
mode: primary
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.

View File

@ -0,0 +1,465 @@
---
name: gim
description: Guidelines for working with the Gim vim-like terminal text editor
license: Proprietary
compatibility: opencode
metadata:
language: Go 1.25.5
framework: BubbleTea
module: git.gophernest.net/azpect/TextEditor
---
# Gim — Agent Skill Guide
Gim is a vim-like terminal text editor written in Go using the BubbleTea TUI framework. This document gives AI agents everything needed to contribute effectively.
---
## Project Identity
| Field | Value |
|-------|-------|
| Module path | `git.gophernest.net/azpect/TextEditor` |
| Go version | 1.25.5 |
| Entry point | `cmd/gim/main.go` |
| TUI framework | BubbleTea v1.3.10 |
| Styling | Lipgloss v1.1.0 |
| Syntax highlighting | Chroma v2.23.1 |
| Testing | teatest (charmbracelet/x/exp/teatest) |
---
## Directory Layout
```
Gim/
├── cmd/gim/
│ └── main.go # Binary entry point
├── internal/
│ ├── action/ # Executable actions (implement Action interface)
│ │ ├── interface.go # Core interfaces: Action, Motion, Operator, etc.
│ │ ├── insert.go # Insert mode entry actions (i, a, o, O, I, A, C, s, S)
│ │ ├── delete.go # Delete actions (x, X, dd, D)
│ │ ├── replace.go # Replace mode actions (r, R)
│ │ ├── find.go # Find char motions (f, F, t, T, ;, ,)
│ │ ├── command.go # Command mode state management
│ │ ├── command_history.go # Command history navigation
│ │ ├── paste.go # Paste (p, P)
│ │ ├── yank.go # Yank (y, yy, Y)
│ │ ├── undo.go # Undo/redo (u, ctrl+r)
│ │ ├── visual.go # Visual mode entry (v, V, ctrl+v)
│ │ ├── scroll.go # Scroll actions (ctrl+d, ctrl+u, etc.)
│ │ └── repeat.go # Dot operator (.)
│ ├── command/ # Ex command mode (:) registry and handlers
│ │ ├── registry.go # Command registration and lookup
│ │ └── handlers.go # Handler functions (:w, :q, :wq, etc.)
│ ├── core/ # Shared data structures and types
│ │ ├── types.go # Mode, MotionType, RegisterType enums
│ │ ├── buffer.go # Buffer struct and operations
│ │ ├── gap_buffer.go # GapBuffer (efficient per-line editing)
│ │ ├── window.go # Window (viewport over a buffer)
│ │ ├── position.go # Position{Line, Col} type
│ │ ├── register.go # Register (vim clipboard) types
│ │ ├── undo.go # UndoStack (UndoStack, Change, ChangeBlock)
│ │ ├── settings.go # EditorSettings (NewDefaultSettings)
│ │ └── command.go # Command parsing helpers
│ ├── editor/ # BubbleTea Model — orchestrates everything
│ │ ├── model.go # Model struct definition
│ │ ├── init.go # tea.Model Init()
│ │ ├── update.go # tea.Model Update()
│ │ ├── view.go # tea.Model View()
│ │ ├── builder.go # ModelBuilder (fluent constructor)
│ │ ├── helpers_test.go # Shared test helpers (all tests use these)
│ │ └── integration_*_test.go # Integration tests by feature area
│ ├── input/ # Key-input state machine
│ │ ├── handler.go # InputState machine, key dispatch
│ │ └── keymap.go # Keybinding registries per mode
│ ├── motion/ # Motion implementations
│ │ ├── basic.go # h, j, k, l
│ │ ├── jump.go # gg, G, 0, ^, $, %, {, }
│ │ └── word.go # w, W, b, B, e, E
│ ├── operator/ # Operator implementations
│ │ ├── delete.go # d{motion}, dd
│ │ ├── yank.go # y{motion}, yy
│ │ └── change.go # c{motion}, cc
│ ├── program/ # BubbleTea program bootstrap
│ │ └── program_builder.go # ProgramBuilder (EmptyProgram, FileProgram)
│ ├── style/ # Visual rendering
│ │ └── style.go # Lipgloss styles, Chroma syntax highlighting
│ ├── textobject/ # Text object ranges
│ │ └── word.go # iw, aw, iW, aW
│ └── theme/ # Color themes
│ └── theme.go # RegisterAll(), kanagawa-wave default
```
---
## Key Interfaces (`internal/action/interface.go`)
All extension points in the editor are interface-based. Implementing the right interface is how new features are added.
```go
// Action — any executable editor command
type Action interface {
Execute(m Model) tea.Cmd
}
// Motion — moves the cursor; returns a range type
type Motion interface {
Action
Type() core.MotionType // CharwiseExclusive | CharwiseInclusive | Linewise
}
// Operator — acts on a range defined by a motion
type Operator interface {
Operate(m Model, start, end core.Position, mtype core.MotionType) tea.Cmd
}
// DoublePresser — supports the doubled form (dd, yy, cc)
type DoublePresser interface {
DoublePress(m Model, count int) tea.Cmd
}
// Repeatable — action that accepts a leading count (3w, 5dd)
type Repeatable interface {
WithCount(n int) Action
}
// CharMotion — motion that needs a char argument (f, F, t, T)
type CharMotion interface {
Motion
WithChar(char string) Motion
}
// Resolvable — motion whose behavior depends on model state at execution time
type Resolvable interface {
Motion
Resolve(m Model) Motion
}
// TextObject — selects a structured range (iw, aw, iW, aW)
type TextObject interface {
GetRange(m Model, cursor core.Position, modifier string) (start, end core.Position, mtype core.MotionType)
}
```
`action.Model` is the main interface that all actions receive — it provides access to windows, buffers, mode, registers, and input dispatch.
---
## Core Types (`internal/core/`)
```go
// Position — line and column (both 0-indexed)
type Position struct{ Line, Col int }
// Mode
type Mode int
const (
NormalMode, InsertMode, CommandMode, CommandOutputMode,
VisualMode, VisualLineMode, VisualBlockMode, ReplaceMode, WaitingMode
)
// MotionType — determines how operator ranges are calculated
type MotionType int
const (
CharwiseExclusive // end col not included (w, b, h, l, 0, ^)
CharwiseInclusive // end col included (e, $, f)
Linewise // whole lines (j, k, G, gg, {, })
)
// RegisterType
type RegisterType int
const (
CharwiseRegister, LinewiseRegister, BlockwiseRegister
)
// Buffer — document held in memory
type Buffer struct {
Id, Type int
Filename string
Filetype string
Lines []*GapBuffer
Modified bool
UndoStack *UndoStack
}
// Window — viewport over a buffer
type Window struct {
Id, Number int
Buffer *Buffer
Cursor Position
Anchor Position // visual mode anchor
ScrollY int
Height int
Width int
}
```
---
## Adding Features — Step-by-Step Patterns
### New Normal-Mode Motion
1. Create `internal/motion/<name>.go`:
```go
package motion
type MyMotion struct{ Count int }
func (m MyMotion) Execute(model action.Model) tea.Cmd {
// Move model.ActiveWindow().Cursor
return nil
}
func (m MyMotion) Type() core.MotionType { return core.CharwiseExclusive }
func (m MyMotion) WithCount(n int) action.Action { return MyMotion{Count: n} }
```
2. Register in `internal/input/keymap.go` inside `NewNormalKeymap()`:
```go
motions: map[string]action.Motion{
"X": motion.MyMotion{},
}
```
3. Write integration tests in `internal/editor/integration_motion_<name>_test.go`.
### New Operator
1. Create `internal/operator/<name>.go`:
```go
package operator
type MyOp struct{}
func (o MyOp) Operate(m action.Model, start, end core.Position, mt core.MotionType) tea.Cmd {
// Act on the range [start, end)
return nil
}
// Double-press support (e.g. "xx" acts on current line)
var _ action.DoublePresser = MyOp{}
func (o MyOp) DoublePress(m action.Model, count int) tea.Cmd { return nil }
```
2. Register in `internal/input/keymap.go`:
```go
operators: map[string]action.Operator{
"X": operator.MyOp{},
}
```
3. Write integration tests in `internal/editor/integration_operator_<name>_test.go`.
### New Standalone Action
1. Create `internal/action/<name>.go`:
```go
package action
type MyAction struct{ Count int }
func (a MyAction) Execute(m Model) tea.Cmd {
// Mutate model state
return nil
}
func (a MyAction) WithCount(n int) Action { return MyAction{Count: n} }
```
2. Register in `internal/input/keymap.go`:
```go
actions: map[string]action.Action{
"X": action.MyAction{},
}
```
### New Ex Command (`:mycommand`)
1. Add handler in `internal/command/handlers.go`:
```go
func handleMyCommand(m action.Model, args []string, force bool) tea.Cmd {
// force = true when called as :mycommand!
return nil
}
```
2. Register in `internal/command/registry.go` inside `registerDefaults()`:
```go
r.Register(Command{
Name: "mycommand",
ShortForm: "myc",
Handler: handleMyCommand,
})
```
---
## Testing
### Framework
All tests use **teatest** — a BubbleTea test driver that runs the full model loop. Integration tests live in `internal/editor/`. Unit tests for isolated logic live alongside their package (`*_test.go` next to the source).
### Shared Test Helpers (`internal/editor/helpers_test.go`)
```go
// Create a default test model
newTestModel(t *testing.T) *teatest.TestModel
// Create a model with specific initial buffer content
newTestModelWithLines(t *testing.T, lines []string) *teatest.TestModel
// Create a model with content and cursor at a specific position
newTestModelWithLinesAndCursorPos(t *testing.T, lines []string, pos core.Position) *teatest.TestModel
// Functional options variant (preferred for complex setups)
newTestModelWithOptions(t *testing.T, opts ...TestModelOption) *teatest.TestModel
// Options:
WithLines(lines []string)
WithCursorPos(pos core.Position)
WithTermSize(width, height int)
WithRegister(name rune, regType core.RegisterType, content []string)
// Send one or more keys to the model
sendKeys(tm *teatest.TestModel, keys ...string)
// Send a string of keys as a sequence
sendKeyString(tm *teatest.TestModel, keyString string)
// Retrieve the final model state for assertions
getFinalModel(t *testing.T, tm *teatest.TestModel) *editor.Model
```
### Test File Naming Convention
| File | Coverage area |
|------|---------------|
| `integration_motion_basic_test.go` | h, j, k, l |
| `integration_motion_jump_test.go` | gg, G, 0, $, %, {, } |
| `integration_motion_word_test.go` | w, W, b, B, e, E |
| `integration_operator_delete_test.go` | d{motion}, dd |
| `integration_operator_change_test.go` | c{motion}, cc |
| `integration_operator_yank_test.go` | y{motion}, yy |
| `integration_insert_test.go` | i, a, o, O, I, A, C, s, S |
| `integration_delete_test.go` | x, X, D, dd |
| `integration_visual_test.go` | v, V, ctrl+v |
| `integration_paste_test.go` | p, P |
| `integration_yank_test.go` | y, yy, Y |
| `integration_repeat_test.go` | . (dot operator) |
| `integration_replace_test.go` | r, R |
| `integration_undo_test.go` | u, ctrl+r |
| `integration_scroll_test.go` | ctrl+d, ctrl+u, etc. |
| `integration_command_test.go` | :w, :q, :wq, etc. |
| `integration_textobject_test.go` | iw, aw, iW, aW |
### Example Integration Test
```go
func TestMyFeature(t *testing.T) {
t.Run("moves forward one word", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"hello world"})
sendKeys(tm, "w")
m := getFinalModel(t, tm)
pos := m.ActiveWindow().Cursor
if pos.Col != 6 {
t.Errorf("expected col 6, got %d", pos.Col)
}
})
}
```
Run all tests:
```
go test ./...
```
Run a specific package:
```
go test git.gophernest.net/azpect/TextEditor/internal/editor
```
---
## Input State Machine (`internal/input/handler.go`)
The handler dispatches key presses through a state machine. Understanding states is critical when tracing how a key sequence is processed.
| State | Meaning |
|-------|---------|
| `StateReady` | Waiting for first key of a command |
| `StateCount` | Accumulating leading count digits (e.g. `12`) |
| `StateOperatorPending` | Operator received, waiting for motion (e.g. after `d`) |
| `StateMotionCount` | Accumulating count after operator (e.g. `d3`) |
| `StateWaitingForChar` | f/t/F/T received, waiting for target char |
| `StateWaitingForTextObject` | i/a received in operator pending, waiting for object (w/W) |
---
## BubbleTea Elm Architecture
```
tea.KeyMsg
editor.Update()
input.Handler.Handle(key)
action.Action.Execute(model) ← motions, operators, actions all land here
model state mutation
editor.View() ← renders buffer with Lipgloss + Chroma
```
The editor model implements `tea.Model`:
- `Init() tea.Cmd` — returns nil (no startup IO)
- `Update(msg tea.Msg) (tea.Model, tea.Cmd)` — handles key, resize, and mouse messages
- `View() string` — renders current state as a string
---
## Code Conventions
- **Comment format:** `// ReceiverType.MethodName: description` on every exported method
- **Builder pattern** for complex structs — `NewBufferBuilder().WithFilename(f).Build()`
- **Functional options** for test setup — `WithLines(...)`, `WithCursorPos(...)`
- **Interface guards** for compile-time checks: `var _ action.DoublePresser = MyOp{}`
- **No panics** in normal paths — return early or propagate via `tea.Cmd`
- **No global mutable state** — all state lives inside `editor.Model`
- `tea.Cmd` return value is almost always `nil` unless async IO is needed
---
## Build & Run
```bash
# Run the editor (no file)
go run ./cmd/gim
# Run with a file
go run ./cmd/gim myfile.go
# Build binary
go build -o gim ./cmd/gim
# Run all tests
go test ./...
# Run tests with verbose output
go test -v ./internal/editor/...
```
---
## What NOT to Change Without Care
- `internal/core/types.go` — changing `MotionType` or `Mode` constants affects the entire codebase
- `internal/action/interface.go` — changing `Action` or `Model` interfaces requires updating all implementations
- `internal/input/keymap.go` — duplicate key registrations silently shadow each other; verify no conflicts
- `internal/editor/update.go` — the central dispatch loop; changes here affect all modes

View File

@ -27,9 +27,9 @@
### File Movement ### File Movement
- [x] `G` - Move to bottom of file (or line N with count) - [x] `G` - Move to bottom of file (or line N with count)
- [x] `gg` - Move to top of file (or line N with count) - [x] `gg` - Move to top of file (or line N with count)
- [ ] `H` - Move to top of screen - [x] `H` - Move to top of screen
- [ ] `M` - Move to middle of screen - [x] `M` - Move to middle of screen
- [ ] `L` - Move to bottom of screen - [x] `L` - Move to bottom of screen
### Scroll ### Scroll
- [x] `ctrl+u` - Scroll up half page - [x] `ctrl+u` - Scroll up half page
@ -57,7 +57,7 @@
- [ ] `#` - Search word under cursor backward - [ ] `#` - Search word under cursor backward
### Other Movement ### Other Movement
- [ ] `%` - Jump to matching bracket - [x] `%` - Jump to matching bracket
- [ ] `{` - Jump to previous paragraph - [ ] `{` - Jump to previous paragraph
- [ ] `}` - Jump to next paragraph - [ ] `}` - Jump to next paragraph
- [ ] `(` - Jump to previous sentence - [ ] `(` - Jump to previous sentence
@ -105,8 +105,8 @@
- [x] `x` - Delete character under cursor - [x] `x` - Delete character under cursor
- [x] `D` - Delete to end of line - [x] `D` - Delete to end of line
- [x] `X` - Delete character before cursor - [x] `X` - Delete character before cursor
- [ ] `J` - Join lines - [x] `J` - Join lines
- [ ] `gJ` - Join lines without space - [x] `gJ` - Join lines without space
### Yank/Paste ### Yank/Paste
- [x] `p` - Paste after cursor - [x] `p` - Paste after cursor
@ -164,8 +164,8 @@
- [ ] `~` - Swap case of selection - [ ] `~` - Swap case of selection
- [ ] `u` - Lowercase selection - [ ] `u` - Lowercase selection
- [ ] `U` - Uppercase selection - [ ] `U` - Uppercase selection
- [ ] `J` - Join selected lines
- [ ] `o` - Go to other end of selection - [ ] `o` - Go to other end of selection
- [ ] `J` - Join selected lines
- [ ] `O` - Go to other corner (block mode) - [ ] `O` - Go to other corner (block mode)
--- ---
@ -295,7 +295,7 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
### Buffer State ### Buffer State
- [ ] Track cursor position per buffer - [ ] Track cursor position per buffer
- [ ] Track undo history per buffer - [x] Track undo history per buffer
- [ ] Track marks per buffer - [ ] Track marks per buffer
- [ ] Remember scroll position when switching - [ ] Remember scroll position when switching
- [ ] Alternate buffer (`#`) tracking - [ ] Alternate buffer (`#`) tracking

View File

@ -4,17 +4,12 @@ import (
"os" "os"
"git.gophernest.net/azpect/TextEditor/internal/program" "git.gophernest.net/azpect/TextEditor/internal/program"
"git.gophernest.net/azpect/TextEditor/internal/theme"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// main: Entry point for the Gim text editor. Creates a buffer and window, // main: Entry point for the Gim text editor. Creates a buffer and window,
// initializes the editor model, and runs the BubbleTea TUI program. // initializes the editor model, and runs the BubbleTea TUI program.
func main() { func main() {
if err := theme.RegisterAll(); err != nil {
panic(err)
}
// <exe> <filename> // <exe> <filename>
args := os.Args[1:] args := os.Args[1:]

28
cmd/theme-loader/main.go Normal file
View File

@ -0,0 +1,28 @@
package main
import (
"fmt"
"sort"
"git.gophernest.net/azpect/TextEditor/internal/theme"
)
func main() {
themes, err := theme.LoadEmbeddedThemesJSON()
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", themes)
names := make([]string, 0, len(themes))
for name := range themes {
names = append(names, name)
}
sort.Strings(names)
fmt.Printf("loaded %d embedded themes:\n", len(names))
for _, name := range names {
fmt.Printf("- %s\n", name)
}
}

View File

@ -41,7 +41,7 @@
export GOOS=linux export GOOS=linux
export GOARCH=amd64 export GOARCH=amd64
export CGO_CFLAGS=-Wno-error=cpp; export CGO_CFLAGS=-Wno-error=cpp;
export CGO_ENABLED=0 export CGO_ENABLED=1
# Exec zsh to replace the current shell process with zsh. # Exec zsh to replace the current shell process with zsh.
# This ensures your prompt and zsh configurations load correctly. # This ensures your prompt and zsh configurations load correctly.

18
go.mod
View File

@ -3,10 +3,24 @@ module git.gophernest.net/azpect/TextEditor
go 1.25.5 go 1.25.5
require ( require (
github.com/alecthomas/chroma/v2 v2.23.1
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/exp/teatest v0.0.0-20260209132835-6b065b8ba62c github.com/charmbracelet/x/exp/teatest v0.0.0-20260209132835-6b065b8ba62c
github.com/tree-sitter/go-tree-sitter v0.25.0
github.com/tree-sitter/tree-sitter-bash v0.25.1
github.com/tree-sitter/tree-sitter-c v0.24.1
github.com/tree-sitter/tree-sitter-c-sharp v0.23.1
github.com/tree-sitter/tree-sitter-cpp v0.23.4
github.com/tree-sitter/tree-sitter-css v0.25.0
github.com/tree-sitter/tree-sitter-go v0.25.0
github.com/tree-sitter/tree-sitter-html v0.23.2
github.com/tree-sitter/tree-sitter-java v0.23.5
github.com/tree-sitter/tree-sitter-javascript v0.25.0
github.com/tree-sitter/tree-sitter-json v0.24.8
github.com/tree-sitter/tree-sitter-python v0.25.0
github.com/tree-sitter/tree-sitter-ruby v0.23.1
github.com/tree-sitter/tree-sitter-rust v0.24.2
github.com/tree-sitter/tree-sitter-typescript v0.23.2
) )
require ( require (
@ -20,11 +34,11 @@ require (
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-pointer v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect

54
go.sum
View File

@ -1,9 +1,3 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
@ -30,18 +24,18 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@ -50,8 +44,46 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU=
github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU=
github.com/tree-sitter/tree-sitter-bash v0.25.1 h1:ZD3MK4oDB5lAsFztqbdcyYEd24pxDtx3g9UOWA062rE=
github.com/tree-sitter/tree-sitter-bash v0.25.1/go.mod h1:AksQ6zE+sP9hnp7mKTMT7Q+CwpthV7VGQLXvweVXz9U=
github.com/tree-sitter/tree-sitter-c v0.24.1 h1:GV9DjvIV6uYe3W/JBKMFwE4hJcRxzRDq63llxNFHOkY=
github.com/tree-sitter/tree-sitter-c v0.24.1/go.mod h1:/SpJlv2BuiCgFA5xvtgukFGi51WxctByPUGDxPl60fc=
github.com/tree-sitter/tree-sitter-c-sharp v0.23.1 h1:ddG6osP34sMieVNN6lu5ZG/3N8Wn+67+43BmipqidyM=
github.com/tree-sitter/tree-sitter-c-sharp v0.23.1/go.mod h1:H7/aFm5vR1A8Yn5VIOfLWPdlKuJsMgZ5eDmaJdv8bY0=
github.com/tree-sitter/tree-sitter-cpp v0.23.4 h1:LaWZsiqQKvR65yHgKmnaqA+uz6tlDJTJFCyFIeZU/8w=
github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8=
github.com/tree-sitter/tree-sitter-css v0.25.0 h1:S5NbzhdZ5LE5V474wmdg+7NthmLjIg5v4wbyewMpziw=
github.com/tree-sitter/tree-sitter-css v0.25.0/go.mod h1:0Z46XCb3L16nVOVw0Lhb43pzloUG/4T6E/pAOE62fEw=
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2 h1:nFkkH6Sbe56EXLmZBqHHcamTpmz3TId97I16EnGy4rg=
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2/go.mod h1:HNPOhN0qF3hWluYLdxWs5WbzP/iE4aaRVPMsdxuzIaQ=
github.com/tree-sitter/tree-sitter-go v0.25.0 h1:cEB0Q3LHgZtS+ECHx9wcP7AwzoOddJFQCVmytX42cVU=
github.com/tree-sitter/tree-sitter-go v0.25.0/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040=
github.com/tree-sitter/tree-sitter-html v0.23.2 h1:1UYDV+Yd05GGRhVnTcbP58GkKLSHHZwVaN+lBZV11Lc=
github.com/tree-sitter/tree-sitter-html v0.23.2/go.mod h1:gpUv/dG3Xl/eebqgeYeFMt+JLOY9cgFinb/Nw08a9og=
github.com/tree-sitter/tree-sitter-java v0.23.5 h1:J9YeMGMwXYlKSP3K4Us8CitC6hjtMjqpeOf2GGo6tig=
github.com/tree-sitter/tree-sitter-java v0.23.5/go.mod h1:NRKlI8+EznxA7t1Yt3xtraPk1Wzqh3GAIC46wxvc320=
github.com/tree-sitter/tree-sitter-javascript v0.25.0 h1:ZkWETb66/w8cc13yhfnNuHOLDQWl3BnKlH6f9AdR88c=
github.com/tree-sitter/tree-sitter-javascript v0.25.0/go.mod h1:lmGD1EJdCA+v0S1u2fFgepMg/opzSg/4pgFym2FPGAs=
github.com/tree-sitter/tree-sitter-json v0.24.8 h1:tV5rMkihgtiOe14a9LHfDY5kzTl5GNUYe6carZBn0fQ=
github.com/tree-sitter/tree-sitter-json v0.24.8/go.mod h1:F351KK0KGvCaYbZ5zxwx/gWWvZhIDl0eMtn+1r+gQbo=
github.com/tree-sitter/tree-sitter-php v0.23.11 h1:iHewsLNDmznh8kgGyfWfujsZxIz1YGbSd2ZTEM0ZiP8=
github.com/tree-sitter/tree-sitter-php v0.23.11/go.mod h1:T/kbfi+UcCywQfUNAJnGTN/fMSUjnwPXA8k4yoIks74=
github.com/tree-sitter/tree-sitter-python v0.25.0 h1:O6XD9v8U1LOcRc3cNj9nM7XufrtEBezE6VrpRrHZDf0=
github.com/tree-sitter/tree-sitter-python v0.25.0/go.mod h1:cpdthSy/Yoa28aJFBscFHlGiU+cnSiSh1kuDVtI8YeM=
github.com/tree-sitter/tree-sitter-ruby v0.23.1 h1:T/NKHUA+iVbHM440hFx+lzVOzS4dV6z8Qw8ai+72bYo=
github.com/tree-sitter/tree-sitter-ruby v0.23.1/go.mod h1:kUS4kCCQloFcdX6sdpr8p6r2rogbM6ZjTox5ZOQy8cA=
github.com/tree-sitter/tree-sitter-rust v0.24.2 h1:NL4nF67ib21RMzzfvkmXlVwe45vvhW10DVyO+D0z/W0=
github.com/tree-sitter/tree-sitter-rust v0.24.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI=
github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0DbNVsU6Qocv9mfkyzdXwU=
github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
@ -62,3 +94,5 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -2,7 +2,7 @@ package action
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style" "git.gophernest.net/azpect/TextEditor/internal/theme"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@ -46,6 +46,12 @@ type Model interface {
CommandHistoryCursor() int CommandHistoryCursor() int
SetCommandHistoryCursor(cur int) SetCommandHistoryCursor(cur int)
// ==================================================
// Search Mode State
// ==================================================
SearchState() core.SearchState
SetSearchState(s core.SearchState)
// ================================================== // ==================================================
// Editor-wide State // Editor-wide State
// ================================================== // ==================================================
@ -54,8 +60,14 @@ type Model interface {
Settings() core.EditorSettings Settings() core.EditorSettings
SetSettings(s core.EditorSettings) SetSettings(s core.EditorSettings)
Styles() style.Styles
SetStyles(s style.Styles) // ==================================================
// Themes
// ==================================================
Theme() (string, theme.EditorTheme)
SetTheme(name string)
Themes() map[string]theme.EditorTheme
SetThemes(t map[string]theme.EditorTheme)
// ================================================== // ==================================================
// Registers // Registers

60
internal/action/join.go Normal file
View File

@ -0,0 +1,60 @@
package action
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type JoinLines struct {
Preserve bool
Count int
}
// WithCount sets the count (required by Repeatable interface)
func (m JoinLines) WithCount(n int) Action {
m.Count = n
return m
}
func (a JoinLines) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
y := win.Cursor.Line
var line strings.Builder
line.WriteString(buf.Line(y))
spacePending := false
for range max(1, a.Count-1) {
if (y + 1) >= buf.LineCount() {
break
}
l := buf.Line(y + 1)
if a.Preserve {
line.WriteString(l)
} else {
l = strings.TrimLeft(l, " ")
if l == "" {
spacePending = true
} else {
line.WriteString(" " + l)
spacePending = false
}
}
buf.DeleteLine(y + 1)
}
if !a.Preserve && spacePending {
line.WriteString(" ")
}
if line.String() != buf.Line(y) {
buf.SetLine(y, line.String())
}
return nil
}

View File

@ -2,7 +2,7 @@ package action
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style" "git.gophernest.net/azpect/TextEditor/internal/theme"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@ -23,7 +23,7 @@ type MockModel struct {
CommandHistoryList []string CommandHistoryList []string
CommandHistoryCur int CommandHistoryCur int
LastFindVal core.LastFindCommand LastFindVal core.LastFindCommand
StylesVal style.Styles ThemesMap map[string]theme.EditorTheme
LastChangeKeysList []string LastChangeKeysList []string
} }
@ -46,6 +46,7 @@ func NewMockModel() *MockModel {
SettingsVal: core.NewDefaultSettings(), SettingsVal: core.NewDefaultSettings(),
ModeVal: core.NormalMode, ModeVal: core.NormalMode,
RegistersMap: core.DefaultRegisters(), RegistersMap: core.DefaultRegisters(),
ThemesMap: map[string]theme.EditorTheme{"default": {}},
} }
} }
@ -64,6 +65,7 @@ func NewMockModelWithBuffer(buf *core.Buffer) *MockModel {
SettingsVal: core.NewDefaultSettings(), SettingsVal: core.NewDefaultSettings(),
ModeVal: core.NormalMode, ModeVal: core.NormalMode,
RegistersMap: core.DefaultRegisters(), RegistersMap: core.DefaultRegisters(),
ThemesMap: map[string]theme.EditorTheme{"default": {}},
} }
} }
@ -76,6 +78,7 @@ func NewMockModelWithWindow(win *core.Window) *MockModel {
SettingsVal: core.NewDefaultSettings(), SettingsVal: core.NewDefaultSettings(),
ModeVal: core.NormalMode, ModeVal: core.NormalMode,
RegistersMap: core.DefaultRegisters(), RegistersMap: core.DefaultRegisters(),
ThemesMap: map[string]theme.EditorTheme{"default": {}},
} }
} }
@ -117,8 +120,26 @@ func (m *MockModel) Mode() core.Mode { return m.ModeVal }
func (m *MockModel) SetMode(mode core.Mode) { m.ModeVal = mode } func (m *MockModel) SetMode(mode core.Mode) { m.ModeVal = mode }
func (m *MockModel) Settings() core.EditorSettings { return m.SettingsVal } 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) Styles() style.Styles { return m.StylesVal } func (m *MockModel) Theme() (string, theme.EditorTheme) {
func (m *MockModel) SetStyles(s style.Styles) { m.StylesVal = s } if m.ThemesMap != nil {
if t, ok := m.ThemesMap[m.SettingsVal.CurrentTheme]; ok {
return m.SettingsVal.CurrentTheme, t
}
if t, ok := m.ThemesMap["default"]; ok {
return "default", t
}
}
return m.SettingsVal.CurrentTheme, theme.EditorTheme{}
}
func (m *MockModel) SetTheme(name string) { m.SettingsVal.CurrentTheme = name }
func (m *MockModel) Themes() map[string]theme.EditorTheme {
if m.ThemesMap == nil {
m.ThemesMap = map[string]theme.EditorTheme{}
}
return m.ThemesMap
}
func (m *MockModel) SetThemes(t map[string]theme.EditorTheme) { m.ThemesMap = t }
// Registers // Registers
func (m *MockModel) Registers() map[rune]core.Register { return m.RegistersMap } func (m *MockModel) Registers() map[rune]core.Register { return m.RegistersMap }

View File

@ -35,6 +35,14 @@ func (a ReplaceChar) Execute(m Model) tea.Cmd {
buf.UndoStack.BeginBlock(win.Cursor) buf.UndoStack.BeginBlock(win.Cursor)
} }
switch m.Mode() {
case core.VisualMode:
replaceVisualCharSelection(m, a.Char)
case core.VisualLineMode:
replaceVisualLineSelection(m, a.Char)
case core.VisualBlockMode:
replaceVisualBlockSelection(m, a.Char)
default:
pos := win.Cursor.Col pos := win.Cursor.Col
line := buf.Line(win.Cursor.Line) line := buf.Line(win.Cursor.Line)
for i := 0; i < a.Count && pos < len(line); i++ { for i := 0; i < a.Count && pos < len(line); i++ {
@ -44,6 +52,8 @@ func (a ReplaceChar) Execute(m Model) tea.Cmd {
} }
win.SetCursorCol(pos - 1) win.SetCursorCol(pos - 1)
}
m.SetMode(core.NormalMode) m.SetMode(core.NormalMode)
if buf.UndoStack != nil { if buf.UndoStack != nil {
@ -53,6 +63,80 @@ func (a ReplaceChar) Execute(m Model) tea.Cmd {
return nil return nil
} }
func replaceVisualCharSelection(m Model, char string) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
start, end := normalizeSelection(m)
for y := start.Line; y <= end.Line; y++ {
line := buf.Line(y)
if len(line) == 0 {
continue
}
from := 0
to := len(line) - 1
if y == start.Line {
from = start.Col
}
if y == end.Line {
to = min(end.Col, len(line)-1)
}
if from < 0 {
from = 0
}
if from >= len(line) || to < from {
continue
}
replaced := strings.Repeat(char, to-from+1)
buf.SetLine(y, line[:from]+replaced+line[to+1:])
}
win.SetCursorPos(start.Line, start.Col)
}
func replaceVisualLineSelection(m Model, char string) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
start, end := normalizeSelection(m)
for y := start.Line; y <= end.Line; y++ {
line := buf.Line(y)
if len(line) == 0 {
continue
}
buf.SetLine(y, strings.Repeat(char, len(line)))
}
win.SetCursorPos(start.Line, 0)
}
func replaceVisualBlockSelection(m Model, char string) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
start, end := normalizeSelection(m)
startCol := min(start.Col, end.Col)
endCol := max(start.Col, end.Col)
for y := start.Line; y <= end.Line; y++ {
line := buf.Line(y)
if startCol >= len(line) {
continue
}
ec := min(endCol, len(line)-1)
replaced := strings.Repeat(char, ec-startCol+1)
buf.SetLine(y, line[:startCol]+replaced+line[ec+1:])
}
win.SetCursorPos(start.Line, startCol)
}
type EnterReplace struct { type EnterReplace struct {
Count int Count int
} }

178
internal/action/search.go Normal file
View File

@ -0,0 +1,178 @@
package action
import (
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
type EnterSearchMode struct {
Forward bool
}
func (a EnterSearchMode) Execute(m Model) tea.Cmd {
search := m.SearchState()
search.Forword = a.Forward
// BUG: Not sure if this is safe?
m.SetCommandOutput(nil)
m.SetSearchState(search)
m.SetMode(core.SearchMode)
return nil
}
type ExitSearchMode struct{}
func (a ExitSearchMode) Execute(m Model) tea.Cmd {
// Reset state
search := m.SearchState()
if strings.TrimSpace(search.Query) != "" {
search.History = append(search.History, search.Query)
}
search.Cursor = 0
search.Query = ""
search.HistoryCursor = 0
// TODO: Maybe we want to keep Query until we enter it again next, for N and n?
m.SetSearchState(search)
m.SetMode(core.NormalMode)
return nil
}
type InsertSearchChar struct {
Char string
}
// InsertSearchChar.Execute: Inserts a character at the search cursor position.
func (a InsertSearchChar) Execute(m Model) tea.Cmd {
search := m.SearchState()
cur := search.Cursor
query := search.Query
search.Query = query[:cur] + a.Char + query[cur:]
search.Cursor++
m.SetSearchState(search)
return nil
}
// SearchBackspace implements Action - deletes character before cursor in search mode.
type SearchBackspace struct{}
// SearchBackspace.Execute: Deletes the character before the search cursor (Backspace key).
func (a SearchBackspace) Execute(m Model) tea.Cmd {
search := m.SearchState()
cur := search.Cursor
query := search.Query
if cur > 0 {
search.Query = query[:cur-1] + query[cur:]
search.Cursor--
m.SetSearchState(search)
}
return nil
}
// SearchDelete implements Action - deletes character at cursor in search mode.
type SearchDelete struct{}
// SearchDelete.Execute: Deletes the character at the command cursor (Delete key).
func (a SearchDelete) Execute(m Model) tea.Cmd {
search := m.SearchState()
cur := search.Cursor
query := search.Query
if cur < len(query)-1 {
search.Query = query[:cur+1] + query[cur+2:]
} else if cur == len(query)-1 {
// last text char, delete it
search.Query = query[:cur] + query[cur+1:]
} else if cur == len(query) && cur > 0 {
// if at end, we do backspace op
search.Query = query[:cur-1] + query[cur:]
search.Cursor = max(0, search.Cursor-1)
}
m.SetSearchState(search)
return nil
}
// SearchDeletePreviousWord implements Action - deletes word before cursor in search mode.
type SearchDeletePreviousWord struct{}
// SearchDeletePreviousWord.Execute: Deletes the word before the search cursor (Ctrl+W).
func (a SearchDeletePreviousWord) Execute(m Model) tea.Cmd {
search := m.SearchState()
cur := search.Cursor
cmd := search.Query
if cur > 0 {
newCur := cur
// If we are on punctuation, we should just skip them all and quit
if isPunctuation(cmd[newCur-1]) {
for newCur > 0 && isPunctuation(cmd[newCur-1]) {
newCur--
}
search.Query = cmd[:newCur] + cmd[cur:]
search.Cursor = newCur
m.SetSearchState(search)
return nil
}
// Skip whitespace immediately before the cursor
for newCur > 0 && (cmd[newCur-1] == ' ' || cmd[newCur-1] == '\t') {
newCur--
}
// Skip the word characters before the cursor
for newCur > 0 && isWordChar(cmd[newCur-1]) {
newCur--
}
// Delete everything from newCur up to cur in one operation
search.Query = cmd[:newCur] + cmd[cur:]
search.Cursor = newCur
m.SetSearchState(search)
}
return nil
}
type SearchExecute struct{}
func (a SearchExecute) Execute(m Model) tea.Cmd {
search := m.SearchState()
// Exit normally
defer func() {
act := ExitSearchMode{}
act.Execute(m)
}()
if !search.Forword {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{"reverse search not implemented yet."},
Inline: true,
IsError: true,
})
return nil
}
// Do the search
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
return nil
}

View File

@ -12,8 +12,6 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
"github.com/alecthomas/chroma/v2/styles"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@ -883,29 +881,26 @@ func parseSetOption(m action.Model, opt string) error {
// Colorscheme Commands // Colorscheme Commands
// -------------------------------------------------- // --------------------------------------------------
// TODO: Implement this using the new colorschemes
func cmdColorscheme(m action.Model, args []string, force bool) tea.Cmd { func cmdColorscheme(m action.Model, args []string, force bool) tea.Cmd {
_ = force _ = force
// No args, just print the current scheme // No args, just print the current scheme
if len(args) == 0 { if len(args) == 0 {
s := m.Styles().ChromaStyle name, _ := m.Theme()
if s == nil {
return nil
}
m.SetCommandOutput(&core.CommandOutput{ m.SetCommandOutput(&core.CommandOutput{
Lines: []string{s.Name}, Lines: []string{name},
Inline: true, Inline: true,
IsError: false, IsError: false,
}) })
return nil return nil
} }
// Args given, set the scheme // Theme switching is disabled while migrating away from Chroma.
name := strings.Join(args, " ") name := strings.TrimSpace(strings.Join(args, " "))
_, found := m.Themes()[name]
chromaStyle := styles.Registry[name] if name == "" || !found {
if chromaStyle == nil {
m.SetCommandOutput(&core.CommandOutput{ m.SetCommandOutput(&core.CommandOutput{
Lines: []string{fmt.Sprintf("colorscheme not found: %s", name)}, Lines: []string{fmt.Sprintf("colorscheme not found: %s", name)},
Inline: true, Inline: true,
@ -914,14 +909,19 @@ func cmdColorscheme(m action.Model, args []string, force bool) tea.Cmd {
return nil return nil
} }
m.SetStyles(style.ChromaStyles(chromaStyle)) m.SetTheme(name)
return nil return nil
} }
func cmdListColorschemes(m action.Model, args []string, force bool) tea.Cmd { func cmdListColorschemes(m action.Model, args []string, force bool) tea.Cmd {
_, _ = args, force _, _ = args, force
colors := styles.Names() var colors []string
for k := range m.Themes() {
colors = append(colors, k)
}
slices.Sort(colors)
m.SetMode(core.CommandOutputMode) m.SetMode(core.CommandOutputMode)
m.SetCommandOutput(&core.CommandOutput{ m.SetCommandOutput(&core.CommandOutput{
@ -959,3 +959,27 @@ func cmdUndoList(m action.Model, args []string, force bool) tea.Cmd {
return nil return nil
} }
func cmdSearch(m action.Model, args []string, force bool) tea.Cmd {
_, _ = args, force
search := m.SearchState()
lines := []string{
fmt.Sprintf("Query: %s", search.Query),
fmt.Sprintf("Forward: %v", search.Forword),
fmt.Sprintf("Cursor: %d", search.Cursor),
fmt.Sprintf("HistoryCursor: %d", search.HistoryCursor),
fmt.Sprintf("History: %q", search.History),
}
m.SetMode(core.CommandOutputMode)
m.SetCommandOutput(&core.CommandOutput{
Title: ":search",
Lines: lines,
Inline: false,
IsError: false,
})
return nil
}

View File

@ -9,8 +9,7 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style" "git.gophernest.net/azpect/TextEditor/internal/theme"
cStyles "github.com/alecthomas/chroma/v2/styles"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@ -5508,40 +5507,52 @@ func TestCmdDeleteBuffer(t *testing.T) {
// ================================================== // ==================================================
func TestCmdColorscheme(t *testing.T) { func TestCmdColorscheme(t *testing.T) {
pickTheme := func(m *action.MockModel) string {
for name := range m.Themes() {
return name
}
return ""
}
// -------------------------------------------------- // --------------------------------------------------
// Group 1: Valid name — styles are updated // Group 1: Valid name — styles are updated
// -------------------------------------------------- // --------------------------------------------------
t.Run("valid name updates styles on model", func(t *testing.T) { // t.Run("valid name updates styles on model", func(t *testing.T) {
m := action.NewMockModel() // m := action.NewMockModel()
// // m.SetStyles(style.DefaultStyles())
cmdColorscheme(m, []string{"onedark"}, false) // before := m.StylesVal.BackgroundStyle.Render(" ")
//
name := m.Styles().ChromaStyle.Name // cmdColorscheme(m, []string{"default"}, false)
if name != "onedark" { //
t.Error("expected styles to change after setting a valid colorscheme") // after := m.StylesVal.BackgroundStyle.Render(" ")
} // if after != before {
}) // t.Error("expected default styles to remain stable after applying default")
// }
t.Run("same valid name applied twice produces same styles", func(t *testing.T) { // })
m := action.NewMockModel() //
// t.Run("same valid name applied twice produces same styles", func(t *testing.T) {
cmdColorscheme(m, []string{"monokai"}, false) // m := action.NewMockModel()
first := m.StylesVal.BackgroundStyle.Render(" ") //
// cmdColorscheme(m, []string{"default"}, false)
cmdColorscheme(m, []string{"monokai"}, false) // first := m.StylesVal.BackgroundStyle.Render(" ")
second := m.StylesVal.BackgroundStyle.Render(" ") //
// cmdColorscheme(m, []string{"default"}, false)
if first != second { // second := m.StylesVal.BackgroundStyle.Render(" ")
t.Error("expected applying the same colorscheme twice to produce identical styles") //
} // if first != second {
}) // t.Error("expected applying the same colorscheme twice to produce identical styles")
// }
// })
t.Run("valid name sets no error output", func(t *testing.T) { t.Run("valid name sets no error output", func(t *testing.T) {
m := action.NewMockModel() m := action.NewMockModel()
name := pickTheme(m)
if name == "" {
t.Fatal("expected at least one theme in mock")
}
cmdColorscheme(m, []string{"monokai"}, false) cmdColorscheme(m, []string{name}, false)
if m.CommandOutputVal != nil && m.CommandOutputVal.IsError { if m.CommandOutputVal != nil && m.CommandOutputVal.IsError {
t.Error("expected no error output for a valid colorscheme name") t.Error("expected no error output for a valid colorscheme name")
@ -5550,8 +5561,12 @@ func TestCmdColorscheme(t *testing.T) {
t.Run("valid name returns nil tea.Cmd", func(t *testing.T) { t.Run("valid name returns nil tea.Cmd", func(t *testing.T) {
m := action.NewMockModel() m := action.NewMockModel()
name := pickTheme(m)
if name == "" {
t.Fatal("expected at least one theme in mock")
}
cmd := cmdColorscheme(m, []string{"monokai"}, false) cmd := cmdColorscheme(m, []string{name}, false)
if cmd != nil { if cmd != nil {
t.Error("expected nil tea.Cmd for colorscheme command") t.Error("expected nil tea.Cmd for colorscheme command")
@ -5575,16 +5590,16 @@ func TestCmdColorscheme(t *testing.T) {
} }
}) })
t.Run("unknown name does not change styles", func(t *testing.T) { // t.Run("unknown name does not change styles", func(t *testing.T) {
m := action.NewMockModel() // m := action.NewMockModel()
before := m.StylesVal.BackgroundStyle.Render(" ") // before := m.StylesVal.BackgroundStyle.Render(" ")
//
cmdColorscheme(m, []string{"not-a-real-theme"}, false) // cmdColorscheme(m, []string{"not-a-real-theme"}, false)
//
if m.StylesVal.BackgroundStyle.Render(" ") != before { // if m.StylesVal.BackgroundStyle.Render(" ") != before {
t.Error("expected styles to remain unchanged after unknown colorscheme") // t.Error("expected styles to remain unchanged after unknown colorscheme")
} // }
}) // })
t.Run("empty string name sets error output", func(t *testing.T) { t.Run("empty string name sets error output", func(t *testing.T) {
m := action.NewMockModel() m := action.NewMockModel()
@ -5623,16 +5638,16 @@ func TestCmdColorscheme(t *testing.T) {
} }
}) })
t.Run("no args does not change styles", func(t *testing.T) { // t.Run("no args does not change styles", func(t *testing.T) {
m := action.NewMockModel() // m := action.NewMockModel()
before := m.StylesVal.BackgroundStyle.Render(" ") // before := m.StylesVal.BackgroundStyle.Render(" ")
//
cmdColorscheme(m, []string{}, false) // cmdColorscheme(m, []string{}, false)
//
if m.StylesVal.BackgroundStyle.Render(" ") != before { // if m.StylesVal.BackgroundStyle.Render(" ") != before {
t.Error("expected styles to remain unchanged when no args given") // t.Error("expected styles to remain unchanged when no args given")
} // }
}) // })
t.Run("no args returns nil tea.Cmd", func(t *testing.T) { t.Run("no args returns nil tea.Cmd", func(t *testing.T) {
m := action.NewMockModel() m := action.NewMockModel()
@ -5650,8 +5665,12 @@ func TestCmdColorscheme(t *testing.T) {
t.Run("extra args beyond name do not panic", func(t *testing.T) { t.Run("extra args beyond name do not panic", func(t *testing.T) {
m := action.NewMockModel() m := action.NewMockModel()
name := pickTheme(m)
if name == "" {
t.Fatal("expected at least one theme in mock")
}
cmdColorscheme(m, []string{"monokai", "extra", "args"}, false) cmdColorscheme(m, []string{name, "extra", "args"}, false)
}) })
// -------------------------------------------------- // --------------------------------------------------
@ -5660,11 +5679,14 @@ func TestCmdColorscheme(t *testing.T) {
t.Run("force flag with valid name still sets styles", func(t *testing.T) { t.Run("force flag with valid name still sets styles", func(t *testing.T) {
m := action.NewMockModel() m := action.NewMockModel()
name := pickTheme(m)
if name == "" {
t.Fatal("expected at least one theme in mock")
}
cmdColorscheme(m, []string{"monokai"}, true) cmdColorscheme(m, []string{name}, true)
name := m.Styles().ChromaStyle.Name if m.CommandOutputVal != nil && m.CommandOutputVal.IsError {
if name != "monokai" {
t.Error("expected styles to change with force=true and valid name") t.Error("expected styles to change with force=true and valid name")
} }
}) })
@ -5738,11 +5760,17 @@ func TestCmdListColorschemes(t *testing.T) {
t.Run("commandOutput lines contains known built-in styles", func(t *testing.T) { t.Run("commandOutput lines contains known built-in styles", func(t *testing.T) {
m := action.NewMockModel() m := action.NewMockModel()
m.SetThemes(map[string]theme.EditorTheme{
"kanagawa": {},
"kanagawa-dragon": {},
"kanagawa-lotus": {},
"tokyonight-storm": {},
})
cmdListColorschemes(m, []string{}, false) cmdListColorschemes(m, []string{}, false)
lines := m.CommandOutputVal.Lines lines := m.CommandOutputVal.Lines
known := []string{"monokai", "github-dark", "dracula"} known := []string{"kanagawa", "kanagawa-dragon", "kanagawa-lotus", "tokyonight-storm"}
for _, name := range known { for _, name := range known {
found := false found := false
for _, l := range lines { for _, l := range lines {
@ -5757,14 +5785,18 @@ func TestCmdListColorschemes(t *testing.T) {
} }
}) })
t.Run("commandOutput lines matches styles.Names()", func(t *testing.T) { t.Run("commandOutput lines matches themes map size", func(t *testing.T) {
m := action.NewMockModel() m := action.NewMockModel()
m.SetThemes(map[string]theme.EditorTheme{
"kanagawa": {},
"kanagawa-dragon": {},
"kanagawa-lotus": {},
})
cmdListColorschemes(m, []string{}, false) cmdListColorschemes(m, []string{}, false)
expected := cStyles.Names() if len(m.CommandOutputVal.Lines) != len(m.Themes()) {
if len(m.CommandOutputVal.Lines) != len(expected) { t.Errorf("expected %d colorschemes, got %d", len(m.Themes()), len(m.CommandOutputVal.Lines))
t.Errorf("expected %d colorschemes, got %d", len(expected), len(m.CommandOutputVal.Lines))
} }
}) })
@ -5783,9 +5815,19 @@ func TestCmdListColorschemes(t *testing.T) {
t.Run("args and force are ignored", func(t *testing.T) { t.Run("args and force are ignored", func(t *testing.T) {
m1 := action.NewMockModel() m1 := action.NewMockModel()
m2 := action.NewMockModel() m2 := action.NewMockModel()
m1.SetThemes(map[string]theme.EditorTheme{
"kanagawa": {},
"kanagawa-dragon": {},
"kanagawa-lotus": {},
})
m2.SetThemes(map[string]theme.EditorTheme{
"kanagawa": {},
"kanagawa-dragon": {},
"kanagawa-lotus": {},
})
cmdListColorschemes(m1, []string{}, false) cmdListColorschemes(m1, []string{}, false)
cmdListColorschemes(m2, []string{"monokai", "extra"}, true) cmdListColorschemes(m2, []string{"kanagawa", "extra"}, true)
if len(m1.CommandOutputVal.Lines) != len(m2.CommandOutputVal.Lines) { if len(m1.CommandOutputVal.Lines) != len(m2.CommandOutputVal.Lines) {
t.Error("expected args and force to have no effect on list output") t.Error("expected args and force to have no effect on list output")
@ -5802,5 +5844,3 @@ func TestCmdListColorschemes(t *testing.T) {
} }
}) })
} }
var _ = style.DefaultStyles

View File

@ -244,4 +244,10 @@ func (r *Registry) registerDefaults() {
ShortForm: "u", ShortForm: "u",
Handler: cmdUndoList, Handler: cmdUndoList,
}) })
r.Register(Command{
Name: "search",
ShortForm: "s",
Handler: cmdSearch,
})
} }

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ const (
VisualBlockMode VisualBlockMode
ReplaceMode ReplaceMode
WaitingMode // Same as NORMAL output, but cursor is the REPLACE cursor WaitingMode // Same as NORMAL output, but cursor is the REPLACE cursor
SearchMode
) )
// Mode.ToString: Returns a human-readable string representation of the mode // Mode.ToString: Returns a human-readable string representation of the mode
@ -25,6 +26,8 @@ func (m Mode) ToString() string {
return "INSERT" return "INSERT"
case CommandMode: case CommandMode:
return "COMMAND" return "COMMAND"
case SearchMode:
return "SEARCH"
case VisualMode: case VisualMode:
return "VISUAL" return "VISUAL"
case VisualLineMode: case VisualLineMode:

22
internal/core/search.go Normal file
View File

@ -0,0 +1,22 @@
package core
type SearchState struct {
Query string
Forword bool
Cursor int
// History is editor wide
History []string
HistoryCursor int
}
func NewSearchState() SearchState {
return SearchState{
Query: "",
Forword: true,
Cursor: 0,
History: []string{},
HistoryCursor: 0,
}
}

View File

@ -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: "default",
} }
} }

View File

@ -1,5 +1,7 @@
package core package core
import "strconv"
type WinOptions struct { type WinOptions struct {
Number bool Number bool
RelativeNumber bool RelativeNumber bool
@ -26,6 +28,7 @@ type Window struct {
Anchor Position Anchor Position
ScrollY int ScrollY int
ScrollX int
Height int Height int
Width int Width int
@ -70,17 +73,17 @@ func (w *Window) ClampCursor() {
} }
} }
// Window.AdjustScroll ensures the cursor stays within the height with scrollOff margins. // Window.AdjustScroll ensures the cursor stays within the visible viewport on both axes.
// Call this after any cursor movement. // Call this after any cursor movement.
func (w *Window) AdjustScroll() { func (w *Window) AdjustScroll() {
if w.Height <= 0 { if w.Buffer == nil || w.Height <= 0 {
return return
} }
viewPort := w.ViewportHeight() viewPortHeight := w.ViewportHeight()
// Effective scrollOff (can't be more than half the viewport) // Effective scrollOff (can't be more than half the viewport)
off := min(w.Options.ScrollOff, viewPort/2) off := min(w.Options.ScrollOff, viewPortHeight/2)
// Cursor too close to top — scroll up // Cursor too close to top — scroll up
if w.Cursor.Line < w.ScrollY+off { if w.Cursor.Line < w.ScrollY+off {
@ -88,13 +91,29 @@ func (w *Window) AdjustScroll() {
} }
// Cursor too close to bottom — scroll down // Cursor too close to bottom — scroll down
if w.Cursor.Line > w.ScrollY+viewPort-1-off { if w.Cursor.Line > w.ScrollY+viewPortHeight-1-off {
w.ScrollY = w.Cursor.Line - viewPort + 1 + off w.ScrollY = w.Cursor.Line - viewPortHeight + 1 + off
} }
// Clamp scrollY to valid range // Clamp scrollY to valid range
maxScroll := max(0, w.Buffer.LineCount()-viewPort) maxScroll := max(0, w.Buffer.LineCount()-viewPortHeight)
w.ScrollY = max(0, min(w.ScrollY, maxScroll)) w.ScrollY = max(0, min(w.ScrollY, maxScroll))
viewPortWidth := w.ViewportWidth()
if viewPortWidth <= 0 {
w.ScrollX = 0
return
}
if w.Cursor.Col < w.ScrollX {
w.ScrollX = w.Cursor.Col
} else if w.Cursor.Col >= w.ScrollX+viewPortWidth {
w.ScrollX = w.Cursor.Col - viewPortWidth + 1
}
lineLen := w.Buffer.Lines[w.Cursor.Line].Len()
maxScrollX := max(0, lineLen-viewPortWidth+1)
w.ScrollX = max(0, min(w.ScrollX, maxScrollX))
} }
// ================================================== // ==================================================
@ -109,6 +128,24 @@ func (w *Window) ViewportHeight() int {
return w.Height - 2 return w.Height - 2
} }
func (w *Window) GutterWidth() int {
if !(w.Options.Number || w.Options.RelativeNumber) {
return 0
}
lineCount := 1
if w.Buffer != nil {
lineCount = max(1, w.Buffer.LineCount())
}
maxLineLen := len(strconv.Itoa(lineCount))
return max(w.Options.GutterSize, maxLineLen+2)
}
func (w *Window) ViewportWidth() int {
return max(0, w.Width-w.GutterWidth())
}
// ================================================== // ==================================================
// Setters // Setters
// ================================================== // ==================================================

View File

@ -279,6 +279,115 @@ func TestWindow_AdjustScroll(t *testing.T) {
t.Error("cursor should be visible in small viewport") t.Error("cursor should be visible in small viewport")
} }
}) })
t.Run("scrolls right when cursor moves past visible width", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"0123456789abcdefghij"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithWidth(12).
WithHeight(10).
Build()
win.SetCursorCol(10)
win.AdjustScroll()
if win.ScrollX == 0 {
t.Fatal("expected horizontal scroll to move right")
}
viewport := win.ViewportWidth()
if win.Cursor.Col < win.ScrollX || win.Cursor.Col >= win.ScrollX+viewport {
t.Errorf("cursor at %d not visible in scroll range [%d, %d)",
win.Cursor.Col, win.ScrollX, win.ScrollX+viewport)
}
})
t.Run("scrolls left when cursor moves back into hidden content", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"0123456789abcdefghij"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithWidth(12).
WithHeight(10).
Build()
win.SetCursorCol(14)
win.AdjustScroll()
if win.ScrollX == 0 {
t.Fatal("expected initial horizontal scroll to move right")
}
win.SetCursorCol(2)
win.AdjustScroll()
if win.ScrollX != 2 {
t.Errorf("expected horizontal scroll to follow cursor left, got %d", win.ScrollX)
}
})
}
func TestWindow_AdjustScrollHorizontalRuneAware(t *testing.T) {
tests := []struct {
name string
line string
width int
cursorCol int
initialScroll int
expected int
}{
{
name: "ascii line scrolls using visible columns",
line: "0123456789abcdef",
width: 12,
cursorCol: 10,
expected: 4,
},
{
name: "multibyte rune line uses rune length not bytes",
line: "abécdefghij",
width: 10,
cursorCol: 10,
expected: 6,
},
{
name: "moving left pulls scroll back toward cursor",
line: "abécdefghij",
width: 10,
cursorCol: 2,
initialScroll: 6,
expected: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := NewBufferBuilder().WithLines([]string{tt.line}).Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithWidth(tt.width).
WithHeight(10).
Build()
win.ScrollX = tt.initialScroll
win.SetCursorCol(tt.cursorCol)
win.AdjustScroll()
if win.ScrollX != tt.expected {
t.Errorf("ScrollX() = %d, want %d", win.ScrollX, tt.expected)
}
viewport := win.ViewportWidth()
if viewport > 0 && (win.Cursor.Col < win.ScrollX || win.Cursor.Col >= win.ScrollX+viewport) {
t.Errorf("cursor at %d not visible in scroll range [%d, %d)",
win.Cursor.Col, win.ScrollX, win.ScrollX+viewport)
}
})
}
} }
func TestWindow_ViewportHeight(t *testing.T) { func TestWindow_ViewportHeight(t *testing.T) {
@ -327,6 +436,34 @@ func TestWindow_ViewportHeight(t *testing.T) {
}) })
} }
func TestWindow_ViewportWidth(t *testing.T) {
t.Run("subtracts gutter width", func(t *testing.T) {
buf := NewBufferBuilder().WithLines([]string{"line"}).Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithWidth(20).
Build()
expected := 15
if win.ViewportWidth() != expected {
t.Errorf("expected viewport width %d, got %d", expected, win.ViewportWidth())
}
})
t.Run("returns full width when gutter disabled", func(t *testing.T) {
buf := NewBufferBuilder().WithLines([]string{"line"}).Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithWidth(20).
WithOptions(WinOptions{Number: false, RelativeNumber: false, GutterSize: 5, ScrollOff: 8}).
Build()
if win.ViewportWidth() != 20 {
t.Errorf("expected viewport width 20, got %d", win.ViewportWidth())
}
})
}
func TestWindow_SetOptions(t *testing.T) { func TestWindow_SetOptions(t *testing.T) {
t.Run("updates options", func(t *testing.T) { t.Run("updates options", func(t *testing.T) {
buf := NewBufferBuilder().Build() buf := NewBufferBuilder().Build()

View File

@ -0,0 +1,179 @@
package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
)
func TestJoinLines(t *testing.T) {
t.Run("J joins current line with next line using a space", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"hello", "world"})
sendKeys(tm, "J")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"hello world"})
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("Cursor.Line = %d, want 0", m.ActiveWindow().Cursor.Line)
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("Cursor.Col = %d, want 0", m.ActiveWindow().Cursor.Col)
}
})
t.Run("J joins from the cursor line, not always the first line", func(t *testing.T) {
tm := newTestModelWithLinesAndCursorPos(t, []string{"one", "two", "three"}, core.Position{Line: 1, Col: 2})
sendKeys(tm, "J")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"one", "two three"})
if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line)
}
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("Cursor.Col = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
t.Run("J trims indentation from the joined line", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"foo", " bar"})
sendKeys(tm, "J")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"foo bar"})
})
}
func TestJoinLinesNoSpace(t *testing.T) {
t.Run("gJ joins without inserting a space", func(t *testing.T) {
tm := newTestModelWithLinesAndCursorPos(t, []string{"hello", "world"}, core.Position{Line: 0, Col: 3})
sendKeys(tm, "g", "J")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"helloworld"})
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("Cursor.Line = %d, want 0", m.ActiveWindow().Cursor.Line)
}
if m.ActiveWindow().Cursor.Col != 3 {
t.Errorf("Cursor.Col = %d, want 3", m.ActiveWindow().Cursor.Col)
}
})
t.Run("gJ preserves leading whitespace on the next line", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"foo", " bar"})
sendKeys(tm, "g", "J")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"foo bar"})
})
}
func TestJoinLinesWithCount(t *testing.T) {
t.Run("3J joins three lines with spaces", func(t *testing.T) {
tm := newTestModelWithLinesAndCursorPos(t, []string{"a", "b", "c", "d"}, core.Position{Line: 0, Col: 1})
sendKeys(tm, "3", "J")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"a b c", "d"})
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("Cursor.Line = %d, want 0", m.ActiveWindow().Cursor.Line)
}
if m.ActiveWindow().Cursor.Col != 1 {
t.Errorf("Cursor.Col = %d, want 1", m.ActiveWindow().Cursor.Col)
}
})
t.Run("3gJ joins three lines without spaces", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"a", "b", "c", "d"})
sendKeys(tm, "3", "g", "J")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"abc", "d"})
})
t.Run("count larger than remaining lines joins through end of file", func(t *testing.T) {
tm := newTestModelWithLinesAndCursorPos(t, []string{"a", "b", "c"}, core.Position{Line: 1, Col: 0})
sendKeys(tm, "9", "J")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"a", "b c"})
})
}
func TestJoinLinesEdgeCases(t *testing.T) {
t.Run("J on last line does nothing", func(t *testing.T) {
tm := newTestModelWithLinesAndCursorPos(t, []string{"one", "two"}, core.Position{Line: 1, Col: 2})
sendKeys(tm, "J")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"one", "two"})
if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line)
}
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("Cursor.Col = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
t.Run("gJ on single-line buffer does nothing", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"solo"})
sendKeys(tm, "g", "J")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"solo"})
})
t.Run("J across many empty lines keeps a single separating space", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"foo", "", "", "", "bar"})
sendKeys(tm, "5", "J")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"foo bar"})
})
}
func TestJoinLinesRepeatAndUndo(t *testing.T) {
t.Run("dot repeats J join", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"a", "b", "c", "d"})
sendKeys(tm, "J", "j", ".")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"a b", "c d"})
})
t.Run("dot repeats gJ join", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"a", "b", "c", "d"})
sendKeys(tm, "g", "J", "j", ".")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"ab", "cd"})
})
t.Run("undo restores lines after J", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"left", "right"})
sendKeys(tm, "J", "u")
m := getFinalModel(t, tm)
assertBufferLines(t, m.ActiveBuffer(), []string{"left", "right"})
})
}
func assertBufferLines(t *testing.T, buf *core.Buffer, want []string) {
t.Helper()
got := make([]string, buf.LineCount())
for i := 0; i < buf.LineCount(); i++ {
got[i] = buf.Line(i)
}
if len(got) != len(want) {
t.Errorf("lines = %q (len=%d), want %q (len=%d)", got, len(got), want, len(want))
return
}
for i := range got {
if got[i] != want[i] {
t.Errorf("lines = %q (len=%d), want %q (len=%d)", got, len(got), want, len(want))
return
}
}
}

View File

@ -725,3 +725,378 @@ func TestMoveToColumnInVisualMode(t *testing.T) {
} }
}) })
} }
func TestJumpToMatchingDelimiter(t *testing.T) {
tests := []struct {
name string
lines []string
start core.Position
expected core.Position
}{
{
name: "opening paren jumps forward to matching paren",
lines: []string{"a(b(c)d)e"},
start: core.Position{Line: 0, Col: 1},
expected: core.Position{Line: 0, Col: 7},
},
{
name: "closing paren jumps backward to matching opening paren",
lines: []string{"a(b(c)d)e"},
start: core.Position{Line: 0, Col: 7},
expected: core.Position{Line: 0, Col: 1},
},
{
name: "opening square bracket jumps forward with nesting",
lines: []string{"x[ab[c]d]y"},
start: core.Position{Line: 0, Col: 1},
expected: core.Position{Line: 0, Col: 8},
},
{
name: "closing square bracket jumps backward with nesting",
lines: []string{"x[ab[c]d]y"},
start: core.Position{Line: 0, Col: 8},
expected: core.Position{Line: 0, Col: 1},
},
{
name: "opening brace jumps forward across lines",
lines: []string{"if (ok) {", " call()", "}"},
start: core.Position{Line: 0, Col: 8},
expected: core.Position{Line: 2, Col: 0},
},
{
name: "closing brace jumps backward across lines",
lines: []string{"if (ok) {", " call()", "}"},
start: core.Position{Line: 2, Col: 0},
expected: core.Position{Line: 0, Col: 8},
},
{
name: "searches forward on current line when not on delimiter",
lines: []string{"xx (a(b)c) yy"},
start: core.Position{Line: 0, Col: 0},
expected: core.Position{Line: 0, Col: 9},
},
{
name: "no delimiter at or after cursor does nothing",
lines: []string{"xx (a(b)c) yy"},
start: core.Position{Line: 0, Col: 10},
expected: core.Position{Line: 0, Col: 10},
},
{
name: "unmatched opening delimiter does nothing",
lines: []string{"x (abc"},
start: core.Position{Line: 0, Col: 2},
expected: core.Position{Line: 0, Col: 2},
},
{
name: "unmatched closing delimiter does nothing",
lines: []string{"abc)"},
start: core.Position{Line: 0, Col: 3},
expected: core.Position{Line: 0, Col: 3},
},
{
name: "backward matching across lines handles nested delimiters",
lines: []string{"if (a +", " (b * c)", ")"},
start: core.Position{Line: 2, Col: 0},
expected: core.Position{Line: 0, Col: 3},
},
{
name: "forward matching across lines handles nested delimiters",
lines: []string{"if (a +", " (b * c)", ")"},
start: core.Position{Line: 0, Col: 3},
expected: core.Position{Line: 2, Col: 0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tm := newTestModelWithLinesAndCursorPos(t, tt.lines, tt.start)
sendKeys(tm, "%")
m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != tt.expected.Line {
t.Errorf("CursorY() = %d, want %d", m.ActiveWindow().Cursor.Line, tt.expected.Line)
}
if m.ActiveWindow().Cursor.Col != tt.expected.Col {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, tt.expected.Col)
}
})
}
}
func TestJumpToMatchingDelimiterInVisualMode(t *testing.T) {
t.Run("test 'v%' selects to matching delimiter", func(t *testing.T) {
lines := []string{"foo(bar)baz"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 3})
sendKeys(tm, "v", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualMode {
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
}
if m.ActiveWindow().Anchor.Col != 3 {
t.Errorf("AnchorX() = %d, want 3", m.ActiveWindow().Anchor.Col)
}
if m.ActiveWindow().Cursor.Col != 7 {
t.Errorf("CursorX() = %d, want 7", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'v%' from closing delimiter selects backward", func(t *testing.T) {
lines := []string{"foo(bar)baz"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 7})
sendKeys(tm, "v", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualMode {
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
}
if m.ActiveWindow().Anchor.Col != 7 {
t.Errorf("AnchorX() = %d, want 7", m.ActiveWindow().Anchor.Col)
}
if m.ActiveWindow().Cursor.Col != 3 {
t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'v%' searches forward on current line before selecting", func(t *testing.T) {
lines := []string{"xx (a(b)c) yy"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 0})
sendKeys(tm, "v", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualMode {
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
}
if m.ActiveWindow().Anchor.Col != 0 {
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
}
if m.ActiveWindow().Cursor.Col != 9 {
t.Errorf("CursorX() = %d, want 9", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'v%' with no delimiter after cursor keeps selection in place", func(t *testing.T) {
lines := []string{"xx (a(b)c) yy"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 10})
sendKeys(tm, "v", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualMode {
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
}
if m.ActiveWindow().Anchor.Col != 10 {
t.Errorf("AnchorX() = %d, want 10", m.ActiveWindow().Anchor.Col)
}
if m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'v%' on unmatched opening delimiter keeps selection in place", func(t *testing.T) {
lines := []string{"x (abc"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 2})
sendKeys(tm, "v", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualMode {
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
}
if m.ActiveWindow().Anchor.Col != 2 {
t.Errorf("AnchorX() = %d, want 2", m.ActiveWindow().Anchor.Col)
}
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'v%d' deletes the matched delimiter range", func(t *testing.T) {
lines := []string{"foo(bar)baz"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 3})
sendKeys(tm, "v", "%", "d")
m := getFinalModel(t, tm)
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
}
if m.ActiveBuffer().Lines[0].String() != "foobaz" {
t.Errorf("Line(0) = %q, want 'foobaz'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'V%' spans matching lines", func(t *testing.T) {
lines := []string{"if (ok) {", " call()", "}"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 8})
sendKeys(tm, "V", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualLineMode {
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
}
if m.ActiveWindow().Anchor.Line != 0 {
t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line)
}
if m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
}
})
t.Run("test 'V%' from closing delimiter selects backward across lines", func(t *testing.T) {
lines := []string{"if (ok) {", " call()", "}"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 2, Col: 0})
sendKeys(tm, "V", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualLineMode {
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
}
if m.ActiveWindow().Anchor.Line != 2 {
t.Errorf("AnchorY() = %d, want 2", m.ActiveWindow().Anchor.Line)
}
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
}
})
t.Run("test 'V%' with no delimiter after cursor keeps selection in place", func(t *testing.T) {
lines := []string{"xx (a(b)c) yy"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 10})
sendKeys(tm, "V", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualLineMode {
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
}
if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Anchor.Col != 10 {
t.Errorf("anchor = (%d,%d), want (0,10)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col)
}
if m.ActiveWindow().Cursor.Line != 0 || m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("cursor = (%d,%d), want (0,10)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'V%' on unmatched opening delimiter keeps selection in place", func(t *testing.T) {
lines := []string{"if (abc"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 3})
sendKeys(tm, "V", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualLineMode {
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
}
if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Anchor.Col != 3 {
t.Errorf("anchor = (%d,%d), want (0,3)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col)
}
if m.ActiveWindow().Cursor.Line != 0 || m.ActiveWindow().Cursor.Col != 3 {
t.Errorf("cursor = (%d,%d), want (0,3)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'V%d' deletes linewise matched range", func(t *testing.T) {
lines := []string{"if (ok) {", " call()", "}"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 8})
sendKeys(tm, "V", "%", "d")
m := getFinalModel(t, tm)
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
}
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'ctrl+v%' updates block selection to matching delimiter", func(t *testing.T) {
lines := []string{"if (ok) {", " call()", "}"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 8})
sendKeys(tm, "ctrl+v", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualBlockMode {
t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode())
}
if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Anchor.Col != 8 {
t.Errorf("anchor = (%d,%d), want (0,8)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col)
}
if m.ActiveWindow().Cursor.Line != 2 || m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("cursor = (%d,%d), want (2,0)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'ctrl+v%' from closing delimiter selects backward", func(t *testing.T) {
lines := []string{"if (ok) {", " call()", "}"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 2, Col: 0})
sendKeys(tm, "ctrl+v", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualBlockMode {
t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode())
}
if m.ActiveWindow().Anchor.Line != 2 || m.ActiveWindow().Anchor.Col != 0 {
t.Errorf("anchor = (%d,%d), want (2,0)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col)
}
if m.ActiveWindow().Cursor.Line != 0 || m.ActiveWindow().Cursor.Col != 8 {
t.Errorf("cursor = (%d,%d), want (0,8)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'ctrl+v%' with no delimiter after cursor keeps block selection in place", func(t *testing.T) {
lines := []string{"xx (a(b)c) yy"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 10})
sendKeys(tm, "ctrl+v", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualBlockMode {
t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode())
}
if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Anchor.Col != 10 {
t.Errorf("anchor = (%d,%d), want (0,10)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col)
}
if m.ActiveWindow().Cursor.Line != 0 || m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("cursor = (%d,%d), want (0,10)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'ctrl+v%y' yanks block and exits visual mode", func(t *testing.T) {
lines := []string{"if (ok) {", " call()", "}"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 8})
sendKeys(tm, "ctrl+v", "%", "y")
m := getFinalModel(t, tm)
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
}
})
}
func TestJumpToMatchingDelimiterIgnoresCount(t *testing.T) {
t.Run("test '5%' in normal mode still performs delimiter matching", func(t *testing.T) {
lines := []string{"a(b)c"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 1})
sendKeys(tm, "5", "%")
m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 3 {
t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'v5%' in visual mode still performs delimiter matching", func(t *testing.T) {
lines := []string{"a(b)c"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 1})
sendKeys(tm, "v", "5", "%")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualMode {
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
}
if m.ActiveWindow().Anchor.Col != 1 {
t.Errorf("AnchorX() = %d, want 1", m.ActiveWindow().Anchor.Col)
}
if m.ActiveWindow().Cursor.Col != 3 {
t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col)
}
})
}

View File

@ -0,0 +1,162 @@
package editor
import (
"fmt"
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
)
func TestScreenTopMotion(t *testing.T) {
t.Run("H moves to the top visible line first non-blank", func(t *testing.T) {
lines := screenMotionLines(100)
lines[82] = " top"
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G", "H")
m := getFinalModel(t, tm)
assertCursorPos(t, m, 82, 4)
})
t.Run("3H moves to third visible line from top", func(t *testing.T) {
lines := screenMotionLines(100)
lines[84] = " third"
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G", "3", "H")
m := getFinalModel(t, tm)
assertCursorPos(t, m, 84, 2)
})
t.Run("H count clamps to bottom visible line", func(t *testing.T) {
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G", "9", "9", "9", "H")
m := getFinalModel(t, tm)
assertCursorPos(t, m, 99, 0)
})
}
func TestScreenMiddleMotion(t *testing.T) {
t.Run("M moves to middle visible line first non-blank", func(t *testing.T) {
lines := screenMotionLines(100)
lines[90] = "\tmiddle"
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G", "M")
m := getFinalModel(t, tm)
assertCursorPos(t, m, 90, 1)
})
t.Run("count before M is ignored", func(t *testing.T) {
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G", "9", "M")
m := getFinalModel(t, tm)
assertCursorPos(t, m, 90, 0)
})
}
func TestScreenBottomMotion(t *testing.T) {
t.Run("L moves to bottom visible line first non-blank", func(t *testing.T) {
lines := screenMotionLines(100)
lines[99] = " bottom"
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G", "L")
m := getFinalModel(t, tm)
assertCursorPos(t, m, 99, 2)
})
t.Run("3L moves to third visible line from bottom", func(t *testing.T) {
lines := screenMotionLines(100)
lines[97] = " above"
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G", "3", "L")
m := getFinalModel(t, tm)
assertCursorPos(t, m, 97, 4)
})
t.Run("L count clamps to top visible line", func(t *testing.T) {
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G", "9", "9", "9", "L")
m := getFinalModel(t, tm)
assertCursorPos(t, m, 82, 0)
})
}
func TestScreenMotionsEdgeCases(t *testing.T) {
t.Run("small file: H and L clamp to file bounds", func(t *testing.T) {
lines := []string{" one", "two", "three", "four", "five"}
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "L")
sendKeys(tm, "H")
m := getFinalModel(t, tm)
assertCursorPos(t, m, 0, 4)
})
t.Run("M on blank middle line lands at column 0", func(t *testing.T) {
lines := screenMotionLines(100)
lines[90] = ""
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G", "M")
m := getFinalModel(t, tm)
assertCursorPos(t, m, 90, 0)
})
}
func TestScreenMotionsIntegration(t *testing.T) {
t.Run("dH treats H as a linewise motion", func(t *testing.T) {
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G", "d", "H")
m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 82 {
t.Errorf("LineCount() = %d, want 82", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Line(81) != "line 81" {
t.Errorf("Line(81) = %q, want %q", m.ActiveBuffer().Line(81), "line 81")
}
})
t.Run("H works as a visual line motion", func(t *testing.T) {
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G", "V", "H", "d")
m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 82 {
t.Errorf("LineCount() = %d, want 82", m.ActiveBuffer().LineCount())
}
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want %v", m.Mode(), core.NormalMode)
}
})
}
func screenMotionLines(n int) []string {
lines := make([]string, n)
for i := range n {
lines[i] = fmt.Sprintf("line %d", i)
}
return lines
}
func assertCursorPos(t *testing.T, m *Model, wantLine, wantCol int) {
t.Helper()
if m.ActiveWindow().Cursor.Line != wantLine {
t.Errorf("Cursor.Line = %d, want %d", m.ActiveWindow().Cursor.Line, wantLine)
}
if m.ActiveWindow().Cursor.Col != wantCol {
t.Errorf("Cursor.Col = %d, want %d", m.ActiveWindow().Cursor.Col, wantCol)
}
}

View File

@ -1038,3 +1038,121 @@ func TestReplaceModeRepeat(t *testing.T) {
} }
}) })
} }
// ==================================================
// Visual Replace Char (v/V/ctrl+v + r{char}) Tests
// ==================================================
func TestVisualReplaceChar(t *testing.T) {
t.Run("test 'vlllrx' replaces each selected char in characterwise visual mode", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "v", "l", "l", "l", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "xxxxo world" {
t.Errorf("Line(0) = %q, want %q", m.ActiveBuffer().Line(0), "xxxxo world")
}
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want %v", m.Mode(), core.NormalMode)
}
})
t.Run("test backward characterwise selection with 'r'", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "v", "h", "h", "r", "z")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "hezzz" {
t.Errorf("Line(0) = %q, want %q", m.ActiveBuffer().Line(0), "hezzz")
}
})
t.Run("test 'VjrX' replaces all characters in selected lines", func(t *testing.T) {
lines := []string{"abc", "de", "fghi"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "V", "j", "r", "X")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"XXX", "XX", "fghi"})
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want %v", m.Mode(), core.NormalMode)
}
})
t.Run("test visual line backward selection with 'r'", func(t *testing.T) {
lines := []string{"one", "two", "three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2})
sendKeys(tm, "V", "k", "r", "_")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"one", "___", "_____"})
})
t.Run("test 'ctrl+vljrx' replaces each char in block selection", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "ctrl+v", "l", "j", "r", "x")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"xxllo", "xxrld"})
})
t.Run("test block replace with ragged line lengths replaces available chars", func(t *testing.T) {
lines := []string{"abcd", "xy", "1234"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "ctrl+v", "l", "j", "j", "r", "q")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"aqqd", "xq", "1qq4"})
})
}
// ==================================================
// Visual Replace Char Repeat (.) Tests
// ==================================================
func TestVisualReplaceCharRepeat(t *testing.T) {
t.Run("test dot repeats characterwise visual replace", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "v", "l", "l", "r", "x", "w", ".")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "xxxlo xxxld" {
t.Errorf("Line(0) = %q, want %q", m.ActiveBuffer().Line(0), "xxxlo xxxld")
}
})
t.Run("test dot repeats visual line replace on next line", func(t *testing.T) {
lines := []string{"abcde", "vwxyz"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "V", "r", "_", "j", ".")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"_____", "_____"})
})
t.Run("test dot repeats visual block replace at new location", func(t *testing.T) {
lines := []string{"abcdef", "ghijkl", "mnopqr", "stuvwx"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "ctrl+v", "l", "j", "r", "*", "j", "j", ".")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"**cdef", "**ijkl", "**opqr", "**uvwx"})
})
}
func assertReplaceVisualLines(t *testing.T, m *Model, want []string) {
t.Helper()
if m.ActiveBuffer().LineCount() != len(want) {
t.Fatalf("LineCount() = %d, want %d", m.ActiveBuffer().LineCount(), len(want))
}
for i := range want {
if m.ActiveBuffer().Line(i) != want[i] {
t.Errorf("Line(%d) = %q, want %q", i, m.ActiveBuffer().Line(i), want[i])
}
}
}

View File

@ -0,0 +1,86 @@
package editor
import (
"regexp"
"strings"
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func stripANSI(s string) string {
return ansiPattern.ReplaceAllString(s, "")
}
func firstViewLine(view string) string {
lines := strings.Split(view, "\n")
if len(lines) == 0 {
return ""
}
return stripANSI(lines[0])
}
func TestHorizontalScrollRender(t *testing.T) {
line := "0123456789abcdef"
tm := newTestModelWithTermSize(t, []string{line}, core.Position{Line: 0, Col: 0}, 12, 10)
for range 10 {
sendKeys(tm, "l")
}
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollX != 4 {
t.Fatalf("ScrollX() = %d, want 4", m.ActiveWindow().ScrollX)
}
visible := firstViewLine(m.View())
if !strings.Contains(visible, "456789a") {
t.Fatalf("first visible line = %q, want it to contain %q", visible, "456789a")
}
}
func TestHorizontalScrollRenderReturnsWhenMovingLeft(t *testing.T) {
line := "0123456789abcdef"
tm := newTestModelWithTermSize(t, []string{line}, core.Position{Line: 0, Col: 0}, 12, 10)
for range 10 {
sendKeys(tm, "l")
}
for range 10 {
sendKeys(tm, "h")
}
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollX != 0 {
t.Fatalf("ScrollX() after moving back left = %d, want 0", m.ActiveWindow().ScrollX)
}
visible := firstViewLine(m.View())
if !strings.Contains(visible, "0123456") {
t.Fatalf("first visible line after moving back left = %q, want it to contain %q", visible, "0123456")
}
}
func TestHorizontalScrollResizeClampWithRunes(t *testing.T) {
line := "abécdefghij"
tm := newTestModelWithTermSize(t, []string{line}, core.Position{Line: 0, Col: 0}, 10, 10)
for range 10 {
sendKeys(tm, "l")
}
tm.Send(tea.WindowSizeMsg{Width: 12, Height: 10})
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollX != 5 {
t.Fatalf("ScrollX() after resize = %d, want 5", m.ActiveWindow().ScrollX)
}
visible := firstViewLine(m.View())
if !strings.Contains(visible, "efghij") {
t.Fatalf("first visible line after resize = %q, want it to contain %q", visible, "efghij")
}
}

View File

@ -7,7 +7,8 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/input" "git.gophernest.net/azpect/TextEditor/internal/input"
"git.gophernest.net/azpect/TextEditor/internal/style" "git.gophernest.net/azpect/TextEditor/internal/syntax"
"git.gophernest.net/azpect/TextEditor/internal/theme"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@ -43,6 +44,9 @@ type Model struct {
commandHistory []string commandHistory []string
commandHistoryCursor int commandHistoryCursor int
// Search state
searchState core.SearchState
// Global settings // Global settings
settings core.EditorSettings settings core.EditorSettings
@ -50,7 +54,8 @@ type Model struct {
registers map[rune]core.Register // name -> register registers map[rune]core.Register // name -> register
// Visual styles // Visual styles
styles style.Styles themes map[string]theme.EditorTheme
syntax syntax.Engine
// Dot operator state // Dot operator state
lastChangeKeys []string lastChangeKeys []string
@ -87,6 +92,7 @@ func (m *Model) Buffers() []*core.Buffer {
func (m *Model) SetBuffers(bufs []*core.Buffer) { func (m *Model) SetBuffers(bufs []*core.Buffer) {
m.buffers = bufs m.buffers = bufs
m.bindBufferSyntaxHooks(bufs)
} }
func (m *Model) ActiveBuffer() *core.Buffer { func (m *Model) ActiveBuffer() *core.Buffer {
@ -138,8 +144,13 @@ func (m *Model) ClearLastChangeKeys() {
m.lastChangeKeys = []string{} m.lastChangeKeys = []string{}
} }
// Handle key also adjusts the scroll anytime an input is pressed.
func (m *Model) HandleKey(key string) tea.Cmd { func (m *Model) HandleKey(key string) tea.Cmd {
return m.input.Handle(m, key) cmd := m.input.Handle(m, key)
if len(m.windows) > 0 {
m.ActiveWindow().AdjustScroll()
}
return cmd
} }
func (m *Model) ExitInsertMode() { func (m *Model) ExitInsertMode() {
@ -148,7 +159,7 @@ func (m *Model) ExitInsertMode() {
m.replayInsert() m.replayInsert()
} }
if win.Cursor.Col > 0 { if win.Cursor.Col > 0 {
win.Cursor.Col-- win.SetCursorCol(win.Cursor.Col - 1)
} }
m.mode = core.NormalMode m.mode = core.NormalMode
m.insertCount = 0 m.insertCount = 0
@ -182,7 +193,7 @@ func (m *Model) replayInsert() {
} }
} }
// TODO: Fix this shitty shit shit shit // TODO: This can't be the best way....
func (m *Model) processInsertKey(key string) { func (m *Model) processInsertKey(key string) {
win := m.ActiveWindow() win := m.ActiveWindow()
buf := m.ActiveBuffer() buf := m.ActiveBuffer()
@ -268,6 +279,7 @@ func (m *Model) processInsertKey(key string) {
} }
win.SetCursorCol(col + len(key)) win.SetCursorCol(col + len(key))
} }
} }
// ================================================== // ==================================================
@ -319,6 +331,18 @@ func (m *Model) SetCommandHistoryCursor(cur int) {
m.commandHistoryCursor = cur m.commandHistoryCursor = cur
} }
// ==================================================
// Search Mode State
// ==================================================
func (m *Model) SearchState() core.SearchState {
return m.searchState
}
func (m *Model) SetSearchState(s core.SearchState) {
m.searchState = s
}
// ================================================== // ==================================================
// Editor-wide State // Editor-wide State
// ================================================== // ==================================================
@ -338,14 +362,75 @@ func (m *Model) SetSettings(s core.EditorSettings) {
m.settings = s m.settings = s
} }
// Model.Styles: Returns the visual styles used for rendering. // ==================================================
func (m *Model) Styles() style.Styles { // Themes
return m.styles // ==================================================
func (m *Model) Theme() (string, theme.EditorTheme) {
t, ok := m.themes[m.settings.CurrentTheme]
if ok {
return m.settings.CurrentTheme, t
}
return "default", m.themes["default"]
} }
// Model.SetStyles: Sets the visual styles used for rendering. func (m *Model) SetTheme(name string) {
func (m *Model) SetStyles(s style.Styles) { m.settings.CurrentTheme = name
m.styles = s
if m.syntax == nil {
return
}
// Need to invalidate the buffers to force a redraw
for _, buf := range m.buffers {
if buf == nil {
continue
}
m.syntax.InvalidateBuffer(buf)
}
}
func (m *Model) Themes() map[string]theme.EditorTheme {
return m.themes
}
func (m *Model) SetThemes(t map[string]theme.EditorTheme) {
m.themes = t
}
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)
}
}
}
} }
// ================================================== // ==================================================

View File

@ -3,8 +3,9 @@ package editor
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/input" "git.gophernest.net/azpect/TextEditor/internal/input"
"git.gophernest.net/azpect/TextEditor/internal/style" "git.gophernest.net/azpect/TextEditor/internal/syntax"
"github.com/alecthomas/chroma/v2/styles" "git.gophernest.net/azpect/TextEditor/internal/theme"
"git.gophernest.net/azpect/TextEditor/internal/theme/themes"
) )
type ModelBuilder struct { type ModelBuilder struct {
@ -13,7 +14,17 @@ type ModelBuilder struct {
// NewModelBuilder: Builds and returns a new model, using the default color scheme (kanagawa-wave). // NewModelBuilder: Builds and returns a new model, using the default color scheme (kanagawa-wave).
func NewModelBuilder() *ModelBuilder { func NewModelBuilder() *ModelBuilder {
chromaStyle := styles.Get("kanagawa-wave") editorTheme := themes.NewDefaultTheme()
// Embed the themes
var embededThemes map[string]theme.EditorTheme
embededThemesJson, err := theme.LoadEmbeddedThemesJSON()
if err == nil {
embededThemes = theme.MapEmbeddedThemeToEditorTheme(embededThemesJson)
}
// Always have a default theme
embededThemes["default"] = themes.NewDefaultTheme()
return &ModelBuilder{ return &ModelBuilder{
model: Model{ model: Model{
@ -32,7 +43,8 @@ func NewModelBuilder() *ModelBuilder {
commandOutput: nil, commandOutput: nil,
settings: core.NewDefaultSettings(), settings: core.NewDefaultSettings(),
registers: core.DefaultRegisters(), registers: core.DefaultRegisters(),
styles: style.ChromaStyles(chromaStyle), syntax: syntax.NewTreeSitterEngine(editorTheme),
themes: embededThemes,
}, },
} }
} }
@ -125,13 +137,9 @@ func (mb *ModelBuilder) WithCommandOutput(out *core.CommandOutput) *ModelBuilder
return mb return mb
} }
// ModelBuilder.WithStyles: Set the visual styling for the editor.
func (mb *ModelBuilder) WithStyles(styles style.Styles) *ModelBuilder {
mb.model.styles = styles
return mb
}
// ModelBuilder.Build: Build and return the configured Model instance. // ModelBuilder.Build: Build and return the configured Model instance.
func (mb *ModelBuilder) Build() *Model { func (mb *ModelBuilder) Build() *Model {
return &mb.model m := &mb.model
m.bindBufferSyntaxHooks(m.buffers)
return m
} }

View File

@ -48,6 +48,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
for i := range m.windows { for i := range m.windows {
m.windows[i].Height = msg.Height m.windows[i].Height = msg.Height
m.windows[i].Width = msg.Width m.windows[i].Width = msg.Width
m.windows[i].AdjustScroll()
} }
// TODO: This is not great, totally temporary. But I don't like vim's handling, so this is up to me // TODO: This is not great, totally temporary. But I don't like vim's handling, so this is up to me
@ -60,6 +61,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
scrollAction := motion.ScrollDownPage{Divisor: 4} // Quarter page scrollAction := motion.ScrollDownPage{Divisor: 4} // Quarter page
cmd = scrollAction.Execute(m) cmd = scrollAction.Execute(m)
} }
if len(m.windows) > 0 {
m.ActiveWindow().AdjustScroll()
}
case tea.KeyMsg: case tea.KeyMsg:
// TODO: This needs to be removed, but for now its required for the tests. // TODO: This needs to be removed, but for now its required for the tests.
@ -83,13 +87,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.CommandOutput().ScrollUp() m.CommandOutput().ScrollUp()
} }
} else { } else {
cmd = m.input.Handle(m, msg.String()) cmd = m.HandleKey(msg.String())
} }
} }
// Keep cursor in view after any update
win := m.ActiveWindow()
win.AdjustScroll()
return m, cmd return m, cmd
} }

View File

@ -6,7 +6,8 @@ import (
"strings" "strings"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style" "git.gophernest.net/azpect/TextEditor/internal/syntax"
"git.gophernest.net/azpect/TextEditor/internal/theme"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@ -20,7 +21,7 @@ func (m Model) View() string {
// Each window has its own line numbers and gutter // Each window has its own line numbers and gutter
// Each window has its own status bar and mode // Each window has its own status bar and mode
styles := m.Styles() _, t := m.Theme()
options := win.Options options := win.Options
// Adjust gutter to fit line len // Adjust gutter to fit line len
@ -28,17 +29,17 @@ func (m Model) View() string {
options.GutterSize = max(options.GutterSize, maxLineLen+2) options.GutterSize = max(options.GutterSize, maxLineLen+2)
// Draw window // Draw window
view := viewWindow(win, styles, options, m.Mode()) view := viewWindow(win, t, options, m.Mode(), m.Syntax())
// Command bar is seperate // Command bar is separate
cmdBar := drawCommandBar(m) cmdBar := drawCommandBar(m, t)
view += cmdBar view += cmdBar
// Handle command output, draw on top // Handle command output, draw on top
// TODO: This is not idea, but it works for now // TODO: This is not idea, but it works for now
cmd := m.CommandOutput() cmd := m.CommandOutput()
if cmd != nil && cmd.IsActive() && !cmd.Inline && cmd.Height() > 0 { if cmd != nil && cmd.IsActive() && !cmd.Inline && cmd.Height() > 0 {
view = overlayCommandOutputWindow(view, cmd, styles, m.termWidth, m.termHeight) view = overlayCommandOutputWindow(view, cmd, t, m.termWidth, m.termHeight)
} }
return view return view
@ -46,31 +47,34 @@ func (m Model) View() string {
// viewWindow: Renders a single window's content including line numbers and buffer text. // viewWindow: Renders a single window's content including line numbers and buffer text.
// Each window has its own line numbers, gutter, and viewport dimensions. // Each window has its own line numbers, gutter, and viewport dimensions.
func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode) string { func viewWindow(w *core.Window, t theme.EditorTheme, options core.WinOptions, mode core.Mode, sx syntax.Engine) string {
buf := w.Buffer buf := w.Buffer
var view strings.Builder var view strings.Builder
if sx != nil {
sx.PrepareBuffer(buf, t)
}
// Compute window size (y) // Compute window size (y)
start := w.ScrollY start := w.ScrollY
end := w.ScrollY + w.ViewportHeight() end := w.ScrollY + w.ViewportHeight()
// Chroma stuff
lexer := style.GetLexer(buf)
// Draw buffer lines // Draw buffer lines
for lineNum := start; lineNum < end; lineNum++ { for lineNum := start; lineNum < end; lineNum++ {
if lineNum < buf.LineCount() { if lineNum < buf.LineCount() {
styleMap := styles.MakeStyleMap(lexer, buf.Line(lineNum)) line := buf.Lines[lineNum]
line := drawLine(w, styles, options, mode, buf.Line(lineNum), lineNum, styleMap) styleMap := make([]lipgloss.Style, line.Len())
view.WriteString(line) if sx != nil {
styleMap = sx.LineStyleMap(buf, lineNum, t)
}
view.WriteString(drawLine(w, t, options, mode, line, lineNum, styleMap))
} else { } else {
view.WriteString(strings.Repeat(styles.BackgroundStyle.Render(" "), w.Width)) view.WriteString(strings.Repeat(t.Background.Render(" "), w.Width))
} }
view.WriteRune('\n') view.WriteRune('\n')
} }
// Draw status line // Draw status line
statusBar := drawStatusBar(w, mode, styles) statusBar := drawStatusBar(w, mode, t)
view.WriteString(statusBar + "\n") view.WriteString(statusBar + "\n")
return view.String() return view.String()
@ -78,52 +82,55 @@ func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mo
// drawLine: Renders a single line with syntax highlighting, cursor, and visual selection. // drawLine: Renders a single line with syntax highlighting, cursor, and visual selection.
// Handles gutter, cursor rendering, and visual mode highlighting. // Handles gutter, cursor rendering, and visual mode highlighting.
func drawLine(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode, line string, lineNumber int, styleMap []lipgloss.Style) string { func drawLine(w *core.Window, t theme.EditorTheme, options core.WinOptions, mode core.Mode, line *core.GapBuffer, lineNumber int, styleMap []lipgloss.Style) string {
var view strings.Builder var view strings.Builder
runes := []rune(line) lineLen := line.Len()
// Draw gutter first // Draw gutter first
gutter := drawGutter(w, styles, options, lineNumber) gutter := drawGutter(w, t, options, lineNumber)
view.WriteString(gutter) view.WriteString(gutter)
contentWidth := w.ViewportWidth()
if contentWidth <= 0 {
return view.String()
}
// Draw visible content slice only
startCol := max(0, w.ScrollX)
for screenCol := range contentWidth {
col := startCol + screenCol
// Now draw the line content
for col := 0; col <= len(runes); col++ {
// Current char is cursor // Current char is cursor
if col == w.Cursor.Col && lineNumber == w.Cursor.Line { if col == w.Cursor.Col && lineNumber == w.Cursor.Line {
if col < len(runes) { if col < lineLen {
cur := styles.CursorStyle(mode, styleMap[col]) cur := t.Cursor(mode, styleMap[col])
view.WriteString(cur.Render(string(runes[col]))) view.WriteString(cur.Render(string(line.RuneAt(col))))
} else { } else {
view.WriteString(styles.DefaultCursorStyle(mode).Render(" ")) view.WriteString(t.DefaultCursor(mode).Render(" "))
}
continue
} }
// Not cursor, but not end if col < lineLen {
} else if col < len(runes) {
s := styleMap[col] s := styleMap[col]
if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) { if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
vis := styles.VisualHighlightWithTextColor(s) vis := t.VisualHighlightWithTextColor(s)
view.WriteString(vis.Render(string(runes[col]))) view.WriteString(vis.Render(string(line.RuneAt(col))))
} else { } else {
view.WriteString(s.Render(string(runes[col]))) view.WriteString(s.Render(string(line.RuneAt(col))))
} }
// Allow highlight on blank lines or chars
} else if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) { } else if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
view.WriteString(styles.VisualHighlight.Render(" ")) view.WriteString(t.VisualHightlight.Render(" "))
} else {
view.WriteString(t.Background.Render(" "))
} }
} }
// Pad remainder of line to window width with background color
dif := w.Width - lipgloss.Width(view.String())
if dif > 0 {
view.WriteString(strings.Repeat(styles.BackgroundStyle.Render(" "), dif))
}
return view.String() return view.String()
} }
// drawGutter: Renders the line number gutter with support for both absolute and // drawGutter: Renders the line number gutter with support for both absolute and
// relative line numbers, highlighting the current line differently. // relative line numbers, highlighting the current line differently.
func drawGutter(w *core.Window, styles style.Styles, options core.WinOptions, curLine int) string { func drawGutter(w *core.Window, t theme.EditorTheme, options core.WinOptions, curLine int) string {
if !(options.Number || options.RelativeNumber) { if !(options.Number || options.RelativeNumber) {
return "" return ""
} }
@ -136,8 +143,8 @@ func drawGutter(w *core.Window, styles style.Styles, options core.WinOptions, cu
lineNumber int lineNumber int
gutter string gutter string
gutterStyle = styles.Gutter gutterStyle = t.Gutter.Default
gutterStyleCur = styles.GutterCurrentLine gutterStyleCur = t.Gutter.CurrentLine
) )
// If we have relative setting, set the numbers relatively // If we have relative setting, set the numbers relatively
@ -169,9 +176,9 @@ func drawGutter(w *core.Window, styles style.Styles, options core.WinOptions, cu
// drawStatusBar: Renders the status bar with mode and cursor position, // drawStatusBar: Renders the status bar with mode and cursor position,
// padding the middle with spaces to fill the terminal width. // padding the middle with spaces to fill the terminal width.
func drawStatusBar(w *core.Window, mode core.Mode, styles style.Styles) string { func drawStatusBar(w *core.Window, mode core.Mode, t theme.EditorTheme) string {
left := leftBar(w, mode, styles) left := leftBar(w, mode, t)
right := rightBar(w, mode, styles) right := rightBar(w, mode, t)
diff := w.Width - (lipgloss.Width(left) + lipgloss.Width(right)) diff := w.Width - (lipgloss.Width(left) + lipgloss.Width(right))
@ -180,12 +187,12 @@ func drawStatusBar(w *core.Window, mode core.Mode, styles style.Styles) string {
return "" return ""
} }
middle := strings.Repeat(styles.BackgroundStyle.Render(" "), diff) middle := strings.Repeat(t.Background.Render(" "), diff)
return left + middle + right return left + middle + right
} }
// leftBar: Returns the left side of the status bar showing the current mode. // leftBar: Returns the left side of the status bar showing the current mode.
func leftBar(w *core.Window, mode core.Mode, styles style.Styles) string { func leftBar(w *core.Window, mode core.Mode, t theme.EditorTheme) string {
buf := w.Buffer buf := w.Buffer
var flags []string var flags []string
@ -202,12 +209,12 @@ func leftBar(w *core.Window, mode core.Mode, styles style.Styles) string {
} }
bar := fmt.Sprintf(" %s %s %s", mode.ToString(), buf.Filename, flagStr) bar := fmt.Sprintf(" %s %s %s", mode.ToString(), buf.Filename, flagStr)
return styles.LineStyle.Render(bar) return t.Line.Render(bar)
} }
// rightBar: Returns the right side of the status bar showing cursor position // rightBar: Returns the right side of the status bar showing cursor position
// and selection count in visual mode. // and selection count in visual mode.
func rightBar(w *core.Window, mode core.Mode, styles style.Styles) (bar string) { func rightBar(w *core.Window, mode core.Mode, t theme.EditorTheme) (bar string) {
if mode.IsVisualMode() { if mode.IsVisualMode() {
lineCount := max(w.Anchor.Line, w.Cursor.Line) - min(w.Anchor.Line, w.Cursor.Line) + 1 lineCount := max(w.Anchor.Line, w.Cursor.Line) - min(w.Anchor.Line, w.Cursor.Line) + 1
bar = fmt.Sprintf("%d:%d <%d>", w.Cursor.Line+1, w.Cursor.Col+1, lineCount) bar = fmt.Sprintf("%d:%d <%d>", w.Cursor.Line+1, w.Cursor.Col+1, lineCount)
@ -215,44 +222,66 @@ func rightBar(w *core.Window, mode core.Mode, styles style.Styles) (bar string)
bar = fmt.Sprintf("%d:%d ", w.Cursor.Line+1, w.Cursor.Col+1) bar = fmt.Sprintf("%d:%d ", w.Cursor.Line+1, w.Cursor.Col+1)
} }
buf := w.Buffer buf := w.Buffer
bar = styles.LineStyle.Render(fmt.Sprintf("%s %s", buf.Filetype, bar)) bar = t.Line.Render(fmt.Sprintf("%s %s", buf.Filetype, bar))
return return
} }
// drawCommandBar: Renders the command line showing command input, errors, or // drawCommandBar: Renders the command line showing command input, errors, or
// output depending on the current mode and state. // output depending on the current mode and state.
func drawCommandBar(m Model) string { func drawCommandBar(m Model, t theme.EditorTheme) string {
styles := m.Styles()
// Compute left bar (command side) // Compute left bar (command side)
var leftBar string var leftBar string
if m.Mode() == core.CommandMode { if m.Mode() == core.CommandMode {
leftBar = styles.LineStyle.Render(":") leftBar = t.Line.Render(":")
cmd := []rune(m.Command()) cmd := []rune(m.Command())
cur := m.CommandCursor() cur := m.CommandCursor()
for i, r := range cmd { for i, r := range cmd {
if i == cur { if i == cur {
leftBar += styles.DefaultCursorStyle(m.Mode()).Render(string(r)) leftBar += t.DefaultCursor(m.Mode()).Render(string(r))
} else { } else {
leftBar += styles.LineStyle.Render(string(r)) leftBar += t.Line.Render(string(r))
} }
} }
// Cursor at end of command // Cursor at end of command
if cur >= len(cmd) { if cur >= len(cmd) {
leftBar += styles.DefaultCursorStyle(m.Mode()).Render(" ") leftBar += t.DefaultCursor(m.Mode()).Render(" ")
} }
// bar = fmt.Sprintf("%s %d", bar, cur) // bar = fmt.Sprintf("%s %d", bar, cur)
} else if out := m.CommandOutput(); out != nil && len(out.Lines) > 0 && out.Inline { } else if out := m.CommandOutput(); out != nil && len(out.Lines) > 0 && out.Inline {
// TODO: This is not perfect, temporary // TODO: This is not perfect, temporary
text := strings.Join(out.Lines, " ") text := strings.Join(out.Lines, " ")
if out.IsError { if out.IsError {
leftBar = styles.CommandError.Render(text) leftBar = t.CommandLine.Error.Render(text)
} else { } else {
leftBar = styles.LineStyle.Render(text) leftBar = t.Line.Render(text)
} }
} else if strings.TrimSpace(m.Command()) != "" { } else if strings.TrimSpace(m.Command()) != "" {
content := fmt.Sprintf(":%s", m.Command()) content := fmt.Sprintf(":%s", m.Command())
leftBar = styles.LineStyle.Render(content) // leftBar = t.Line.Render(content)
}
// We only display when when we are currently searching
if m.Mode() == core.SearchMode {
search := m.SearchState()
if search.Forword {
leftBar = t.Line.Render("/")
} else {
leftBar = t.Line.Render("?")
}
for i, r := range search.Query {
if i == search.Cursor {
// TODO: Make sure other themes support this
leftBar += t.DefaultCursor(m.Mode()).Render(string(r))
} else {
leftBar += t.Line.Render(string(r))
}
}
// Cursor at end of command
if search.Cursor >= len(search.Query) {
leftBar += t.DefaultCursor(m.Mode()).Render(" ")
}
} }
// Compute right bar // Compute right bar
@ -261,12 +290,12 @@ func drawCommandBar(m Model) string {
if len(m.input.Pending()) > 0 { if len(m.input.Pending()) > 0 {
width := 10 // Size of the block to display width := 10 // Size of the block to display
content := fmt.Sprintf("%-*s", width, m.input.Pending()) content := fmt.Sprintf("%-*s", width, m.input.Pending())
rightBar = styles.LineStyle.Render(content) rightBar = t.Line.Render(content)
} }
dif := m.termWidth - (lipgloss.Width(leftBar) + lipgloss.Width(rightBar)) dif := m.termWidth - (lipgloss.Width(leftBar) + lipgloss.Width(rightBar))
bar := leftBar + strings.Repeat(styles.BackgroundStyle.Render(" "), max(0, dif)) + rightBar bar := leftBar + strings.Repeat(t.Background.Render(" "), max(0, dif)) + rightBar
return bar return bar
} }
@ -317,7 +346,7 @@ func posInsideSelection(w *core.Window, mode core.Mode, col, line int) bool {
// overlayCommandOutputWindow: Draw the overlay of the command output window. This will override // overlayCommandOutputWindow: Draw the overlay of the command output window. This will override
// (overlay) the displayed content, so it should be used only when needed. // (overlay) the displayed content, so it should be used only when needed.
func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles style.Styles, termWidth int, termHeight int) string { func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, t theme.EditorTheme, termWidth int, termHeight int) string {
// Safety check // Safety check
if cmd == nil { if cmd == nil {
return view return view
@ -328,22 +357,22 @@ func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles sty
// Build the overlay // Build the overlay
var overlay []string var overlay []string
overlay = append(overlay, styles.CommandOutputBorder.Render(strings.Repeat(" ", termWidth))) overlay = append(overlay, t.CommandLine.OutputBorder.Render(strings.Repeat(" ", termWidth)))
if strings.TrimSpace(cmd.Title) != "" { if strings.TrimSpace(cmd.Title) != "" {
title := styles.LineStyle.Render(cmd.Title) title := t.Line.Render(cmd.Title)
overlay = append(overlay, title) overlay = append(overlay, title)
} }
viewLines := cmd.Viewport(termHeight) viewLines := cmd.Viewport(termHeight)
for _, l := range viewLines { for _, l := range viewLines {
content := styles.LineStyle.Render(strings.ReplaceAll(l, "\n", "\\n")) content := t.Line.Render(strings.ReplaceAll(l, "\n", "\\n"))
overlay = append(overlay, content) overlay = append(overlay, content)
} }
msg := core.CommandOutputExitMessage msg := core.CommandOutputExitMessage
if len(cmd.Lines) > len(cmd.Viewport(termHeight)) { if len(cmd.Lines) > len(cmd.Viewport(termHeight)) {
msg += ". " + core.CommandOutputScrollMessage msg += ". " + core.CommandOutputScrollMessage
} }
overlay = append(overlay, styles.CommandContinueMessage.Render(msg)) overlay = append(overlay, t.CommandLine.ContinueMessage.Render(msg))
// NOTE: strings.Split on "\n" is safe as long as no style uses .Width()/.Height()/.Padding()/.Margin(), // NOTE: strings.Split on "\n" is safe as long as no style uses .Width()/.Height()/.Padding()/.Margin(),
// which would cause Lipgloss to embed newlines internally and corrupt the line count. // which would cause Lipgloss to embed newlines internally and corrupt the line count.
@ -352,7 +381,7 @@ func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles sty
// Add background color to end of each line // Add background color to end of each line
for i, l := range overlay { for i, l := range overlay {
dif := termWidth - lipgloss.Width(l) dif := termWidth - lipgloss.Width(l)
overlay[i] += styles.BackgroundStyle.Render(strings.Repeat(" ", dif)) overlay[i] += t.Background.Render(strings.Repeat(" ", dif))
} }
// Remove 'h' lines from back of view and append overlay // Remove 'h' lines from back of view and append overlay

View File

@ -43,6 +43,7 @@ type Handler struct {
insertKeymap *Keymap insertKeymap *Keymap
replaceKeymap *Keymap replaceKeymap *Keymap
commandKeymap *Keymap commandKeymap *Keymap
searchKeymap *Keymap
currentKeymap *Keymap currentKeymap *Keymap
} }
@ -56,6 +57,7 @@ func NewHandler() *Handler {
insertKeymap: NewInsertKeymap(), insertKeymap: NewInsertKeymap(),
replaceKeymap: NewReplaceKeymap(), replaceKeymap: NewReplaceKeymap(),
commandKeymap: NewCommandKeymap(), commandKeymap: NewCommandKeymap(),
searchKeymap: NewSearchKeymap(),
currentKeymap: nil, currentKeymap: nil,
} }
} }
@ -71,7 +73,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
} }
// ESC always resets everything // ESC always resets everything
if key == "esc" { if key == "esc" && m.Mode() != core.SearchMode {
// If insert mode, keep the escape // If insert mode, keep the escape
if m.Mode() == core.InsertMode { if m.Mode() == core.InsertMode {
m.SetLastChangeKeys(append(m.LastChangeKeys(), key)) m.SetLastChangeKeys(append(m.LastChangeKeys(), key))
@ -101,6 +103,8 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
return h.handleReplaceKey(m, key) return h.handleReplaceKey(m, key)
case core.CommandMode: case core.CommandMode:
return h.handleCommandKey(m, key) return h.handleCommandKey(m, key)
case core.SearchMode:
return h.handleSearchKey(m, key)
} }
// If waiting for character argument (f/t/F/T), capture it // If waiting for character argument (f/t/F/T), capture it
@ -176,7 +180,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
func (h *Handler) dispatch(m action.Model, kind string, binding any, key string) tea.Cmd { func (h *Handler) dispatch(m action.Model, kind string, binding any, key string) tea.Cmd {
// Handle character motions (f/t/F/T) - transition to waiting state // Handle character motions (f/t/F/T) - transition to waiting state
if kind == "char_motion" { if kind == "char_motion" {
if key == "r" { if key == "r" && !m.Mode().IsVisualMode() {
m.SetMode(core.WaitingMode) m.SetMode(core.WaitingMode)
} }
h.charMotionType = key h.charMotionType = key
@ -616,6 +620,22 @@ func (h *Handler) handleCommandKey(m action.Model, key string) tea.Cmd {
return action.InsertCommandChar{Char: key}.Execute(m) return action.InsertCommandChar{Char: key}.Execute(m)
} }
// Handler.handleSearchKey: Processes a keypress in search mode, executing
// it as an action or inserting it into the search line. This does not record
// anything into the undo stack.
func (h *Handler) handleSearchKey(m action.Model, key string) tea.Cmd {
kind, binding := h.searchKeymap.Lookup(key)
switch kind {
case "action":
return binding.(action.Action).Execute(m)
case "motion":
return binding.(action.Motion).Execute(m)
}
// Fallback: treat as a regular character to insert
return action.InsertSearchChar{Char: key}.Execute(m)
}
// normalizeVisualSelection: Returns the visual selection with start before end, // normalizeVisualSelection: Returns the visual selection with start before end,
// regardless of which direction the selection was made. // regardless of which direction the selection was made.
func normalizeVisualSelection(m action.Model) (core.Position, core.Position) { func normalizeVisualSelection(m action.Model) (core.Position, core.Position) {

View File

@ -26,6 +26,9 @@ func NewNormalKeymap() *Keymap {
"k": motion.MoveUp{Count: 1}, "k": motion.MoveUp{Count: 1},
"h": motion.MoveLeft{Count: 1}, "h": motion.MoveLeft{Count: 1},
"l": motion.MoveRight{Count: 1}, "l": motion.MoveRight{Count: 1},
"H": motion.MoveToScreenTop{Count: 1},
"M": motion.MoveToScreenMiddle{},
"L": motion.MoveToScreenBottom{Count: 1},
"G": motion.MoveToBottom{}, "G": motion.MoveToBottom{},
"gg": motion.MoveToTop{}, "gg": motion.MoveToTop{},
"0": motion.MoveToLineStart{}, "0": motion.MoveToLineStart{},
@ -47,6 +50,7 @@ func NewNormalKeymap() *Keymap {
"ctrl+f": motion.ScrollDownPage{Divisor: 1}, "ctrl+f": motion.ScrollDownPage{Divisor: 1},
";": action.RepeatFind{Count: 1, Reverse: false}, ";": action.RepeatFind{Count: 1, Reverse: false},
",": action.RepeatFind{Count: 1, Reverse: true}, ",": action.RepeatFind{Count: 1, Reverse: true},
"%": motion.JumpToMatchingDelimiter{},
}, },
operators: map[string]action.Operator{ operators: map[string]action.Operator{
"d": operator.DeleteOperator{}, "d": operator.DeleteOperator{},
@ -78,6 +82,10 @@ func NewNormalKeymap() *Keymap {
"ctrl+r": action.Redo{}, "ctrl+r": action.Redo{},
".": action.Repeat{Count: 1}, ".": action.Repeat{Count: 1},
"R": action.EnterReplace{}, "R": action.EnterReplace{},
"J": action.JoinLines{Preserve: false},
"gJ": action.JoinLines{Preserve: true},
"/": action.EnterSearchMode{Forward: true},
"?": action.EnterSearchMode{Forward: false},
}, },
charMotions: map[string]action.Motion{ charMotions: map[string]action.Motion{
"f": action.FindChar{Forward: true, Inclusive: true, Repeated: false}, "f": action.FindChar{Forward: true, Inclusive: true, Repeated: false},
@ -119,6 +127,9 @@ func NewVisualKeymap() *Keymap {
"k": motion.MoveUp{Count: 1}, "k": motion.MoveUp{Count: 1},
"h": motion.MoveLeft{Count: 1}, "h": motion.MoveLeft{Count: 1},
"l": motion.MoveRight{Count: 1}, "l": motion.MoveRight{Count: 1},
"H": motion.MoveToScreenTop{Count: 1},
"M": motion.MoveToScreenMiddle{},
"L": motion.MoveToScreenBottom{Count: 1},
"G": motion.MoveToBottom{}, "G": motion.MoveToBottom{},
"gg": motion.MoveToTop{}, "gg": motion.MoveToTop{},
"0": motion.MoveToLineStart{}, "0": motion.MoveToLineStart{},
@ -140,6 +151,7 @@ func NewVisualKeymap() *Keymap {
"ctrl+f": motion.ScrollDownPage{Divisor: 1}, "ctrl+f": motion.ScrollDownPage{Divisor: 1},
";": action.RepeatFind{Count: 1, Reverse: false}, ";": action.RepeatFind{Count: 1, Reverse: false},
",": action.RepeatFind{Count: 1, Reverse: true}, ",": action.RepeatFind{Count: 1, Reverse: true},
"%": motion.JumpToMatchingDelimiter{},
// TODO: O and o. These are fun ones! Should be simple too // TODO: O and o. These are fun ones! Should be simple too
}, },
operators: map[string]action.Operator{ operators: map[string]action.Operator{
@ -162,6 +174,7 @@ func NewVisualKeymap() *Keymap {
"F": action.FindChar{Forward: false, Inclusive: true}, "F": action.FindChar{Forward: false, Inclusive: true},
"t": action.FindChar{Forward: true, Inclusive: false}, "t": action.FindChar{Forward: true, Inclusive: false},
"T": action.FindChar{Forward: false, Inclusive: false}, "T": action.FindChar{Forward: false, Inclusive: false},
"r": action.ReplaceChar{Count: 1},
}, },
modifiers: map[string]any{ modifiers: map[string]any{
"i": nil, "i": nil,
@ -246,6 +259,26 @@ func NewCommandKeymap() *Keymap {
"ctrl+w": action.CommandDeletePreviousWord{}, "ctrl+w": action.CommandDeletePreviousWord{},
}, },
} }
}
// NewSearchKeymap: Creates a keymap for search mode with command line editing.
func NewSearchKeymap() *Keymap {
return &Keymap{
motions: map[string]action.Motion{
"left": motion.MoveSearchLeft{},
"right": motion.MoveSearchRight{},
// "up": motion.MoveCommandHistoryUp{},
// "down": motion.MoveCommandHistoryDown{},
},
operators: map[string]action.Operator{}, // this will likely be empty
actions: map[string]action.Action{
"esc": action.ExitSearchMode{},
"enter": action.SearchExecute{},
"backspace": action.SearchBackspace{},
"delete": action.SearchDelete{},
"ctrl+w": action.SearchDeletePreviousWord{},
},
}
} }

View File

@ -315,7 +315,7 @@ func TestCommandHistoryIntegration(t *testing.T) {
func TestCommandHistoryWithLongHistory(t *testing.T) { func TestCommandHistoryWithLongHistory(t *testing.T) {
t.Run("navigate through 20 commands", func(t *testing.T) { t.Run("navigate through 20 commands", func(t *testing.T) {
history := make([]string, 20) history := make([]string, 20)
for i := 0; i < 20; i++ { for i := range 20 {
history[i] = string(rune('A' + i)) history[i] = string(rune('A' + i))
} }
@ -329,7 +329,7 @@ func TestCommandHistoryWithLongHistory(t *testing.T) {
upAction := MoveCommandHistoryUp{} upAction := MoveCommandHistoryUp{}
// Navigate to 10th command // Navigate to 10th command
for i := 0; i < 10; i++ { for range 10 {
upAction.Execute(m) upAction.Execute(m)
} }
@ -344,7 +344,7 @@ func TestCommandHistoryWithLongHistory(t *testing.T) {
t.Run("navigate to very end of long history", func(t *testing.T) { t.Run("navigate to very end of long history", func(t *testing.T) {
history := make([]string, 50) history := make([]string, 50)
for i := 0; i < 50; i++ { for i := range 50 {
history[i] = string(rune('0' + (i % 10))) history[i] = string(rune('0' + (i % 10)))
} }
@ -358,7 +358,7 @@ func TestCommandHistoryWithLongHistory(t *testing.T) {
upAction := MoveCommandHistoryUp{} upAction := MoveCommandHistoryUp{}
// Navigate all the way to the end // Navigate all the way to the end
for i := 0; i < 100; i++ { // Try to go past end for range 100 { // Try to go past end
upAction.Execute(m) upAction.Execute(m)
} }

View File

@ -0,0 +1,113 @@
package motion
import "git.gophernest.net/azpect/TextEditor/internal/core"
// Delimiter matching helpers operate on byte-indexed columns, which matches the
// rest of the editor cursor model.
func isDelimiter(ch byte) bool {
switch ch {
case '{', '}', '[', ']', '(', ')':
return true
default:
return false
}
}
func isOpeningDelimiter(ch byte) bool {
return ch == '{' || ch == '[' || ch == '('
}
func getOppositeDelimiter(ch byte) (byte, bool) {
switch ch {
case '{':
return '}', true
case '}':
return '{', true
case '[':
return ']', true
case ']':
return '[', true
case '(':
return ')', true
case ')':
return '(', true
default:
return 0, false
}
}
func findDelimiterOnLine(line string, startCol int) (int, byte, bool) {
if len(line) == 0 {
return 0, 0, false
}
col := max(0, startCol)
for col < len(line) {
ch := line[col]
if isDelimiter(ch) {
return col, ch, true
}
col++
}
return 0, 0, false
}
func findMatchingForward(buf *core.Buffer, line, col int, startDelim, matchDelim byte) (core.Position, bool) {
depth := 0
for y := line; y < buf.LineCount(); y++ {
text := buf.Line(y)
xStart := 0
if y == line {
xStart = col + 1
}
for x := xStart; x < len(text); x++ {
ch := text[x]
if ch == startDelim {
depth++
continue
}
if ch == matchDelim {
if depth == 0 {
return core.Position{Line: y, Col: x}, true
}
depth--
}
}
}
return core.Position{}, false
}
func findMatchingBackward(buf *core.Buffer, line, col int, startDelim, matchDelim byte) (core.Position, bool) {
depth := 0
for y := line; y >= 0; y-- {
text := buf.Line(y)
xStart := len(text) - 1
if y == line {
xStart = col - 1
}
for x := xStart; x >= 0; x-- {
ch := text[x]
if ch == startDelim {
depth++
continue
}
if ch == matchDelim {
if depth == 0 {
return core.Position{Line: y, Col: x}, true
}
depth--
}
}
}
return core.Position{}, false
}

View File

@ -6,6 +6,27 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
func firstNonBlankCol(line string) int {
for i := 0; i < len(line); i++ {
if line[i] != ' ' && line[i] != '\t' {
return i
}
}
return 0
}
func visibleLineBounds(win *core.Window, buf *core.Buffer) (int, int) {
if buf.LineCount() == 0 {
return 0, 0
}
start := win.ScrollY
end := start + win.ViewportHeight() - 1
end = max(min(end, buf.LineCount()-1), start)
return start, end
}
// MoveToTop implements Motion (gg) - linewise // MoveToTop implements Motion (gg) - linewise
type MoveToTop struct{} type MoveToTop struct{}
@ -198,3 +219,114 @@ func (a ScrollUpPage) Execute(m action.Model) tea.Cmd {
} }
func (a ScrollUpPage) Type() core.MotionType { return core.Linewise } func (a ScrollUpPage) Type() core.MotionType { return core.Linewise }
// MoveToScreenTop implements Motion (H) - linewise
type MoveToScreenTop struct {
Count int
}
// MoveToScreenTop.Execute: Moves the cursor to the count-th line from the top
// of the visible window and places it on the first non-blank character.
func (a MoveToScreenTop) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
start, end := visibleLineBounds(win, buf)
count := max(1, a.Count)
targetLine := min(start+count-1, end)
targetCol := firstNonBlankCol(buf.Line(targetLine))
win.SetCursorPos(targetLine, targetCol)
return nil
}
func (a MoveToScreenTop) Type() core.MotionType { return core.Linewise }
func (a MoveToScreenTop) WithCount(n int) action.Action {
return MoveToScreenTop{Count: n}
}
// MoveToScreenMiddle implements Motion (M) - linewise
type MoveToScreenMiddle struct{}
// MoveToScreenMiddle.Execute: Moves the cursor to the middle visible line and
// places it on the first non-blank character.
func (a MoveToScreenMiddle) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
start, end := visibleLineBounds(win, buf)
targetLine := start + (end-start)/2
targetCol := firstNonBlankCol(buf.Line(targetLine))
win.SetCursorPos(targetLine, targetCol)
return nil
}
func (a MoveToScreenMiddle) Type() core.MotionType { return core.Linewise }
// MoveToScreenBottom implements Motion (L) - linewise
type MoveToScreenBottom struct {
Count int
}
// MoveToScreenBottom.Execute: Moves the cursor to the count-th line from the
// bottom of the visible window and places it on the first non-blank character.
func (a MoveToScreenBottom) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
start, end := visibleLineBounds(win, buf)
count := max(1, a.Count)
targetLine := max(end-count+1, start)
targetCol := firstNonBlankCol(buf.Line(targetLine))
win.SetCursorPos(targetLine, targetCol)
return nil
}
func (a MoveToScreenBottom) Type() core.MotionType { return core.Linewise }
func (a MoveToScreenBottom) WithCount(n int) action.Action {
return MoveToScreenBottom{Count: n}
}
// Used for the % motion, not countable
type JumpToMatchingDelimiter struct{}
func (a JumpToMatchingDelimiter) Execute(m action.Model) tea.Cmd {
buf := m.ActiveBuffer()
if buf.LineCount() == 0 {
return nil
}
win := m.ActiveWindow()
lineIdx := win.Cursor.Line
line := buf.Line(lineIdx)
col, startDelim, found := findDelimiterOnLine(line, win.Cursor.Col)
if !found {
return nil
}
matchDelim, ok := getOppositeDelimiter(startDelim)
if !ok {
return nil
}
var target core.Position
if isOpeningDelimiter(startDelim) {
target, found = findMatchingForward(buf, lineIdx, col, startDelim, matchDelim)
} else {
target, found = findMatchingBackward(buf, lineIdx, col, startDelim, matchDelim)
}
if !found {
return nil
}
win.SetCursorPos(target.Line, target.Col)
return nil
}
func (a JumpToMatchingDelimiter) Type() core.MotionType { return core.CharwiseInclusive }

37
internal/motion/search.go Normal file
View File

@ -0,0 +1,37 @@
package motion
import (
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// MoveSearchLeft implements Motion - moves cursor left in search line.
type MoveSearchLeft struct{}
// MoveSearchLeft.Execute: Moves the search line cursor one position to the left.
func (a MoveSearchLeft) Execute(m action.Model) tea.Cmd {
search := m.SearchState()
search.Cursor = max(0, search.Cursor-1)
m.SetSearchState(search)
return nil
}
// MoveSearchLeft.Type: Returns CharwiseExclusive for search line motion.
func (a MoveSearchLeft) Type() core.MotionType { return core.CharwiseExclusive }
// MoveSearchRight implements Motion - moves cursor right in search line.
type MoveSearchRight struct{}
// MoveSearchRight.Execute: Moves the search line cursor one position to the right.
func (a MoveSearchRight) Execute(m action.Model) tea.Cmd {
search := m.SearchState()
search.Cursor = min(search.Cursor+1, len(search.Query))
m.SetSearchState(search)
return nil
}
// MoveSearchRight.Type: Returns CharwiseExclusive for search line motion.
func (a MoveSearchRight) Type() core.MotionType { return core.CharwiseExclusive }

View File

@ -6,7 +6,7 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// ChangeOperator implements Operator (c) - changes (deletes and enters insert mode) text. // ChangeOperator implements Operator (c, s, R) - changes (deletes and enters insert mode) text.
type ChangeOperator struct{} type ChangeOperator struct{}
// ChangeOperator.Operate: Changes text based on the current mode and motion type. // ChangeOperator.Operate: Changes text based on the current mode and motion type.

View File

@ -1,282 +0,0 @@
package style
import (
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/charmbracelet/lipgloss"
)
// Styles holds all the visual styling for the editor.
type Styles struct {
// Cursor styles by mode
CursorNormal lipgloss.Style
CursorInsert lipgloss.Style
CursorCommand lipgloss.Style
CursorReplace lipgloss.Style
// Gutter (line numbers)
Gutter lipgloss.Style
GutterCurrentLine lipgloss.Style
// Visual mode
VisualHighlight lipgloss.Style
VisualAnchor lipgloss.Style // debugging
// Status bar
StatusBar lipgloss.Style
StatusBarActive lipgloss.Style
// Command line
CommandError lipgloss.Style
CommandOutputBorder lipgloss.Style
CommandContinueMessage lipgloss.Style
// General Styles
LineStyle lipgloss.Style // This is a simple background with no text coloring
BackgroundStyle lipgloss.Style // This is just the background
// Chroma data
ChromaStyle *chroma.Style
}
// DefaultStyles: Returns the default editor color scheme.
func DefaultStyles() Styles {
return Styles{
CursorNormal: lipgloss.NewStyle().Reverse(true),
CursorInsert: lipgloss.NewStyle().Underline(true),
CursorCommand: lipgloss.NewStyle().Reverse(true),
CursorReplace: lipgloss.NewStyle().Underline(true),
Gutter: lipgloss.NewStyle().
Background(lipgloss.Color("236")).
Foreground(lipgloss.Color("243")),
GutterCurrentLine: lipgloss.NewStyle().
Background(lipgloss.Color("236")).
Foreground(lipgloss.Color("#d69d00")),
VisualHighlight: lipgloss.NewStyle().
Background(lipgloss.Color("#7a6a00")),
VisualAnchor: lipgloss.NewStyle().
Background(lipgloss.Color("#a89020")),
StatusBar: lipgloss.NewStyle().
Background(lipgloss.Color("236")).
Foreground(lipgloss.Color("243")),
StatusBarActive: lipgloss.NewStyle().
Background(lipgloss.Color("62")).
Foreground(lipgloss.Color("230")),
CommandError: lipgloss.NewStyle().
Foreground(lipgloss.Color("#e3203a")),
CommandOutputBorder: lipgloss.NewStyle().
Background(lipgloss.Color("#000000")),
CommandContinueMessage: lipgloss.NewStyle().
Foreground(lipgloss.Color("#546fba")),
ChromaStyle: nil,
}
}
func ChromaStyles(chromaStyle *chroma.Style) Styles {
bgString := chromaStyle.Get(chroma.Background).Background.String()
lineNumbers := chromaStyle.Get(chroma.LineTableTD)
lineHighlight := chromaStyle.Get(chroma.LineHighlight)
return Styles{
CursorNormal: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Reverse(true),
CursorInsert: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Bold(true).
Underline(true),
CursorCommand: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Reverse(true),
CursorReplace: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Underline(true),
Gutter: lipgloss.NewStyle().
Background(lipgloss.Color(
darkenColor(lineNumbers.Background, 0.9).String()),
).
Foreground(lipgloss.Color(lineNumbers.Colour.String())),
GutterCurrentLine: lipgloss.NewStyle().
Background(lipgloss.Color(
darkenColor(lineNumbers.Background, 0.9).String()),
).
Foreground(lipgloss.Color(lineNumbers.Colour.String())),
VisualHighlight: lipgloss.NewStyle().
Background(lipgloss.Color(lineHighlight.Background.String())).
Foreground(lipgloss.Color(lineHighlight.Colour.String())),
VisualAnchor: lipgloss.NewStyle().
Background(lipgloss.Color(lineHighlight.Background.String())).
Foreground(lipgloss.Color(lineHighlight.Colour.String())),
StatusBar: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Foreground(lipgloss.Color("243")),
StatusBarActive: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Foreground(lipgloss.Color("230")),
CommandError: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Foreground(lipgloss.Color("#e3203a")),
CommandOutputBorder: lipgloss.NewStyle().
Background(
lipgloss.Color(
darkenColor(
chromaStyle.Get(chroma.Background).Background, 0.5).
String(),
),
),
CommandContinueMessage: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Foreground(lipgloss.Color("#546fba")),
LineStyle: lipgloss.NewStyle().
Foreground(lipgloss.Color(chromaStyle.Get(chroma.Line).Colour.String())).
Background(lipgloss.Color(bgString)),
BackgroundStyle: lipgloss.NewStyle().Background(lipgloss.Color(bgString)),
ChromaStyle: chromaStyle,
}
}
// Styles.DefaultCursorStyle: Returns the appropriate cursor style for the given mode.
func (s Styles) DefaultCursorStyle(mode core.Mode) lipgloss.Style {
switch mode {
case core.InsertMode:
return s.CursorInsert
case core.CommandMode:
return s.CursorCommand
case core.ReplaceMode:
return s.CursorReplace
default:
return s.CursorNormal
}
}
// Styles.CursorStyle: Returns a cursor style derived from a chroma style. This function should preferred
// over the DefaultCursorStyle, but in cases where there is no style to apply, the DefaultCursorStyle
// will always work.
func (s Styles) CursorStyle(mode core.Mode, style lipgloss.Style) lipgloss.Style {
switch mode {
case core.NormalMode, core.VisualLineMode, core.VisualBlockMode, core.VisualMode:
return lipgloss.NewStyle().
Background(style.GetForeground()).
Foreground(style.GetBackground())
case core.ReplaceMode, core.WaitingMode:
return lipgloss.NewStyle().
Background(style.GetBackground()).
Foreground(style.GetForeground()).
Underline(true)
default:
return lipgloss.NewStyle().
Background(s.BackgroundStyle.GetBackground()).
Foreground(style.GetForeground()).
Underline(true)
}
}
// Styles.VisualHighlightWithTextColor: Works analogously to CursorStyle vs DefaultCursorStyle. When a
// style is available, this function should be used, so the text color will be rendered in front
// of the background. Otherwise, the VisualHighlight property will always work.
func (s Styles) VisualHighlightWithTextColor(style lipgloss.Style) lipgloss.Style {
return lipgloss.NewStyle().
Background(s.VisualHighlight.GetBackground()).
Foreground(style.GetForeground())
}
// Styles.MakeStyleMap: Generates a style map for a single line. A style map is a mapping from
// column a lipgloss style. Cursor styles are not handled by this map, but they can be derived
// by inverting the background and foreground (and rolling back to the default).
func (s Styles) MakeStyleMap(lexer chroma.Lexer, line string) []lipgloss.Style {
m := make([]lipgloss.Style, len(line))
if s.ChromaStyle == nil {
return m
}
iter, err := lexer.Tokenise(nil, line)
if err != nil {
panic(err)
}
col := 0
for _, token := range iter.Tokens() {
entry := s.ChromaStyle.Get(token.Type)
s := lipgloss.NewStyle().
Background(lipgloss.Color(entry.Background.String())).
Foreground(lipgloss.Color(entry.Colour.String()))
for _, char := range token.Value {
if char == '\n' {
continue
}
if col < len(m) {
m[col] = s
}
col++
}
}
return m
}
// darkenColor: Uses a factor (0.0 to 1.0) to darken a color using its opacity.
func darkenColor(c chroma.Colour, factor float64) chroma.Colour {
r := uint8(float64(c.Red()) * factor)
g := uint8(float64(c.Green()) * factor)
b := uint8(float64(c.Blue()) * factor)
return chroma.NewColour(r, g, b)
}
// GetLexer: Uses buffer meta data or content to pick a lexer for use in applying
// highlights.
func GetLexer(buf *core.Buffer) chroma.Lexer {
var lexer chroma.Lexer
if buf.Filetype != "" {
lexer = lexers.Get(strings.TrimPrefix(buf.Filetype, "."))
}
if lexer == nil && buf.Filename != "" {
lexer = lexers.Match(buf.Filename)
}
if lexer == nil && len(buf.Lines) > 0 {
// Get first few lines for content analysis
var content strings.Builder
for i := 0; i < min(len(buf.Lines), 10); i++ {
content.WriteString(buf.Lines[i].String() + "\n")
}
lexer = lexers.Analyse(content.String())
}
if lexer == nil {
lexer = lexers.Fallback
}
lexer = chroma.Coalesce(lexer) // Merge tokens together
return lexer
}

10
internal/syntax/README.md Normal file
View File

@ -0,0 +1,10 @@
# How to add more languages now (quick workflow)
1. Add binding dependency in go.mod
2. Add query file under internal/syntax/queries/<lang>/highlights.scm
3. Embed it in internal/syntax/query_assets.go
4. Add one register(...) block in internal/syntax/registry.go
5. Update internal/syntax/query_assets_test.go with another test
## Where to get .scm files
[nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter/tree/master/queries)

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

@ -0,0 +1,28 @@
package syntax
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/theme"
"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, t theme.EditorTheme)
// 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, t theme.EditorTheme) []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)
}

View File

@ -0,0 +1,261 @@
[
"("
")"
"{"
"}"
"["
"]"
"[["
"]]"
"(("
"))"
] @punctuation.bracket
[
";"
";;"
";&"
";;&"
"&"
] @punctuation.delimiter
[
">"
">>"
"<"
"<<"
"&&"
"|"
"|&"
"||"
"="
"+="
"=~"
"=="
"!="
"&>"
"&>>"
"<&"
">&"
">|"
"<&-"
">&-"
"<<-"
"<<<"
".."
"!"
] @operator
; Do *not* spell check strings since they typically have some sort of
; interpolation in them, or, are typically used for things like filenames, URLs,
; flags and file content.
[
(string)
(raw_string)
(ansi_c_string)
(heredoc_body)
] @string
[
(heredoc_start)
(heredoc_end)
] @label
(variable_assignment
(word) @string)
(command
argument: "$" @string) ; bare dollar
(concatenation
(word) @string)
[
"if"
"then"
"else"
"elif"
"fi"
"case"
"in"
"esac"
] @keyword.conditional
[
"for"
"do"
"done"
"select"
"until"
"while"
] @keyword.repeat
[
"declare"
"typeset"
"readonly"
"local"
"unset"
"unsetenv"
] @keyword
"export" @keyword.import
"function" @keyword.function
(special_variable_name) @constant
; trap -l
((word) @constant.builtin
(#any-of? @constant.builtin
"SIGHUP" "SIGINT" "SIGQUIT" "SIGILL" "SIGTRAP" "SIGABRT" "SIGBUS" "SIGFPE" "SIGKILL" "SIGUSR1"
"SIGSEGV" "SIGUSR2" "SIGPIPE" "SIGALRM" "SIGTERM" "SIGSTKFLT" "SIGCHLD" "SIGCONT" "SIGSTOP"
"SIGTSTP" "SIGTTIN" "SIGTTOU" "SIGURG" "SIGXCPU" "SIGXFSZ" "SIGVTALRM" "SIGPROF" "SIGWINCH"
"SIGIO" "SIGPWR" "SIGSYS" "SIGRTMIN" "SIGRTMIN+1" "SIGRTMIN+2" "SIGRTMIN+3" "SIGRTMIN+4"
"SIGRTMIN+5" "SIGRTMIN+6" "SIGRTMIN+7" "SIGRTMIN+8" "SIGRTMIN+9" "SIGRTMIN+10" "SIGRTMIN+11"
"SIGRTMIN+12" "SIGRTMIN+13" "SIGRTMIN+14" "SIGRTMIN+15" "SIGRTMAX-14" "SIGRTMAX-13"
"SIGRTMAX-12" "SIGRTMAX-11" "SIGRTMAX-10" "SIGRTMAX-9" "SIGRTMAX-8" "SIGRTMAX-7" "SIGRTMAX-6"
"SIGRTMAX-5" "SIGRTMAX-4" "SIGRTMAX-3" "SIGRTMAX-2" "SIGRTMAX-1" "SIGRTMAX"))
((word) @boolean
(#any-of? @boolean "true" "false"))
(comment) @comment @spell
(test_operator) @operator
(command_substitution
"$(" @punctuation.special
")" @punctuation.special)
(process_substitution
[
"<("
">("
] @punctuation.special
")" @punctuation.special)
(arithmetic_expansion
[
"$(("
"(("
] @punctuation.special
"))" @punctuation.special)
(arithmetic_expansion
"," @punctuation.delimiter)
(ternary_expression
[
"?"
":"
] @keyword.conditional.ternary)
(binary_expression
operator: _ @operator)
(unary_expression
operator: _ @operator)
(postfix_expression
operator: _ @operator)
(function_definition
name: (word) @function)
(command_name
(word) @function.call)
(command_name
(word) @function.builtin
(#any-of? @function.builtin
"." ":" "alias" "bg" "bind" "break" "builtin" "caller" "cd" "command" "compgen" "complete"
"compopt" "continue" "coproc" "dirs" "disown" "echo" "enable" "eval" "exec" "exit" "false" "fc"
"fg" "getopts" "hash" "help" "history" "jobs" "kill" "let" "logout" "mapfile" "popd" "printf"
"pushd" "pwd" "read" "readarray" "return" "set" "shift" "shopt" "source" "suspend" "test" "time"
"times" "trap" "true" "type" "typeset" "ulimit" "umask" "unalias" "wait"))
(command
argument: [
(word) @variable.parameter
(concatenation
(word) @variable.parameter)
])
(declaration_command
(word) @variable.parameter)
(unset_command
(word) @variable.parameter)
(number) @number
((word) @number
(#lua-match? @number "^[0-9]+$"))
(file_redirect
(word) @string.special.path)
(herestring_redirect
(word) @string)
(file_descriptor) @operator
(simple_expansion
"$" @punctuation.special) @none
(expansion
"${" @punctuation.special
"}" @punctuation.special) @none
(expansion
operator: _ @punctuation.special)
(expansion
"@"
.
operator: _ @character.special)
((expansion
(subscript
index: (word) @character.special))
(#any-of? @character.special "@" "*"))
"``" @punctuation.special
(variable_name) @variable
((variable_name) @constant
(#lua-match? @constant "^[A-Z][A-Z_0-9]*$"))
((variable_name) @variable.builtin
(#any-of? @variable.builtin
; https://www.gnu.org/software/bash/manual/html_node/Bourne-Shell-Variables.html
"CDPATH" "HOME" "IFS" "MAIL" "MAILPATH" "OPTARG" "OPTIND" "PATH" "PS1" "PS2"
; https://www.gnu.org/software/bash/manual/html_node/Bash-Variables.html
"_" "BASH" "BASHOPTS" "BASHPID" "BASH_ALIASES" "BASH_ARGC" "BASH_ARGV" "BASH_ARGV0" "BASH_CMDS"
"BASH_COMMAND" "BASH_COMPAT" "BASH_ENV" "BASH_EXECUTION_STRING" "BASH_LINENO"
"BASH_LOADABLES_PATH" "BASH_REMATCH" "BASH_SOURCE" "BASH_SUBSHELL" "BASH_VERSINFO"
"BASH_VERSION" "BASH_XTRACEFD" "CHILD_MAX" "COLUMNS" "COMP_CWORD" "COMP_LINE" "COMP_POINT"
"COMP_TYPE" "COMP_KEY" "COMP_WORDBREAKS" "COMP_WORDS" "COMPREPLY" "COPROC" "DIRSTACK" "EMACS"
"ENV" "EPOCHREALTIME" "EPOCHSECONDS" "EUID" "EXECIGNORE" "FCEDIT" "FIGNORE" "FUNCNAME"
"FUNCNEST" "GLOBIGNORE" "GROUPS" "histchars" "HISTCMD" "HISTCONTROL" "HISTFILE" "HISTFILESIZE"
"HISTIGNORE" "HISTSIZE" "HISTTIMEFORMAT" "HOSTFILE" "HOSTNAME" "HOSTTYPE" "IGNOREEOF" "INPUTRC"
"INSIDE_EMACS" "LANG" "LC_ALL" "LC_COLLATE" "LC_CTYPE" "LC_MESSAGES" "LC_NUMERIC" "LC_TIME"
"LINENO" "LINES" "MACHTYPE" "MAILCHECK" "MAPFILE" "OLDPWD" "OPTERR" "OSTYPE" "PIPESTATUS"
"POSIXLY_CORRECT" "PPID" "PROMPT_COMMAND" "PROMPT_DIRTRIM" "PS0" "PS3" "PS4" "PWD" "RANDOM"
"READLINE_ARGUMENT" "READLINE_LINE" "READLINE_MARK" "READLINE_POINT" "REPLY" "SECONDS" "SHELL"
"SHELLOPTS" "SHLVL" "SRANDOM" "TIMEFORMAT" "TMOUT" "TMPDIR" "UID"))
(case_item
value: (word) @variable.parameter)
[
(regex)
(extglob_pattern)
] @string.regexp
((program
.
(comment) @keyword.directive @nospell)
(#lua-match? @keyword.directive "^#!/"))

View File

@ -0,0 +1,341 @@
; Lower priority to prefer @variable.parameter when identifier appears in parameter_declaration.
((identifier) @variable
(#set! priority 95))
(preproc_def
(preproc_arg) @variable)
[
"default"
"goto"
"asm"
"__asm__"
] @keyword
[
"enum"
"struct"
"union"
"typedef"
] @keyword.type
[
"sizeof"
"offsetof"
] @keyword.operator
(alignof_expression
.
_ @keyword.operator)
"return" @keyword.return
[
"while"
"for"
"do"
"continue"
"break"
] @keyword.repeat
[
"if"
"else"
"case"
"switch"
] @keyword.conditional
[
"#if"
"#ifdef"
"#ifndef"
"#else"
"#elif"
"#endif"
"#elifdef"
"#elifndef"
(preproc_directive)
] @keyword.directive
"#define" @keyword.directive.define
"#include" @keyword.import
[
";"
":"
","
"."
"::"
] @punctuation.delimiter
"..." @punctuation.special
[
"("
")"
"["
"]"
"{"
"}"
] @punctuation.bracket
[
"="
"-"
"*"
"/"
"+"
"%"
"~"
"|"
"&"
"^"
"<<"
">>"
"->"
"<"
"<="
">="
">"
"=="
"!="
"!"
"&&"
"||"
"-="
"+="
"*="
"/="
"%="
"|="
"&="
"^="
">>="
"<<="
"--"
"++"
] @operator
; Make sure the comma operator is given a highlight group after the comma
; punctuator so the operator is highlighted properly.
(comma_expression
"," @operator)
[
(true)
(false)
] @boolean
(conditional_expression
[
"?"
":"
] @keyword.conditional.ternary)
(string_literal) @string
(system_lib_string) @string
(escape_sequence) @string.escape
(null) @constant.builtin
(number_literal) @number
(char_literal) @character
(preproc_defined) @function.macro
((field_expression
(field_identifier) @property) @_parent
(#not-has-parent? @_parent template_method function_declarator call_expression))
(field_designator) @property
((field_identifier) @property
(#has-ancestor? @property field_declaration)
(#not-has-ancestor? @property function_declarator))
(statement_identifier) @label
(declaration
type: (type_identifier) @_type
declarator: (identifier) @label
(#eq? @_type "__label__"))
[
(type_identifier)
(type_descriptor)
] @type
(storage_class_specifier) @keyword.modifier
[
(type_qualifier)
(gnu_asm_qualifier)
"__extension__"
] @keyword.modifier
(linkage_specification
"extern" @keyword.modifier)
(type_definition
declarator: (type_identifier) @type.definition)
(primitive_type) @type.builtin
(sized_type_specifier
_ @type.builtin
type: _?)
((identifier) @constant
(#lua-match? @constant "^[A-Z][A-Z0-9_]+$"))
(preproc_def
(preproc_arg) @constant
(#lua-match? @constant "^[A-Z][A-Z0-9_]+$"))
(enumerator
name: (identifier) @constant)
(case_statement
value: (identifier) @constant)
((identifier) @constant.builtin
; format-ignore
(#any-of? @constant.builtin
"stderr" "stdin" "stdout"
"__FILE__" "__LINE__" "__DATE__" "__TIME__"
"__STDC__" "__STDC_VERSION__" "__STDC_HOSTED__"
"__cplusplus" "__OBJC__" "__ASSEMBLER__"
"__BASE_FILE__" "__FILE_NAME__" "__INCLUDE_LEVEL__"
"__TIMESTAMP__" "__clang__" "__clang_major__"
"__clang_minor__" "__clang_patchlevel__"
"__clang_version__" "__clang_literal_encoding__"
"__clang_wide_literal_encoding__"
"__FUNCTION__" "__func__" "__PRETTY_FUNCTION__"
"__VA_ARGS__" "__VA_OPT__"))
(preproc_def
(preproc_arg) @constant.builtin
; format-ignore
(#any-of? @constant.builtin
"stderr" "stdin" "stdout"
"__FILE__" "__LINE__" "__DATE__" "__TIME__"
"__STDC__" "__STDC_VERSION__" "__STDC_HOSTED__"
"__cplusplus" "__OBJC__" "__ASSEMBLER__"
"__BASE_FILE__" "__FILE_NAME__" "__INCLUDE_LEVEL__"
"__TIMESTAMP__" "__clang__" "__clang_major__"
"__clang_minor__" "__clang_patchlevel__"
"__clang_version__" "__clang_literal_encoding__"
"__clang_wide_literal_encoding__"
"__FUNCTION__" "__func__" "__PRETTY_FUNCTION__"
"__VA_ARGS__" "__VA_OPT__"))
(attribute_specifier
(argument_list
(identifier) @variable.builtin))
(attribute_specifier
(argument_list
(call_expression
function: (identifier) @variable.builtin)))
((call_expression
function: (identifier) @function.builtin)
(#lua-match? @function.builtin "^__builtin_"))
((call_expression
function: (identifier) @function.builtin)
(#has-ancestor? @function.builtin attribute_specifier))
; Preproc def / undef
(preproc_def
name: (_) @constant.macro)
(preproc_call
directive: (preproc_directive) @_u
argument: (_) @constant.macro
(#eq? @_u "#undef"))
(preproc_ifdef
name: (identifier) @constant.macro)
(preproc_elifdef
name: (identifier) @constant.macro)
(preproc_defined
(identifier) @constant.macro)
(call_expression
function: (identifier) @function.call)
(call_expression
function: (field_expression
field: (field_identifier) @function.call))
(function_declarator
declarator: (identifier) @function)
(function_declarator
declarator: (parenthesized_declarator
(pointer_declarator
declarator: (field_identifier) @function)))
(preproc_function_def
name: (identifier) @function.macro)
(comment) @comment @spell
((comment) @comment.documentation
(#lua-match? @comment.documentation "^/[*][*][^*].*[*]/$"))
; Parameters
(parameter_declaration
declarator: (identifier) @variable.parameter)
(parameter_declaration
declarator: (array_declarator) @variable.parameter)
(parameter_declaration
declarator: (pointer_declarator) @variable.parameter)
; K&R functions
; To enable support for K&R functions,
; add the following lines to your own query config and uncomment them.
; They are commented out as they'll conflict with C++
; Note that you'll need to have `; extends` at the top of your query file.
;
; (parameter_list (identifier) @variable.parameter)
;
; (function_definition
; declarator: _
; (declaration
; declarator: (identifier) @variable.parameter))
;
; (function_definition
; declarator: _
; (declaration
; declarator: (array_declarator) @variable.parameter))
;
; (function_definition
; declarator: _
; (declaration
; declarator: (pointer_declarator) @variable.parameter))
(preproc_params
(identifier) @variable.parameter)
[
"__attribute__"
"__declspec"
"__based"
"__cdecl"
"__clrcall"
"__stdcall"
"__fastcall"
"__thiscall"
"__vectorcall"
(ms_pointer_modifier)
(attribute_declaration)
] @attribute

View File

@ -0,0 +1,608 @@
; Lower priority to prefer @variable.parameter when identifier appears in parameter_declaration.
((identifier) @variable
(#set! priority 95))
(preproc_def
(preproc_arg) @variable)
[
"default"
"goto"
"asm"
"__asm__"
] @keyword
[
"enum"
"struct"
"union"
"typedef"
] @keyword.type
[
"sizeof"
"offsetof"
] @keyword.operator
(alignof_expression
.
_ @keyword.operator)
"return" @keyword.return
[
"while"
"for"
"do"
"continue"
"break"
] @keyword.repeat
[
"if"
"else"
"case"
"switch"
] @keyword.conditional
[
"#if"
"#ifdef"
"#ifndef"
"#else"
"#elif"
"#endif"
"#elifdef"
"#elifndef"
(preproc_directive)
] @keyword.directive
"#define" @keyword.directive.define
"#include" @keyword.import
[
";"
":"
","
"."
"::"
] @punctuation.delimiter
"..." @punctuation.special
[
"("
")"
"["
"]"
"{"
"}"
] @punctuation.bracket
[
"="
"-"
"*"
"/"
"+"
"%"
"~"
"|"
"&"
"^"
"<<"
">>"
"->"
"<"
"<="
">="
">"
"=="
"!="
"!"
"&&"
"||"
"-="
"+="
"*="
"/="
"%="
"|="
"&="
"^="
">>="
"<<="
"--"
"++"
] @operator
; Make sure the comma operator is given a highlight group after the comma
; punctuator so the operator is highlighted properly.
(comma_expression
"," @operator)
[
(true)
(false)
] @boolean
(conditional_expression
[
"?"
":"
] @keyword.conditional.ternary)
(string_literal) @string
(system_lib_string) @string
(escape_sequence) @string.escape
(null) @constant.builtin
(number_literal) @number
(char_literal) @character
(preproc_defined) @function.macro
((field_expression
(field_identifier) @property) @_parent
(#not-has-parent? @_parent template_method function_declarator call_expression))
(field_designator) @property
((field_identifier) @property
(#has-ancestor? @property field_declaration)
(#not-has-ancestor? @property function_declarator))
(statement_identifier) @label
(declaration
type: (type_identifier) @_type
declarator: (identifier) @label
(#eq? @_type "__label__"))
[
(type_identifier)
(type_descriptor)
] @type
(storage_class_specifier) @keyword.modifier
[
(type_qualifier)
(gnu_asm_qualifier)
"__extension__"
] @keyword.modifier
(linkage_specification
"extern" @keyword.modifier)
(type_definition
declarator: (type_identifier) @type.definition)
(primitive_type) @type.builtin
(sized_type_specifier
_ @type.builtin
type: _?)
((identifier) @constant
(#lua-match? @constant "^[A-Z][A-Z0-9_]+$"))
(preproc_def
(preproc_arg) @constant
(#lua-match? @constant "^[A-Z][A-Z0-9_]+$"))
(enumerator
name: (identifier) @constant)
(case_statement
value: (identifier) @constant)
((identifier) @constant.builtin
; format-ignore
(#any-of? @constant.builtin
"stderr" "stdin" "stdout"
"__FILE__" "__LINE__" "__DATE__" "__TIME__"
"__STDC__" "__STDC_VERSION__" "__STDC_HOSTED__"
"__cplusplus" "__OBJC__" "__ASSEMBLER__"
"__BASE_FILE__" "__FILE_NAME__" "__INCLUDE_LEVEL__"
"__TIMESTAMP__" "__clang__" "__clang_major__"
"__clang_minor__" "__clang_patchlevel__"
"__clang_version__" "__clang_literal_encoding__"
"__clang_wide_literal_encoding__"
"__FUNCTION__" "__func__" "__PRETTY_FUNCTION__"
"__VA_ARGS__" "__VA_OPT__"))
(preproc_def
(preproc_arg) @constant.builtin
; format-ignore
(#any-of? @constant.builtin
"stderr" "stdin" "stdout"
"__FILE__" "__LINE__" "__DATE__" "__TIME__"
"__STDC__" "__STDC_VERSION__" "__STDC_HOSTED__"
"__cplusplus" "__OBJC__" "__ASSEMBLER__"
"__BASE_FILE__" "__FILE_NAME__" "__INCLUDE_LEVEL__"
"__TIMESTAMP__" "__clang__" "__clang_major__"
"__clang_minor__" "__clang_patchlevel__"
"__clang_version__" "__clang_literal_encoding__"
"__clang_wide_literal_encoding__"
"__FUNCTION__" "__func__" "__PRETTY_FUNCTION__"
"__VA_ARGS__" "__VA_OPT__"))
(attribute_specifier
(argument_list
(identifier) @variable.builtin))
(attribute_specifier
(argument_list
(call_expression
function: (identifier) @variable.builtin)))
((call_expression
function: (identifier) @function.builtin)
(#lua-match? @function.builtin "^__builtin_"))
((call_expression
function: (identifier) @function.builtin)
(#has-ancestor? @function.builtin attribute_specifier))
; Preproc def / undef
(preproc_def
name: (_) @constant.macro)
(preproc_call
directive: (preproc_directive) @_u
argument: (_) @constant.macro
(#eq? @_u "#undef"))
(preproc_ifdef
name: (identifier) @constant.macro)
(preproc_elifdef
name: (identifier) @constant.macro)
(preproc_defined
(identifier) @constant.macro)
(call_expression
function: (identifier) @function.call)
(call_expression
function: (field_expression
field: (field_identifier) @function.call))
(function_declarator
declarator: (identifier) @function)
(function_declarator
declarator: (parenthesized_declarator
(pointer_declarator
declarator: (field_identifier) @function)))
(preproc_function_def
name: (identifier) @function.macro)
(comment) @comment @spell
((comment) @comment.documentation
(#lua-match? @comment.documentation "^/[*][*][^*].*[*]/$"))
; Parameters
(parameter_declaration
declarator: (identifier) @variable.parameter)
(parameter_declaration
declarator: (array_declarator) @variable.parameter)
(parameter_declaration
declarator: (pointer_declarator) @variable.parameter)
; K&R functions
; To enable support for K&R functions,
; add the following lines to your own query config and uncomment them.
; They are commented out as they'll conflict with C++
; Note that you'll need to have `; extends` at the top of your query file.
;
; (parameter_list (identifier) @variable.parameter)
;
; (function_definition
; declarator: _
; (declaration
; declarator: (identifier) @variable.parameter))
;
; (function_definition
; declarator: _
; (declaration
; declarator: (array_declarator) @variable.parameter))
;
; (function_definition
; declarator: _
; (declaration
; declarator: (pointer_declarator) @variable.parameter))
(preproc_params
(identifier) @variable.parameter)
[
"__attribute__"
"__declspec"
"__based"
"__cdecl"
"__clrcall"
"__stdcall"
"__fastcall"
"__thiscall"
"__vectorcall"
(ms_pointer_modifier)
(attribute_declaration)
] @attribute
((identifier) @variable.member
(#lua-match? @variable.member "^m_.*$"))
(parameter_declaration
declarator: (reference_declarator) @variable.parameter)
; function(Foo ...foo)
(variadic_parameter_declaration
declarator: (variadic_declarator
(_) @variable.parameter))
; int foo = 0
(optional_parameter_declaration
declarator: (_) @variable.parameter)
;(field_expression) @variable.parameter ;; How to highlight this?
((field_expression
(field_identifier) @function.method) @_parent
(#has-parent? @_parent template_method function_declarator))
(field_declaration
(field_identifier) @variable.member)
(field_initializer
(field_identifier) @property)
(function_declarator
declarator: (field_identifier) @function.method)
(concept_definition
name: (identifier) @type.definition)
(alias_declaration
name: (type_identifier) @type.definition)
(auto) @type.builtin
(namespace_identifier) @module
((namespace_identifier) @type
(#lua-match? @type "^[%u]"))
(case_statement
value: (qualified_identifier
(identifier) @constant))
(using_declaration
.
"using"
.
"namespace"
.
[
(qualified_identifier)
(identifier)
] @module)
(destructor_name
(identifier) @function.method)
; functions
(function_declarator
(qualified_identifier
(identifier) @function))
(function_declarator
(qualified_identifier
(qualified_identifier
(identifier) @function)))
(function_declarator
(qualified_identifier
(qualified_identifier
(qualified_identifier
(identifier) @function))))
((qualified_identifier
(qualified_identifier
(qualified_identifier
(qualified_identifier
(identifier) @function)))) @_parent
(#has-ancestor? @_parent function_declarator))
(function_declarator
(template_function
(identifier) @function))
(operator_name) @function
"operator" @function
"static_assert" @function.builtin
(call_expression
(qualified_identifier
(identifier) @function.call))
(call_expression
(qualified_identifier
(qualified_identifier
(identifier) @function.call)))
(call_expression
(qualified_identifier
(qualified_identifier
(qualified_identifier
(identifier) @function.call))))
((qualified_identifier
(qualified_identifier
(qualified_identifier
(qualified_identifier
(identifier) @function.call)))) @_parent
(#has-ancestor? @_parent call_expression))
(call_expression
(template_function
(identifier) @function.call))
(call_expression
(qualified_identifier
(template_function
(identifier) @function.call)))
(call_expression
(qualified_identifier
(qualified_identifier
(template_function
(identifier) @function.call))))
(call_expression
(qualified_identifier
(qualified_identifier
(qualified_identifier
(template_function
(identifier) @function.call)))))
((qualified_identifier
(qualified_identifier
(qualified_identifier
(qualified_identifier
(template_function
(identifier) @function.call))))) @_parent
(#has-ancestor? @_parent call_expression))
; methods
(function_declarator
(template_method
(field_identifier) @function.method))
(call_expression
(field_expression
(field_identifier) @function.method.call))
; constructors
((function_declarator
(qualified_identifier
(identifier) @constructor))
(#lua-match? @constructor "^%u"))
((call_expression
function: (identifier) @constructor)
(#lua-match? @constructor "^%u"))
((call_expression
function: (qualified_identifier
name: (identifier) @constructor))
(#lua-match? @constructor "^%u"))
((call_expression
function: (field_expression
field: (field_identifier) @constructor))
(#lua-match? @constructor "^%u"))
; constructing a type in an initializer list: Constructor (): **SuperType (1)**
((field_initializer
(field_identifier) @constructor
(argument_list))
(#lua-match? @constructor "^%u"))
; Constants
(this) @variable.builtin
(null
"nullptr" @constant.builtin)
(true) @boolean
(false) @boolean
; Literals
(raw_string_literal) @string
; Keywords
[
"try"
"catch"
"noexcept"
"throw"
] @keyword.exception
[
"decltype"
"explicit"
"friend"
"override"
"using"
"requires"
"constexpr"
] @keyword
[
"class"
"namespace"
"template"
"typename"
"concept"
] @keyword.type
[
"co_await"
"co_yield"
"co_return"
] @keyword.coroutine
[
"public"
"private"
"protected"
"final"
"virtual"
] @keyword.modifier
[
"new"
"delete"
"xor"
"bitand"
"bitor"
"compl"
"not"
"xor_eq"
"and_eq"
"or_eq"
"not_eq"
"and"
"or"
] @keyword.operator
"<=>" @operator
"::" @punctuation.delimiter
(template_argument_list
[
"<"
">"
] @punctuation.bracket)
(template_parameter_list
[
"<"
">"
] @punctuation.bracket)
(literal_suffix) @operator

View File

@ -0,0 +1,577 @@
[
(identifier)
(preproc_arg)
] @variable
((preproc_arg) @constant.macro
(#lua-match? @constant.macro "^[_A-Z][_A-Z0-9]*$"))
((identifier) @keyword
(#eq? @keyword "value")
(#has-ancestor? @keyword accessor_declaration))
(method_declaration
name: (identifier) @function.method)
(local_function_statement
name: (identifier) @function.method)
(method_declaration
returns: [
(identifier) @type
(generic_name
(identifier) @type)
])
(event_declaration
type: (identifier) @type)
(event_declaration
name: (identifier) @variable.member)
(event_field_declaration
(variable_declaration
(variable_declarator
name: (identifier) @variable.member)))
(declaration_pattern
type: (identifier) @type)
(local_function_statement
type: (identifier) @type)
(interpolation) @none
(member_access_expression
name: (identifier) @variable.member)
(invocation_expression
(member_access_expression
name: (identifier) @function.method.call))
(invocation_expression
function: (conditional_access_expression
(member_binding_expression
name: (identifier) @function.method.call)))
(namespace_declaration
name: [
(qualified_name)
(identifier)
] @module)
(qualified_name
(identifier) @type)
(namespace_declaration
name: (identifier) @module)
(file_scoped_namespace_declaration
name: (identifier) @module)
(qualified_name
(identifier) @module
(#not-has-ancestor? @module method_declaration)
(#not-has-ancestor? @module record_declaration)
(#has-ancestor? @module namespace_declaration file_scoped_namespace_declaration))
(invocation_expression
(identifier) @function.method.call)
(field_declaration
(variable_declaration
(variable_declarator
(identifier) @variable.member)))
(initializer_expression
(assignment_expression
left: (identifier) @variable.member))
(parameter
name: (identifier) @variable.parameter)
(parameter_list
name: (identifier) @variable.parameter)
(bracketed_parameter_list
name: (identifier) @variable.parameter)
(implicit_parameter) @variable.parameter
(parameter_list
(parameter
type: (identifier) @type))
(integer_literal) @number
(real_literal) @number.float
(null_literal) @constant.builtin
(calling_convention
[
(identifier)
"Cdecl"
"Stdcall"
"Thiscall"
"Fastcall"
] @attribute.builtin)
(character_literal) @character
[
(string_literal)
(raw_string_literal)
(verbatim_string_literal)
(interpolated_string_expression)
] @string
(escape_sequence) @string.escape
[
"true"
"false"
] @boolean
(predefined_type) @type.builtin
(implicit_type) @keyword
(comment) @comment @spell
((comment) @comment.documentation
(#lua-match? @comment.documentation "^/[*][*][^*].*[*]/$"))
((comment) @comment.documentation
(#lua-match? @comment.documentation "^///[^/]"))
((comment) @comment.documentation
(#lua-match? @comment.documentation "^///$"))
(using_directive
(identifier) @type)
(using_directive
(type) @type.definition)
(property_declaration
name: (identifier) @property)
(property_declaration
type: (identifier) @type)
(nullable_type
type: (identifier) @type)
(array_type
type: (identifier) @type)
(ref_type
type: (identifier) @type)
(pointer_type
type: (identifier) @type)
(catch_declaration
type: (identifier) @type)
(interface_declaration
name: (identifier) @type)
(class_declaration
name: (identifier) @type)
(record_declaration
name: (identifier) @type)
(struct_declaration
name: (identifier) @type)
(enum_declaration
name: (identifier) @type)
(enum_member_declaration
name: (identifier) @variable.member)
(operator_declaration
type: (identifier) @type)
(conversion_operator_declaration
type: (identifier) @type)
(explicit_interface_specifier
[
(identifier) @type
(generic_name
(identifier) @type)
])
(explicit_interface_specifier
(identifier) @type)
(primary_constructor_base_type
type: (identifier) @type)
[
"assembly"
"module"
"this"
"base"
] @variable.builtin
(constructor_declaration
name: (identifier) @constructor)
(destructor_declaration
name: (identifier) @constructor)
(constructor_initializer
"base" @constructor)
(variable_declaration
(identifier) @type)
(object_creation_expression
(identifier) @type)
; Generic Types.
(typeof_expression
(generic_name
(identifier) @type))
(type_argument_list
(generic_name
(identifier) @type))
(base_list
(generic_name
(identifier) @type))
(type_parameter_constraint
[
(identifier) @type
(type
(generic_name
(identifier) @type))
])
(object_creation_expression
(generic_name
(identifier) @type))
(property_declaration
(generic_name
(identifier) @type))
(_
type: (generic_name
(identifier) @type))
; Generic Method invocation with generic type
(invocation_expression
function: (generic_name
.
(identifier) @function.method.call))
(invocation_expression
(member_access_expression
(generic_name
(identifier) @function.method)))
(base_list
(identifier) @type)
(type_argument_list
(identifier) @type)
(type_parameter_list
(type_parameter) @type)
(type_parameter
name: (identifier) @type)
(type_parameter_constraints_clause
"where"
.
(identifier) @type)
(attribute
name: (identifier) @attribute)
(foreach_statement
type: (identifier) @type)
(goto_statement
(identifier) @label)
(labeled_statement
(identifier) @label)
(tuple_element
type: (identifier) @type)
(tuple_expression
(argument
(declaration_expression
type: (identifier) @type)))
(cast_expression
type: (identifier) @type)
(lambda_expression
type: (identifier) @type)
(as_expression
right: (identifier) @type)
(typeof_expression
(identifier) @type)
(preproc_error) @keyword.exception
[
"#define"
"#undef"
] @keyword.directive.define
[
"#if"
"#elif"
"#else"
"#endif"
"#region"
"#endregion"
"#line"
"#pragma"
"#nullable"
"#error"
(shebang_directive)
] @keyword.directive
[
(preproc_line)
(preproc_pragma)
(preproc_nullable)
] @constant.macro
(preproc_pragma
(identifier) @constant)
(preproc_if
(identifier) @constant)
[
"if"
"else"
"switch"
"break"
"case"
"when"
] @keyword.conditional
[
"while"
"for"
"do"
"continue"
"goto"
"foreach"
] @keyword.repeat
[
"try"
"catch"
"throw"
"finally"
] @keyword.exception
[
"+"
"?"
":"
"++"
"-"
"--"
"&"
"&&"
"|"
"||"
"!"
"!="
"=="
"*"
"/"
"%"
"<"
"<="
">"
">="
"="
"-="
"+="
"*="
"/="
"%="
"^"
"^="
"&="
"|="
"~"
">>"
">>>"
"<<"
"<<="
">>="
">>>="
"=>"
"??"
"??="
".."
] @operator
(list_pattern
".." @character.special)
(discard) @character.special
[
";"
"."
","
":"
] @punctuation.delimiter
(conditional_expression
[
"?"
":"
] @keyword.conditional.ternary)
[
"["
"]"
"{"
"}"
"("
")"
] @punctuation.bracket
(interpolation_brace) @punctuation.special
(type_argument_list
[
"<"
">"
] @punctuation.bracket)
[
"using"
"as"
] @keyword.import
(alias_qualified_name
(identifier
"global") @keyword.import)
[
"with"
"new"
"typeof"
"sizeof"
"is"
"and"
"or"
"not"
"stackalloc"
"__makeref"
"__reftype"
"__refvalue"
"in"
"out"
"ref"
] @keyword.operator
[
"lock"
"params"
"operator"
"default"
"implicit"
"explicit"
"override"
"get"
"set"
"init"
"where"
"add"
"remove"
"checked"
"unchecked"
"fixed"
"alias"
"file"
"unsafe"
] @keyword
(attribute_target_specifier
.
_ @keyword)
[
"enum"
"record"
"class"
"struct"
"interface"
"namespace"
"event"
"delegate"
] @keyword.type
[
"async"
"await"
] @keyword.coroutine
[
"const"
"extern"
"readonly"
"static"
"volatile"
"required"
"managed"
"unmanaged"
"notnull"
"abstract"
"private"
"protected"
"internal"
"public"
"partial"
"sealed"
"virtual"
"global"
] @keyword.modifier
(scoped_type
"scoped" @keyword.modifier)
(query_expression
(_
[
"from"
"orderby"
"select"
"group"
"by"
"ascending"
"descending"
"equals"
"let"
] @keyword))
[
"return"
"yield"
] @keyword.return

View File

@ -0,0 +1,109 @@
[
"@media"
"@charset"
"@namespace"
"@supports"
"@keyframes"
(at_keyword)
] @keyword.directive
"@import" @keyword.import
[
(to)
(from)
] @keyword
(comment) @comment @spell
(tag_name) @tag
(class_name) @type
(id_name) @constant
[
(property_name)
(feature_name)
] @property
[
(nesting_selector)
(universal_selector)
] @character.special
(function_name) @function
[
"~"
">"
"+"
"-"
"*"
"/"
"="
"^="
"|="
"~="
"$="
"*="
] @operator
[
"and"
"or"
"not"
"only"
] @keyword.operator
(important) @keyword.modifier
(attribute_selector
(plain_value) @string)
(pseudo_element_selector
"::"
(tag_name) @attribute)
(pseudo_class_selector
(class_name) @attribute)
(attribute_name) @tag.attribute
(namespace_name) @module
(keyframes_name) @variable
((property_name) @variable
(#lua-match? @variable "^[-][-]"))
((plain_value) @variable
(#lua-match? @variable "^[-][-]"))
[
(string_value)
(color_value)
(unit)
] @string
(integer_value) @number
(float_value) @number.float
[
"#"
","
"."
":"
"::"
";"
] @punctuation.delimiter
[
"{"
")"
"("
"}"
"["
"]"
] @punctuation.bracket

View File

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

View File

@ -0,0 +1,115 @@
(tag_name) @tag
; (erroneous_end_tag_name) @error ; we do not lint syntax errors
(comment) @comment @spell
(attribute_name) @tag.attribute
((attribute
(quoted_attribute_value) @string)
(#set! priority 99))
(text) @none @spell
((element
(start_tag
(tag_name) @_tag)
(text) @markup.heading)
(#eq? @_tag "title"))
((element
(start_tag
(tag_name) @_tag)
(text) @markup.heading.1)
(#eq? @_tag "h1"))
((element
(start_tag
(tag_name) @_tag)
(text) @markup.heading.2)
(#eq? @_tag "h2"))
((element
(start_tag
(tag_name) @_tag)
(text) @markup.heading.3)
(#eq? @_tag "h3"))
((element
(start_tag
(tag_name) @_tag)
(text) @markup.heading.4)
(#eq? @_tag "h4"))
((element
(start_tag
(tag_name) @_tag)
(text) @markup.heading.5)
(#eq? @_tag "h5"))
((element
(start_tag
(tag_name) @_tag)
(text) @markup.heading.6)
(#eq? @_tag "h6"))
((element
(start_tag
(tag_name) @_tag)
(text) @markup.strong)
(#any-of? @_tag "strong" "b"))
((element
(start_tag
(tag_name) @_tag)
(text) @markup.italic)
(#any-of? @_tag "em" "i"))
((element
(start_tag
(tag_name) @_tag)
(text) @markup.strikethrough)
(#any-of? @_tag "s" "del"))
((element
(start_tag
(tag_name) @_tag)
(text) @markup.underline)
(#eq? @_tag "u"))
((element
(start_tag
(tag_name) @_tag)
(text) @markup.raw)
(#any-of? @_tag "code" "kbd"))
((element
(start_tag
(tag_name) @_tag)
(text) @markup.link.label)
(#eq? @_tag "a"))
((attribute
(attribute_name) @_attr
(quoted_attribute_value
(attribute_value) @string.special.url))
(#any-of? @_attr "href" "src"))
((attribute
(attribute_name) @tag.attribute.url)
(#any-of? @tag.attribute.url "href" "src"))
[
"<"
">"
"</"
"/>"
] @tag.delimiter
"=" @operator
(doctype) @constant
"<!" @tag.delimiter
(entity) @character.special

View File

@ -0,0 +1,330 @@
; CREDITS @maxbrunsfeld (maxbrunsfeld@gmail.com)
; Variables
(identifier) @variable
(underscore_pattern) @character.special
; Methods
(method_declaration
name: (identifier) @function.method)
(method_invocation
name: (identifier) @function.method.call)
(super) @function.builtin
; Parameters
(formal_parameter
name: (identifier) @variable.parameter)
(spread_parameter
(variable_declarator
name: (identifier) @variable.parameter)) ; int... foo
; Lambda parameter
(inferred_parameters
(identifier) @variable.parameter) ; (x,y) -> ...
(lambda_expression
parameters: (identifier) @variable.parameter) ; x -> ...
; Operators
[
"+"
":"
"++"
"-"
"--"
"&"
"&&"
"|"
"||"
"!"
"!="
"=="
"*"
"/"
"%"
"<"
"<="
">"
">="
"="
"-="
"+="
"*="
"/="
"%="
"->"
"^"
"^="
"&="
"|="
"~"
">>"
">>>"
"<<"
"::"
] @operator
; Types
(interface_declaration
name: (identifier) @type)
(annotation_type_declaration
name: (identifier) @type)
(class_declaration
name: (identifier) @type)
(record_declaration
name: (identifier) @type)
(enum_declaration
name: (identifier) @type)
(constructor_declaration
name: (identifier) @type)
(compact_constructor_declaration
name: (identifier) @type)
(type_identifier) @type
((type_identifier) @type.builtin
(#eq? @type.builtin "var"))
((method_invocation
object: (identifier) @type)
(#lua-match? @type "^[A-Z]"))
((method_reference
.
(identifier) @type)
(#lua-match? @type "^[A-Z]"))
((field_access
object: (identifier) @type)
(#lua-match? @type "^[A-Z]"))
(scoped_identifier
(identifier) @type
(#lua-match? @type "^[A-Z]"))
; Fields
(field_declaration
declarator: (variable_declarator
name: (identifier) @variable.member))
(field_access
field: (identifier) @variable.member)
[
(boolean_type)
(integral_type)
(floating_point_type)
(void_type)
] @type.builtin
; Variables
((identifier) @constant
(#lua-match? @constant "^[A-Z_][A-Z%d_]+$"))
(this) @variable.builtin
; Annotations
(annotation
"@" @attribute
name: (identifier) @attribute)
(marker_annotation
"@" @attribute
name: (identifier) @attribute)
; Literals
(string_literal) @string
(escape_sequence) @string.escape
(character_literal) @character
[
(hex_integer_literal)
(decimal_integer_literal)
(octal_integer_literal)
(binary_integer_literal)
] @number
[
(decimal_floating_point_literal)
(hex_floating_point_literal)
] @number.float
[
(true)
(false)
] @boolean
(null_literal) @constant.builtin
; Keywords
[
"assert"
"default"
"extends"
"implements"
"instanceof"
"@interface"
"permits"
"to"
"with"
] @keyword
[
"record"
"class"
"enum"
"interface"
] @keyword.type
(synchronized_statement
"synchronized" @keyword)
[
"abstract"
"final"
"native"
"non-sealed"
"open"
"private"
"protected"
"public"
"sealed"
"static"
"strictfp"
"transitive"
] @keyword.modifier
(modifiers
"synchronized" @keyword.modifier)
[
"transient"
"volatile"
] @keyword.modifier
[
"return"
"yield"
] @keyword.return
"new" @keyword.operator
; Conditionals
[
"if"
"else"
"switch"
"case"
"when"
] @keyword.conditional
(ternary_expression
[
"?"
":"
] @keyword.conditional.ternary)
; Loops
[
"for"
"while"
"do"
"continue"
"break"
] @keyword.repeat
; Includes
[
"exports"
"import"
"module"
"opens"
"package"
"provides"
"requires"
"uses"
] @keyword.import
(import_declaration
(asterisk
"*" @character.special))
; Punctuation
[
";"
"."
"..."
","
] @punctuation.delimiter
[
"{"
"}"
] @punctuation.bracket
[
"["
"]"
] @punctuation.bracket
[
"("
")"
] @punctuation.bracket
(type_arguments
[
"<"
">"
] @punctuation.bracket)
(type_parameters
[
"<"
">"
] @punctuation.bracket)
(string_interpolation
[
"\\{"
"}"
] @punctuation.special)
; Exceptions
[
"throw"
"throws"
"finally"
"try"
"catch"
] @keyword.exception
; Labels
(labeled_statement
(identifier) @label)
; Comments
[
(line_comment)
(block_comment)
] @comment @spell
((block_comment) @comment.documentation
(#lua-match? @comment.documentation "^/[*][*][^*].*[*]/$"))
((line_comment) @comment.documentation
(#lua-match? @comment.documentation "^///[^/]"))
((line_comment) @comment.documentation
(#lua-match? @comment.documentation "^///$"))

View File

@ -0,0 +1,605 @@
; Types
; Javascript
; Variables
;-----------
(identifier) @variable
; Properties
;-----------
(property_identifier) @variable.member
(shorthand_property_identifier) @variable.member
(private_property_identifier) @variable.member
(object_pattern
(shorthand_property_identifier_pattern) @variable)
(object_pattern
(object_assignment_pattern
(shorthand_property_identifier_pattern) @variable))
; Special identifiers
;--------------------
((identifier) @type
(#lua-match? @type "^[A-Z]"))
((identifier) @constant
(#lua-match? @constant "^_*[A-Z][A-Z%d_]*$"))
((shorthand_property_identifier) @constant
(#lua-match? @constant "^_*[A-Z][A-Z%d_]*$"))
((identifier) @variable.builtin
(#any-of? @variable.builtin "arguments" "module" "console" "window" "document"))
((identifier) @type.builtin
(#any-of? @type.builtin
"Object" "Function" "Boolean" "Symbol" "Number" "Math" "Date" "String" "RegExp" "Map" "Set"
"WeakMap" "WeakSet" "Promise" "Array" "Int8Array" "Uint8Array" "Uint8ClampedArray" "Int16Array"
"Uint16Array" "Int32Array" "Uint32Array" "Float32Array" "Float64Array" "ArrayBuffer" "DataView"
"Error" "EvalError" "InternalError" "RangeError" "ReferenceError" "SyntaxError" "TypeError"
"URIError"))
(statement_identifier) @label
; Function and method definitions
;--------------------------------
(function_expression
name: (identifier) @function)
(function_declaration
name: (identifier) @function)
(generator_function
name: (identifier) @function)
(generator_function_declaration
name: (identifier) @function)
(method_definition
name: [
(property_identifier)
(private_property_identifier)
] @function.method)
(method_definition
name: (property_identifier) @constructor
(#eq? @constructor "constructor"))
(pair
key: (property_identifier) @function.method
value: (function_expression))
(pair
key: (property_identifier) @function.method
value: (arrow_function))
(assignment_expression
left: (member_expression
property: (property_identifier) @function.method)
right: (arrow_function))
(assignment_expression
left: (member_expression
property: (property_identifier) @function.method)
right: (function_expression))
(variable_declarator
name: (identifier) @function
value: (arrow_function))
(variable_declarator
name: (identifier) @function
value: (function_expression))
(assignment_expression
left: (identifier) @function
right: (arrow_function))
(assignment_expression
left: (identifier) @function
right: (function_expression))
; Function and method calls
;--------------------------
(call_expression
function: (identifier) @function.call)
(call_expression
function: (member_expression
property: [
(property_identifier)
(private_property_identifier)
] @function.method.call))
(call_expression
function: (await_expression
(identifier) @function.call))
(call_expression
function: (await_expression
(member_expression
property: [
(property_identifier)
(private_property_identifier)
] @function.method.call)))
; Builtins
;---------
((identifier) @module.builtin
(#eq? @module.builtin "Intl"))
((identifier) @function.builtin
(#any-of? @function.builtin
"eval" "isFinite" "isNaN" "parseFloat" "parseInt" "decodeURI" "decodeURIComponent" "encodeURI"
"encodeURIComponent" "require"))
; Constructor
;------------
(new_expression
constructor: (identifier) @constructor)
; Decorators
;----------
(decorator
"@" @attribute
(identifier) @attribute)
(decorator
"@" @attribute
(call_expression
(identifier) @attribute))
(decorator
"@" @attribute
(member_expression
(property_identifier) @attribute))
(decorator
"@" @attribute
(call_expression
(member_expression
(property_identifier) @attribute)))
; Literals
;---------
[
(this)
(super)
] @variable.builtin
((identifier) @variable.builtin
(#eq? @variable.builtin "self"))
[
(true)
(false)
] @boolean
[
(null)
(undefined)
] @constant.builtin
[
(comment)
(html_comment)
] @comment @spell
((comment) @comment.documentation
(#lua-match? @comment.documentation "^/[*][*][^*].*[*]/$"))
(hash_bang_line) @keyword.directive
((string_fragment) @keyword.directive
(#eq? @keyword.directive "use strict"))
(string) @string
(template_string) @string
(escape_sequence) @string.escape
(regex_pattern) @string.regexp
(regex_flags) @character.special
(regex
"/" @punctuation.bracket) ; Regex delimiters
(number) @number
((identifier) @number
(#any-of? @number "NaN" "Infinity"))
; Punctuation
;------------
[
";"
"."
","
":"
] @punctuation.delimiter
[
"--"
"-"
"-="
"&&"
"+"
"++"
"+="
"&="
"/="
"**="
"<<="
"<"
"<="
"<<"
"="
"=="
"==="
"!="
"!=="
"=>"
">"
">="
">>"
"||"
"%"
"%="
"*"
"**"
">>>"
"&"
"|"
"^"
"??"
"*="
">>="
">>>="
"^="
"|="
"&&="
"||="
"??="
"..."
] @operator
(binary_expression
"/" @operator)
(ternary_expression
[
"?"
":"
] @keyword.conditional.ternary)
(unary_expression
[
"!"
"~"
"-"
"+"
] @operator)
(unary_expression
[
"delete"
"void"
] @keyword.operator)
[
"("
")"
"["
"]"
"{"
"}"
] @punctuation.bracket
(template_substitution
[
"${"
"}"
] @punctuation.special) @none
; Imports
;----------
(namespace_import
"*" @character.special
(identifier) @module)
(namespace_export
"*" @character.special
(identifier) @module)
(export_statement
"*" @character.special)
; Keywords
;----------
[
"if"
"else"
"switch"
"case"
] @keyword.conditional
[
"import"
"from"
"as"
"export"
] @keyword.import
[
"for"
"of"
"do"
"while"
"continue"
] @keyword.repeat
[
"break"
"const"
"debugger"
"extends"
"get"
"let"
"set"
"static"
"target"
"var"
"with"
] @keyword
"class" @keyword.type
[
"async"
"await"
] @keyword.coroutine
[
"return"
"yield"
] @keyword.return
"function" @keyword.function
[
"new"
"delete"
"in"
"instanceof"
"typeof"
] @keyword.operator
[
"throw"
"try"
"catch"
"finally"
] @keyword.exception
(export_statement
"default" @keyword)
(switch_default
"default" @keyword.conditional)
(jsx_element
open_tag: (jsx_opening_element
[
"<"
">"
] @tag.delimiter))
(jsx_element
close_tag: (jsx_closing_element
[
"</"
">"
] @tag.delimiter))
(jsx_self_closing_element
[
"<"
"/>"
] @tag.delimiter)
(jsx_attribute
(property_identifier) @tag.attribute)
(jsx_opening_element
name: (identifier) @tag.builtin)
(jsx_closing_element
name: (identifier) @tag.builtin)
(jsx_self_closing_element
name: (identifier) @tag.builtin)
(jsx_opening_element
((identifier) @tag
(#lua-match? @tag "^[A-Z]")))
; Handle the dot operator effectively - <My.Component>
(jsx_opening_element
(member_expression
(identifier) @tag.builtin
(property_identifier) @tag))
(jsx_closing_element
((identifier) @tag
(#lua-match? @tag "^[A-Z]")))
; Handle the dot operator effectively - </My.Component>
(jsx_closing_element
(member_expression
(identifier) @tag.builtin
(property_identifier) @tag))
(jsx_self_closing_element
((identifier) @tag
(#lua-match? @tag "^[A-Z]")))
; Handle the dot operator effectively - <My.Component />
(jsx_self_closing_element
(member_expression
(identifier) @tag.builtin
(property_identifier) @tag))
(html_character_reference) @tag
(jsx_text) @none @spell
(html_character_reference) @character.special
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading)
(#eq? @_tag "title"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading.1)
(#eq? @_tag "h1"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading.2)
(#eq? @_tag "h2"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading.3)
(#eq? @_tag "h3"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading.4)
(#eq? @_tag "h4"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading.5)
(#eq? @_tag "h5"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading.6)
(#eq? @_tag "h6"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.strong)
(#any-of? @_tag "strong" "b"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.italic)
(#any-of? @_tag "em" "i"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.strikethrough)
(#any-of? @_tag "s" "del"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.underline)
(#eq? @_tag "u"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.raw)
(#any-of? @_tag "code" "kbd"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.link.label)
(#eq? @_tag "a"))
((jsx_attribute
(property_identifier) @_attr
(string
(string_fragment) @string.special.url))
(#any-of? @_attr "href" "src"))
((jsx_element) @_jsx_element
(#set! @_jsx_element bo.commentstring "{/* %s */}"))
((jsx_attribute) @_jsx_attribute
(#set! @_jsx_attribute bo.commentstring "// %s"))
; Parameters
(formal_parameters
(identifier) @variable.parameter)
(formal_parameters
(rest_pattern
(identifier) @variable.parameter))
; ({ a }) => null
(formal_parameters
(object_pattern
(shorthand_property_identifier_pattern) @variable.parameter))
; ({ a = b }) => null
(formal_parameters
(object_pattern
(object_assignment_pattern
(shorthand_property_identifier_pattern) @variable.parameter)))
; ({ a: b }) => null
(formal_parameters
(object_pattern
(pair_pattern
value: (identifier) @variable.parameter)))
; ([ a ]) => null
(formal_parameters
(array_pattern
(identifier) @variable.parameter))
; ({ a } = { a }) => null
(formal_parameters
(assignment_pattern
(object_pattern
(shorthand_property_identifier_pattern) @variable.parameter)))
; ({ a = b } = { a }) => null
(formal_parameters
(assignment_pattern
(object_pattern
(object_assignment_pattern
(shorthand_property_identifier_pattern) @variable.parameter))))
; a => null
(arrow_function
parameter: (identifier) @variable.parameter)
; optional parameters
(formal_parameters
(assignment_pattern
left: (identifier) @variable.parameter))
; punctuation
(optional_chain) @punctuation.delimiter

View File

@ -0,0 +1,38 @@
[
(true)
(false)
] @boolean
(null) @constant.builtin
(number) @number
(pair
key: (string) @property)
(pair
value: (string) @string)
(array
(string) @string)
[
","
":"
] @punctuation.delimiter
[
"["
"]"
"{"
"}"
] @punctuation.bracket
("\"" @conceal
(#set! conceal ""))
(escape_sequence) @string.escape
((escape_sequence) @conceal
(#eq? @conceal "\\\"")
(#set! conceal "\""))

View File

@ -0,0 +1,443 @@
; From tree-sitter-python licensed under MIT License
; Copyright (c) 2016 Max Brunsfeld
; Variables
(identifier) @variable
; Reset highlighting in f-string interpolations
(interpolation) @none
; Identifier naming conventions
((identifier) @type
(#lua-match? @type "^[A-Z].*[a-z]"))
((identifier) @constant
(#lua-match? @constant "^[A-Z][A-Z_0-9]*$"))
((identifier) @constant.builtin
(#lua-match? @constant.builtin "^__[a-zA-Z0-9_]*__$"))
((identifier) @constant.builtin
(#any-of? @constant.builtin
; https://docs.python.org/3/library/constants.html
"NotImplemented" "Ellipsis" "quit" "exit" "copyright" "credits" "license"))
"_" @character.special ; match wildcard
((assignment
left: (identifier) @type.definition
(type
(identifier) @_annotation))
(#eq? @_annotation "TypeAlias"))
((assignment
left: (identifier) @type.definition
right: (call
function: (identifier) @_func))
(#any-of? @_func "TypeVar" "NewType"))
; Function definitions
(function_definition
name: (identifier) @function)
(type
(identifier) @type)
(type
(subscript
(identifier) @type)) ; type subscript: Tuple[int]
((call
function: (identifier) @_isinstance
arguments: (argument_list
(_)
(identifier) @type))
(#eq? @_isinstance "isinstance"))
; Literals
(none) @constant.builtin
[
(true)
(false)
] @boolean
(integer) @number
(float) @number.float
(comment) @comment @spell
((module
.
(comment) @keyword.directive @nospell)
(#lua-match? @keyword.directive "^#!/"))
(string) @string
[
(escape_sequence)
(escape_interpolation)
] @string.escape
; doc-strings
(expression_statement
(string
(string_content) @spell) @string.documentation)
; Tokens
[
"-"
"-="
":="
"!="
"*"
"**"
"**="
"*="
"/"
"//"
"//="
"/="
"&"
"&="
"%"
"%="
"^"
"^="
"+"
"+="
"<"
"<<"
"<<="
"<="
"<>"
"="
"=="
">"
">="
">>"
">>="
"@"
"@="
"|"
"|="
"~"
"->"
] @operator
; Keywords
[
"and"
"in"
"is"
"not"
"or"
"is not"
"not in"
"del"
] @keyword.operator
[
"def"
"lambda"
] @keyword.function
[
"assert"
"exec"
"global"
"nonlocal"
"pass"
"print"
"with"
"as"
] @keyword
[
"type"
"class"
] @keyword.type
[
"async"
"await"
] @keyword.coroutine
[
"return"
"yield"
] @keyword.return
(yield
"from" @keyword.return)
(future_import_statement
"from" @keyword.import
"__future__" @module.builtin)
(import_from_statement
"from" @keyword.import)
"import" @keyword.import
(aliased_import
"as" @keyword.import)
(wildcard_import
"*" @character.special)
(import_statement
name: (dotted_name
(identifier) @module))
(import_statement
name: (aliased_import
name: (dotted_name
(identifier) @module)
alias: (identifier) @module))
(import_from_statement
module_name: (dotted_name
(identifier) @module))
(import_from_statement
module_name: (relative_import
(dotted_name
(identifier) @module)))
[
"if"
"elif"
"else"
"match"
"case"
] @keyword.conditional
[
"for"
"while"
"break"
"continue"
] @keyword.repeat
[
"try"
"except"
; "except*"
"raise"
"finally"
] @keyword.exception
(raise_statement
"from" @keyword.exception)
(try_statement
(else_clause
"else" @keyword.exception))
[
"("
")"
"["
"]"
"{"
"}"
] @punctuation.bracket
(interpolation
"{" @punctuation.special
"}" @punctuation.special)
(type_conversion) @function.macro
[
","
"."
":"
";"
(ellipsis)
] @punctuation.delimiter
((identifier) @type.builtin
(#any-of? @type.builtin
; https://docs.python.org/3/library/exceptions.html
"BaseException" "Exception" "ArithmeticError" "BufferError" "LookupError" "AssertionError"
"AttributeError" "EOFError" "FloatingPointError" "GeneratorExit" "ImportError"
"ModuleNotFoundError" "IndexError" "KeyError" "KeyboardInterrupt" "MemoryError" "NameError"
"NotImplementedError" "OSError" "OverflowError" "RecursionError" "ReferenceError" "RuntimeError"
"StopIteration" "StopAsyncIteration" "SyntaxError" "IndentationError" "TabError" "SystemError"
"SystemExit" "TypeError" "UnboundLocalError" "UnicodeError" "UnicodeEncodeError"
"UnicodeDecodeError" "UnicodeTranslateError" "ValueError" "ZeroDivisionError" "EnvironmentError"
"IOError" "WindowsError" "BlockingIOError" "ChildProcessError" "ConnectionError"
"BrokenPipeError" "ConnectionAbortedError" "ConnectionRefusedError" "ConnectionResetError"
"FileExistsError" "FileNotFoundError" "InterruptedError" "IsADirectoryError"
"NotADirectoryError" "PermissionError" "ProcessLookupError" "TimeoutError" "Warning"
"UserWarning" "DeprecationWarning" "PendingDeprecationWarning" "SyntaxWarning" "RuntimeWarning"
"FutureWarning" "ImportWarning" "UnicodeWarning" "BytesWarning" "ResourceWarning"
; https://docs.python.org/3/library/stdtypes.html
"bool" "int" "float" "complex" "list" "tuple" "range" "str" "bytes" "bytearray" "memoryview"
"set" "frozenset" "dict" "type" "object"))
; Normal parameters
(parameters
(identifier) @variable.parameter)
; Lambda parameters
(lambda_parameters
(identifier) @variable.parameter)
(lambda_parameters
(tuple_pattern
(identifier) @variable.parameter))
; Default parameters
(keyword_argument
name: (identifier) @variable.parameter)
; Naming parameters on call-site
(default_parameter
name: (identifier) @variable.parameter)
(typed_parameter
(identifier) @variable.parameter)
(typed_default_parameter
name: (identifier) @variable.parameter)
; Variadic parameters *args, **kwargs
(parameters
(list_splat_pattern ; *args
(identifier) @variable.parameter))
(parameters
(dictionary_splat_pattern ; **kwargs
(identifier) @variable.parameter))
; Typed variadic parameters
(parameters
(typed_parameter
(list_splat_pattern ; *args: type
(identifier) @variable.parameter)))
(parameters
(typed_parameter
(dictionary_splat_pattern ; *kwargs: type
(identifier) @variable.parameter)))
; Lambda parameters
(lambda_parameters
(list_splat_pattern
(identifier) @variable.parameter))
(lambda_parameters
(dictionary_splat_pattern
(identifier) @variable.parameter))
((identifier) @variable.builtin
(#eq? @variable.builtin "self"))
((identifier) @variable.builtin
(#eq? @variable.builtin "cls"))
; After @type.builtin bacause builtins (such as `type`) are valid as attribute name
((attribute
attribute: (identifier) @variable.member)
(#lua-match? @variable.member "^[%l_].*$"))
; Class definitions
(class_definition
name: (identifier) @type)
(class_definition
body: (block
(function_definition
name: (identifier) @function.method)))
(class_definition
superclasses: (argument_list
(identifier) @type))
((class_definition
body: (block
(expression_statement
(assignment
left: (identifier) @variable.member))))
(#lua-match? @variable.member "^[%l_].*$"))
((class_definition
body: (block
(expression_statement
(assignment
left: (_
(identifier) @variable.member)))))
(#lua-match? @variable.member "^[%l_].*$"))
((class_definition
(block
(function_definition
name: (identifier) @constructor)))
(#any-of? @constructor "__new__" "__init__"))
; Function calls
(call
function: (identifier) @function.call)
(call
function: (attribute
attribute: (identifier) @function.method.call))
((call
function: (identifier) @constructor)
(#lua-match? @constructor "^%u"))
((call
function: (attribute
attribute: (identifier) @constructor))
(#lua-match? @constructor "^%u"))
; Builtin functions
((call
function: (identifier) @function.builtin)
(#any-of? @function.builtin
"abs" "all" "any" "ascii" "bin" "bool" "breakpoint" "bytearray" "bytes" "callable" "chr"
"classmethod" "compile" "complex" "delattr" "dict" "dir" "divmod" "enumerate" "eval" "exec"
"filter" "float" "format" "frozenset" "getattr" "globals" "hasattr" "hash" "help" "hex" "id"
"input" "int" "isinstance" "issubclass" "iter" "len" "list" "locals" "map" "max" "memoryview"
"min" "next" "object" "oct" "open" "ord" "pow" "print" "property" "range" "repr" "reversed"
"round" "set" "setattr" "slice" "sorted" "staticmethod" "str" "sum" "super" "tuple" "type"
"vars" "zip" "__import__"))
; Regex from the `re` module
(call
function: (attribute
object: (identifier) @_re)
arguments: (argument_list
.
(string
(string_content) @string.regexp))
(#eq? @_re "re"))
; Decorators
((decorator
"@" @attribute)
(#set! priority 101))
(decorator
(identifier) @attribute)
(decorator
(attribute
attribute: (identifier) @attribute))
(decorator
(call
(identifier) @attribute))
(decorator
(call
(attribute
attribute: (identifier) @attribute)))
((decorator
(identifier) @attribute.builtin)
(#any-of? @attribute.builtin "classmethod" "property" "staticmethod"))

View File

@ -0,0 +1,309 @@
; Variables
[
(identifier)
(global_variable)
] @variable
; Keywords
[
"alias"
"begin"
"do"
"end"
"ensure"
"module"
"rescue"
"then"
] @keyword
"class" @keyword.type
[
"return"
"yield"
] @keyword.return
[
"and"
"or"
"in"
"not"
] @keyword.operator
[
"def"
"undef"
] @keyword.function
(method
"end" @keyword.function)
[
"case"
"else"
"elsif"
"if"
"unless"
"when"
"then"
] @keyword.conditional
(if
"end" @keyword.conditional)
[
"for"
"until"
"while"
"break"
"redo"
"retry"
"next"
] @keyword.repeat
(constant) @constant
((identifier) @keyword.modifier
(#any-of? @keyword.modifier "private" "protected" "public"))
[
"rescue"
"ensure"
] @keyword.exception
; Function calls
"defined?" @function
(call
receiver: (constant)? @type
method: [
(identifier)
(constant)
] @function.call)
(program
(call
(identifier) @keyword.import)
(#any-of? @keyword.import "require" "require_relative" "load"))
; Function definitions
(alias
(identifier) @function)
(setter
(identifier) @function)
(method
name: [
(identifier) @function
(constant) @type
])
(singleton_method
name: [
(identifier) @function
(constant) @type
])
(class
name: (constant) @type)
(module
name: (constant) @type)
(superclass
(constant) @type)
; Identifiers
[
(class_variable)
(instance_variable)
] @variable.member
((identifier) @constant.builtin
(#any-of? @constant.builtin
"__callee__" "__dir__" "__id__" "__method__" "__send__" "__ENCODING__" "__FILE__" "__LINE__"))
((identifier) @function.builtin
(#any-of? @function.builtin "attr_reader" "attr_writer" "attr_accessor" "module_function"))
((call
!receiver
method: (identifier) @function.builtin)
(#any-of? @function.builtin "include" "extend" "prepend" "refine" "using"))
((identifier) @keyword.exception
(#any-of? @keyword.exception "raise" "fail" "catch" "throw"))
((constant) @type
(#not-lua-match? @type "^[A-Z0-9_]+$"))
[
(self)
(super)
] @variable.builtin
(method_parameters
(identifier) @variable.parameter)
(lambda_parameters
(identifier) @variable.parameter)
(block_parameters
(identifier) @variable.parameter)
(splat_parameter
(identifier) @variable.parameter)
(hash_splat_parameter
(identifier) @variable.parameter)
(optional_parameter
(identifier) @variable.parameter)
(destructured_parameter
(identifier) @variable.parameter)
(block_parameter
(identifier) @variable.parameter)
(keyword_parameter
(identifier) @variable.parameter)
; TODO: Re-enable this once it is supported
; ((identifier) @function
; (#is-not? local))
; Literals
[
(string_content)
(heredoc_content)
"\""
"`"
] @string
[
(heredoc_beginning)
(heredoc_end)
] @label
[
(bare_symbol)
(simple_symbol)
(delimited_symbol)
(hash_key_symbol)
] @string.special.symbol
(regex
(string_content) @string.regexp)
(escape_sequence) @string.escape
(integer) @number
(float) @number.float
[
(true)
(false)
] @boolean
(nil) @constant.builtin
(comment) @comment @spell
((program
.
(comment) @keyword.directive @nospell)
(#lua-match? @keyword.directive "^#!/"))
(program
(comment)+ @comment.documentation
(class))
(module
(comment)+ @comment.documentation
(body_statement
(class)))
(class
(comment)+ @comment.documentation
(body_statement
(method)))
(body_statement
(comment)+ @comment.documentation
(method))
; Operators
[
"!"
"="
"=="
"==="
"<=>"
"=>"
"->"
">>"
"<<"
">"
"<"
">="
"<="
"**"
"*"
"/"
"%"
"+"
"-"
"&"
"|"
"^"
"&&"
"||"
"||="
"&&="
"!="
"%="
"+="
"-="
"*="
"/="
"=~"
"!~"
"?"
":"
".."
"..."
] @operator
[
","
";"
"."
"&."
"::"
] @punctuation.delimiter
(regex
"/" @punctuation.bracket)
(pair
":" @punctuation.delimiter)
[
"("
")"
"["
"]"
"{"
"}"
"%w("
"%i("
] @punctuation.bracket
(block_parameters
"|" @punctuation.bracket)
(interpolation
"#{" @punctuation.special
"}" @punctuation.special)

View File

@ -0,0 +1,531 @@
; Forked from https://github.com/tree-sitter/tree-sitter-rust
; Copyright (c) 2017 Maxim Sokolov
; Licensed under the MIT license.
; Identifier conventions
(shebang) @keyword.directive
(identifier) @variable
((identifier) @type
(#lua-match? @type "^[A-Z]"))
(const_item
name: (identifier) @constant)
; Assume all-caps names are constants
((identifier) @constant
(#lua-match? @constant "^[A-Z][A-Z%d_]*$"))
; Other identifiers
(type_identifier) @type
(primitive_type) @type.builtin
(field_identifier) @variable.member
(shorthand_field_identifier) @variable.member
(shorthand_field_initializer
(identifier) @variable.member)
(mod_item
name: (identifier) @module)
(self) @variable.builtin
"_" @character.special
(label
[
"'"
(identifier)
] @label)
; Function definitions
(function_item
(identifier) @function)
(function_signature_item
(identifier) @function)
(parameter
[
(identifier)
"_"
] @variable.parameter)
(parameter
(ref_pattern
[
(mut_pattern
(identifier) @variable.parameter)
(identifier) @variable.parameter
]))
(closure_parameters
(_) @variable.parameter)
; Function calls
(call_expression
function: (identifier) @function.call)
(call_expression
function: (scoped_identifier
(identifier) @function.call .))
(call_expression
function: (field_expression
field: (field_identifier) @function.call))
(generic_function
function: (identifier) @function.call)
(generic_function
function: (scoped_identifier
name: (identifier) @function.call))
(generic_function
function: (field_expression
field: (field_identifier) @function.call))
; Assume other uppercase names are enum constructors
((field_identifier) @constant
(#lua-match? @constant "^[A-Z]"))
(enum_variant
name: (identifier) @constant)
; Assume that uppercase names in paths are types
(scoped_identifier
path: (identifier) @module)
(scoped_identifier
(scoped_identifier
name: (identifier) @module))
(scoped_type_identifier
path: (identifier) @module)
(scoped_type_identifier
path: (identifier) @type
(#lua-match? @type "^[A-Z]"))
(scoped_type_identifier
(scoped_identifier
name: (identifier) @module))
((scoped_identifier
path: (identifier) @type)
(#lua-match? @type "^[A-Z]"))
((scoped_identifier
name: (identifier) @type)
(#lua-match? @type "^[A-Z]"))
((scoped_identifier
name: (identifier) @constant)
(#lua-match? @constant "^[A-Z][A-Z%d_]*$"))
((scoped_identifier
path: (identifier) @type
name: (identifier) @constant)
(#lua-match? @type "^[A-Z]")
(#lua-match? @constant "^[A-Z]"))
((scoped_type_identifier
path: (identifier) @type
name: (type_identifier) @constant)
(#lua-match? @type "^[A-Z]")
(#lua-match? @constant "^[A-Z]"))
[
(crate)
(super)
] @module
(scoped_use_list
path: (identifier) @module)
(scoped_use_list
path: (scoped_identifier
(identifier) @module))
(use_list
(scoped_identifier
(identifier) @module
.
(_)))
(use_list
(identifier) @type
(#lua-match? @type "^[A-Z]"))
(use_as_clause
alias: (identifier) @type
(#lua-match? @type "^[A-Z]"))
; Correct enum constructors
(call_expression
function: (scoped_identifier
"::"
name: (identifier) @constant)
(#lua-match? @constant "^[A-Z]"))
; Assume uppercase names in a match arm are constants.
((match_arm
pattern: (match_pattern
(identifier) @constant))
(#lua-match? @constant "^[A-Z]"))
((match_arm
pattern: (match_pattern
(scoped_identifier
name: (identifier) @constant)))
(#lua-match? @constant "^[A-Z]"))
((identifier) @constant.builtin
(#any-of? @constant.builtin "Some" "None" "Ok" "Err"))
; Macro definitions
"$" @function.macro
(metavariable) @function.macro
(macro_definition
"macro_rules!" @function.macro)
; Attribute macros
(attribute_item
(attribute
(identifier) @function.macro))
(inner_attribute_item
(attribute
(identifier) @function.macro))
(attribute
(scoped_identifier
(identifier) @function.macro .))
; Derive macros (assume all arguments are types)
; (attribute
; (identifier) @_name
; arguments: (attribute (attribute (identifier) @type))
; (#eq? @_name "derive"))
; Function-like macros
(macro_invocation
macro: (identifier) @function.macro)
(macro_invocation
macro: (scoped_identifier
(identifier) @function.macro .))
; Literals
(boolean_literal) @boolean
(integer_literal) @number
(float_literal) @number.float
[
(raw_string_literal)
(string_literal)
] @string
(escape_sequence) @string.escape
(char_literal) @character
; Keywords
[
"use"
"mod"
] @keyword.import
(use_as_clause
"as" @keyword.import)
[
"default"
"impl"
"let"
"move"
"unsafe"
"where"
] @keyword
[
"enum"
"struct"
"union"
"trait"
"type"
] @keyword.type
[
"async"
"await"
"gen"
] @keyword.coroutine
"try" @keyword.exception
[
"ref"
"pub"
"raw"
(mutable_specifier)
"const"
"static"
"dyn"
"extern"
] @keyword.modifier
(lifetime
"'" @keyword.modifier)
(lifetime
(identifier) @attribute)
(lifetime
(identifier) @attribute.builtin
(#any-of? @attribute.builtin "static" "_"))
"fn" @keyword.function
[
"return"
"yield"
] @keyword.return
(type_cast_expression
"as" @keyword.operator)
(qualified_type
"as" @keyword.operator)
(use_list
(self) @module)
(scoped_use_list
(self) @module)
(scoped_identifier
[
(crate)
(super)
(self)
] @module)
(visibility_modifier
[
(crate)
(super)
(self)
] @module)
[
"if"
"else"
"match"
] @keyword.conditional
[
"break"
"continue"
"in"
"loop"
"while"
] @keyword.repeat
"for" @keyword
(for_expression
"for" @keyword.repeat)
; Operators
[
"!"
"!="
"%"
"%="
"&"
"&&"
"&="
"*"
"*="
"+"
"+="
"-"
"-="
".."
"..="
"..."
"/"
"/="
"<"
"<<"
"<<="
"<="
"="
"=="
">"
">="
">>"
">>="
"?"
"@"
"^"
"^="
"|"
"|="
"||"
] @operator
(use_wildcard
"*" @character.special)
(remaining_field_pattern
".." @character.special)
(range_pattern
[
".."
"..="
"..."
] @character.special)
; Punctuation
[
"("
")"
"["
"]"
"{"
"}"
] @punctuation.bracket
(closure_parameters
"|" @punctuation.bracket)
(type_arguments
[
"<"
">"
] @punctuation.bracket)
(type_parameters
[
"<"
">"
] @punctuation.bracket)
(bracketed_type
[
"<"
">"
] @punctuation.bracket)
(for_lifetimes
[
"<"
">"
] @punctuation.bracket)
[
","
"."
":"
"::"
";"
"->"
"=>"
] @punctuation.delimiter
(attribute_item
"#" @punctuation.special)
(inner_attribute_item
[
"!"
"#"
] @punctuation.special)
(macro_invocation
"!" @function.macro)
(never_type
"!" @type.builtin)
(macro_invocation
macro: (identifier) @_identifier @keyword.exception
"!" @keyword.exception
(#eq? @_identifier "panic"))
(macro_invocation
macro: (identifier) @_identifier @keyword.exception
"!" @keyword.exception
(#contains? @_identifier "assert"))
(macro_invocation
macro: (identifier) @_identifier @keyword.debug
"!" @keyword.debug
(#eq? @_identifier "dbg"))
; Comments
[
(line_comment)
(block_comment)
(outer_doc_comment_marker)
(inner_doc_comment_marker)
] @comment @spell
(line_comment
(doc_comment)) @comment.documentation
(block_comment
(doc_comment)) @comment.documentation
(call_expression
function: (scoped_identifier
path: (identifier) @_regex
(#any-of? @_regex "Regex" "ByteRegexBuilder")
name: (identifier) @_new
(#eq? @_new "new"))
arguments: (arguments
(raw_string_literal
(string_content) @string.regexp)))
(call_expression
function: (scoped_identifier
path: (scoped_identifier
(identifier) @_regex
(#any-of? @_regex "Regex" "ByteRegexBuilder") .)
name: (identifier) @_new
(#eq? @_new "new"))
arguments: (arguments
(raw_string_literal
(string_content) @string.regexp)))
(call_expression
function: (scoped_identifier
path: (identifier) @_regex
(#any-of? @_regex "RegexSet" "RegexSetBuilder")
name: (identifier) @_new
(#eq? @_new "new"))
arguments: (arguments
(array_expression
(raw_string_literal
(string_content) @string.regexp))))
(call_expression
function: (scoped_identifier
path: (scoped_identifier
(identifier) @_regex
(#any-of? @_regex "RegexSet" "RegexSetBuilder") .)
name: (identifier) @_new
(#eq? @_new "new"))
arguments: (arguments
(array_expression
(raw_string_literal
(string_content) @string.regexp))))

View File

@ -0,0 +1,757 @@
(jsx_element
open_tag: (jsx_opening_element
[
"<"
">"
] @tag.delimiter))
(jsx_element
close_tag: (jsx_closing_element
[
"</"
">"
] @tag.delimiter))
(jsx_self_closing_element
[
"<"
"/>"
] @tag.delimiter)
(jsx_attribute
(property_identifier) @tag.attribute)
(jsx_opening_element
name: (identifier) @tag.builtin)
(jsx_closing_element
name: (identifier) @tag.builtin)
(jsx_self_closing_element
name: (identifier) @tag.builtin)
(jsx_opening_element
((identifier) @tag
(#lua-match? @tag "^[A-Z]")))
; Handle the dot operator effectively - <My.Component>
(jsx_opening_element
(member_expression
(identifier) @tag.builtin
(property_identifier) @tag))
(jsx_closing_element
((identifier) @tag
(#lua-match? @tag "^[A-Z]")))
; Handle the dot operator effectively - </My.Component>
(jsx_closing_element
(member_expression
(identifier) @tag.builtin
(property_identifier) @tag))
(jsx_self_closing_element
((identifier) @tag
(#lua-match? @tag "^[A-Z]")))
; Handle the dot operator effectively - <My.Component />
(jsx_self_closing_element
(member_expression
(identifier) @tag.builtin
(property_identifier) @tag))
(html_character_reference) @tag
(jsx_text) @none @spell
(html_character_reference) @character.special
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading)
(#eq? @_tag "title"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading.1)
(#eq? @_tag "h1"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading.2)
(#eq? @_tag "h2"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading.3)
(#eq? @_tag "h3"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading.4)
(#eq? @_tag "h4"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading.5)
(#eq? @_tag "h5"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.heading.6)
(#eq? @_tag "h6"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.strong)
(#any-of? @_tag "strong" "b"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.italic)
(#any-of? @_tag "em" "i"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.strikethrough)
(#any-of? @_tag "s" "del"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.underline)
(#eq? @_tag "u"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.raw)
(#any-of? @_tag "code" "kbd"))
((jsx_element
(jsx_opening_element
name: (identifier) @_tag)
(jsx_text) @markup.link.label)
(#eq? @_tag "a"))
((jsx_attribute
(property_identifier) @_attr
(string
(string_fragment) @string.special.url))
(#any-of? @_attr "href" "src"))
((jsx_element) @_jsx_element
(#set! @_jsx_element bo.commentstring "{/* %s */}"))
((jsx_attribute) @_jsx_attribute
(#set! @_jsx_attribute bo.commentstring "// %s"))
; Types
; Javascript
; Variables
;-----------
(identifier) @variable
; Properties
;-----------
(property_identifier) @variable.member
(shorthand_property_identifier) @variable.member
(private_property_identifier) @variable.member
(object_pattern
(shorthand_property_identifier_pattern) @variable)
(object_pattern
(object_assignment_pattern
(shorthand_property_identifier_pattern) @variable))
; Special identifiers
;--------------------
((identifier) @type
(#lua-match? @type "^[A-Z]"))
((identifier) @constant
(#lua-match? @constant "^_*[A-Z][A-Z%d_]*$"))
((shorthand_property_identifier) @constant
(#lua-match? @constant "^_*[A-Z][A-Z%d_]*$"))
((identifier) @variable.builtin
(#any-of? @variable.builtin "arguments" "module" "console" "window" "document"))
((identifier) @type.builtin
(#any-of? @type.builtin
"Object" "Function" "Boolean" "Symbol" "Number" "Math" "Date" "String" "RegExp" "Map" "Set"
"WeakMap" "WeakSet" "Promise" "Array" "Int8Array" "Uint8Array" "Uint8ClampedArray" "Int16Array"
"Uint16Array" "Int32Array" "Uint32Array" "Float32Array" "Float64Array" "ArrayBuffer" "DataView"
"Error" "EvalError" "InternalError" "RangeError" "ReferenceError" "SyntaxError" "TypeError"
"URIError"))
(statement_identifier) @label
; Function and method definitions
;--------------------------------
(function_expression
name: (identifier) @function)
(function_declaration
name: (identifier) @function)
(generator_function
name: (identifier) @function)
(generator_function_declaration
name: (identifier) @function)
(method_definition
name: [
(property_identifier)
(private_property_identifier)
] @function.method)
(method_definition
name: (property_identifier) @constructor
(#eq? @constructor "constructor"))
(pair
key: (property_identifier) @function.method
value: (function_expression))
(pair
key: (property_identifier) @function.method
value: (arrow_function))
(assignment_expression
left: (member_expression
property: (property_identifier) @function.method)
right: (arrow_function))
(assignment_expression
left: (member_expression
property: (property_identifier) @function.method)
right: (function_expression))
(variable_declarator
name: (identifier) @function
value: (arrow_function))
(variable_declarator
name: (identifier) @function
value: (function_expression))
(assignment_expression
left: (identifier) @function
right: (arrow_function))
(assignment_expression
left: (identifier) @function
right: (function_expression))
; Function and method calls
;--------------------------
(call_expression
function: (identifier) @function.call)
(call_expression
function: (member_expression
property: [
(property_identifier)
(private_property_identifier)
] @function.method.call))
(call_expression
function: (await_expression
(identifier) @function.call))
(call_expression
function: (await_expression
(member_expression
property: [
(property_identifier)
(private_property_identifier)
] @function.method.call)))
; Builtins
;---------
((identifier) @module.builtin
(#eq? @module.builtin "Intl"))
((identifier) @function.builtin
(#any-of? @function.builtin
"eval" "isFinite" "isNaN" "parseFloat" "parseInt" "decodeURI" "decodeURIComponent" "encodeURI"
"encodeURIComponent" "require"))
; Constructor
;------------
(new_expression
constructor: (identifier) @constructor)
; Decorators
;----------
(decorator
"@" @attribute
(identifier) @attribute)
(decorator
"@" @attribute
(call_expression
(identifier) @attribute))
(decorator
"@" @attribute
(member_expression
(property_identifier) @attribute))
(decorator
"@" @attribute
(call_expression
(member_expression
(property_identifier) @attribute)))
; Literals
;---------
[
(this)
(super)
] @variable.builtin
((identifier) @variable.builtin
(#eq? @variable.builtin "self"))
[
(true)
(false)
] @boolean
[
(null)
(undefined)
] @constant.builtin
[
(comment)
(html_comment)
] @comment @spell
((comment) @comment.documentation
(#lua-match? @comment.documentation "^/[*][*][^*].*[*]/$"))
(hash_bang_line) @keyword.directive
((string_fragment) @keyword.directive
(#eq? @keyword.directive "use strict"))
(string) @string
(template_string) @string
(escape_sequence) @string.escape
(regex_pattern) @string.regexp
(regex_flags) @character.special
(regex
"/" @punctuation.bracket) ; Regex delimiters
(number) @number
((identifier) @number
(#any-of? @number "NaN" "Infinity"))
; Punctuation
;------------
[
";"
"."
","
":"
] @punctuation.delimiter
[
"--"
"-"
"-="
"&&"
"+"
"++"
"+="
"&="
"/="
"**="
"<<="
"<"
"<="
"<<"
"="
"=="
"==="
"!="
"!=="
"=>"
">"
">="
">>"
"||"
"%"
"%="
"*"
"**"
">>>"
"&"
"|"
"^"
"??"
"*="
">>="
">>>="
"^="
"|="
"&&="
"||="
"??="
"..."
] @operator
(binary_expression
"/" @operator)
(ternary_expression
[
"?"
":"
] @keyword.conditional.ternary)
(unary_expression
[
"!"
"~"
"-"
"+"
] @operator)
(unary_expression
[
"delete"
"void"
] @keyword.operator)
[
"("
")"
"["
"]"
"{"
"}"
] @punctuation.bracket
(template_substitution
[
"${"
"}"
] @punctuation.special) @none
; Imports
;----------
(namespace_import
"*" @character.special
(identifier) @module)
(namespace_export
"*" @character.special
(identifier) @module)
(export_statement
"*" @character.special)
; Keywords
;----------
[
"if"
"else"
"switch"
"case"
] @keyword.conditional
[
"import"
"from"
"as"
"export"
] @keyword.import
[
"for"
"of"
"do"
"while"
"continue"
] @keyword.repeat
[
"break"
"const"
"debugger"
"extends"
"get"
"let"
"set"
"static"
"target"
"var"
"with"
] @keyword
"class" @keyword.type
[
"async"
"await"
] @keyword.coroutine
[
"return"
"yield"
] @keyword.return
"function" @keyword.function
[
"new"
"delete"
"in"
"instanceof"
"typeof"
] @keyword.operator
[
"throw"
"try"
"catch"
"finally"
] @keyword.exception
(export_statement
"default" @keyword)
(switch_default
"default" @keyword.conditional)
"require" @keyword.import
(import_require_clause
source: (string) @string.special.url)
[
"declare"
"implements"
"type"
"override"
"module"
"asserts"
"infer"
"is"
"using"
] @keyword
[
"namespace"
"interface"
"enum"
] @keyword.type
[
"keyof"
"satisfies"
] @keyword.operator
(as_expression
"as" @keyword.operator)
(mapped_type_clause
"as" @keyword.operator)
[
"abstract"
"private"
"protected"
"public"
"readonly"
] @keyword.modifier
; types
(type_identifier) @type
(predefined_type) @type.builtin
(import_statement
"type"
(import_clause
(named_imports
(import_specifier
name: (identifier) @type))))
(template_literal_type) @string
(non_null_expression
"!" @operator)
; punctuation
(type_arguments
[
"<"
">"
] @punctuation.bracket)
(type_parameters
[
"<"
">"
] @punctuation.bracket)
(object_type
[
"{|"
"|}"
] @punctuation.bracket)
(union_type
"|" @punctuation.delimiter)
(intersection_type
"&" @punctuation.delimiter)
(type_annotation
":" @punctuation.delimiter)
(type_predicate_annotation
":" @punctuation.delimiter)
(index_signature
":" @punctuation.delimiter)
(omitting_type_annotation
"-?:" @punctuation.delimiter)
(adding_type_annotation
"+?:" @punctuation.delimiter)
(opting_type_annotation
"?:" @punctuation.delimiter)
"?." @punctuation.delimiter
(abstract_method_signature
"?" @punctuation.special)
(method_signature
"?" @punctuation.special)
(method_definition
"?" @punctuation.special)
(property_signature
"?" @punctuation.special)
(optional_parameter
"?" @punctuation.special)
(optional_type
"?" @punctuation.special)
(public_field_definition
[
"?"
"!"
] @punctuation.special)
(flow_maybe_type
"?" @punctuation.special)
(template_type
[
"${"
"}"
] @punctuation.special)
(conditional_type
[
"?"
":"
] @keyword.conditional.ternary)
; Parameters
(required_parameter
pattern: (identifier) @variable.parameter)
(optional_parameter
pattern: (identifier) @variable.parameter)
(required_parameter
(rest_pattern
(identifier) @variable.parameter))
; ({ a }) => null
(required_parameter
(object_pattern
(shorthand_property_identifier_pattern) @variable.parameter))
; ({ a = b }) => null
(required_parameter
(object_pattern
(object_assignment_pattern
(shorthand_property_identifier_pattern) @variable.parameter)))
; ({ a: b }) => null
(required_parameter
(object_pattern
(pair_pattern
value: (identifier) @variable.parameter)))
; ([ a ]) => null
(required_parameter
(array_pattern
(identifier) @variable.parameter))
; a => null
(arrow_function
parameter: (identifier) @variable.parameter)
; global declaration
(ambient_declaration
"global" @module)
; function signatures
(ambient_declaration
(function_signature
name: (identifier) @function))
; method signatures
(method_signature
name: (_) @function.method)
(abstract_method_signature
name: (property_identifier) @function.method)
; property signatures
(property_signature
name: (property_identifier) @function.method
type: (type_annotation
[
(union_type
(parenthesized_type
(function_type)))
(function_type)
]))

View File

@ -0,0 +1,599 @@
; Types
; Javascript
; Variables
;-----------
(identifier) @variable
; Properties
;-----------
(property_identifier) @variable.member
(shorthand_property_identifier) @variable.member
(private_property_identifier) @variable.member
(object_pattern
(shorthand_property_identifier_pattern) @variable)
(object_pattern
(object_assignment_pattern
(shorthand_property_identifier_pattern) @variable))
; Special identifiers
;--------------------
((identifier) @type
(#lua-match? @type "^[A-Z]"))
((identifier) @constant
(#lua-match? @constant "^_*[A-Z][A-Z%d_]*$"))
((shorthand_property_identifier) @constant
(#lua-match? @constant "^_*[A-Z][A-Z%d_]*$"))
((identifier) @variable.builtin
(#any-of? @variable.builtin "arguments" "module" "console" "window" "document"))
((identifier) @type.builtin
(#any-of? @type.builtin
"Object" "Function" "Boolean" "Symbol" "Number" "Math" "Date" "String" "RegExp" "Map" "Set"
"WeakMap" "WeakSet" "Promise" "Array" "Int8Array" "Uint8Array" "Uint8ClampedArray" "Int16Array"
"Uint16Array" "Int32Array" "Uint32Array" "Float32Array" "Float64Array" "ArrayBuffer" "DataView"
"Error" "EvalError" "InternalError" "RangeError" "ReferenceError" "SyntaxError" "TypeError"
"URIError"))
(statement_identifier) @label
; Function and method definitions
;--------------------------------
(function_expression
name: (identifier) @function)
(function_declaration
name: (identifier) @function)
(generator_function
name: (identifier) @function)
(generator_function_declaration
name: (identifier) @function)
(method_definition
name: [
(property_identifier)
(private_property_identifier)
] @function.method)
(method_definition
name: (property_identifier) @constructor
(#eq? @constructor "constructor"))
(pair
key: (property_identifier) @function.method
value: (function_expression))
(pair
key: (property_identifier) @function.method
value: (arrow_function))
(assignment_expression
left: (member_expression
property: (property_identifier) @function.method)
right: (arrow_function))
(assignment_expression
left: (member_expression
property: (property_identifier) @function.method)
right: (function_expression))
(variable_declarator
name: (identifier) @function
value: (arrow_function))
(variable_declarator
name: (identifier) @function
value: (function_expression))
(assignment_expression
left: (identifier) @function
right: (arrow_function))
(assignment_expression
left: (identifier) @function
right: (function_expression))
; Function and method calls
;--------------------------
(call_expression
function: (identifier) @function.call)
(call_expression
function: (member_expression
property: [
(property_identifier)
(private_property_identifier)
] @function.method.call))
(call_expression
function: (await_expression
(identifier) @function.call))
(call_expression
function: (await_expression
(member_expression
property: [
(property_identifier)
(private_property_identifier)
] @function.method.call)))
; Builtins
;---------
((identifier) @module.builtin
(#eq? @module.builtin "Intl"))
((identifier) @function.builtin
(#any-of? @function.builtin
"eval" "isFinite" "isNaN" "parseFloat" "parseInt" "decodeURI" "decodeURIComponent" "encodeURI"
"encodeURIComponent" "require"))
; Constructor
;------------
(new_expression
constructor: (identifier) @constructor)
; Decorators
;----------
(decorator
"@" @attribute
(identifier) @attribute)
(decorator
"@" @attribute
(call_expression
(identifier) @attribute))
(decorator
"@" @attribute
(member_expression
(property_identifier) @attribute))
(decorator
"@" @attribute
(call_expression
(member_expression
(property_identifier) @attribute)))
; Literals
;---------
[
(this)
(super)
] @variable.builtin
((identifier) @variable.builtin
(#eq? @variable.builtin "self"))
[
(true)
(false)
] @boolean
[
(null)
(undefined)
] @constant.builtin
[
(comment)
(html_comment)
] @comment @spell
((comment) @comment.documentation
(#lua-match? @comment.documentation "^/[*][*][^*].*[*]/$"))
(hash_bang_line) @keyword.directive
((string_fragment) @keyword.directive
(#eq? @keyword.directive "use strict"))
(string) @string
(template_string) @string
(escape_sequence) @string.escape
(regex_pattern) @string.regexp
(regex_flags) @character.special
(regex
"/" @punctuation.bracket) ; Regex delimiters
(number) @number
((identifier) @number
(#any-of? @number "NaN" "Infinity"))
; Punctuation
;------------
[
";"
"."
","
":"
] @punctuation.delimiter
[
"--"
"-"
"-="
"&&"
"+"
"++"
"+="
"&="
"/="
"**="
"<<="
"<"
"<="
"<<"
"="
"=="
"==="
"!="
"!=="
"=>"
">"
">="
">>"
"||"
"%"
"%="
"*"
"**"
">>>"
"&"
"|"
"^"
"??"
"*="
">>="
">>>="
"^="
"|="
"&&="
"||="
"??="
"..."
] @operator
(binary_expression
"/" @operator)
(ternary_expression
[
"?"
":"
] @keyword.conditional.ternary)
(unary_expression
[
"!"
"~"
"-"
"+"
] @operator)
(unary_expression
[
"delete"
"void"
] @keyword.operator)
[
"("
")"
"["
"]"
"{"
"}"
] @punctuation.bracket
(template_substitution
[
"${"
"}"
] @punctuation.special) @none
; Imports
;----------
(namespace_import
"*" @character.special
(identifier) @module)
(namespace_export
"*" @character.special
(identifier) @module)
(export_statement
"*" @character.special)
; Keywords
;----------
[
"if"
"else"
"switch"
"case"
] @keyword.conditional
[
"import"
"from"
"as"
"export"
] @keyword.import
[
"for"
"of"
"do"
"while"
"continue"
] @keyword.repeat
[
"break"
"const"
"debugger"
"extends"
"get"
"let"
"set"
"static"
"target"
"var"
"with"
] @keyword
"class" @keyword.type
[
"async"
"await"
] @keyword.coroutine
[
"return"
"yield"
] @keyword.return
"function" @keyword.function
[
"new"
"delete"
"in"
"instanceof"
"typeof"
] @keyword.operator
[
"throw"
"try"
"catch"
"finally"
] @keyword.exception
(export_statement
"default" @keyword)
(switch_default
"default" @keyword.conditional)
"require" @keyword.import
(import_require_clause
source: (string) @string.special.url)
[
"declare"
"implements"
"type"
"override"
"module"
"asserts"
"infer"
"is"
"using"
] @keyword
[
"namespace"
"interface"
"enum"
] @keyword.type
[
"keyof"
"satisfies"
] @keyword.operator
(as_expression
"as" @keyword.operator)
(mapped_type_clause
"as" @keyword.operator)
[
"abstract"
"private"
"protected"
"public"
"readonly"
] @keyword.modifier
; types
(type_identifier) @type
(predefined_type) @type.builtin
(import_statement
"type"
(import_clause
(named_imports
(import_specifier
name: (identifier) @type))))
(template_literal_type) @string
(non_null_expression
"!" @operator)
; punctuation
(type_arguments
[
"<"
">"
] @punctuation.bracket)
(type_parameters
[
"<"
">"
] @punctuation.bracket)
(object_type
[
"{|"
"|}"
] @punctuation.bracket)
(union_type
"|" @punctuation.delimiter)
(intersection_type
"&" @punctuation.delimiter)
(type_annotation
":" @punctuation.delimiter)
(type_predicate_annotation
":" @punctuation.delimiter)
(index_signature
":" @punctuation.delimiter)
(omitting_type_annotation
"-?:" @punctuation.delimiter)
(adding_type_annotation
"+?:" @punctuation.delimiter)
(opting_type_annotation
"?:" @punctuation.delimiter)
"?." @punctuation.delimiter
(abstract_method_signature
"?" @punctuation.special)
(method_signature
"?" @punctuation.special)
(method_definition
"?" @punctuation.special)
(property_signature
"?" @punctuation.special)
(optional_parameter
"?" @punctuation.special)
(optional_type
"?" @punctuation.special)
(public_field_definition
[
"?"
"!"
] @punctuation.special)
(flow_maybe_type
"?" @punctuation.special)
(template_type
[
"${"
"}"
] @punctuation.special)
(conditional_type
[
"?"
":"
] @keyword.conditional.ternary)
; Parameters
(required_parameter
pattern: (identifier) @variable.parameter)
(optional_parameter
pattern: (identifier) @variable.parameter)
(required_parameter
(rest_pattern
(identifier) @variable.parameter))
; ({ a }) => null
(required_parameter
(object_pattern
(shorthand_property_identifier_pattern) @variable.parameter))
; ({ a = b }) => null
(required_parameter
(object_pattern
(object_assignment_pattern
(shorthand_property_identifier_pattern) @variable.parameter)))
; ({ a: b }) => null
(required_parameter
(object_pattern
(pair_pattern
value: (identifier) @variable.parameter)))
; ([ a ]) => null
(required_parameter
(array_pattern
(identifier) @variable.parameter))
; a => null
(arrow_function
parameter: (identifier) @variable.parameter)
; global declaration
(ambient_declaration
"global" @module)
; function signatures
(ambient_declaration
(function_signature
name: (identifier) @function))
; method signatures
(method_signature
name: (_) @function.method)
(abstract_method_signature
name: (property_identifier) @function.method)
; property signatures
(property_signature
name: (property_identifier) @function.method
type: (type_annotation
[
(union_type
(parenthesized_type
(function_type)))
(function_type)
]))

View File

@ -0,0 +1,108 @@
package syntax
import _ "embed"
//go:embed queries/go/highlights.scm
var goHighlightsQuery string
//go:embed queries/javascript/highlights.scm
var javascriptHighlightsQuery string
//go:embed queries/python/highlights.scm
var pythonHighlightsQuery string
//go:embed queries/rust/highlights.scm
var rustHighlightsQuery string
//go:embed queries/typescript/highlights.scm
var typescriptHighlightsQuery string
//go:embed queries/tsx/highlights.scm
var tsxHighlightsQuery string
//go:embed queries/bash/highlights.scm
var bashHighlightsQuery string
//go:embed queries/json/highlights.scm
var jsonHighlightsQuery string
//go:embed queries/css/highlights.scm
var cssHighlightsQuery string
//go:embed queries/html/highlights.scm
var htmlHighlightsQuery string
//go:embed queries/c/highlights.scm
var cHighlightsQuery string
//go:embed queries/cpp/highlights.scm
var cppHighlightsQuery string
//go:embed queries/java/highlights.scm
var javaHighlightsQuery string
//go:embed queries/csharp/highlights.scm
var csharpHighlightsQuery string
//go:embed queries/ruby/highlights.scm
var rubyHighlightsQuery string
func loadGoHighlightsQuery() ([]byte, error) {
return []byte(goHighlightsQuery), nil
}
func loadJavaScriptHighlightsQuery() ([]byte, error) {
return []byte(javascriptHighlightsQuery), nil
}
func loadPythonHighlightsQuery() ([]byte, error) {
return []byte(pythonHighlightsQuery), nil
}
func loadRustHighlightsQuery() ([]byte, error) {
return []byte(rustHighlightsQuery), nil
}
func loadTypeScriptHighlightsQuery() ([]byte, error) {
return []byte(typescriptHighlightsQuery), nil
}
func loadTSXHighlightsQuery() ([]byte, error) {
return []byte(tsxHighlightsQuery), nil
}
func loadBashHighlightsQuery() ([]byte, error) {
return []byte(bashHighlightsQuery), nil
}
func loadJSONHighlightsQuery() ([]byte, error) {
return []byte(jsonHighlightsQuery), nil
}
func loadCSSHighlightsQuery() ([]byte, error) {
return []byte(cssHighlightsQuery), nil
}
func loadHTMLHighlightsQuery() ([]byte, error) {
return []byte(htmlHighlightsQuery), nil
}
func loadCHighlightsQuery() ([]byte, error) {
return []byte(cHighlightsQuery), nil
}
func loadCppHighlightsQuery() ([]byte, error) {
return []byte(cppHighlightsQuery), nil
}
func loadJavaHighlightsQuery() ([]byte, error) {
return []byte(javaHighlightsQuery), nil
}
func loadCSharpHighlightsQuery() ([]byte, error) {
return []byte(csharpHighlightsQuery), nil
}
func loadRubyHighlightsQuery() ([]byte, error) {
return []byte(rubyHighlightsQuery), nil
}

View File

@ -0,0 +1,101 @@
package syntax
import (
"testing"
sitter "github.com/tree-sitter/go-tree-sitter"
ts_bash "github.com/tree-sitter/tree-sitter-bash/bindings/go"
ts_csharp "github.com/tree-sitter/tree-sitter-c-sharp/bindings/go"
ts_c "github.com/tree-sitter/tree-sitter-c/bindings/go"
ts_cpp "github.com/tree-sitter/tree-sitter-cpp/bindings/go"
ts_css "github.com/tree-sitter/tree-sitter-css/bindings/go"
ts_go "github.com/tree-sitter/tree-sitter-go/bindings/go"
ts_html "github.com/tree-sitter/tree-sitter-html/bindings/go"
ts_java "github.com/tree-sitter/tree-sitter-java/bindings/go"
ts_js "github.com/tree-sitter/tree-sitter-javascript/bindings/go"
ts_json "github.com/tree-sitter/tree-sitter-json/bindings/go"
ts_python "github.com/tree-sitter/tree-sitter-python/bindings/go"
ts_ruby "github.com/tree-sitter/tree-sitter-ruby/bindings/go"
ts_rust "github.com/tree-sitter/tree-sitter-rust/bindings/go"
ts_ts "github.com/tree-sitter/tree-sitter-typescript/bindings/go"
)
func TestEmbeddedQueriesLoadAndCompile(t *testing.T) {
tests := []struct {
name string
loadQuery func() ([]byte, error)
expectNonNil bool
}{
{name: "go", loadQuery: loadGoHighlightsQuery, expectNonNil: true},
{name: "javascript", loadQuery: loadJavaScriptHighlightsQuery, expectNonNil: true},
{name: "typescript", loadQuery: loadTypeScriptHighlightsQuery, expectNonNil: true},
{name: "tsx", loadQuery: loadTSXHighlightsQuery, expectNonNil: true},
{name: "python", loadQuery: loadPythonHighlightsQuery, expectNonNil: true},
{name: "rust", loadQuery: loadRustHighlightsQuery, expectNonNil: true},
{name: "bash", loadQuery: loadBashHighlightsQuery, expectNonNil: true},
{name: "json", loadQuery: loadJSONHighlightsQuery, expectNonNil: true},
{name: "css", loadQuery: loadCSSHighlightsQuery, expectNonNil: true},
{name: "html", loadQuery: loadHTMLHighlightsQuery, expectNonNil: true},
{name: "c", loadQuery: loadCHighlightsQuery, expectNonNil: true},
{name: "cpp", loadQuery: loadCppHighlightsQuery, expectNonNil: true},
{name: "java", loadQuery: loadJavaHighlightsQuery, expectNonNil: true},
{name: "csharp", loadQuery: loadCSharpHighlightsQuery, expectNonNil: true},
{name: "ruby", loadQuery: loadRubyHighlightsQuery, expectNonNil: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
b, err := tc.loadQuery()
if err != nil {
t.Fatalf("failed loading embedded query: %v", err)
}
if tc.expectNonNil && b == nil {
t.Fatalf("expected non-nil embedded query bytes")
}
})
}
}
func TestEmbeddedQueriesCompileKnownGood(t *testing.T) {
compileTests := []struct {
name string
loadQuery func() ([]byte, error)
newLanguage func() *sitter.Language
}{
{name: "go", loadQuery: loadGoHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_go.Language()) }},
{name: "javascript", loadQuery: loadJavaScriptHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_js.Language()) }},
{name: "typescript", loadQuery: loadTypeScriptHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_ts.LanguageTypescript()) }},
{name: "tsx", loadQuery: loadTSXHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_ts.LanguageTSX()) }},
{name: "python", loadQuery: loadPythonHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_python.Language()) }},
{name: "rust", loadQuery: loadRustHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_rust.Language()) }},
{name: "bash", loadQuery: loadBashHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_bash.Language()) }},
{name: "json", loadQuery: loadJSONHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_json.Language()) }},
{name: "css", loadQuery: loadCSSHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_css.Language()) }},
{name: "html", loadQuery: loadHTMLHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_html.Language()) }},
{name: "c", loadQuery: loadCHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_c.Language()) }},
{name: "cpp", loadQuery: loadCppHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_cpp.Language()) }},
{name: "java", loadQuery: loadJavaHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_java.Language()) }},
{name: "csharp", loadQuery: loadCSharpHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_csharp.Language()) }},
{name: "ruby", loadQuery: loadRubyHighlightsQuery, newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_ruby.Language()) }},
}
for _, tc := range compileTests {
t.Run(tc.name, func(t *testing.T) {
b, err := tc.loadQuery()
if err != nil {
t.Fatalf("failed loading embedded query: %v", err)
}
lang := tc.newLanguage()
if lang == nil {
t.Fatalf("language handle is nil")
}
q, qErr := sitter.NewQuery(lang, string(b))
if qErr != nil {
t.Fatalf("embedded query failed to compile: %v", qErr)
}
q.Close()
})
}
}

291
internal/syntax/registry.go Normal file
View File

@ -0,0 +1,291 @@
package syntax
import (
"fmt"
"strings"
sitter "github.com/tree-sitter/go-tree-sitter"
ts_bash "github.com/tree-sitter/tree-sitter-bash/bindings/go"
ts_csharp "github.com/tree-sitter/tree-sitter-c-sharp/bindings/go"
ts_c "github.com/tree-sitter/tree-sitter-c/bindings/go"
ts_cpp "github.com/tree-sitter/tree-sitter-cpp/bindings/go"
ts_css "github.com/tree-sitter/tree-sitter-css/bindings/go"
ts_go "github.com/tree-sitter/tree-sitter-go/bindings/go"
ts_html "github.com/tree-sitter/tree-sitter-html/bindings/go"
ts_java "github.com/tree-sitter/tree-sitter-java/bindings/go"
ts_js "github.com/tree-sitter/tree-sitter-javascript/bindings/go"
ts_json "github.com/tree-sitter/tree-sitter-json/bindings/go"
ts_python "github.com/tree-sitter/tree-sitter-python/bindings/go"
ts_ruby "github.com/tree-sitter/tree-sitter-ruby/bindings/go"
ts_rust "github.com/tree-sitter/tree-sitter-rust/bindings/go"
ts_ts "github.com/tree-sitter/tree-sitter-typescript/bindings/go"
)
type languagePack struct {
// languagePack.id is the stable registry identifier (for example, "go").
id string
// languagePack.filetypes are normalized aliases resolved from buffer filetype.
filetypes []string
// languagePack.extensions are normalized filename extensions (for example, ".go").
extensions []string
// languagePack.newLanguage constructs the tree-sitter language handle.
newLanguage func() *sitter.Language
// languagePack.loadQuery returns highlights query source for this language.
loadQuery func() ([]byte, error)
}
// resolvedLanguage stores compiled runtime assets for one language.
//
// Instances are cached in languageRegistry.compiledByLang and reused by all
// buffers that resolve to the same language id.
type resolvedLanguage struct {
id string
language *sitter.Language
query *sitter.Query
}
// languageRegistry maps buffer metadata to language packs and lazily compiles
// tree-sitter language/query assets.
type languageRegistry struct {
packs []languagePack
byFiletype map[string]languagePack
byExtension map[string]languagePack
compiledByLang map[string]*resolvedLanguage
}
// newLanguageRegistry constructs the default in-process language registry.
//
// It registers built-in packs and prepares lookup maps for filetype and
// extension resolution.
func newLanguageRegistry() *languageRegistry {
r := &languageRegistry{
packs: []languagePack{},
byFiletype: map[string]languagePack{},
byExtension: map[string]languagePack{},
compiledByLang: map[string]*resolvedLanguage{},
}
r.register(languagePack{
id: "go",
filetypes: []string{"go", "golang"},
extensions: []string{".go"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_go.Language()) },
loadQuery: loadGoHighlightsQuery,
})
r.register(languagePack{
id: "javascript",
filetypes: []string{"javascript", "js", "jsx"},
extensions: []string{".js", ".mjs", ".cjs", ".jsx"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_js.Language()) },
loadQuery: loadJavaScriptHighlightsQuery,
})
r.register(languagePack{
id: "typescript",
filetypes: []string{"typescript", "ts"},
extensions: []string{".ts"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_ts.LanguageTypescript()) },
loadQuery: loadTypeScriptHighlightsQuery,
})
r.register(languagePack{
id: "tsx",
filetypes: []string{"tsx"},
extensions: []string{".tsx"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_ts.LanguageTSX()) },
loadQuery: loadTSXHighlightsQuery,
})
r.register(languagePack{
id: "python",
filetypes: []string{"python", "py"},
extensions: []string{".py"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_python.Language()) },
loadQuery: loadPythonHighlightsQuery,
})
r.register(languagePack{
id: "rust",
filetypes: []string{"rust", "rs"},
extensions: []string{".rs"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_rust.Language()) },
loadQuery: loadRustHighlightsQuery,
})
r.register(languagePack{
id: "bash",
filetypes: []string{"bash", "sh", "shell"},
extensions: []string{".sh", ".bash"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_bash.Language()) },
loadQuery: loadBashHighlightsQuery,
})
r.register(languagePack{
id: "json",
filetypes: []string{"json"},
extensions: []string{".json"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_json.Language()) },
loadQuery: loadJSONHighlightsQuery,
})
r.register(languagePack{
id: "css",
filetypes: []string{"css"},
extensions: []string{".css"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_css.Language()) },
loadQuery: loadCSSHighlightsQuery,
})
r.register(languagePack{
id: "html",
filetypes: []string{"html"},
extensions: []string{".html", ".htm"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_html.Language()) },
loadQuery: loadHTMLHighlightsQuery,
})
r.register(languagePack{
id: "c",
filetypes: []string{"c"},
extensions: []string{".c", ".h"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_c.Language()) },
loadQuery: loadCHighlightsQuery,
})
r.register(languagePack{
id: "cpp",
filetypes: []string{"cpp", "c++", "hpp"},
extensions: []string{".cc", ".cpp", ".cxx", ".hpp", ".hh", ".hxx"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_cpp.Language()) },
loadQuery: loadCppHighlightsQuery,
})
r.register(languagePack{
id: "java",
filetypes: []string{"java"},
extensions: []string{".java"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_java.Language()) },
loadQuery: loadJavaHighlightsQuery,
})
r.register(languagePack{
id: "csharp",
filetypes: []string{"csharp", "cs"},
extensions: []string{".cs"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_csharp.Language()) },
loadQuery: loadCSharpHighlightsQuery,
})
r.register(languagePack{
id: "ruby",
filetypes: []string{"ruby", "rb"},
extensions: []string{".rb"},
newLanguage: func() *sitter.Language { return sitter.NewLanguage(ts_ruby.Language()) },
loadQuery: loadRubyHighlightsQuery,
})
return r
}
// register adds a language pack and indexes it by normalized keys.
func (r *languageRegistry) register(pack languagePack) {
r.packs = append(r.packs, pack)
for _, ft := range pack.filetypes {
n := normalizeKey(ft)
if n != "" {
r.byFiletype[n] = pack
}
}
for _, ext := range pack.extensions {
n := normalizeExtension(ext)
if n != "" {
r.byExtension[n] = pack
}
}
}
// resolve returns compiled language/query assets for a buffer identity.
//
// Resolution is filetype-first, extension-second. Results are compiled once
// per language id and cached in compiledByLang.
func (r *languageRegistry) resolve(filetype, filename string) (*resolvedLanguage, bool, error) {
pack, ok := r.resolvePack(filetype, filename)
if !ok {
return nil, false, nil
}
if cached, ok := r.compiledByLang[pack.id]; ok {
return cached, true, nil
}
lang := pack.newLanguage()
if lang == nil {
return nil, false, fmt.Errorf("language %q did not provide a language handle", pack.id)
}
qBytes, err := pack.loadQuery()
if err != nil {
return nil, false, fmt.Errorf("load query for %q: %w", pack.id, err)
}
q, qErr := sitter.NewQuery(lang, string(qBytes))
if qErr != nil {
return nil, false, fmt.Errorf("compile query for %q: %w", pack.id, qErr)
}
resolved := &resolvedLanguage{id: pack.id, language: lang, query: q}
r.compiledByLang[pack.id] = resolved
return resolved, true, nil
}
// resolvePack finds a registered language pack using normalized buffer
// metadata without compiling queries.
func (r *languageRegistry) resolvePack(filetype, filename string) (languagePack, bool) {
if p, ok := r.byFiletype[normalizeKey(filetype)]; ok {
return p, true
}
if p, ok := r.byExtension[extensionOf(filename)]; ok {
return p, true
}
return languagePack{}, false
}
// normalizeKey canonicalizes filetype-like keys for registry lookups.
func normalizeKey(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
s = strings.TrimPrefix(s, ".")
return s
}
// normalizeExtension canonicalizes extension keys and guarantees a leading
// dot for non-empty values.
func normalizeExtension(ext string) string {
ext = strings.TrimSpace(strings.ToLower(ext))
if ext == "" {
return ""
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return ext
}
// extensionOf extracts a normalized extension from a filename.
// Returns empty string when no usable extension is present.
func extensionOf(filename string) string {
name := strings.TrimSpace(strings.ToLower(filename))
if name == "" {
return ""
}
i := strings.LastIndex(name, ".")
if i <= 0 || i == len(name)-1 {
return ""
}
return name[i:]
}

View File

@ -0,0 +1,110 @@
package syntax
import "testing"
func TestLanguageRegistryResolve(t *testing.T) {
r := newLanguageRegistry()
tests := []struct {
name string
args struct {
filetype string
filename string
}
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,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
res, ok, err := r.resolve(tc.args.filetype, tc.args.filename)
if (err != nil) != tc.wantErr {
t.Fatalf("resolve error = %v, wantErr=%v", err, tc.wantErr)
}
if tc.expected == "" {
if ok || res != nil {
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")
}
}

View File

@ -0,0 +1,442 @@
package syntax
import (
"bytes"
"sort"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/theme"
"github.com/charmbracelet/lipgloss"
sitter "github.com/tree-sitter/go-tree-sitter"
)
// TreeSitterEngine provides syntax highlighting using Tree-sitter queries.
//
// The engine stores per-buffer parser state and a cached style map so redraws
// can reuse prior work. It supports both full rebuilds and incremental edits:
// - full rebuilds when a buffer is first seen, language changes, or state is invalid
// - incremental updates when ApplyEdit provides enough information to reparse
// only changed regions
//
// Cached styles are represented as one style per rune for each line.
type TreeSitterEngine struct {
registry *languageRegistry
cache map[*core.Buffer]*bufferCache
}
// bufferCache stores all derived highlighting state for a single buffer.
//
// It contains both style output (`lines`) and parse/query state (`parser`,
// `tree`, `source`, language/query bindings) so the engine can incrementally
// update only dirty lines instead of recomputing the whole file each frame.
type bufferCache struct {
built bool
lines map[int][]lipgloss.Style
count int
parser *sitter.Parser
tree *sitter.Tree
source []byte
dirtyAll bool
dirty []lineRange
langID string
language *sitter.Language
query *sitter.Query
}
// lineRange is an inclusive line interval [start, end].
//
// Dirty tracking and partial restyling use this type to represent which rows
// need work.
type lineRange struct {
start int
end int
}
// captureRange describes one Tree-sitter capture span.
//
// Coordinates are in row/byte-column space, matching Tree-sitter node
// positions. The range is later converted to rune indexes for style writes.
type captureRange struct {
startRow uint
startCol uint
endRow uint
endCol uint
name string
}
// NewTreeSitterEngine constructs a TreeSitterEngine with the provided style set.
//
// Language support is resolved through the language registry, so the engine can
// work with any language/query pair registered there.
func NewTreeSitterEngine(t theme.EditorTheme) *TreeSitterEngine {
return &TreeSitterEngine{
registry: newLanguageRegistry(),
cache: map[*core.Buffer]*bufferCache{},
}
}
// PrepareBuffer ensures highlighting data for buf is ready to read.
//
// This method is idempotent: if cached styles are already valid (`built`), it
// returns immediately. Otherwise it resolves language support and performs a
// rebuild pass (full or dirty-range-based) to refresh `bc.lines`.
//
// If the buffer language is unsupported or resolution fails, it still marks the
// cache as built with an empty style map so callers can safely continue.
func (e *TreeSitterEngine) PrepareBuffer(buf *core.Buffer, t theme.EditorTheme) {
// 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
lang, ok, err := e.resolveBufferLanguage(buf, bc)
if err != nil || !ok {
bc.lines = map[int][]lipgloss.Style{}
bc.built = true
return
}
_ = lang
e.buildFullBuffer(buf, bc, t)
}
// LineStyleMap returns the style row for a specific line in buf.
//
// It first guarantees buffer preparation, then returns cached styles when
// available. Missing lines are lazily initialized to the base line style and
// stored in cache to keep downstream rendering logic simple.
func (e *TreeSitterEngine) LineStyleMap(buf *core.Buffer, line int, t theme.EditorTheme) []lipgloss.Style {
if buf == nil {
return nil
}
e.PrepareBuffer(buf, t)
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] = t.Line
}
bc.lines[line] = out
return out
}
// ApplyEdit applies an incremental buffer edit to parser and style cache state.
//
// Workflow:
// - validate buffer and language support
// - apply the edit to the current parse tree (InputEdit)
// - reparse using the previous tree as incremental context
// - collect changed line ranges from both the user edit and parser changes
// - mark cache as unbuilt so the next PrepareBuffer restyles only dirty areas
//
// If incremental parsing cannot proceed (missing parser/tree/source or parse
// failure), it falls back to a full-dirty rebuild on the next preparation.
func (e *TreeSitterEngine) ApplyEdit(buf *core.Buffer, edit *core.BufferEdit) {
if buf == nil || edit == nil {
return
}
bc := e.getCache(buf)
lang, ok, err := e.resolveBufferLanguage(buf, bc)
if err != nil || !ok {
bc.built = false
bc.dirtyAll = true
return
}
_ = lang
if bc.parser == nil {
bc.parser = sitter.NewParser()
bc.parser.SetLanguage(bc.language)
}
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
}
// InvalidateBuffer marks all cached highlighting data for buf as stale.
//
// The next PrepareBuffer call will rebuild styles from scratch for the buffer.
func (e *TreeSitterEngine) InvalidateBuffer(buf *core.Buffer) {
if buf == nil {
return
}
bc := e.getCache(buf)
bc.built = false
bc.dirtyAll = true
bc.dirty = nil
}
// InvalidateLines marks a line interval in buf as dirty.
//
// The range is inclusive and normalized by addDirtyRange. On the next
// preparation pass, those lines (plus capture-context neighbors) are
// recalculated while unchanged lines are preserved.
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
}
// resolveBufferLanguage resolves and applies language/query config for buf.
//
// It asks the registry to resolve filetype/filename to a concrete language id,
// language object, and highlight query. When the resolved language id changes,
// parser/query bindings are updated and the cache is marked dirty for rebuild.
//
// Returns (resolved, true, nil) on success. When unsupported it returns
// (nil, false, nil). Resolution errors are returned as the third value.
func (e *TreeSitterEngine) resolveBufferLanguage(buf *core.Buffer, bc *bufferCache) (*resolvedLanguage, bool, error) {
if e.registry == nil {
e.registry = newLanguageRegistry()
}
resolved, ok, err := e.registry.resolve(buf.Filetype, buf.Filename)
if err != nil || !ok {
return nil, ok, err
}
if bc.langID != resolved.id {
bc.langID = resolved.id
bc.language = resolved.language
bc.query = resolved.query
if bc.parser != nil {
bc.parser.SetLanguage(bc.language)
}
bc.dirtyAll = true
bc.built = false
}
return resolved, true, nil
}
// getCache returns the cache object associated with buf, creating it if needed.
//
// New caches start with an initialized lines map and default zero-values for
// parse/highlight state.
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
}
// buildFullBuffer rebuilds highlight styles for buf using current cache state.
//
// Despite the name, this method handles both full and partial updates:
// - full rebuild: reset every line to base style, query entire file
// - partial rebuild: reset only dirty lines, query around dirty ranges
//
// It (re)parses source when needed, collects query captures, sorts captures by
// precedence order, then writes styles onto per-rune line slices. After a
// successful pass it clears dirty flags and marks the cache as built.
func (e *TreeSitterEngine) buildFullBuffer(buf *core.Buffer, bc *bufferCache, t theme.EditorTheme) {
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], t.Line)
}
} else {
dirty := normalizedDirtyRanges(bc.dirty, lineCount)
for _, r := range dirty {
for i := r.start; i <= r.end; i++ {
bc.lines[i] = defaultLineStyles(lines[i], t.Line)
}
}
}
source := buildBufferSource(buf)
useCurrentTree := bc.tree != nil && bytes.Equal(bc.source, source)
if bc.parser == nil {
bc.parser = sitter.NewParser()
bc.parser.SetLanguage(bc.language)
}
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(bc.query, root, source)
captures = append(captures, collectCaptures(iter, bc.query)...)
} 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(bc.query, root, source)
captures = append(captures, collectCaptures(iter, bc.query)...)
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 := t.CaptureStyle(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
}

View File

@ -0,0 +1,383 @@
package syntax
import (
"fmt"
"maps"
"strings"
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/theme"
"git.gophernest.net/azpect/TextEditor/internal/theme/themes"
"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
editorTheme := themes.NewDefaultTheme()
engine := NewTreeSitterEngine(editorTheme)
engine.PrepareBuffer(buf, editorTheme)
base := editorTheme.Line
line0 := buf.Line(0)
map0 := engine.LineStyleMap(buf, 0, editorTheme)
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, editorTheme)
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
editorTheme := themes.NewDefaultTheme()
engine := NewTreeSitterEngine(editorTheme)
engine.PrepareBuffer(buf, editorTheme)
base := editorTheme.Line
map3 := engine.LineStyleMap(buf, 3, editorTheme)
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
editorTheme := themes.NewDefaultTheme()
engine := NewTreeSitterEngine(editorTheme)
engine.PrepareBuffer(buf, editorTheme)
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, editorTheme)
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, editorTheme)
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, editorTheme)
newStyle := newMap[newIdx]
if styleEquivalent(newStyle, editorTheme.Line) {
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
editorTheme := themes.NewDefaultTheme()
engine := NewTreeSitterEngine(editorTheme)
engine.PrepareBuffer(buf, editorTheme)
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, editorTheme)
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
editorTheme := themes.NewDefaultTheme()
engine := NewTreeSitterEngine(editorTheme)
engine.PrepareBuffer(buf, editorTheme)
base := editorTheme.Line
line := buf.Line(0)
m := engine.LineStyleMap(buf, 0, editorTheme)
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
editorTheme := themes.NewDefaultTheme()
engine := NewTreeSitterEngine(editorTheme)
engine.PrepareBuffer(buf, editorTheme)
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, editorTheme)
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 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))
maps.Copy(out, in)
return out
}
func styleEquivalent(a, b lipgloss.Style) bool {
return styleSignature(a) == styleSignature(b)
}
func styleSignature(s lipgloss.Style) string {
return fmt.Sprintf(
"fg=%v,bg=%v,bold=%v,italic=%v,underline=%v,reverse=%v",
s.GetForeground(),
s.GetBackground(),
s.GetBold(),
s.GetItalic(),
s.GetUnderline(),
s.GetReverse(),
)
}

View File

@ -0,0 +1,38 @@
package syntax
import (
"fmt"
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/theme/themes"
)
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
editorTheme := themes.NewDefaultTheme()
eng := NewTreeSitterEngine(editorTheme)
eng.PrepareBuffer(buf, editorTheme)
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, editorTheme)
}
}

View File

@ -0,0 +1,86 @@
package syntax
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/theme/themes"
)
func TestTreeSitterEngineApplyEditMarksDirtyWithoutFullInvalidation(t *testing.T) {
b := core.NewBufferBuilder().
WithFilename("x.go").
WithFiletype("go").
WithLines([]string{"package main", "func main() {", "println(1)", "}"}).
Build()
buf := &b
editorTheme := themes.NewDefaultTheme()
engine := NewTreeSitterEngine(editorTheme)
engine.PrepareBuffer(buf, editorTheme)
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, editorTheme)
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
editorTheme := themes.NewDefaultTheme()
engine := NewTreeSitterEngine(editorTheme)
engine.PrepareBuffer(buf, editorTheme)
bc := engine.getCache(buf)
engine.InvalidateLines(buf, 1, 1)
if bc.built {
t.Fatalf("expected invalidate lines to unset built")
}
if bc.dirtyAll {
t.Fatalf("expected line invalidation to avoid dirtyAll")
}
if len(bc.dirty) == 0 {
t.Fatalf("expected dirty line ranges")
}
engine.InvalidateBuffer(buf)
if !bc.dirtyAll {
t.Fatalf("expected invalidate buffer to set dirtyAll")
}
}

View File

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

View File

@ -0,0 +1,283 @@
package syntax
import "testing"
func TestMergeRanges(t *testing.T) {
tests := []struct {
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,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := mergeRanges(tc.input)
if tc.wantErr {
t.Fatalf("unexpected wantErr=true for mergeRanges")
}
if len(got) != len(tc.expected) {
t.Fatalf("unexpected merged range count: got %d want %d", len(got), len(tc.expected))
}
for i := range got {
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) {
tests := []struct {
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,
},
}
for _, tc := range tests {
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 len(got) != len(tc.expected) {
t.Fatalf("unexpected normalized range count: got %d want %d", len(got), len(tc.expected))
}
for i := range got {
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) {
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,
},
}
for _, tc := range tests {
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 != tc.expected {
t.Fatalf("unexpected rune index: got %d want %d", got, tc.expected)
}
})
}
}
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)
}
})
}
}

View File

@ -0,0 +1,114 @@
package syntax
import (
"fmt"
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/theme"
"git.gophernest.net/azpect/TextEditor/internal/theme/themes"
)
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
editorTheme := themes.NewDefaultTheme()
engine := NewTreeSitterEngine(editorTheme)
buf.OnChange = func(change core.BufferChange) {
if change.Edit != nil {
engine.ApplyEdit(buf, change.Edit)
} else {
engine.InvalidateBuffer(buf)
}
}
engine.PrepareBuffer(buf, editorTheme)
assertEngineInvariants(t, engine, buf, editorTheme, "initial")
for i, op := range tc.opList {
op(buf, win)
engine.PrepareBuffer(buf, editorTheme)
assertEngineInvariants(t, engine, buf, editorTheme, fmt.Sprintf("after op %d", i+1))
}
})
}
}
func assertEngineInvariants(t *testing.T, engine *TreeSitterEngine, buf *core.Buffer, editorTheme theme.EditorTheme, 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, editorTheme)
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)))
}
}
}

View File

@ -0,0 +1,212 @@
package syntax
import (
"sort"
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
"github.com/charmbracelet/lipgloss"
sitter "github.com/tree-sitter/go-tree-sitter"
)
// addDirtyRange records a potentially changed line span in the buffer cache.
//
// The parser/highlighter keeps a list of "dirty" line ranges that must be
// reparsed or restyled after edits. This helper makes sure the incoming range
// is safe and normalized before storing it:
// - nil cache is ignored (defensive early-return)
// - start/end are swapped if the caller passed them in reverse order
// - negative values are clamped to 0
//
// After appending the new range, it merges overlaps/adjacent ranges so the
// dirty list stays compact and avoids duplicate work during incremental updates.
func addDirtyRange(bc *bufferCache, start, end int) {
if bc == nil {
return
}
if end < start {
start, end = end, start
}
start = max(0, start)
end = max(0, end)
bc.dirty = append(bc.dirty, lineRange{start: start, end: end})
bc.dirty = mergeRanges(bc.dirty)
}
// normalizedDirtyRanges clamps, filters, and merges dirty ranges for a buffer.
//
// Tree-sitter and styling operations expect valid row bounds. This function
// takes arbitrary line ranges and converts them into a clean canonical form
// based on the current buffer size:
// - returns nil if there are no lines or no input ranges
// - clamps each range to [0, lineCount-1]
// - drops invalid ranges where start > end after clamping
// - merges overlapping or adjacent ranges
//
// The returned slice is safe to iterate directly for reparse/restyle passes.
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)
}
// mergeRanges sorts and coalesces line ranges into a minimal non-overlapping set.
//
// Two ranges are merged when they overlap or touch (for example [1,3] and [4,6]
// become [1,6]). Treating adjacent ranges as one avoids unnecessary splits in
// later highlighting logic.
//
// Note: this function sorts the provided slice in place before building and
// returning a merged result.
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
}
// rowInRanges reports whether a row index is covered by any range.
//
// This is a simple membership check used by update paths that need to decide
// whether a specific line should be recomputed.
func rowInRanges(row int, ranges []lineRange) bool {
for _, r := range ranges {
if row >= r.start && row <= r.end {
return true
}
}
return false
}
// defaultLineStyles creates a style-per-rune slice initialized with base.
//
// The highlighter applies styles at rune granularity (not byte granularity) so
// multibyte UTF-8 characters still map to exactly one style entry per visible
// character. This function produces the baseline style row before syntax
// captures overwrite specific spans.
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
}
// collectCaptures consumes a Tree-sitter capture iterator into local ranges.
//
// For each capture returned by the query iterator, it resolves the capture name
// and records start/end row+column coordinates as a captureRange. These ranges
// are then used by the renderer to map syntax names to concrete styles.
//
// Special handling:
// - nil query yields nil output
// - capture indexes outside query.CaptureNames() are ignored defensively
// - captures named "spell" are skipped, because spell-check is handled by a
// separate pass and should not be treated as a syntax-highlight capture here
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
}
// buildBufferSource flattens the editor buffer into a single newline-delimited
// byte slice suitable for Tree-sitter parsing.
//
// The buffer stores text as lines, while Tree-sitter expects one contiguous
// source blob. This helper joins all lines with '\n' separators, preserving row
// structure expected by parser positions.
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"))
}
// byteColToRuneIndex converts a byte-based column offset to a rune index.
//
// Tree-sitter positions use byte columns, while the renderer/highlighter often
// indexes text by runes so multibyte UTF-8 characters are handled correctly.
// This conversion keeps style slicing aligned with displayed characters.
//
// Boundary behavior:
// - byteCol <= 0 -> 0
// - byteCol >= len(line) -> rune length of the entire line
func byteColToRuneIndex(line []byte, byteCol int) int {
if byteCol <= 0 {
return 0
}
if byteCol >= len(line) {
return len([]rune(string(line)))
}
prefix := line[:byteCol]
return len([]rune(string(prefix)))
}

View File

@ -194,7 +194,7 @@ func isValidPair(line string, startDelim, endDelim rune, startPos, endPos int) b
// A closing quote has an odd number of quotes before it (1, 3, 5, ...) // A closing quote has an odd number of quotes before it (1, 3, 5, ...)
quotesBeforeStart := 0 quotesBeforeStart := 0
for i := 0; i < startPos; i++ { for i := range startPos {
if rune(line[i]) == startDelim { if rune(line[i]) == startDelim {
quotesBeforeStart++ quotesBeforeStart++
} }
@ -470,7 +470,7 @@ func isCursorBetween(cursor, start, end core.Position) bool {
// hasOnlyWhitespaceBefore checks if there is only whitespace before the character at col. // hasOnlyWhitespaceBefore checks if there is only whitespace before the character at col.
func hasOnlyWhitespaceBefore(line string, col int) bool { func hasOnlyWhitespaceBefore(line string, col int) bool {
for i := 0; i < col; i++ { for i := range col {
if !isWhitespace(rune(line[i])) { if !isWhitespace(rune(line[i])) {
return false return false
} }

148
internal/theme/loader.go Normal file
View File

@ -0,0 +1,148 @@
package theme
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"path/filepath"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
)
//go:embed themes/*.json
var embeddedThemes embed.FS
// LoadEmbeddedThemesJSON reads all embedded theme JSON files and unmarshals
// them into ThemeJSON objects keyed by theme name.
func LoadEmbeddedThemesJSON() (map[string]ThemeJSON, error) {
paths, err := fs.Glob(embeddedThemes, "themes/*.json")
if err != nil {
return nil, err
}
sort.Strings(paths)
out := make(map[string]ThemeJSON, len(paths))
for _, path := range paths {
b, readErr := embeddedThemes.ReadFile(path)
if readErr != nil {
return nil, fmt.Errorf("read embedded theme %q: %w", path, readErr)
}
var th ThemeJSON
if unmarshalErr := json.Unmarshal(b, &th); unmarshalErr != nil {
return nil, fmt.Errorf("decode embedded theme %q: %w", path, unmarshalErr)
}
if strings.TrimSpace(th.Name) == "" {
th.Name = strings.TrimSuffix(filepath.Base(path), ".json")
}
out[th.Name] = th
}
return out, nil
}
func MapEmbeddedThemeToEditorTheme(em map[string]ThemeJSON) map[string]EditorTheme {
out := make(map[string]EditorTheme, len(em))
for name, in := range em {
line := styleFromJSON(in.Line)
lineBg := colorString(in.Line.BG)
syntaxExact := make(map[string]lipgloss.Style, len(in.Syntax.Exact))
for capture, col := range in.Syntax.Exact {
c := normalizeCaptureKey(capture)
if c == "" {
continue
}
syntaxExact[c] = syntaxColorStyle(col, lineBg)
}
syntaxGroup := make(map[string]lipgloss.Style, len(in.Syntax.Group))
for group, col := range in.Syntax.Group {
g := normalizeCaptureKey(group)
if g == "" {
continue
}
syntaxGroup[g] = syntaxColorStyle(col, lineBg)
}
key := strings.TrimSpace(name)
if key == "" {
key = strings.TrimSpace(in.Name)
}
if key == "" {
continue
}
out[key] = EditorTheme{
Cursors: CursorTheme{
Normal: styleFromJSON(in.Cursors.Normal),
Insert: styleFromJSON(in.Cursors.Insert),
Command: styleFromJSON(in.Cursors.Command),
Replace: styleFromJSON(in.Cursors.Replace),
},
Gutter: GutterTheme{
Default: styleFromJSON(in.Gutter.Default),
CurrentLine: styleFromJSON(in.Gutter.CurrentLine),
},
VisualHightlight: styleFromJSON(in.VisualHighlight),
StatusBar: StatusBarTheme{
Default: styleFromJSON(in.StatusBar.Default),
},
CommandLine: CommandLineTheme{
Error: styleFromJSON(in.CommandLine.Error),
OutputBorder: styleFromJSON(in.CommandLine.OutputBorder),
ContinueMessage: styleFromJSON(in.CommandLine.ContinueMessage),
},
Line: line,
Background: styleFromJSON(in.Background),
Syntax: SyntaxTheme{
Exact: syntaxExact,
Group: syntaxGroup,
},
}
}
return out
}
func styleFromJSON(in ColorStyleJSON) lipgloss.Style {
out := lipgloss.NewStyle()
if fg := colorString(in.FG); fg != "" {
out = out.Foreground(lipgloss.Color(fg))
}
if bg := colorString(in.BG); bg != "" {
out = out.Background(lipgloss.Color(bg))
}
return out
}
func colorString(c string) string {
return strings.TrimSpace(c)
}
func normalizeCaptureKey(k string) string {
k = strings.TrimSpace(strings.ToLower(k))
k = strings.TrimPrefix(k, "@")
return k
}
func syntaxColorStyle(fg, bg string) lipgloss.Style {
out := lipgloss.NewStyle()
if f := colorString(fg); f != "" {
out = out.Foreground(lipgloss.Color(f))
}
if b := colorString(bg); b != "" {
out = out.Background(lipgloss.Color(b))
}
return out
}

View File

@ -0,0 +1,95 @@
package theme
import (
"fmt"
"testing"
)
func TestLoadEmbeddedThemesJSON_LoadsExpectedThemes(t *testing.T) {
themesJSON, err := LoadEmbeddedThemesJSON()
if err != nil {
t.Fatalf("LoadEmbeddedThemesJSON returned error: %v", err)
}
want := []string{"kanagawa", "kanagawa-dragon", "kanagawa-lotus"}
for _, name := range want {
th, ok := themesJSON[name]
if !ok {
t.Fatalf("expected embedded theme %q to be loaded", name)
}
if th.Name == "" {
t.Fatalf("expected embedded theme %q to have a name", name)
}
if th.Line.BG == "" {
t.Fatalf("expected embedded theme %q to include line background", name)
}
}
}
func TestMapEmbeddedThemeToEditorTheme_MapsStylesAndNormalizesSyntaxKeys(t *testing.T) {
in := map[string]ThemeJSON{
"custom": {
Name: "custom",
Line: ColorStyleJSON{FG: "#dddddd", BG: "#101010"},
Background: ColorStyleJSON{BG: "#101010"},
VisualHighlight: ColorStyleJSON{BG: "#202020"},
Cursors: CursorJSON{
Normal: ColorStyleJSON{FG: "#101010", BG: "#dddddd"},
Insert: ColorStyleJSON{FG: "#101010", BG: "#cccccc"},
Command: ColorStyleJSON{FG: "#101010", BG: "#bbbbbb"},
Replace: ColorStyleJSON{FG: "#101010", BG: "#aaaaaa"},
},
Gutter: GutterJSON{
Default: ColorStyleJSON{FG: "#666666", BG: "#0a0a0a"},
CurrentLine: ColorStyleJSON{FG: "#eeeeee", BG: "#0a0a0a"},
},
StatusBar: StatusBarJSON{
Default: ColorStyleJSON{FG: "#cccccc", BG: "#0a0a0a"},
},
CommandLine: CommandLineJSON{
Error: ColorStyleJSON{FG: "#ff0000", BG: "#101010"},
OutputBorder: ColorStyleJSON{FG: "#dddddd", BG: "#0a0a0a"},
ContinueMessage: ColorStyleJSON{FG: "#00aaff", BG: "#101010"},
},
Syntax: SyntaxJSON{
Group: map[string]string{
" String ": "#00ff00",
},
Exact: map[string]string{
" @KEYWORD.Return ": "#ff00ff",
},
},
},
}
out := MapEmbeddedThemeToEditorTheme(in)
th, ok := out["custom"]
if !ok {
t.Fatalf("expected mapped theme with key %q", "custom")
}
if got := colorHex(th.Line.GetForeground()); got != "#dddddd" {
t.Fatalf("line fg mismatch: got %q want %q", got, "#dddddd")
}
if got := colorHex(th.Line.GetBackground()); got != "#101010" {
t.Fatalf("line bg mismatch: got %q want %q", got, "#101010")
}
if got := colorHex(th.Syntax.Exact["keyword.return"].GetForeground()); got != "#ff00ff" {
t.Fatalf("exact capture fg mismatch: got %q want %q", got, "#ff00ff")
}
if got := colorHex(th.Syntax.Exact["keyword.return"].GetBackground()); got != "#101010" {
t.Fatalf("exact capture bg mismatch: got %q want %q", got, "#101010")
}
if got := colorHex(th.Syntax.Group["string"].GetForeground()); got != "#00ff00" {
t.Fatalf("group capture fg mismatch: got %q want %q", got, "#00ff00")
}
if got := colorHex(th.CommandLine.Error.GetForeground()); got != "#ff0000" {
t.Fatalf("command error fg mismatch: got %q want %q", got, "#ff0000")
}
}
func colorHex(c any) string {
return fmt.Sprint(c)
}

View File

@ -1,43 +1,105 @@
package theme package theme
import ( import (
"embed" "strings"
"fmt"
"github.com/alecthomas/chroma/v2" "git.gophernest.net/azpect/TextEditor/internal/core"
"github.com/alecthomas/chroma/v2/styles" "github.com/charmbracelet/lipgloss"
) )
//go:embed themes/* type EditorTheme struct {
var themeFS embed.FS Cursors CursorTheme
Gutter GutterTheme
// RegisterAll: Registers all XML theme files embedded in the themes/ directory VisualHightlight lipgloss.Style
// with chroma's style registry. After calling this, styles.Get() will recognize StatusBar StatusBarTheme
// any theme defined in those files. CommandLine CommandLineTheme
func RegisterAll() error { Line lipgloss.Style
entries, err := themeFS.ReadDir("themes") Background lipgloss.Style
if err != nil { Syntax SyntaxTheme
return fmt.Errorf("failed to read embedded themes directory: %w", err) }
}
type CursorTheme struct {
for _, entry := range entries { Normal lipgloss.Style
if entry.IsDir() { Insert lipgloss.Style
continue Command lipgloss.Style
} Replace lipgloss.Style
}
f, err := themeFS.Open("themes/" + entry.Name())
if err != nil { type GutterTheme struct {
return fmt.Errorf("failed to open theme %s: %w", entry.Name(), err) Default lipgloss.Style
} CurrentLine lipgloss.Style
}
style, err := chroma.NewXMLStyle(f)
f.Close() type StatusBarTheme struct {
if err != nil { Default lipgloss.Style
return fmt.Errorf("failed to parse theme %s: %w", entry.Name(), err) }
}
type CommandLineTheme struct {
styles.Register(style) Error lipgloss.Style
} OutputBorder lipgloss.Style
ContinueMessage lipgloss.Style
return nil }
type SyntaxTheme struct {
Exact map[string]lipgloss.Style
Group map[string]lipgloss.Style
}
func (t EditorTheme) Cursor(mode core.Mode, textStyle lipgloss.Style) lipgloss.Style {
bg := textStyle.GetBackground()
fg := textStyle.GetForeground()
switch mode {
case core.NormalMode, core.VisualLineMode, core.VisualBlockMode, core.VisualMode:
return lipgloss.NewStyle().
Background(fg).
Foreground(bg)
case core.ReplaceMode, core.WaitingMode:
return textStyle.
Underline(true)
default:
return t.Background.
Foreground(fg).
Underline(true)
}
}
func (t EditorTheme) DefaultCursor(mode core.Mode) lipgloss.Style {
switch mode {
case core.InsertMode:
return t.Cursors.Insert
case core.CommandMode, core.SearchMode:
return t.Cursors.Command
case core.ReplaceMode:
return t.Cursors.Replace
default:
return t.Cursors.Normal
}
}
// This method is preferred to raw access of EditorTheme.VisualHightlight since
// is has the proper foreground color applied
func (t EditorTheme) VisualHighlightWithTextColor(textStyle lipgloss.Style) lipgloss.Style {
return t.VisualHightlight.
Foreground(textStyle.GetForeground())
}
// Use base (Line) as fallback. Every style will use the background from the base (Line).
//
// NOTE: Maybe we keep background on the mapping? Not sure for now
func (t EditorTheme) CaptureStyle(capture string) lipgloss.Style {
base := t.Line
exact := strings.ToLower(strings.TrimSpace(capture))
group := strings.Split(exact, ".")[0]
if sty, ok := t.Syntax.Exact[exact]; ok {
return sty.Background(base.GetBackground())
}
if sty, ok := t.Syntax.Group[group]; ok {
return sty.Background(base.GetBackground())
}
return base
} }

View File

@ -0,0 +1,53 @@
package theme
// ThemeJSON is the file-backed theme DTO used for JSON unmarshalling.
//
// This mirrors the format documented in internal/theme/themes/README.md.
// It is intentionally string-based so values can be validated and compiled
// into EditorTheme styles in a separate step.
type ThemeJSON struct {
Name string `json:"name"`
Line ColorStyleJSON `json:"line"`
Background ColorStyleJSON `json:"background"`
VisualHighlight ColorStyleJSON `json:"visual_highlight"`
Cursors CursorJSON `json:"cursors"`
Gutter GutterJSON `json:"gutter"`
StatusBar StatusBarJSON `json:"status_bar"`
CommandLine CommandLineJSON `json:"command_line"`
Syntax SyntaxJSON `json:"syntax"`
}
// ColorStyleJSON represents a simple fg/bg style entry.
//
// For v1 themes, only color values are supported.
type ColorStyleJSON struct {
FG string `json:"fg,omitempty"`
BG string `json:"bg,omitempty"`
}
type CursorJSON struct {
Normal ColorStyleJSON `json:"normal"`
Insert ColorStyleJSON `json:"insert"`
Command ColorStyleJSON `json:"command"`
Replace ColorStyleJSON `json:"replace"`
}
type GutterJSON struct {
Default ColorStyleJSON `json:"default"`
CurrentLine ColorStyleJSON `json:"current_line"`
}
type StatusBarJSON struct {
Default ColorStyleJSON `json:"default"`
}
type CommandLineJSON struct {
Error ColorStyleJSON `json:"error"`
OutputBorder ColorStyleJSON `json:"output_border"`
ContinueMessage ColorStyleJSON `json:"continue_message"`
}
type SyntaxJSON struct {
Group map[string]string `json:"group"`
Exact map[string]string `json:"exact"`
}

View File

@ -0,0 +1,81 @@
# Theme JSON Format (v1)
This document defines the JSON structure for editor themes.
- All color values are 6-digit hex strings (for example `#d4d8e1`).
- Capture keys must be lowercase and must not include `@`.
- `syntax.exact` overrides `syntax.group`. **These can be any values!**
- If a capture is missing from both maps, the editor should fall back to the base `line` style.
## Full structure
```json
{
"name": "default",
"line": { "fg": "#d4d8e1", "bg": "#111418" },
"background": { "bg": "#111418" },
"visual_highlight": { "bg": "#2f334d" },
"cursors": {
"normal": { "fg": "#111418", "bg": "#d4d8e1" },
"insert": { "fg": "#d4d8e1", "bg": "#111418" },
"command": { "fg": "#111418", "bg": "#d4d8e1" },
"replace": { "fg": "#d4d8e1", "bg": "#111418" }
},
"gutter": {
"default": { "fg": "#6b7280", "bg": "#0d1014" },
"current_line": { "fg": "#c0c8d8", "bg": "#0d1014" }
},
"status_bar": {
"default": { "fg": "#8f99aa", "bg": "#0d1014" }
},
"command_line": {
"error": { "fg": "#bf616a", "bg": "#111418" },
"output_border": { "fg": "#d4d8e1", "bg": "#0d1014" },
"continue_message": { "fg": "#81a1c1", "bg": "#111418" }
},
"syntax": {
"group": {
"comment": "#7f8795",
"function": "#81a1c1",
"keyword": "#b48ead",
"number": "#88c0d0",
"string": "#a3be8c",
"type": "#ebcb8b",
"variable": "#d4d8e1"
...
},
"exact": {
"comment.documentation": "#8f99aa",
"function.call": "#81a1c1",
"keyword.return": "#b48ead",
"string.escape": "#d08770",
"variable.parameter": "#c0c8d8",
...
}
}
}
```
## Field notes
- `name`: theme name shown by `:colorscheme`.
- `line`: base text style used as the default fallback.
- `background`: background fill style used for empty space.
- `visual_highlight`: selection background style.
- `syntax.group`: fallback colors by capture group (`keyword`, `string`, `comment`, etc.).
- `syntax.exact`: exact capture overrides (`keyword.function`, `string.escape`, etc.).
## Future ideas
For now, styles only support foreground/background colors.
In a future version we may add optional text attributes such as:
- `bold`
- `italic`
- `underline`

View File

@ -0,0 +1,79 @@
{
"name": "ayu-mirage",
"line": {
"fg": "#d9d7ce",
"bg": "#212733"
},
"background": {
"bg": "#212733"
},
"visual_highlight": {
"bg": "#343f4c"
},
"cursors": {
"normal": {
"fg": "#212733",
"bg": "#ffcc66"
},
"insert": {
"fg": "#212733",
"bg": "#bae67e"
},
"command": {
"fg": "#212733",
"bg": "#5ccfe6"
},
"replace": {
"fg": "#212733",
"bg": "#ff3333"
}
},
"gutter": {
"default": {
"fg": "#5c6773",
"bg": "#212733"
},
"current_line": {
"fg": "#d9d7ce",
"bg": "#212733"
}
},
"status_bar": {
"default": {
"fg": "#d9d7ce",
"bg": "#191e2a"
}
},
"command_line": {
"error": {
"fg": "#ff3333",
"bg": "#212733"
},
"output_border": {
"fg": "#343f4c",
"bg": "#191e2a"
},
"continue_message": {
"fg": "#ffad66",
"bg": "#212733"
}
},
"syntax": {
"group": {
"comment": "#5c6773",
"function": "#ffcc66",
"keyword": "#ffa759",
"number": "#ffad66",
"string": "#bae67e",
"type": "#5ccfe6",
"variable": "#d9d7ce"
},
"exact": {
"comment.documentation": "#5c6773",
"function.call": "#ffcc66",
"keyword.return": "#ffa759",
"string.escape": "#95e6cb",
"variable.parameter": "#d9d7ce"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "catppuccin-frappe",
"line": {
"fg": "#c6d0f5",
"bg": "#303446"
},
"background": {
"bg": "#303446"
},
"visual_highlight": {
"bg": "#414559"
},
"cursors": {
"normal": {
"fg": "#303446",
"bg": "#c6d0f5"
},
"insert": {
"fg": "#303446",
"bg": "#a6d189"
},
"command": {
"fg": "#303446",
"bg": "#ca9ee6"
},
"replace": {
"fg": "#303446",
"bg": "#e78284"
}
},
"gutter": {
"default": {
"fg": "#838ba7",
"bg": "#292c3c"
},
"current_line": {
"fg": "#c6d0f5",
"bg": "#292c3c"
}
},
"status_bar": {
"default": {
"fg": "#a5adce",
"bg": "#232634"
}
},
"command_line": {
"error": {
"fg": "#e78284",
"bg": "#303446"
},
"output_border": {
"fg": "#414559",
"bg": "#232634"
},
"continue_message": {
"fg": "#8caaee",
"bg": "#303446"
}
},
"syntax": {
"group": {
"comment": "#838ba7",
"function": "#8caaee",
"keyword": "#ca9ee6",
"number": "#ef9f76",
"string": "#a6d189",
"type": "#e5c890",
"variable": "#c6d0f5"
},
"exact": {
"comment.documentation": "#949cbb",
"function.call": "#8caaee",
"keyword.return": "#e78284",
"string.escape": "#81c8be",
"variable.parameter": "#eebebe"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "catppuccin-latte",
"line": {
"fg": "#4c4f69",
"bg": "#eff1f5"
},
"background": {
"bg": "#eff1f5"
},
"visual_highlight": {
"bg": "#ccd0da"
},
"cursors": {
"normal": {
"fg": "#eff1f5",
"bg": "#4c4f69"
},
"insert": {
"fg": "#eff1f5",
"bg": "#40a02b"
},
"command": {
"fg": "#eff1f5",
"bg": "#8839ef"
},
"replace": {
"fg": "#eff1f5",
"bg": "#d20f39"
}
},
"gutter": {
"default": {
"fg": "#9ca0b0",
"bg": "#e6e9ef"
},
"current_line": {
"fg": "#4c4f69",
"bg": "#e6e9ef"
}
},
"status_bar": {
"default": {
"fg": "#6c6f85",
"bg": "#dce0e8"
}
},
"command_line": {
"error": {
"fg": "#d20f39",
"bg": "#eff1f5"
},
"output_border": {
"fg": "#ccd0da",
"bg": "#dce0e8"
},
"continue_message": {
"fg": "#1e66f5",
"bg": "#eff1f5"
}
},
"syntax": {
"group": {
"comment": "#9ca0b0",
"function": "#1e66f5",
"keyword": "#8839ef",
"number": "#fe640b",
"string": "#40a02b",
"type": "#df8e1d",
"variable": "#4c4f69"
},
"exact": {
"comment.documentation": "#7c7f93",
"function.call": "#1e66f5",
"keyword.return": "#d20f39",
"string.escape": "#179287",
"variable.parameter": "#dd7878"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "catppuccin-macchiato",
"line": {
"fg": "#cad3f5",
"bg": "#24273a"
},
"background": {
"bg": "#24273a"
},
"visual_highlight": {
"bg": "#363a4f"
},
"cursors": {
"normal": {
"fg": "#24273a",
"bg": "#cad3f5"
},
"insert": {
"fg": "#24273a",
"bg": "#a6da95"
},
"command": {
"fg": "#24273a",
"bg": "#c6a0f6"
},
"replace": {
"fg": "#24273a",
"bg": "#ed8796"
}
},
"gutter": {
"default": {
"fg": "#8087a2",
"bg": "#1e2030"
},
"current_line": {
"fg": "#cad3f5",
"bg": "#1e2030"
}
},
"status_bar": {
"default": {
"fg": "#a5adcb",
"bg": "#181926"
}
},
"command_line": {
"error": {
"fg": "#ed8796",
"bg": "#24273a"
},
"output_border": {
"fg": "#363a4f",
"bg": "#181926"
},
"continue_message": {
"fg": "#8aadf4",
"bg": "#24273a"
}
},
"syntax": {
"group": {
"comment": "#8087a2",
"function": "#8aadf4",
"keyword": "#c6a0f6",
"number": "#f5a97f",
"string": "#a6da95",
"type": "#eed49f",
"variable": "#cad3f5"
},
"exact": {
"comment.documentation": "#939ab7",
"function.call": "#8aadf4",
"keyword.return": "#ed8796",
"string.escape": "#8bd5ca",
"variable.parameter": "#ee99a0"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "catppuccin-mocha",
"line": {
"fg": "#cdd6f4",
"bg": "#1e1e2e"
},
"background": {
"bg": "#1e1e2e"
},
"visual_highlight": {
"bg": "#313244"
},
"cursors": {
"normal": {
"fg": "#1e1e2e",
"bg": "#cdd6f4"
},
"insert": {
"fg": "#1e1e2e",
"bg": "#a6e3a1"
},
"command": {
"fg": "#1e1e2e",
"bg": "#cba6f7"
},
"replace": {
"fg": "#1e1e2e",
"bg": "#f38ba8"
}
},
"gutter": {
"default": {
"fg": "#7f849c",
"bg": "#181825"
},
"current_line": {
"fg": "#cdd6f4",
"bg": "#181825"
}
},
"status_bar": {
"default": {
"fg": "#a6adc8",
"bg": "#11111b"
}
},
"command_line": {
"error": {
"fg": "#f38ba8",
"bg": "#1e1e2e"
},
"output_border": {
"fg": "#313244",
"bg": "#11111b"
},
"continue_message": {
"fg": "#89b4fa",
"bg": "#1e1e2e"
}
},
"syntax": {
"group": {
"comment": "#7f849c",
"function": "#89b4fa",
"keyword": "#cba6f7",
"number": "#fab387",
"string": "#a6e3a1",
"type": "#f9e2af",
"variable": "#cdd6f4"
},
"exact": {
"comment.documentation": "#9399b2",
"function.call": "#89b4fa",
"keyword.return": "#f38ba8",
"string.escape": "#94e2d5",
"variable.parameter": "#eba0ac"
}
}
}

View File

@ -0,0 +1,196 @@
package themes
import (
"strings"
"git.gophernest.net/azpect/TextEditor/internal/theme"
"github.com/charmbracelet/lipgloss"
)
const background = lipgloss.Color("#111418")
const foreground = lipgloss.Color("#d4d8e1")
func NewDefaultTheme() theme.EditorTheme {
hightlight := lipgloss.NewStyle().
Background(lipgloss.Color("#2f334d"))
line := lipgloss.NewStyle().
Foreground(foreground).
Background(background)
background := lipgloss.NewStyle().
Background(background)
return theme.EditorTheme{
Cursors: newDefaultCursorTheme(),
Gutter: newDefaultGutterTheme(),
VisualHightlight: hightlight,
StatusBar: newDefaultStatusBarTheme(),
CommandLine: newDefaultCommandLineTheme(),
Line: line,
Background: background,
Syntax: newDefaultSyntaxTheme(),
}
}
// This is only used for the default cursors, in any other case
// the EditorTheme.Cursor() method is preferred.
func newDefaultCursorTheme() theme.CursorTheme {
base := lipgloss.NewStyle().
Foreground(foreground).
Background(background)
inv := lipgloss.NewStyle().
Foreground(background).
Background(foreground)
return theme.CursorTheme{
Normal: inv,
Insert: base.Underline(true),
Command: inv,
Replace: base.Underline(true),
}
}
func newDefaultGutterTheme() theme.GutterTheme {
base := lipgloss.NewStyle().
Background(lipgloss.Color("#0d1014")).
Foreground(lipgloss.Color("#6b7280"))
return theme.GutterTheme{
Default: base,
CurrentLine: base.Foreground(lipgloss.Color("#c0c8d8")),
}
}
func newDefaultStatusBarTheme() theme.StatusBarTheme {
bar := lipgloss.NewStyle().
Background(lipgloss.Color("#0d1014")).
Foreground(lipgloss.Color("#8f99aa"))
return theme.StatusBarTheme{
Default: bar,
}
}
func newDefaultCommandLineTheme() theme.CommandLineTheme {
base := lipgloss.NewStyle().
Foreground(foreground).
Background(background)
return theme.CommandLineTheme{
Error: base.Foreground(lipgloss.Color("#bf616a")),
OutputBorder: base.Background(lipgloss.Color("#0d1014")),
ContinueMessage: base.Foreground(lipgloss.Color("#81a1c1")),
}
}
func newDefaultSyntaxTheme() theme.SyntaxTheme {
exact := map[string]lipgloss.Style{
"attribute.builtin": color("#ebcb8b"),
"character.special": color("#d08770"),
"comment.documentation": color("#8f99aa"),
"constant.builtin": color("#88c0d0"),
"constant.macro": color("#d08770"),
"function.builtin": color("#88c0d0"),
"function.call": color("#81a1c1"),
"function.macro": color("#d08770"),
"function.method": color("#81a1c1"),
"function.method.call": color("#81a1c1"),
"keyword.conditional": color("#b48ead"),
"keyword.conditional.ternary": color("#b48ead"),
"keyword.coroutine": color("#b48ead"),
"keyword.debug": color("#b48ead"),
"keyword.directive": color("#b48ead"),
"keyword.directive.define": color("#b48ead"),
"keyword.exception": color("#b48ead"),
"keyword.function": color("#b48ead"),
"keyword.import": color("#b48ead"),
"keyword.modifier": color("#b48ead"),
"keyword.operator": color("#d08770"),
"keyword.repeat": color("#b48ead"),
"keyword.return": color("#b48ead"),
"keyword.type": color("#ebcb8b"),
"markup.heading": color("#ebcb8b"),
"markup.heading.1": color("#ebcb8b"),
"markup.heading.2": color("#e5c68a"),
"markup.heading.3": color("#ddbe88"),
"markup.heading.4": color("#d5b686"),
"markup.heading.5": color("#cdaf84"),
"markup.heading.6": color("#c5a883"),
"markup.italic": color("#c0c8d8"),
"markup.link.label": color("#81a1c1"),
"markup.raw": color("#a3be8c"),
"markup.strikethrough": color("#7f8795"),
"markup.strong": color("#ebcb8b"),
"markup.underline": color("#88c0d0"),
"module.builtin": color("#88c0d0"),
"number.float": color("#88c0d0"),
"punctuation.bracket": color("#9aa4b2"),
"punctuation.delimiter": color("#9aa4b2"),
"punctuation.special": color("#d08770"),
"string.documentation": color("#a3be8c"),
"string.escape": color("#d08770"),
"string.regexp": color("#88c0d0"),
"string.special.path": color("#a3be8c"),
"string.special.symbol": color("#88c0d0"),
"string.special.url": color("#88c0d0"),
"tag.attribute": color("#ebcb8b"),
"tag.attribute.url": color("#88c0d0"),
"tag.builtin": color("#81a1c1"),
"tag.delimiter": color("#9aa4b2"),
"type.builtin": color("#ebcb8b"),
"type.definition": color("#ebcb8b"),
"variable.builtin": color("#8fbcbb"),
"variable.member": color("#c0c8d8"),
"variable.parameter": color("#c0c8d8"),
}
group := map[string]lipgloss.Style{
"attribute": color("#ebcb8b"),
"boolean": color("#88c0d0"),
"character": color("#a3be8c"),
"charset": color("#ebcb8b"),
"comment": color("#7f8795"),
"conceal": color("#7f8795"),
"constant": color("#88c0d0"),
"constructor": color("#ebcb8b"),
"error": color("#bf616a"),
"function": color("#81a1c1"),
"import": color("#b48ead"),
"interface": color("#ebcb8b"),
"keyframes": color("#d08770"),
"keyword": color("#b48ead"),
"label": color("#d08770"),
"media": color("#d08770"),
"module": color("#81a1c1"),
"namespace": color("#81a1c1"),
"none": color("#d4d8e1"),
"nospell": color("#d4d8e1"),
"number": color("#88c0d0"),
"operator": color("#9aa4b2"),
"property": color("#c0c8d8"),
"spell": color("#d4d8e1"),
"string": color("#a3be8c"),
"supports": color("#d08770"),
"tag": color("#81a1c1"),
"type": color("#ebcb8b"),
"variable": color("#d4d8e1"),
}
return theme.SyntaxTheme{
Exact: exact,
Group: group,
}
}
// Simple helper to create a lipgloss style with the provided foreground
func color(c string) lipgloss.Style {
col := foreground
if strings.TrimSpace(c) != "" {
col = lipgloss.Color(c)
}
return lipgloss.NewStyle().
Background(background).
Foreground(col)
}

View File

@ -0,0 +1,79 @@
{
"name": "dracula",
"line": {
"fg": "#f8f8f2",
"bg": "#282a36"
},
"background": {
"bg": "#282a36"
},
"visual_highlight": {
"bg": "#44475a"
},
"cursors": {
"normal": {
"fg": "#282a36",
"bg": "#f8f8f2"
},
"insert": {
"fg": "#282a36",
"bg": "#50fa7b"
},
"command": {
"fg": "#282a36",
"bg": "#bd93f9"
},
"replace": {
"fg": "#282a36",
"bg": "#ff5555"
}
},
"gutter": {
"default": {
"fg": "#6272a4",
"bg": "#282a36"
},
"current_line": {
"fg": "#f8f8f2",
"bg": "#343746"
}
},
"status_bar": {
"default": {
"fg": "#f8f8f2",
"bg": "#191a21"
}
},
"command_line": {
"error": {
"fg": "#ff5555",
"bg": "#282a36"
},
"output_border": {
"fg": "#44475a",
"bg": "#191a21"
},
"continue_message": {
"fg": "#8be9fd",
"bg": "#282a36"
}
},
"syntax": {
"group": {
"comment": "#6272a4",
"function": "#50fa7b",
"keyword": "#ff79c6",
"number": "#bd93f9",
"string": "#f1fa8c",
"type": "#8be9fd",
"variable": "#f8f8f2"
},
"exact": {
"comment.documentation": "#6272a4",
"function.call": "#50fa7b",
"keyword.return": "#ff79c6",
"string.escape": "#ffb86c",
"variable.parameter": "#ffb86c"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "github-dark",
"line": {
"fg": "#c9d1d9",
"bg": "#0d1117"
},
"background": {
"bg": "#0d1117"
},
"visual_highlight": {
"bg": "#21262d"
},
"cursors": {
"normal": {
"fg": "#0d1117",
"bg": "#58a6ff"
},
"insert": {
"fg": "#0d1117",
"bg": "#3fb950"
},
"command": {
"fg": "#0d1117",
"bg": "#bc8cff"
},
"replace": {
"fg": "#0d1117",
"bg": "#f85149"
}
},
"gutter": {
"default": {
"fg": "#484f58",
"bg": "#0d1117"
},
"current_line": {
"fg": "#c9d1d9",
"bg": "#161b22"
}
},
"status_bar": {
"default": {
"fg": "#8b949e",
"bg": "#010409"
}
},
"command_line": {
"error": {
"fg": "#f85149",
"bg": "#0d1117"
},
"output_border": {
"fg": "#30363d",
"bg": "#010409"
},
"continue_message": {
"fg": "#58a6ff",
"bg": "#0d1117"
}
},
"syntax": {
"group": {
"comment": "#8b949e",
"function": "#d2a8ff",
"keyword": "#ff7b72",
"number": "#79c0ff",
"string": "#a5d6ff",
"type": "#ffa657",
"variable": "#c9d1d9"
},
"exact": {
"comment.documentation": "#8b949e",
"function.call": "#d2a8ff",
"keyword.return": "#ff7b72",
"string.escape": "#79c0ff",
"variable.parameter": "#ffa657"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "github-light",
"line": {
"fg": "#24292f",
"bg": "#ffffff"
},
"background": {
"bg": "#ffffff"
},
"visual_highlight": {
"bg": "#afdbff"
},
"cursors": {
"normal": {
"fg": "#ffffff",
"bg": "#0969da"
},
"insert": {
"fg": "#ffffff",
"bg": "#1a7f37"
},
"command": {
"fg": "#ffffff",
"bg": "#8250df"
},
"replace": {
"fg": "#ffffff",
"bg": "#cf222e"
}
},
"gutter": {
"default": {
"fg": "#8c959f",
"bg": "#ffffff"
},
"current_line": {
"fg": "#24292f",
"bg": "#f6f8fa"
}
},
"status_bar": {
"default": {
"fg": "#57606a",
"bg": "#f6f8fa"
}
},
"command_line": {
"error": {
"fg": "#cf222e",
"bg": "#ffffff"
},
"output_border": {
"fg": "#d0d7de",
"bg": "#f6f8fa"
},
"continue_message": {
"fg": "#0969da",
"bg": "#ffffff"
}
},
"syntax": {
"group": {
"comment": "#6e7781",
"function": "#8250df",
"keyword": "#cf222e",
"number": "#0550ae",
"string": "#0a3069",
"type": "#953800",
"variable": "#24292f"
},
"exact": {
"comment.documentation": "#57606a",
"function.call": "#8250df",
"keyword.return": "#cf222e",
"string.escape": "#0550ae",
"variable.parameter": "#24292f"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "gruvbox-dark",
"line": {
"fg": "#ebdbb2",
"bg": "#282828"
},
"background": {
"bg": "#282828"
},
"visual_highlight": {
"bg": "#504945"
},
"cursors": {
"normal": {
"fg": "#282828",
"bg": "#ebdbb2"
},
"insert": {
"fg": "#282828",
"bg": "#b8bb26"
},
"command": {
"fg": "#282828",
"bg": "#83a598"
},
"replace": {
"fg": "#282828",
"bg": "#fb4934"
}
},
"gutter": {
"default": {
"fg": "#928374",
"bg": "#282828"
},
"current_line": {
"fg": "#ebdbb2",
"bg": "#3c3836"
}
},
"status_bar": {
"default": {
"fg": "#a89984",
"bg": "#3c3836"
}
},
"command_line": {
"error": {
"fg": "#fb4934",
"bg": "#282828"
},
"output_border": {
"fg": "#504945",
"bg": "#3c3836"
},
"continue_message": {
"fg": "#83a598",
"bg": "#282828"
}
},
"syntax": {
"group": {
"comment": "#928374",
"function": "#b8bb26",
"keyword": "#fb4934",
"number": "#d3869b",
"string": "#b8bb26",
"type": "#fabd2f",
"variable": "#ebdbb2"
},
"exact": {
"comment.documentation": "#a89984",
"function.call": "#8ec07c",
"keyword.return": "#fb4934",
"string.escape": "#fe8019",
"variable.parameter": "#83a598"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "gruvbox-light",
"line": {
"fg": "#3c3836",
"bg": "#fbf1c7"
},
"background": {
"bg": "#fbf1c7"
},
"visual_highlight": {
"bg": "#ebdbb2"
},
"cursors": {
"normal": {
"fg": "#fbf1c7",
"bg": "#3c3836"
},
"insert": {
"fg": "#fbf1c7",
"bg": "#79740e"
},
"command": {
"fg": "#fbf1c7",
"bg": "#076678"
},
"replace": {
"fg": "#fbf1c7",
"bg": "#9d0006"
}
},
"gutter": {
"default": {
"fg": "#928374",
"bg": "#fbf1c7"
},
"current_line": {
"fg": "#3c3836",
"bg": "#f2e5bc"
}
},
"status_bar": {
"default": {
"fg": "#665c54",
"bg": "#f2e5bc"
}
},
"command_line": {
"error": {
"fg": "#9d0006",
"bg": "#fbf1c7"
},
"output_border": {
"fg": "#ebdbb2",
"bg": "#f2e5bc"
},
"continue_message": {
"fg": "#076678",
"bg": "#fbf1c7"
}
},
"syntax": {
"group": {
"comment": "#928374",
"function": "#79740e",
"keyword": "#9d0006",
"number": "#8f3f71",
"string": "#79740e",
"type": "#b57614",
"variable": "#3c3836"
},
"exact": {
"comment.documentation": "#7c6f64",
"function.call": "#427b58",
"keyword.return": "#9d0006",
"string.escape": "#af3a03",
"variable.parameter": "#076678"
}
}
}

View File

@ -0,0 +1,153 @@
{
"name": "kanagawa-dragon",
"line": {
"fg": "#c5c9c5",
"bg": "#181616"
},
"background": {
"bg": "#181616"
},
"visual_highlight": {
"bg": "#223249"
},
"cursors": {
"normal": {
"fg": "#181616",
"bg": "#c5c9c5"
},
"insert": {
"fg": "#181616",
"bg": "#c5c9c5"
},
"command": {
"fg": "#181616",
"bg": "#c5c9c5"
},
"replace": {
"fg": "#181616",
"bg": "#c5c9c5"
}
},
"gutter": {
"default": {
"fg": "#625e5a",
"bg": "#282727"
},
"current_line": {
"fg": "#c4b28a",
"bg": "#282727"
}
},
"status_bar": {
"default": {
"fg": "#c5c9c5",
"bg": "#282727"
}
},
"command_line": {
"error": {
"fg": "#e82424",
"bg": "#181616"
},
"output_border": {
"fg": "#c5c9c5",
"bg": "#0d0c0c"
},
"continue_message": {
"fg": "#8ba4b0",
"bg": "#181616"
}
},
"syntax": {
"group": {
"attribute": "#c4b28a",
"boolean": "#a292a3",
"character": "#8a9a7b",
"charset": "#8ea4a2",
"comment": "#737c73",
"conceal": "#737c73",
"constant": "#b6927b",
"constructor": "#c4b28a",
"error": "#e82424",
"function": "#8ba4b0",
"import": "#8992a7",
"interface": "#8ea4a2",
"keyframes": "#c4746e",
"keyword": "#8992a7",
"label": "#949fb5",
"media": "#c4746e",
"module": "#949fb5",
"namespace": "#949fb5",
"none": "#c5c9c5",
"nospell": "#c5c9c5",
"number": "#a292a3",
"operator": "#c4746e",
"property": "#c4b28a",
"spell": "#c5c9c5",
"string": "#8a9a7b",
"supports": "#c4746e",
"tag": "#8ba4b0",
"type": "#8ea4a2",
"variable": "#c5c9c5"
},
"exact": {
"attribute.builtin": "#c4b28a",
"character.special": "#c4746e",
"comment.documentation": "#a6a69c",
"constant.builtin": "#b6927b",
"constant.macro": "#c4746e",
"function.builtin": "#949fb5",
"function.call": "#8ba4b0",
"function.macro": "#c4746e",
"function.method": "#8ba4b0",
"function.method.call": "#8ba4b0",
"keyword.conditional": "#8992a7",
"keyword.conditional.ternary": "#8992a7",
"keyword.coroutine": "#8992a7",
"keyword.debug": "#8992a7",
"keyword.directive": "#c4746e",
"keyword.directive.define": "#c4746e",
"keyword.exception": "#8992a7",
"keyword.function": "#8992a7",
"keyword.import": "#8992a7",
"keyword.modifier": "#8992a7",
"keyword.operator": "#c4746e",
"keyword.repeat": "#8992a7",
"keyword.return": "#8992a7",
"keyword.type": "#8ea4a2",
"markup.heading": "#c4b28a",
"markup.heading.1": "#c4b28a",
"markup.heading.2": "#b6927b",
"markup.heading.3": "#a292a3",
"markup.heading.4": "#949fb5",
"markup.heading.5": "#8ba4b0",
"markup.heading.6": "#8ea4a2",
"markup.italic": "#a6a69c",
"markup.link.label": "#8ba4b0",
"markup.raw": "#8a9a7b",
"markup.strikethrough": "#737c73",
"markup.strong": "#c5c9c5",
"markup.underline": "#949fb5",
"module.builtin": "#949fb5",
"number.float": "#a292a3",
"punctuation.bracket": "#9e9b93",
"punctuation.delimiter": "#9e9b93",
"punctuation.special": "#c4746e",
"string.documentation": "#a6a69c",
"string.escape": "#c4746e",
"string.regexp": "#c4746e",
"string.special.path": "#8a9a7b",
"string.special.symbol": "#b6927b",
"string.special.url": "#8ba4b0",
"tag.attribute": "#c4b28a",
"tag.attribute.url": "#8ba4b0",
"tag.builtin": "#949fb5",
"tag.delimiter": "#9e9b93",
"type.builtin": "#8ea4a2",
"type.definition": "#8ea4a2",
"variable.builtin": "#b6927b",
"variable.member": "#c4b28a",
"variable.parameter": "#a6a69c"
}
}
}

View File

@ -1,83 +0,0 @@
<style name="kanagawa-dragon">
<entry type="Background" style="bg:#181616 #c5c9c5" />
<entry type="CodeLine" style="#c5c9c5" />
<entry type="Error" style="#e82424" />
<entry type="Other" style="#c5c9c5" />
<entry type="LineTableTD" style="" />
<entry type="LineTable" style="" />
<entry type="LineHighlight" style="bg:#393836" />
<entry type="LineNumbersTable" style="#625e5a" />
<entry type="LineNumbers" style="#625e5a" />
<entry type="Keyword" style="#8992a7" />
<entry type="KeywordReserved" style="#8992a7" />
<entry type="KeywordPseudo" style="#8992a7" />
<entry type="KeywordConstant" style="#b6927b" />
<entry type="KeywordDeclaration" style="#8992a7" />
<entry type="KeywordNamespace" style="#c4b28a" />
<entry type="KeywordType" style="#8ea4a2" />
<entry type="Name" style="#c5c9c5" />
<entry type="NameClass" style="#8ea4a2" />
<entry type="NameConstant" style="#b6927b" />
<entry type="NameDecorator" style="bold #b6927b" />
<entry type="NameEntity" style="#c4b28a" />
<entry type="NameException" style="#b6927b" />
<entry type="NameFunction" style="#8ba4b0" />
<entry type="NameFunctionMagic" style="#8ba4b0" />
<entry type="NameLabel" style="#949fb5" />
<entry type="NameNamespace" style="#c4b28a" />
<entry type="NameProperty" style="#c4b28a" />
<entry type="NameTag" style="#8ba4b0" />
<entry type="NameVariable" style="#c5c9c5" />
<entry type="NameVariableClass" style="#c5c9c5" />
<entry type="NameVariableGlobal" style="#c5c9c5" />
<entry type="NameVariableInstance" style="#c5c9c5" />
<entry type="NameVariableMagic" style="#c5c9c5" />
<entry type="NameAttribute" style="#c4b28a" />
<entry type="NameBuiltin" style="#c4746e" />
<entry type="NameBuiltinPseudo" style="#c4746e" />
<entry type="NameOther" style="#c5c9c5" />
<entry type="Literal" style="#c5c9c5" />
<entry type="LiteralDate" style="#c5c9c5" />
<entry type="LiteralString" style="#8a9a7b" />
<entry type="LiteralStringChar" style="#8a9a7b" />
<entry type="LiteralStringSingle" style="#8a9a7b" />
<entry type="LiteralStringDouble" style="#8a9a7b" />
<entry type="LiteralStringBacktick" style="#8a9a7b" />
<entry type="LiteralStringOther" style="#8a9a7b" />
<entry type="LiteralStringSymbol" style="#8a9a7b" />
<entry type="LiteralStringInterpol" style="#949fb5" />
<entry type="LiteralStringAffix" style="#c4746e" />
<entry type="LiteralStringDelimiter" style="#949fb5" />
<entry type="LiteralStringEscape" style="#c4746e" />
<entry type="LiteralStringRegex" style="#c4746e" />
<entry type="LiteralStringDoc" style="#737c73" />
<entry type="LiteralStringHeredoc" style="#737c73" />
<entry type="LiteralNumber" style="#a292a3" />
<entry type="LiteralNumberBin" style="#a292a3" />
<entry type="LiteralNumberHex" style="#a292a3" />
<entry type="LiteralNumberInteger" style="#a292a3" />
<entry type="LiteralNumberFloat" style="#a292a3" />
<entry type="LiteralNumberIntegerLong" style="#a292a3" />
<entry type="LiteralNumberOct" style="#a292a3" />
<entry type="Operator" style="bold #c4746e" />
<entry type="OperatorWord" style="bold #c4746e" />
<entry type="Comment" style="italic #737c73" />
<entry type="CommentSingle" style="italic #737c73" />
<entry type="CommentMultiline" style="italic #737c73" />
<entry type="CommentSpecial" style="italic #737c73" />
<entry type="CommentHashbang" style="italic #737c73" />
<entry type="CommentPreproc" style="italic #c4746e" />
<entry type="CommentPreprocFile" style="bold #c4746e" />
<entry type="Generic" style="#c5c9c5" />
<entry type="GenericInserted" style="bg:#2b3328 #76946a" />
<entry type="GenericDeleted" style="bg:#43242b #c34043" />
<entry type="GenericEmph" style="italic #c5c9c5" />
<entry type="GenericStrong" style="bold #c5c9c5" />
<entry type="GenericUnderline" style="underline #c5c9c5" />
<entry type="GenericHeading" style="bold #8ba4b0" />
<entry type="GenericSubheading" style="bold #8ba4b0" />
<entry type="GenericOutput" style="#c5c9c5" />
<entry type="GenericPrompt" style="#c5c9c5" />
<entry type="GenericError" style="#e82424" />
<entry type="GenericTraceback" style="#e82424" />
</style>

View File

@ -0,0 +1,153 @@
{
"name": "kanagawa-lotus",
"line": {
"fg": "#545464",
"bg": "#f2ecbc"
},
"background": {
"bg": "#f2ecbc"
},
"visual_highlight": {
"bg": "#c9cbd1"
},
"cursors": {
"normal": {
"fg": "#f2ecbc",
"bg": "#545464"
},
"insert": {
"fg": "#f2ecbc",
"bg": "#545464"
},
"command": {
"fg": "#f2ecbc",
"bg": "#545464"
},
"replace": {
"fg": "#f2ecbc",
"bg": "#545464"
}
},
"gutter": {
"default": {
"fg": "#a09cac",
"bg": "#e7dba0"
},
"current_line": {
"fg": "#77713f",
"bg": "#e7dba0"
}
},
"status_bar": {
"default": {
"fg": "#43436c",
"bg": "#e7dba0"
}
},
"command_line": {
"error": {
"fg": "#e82424",
"bg": "#f2ecbc"
},
"output_border": {
"fg": "#545464",
"bg": "#e7dba0"
},
"continue_message": {
"fg": "#4d699b",
"bg": "#f2ecbc"
}
},
"syntax": {
"group": {
"attribute": "#77713f",
"boolean": "#b35b79",
"character": "#6f894e",
"charset": "#597b75",
"comment": "#8a8980",
"conceal": "#8a8980",
"constant": "#cc6d00",
"constructor": "#77713f",
"error": "#e82424",
"function": "#4d699b",
"import": "#624c83",
"interface": "#597b75",
"keyframes": "#c84053",
"keyword": "#624c83",
"label": "#6693bf",
"media": "#c84053",
"module": "#6693bf",
"namespace": "#6693bf",
"none": "#545464",
"nospell": "#545464",
"number": "#b35b79",
"operator": "#836f4a",
"property": "#77713f",
"spell": "#545464",
"string": "#6f894e",
"supports": "#c84053",
"tag": "#4d699b",
"type": "#597b75",
"variable": "#545464"
},
"exact": {
"attribute.builtin": "#77713f",
"character.special": "#836f4a",
"comment.documentation": "#716e61",
"constant.builtin": "#cc6d00",
"constant.macro": "#c84053",
"function.builtin": "#6693bf",
"function.call": "#4d699b",
"function.macro": "#c84053",
"function.method": "#4d699b",
"function.method.call": "#4d699b",
"keyword.conditional": "#624c83",
"keyword.conditional.ternary": "#624c83",
"keyword.coroutine": "#624c83",
"keyword.debug": "#624c83",
"keyword.directive": "#c84053",
"keyword.directive.define": "#c84053",
"keyword.exception": "#624c83",
"keyword.function": "#624c83",
"keyword.import": "#624c83",
"keyword.modifier": "#624c83",
"keyword.operator": "#836f4a",
"keyword.repeat": "#624c83",
"keyword.return": "#624c83",
"keyword.type": "#597b75",
"markup.heading": "#77713f",
"markup.heading.1": "#77713f",
"markup.heading.2": "#836f4a",
"markup.heading.3": "#cc6d00",
"markup.heading.4": "#4d699b",
"markup.heading.5": "#624c83",
"markup.heading.6": "#6693bf",
"markup.italic": "#716e61",
"markup.link.label": "#4d699b",
"markup.raw": "#6f894e",
"markup.strikethrough": "#8a8980",
"markup.strong": "#545464",
"markup.underline": "#6693bf",
"module.builtin": "#6693bf",
"number.float": "#b35b79",
"punctuation.bracket": "#4e8ca2",
"punctuation.delimiter": "#4e8ca2",
"punctuation.special": "#836f4a",
"string.documentation": "#716e61",
"string.escape": "#836f4a",
"string.regexp": "#836f4a",
"string.special.path": "#6f894e",
"string.special.symbol": "#cc6d00",
"string.special.url": "#4d699b",
"tag.attribute": "#77713f",
"tag.attribute.url": "#4d699b",
"tag.builtin": "#6693bf",
"tag.delimiter": "#4e8ca2",
"type.builtin": "#597b75",
"type.definition": "#597b75",
"variable.builtin": "#c84053",
"variable.member": "#77713f",
"variable.parameter": "#5d57a3"
}
}
}

View File

@ -1,83 +0,0 @@
<style name="kanagawa-lotus">
<entry type="Background" style="bg:#f2ecbc #545464" />
<entry type="CodeLine" style="#545464" />
<entry type="Error" style="#e82424" />
<entry type="Other" style="#545464" />
<entry type="LineTableTD" style="" />
<entry type="LineTable" style="" />
<entry type="LineHighlight" style="bg:#e4d794" />
<entry type="LineNumbersTable" style="#a09cac" />
<entry type="LineNumbers" style="#a09cac" />
<entry type="Keyword" style="#624c83" />
<entry type="KeywordReserved" style="#624c83" />
<entry type="KeywordPseudo" style="#624c83" />
<entry type="KeywordConstant" style="#cc6d00" />
<entry type="KeywordDeclaration" style="#624c83" />
<entry type="KeywordNamespace" style="#77713f" />
<entry type="KeywordType" style="#597b75" />
<entry type="Name" style="#545464" />
<entry type="NameClass" style="#597b75" />
<entry type="NameConstant" style="#cc6d00" />
<entry type="NameDecorator" style="bold #cc6d00" />
<entry type="NameEntity" style="#77713f" />
<entry type="NameException" style="#cc6d00" />
<entry type="NameFunction" style="#4d699b" />
<entry type="NameFunctionMagic" style="#4d699b" />
<entry type="NameLabel" style="#6693bf" />
<entry type="NameNamespace" style="#77713f" />
<entry type="NameProperty" style="#77713f" />
<entry type="NameTag" style="#4d699b" />
<entry type="NameVariable" style="#545464" />
<entry type="NameVariableClass" style="#545464" />
<entry type="NameVariableGlobal" style="#545464" />
<entry type="NameVariableInstance" style="#545464" />
<entry type="NameVariableMagic" style="#545464" />
<entry type="NameAttribute" style="#77713f" />
<entry type="NameBuiltin" style="#c84053" />
<entry type="NameBuiltinPseudo" style="#c84053" />
<entry type="NameOther" style="#545464" />
<entry type="Literal" style="#545464" />
<entry type="LiteralDate" style="#545464" />
<entry type="LiteralString" style="#6f894e" />
<entry type="LiteralStringChar" style="#6f894e" />
<entry type="LiteralStringSingle" style="#6f894e" />
<entry type="LiteralStringDouble" style="#6f894e" />
<entry type="LiteralStringBacktick" style="#6f894e" />
<entry type="LiteralStringOther" style="#6f894e" />
<entry type="LiteralStringSymbol" style="#6f894e" />
<entry type="LiteralStringInterpol" style="#6693bf" />
<entry type="LiteralStringAffix" style="#c84053" />
<entry type="LiteralStringDelimiter" style="#6693bf" />
<entry type="LiteralStringEscape" style="#836f4a" />
<entry type="LiteralStringRegex" style="#836f4a" />
<entry type="LiteralStringDoc" style="#8a8980" />
<entry type="LiteralStringHeredoc" style="#8a8980" />
<entry type="LiteralNumber" style="#b35b79" />
<entry type="LiteralNumberBin" style="#b35b79" />
<entry type="LiteralNumberHex" style="#b35b79" />
<entry type="LiteralNumberInteger" style="#b35b79" />
<entry type="LiteralNumberFloat" style="#b35b79" />
<entry type="LiteralNumberIntegerLong" style="#b35b79" />
<entry type="LiteralNumberOct" style="#b35b79" />
<entry type="Operator" style="bold #836f4a" />
<entry type="OperatorWord" style="bold #836f4a" />
<entry type="Comment" style="italic #8a8980" />
<entry type="CommentSingle" style="italic #8a8980" />
<entry type="CommentMultiline" style="italic #8a8980" />
<entry type="CommentSpecial" style="italic #8a8980" />
<entry type="CommentHashbang" style="italic #8a8980" />
<entry type="CommentPreproc" style="italic #c84053" />
<entry type="CommentPreprocFile" style="bold #c84053" />
<entry type="Generic" style="#545464" />
<entry type="GenericInserted" style="bg:#b7d0ae #6e915f" />
<entry type="GenericDeleted" style="bg:#d9a594 #d7474b" />
<entry type="GenericEmph" style="italic #545464" />
<entry type="GenericStrong" style="bold #545464" />
<entry type="GenericUnderline" style="underline #545464" />
<entry type="GenericHeading" style="bold #4d699b" />
<entry type="GenericSubheading" style="bold #4d699b" />
<entry type="GenericOutput" style="#545464" />
<entry type="GenericPrompt" style="#545464" />
<entry type="GenericError" style="#e82424" />
<entry type="GenericTraceback" style="#e82424" />
</style>

View File

@ -1,83 +0,0 @@
<style name="kanagawa-wave">
<entry type="Background" style="bg:#1f1f28 #dcd7ba" />
<entry type="CodeLine" style="#dcd7ba" />
<entry type="Error" style="#e82424" />
<entry type="Other" style="#dcd7ba" />
<entry type="LineTableTD" style="" />
<entry type="LineTable" style="" />
<entry type="LineHighlight" style="bg:#363646" />
<entry type="LineNumbersTable" style="#54546d" />
<entry type="LineNumbers" style="#54546d" />
<entry type="Keyword" style="#957fb8" />
<entry type="KeywordReserved" style="#957fb8" />
<entry type="KeywordPseudo" style="#957fb8" />
<entry type="KeywordConstant" style="#ffa066" />
<entry type="KeywordDeclaration" style="#957fb8" />
<entry type="KeywordNamespace" style="#e6c384" />
<entry type="KeywordType" style="#7aa89f" />
<entry type="Name" style="#dcd7ba" />
<entry type="NameClass" style="#7aa89f" />
<entry type="NameConstant" style="#ffa066" />
<entry type="NameDecorator" style="bold #ffa066" />
<entry type="NameEntity" style="#e6c384" />
<entry type="NameException" style="#ffa066" />
<entry type="NameFunction" style="#7e9cd8" />
<entry type="NameFunctionMagic" style="#7e9cd8" />
<entry type="NameLabel" style="#7fb4ca" />
<entry type="NameNamespace" style="#e6c384" />
<entry type="NameProperty" style="#e6c384" />
<entry type="NameTag" style="#7e9cd8" />
<entry type="NameVariable" style="#dcd7ba" />
<entry type="NameVariableClass" style="#dcd7ba" />
<entry type="NameVariableGlobal" style="#dcd7ba" />
<entry type="NameVariableInstance" style="#dcd7ba" />
<entry type="NameVariableMagic" style="#dcd7ba" />
<entry type="NameAttribute" style="#e6c384" />
<entry type="NameBuiltin" style="#e46876" />
<entry type="NameBuiltinPseudo" style="#e46876" />
<entry type="NameOther" style="#dcd7ba" />
<entry type="Literal" style="#dcd7ba" />
<entry type="LiteralDate" style="#dcd7ba" />
<entry type="LiteralString" style="#98bb6c" />
<entry type="LiteralStringChar" style="#98bb6c" />
<entry type="LiteralStringSingle" style="#98bb6c" />
<entry type="LiteralStringDouble" style="#98bb6c" />
<entry type="LiteralStringBacktick" style="#98bb6c" />
<entry type="LiteralStringOther" style="#98bb6c" />
<entry type="LiteralStringSymbol" style="#98bb6c" />
<entry type="LiteralStringInterpol" style="#7fb4ca" />
<entry type="LiteralStringAffix" style="#ff5d62" />
<entry type="LiteralStringDelimiter" style="#7fb4ca" />
<entry type="LiteralStringEscape" style="#c0a36e" />
<entry type="LiteralStringRegex" style="#c0a36e" />
<entry type="LiteralStringDoc" style="#727169" />
<entry type="LiteralStringHeredoc" style="#727169" />
<entry type="LiteralNumber" style="#d27e99" />
<entry type="LiteralNumberBin" style="#d27e99" />
<entry type="LiteralNumberHex" style="#d27e99" />
<entry type="LiteralNumberInteger" style="#d27e99" />
<entry type="LiteralNumberFloat" style="#d27e99" />
<entry type="LiteralNumberIntegerLong" style="#d27e99" />
<entry type="LiteralNumberOct" style="#d27e99" />
<entry type="Operator" style="bold #c0a36e" />
<entry type="OperatorWord" style="bold #c0a36e" />
<entry type="Comment" style="italic #727169" />
<entry type="CommentSingle" style="italic #727169" />
<entry type="CommentMultiline" style="italic #727169" />
<entry type="CommentSpecial" style="italic #727169" />
<entry type="CommentHashbang" style="italic #727169" />
<entry type="CommentPreproc" style="italic #e46876" />
<entry type="CommentPreprocFile" style="bold #e46876" />
<entry type="Generic" style="#dcd7ba" />
<entry type="GenericInserted" style="bg:#2b3328 #76946a" />
<entry type="GenericDeleted" style="bg:#43242b #c34043" />
<entry type="GenericEmph" style="italic #dcd7ba" />
<entry type="GenericStrong" style="bold #dcd7ba" />
<entry type="GenericUnderline" style="underline #dcd7ba" />
<entry type="GenericHeading" style="bold #7e9cd8" />
<entry type="GenericSubheading" style="bold #7e9cd8" />
<entry type="GenericOutput" style="#dcd7ba" />
<entry type="GenericPrompt" style="#dcd7ba" />
<entry type="GenericError" style="#e82424" />
<entry type="GenericTraceback" style="#e82424" />
</style>

View File

@ -0,0 +1,153 @@
{
"name": "kanagawa",
"line": {
"fg": "#dcd7ba",
"bg": "#1f1f28"
},
"background": {
"bg": "#1f1f28"
},
"visual_highlight": {
"bg": "#223249"
},
"cursors": {
"normal": {
"fg": "#1f1f28",
"bg": "#dcd7ba"
},
"insert": {
"fg": "#1f1f28",
"bg": "#dcd7ba"
},
"command": {
"fg": "#1f1f28",
"bg": "#dcd7ba"
},
"replace": {
"fg": "#1f1f28",
"bg": "#dcd7ba"
}
},
"gutter": {
"default": {
"fg": "#727169",
"bg": "#2a2a37"
},
"current_line": {
"fg": "#e6c384",
"bg": "#2a2a37"
}
},
"status_bar": {
"default": {
"fg": "#c8c093",
"bg": "#2a2a37"
}
},
"command_line": {
"error": {
"fg": "#e82424",
"bg": "#1f1f28"
},
"output_border": {
"fg": "#dcd7ba",
"bg": "#16161d"
},
"continue_message": {
"fg": "#7e9cd8",
"bg": "#1f1f28"
}
},
"syntax": {
"group": {
"attribute": "#e6c384",
"boolean": "#d27e99",
"character": "#98bb6c",
"charset": "#7aa89f",
"comment": "#727169",
"conceal": "#727169",
"constant": "#ffa066",
"constructor": "#e6c384",
"error": "#e82424",
"function": "#7e9cd8",
"import": "#957fb8",
"interface": "#7aa89f",
"keyframes": "#e46876",
"keyword": "#957fb8",
"label": "#e46876",
"media": "#e46876",
"module": "#7fb4ca",
"namespace": "#7fb4ca",
"none": "#dcd7ba",
"nospell": "#dcd7ba",
"number": "#d27e99",
"operator": "#c0a36e",
"property": "#e6c384",
"spell": "#dcd7ba",
"string": "#98bb6c",
"supports": "#e46876",
"tag": "#7fb4ca",
"type": "#7aa89f",
"variable": "#dcd7ba"
},
"exact": {
"attribute.builtin": "#e6c384",
"character.special": "#c0a36e",
"comment.documentation": "#c8c093",
"constant.builtin": "#ffa066",
"constant.macro": "#e46876",
"function.builtin": "#7fb4ca",
"function.call": "#7e9cd8",
"function.macro": "#e46876",
"function.method": "#7e9cd8",
"function.method.call": "#7e9cd8",
"keyword.conditional": "#957fb8",
"keyword.conditional.ternary": "#957fb8",
"keyword.coroutine": "#957fb8",
"keyword.debug": "#957fb8",
"keyword.directive": "#e46876",
"keyword.directive.define": "#e46876",
"keyword.exception": "#957fb8",
"keyword.function": "#957fb8",
"keyword.import": "#957fb8",
"keyword.modifier": "#957fb8",
"keyword.operator": "#c0a36e",
"keyword.repeat": "#957fb8",
"keyword.return": "#957fb8",
"keyword.type": "#7aa89f",
"markup.heading": "#e6c384",
"markup.heading.1": "#e6c384",
"markup.heading.2": "#dca561",
"markup.heading.3": "#c0a36e",
"markup.heading.4": "#b6927b",
"markup.heading.5": "#957fb8",
"markup.heading.6": "#7e9cd8",
"markup.italic": "#b8b4d0",
"markup.link.label": "#7e9cd8",
"markup.raw": "#98bb6c",
"markup.strikethrough": "#727169",
"markup.strong": "#c8c093",
"markup.underline": "#7fb4ca",
"module.builtin": "#7fb4ca",
"number.float": "#d27e99",
"punctuation.bracket": "#9cabca",
"punctuation.delimiter": "#9cabca",
"punctuation.special": "#c0a36e",
"string.documentation": "#c8c093",
"string.escape": "#c0a36e",
"string.regexp": "#c0a36e",
"string.special.path": "#98bb6c",
"string.special.symbol": "#ffa066",
"string.special.url": "#7e9cd8",
"tag.attribute": "#e6c384",
"tag.attribute.url": "#7e9cd8",
"tag.builtin": "#7fb4ca",
"tag.delimiter": "#9cabca",
"type.builtin": "#7aa89f",
"type.definition": "#7aa89f",
"variable.builtin": "#ffa066",
"variable.member": "#e6c384",
"variable.parameter": "#b8b4d0"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "material-deep-ocean",
"line": {
"fg": "#a6accd",
"bg": "#0f111a"
},
"background": {
"bg": "#0f111a"
},
"visual_highlight": {
"bg": "#1f2233"
},
"cursors": {
"normal": {
"fg": "#0f111a",
"bg": "#80cbc4"
},
"insert": {
"fg": "#0f111a",
"bg": "#c3e88d"
},
"command": {
"fg": "#0f111a",
"bg": "#82aaff"
},
"replace": {
"fg": "#0f111a",
"bg": "#ff5370"
}
},
"gutter": {
"default": {
"fg": "#464b5d",
"bg": "#0f111a"
},
"current_line": {
"fg": "#a6accd",
"bg": "#090b10"
}
},
"status_bar": {
"default": {
"fg": "#a6accd",
"bg": "#090b10"
}
},
"command_line": {
"error": {
"fg": "#ff5370",
"bg": "#0f111a"
},
"output_border": {
"fg": "#1f2233",
"bg": "#090b10"
},
"continue_message": {
"fg": "#82aaff",
"bg": "#0f111a"
}
},
"syntax": {
"group": {
"comment": "#464b5d",
"function": "#82aaff",
"keyword": "#c792ea",
"number": "#f78c6c",
"string": "#c3e88d",
"type": "#ffcb6b",
"variable": "#a6accd"
},
"exact": {
"comment.documentation": "#546e7a",
"function.call": "#82aaff",
"keyword.return": "#ff5370",
"string.escape": "#89ddff",
"variable.parameter": "#7986cb"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "material-palenight",
"line": {
"fg": "#a6accd",
"bg": "#292d3e"
},
"background": {
"bg": "#292d3e"
},
"visual_highlight": {
"bg": "#3c435e"
},
"cursors": {
"normal": {
"fg": "#292d3e",
"bg": "#ffcb6b"
},
"insert": {
"fg": "#292d3e",
"bg": "#c3e88d"
},
"command": {
"fg": "#292d3e",
"bg": "#c792ea"
},
"replace": {
"fg": "#292d3e",
"bg": "#f07178"
}
},
"gutter": {
"default": {
"fg": "#676e95",
"bg": "#292d3e"
},
"current_line": {
"fg": "#a6accd",
"bg": "#1b1e2b"
}
},
"status_bar": {
"default": {
"fg": "#a6accd",
"bg": "#1b1e2b"
}
},
"command_line": {
"error": {
"fg": "#f07178",
"bg": "#292d3e"
},
"output_border": {
"fg": "#3c435e",
"bg": "#1b1e2b"
},
"continue_message": {
"fg": "#82aaff",
"bg": "#292d3e"
}
},
"syntax": {
"group": {
"comment": "#676e95",
"function": "#82aaff",
"keyword": "#c792ea",
"number": "#f78c6c",
"string": "#c3e88d",
"type": "#ffcb6b",
"variable": "#a6accd"
},
"exact": {
"comment.documentation": "#a6accd",
"function.call": "#82aaff",
"keyword.return": "#f07178",
"string.escape": "#89ddff",
"variable.parameter": "#ff5370"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "monokai",
"line": {
"fg": "#f8f8f2",
"bg": "#272822"
},
"background": {
"bg": "#272822"
},
"visual_highlight": {
"bg": "#49483e"
},
"cursors": {
"normal": {
"fg": "#272822",
"bg": "#f8f8f2"
},
"insert": {
"fg": "#272822",
"bg": "#a6e22e"
},
"command": {
"fg": "#272822",
"bg": "#66d9ef"
},
"replace": {
"fg": "#272822",
"bg": "#f92672"
}
},
"gutter": {
"default": {
"fg": "#75715e",
"bg": "#272822"
},
"current_line": {
"fg": "#f8f8f2",
"bg": "#3e3d32"
}
},
"status_bar": {
"default": {
"fg": "#f8f8f2",
"bg": "#191919"
}
},
"command_line": {
"error": {
"fg": "#f92672",
"bg": "#272822"
},
"output_border": {
"fg": "#49483e",
"bg": "#191919"
},
"continue_message": {
"fg": "#66d9ef",
"bg": "#272822"
}
},
"syntax": {
"group": {
"comment": "#75715e",
"function": "#a6e22e",
"keyword": "#f92672",
"number": "#ae81ff",
"string": "#e6db74",
"type": "#66d9ef",
"variable": "#fd971f"
},
"exact": {
"comment.documentation": "#75715e",
"function.call": "#a6e22e",
"keyword.return": "#f92672",
"string.escape": "#ae81ff",
"variable.parameter": "#fd971f"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "nord",
"line": {
"fg": "#d8dee9",
"bg": "#2e3440"
},
"background": {
"bg": "#2e3440"
},
"visual_highlight": {
"bg": "#434c5e"
},
"cursors": {
"normal": {
"fg": "#2e3440",
"bg": "#d8dee9"
},
"insert": {
"fg": "#2e3440",
"bg": "#a3be8c"
},
"command": {
"fg": "#2e3440",
"bg": "#81a1c1"
},
"replace": {
"fg": "#2e3440",
"bg": "#bf616a"
}
},
"gutter": {
"default": {
"fg": "#4c566a",
"bg": "#2e3440"
},
"current_line": {
"fg": "#d8dee9",
"bg": "#3b4252"
}
},
"status_bar": {
"default": {
"fg": "#d8dee9",
"bg": "#242933"
}
},
"command_line": {
"error": {
"fg": "#bf616a",
"bg": "#2e3440"
},
"output_border": {
"fg": "#3b4252",
"bg": "#242933"
},
"continue_message": {
"fg": "#88c0d0",
"bg": "#2e3440"
}
},
"syntax": {
"group": {
"comment": "#4c566a",
"function": "#88c0d0",
"keyword": "#81a1c1",
"number": "#b48ead",
"string": "#a3be8c",
"type": "#8fbcbb",
"variable": "#d8dee9"
},
"exact": {
"comment.documentation": "#616e88",
"function.call": "#88c0d0",
"keyword.return": "#81a1c1",
"string.escape": "#ebcb8b",
"variable.parameter": "#eceff4"
}
}
}

View File

@ -0,0 +1,79 @@
{
"name": "onedark",
"line": {
"fg": "#abb2bf",
"bg": "#282c34"
},
"background": {
"bg": "#282c34"
},
"visual_highlight": {
"bg": "#3e4452"
},
"cursors": {
"normal": {
"fg": "#282c34",
"bg": "#61afef"
},
"insert": {
"fg": "#282c34",
"bg": "#98c379"
},
"command": {
"fg": "#282c34",
"bg": "#c678dd"
},
"replace": {
"fg": "#282c34",
"bg": "#e06c75"
}
},
"gutter": {
"default": {
"fg": "#4b5263",
"bg": "#282c34"
},
"current_line": {
"fg": "#abb2bf",
"bg": "#2c323c"
}
},
"status_bar": {
"default": {
"fg": "#abb2bf",
"bg": "#21252b"
}
},
"command_line": {
"error": {
"fg": "#e06c75",
"bg": "#282c34"
},
"output_border": {
"fg": "#181a1f",
"bg": "#21252b"
},
"continue_message": {
"fg": "#61afef",
"bg": "#282c34"
}
},
"syntax": {
"group": {
"comment": "#5c6370",
"function": "#61afef",
"keyword": "#c678dd",
"number": "#d19a66",
"string": "#98c379",
"type": "#e5c07b",
"variable": "#e06c75"
},
"exact": {
"comment.documentation": "#5c6370",
"function.call": "#61afef",
"keyword.return": "#c678dd",
"string.escape": "#56b6c2",
"variable.parameter": "#d19a66"
}
}
}

Some files were not shown because too many files have changed in this diff Show More