on master help sign in
Add treesitter textobjects (#728)

* Add treesitter textobject queries Only for Go, Python and Rust for now. * Add tree-sitter textobjects Only has functions and class objects as of now. * Fix tests * Add docs for tree-sitter textobjects * Add guide for creating new textobject queries * Add parameter textobject Only parameter.inside is implemented now, parameter.around will probably require custom predicates akin to nvim' `make-range` since we want to select a trailing comma too (a comma will be an anonymous node and matching against them doesn't work similar to named nodes) * Simplify TextObject cell init

commit4ee92cad19cc94f0751f91fa9391d1899353d740 authorGokul Soumya <gokulps15@gmail.com> dateSat 23 Oct 2021, 08:11 (5 years ago) parentc5298ca
11 files changed+219−5
book/src/SUMMARY.md+2 book/src/guides/README.md+4 book/src/guides/textobject.md+30 book/src/usage.md+10−3 helix-core/src/indent.rs+1 helix-core/src/syntax.rs+41−2 helix-core/src/textobject.rs+51 helix-term/src/commands.rs+19 runtime/queries/go/textobjects.scm+21 runtime/queries/python/textobjects.scm+14 runtime/queries/rust/textobjects.scm+26
book/src/SUMMARY.md+2
@@ -8,3 +8,5 @@
88 - [Keymap](./keymap.md)
99 - [Key Remapping](./remapping.md)
1010 - [Hooks](./hooks.md)
11+- [Guides](./guides/README.md)
12+ - [Adding Textobject Queries](./guides/textobject.md)
book/src/guides/README.mdnew file+4
@@ -0,0 +1,4 @@
1+# Guides
2+
3+This section contains guides for adding new language server configurations,
4+tree-sitter grammers, textobject queries, etc.
book/src/guides/textobject.mdnew file+30
@@ -0,0 +1,30 @@
1+# Adding Textobject Queries
2+
3+Textobjects that are language specific ([like functions, classes, etc][textobjects])
4+require an accompanying tree-sitter grammar and a `textobjects.scm` query file
5+to work properly. Tree-sitter allows us to query the source code syntax tree
6+and capture specific parts of it. The queries are written in a lisp dialect.
7+More information on how to write queries can be found in the [official tree-sitter
8+documentation](tree-sitter-queries).
9+
10+Query files should be placed in `runtime/queries/{language}/textobjects.scm`
11+when contributing. Note that to test the query files locally you should put
12+them under your local runtime directory (`~/.config/helix/runtime` on Linux
13+for example).
14+
15+The following [captures][tree-sitter-captures] are recognized:
16+
17+| Capture Name |
18+| --- |
19+| `function.inside` |
20+| `function.around` |
21+| `class.inside` |
22+| `class.around` |
23+| `parameter.inside` |
24+
25+[Example query files][textobject-examples] can be found in the helix GitHub repository.
26+
27+[textobjects]: ../usage.md#textobjects
28+[tree-sitter-queries]: https://tree-sitter.github.io/tree-sitter/using-parsers#query-syntax
29+[tree-sitter-captures]: https://tree-sitter.github.io/tree-sitter/using-parsers#capturing-nodes
30+[textobject-examples]: https://github.com/search?q=repo%3Ahelix-editor%2Fhelix+filename%3Atextobjects.scm&type=Code&ref=advsearch&l=&l=
book/src/usage.md+10−3
@@ -51,9 +51,10 @@ Multiple characters are currently not supported, but planned.
5151
5252## Textobjects
5353
54−Currently supported: `word`, `surround`.
54+Currently supported: `word`, `surround`, `function`, `class`, `parameter`.
5555
5656![textobject-demo](https://user-images.githubusercontent.com/23398472/124231131-81a4bb00-db2d-11eb-9d10-8e577ca7b177.gif)
57+![textobject-treesitter-demo](https://user-images.githubusercontent.com/23398472/132537398-2a2e0a54-582b-44ab-a77f-eb818942203d.gif)
5758
5859- `ma` - Select around the object (`va` in vim, `<alt-a>` in kakoune)
5960- `mi` - Select inside the object (`vi` in vim, `<alt-i>` in kakoune)
@@ -62,5 +63,11 @@ Currently supported: `word`, `surround`.
6263| --- | --- |
6364| `w` | Word |
6465| `(`, `[`, `'`, etc | Specified surround pairs |
65−
66−Textobjects based on treesitter, like `function`, `class`, etc are planned.
66+| `f` | Function |
67+| `c` | Class |
68+| `p` | Parameter |
69+
70+Note: `f`, `c`, etc need a tree-sitter grammar active for the current
71+document and a special tree-sitter query file to work properly. [Only
72+some grammars](https://github.com/search?q=repo%3Ahelix-editor%2Fhelix+filename%3Atextobjects.scm&type=Code&ref=advsearch&l=&l=)
73+currently have the query file implemented. Contributions are welcome !
helix-core/src/indent.rs+1
@@ -464,6 +464,7 @@ where
464464 unit: String::from(" "),
465465 }),
466466 indent_query: OnceCell::new(),
467+ textobject_query: OnceCell::new(),
467468 }],
468469 });
469470
helix-core/src/syntax.rs+41−2
@@ -49,7 +49,7 @@ pub struct Configuration {
4949#[serde(rename_all = "kebab-case")]
5050pub struct LanguageConfiguration {
5151 #[serde(rename = "name")]
52− pub(crate) language_id: String,
52+ pub language_id: String,
5353 pub scope: String, // source.rust
5454 pub file_types: Vec<String>, // filename ends_with? <Gemfile, rb, etc>
5555 pub roots: Vec<String>, // these indicate project roots <.git, Cargo.toml>
@@ -76,6 +76,8 @@ pub struct LanguageConfiguration {
7676
7777 #[serde(skip)]
7878 pub(crate) indent_query: OnceCell<Option<IndentQuery>>,
79+ #[serde(skip)]
80+ pub(crate) textobject_query: OnceCell<Option<TextObjectQuery>>,
7981}
8082
8183#[derive(Debug, Serialize, Deserialize)]
@@ -105,6 +107,32 @@ pub struct IndentQuery {
105107 pub outdent: HashSet<String>,
106108}
107109
110+#[derive(Debug)]
111+pub struct TextObjectQuery {
112+ pub query: Query,
113+}
114+
115+impl TextObjectQuery {
116+ /// Run the query on the given node and return sub nodes which match given
117+ /// capture ("function.inside", "class.around", etc).
118+ pub fn capture_nodes<'a>(
119+ &'a self,
120+ capture_name: &str,
121+ node: Node<'a>,
122+ slice: RopeSlice<'a>,
123+ cursor: &'a mut QueryCursor,
124+ ) -> Option<impl Iterator<Item = Node<'a>>> {
125+ let capture_idx = self.query.capture_index_for_name(capture_name)?;
126+ let captures = cursor.captures(&self.query, node, RopeProvider(slice));
127+
128+ captures
129+ .filter_map(move |(mat, idx)| {
130+ (mat.captures[idx].index == capture_idx).then(|| mat.captures[idx].node)
131+ })
132+ .into()
133+ }
134+}
135+
108136fn load_runtime_file(language: &str, filename: &str) -> Result<String, std::io::Error> {
109137 let path = crate::RUNTIME_DIR
110138 .join("queries")
@@ -153,7 +181,6 @@ impl LanguageConfiguration {
153181 // highlights_query += "\n(ERROR) @error";
154182
155183 let injections_query = read_query(&language, "injections.scm");
156−
157184 let locals_query = read_query(&language, "locals.scm");
158185
159186 if highlights_query.is_empty() {
@@ -203,6 +230,18 @@ impl LanguageConfiguration {
203230 .as_ref()
204231 }
205232
233+ pub fn textobject_query(&self) -> Option<&TextObjectQuery> {
234+ self.textobject_query
235+ .get_or_init(|| -> Option<TextObjectQuery> {
236+ let lang_name = self.language_id.to_ascii_lowercase();
237+ let query_text = read_query(&lang_name, "textobjects.scm");
238+ let lang = self.highlight_config.get()?.as_ref()?.language;
239+ let query = Query::new(lang, &query_text).ok()?;
240+ Some(TextObjectQuery { query })
241+ })
242+ .as_ref()
243+ }
244+
206245 pub fn scope(&self) -> &str {
207246 &self.scope
208247 }
helix-core/src/textobject.rs+51
@@ -1,9 +1,13 @@
1+use std::fmt::Display;
2+
13use ropey::RopeSlice;
4+use tree_sitter::{Node, QueryCursor};
25
36use crate::chars::{categorize_char, char_is_whitespace, CharCategory};
47use crate::graphemes::next_grapheme_boundary;
58use crate::movement::Direction;
69use crate::surround;
10+use crate::syntax::LanguageConfiguration;
711use crate::Range;
812
913fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction) -> usize {
@@ -51,6 +55,15 @@ pub enum TextObject {
5155 Inside,
5256}
5357
58+impl Display for TextObject {
59+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60+ f.write_str(match self {
61+ Self::Around => "around",
62+ Self::Inside => "inside",
63+ })
64+ }
65+}
66+
5467// count doesn't do anything yet
5568pub fn textobject_word(
5669 slice: RopeSlice,
@@ -108,6 +121,44 @@ pub fn textobject_surround(
108121 .unwrap_or(range)
109122}
110123
124+/// Transform the given range to select text objects based on tree-sitter.
125+/// `object_name` is a query capture base name like "function", "class", etc.
126+/// `slice_tree` is the tree-sitter node corresponding to given text slice.
127+pub fn textobject_treesitter(
128+ slice: RopeSlice,
129+ range: Range,
130+ textobject: TextObject,
131+ object_name: &str,
132+ slice_tree: Node,
133+ lang_config: &LanguageConfiguration,
134+ _count: usize,
135+) -> Range {
136+ let get_range = move || -> Option<Range> {
137+ let byte_pos = slice.char_to_byte(range.cursor(slice));
138+
139+ let capture_name = format!("{}.{}", object_name, textobject); // eg. function.inner
140+ let mut cursor = QueryCursor::new();
141+ let node = lang_config
142+ .textobject_query()?
143+ .capture_nodes(&capture_name, slice_tree, slice, &mut cursor)?
144+ .filter(|node| node.byte_range().contains(&byte_pos))
145+ .min_by_key(|node| node.byte_range().len())?;
146+
147+ let len = slice.len_bytes();
148+ let start_byte = node.start_byte();
149+ let end_byte = node.end_byte();
150+ if start_byte >= len || end_byte >= len {
151+ return None;
152+ }
153+
154+ let start_char = slice.byte_to_char(start_byte);
155+ let end_char = slice.byte_to_char(end_byte);
156+
157+ Some(Range::new(start_char, end_char))
158+ };
159+ get_range().unwrap_or(range)
160+}
161+
111162#[cfg(test)]
112163mod test {
113164 use super::TextObject::*;
helix-term/src/commands.rs+19
@@ -4465,9 +4465,28 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
44654465 let (view, doc) = current!(cx.editor);
44664466 let text = doc.text().slice(..);
44674467
4468+ let textobject_treesitter = |obj_name: &str, range: Range| -> Range {
4469+ let (lang_config, syntax) = match doc.language_config().zip(doc.syntax()) {
4470+ Some(t) => t,
4471+ None => return range,
4472+ };
4473+ textobject::textobject_treesitter(
4474+ text,
4475+ range,
4476+ objtype,
4477+ obj_name,
4478+ syntax.tree().root_node(),
4479+ lang_config,
4480+ count,
4481+ )
4482+ };
4483+
44684484 let selection = doc.selection(view.id).clone().transform(|range| {
44694485 match ch {
44704486 'w' => textobject::textobject_word(text, range, objtype, count),
4487+ 'c' => textobject_treesitter("class", range),
4488+ 'f' => textobject_treesitter("function", range),
4489+ 'p' => textobject_treesitter("parameter", range),
44714490 // TODO: cancel new ranges if inconsistent surround matches across lines
44724491 ch if !ch.is_ascii_alphanumeric() => {
44734492 textobject::textobject_surround(text, range, objtype, ch, count)
runtime/queries/go/textobjects.scmnew file+21
@@ -0,0 +1,21 @@
1+(function_declaration
2+ body: (block)? @function.inside) @function.around
3+
4+(func_literal
5+ (_)? @function.inside) @function.around
6+
7+(method_declaration
8+ body: (block)? @function.inside) @function.around
9+
10+;; struct and interface declaration as class textobject?
11+(type_declaration
12+ (type_spec (type_identifier) (struct_type (field_declaration_list (_)?) @class.inside))) @class.around
13+
14+(type_declaration
15+ (type_spec (type_identifier) (interface_type (method_spec_list (_)?) @class.inside))) @class.around
16+
17+(parameter_list
18+ (_) @parameter.inside)
19+
20+(argument_list
21+ (_) @parameter.inside)
runtime/queries/python/textobjects.scmnew file+14
@@ -0,0 +1,14 @@
1+(function_definition
2+ body: (block)? @function.inside) @function.around
3+
4+(class_definition
5+ body: (block)? @class.inside) @class.around
6+
7+(parameters
8+ (_) @parameter.inside)
9+
10+(lambda_parameters
11+ (_) @parameter.inside)
12+
13+(argument_list
14+ (_) @parameter.inside)
runtime/queries/rust/textobjects.scmnew file+26
@@ -0,0 +1,26 @@
1+(function_item
2+ body: (_) @function.inside) @function.around
3+
4+(struct_item
5+ body: (_) @class.inside) @class.around
6+
7+(enum_item
8+ body: (_) @class.inside) @class.around
9+
10+(union_item
11+ body: (_) @class.inside) @class.around
12+
13+(trait_item
14+ body: (_) @class.inside) @class.around
15+
16+(impl_item
17+ body: (_) @class.inside) @class.around
18+
19+(parameters
20+ (_) @parameter.inside)
21+
22+(closure_parameters
23+ (_) @parameter.inside)
24+
25+(arguments
26+ (_) @parameter.inside)