Skip to content

Commit 4c4d5c5

Browse files
committed
fix efficiency bug
Due to a type mismatch, the code that checked which extmarks changed ended up checking every extmark in the buffer. It now uses nvim_buf_attach to know exactly _what_ position(s) changed. It is now as efficient as was originally intended.
1 parent 885b8df commit 4c4d5c5

File tree

4 files changed

+187
-48
lines changed

4 files changed

+187
-48
lines changed

AGENTS.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# morph.nvim - Agent Development Guide
2+
3+
## Build/Lint/Test Commands
4+
5+
- `make all` - Run lint, format check, and tests
6+
- `make lint` - Typecheck with emmylua_check
7+
- `make fmt` - Format code with stylua
8+
- `make fmt-check` - Check code formatting
9+
- `make test` - Run all tests with coverage (busted)
10+
- `make watch` - Watch for changes and run make automatically
11+
12+
**Single test**: Use busted directly: `busted --verbose --filter='"<FULL TEST NAME HERE>"'`
13+
14+
## Code Style Guidelines
15+
16+
### Formatting
17+
- Use stylua with 2-space indentation, 100 char column width
18+
- Prefer single quotes, auto-prefer single quotes
19+
- No call parentheses for simple statements
20+
- Collapse simple statements always
21+
- Sort requires automatically
22+
23+
### Type Annotations
24+
- Use EmmyLua type annotations (`---@param`, `---@return`, `---@type`)
25+
- Follow patterns in existing code: `morph.Ctx<Props, State>`
26+
- Component functions should annotate props and state types
27+
28+
### Naming Conventions
29+
- Components: PascalCase (e.g., `Counter`, `TodoList`)
30+
- Functions/variables: snake_case
31+
- Constants: UPPER_SNAKE_CASE
32+
- Local variables: concise but descriptive
33+
34+
### Imports
35+
- Use `require 'module'` (single quotes, no parentheses)
36+
- Group imports at top of file
37+
- Use local aliases: `local Morph = require 'morph'`
38+
39+
### Error Handling
40+
- Use pcall for error boundaries in tests
41+
- Return empty string from event handlers to consume keypress
42+
- Validate inputs in component functions
43+
44+
### Component Patterns
45+
- Use context object (`ctx`) for state management
46+
- Initialize state in `ctx.phase == 'mount'` condition
47+
- Use `ctx:update(new_state)` to trigger re-renders (`ctx:refresh()` is short-hand for `ctx:update(ctx.state)`)
48+
- Return arrays/tables of elements, not strings with concatenation

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ fmt:
1515
@stylua .
1616

1717
test:
18+
@echo "## Running tests"
19+
@busted --verbose
20+
21+
test-with-coverage:
1822
@rm -f luacov.*.out
1923
@echo "## Running tests"
2024
@busted --coverage --verbose

lua/morph.lua

Lines changed: 87 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,8 @@ end
297297
local Ctx = {}
298298
Ctx.__index = Ctx
299299

300-
--- @param document? morph.Morph
301300
--- @param bufnr? integer
301+
--- @param document? morph.Morph
302302
--- @param props TProps
303303
--- @param state? TState
304304
--- @param children morph.Tree
@@ -354,6 +354,7 @@ end
354354
--- @field private text_content { old: morph.MorphTextState, curr: morph.MorphTextState }
355355
--- @field private component_tree { old: morph.Tree }
356356
--- @field private cleanup_hooks function[]
357+
--- @field private buf_watcher morph.BufWatcher
357358
local Morph = {}
358359
Morph.__index = Morph
359360

@@ -437,10 +438,7 @@ function Morph.markup_to_lines(opts)
437438
return lines
438439
end
439440

440-
--- @param opts {
441-
--- tree: morph.Tree,
442-
--- format_tag?: fun(tag: morph.Tag): string
443-
--- }
441+
--- @param opts { tree: morph.Tree }
444442
function Morph.markup_to_string(opts) return table.concat(Morph.markup_to_lines(opts), '\n') end
445443

446444
--- @param bufnr integer
@@ -528,22 +526,15 @@ function Morph.new(bufnr)
528526
old = { lines = {}, extmarks = {}, tags_to_extmark_ids = {}, extmark_ids_to_tag = {} },
529527
curr = { lines = {}, extmarks = {}, tags_to_extmark_ids = {}, extmark_ids_to_tag = {} },
530528
},
531-
component_tree = {
532-
old = nil,
533-
curr = nil,
534-
ctx_by_node = {},
535-
},
529+
component_tree = { old = nil },
536530
cleanup_hooks = {},
537531
}, Morph)
538532

