Skip to content

Commit cf1ce90

Browse files
authored
Merge pull request #155 from FluxxField/feat/surround-textobjects
feat: surround operations, textobject registry, and target expansion
2 parents b9b74f8 + 111167d commit cf1ce90

47 files changed

Lines changed: 5366 additions & 199 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ A motion framework for Neovim. Enable a motion, enable an operator. They compose
2828
yank = true, -- y + any motion
2929
change = true, -- c + any motion
3030
treesitter = true, -- ]], [[, af, if, ac, ic, aa, ia, fn, saa, gS, R
31+
surround = true, -- ds, cs, ys, gza, gzp, visual S + i(/a(/di"/dsq/dst/dsf/etc.
3132
diagnostics = true, -- ]d, [d, ]e, [e
3233
misc = true, -- . g. g1-g9 gp gP gA-gZ gmd gmy (repeat, history, pins, multi-cursor)
3334
},
@@ -63,7 +64,7 @@ presets = {
6364
Enable `words` and `delete` as presets. Now `dw`, `db`, `de`, `dge` all work. Enable `search` and `ds`, `dS`, `df`, `dt` work too. Enable `lines` and you get `dj`, `dk`. Every motion preset **multiplies** with every operator preset:
6465

6566
```
66-
11 composable motions × 5 operators = 55+ compositions
67+
11 composable motions × 5 operators = 55+ compositions (+ surround operators)
6768
from 16 keys, zero mappings defined
6869
```
6970

@@ -92,6 +93,39 @@ yaa yank an argument =if indent a function body
9293
cic change inside a class >ac indent a class
9394
```
9495

96+
### Surround with labeled targets
97+
98+
SmartMotion replaces nvim-surround with something better: **every pair on screen gets a label.**
99+
100+
```
101+
ds( → labels appear on every () pair → pick one → delimiters deleted
102+
cs( → labels appear → pick one → delimiters highlight → type replacement
103+
ysaw( → word labels appear → pick one → word wrapped with ( )
104+
dsq → labels on all quotes ("/'/`) → pick one → quotes deleted
105+
dst → labels on all HTML/XML tags → pick one → tags removed
106+
dsf → labels on all function calls → pick one → unwrapped
107+
cst → labels on tags → pick one → tag name deleted → insert mode with live sync
108+
csf → labels on calls → pick one → name deleted → insert mode at name position
109+
```
110+
111+
`di(`, `da{`, `ci"`, `vi[` all show labels on every matching pair visible. No cursor proximity guessing — you pick exactly which pair you mean. `q` matches any quote type. `t` matches HTML/XML tags. `f` targets function calls for surround operations (while `dif`/`daf` still act on function bodies).
112+
113+
Opening chars add padding (`ysaw(``( word )`), closing chars don't (`ysaw)``(word)`). Visual mode `S` wraps a selection. `gza` is a quick "wrap this word" shortcut.
114+
115+
### Expand your target with `+` / `-`
116+
117+
After picking a target, press `+` to grow the selection forward or `-` to grow backward. Dim hints show you what's expandable:
118+
119+
```
120+
ysaw [word labels] → pick "foo"
121+
"foo" highlights, dim + on next word, dim - on previous
122+
+ → range grows to "foo bar"
123+
+ → range grows to "foo bar baz"
124+
( → wraps as ( foo bar baz )
125+
```
126+
127+
No other motion plugin does this. `BS` undoes the last expansion. Any non-expansion key (like the delimiter) confirms and executes. Zero friction when you don't need it — just type the delimiter immediately.
128+
95129
### Modify the search mid-selection
96130

97131
Other motion plugins lock you in once labels appear. SmartMotion lets you change the search context without cancelling:
@@ -181,7 +215,7 @@ Press an operator, then any motion key. Labels appear, pick a target, action run
181215
| Combo | What it does |
182216
|-------|-------------|
183217
| `dw` | Labels words after cursor → pick one → delete it |
184-
| `ds` | Live search → pick match → delete it |
218+
| `ds` | Live search → pick match → delete it (without surround preset) |
185219
| `df` | 2-char find → delete from cursor to target (inclusive) |
186220
| `dt` | 2-char till → delete from cursor to just before target |
187221
| `dd` | Delete current line |
@@ -287,6 +321,59 @@ Works best with [gitsigns.nvim](https://github.com/lewis6991/gitsigns.nvim). Mul
287321

288322
</details>
289323

324+
<details>
325+
<summary><b>Surround</b>: <code>ds</code> <code>cs</code> <code>ys</code> <code>gza</code> <code>gzp</code> <code>S</code> + pair text objects + <code>q</code> <code>t</code> <code>f</code></summary>
326+
327+
**Pair text objects** (work with ANY operator: `di(`, `ya{`, `ci"`, `>a[`):
328+
329+
| Key | Mode | Description |
330+
|-------|---------|--------------------------------------|
331+
| `i(` | x, o | Inside parentheses |
332+
| `a(` | x, o | Around parentheses |
333+
| `i{` | x, o | Inside braces |
334+
| `a{` | x, o | Around braces |
335+
| `i[` | x, o | Inside brackets |
336+
| `a[` | x, o | Around brackets |
337+
| `i<` | x, o | Inside angle brackets |
338+
| `a<` | x, o | Around angle brackets |
339+
| `i"` | x, o | Inside double quotes |
340+
| `a"` | x, o | Around double quotes |
341+
| `i'` | x, o | Inside single quotes |
342+
| `a'` | x, o | Around single quotes |
343+
| `iq` | x, o | Inside nearest quote (any type) |
344+
| `aq` | x, o | Around nearest quote (any type) |
345+
| `it` | x, o | Inside HTML/XML tag |
346+
| `at` | x, o | Around HTML/XML tag |
347+
348+
Closing chars work too: `di)` = `di(`, `da}` = `da{`, etc.
349+
350+
**Surround operators**:
351+
352+
| Key | Mode | Description |
353+
|-------|------|------------------------------------------------------|
354+
| `ds(` | n | Delete surrounding parens (leaves content) |
355+
| `cs(` | n | Change surrounding parens (prompts for replacement) |
356+
| `dsq` | n | Delete nearest surrounding quote (any type) |
357+
| `csq` | n | Change nearest quote to something else |
358+
| `dst` | n | Delete surrounding HTML/XML tag |
359+
| `cst` | n | Change surrounding tag (in-place, live multi-cursor) |
360+
| `dsf` | n | Unwrap function call: `print(x)``x` |
361+
| `csf` | n | Change function name (in-place insert mode) |
362+
| `ys` | n | Add surround: `ysaw(` = wrap word with parens |
363+
| `gza` | n | Quick surround: word hints → pick → type delimiter |
364+
| `gzp` | n | Paste surround: wrap with previously yanked pair |
365+
| `S` | x | Surround visual selection (type delimiter to wrap) |
366+
367+
**Padding**: opening chars add spaces (`ysaw(``( word )`), closing chars don't (`ysaw)``(word)`). Configure with `surround_pad`.
368+
369+
Unlike native text objects, SmartMotion pair text objects label ALL matching pairs on screen. `di(` shows hints on every `()` pair visible, pick the one you want.
370+
371+
**Special surround types**: `t` prompts for a tag name (`ysiw t` → "Tag: " → type `div``<div>word</div>`). `f` prompts for a function name. `q` matches all three quote types at once. `cst` and `csf` use in-place editing instead of prompts -- see below.
372+
373+
**Target expansion**: After picking a target with `ys` or `gza`, press `+` to grow forward, `-` to grow backward, `BS` to shrink. Wrap multiple words in one go: `ysaw` → pick → `++``(``( three words here )`.
374+
375+
</details>
376+
290377
<details>
291378
<summary><b>Misc</b>: repeat, history, pins, global pins, multi-cursor</summary>
292379

@@ -369,6 +456,7 @@ With all presets enabled, SmartMotion consolidates:
369456

370457
```
371458
flash.nvim → search, treesitter, labels
459+
nvim-surround → ds, cs, ys, visual S + pair text objects + tags + function calls
372460
harpoon → pins (g1-g9, gp) + history (g.)
373461
nvim-treesitter- → af/if/ac/ic/aa/ia text objects
374462
textobjects
@@ -392,6 +480,9 @@ One plugin, one config. Your pins know about your history. Your text objects wor
392480
| Fuzzy search | | | | yes |
393481
| Treesitter navigation | | | yes | yes |
394482
| Treesitter text objects | | | | yes |
483+
| Surround (ds/cs/ys) | | | | yes |
484+
| Pair text objects (di(/a{) | | | | yes |
485+
| Target expansion (+/-) | | | | yes |
395486
| Composable d/y/c/p | | | partial | full |
396487
| Remote operations | | | yes | yes |
397488
| Multi-window | | via plugin | yes | yes |
@@ -436,6 +527,12 @@ SmartMotion wouldn't exist without these plugins. See [Why SmartMotion](https://
436527
max_pins = 9, -- maximum pin slots
437528
search_timeout_ms = 500, -- auto-proceed after typing in search
438529
search_idle_timeout_ms = 2000, -- exit search with no input
530+
surround_pad = "opening", -- "opening", "closing", or false
531+
expansion_keys = { -- keys for target expansion (+ grows, - shrinks)
532+
["+"] = "expand_forward",
533+
["-"] = "expand_backward",
534+
["<BS>"] = "shrink",
535+
},
439536
yank_highlight_duration = 150, -- yank flash duration (ms)
440537
history_max_age_days = 30, -- prune history entries older than this
441538
selection_keys = { -- key-action map during label selection

ROADMAP.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,15 @@
6565
- Jump to Git Conflicts.
6666
- Search & Replace with Hints.
6767
- History review with labels.
68+
69+
---
70+
71+
## Phase 7: Surround & Text Objects (v1.3.0+)
72+
73+
### Completed
74+
- ✅ Textobject registry (separate from composable motions, same key can exist in both)
75+
- ✅ Pair text objects (`i`/`a` + delimiter: parentheses, brackets, braces, quotes, tags)
76+
- ✅ Surround operators (`ds`, `cs`, `ys` — delete, change, add surrounding pairs)
77+
- ✅ Standalone surround (`gza` add surround, `gzp` paste surround, visual `S`)
78+
- ✅ Pattern-based pair detection fallback (works without treesitter)
79+
- ✅ Configurable padding (`surround_pad`)

docs/API-Reference.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Complete reference for SmartMotion modules and data structures.
77
## Table of Contents
88

99
- [Motion Registration](#motion-registration)
10+
- [Textobject Registration](#textobject-registration)
1011
- [Built-in Modules](#built-in-modules)
1112
- [Motion State](#motion-state)
1213
- [Context Object](#context-object)
@@ -113,6 +114,124 @@ require("smart-motion").map_motion("w")
113114

114115
---
115116

117+
## Textobject Registration
118+
119+
Text objects (`i`/`a` prefix) live in a separate registry from composable motions. The same key can exist in both registries (`f` composable = 2-char find, `f` textobject = function) because they are consulted in different contexts.
120+
121+
### register
122+
123+
```lua
124+
require("smart-motion").textobjects.register(key, entry)
125+
```
126+
127+
Register a single textobject.
128+
129+
**Parameters:**
130+
131+
| Parameter | Type | Description |
132+
|-----------|------|-------------|
133+
| `key` | string | Textobject key (e.g., `"("`, `"f"`, `"c"`) |
134+
| `entry` | table | Textobject entry |
135+
136+
### register_many
137+
138+
```lua
139+
require("smart-motion").textobjects.register_many(table)
140+
```
141+
142+
Register multiple textobjects at once:
143+
144+
```lua
145+
require("smart-motion").textobjects.register_many({
146+
["("] = { ... },
147+
["["] = { ... },
148+
f = { ... },
149+
})
150+
```
151+
152+
### get
153+
154+
```lua
155+
require("smart-motion").textobjects.get(key)
156+
```
157+
158+
Returns the textobject entry for `key`, or `nil` if not registered.
159+
160+
### has
161+
162+
```lua
163+
require("smart-motion").textobjects.has(key)
164+
```
165+
166+
Returns `true` if a textobject is registered for `key`.
167+
168+
### Entry Fields
169+
170+
| Field | Type | Required | Description |
171+
|-------|------|----------|-------------|
172+
| `collector` | string | Yes | Collector module name |
173+
| `extractor` | string | Yes | Extractor module name |
174+
| `modifier` | string | No | Modifier module name |
175+
| `filter` | string | No | Filter module name |
176+
| `visualizer` | string | No | Visualizer module name |
177+
| `metadata` | table | No | Additional metadata |
178+
| `inside` | table | No | `motion_state` overrides merged when the `i` prefix is used |
179+
| `around` | table | No | `motion_state` overrides merged when the `a` prefix is used |
180+
| `surround` | table | No | `motion_state` overrides merged for surround operators (`ds`, `cs`) |
181+
| `default` | string | No | Which prefix to use when none is specified (`"inside"` or `"around"`) |
182+
183+
The `inside`, `around`, and `surround` fields are plain `motion_state` fragments. When the user types a prefix (`i`, `a`, or a surround operator), the corresponding fragment is merged into `motion_state` before the pipeline runs.
184+
185+
### Prefix Overrides (`_collector_override` / `_extractor_override`)
186+
187+
A textobject's `inside`, `around`, or `surround` fragments can include `_collector_override` and `_extractor_override` fields. When present, these swap the textobject's collector and/or extractor for that prefix only. This allows a single textobject key to use completely different pipeline modules depending on whether it's accessed via `i`/`a` or via surround operators.
188+
189+
**Example:** The `f` textobject uses `treesitter` collector for `dif`/`daf` (function bodies) but swaps to `function_calls` collector for `dsf`/`csf` (function call expressions):
190+
191+
```lua
192+
f = {
193+
collector = "treesitter", -- default: function definitions
194+
extractor = "pass_through",
195+
-- ...
196+
inside = { ts_inner_body = true },
197+
around = {},
198+
surround = {
199+
_collector_override = "function_calls", -- swap collector for surround
200+
_extractor_override = "pairs", -- swap extractor for surround
201+
pair_scope = "surround",
202+
is_surround = true,
203+
},
204+
}
205+
```
206+
207+
This pattern is useful when the same conceptual textobject key (`f` for "function") should behave differently in different contexts.
208+
209+
### Example
210+
211+
```lua
212+
-- Register a custom pair textobject
213+
require("smart-motion").textobjects.register("(", {
214+
collector = "pairs",
215+
extractor = "pairs",
216+
visualizer = "hint_start",
217+
inside = { pair_scope = "inside" },
218+
around = { pair_scope = "around" },
219+
surround = { pair_scope = "surround", is_surround = true },
220+
default = "around",
221+
metadata = {
222+
label = "Parentheses",
223+
open = "(",
224+
close = ")",
225+
},
226+
})
227+
228+
-- Now di( deletes inside parens, da( deletes around parens,
229+
-- ds( deletes the surrounding parens, cs( changes the surrounding parens.
230+
-- All show hint labels so you can pick which pair to act on.
231+
```
232+
233+
---
234+
116235
## Built-in Modules
117236

118237
### Collectors
@@ -121,6 +240,9 @@ require("smart-motion").map_motion("w")
121240
|------|-------------|
122241
| `lines` | All buffer lines |
123242
| `treesitter` | Syntax nodes (see Treesitter modes below) |
243+
| `pairs` | Matching delimiter pairs (treesitter + pattern fallback) |
244+
| `tags` | HTML/XML tag pairs (treesitter + pattern fallback) |
245+
| `function_calls` | Function call expressions (treesitter + pattern fallback) |
124246
| `diagnostics` | LSP diagnostics |
125247
| `git_hunks` | Git changed regions |
126248
| `quickfix` | Quickfix/location list entries |
@@ -145,6 +267,7 @@ require("smart-motion").map_motion("w")
145267
| `text_search_2_char_until` | Two char, exclude target (till) |
146268
| `live_search` | Incremental search |
147269
| `fuzzy_search` | Fuzzy matching |
270+
| `pairs` | Delimiter pair extraction (open/close ranges) |
148271
| `pass_through` | Collector output unchanged |
149272

150273
### Modifiers

0 commit comments

Comments
 (0)