Commutative merging
This is the second part of the tutorial on adding support for a new language in Mergiraf, with the example of C#.
Add commutative parents
You can improve conflict resolution for a language by defining "commutative parents". A node in a syntax tree is a commutative parent when the order of its children is unimportant. This knowledge allows Mergiraf to automatically solve most conflicts involving insertion or deletion of children of such a parent.
Identifying which node types should commutative is easier with some familiarity with the semantics of the language, but there are usual suspects you can consider:
- import statements (such as
importin Java or Go,usein Rust…) - field or method declarations in classes (as in most object-oriented programming languages)
- declarations of sum-types (such as
unionin C or functional programming languages) - dictionary or set objects (such as JSON objects, struct instantiations in C/C++…)
- declarative annotations of various sorts (such as annotation parameters in Java, trait bounds in Rust, tag attributes in XML / HTML…)
For instance, C# has import statements called using declarations and some IDEs seem to allow sorting them alphabetically. This is a good sign that their order is semantically irrelevant, as in many languages, so let's declare that.
First, write a small sample file which contains the syntactic elements you are interested in, such as:
using System;
using System.Collections.Generic;
using System.IO;
namespace HelloWorld {
public class SomeName {
}
}
You can inspect how this file is parsed with, either with the Syntax Tree Playground if the language is supported there, or directly via Mergiraf:
$ cargo parse test_file.cs
which gives:
└compilation_unit
├using_directive
│ ├using
│ ├identifier System
│ └;
├using_directive
│ ├using
│ ├qualified_name
│ │ ├qualifier: qualified_name
│ │ │ ├qualifier: identifier System
│ │ │ ├.
│ │ │ └name: identifier Collections
│ │ ├.
│ │ └name: identifier Generic
│ └;
├using_directive
│ ├using
│ ├qualified_name
│ │ ├qualifier: identifier System
│ │ ├.
│ │ └name: identifier IO
│ └;
└namespace_declaration
├namespace
├name: identifier HelloWorld
└body: declaration_list
├{
├class_declaration
│ ├modifier
│ │ └public
│ ├class
│ ├name: identifier SomeName
│ └body: declaration_list
│ ├{
│ └}
└}
This shows us how our source code is parsed into a tree. We see that the using statements are parsed as using_directive nodes in the tree.
To let Mergiraf reorder using statements to fix conflicts, we declare that their parent is a commutative one, which will by default let them commute with any of their siblings (any other child of their parent in the syntax tree).
In this example, their parent is the root of the tree (with type compilation_unit), which means that we'll allow reordering using statements with other top-level elements, such as the namespace declaration.
We'll see later how to restrict this commutativity by defining children groups.
The commutative parent can be defined in the language profile:
LangProfile {
commutative_parents: vec![
CommutativeParent::without_delimiters("compilation_unit", "\n"),
],
..
},
A commutative parent is not only defined by a type of node, but also:
- the expected separator between its children (here, a newline:
"\n") - any delimiters at the beginning and end of the list of children. Here, there are none, but in many cases, such lists start and end with characters such as
(and)or{and}.
For instance, to declare that a JSON object is a commutative parent, we do so with
CommutativeParent::new("object", "{", ", ", "}")
Note how we use the separator is ", " and not simply ",". The separators and delimiters should come with sensible default whitespace around them. This whitespace is used as last resort, as Mergiraf attempts to imitate the surrounding style by reusing similar whitespace and indentation settings as existing delimiters and separators.
After having added our commutative parent definition, we can compile it again with cargo build. The resulting binary in target/debug/mergiraf will now accept to resolve conflicts like the following one:
using System;
using System.Collections.Generic;
using System;
using System;
using System.IO;
This will be merged to include all three using statements.
When inspecting how a file is parsed with cargo parse test_file.cs, commutative parents are highlighted in the tree, which also helps validate our definitions.
We can add other commutative parent definitions in the language profile. For instance, the declarations in the body of a class (such as field_declaration or method_declaration) can be freely reordered. This can be modeled by marking declaration_list nodes as being commutative parents:
CommutativeParent::new("declaration_list", "{", "\n\n", "}")
and so on. While it is useful to identify as many commutative parent types as possible, not being exhaustive is not a problem as it will only prevent the automated resolution of certain conflicts, but should not otherwise degrade the quality of merges.
Commutative parents via tree-sitter queries
Sometimes, commutative parents can't be defined just by specifying a node type inside which children should commute. It depends from the context whether this particular node should be treated as commutative or not.
For example, Python lists aren't commutative in general (the order matters for iteration,
indexing etc.), but they can be seen as commutative in an __all__ declaration.
We can define a tree-sitter query to select which nodes should be treated as commutative. The tree-sitter playground can be useful to experiment with queries and find one that matches the desired set of nodes. In the example above, the following query selects the relevant lists:
(expression_statement (assignment
left: (identifier) @variable (#eq? @variable "__all__")
right: (list) @commutative
))
Note the use of the @commutative capture to select which node matched by the query should be treated as commutative.
This query can then be used to define a commutative parent as part of the language profile:
CommutativeParent::from_query(
r#"(expression_statement (assignment
left: (identifier) @variable (#eq? @variable "__all__")
right: (list) @commutative)
)"#,
"[", ", ", "]",
)
Add children groups
Within a commutative parent, it is possible to restrict which types of children are able to commute together.
In C#, the compilation_unit root node can not only contain using statements, but also some global statements, namespace declarations and type declarations, for instance.
We can declare children groups, which are sets of node types which are allowed to commute together.
By declaring a children group which contains only using_directive, we make sure that using directive can only be reordered with other using directives:
CommutativeParent::new("declaration_list", "{", "\n\n", "}")
.restricted_to_groups(&[
&["using_directive"]
])
As soon as a children group is declared, that restricts the commutativity of all children of the commutative parent. Conflicts can only be solved if all children involved are part of the same group. So, in this case it's also worth adding other children groups for other types of nodes which can be reordered together:
CommutativeParent::new("declaration_list", "{", "\n\n", "}")
.restricted_to_groups(&[
&["using_directive"],
&["namespace_declaration"],
&["global_attribute"],
])
It is also possible to specify a different separator to be used when joining children of a specific group.
For instance, if we want nodes of using_directive type to be separated by one newline instead of two for the other types of nodes, we can specify it as follows:
CommutativeParent::new("declaration_list", "{", "\n\n", "}")
.restricted_to(vec![
ChildrenGroup::with_separator(&["using_directive"], "\n"),
ChildrenGroup::new(&["namespace_declaration"]),
ChildrenGroup::new(&["global_attribute"]),
]),
Add signatures
One piece of knowledge we have not encoded yet is the fact that using statements should be unique: there is no point in importing the same thing twice. This is specified using so-called signatures, which associate keys to the children of commutative parents. Those keys are then required to be unique among the children of a particular commutative parent. This mechanism can be used to define such keys for a lot of other elements. For instance, class fields are keyed by their name only, given that field names should be unique in a given class, regardless of their type. Keys can also be generated for methods, which not only includes their name but also the types of the arguments the function takes, as C# supports method overloading.
To define a signature, we need to provide two pieces of information:
- the type of node we want to generate a key for, such as
field_declaration - a list of paths from the element being keyed to the descendant(s) making up its key. A path is itself a list of steps leading from the element to the descendant.
For instance:
signature("using_directive", vec![vec![]]),
signature("field_declaration", vec![vec![Field("name")]]),
signature("method_declaration", vec![
vec![Field("name")],
vec![Field("parameters"), ChildType("parameter"), Field("type")]
]),
Let's unpack what this all means.
- for
using_directive, we supply a list containing a single path: the empty one. The empty path goes from the element being keyed to itself. - for
field_declaration, we supply again a list containing a single path, which has one step. This step fetches thenamefield of the element being keyed. - for
method_declaration, we have this time a list of two paths. The first one fetches the name, the second selects the types of the parameters in three steps.
This gives rise to the following signatures:
| Element type | Example | Signature |
|---|---|---|
using_directive | using System.IO; | [[using System.IO]] |
field_declaration | public int familySize; | [[familySize]] |
method_declaration | void Run(int times, bool fast) { } | [[Run], [int, bool]] |
Again, this can be checked with cargo parse test_file.cs, which shows the computed signatures in the tree.
To understand the difference between Field and ChildType in the signature definition for method_declaration, consider the structure of a method declaration as parsed by tree-sitter:
└method_declaration
├type: predefined_type void
├name: identifier Run
├parameters: parameter_list
│ ├(
│ ├parameter
│ │ ├type: predefined_type int
│ │ └name: identifier times
│ ├,
│ ├parameter
│ │ ├type: predefined_type bool
│ │ └name: identifier fast
│ └)
└body: block
├{
└}
Notice that some nodes have two labels attached to them:
- the field name, such as
name, indicating which field of its parent node it belongs to. It is optional: some nodes likeparameterones are not associated to any field. - the kind, such as
identifier, which is the type of AST node. Every node has one (for separators or keywords, the source text is the kind)
In general, when descending into a single predetermined child of a given node, one should use a Field. If the number of children is variable then we expect to select them by kind using ChildKind.
The grammar of a tree-sitter parser is defined in a grammar.js file and reading it directly can be useful, for instance to understand what are the possible children or parent of a given type of node. Note that node types starting with _ are private, meaning that they are not exposed to Mergiraf. In case of doubt, just parse some small example to check.