539-
local text_changed_id = vim.api.nvim_create_autocmd(
540-
{ 'TextChanged', 'TextChangedI', 'TextChangedP' },
541-
{
542-
buffer = bufnr,
543-
callback = function() self:_on_text_changed() end,
544-
}
533+
self.buf_watcher = H.buf_attach_after_autocmd(
534+
bufnr,
535+
function(...) self:_on_bytes_after_autocmd(...) end
545536
)
546-
table.insert(self.cleanup_hooks, function() vim.api.nvim_del_autocmd(text_changed_id) end)
537+
table.insert(self.cleanup_hooks, self.buf_watcher.cleanup)
547538

548539
local wipeout_id = vim.api.nvim_create_autocmd('BufWipeout', {
549540
buffer = self.bufnr,
@@ -974,22 +965,46 @@ function Morph:_expr_map_callback(mode, lhs)
974965
return keypress_cancel and '' or lhs
975966
end
976967

977-
--- @private
978-
function Morph:_on_text_changed()
968+
function Morph:_on_bytes_after_autocmd(
969+
_,
970+
_,
971+
_,
972+
start_row0,
973+
start_col0,
974+
_,
975+
_,
976+
_,
977+
_,
978+
new_end_row_off,
979+
new_end_col_off,
980+
_
981+
)
982+
if self.changing then return end
983+
984+
local end_row0 = start_row0 + new_end_row_off
985+
local end_col0 = start_col0 + new_end_col_off
986+
987+
local max_end_row0 = vim.api.nvim_buf_line_count(self.bufnr) - 1
988+
if end_row0 > max_end_row0 then end_row0 = max_end_row0 end
989+
990+
local max_end_col0 = #(vim.api.nvim_buf_get_lines(self.bufnr, -2, -1, true)[1] or '')
991+
if end_col0 > max_end_col0 then end_col0 = max_end_col0 end
992+
993+
local live_extmarks = Extmark._get_near_overshoot(
994+
self.bufnr,
995+
self.ns,
996+
Pos00.new(start_row0, start_col0),
997+
Pos00.new(end_row0, end_col0)
998+
)
999+
9791000
--- @type { extmark: morph.Extmark, text: string }[]
9801001
local changed = {}
981-
for _, cached_extmark in ipairs(self.text_content.curr.extmarks) do
982-
local live_extmark = assert(Extmark.by_id(self.bufnr, self.ns, cached_extmark.id))
983-
if live_extmark.start ~= cached_extmark or live_extmark.stop ~= cached_extmark.stop then
984-
-- Just because extmarks have shifted, doesn't mean their text has changed:
985-
-- Lookup the old value vs the new on and check:
986-
local cached_tag = assert(self.text_content.curr.extmark_ids_to_tag[cached_extmark.id])
987-
local curr_text = live_extmark:_text()
988-
989-
if cached_tag.curr_text ~= curr_text then
990-
cached_tag.curr_text = curr_text
991-
table.insert(changed, { extmark = live_extmark, text = curr_text })
992-
end
1002+
for _, live_extmark in ipairs(live_extmarks) do
1003+
local curr_text = live_extmark:_text()
1004+
local cached_tag = self.text_content.curr.extmark_ids_to_tag[live_extmark.id]
1005+
if cached_tag and cached_tag.curr_text ~= curr_text then
1006+
cached_tag.curr_text = curr_text
1007+
table.insert(changed, { extmark = live_extmark, text = curr_text })
9931008
end
9941009
end
9951010

@@ -1113,7 +1128,12 @@ function H.is_textlock()
11131128

11141129
-- Try to change the window: if textlock is active, an error will be raised:
11151130
local tmp_buf = vim.api.nvim_create_buf(false, true)
1116-
local ok, tmp_win = pcall(vim.api.nvim_open_win, tmp_buf, true, {})
1131+
local ok, tmp_win = pcall(
1132+
vim.api.nvim_open_win,
1133+
tmp_buf,
1134+
true,
1135+
{ relative = 'editor', width = 1, height = 1, row = 1, col = 1 }
1136+
)
11171137
if
11181138
not ok
11191139
and type(tmp_win) == 'string'
@@ -1232,6 +1252,41 @@ function H.levenshtein(opts)
12321252
return changes
12331253
end
12341254

1255+
--- @class morph.BufWatcher
1256+
--- @field last_on_bytes_args unknown
1257+
--- @field text_changed_autocmd_id integer
1258+
--- @field fire function
1259+
--- @field cleanup function
1260+
1261+
--- This is a helper that waits to notify about changes until _after_ the
1262+
--- TextChanged{,I,P} autocmd has fired. This is because, at the time
1263+
--- nvim_buf_attach notifies the callback of changes, the buffer seems to be in
1264+
--- a strange state. In my testing I saw extra blank lines during the on_bytes
1265+
--- callback, as opposed to when the autocmd fires.
1266+
---
1267+
--- @param bufnr integer
1268+
--- @param callback function
1269+
--- @return morph.BufWatcher
1270+
function H.buf_attach_after_autocmd(bufnr, callback)
1271+
local state = {}
1272+
1273+
vim.api.nvim_buf_attach(
1274+
bufnr,
1275+
false,
1276+
{ on_bytes = function(...) state.last_on_bytes_args = { ... } end }
1277+
)
1278+
1279+
state.text_changed_autocmd_id = vim.api.nvim_create_autocmd(
1280+
{ 'TextChanged', 'TextChangedI', 'TextChangedP' },
1281+
{ buffer = bufnr, callback = function() state.fire() end }
1282+
)
1283+
1284+
function state.fire() callback(unpack(state.last_on_bytes_args)) end
1285+
function state.cleanup() vim.api.nvim_del_autocmd(state.text_changed_autocmd_id) end
1286+
1287+
return state
1288+
end
1289+
12351290
--------------------------------------------------------------------------------
12361291
-- Exports
12371292
--------------------------------------------------------------------------------

0 commit comments

Comments
 (0)