Skip to content

Commit 20471f0

Browse files
authored
Snippet wrap (#94)
* Initial working version * Working draft * Fixes * Allow user snippets * Cleanup * Add docs and make experimental * Switch to tag to enable experimental support * tweak snippet name * Fix docs * Doc fix * More doc * Doc * New snippets; fix watcher * Use `.` instead of `/` for snippet placeholders * Update docs * Fix link in docs * Change tag name * Update docs
1 parent 164632b commit 20471f0

File tree

6 files changed

+246
-31
lines changed

6 files changed

+246
-31
lines changed

docs/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Note: If you'd like to customize any of the spoken forms, please see the [docume
4242
- [Scroll](#scroll)
4343
- [Insert/Use/Repeat](#insertuserepeat)
4444
- [Wrap](#wrap)
45+
- [\[experimental\] Wrap with snippet](#experimental-wrap-with-snippet)
4546
- [Show definition/reference/quick fix](#show-definitionreferencequick-fix)
4647
- [Fold/unfold](#foldunfold)
4748
- [Extract](#extract)
@@ -382,6 +383,10 @@ eg:
382383
`square wrap blue air`
383384
Wraps the token containing letter 'a' with a blue hat in square brackets.
384385

386+
#### \[experimental\] Wrap with snippet
387+
388+
See [experimental documentation](experimental.md#wrapper-snippets).
389+
385390
### Show definition/reference/quick fix
386391

387392
- `"def show"`

docs/experimental.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Experimental features
2+
3+
Here we document features which are currently considered experimental. They are generally functional and well tested, but the API is subject change.
4+
5+
## Wrapper snippets
6+
7+
![Wrapper snippet demo](images/tryWrapFine.gif)
8+
9+
In addition to wrapping with paired delimiters (eg `"square wrap"`, `"round wrap"`, etc), we experimentally support wrapping with snippets. Cursorless ships with a few built-in snippets, but users can also use their own snippets.
10+
11+
### Enabling wrapper snippets
12+
13+
Add the following line to the end of your `settings.talon` (or any other `.talon` file that will be active when vscode is focused):
14+
15+
```
16+
tag(): user.cursorless_experimental_snippets
17+
```
18+
19+
### Using wrapper snippets
20+
21+
#### Command syntax
22+
23+
The command syntax is as follows:
24+
25+
```
26+
"<snippet_name> wrap <target>"
27+
```
28+
29+
#### Examples
30+
31+
- `"try wrap air"`: Wrap the statement containing the marked `a` in a try-catch statement
32+
- `"try wrap air past bat"`: Wrap the sequence of statements from the marked `a` to the marked `b` in a try-catch statement
33+
34+
#### Default scope types
35+
36+
Each snippet wrapper has a default scope type. When you refer to a target, by default it will expand to the given scope type. This way, for example, when you say `"try wrap air"`, it will refer to the statement containing `a` rather than just the token.
37+
38+
### Built-in wrapper snippets
39+
40+
| Default spoken form | Snippet | Default target scope type |
41+
| ------------------- | --------------------------------------------- | ------------------------- |
42+
| `"if wrap"` | If statement | Statement |
43+
| `"else wrap"` | If-else statement; target goes in else branch | Statement |
44+
| `"if else wrap"` | If-else statement; target goes in if branch | Statement |
45+
| `"try wrap"` | Try-catch statement | Statement |
46+
47+
### Customizing spoken forms
48+
49+
As usual, the spoken forms for these wrapper snippets can be [customized by csv](customization.md). The csvs are in the file `cursorless-settings/experimental/wrapper_snippets.csv`.
50+
51+
### Adding your own snippets
52+
53+
To define your own wrapper snippets, proceed as follows:
54+
55+
#### Define snippets in vscode
56+
57+
1. Set the `cursorless.experimental.snippetsDir` setting to a directory in which you'd like to create your snippets.
58+
2. Add snippets to the directory in files ending in `.cursorless-snippets`. See the [documentation](https://github.com/pokey/cursorless-vscode/tree/main/docs/experimental/snippets.md) for the cursorless snippet format.
59+
60+
#### 2. Add snippet to spoken forms csv
61+
62+
For each snippet that you'd like to be able to use as a wrapper snippet, add a line to the `cursorless-settings/experimental/wrapper_snippets.csv` csv overrides file. The first column is the desired spoken form, and the second column is of the form `<name>.<variable>`, where `name` is the name of the snippet (ie the key in your snippet json file), and `variable` is one of the placeholder variables in your snippet where the target should go.
63+
64+
### Customizing built-in snippets
65+
66+
To customize a built-in snippet, just define a custom snippet (as above), but
67+
use the same name as the cursorless core snippet you'd like to change, and give
68+
definitions along with scopes where you'd like your override to be active. Here
69+
is an example:
70+
71+
```json
72+
{
73+
"tryCatchStatement": {
74+
"definitions": [
75+
{
76+
"scope": {
77+
"langIds": [
78+
"typescript",
79+
"typescriptreact",
80+
"javascript",
81+
"javascriptreact"
82+
]
83+
},
84+
"body": [
85+
"try {",
86+
"\t$body",
87+
"} catch (err) {",
88+
"\t$exceptBody",
89+
"}"
90+
]
91+
}
92+
]
93+
}
94+
}
95+
```
96+
97+
The above will change the definition of the try-catch statement in typescript.

docs/images/tryWrapFine.gif

45.4 KB
Loading

src/actions/wrap.py

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,73 @@
1+
from typing import Union
12
from ..paired_delimiter import paired_delimiters_map
2-
from talon import Module
3+
from talon import Module, actions, app, Context
4+
from ..csv_overrides import init_csv_and_watch_changes
35

46

57
mod = Module()
68

9+
mod.tag(
10+
"cursorless_experimental_snippets",
11+
desc="tag for enabling experimental snippet support",
12+
)
713

814
mod.list("cursorless_wrap_action", desc="Cursorless wrap action")
15+
mod.list("cursorless_wrapper_snippet", desc="Cursorless wrapper snippet")
916

17+
experimental_snippets_ctx = Context()
18+
experimental_snippets_ctx.matches = r"""
19+
tag: user.cursorless_experimental_snippets
20+
"""
1021

11-
@mod.capture(rule=("{user.cursorless_paired_delimiter}"))
12-
def cursorless_wrapper(m) -> list[str]:
13-
paired_delimiter_info = paired_delimiters_map[m.cursorless_paired_delimiter]
14-
return [paired_delimiter_info.left, paired_delimiter_info.right]
22+
23+
# NOTE: Please do not change these dicts. Use the CSVs for customization.
24+
# See https://github.com/pokey/cursorless-talon/blob/main/docs/customization.md
25+
wrapper_snippets = {
26+
"else": "ifElseStatement.alternative",
27+
"if else": "ifElseStatement.consequence",
28+
"if": "ifStatement.consequence",
29+
"try": "tryCatchStatement.body",
30+
}
31+
32+
33+
@mod.capture(
34+
rule=(
35+
"({user.cursorless_paired_delimiter} | {user.cursorless_wrapper_snippet}) {user.cursorless_wrap_action}"
36+
)
37+
)
38+
def cursorless_wrapper(m) -> Union[list[str], str]:
39+
try:
40+
paired_delimiter_info = paired_delimiters_map[m.cursorless_paired_delimiter]
41+
return {
42+
"action": "wrapWithPairedDelimiter",
43+
"extra_args": [paired_delimiter_info.left, paired_delimiter_info.right],
44+
}
45+
except AttributeError:
46+
return {
47+
"action": "wrapWithSnippet",
48+
"extra_args": [m.cursorless_wrapper_snippet],
49+
}
50+
51+
52+
@mod.action_class
53+
class Actions:
54+
def cursorless_wrap(cursorless_wrapper: dict, targets: dict):
55+
"""Perform cursorless wrap action"""
56+
actions.user.cursorless_single_target_command_with_arg_list(
57+
cursorless_wrapper["action"], targets, cursorless_wrapper["extra_args"]
58+
)
59+
60+
61+
def on_ready():
62+
init_csv_and_watch_changes(
63+
"experimental/wrapper_snippets",
64+
{
65+
"wrapper_snippet": wrapper_snippets,
66+
},
67+
allow_unknown_values=True,
68+
default_list_name="wrapper_snippet",
69+
ctx=experimental_snippets_ctx,
70+
)
71+
72+
73+
app.register("ready", on_ready)

src/csv_overrides.py

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
from typing import Optional
12
from .conventions import get_cursorless_list_name
23
from talon import Context, Module, actions, fs, app, settings
34
from datetime import datetime
45
from pathlib import Path
56

67

78
mod = Module()
8-
ctx = Context()
99
cursorless_settings_directory = mod.setting(
1010
"cursorless_settings_directory",
1111
type=str,
@@ -17,7 +17,10 @@
1717
def init_csv_and_watch_changes(
1818
filename: str,
1919
default_values: dict[str, dict],
20-
extra_acceptable_values: list[str] = None,
20+
extra_ignored_values: list[str] = None,
21+
allow_unknown_values: bool = False,
22+
default_list_name: Optional[str] = None,
23+
ctx: Context = Context(),
2124
):
2225
"""
2326
Initialize a cursorless settings csv, creating it if necessary, and watch
@@ -37,37 +40,67 @@ def init_csv_and_watch_changes(
3740
`cursorles-settings` dir
3841
default_values (dict[str, dict]): The default values for the lists to
3942
be customized in the given csv
40-
extra_acceptable_values list[str]: Don't throw an exception if any of
41-
these appear as values
43+
extra_ignored_values list[str]: Don't throw an exception if any of
44+
these appear as values; just ignore them and don't add them to any list
45+
allow_unknown_values bool: If unknown values appear, just put them in the list
46+
default_list_name Optional[str]: If unknown values are allowed, put any
47+
unknown values in this list
4248
"""
43-
if extra_acceptable_values is None:
44-
extra_acceptable_values = []
49+
if extra_ignored_values is None:
50+
extra_ignored_values = []
4551

46-
dir_path, file_path = get_file_paths(filename)
52+
file_path = get_full_path(filename)
4753
super_default_values = get_super_values(default_values)
4854

49-
dir_path.mkdir(parents=True, exist_ok=True)
55+
file_path.parent.mkdir(parents=True, exist_ok=True)
5056

5157
def on_watch(path, flags):
5258
if file_path.match(path):
5359
current_values, has_errors = read_file(
54-
file_path, super_default_values.values(), extra_acceptable_values
60+
file_path,
61+
super_default_values.values(),
62+
extra_ignored_values,
63+
allow_unknown_values,
64+
)
65+
update_dicts(
66+
default_values,
67+
current_values,
68+
extra_ignored_values,
69+
allow_unknown_values,
70+
default_list_name,
71+
ctx,
5572
)
56-
update_dicts(default_values, current_values, extra_acceptable_values)
5773

58-
fs.watch(dir_path, on_watch)
74+
fs.watch(file_path.parent, on_watch)
5975

6076
if file_path.is_file():
6177
current_values = update_file(
62-
file_path, super_default_values, extra_acceptable_values
78+
file_path,
79+
super_default_values,
80+
extra_ignored_values,
81+
allow_unknown_values,
82+
)
83+
update_dicts(
84+
default_values,
85+
current_values,
86+
extra_ignored_values,
87+
allow_unknown_values,
88+
default_list_name,
89+
ctx,
6390
)
64-
update_dicts(default_values, current_values, extra_acceptable_values)
6591
else:
6692
create_file(file_path, super_default_values)
67-
update_dicts(default_values, super_default_values, extra_acceptable_values)
93+
update_dicts(
94+
default_values,
95+
super_default_values,
96+
extra_ignored_values,
97+
allow_unknown_values,
98+
default_list_name,
99+
ctx,
100+
)
68101

69102
def unsubscribe():
70-
fs.unwatch(dir_path, on_watch)
103+
fs.unwatch(file_path.parent, on_watch)
71104

72105
return unsubscribe
73106

@@ -79,7 +112,10 @@ def is_removed(value: str):
79112
def update_dicts(
80113
default_values: dict[str, dict],
81114
current_values: dict,
82-
extra_acceptable_values: list[str],
115+
extra_ignored_values: list[str],
116+
allow_unknown_values: bool,
117+
default_list_name: Optional[str],
118+
ctx: Context,
83119
):
84120
# Create map with all default values
85121
results_map = {}
@@ -92,8 +128,14 @@ def update_dicts(
92128
try:
93129
results_map[value]["key"] = key
94130
except KeyError:
95-
if value in extra_acceptable_values:
131+
if value in extra_ignored_values:
96132
pass
133+
elif allow_unknown_values:
134+
results_map[value] = {
135+
"key": key,
136+
"value": value,
137+
"list": default_list_name,
138+
}
97139
else:
98140
raise
99141

@@ -110,9 +152,14 @@ def update_dicts(
110152
ctx.lists[get_cursorless_list_name(list_name)] = dict
111153

112154

113-
def update_file(path: Path, default_values: dict, extra_acceptable_values: list[str]):
155+
def update_file(
156+
path: Path,
157+
default_values: dict,
158+
extra_ignored_values: list[str],
159+
allow_unknown_values: bool,
160+
):
114161
current_values, has_errors = read_file(
115-
path, default_values.values(), extra_acceptable_values
162+
path, default_values.values(), extra_ignored_values, allow_unknown_values
116163
)
117164
current_identifiers = current_values.values()
118165

@@ -178,7 +225,10 @@ def csv_error(path: Path, index: int, message: str, value: str):
178225

179226

180227
def read_file(
181-
path: Path, default_identifiers: list[str], extra_acceptable_values: list[str]
228+
path: Path,
229+
default_identifiers: list[str],
230+
extra_ignored_values: list[str],
231+
allow_unknown_values: bool,
182232
):
183233
with open(path) as f:
184234
lines = list(f)
@@ -210,7 +260,11 @@ def read_file(
210260
seen_header = True
211261
continue
212262

213-
if value not in default_identifiers and value not in extra_acceptable_values:
263+
if (
264+
value not in default_identifiers
265+
and value not in extra_ignored_values
266+
and not allow_unknown_values
267+
):
214268
has_errors = True
215269
csv_error(path, i, "Unknown identifier", value)
216270
continue
@@ -229,17 +283,17 @@ def read_file(
229283
return result, has_errors
230284

231285

232-
def get_file_paths(filename: str):
286+
def get_full_path(filename: str):
233287
if not filename.endswith(".csv"):
234288
filename = f"{filename}.csv"
289+
235290
user_dir = actions.path.talon_user()
236291
settings_directory = Path(cursorless_settings_directory.get())
237292

238293
if not settings_directory.is_absolute():
239294
settings_directory = user_dir / settings_directory
240295

241-
csv_path = Path(settings_directory, filename)
242-
return settings_directory, csv_path
296+
return (settings_directory / filename).resolve()
243297

244298

245299
def get_super_values(values: dict[str, dict]):

0 commit comments

Comments
 (0)