RPA accessibility tree selectors: the actual grammar, with operator precedence
Most RPA tools serialize selectors as XML and pin them to a specific path through the accessibility tree. Terminator's selector engine is a tiny expression language: 16 prefix forms, three boolean operators with explicit precedence, one descendant chain that splits before the boolean parser runs, and one parent walk. This guide is the grammar, the precedence rules, and the parser entry points, with line numbers from the source.
Direct answer (verified 2026-05-07)
A selector for the accessibility tree is a prefix:value pair, optionally combined with boolean operators and a descendant chain. The full grammar is:
- Prefixes (16):
role:name:text:id:nativeid:classname:process:attr:k=vvisible:nth:Nhas:rightof:leftof:above:below:near: - Boolean ops:
&&AND,||or,OR,!NOT - Structural ops:
>>descendant chain,..parent - Precedence: NOT (3) > AND (2) > OR (1). The descendant chain
>>is split before boolean parsing, so it always binds looser than the boolean ops inside one chain segment.
Source: crates/terminator/src/selector.rs. AST at lines 5 to 56, precedence at lines 206 to 213, parser entry at line 474.
What "RPA selector" usually means, and what we mean
When a UiPath developer says selector, they mean the multi-line XML blob the recorder generates: a chain of <wnd>, <ctrl>, and <ui-element> nodes with attributes like aaname, ctrlname, cls, and idx. Power Automate Desktop and Automation Anywhere generate similar structures with their own schemas. They all read from the same Windows UI Automation tree underneath, but they store the path to a control as a long XML document that breaks when the app reflows.
Terminator does not generate selectors for you. You write them, by hand, against the same UI Automation tree (Inspect.exe and Accessibility Insights show you what is in there). What you write is short and structural: one line, prefix:value form, boolean operators where you need them, a descendant chain when you want to scope to a subtree. The selector is data, not code: you can build one at runtime from agent input or workflow parameters, parse it, and pass the AST to the locator engine. The implementation is one Rust file: lines 5 to 56 are the AST, lines 94 to 532 are the parser, and lines 533 onward are tests.
The full grammar, in one table
Every form below is parsed by parse_atomic_selector at selector.rs lines 335 to 472. Substring matching is the default; wildcards (*, ?) are not supported.
| Prefix | Example | What it matches |
|---|---|---|
role: | role:Button | Accessibility role (Button, Edit, MenuItem, Window, ...) |
name: | name:Save | Accessible name / label, case insensitive substring |
text: | text:Open | Visible text content, case sensitive substring |
id: | id:submit | Accessibility / AutomationId (use sparingly: often non-deterministic) |
nativeid: | nativeid:42 | OS-specific automation id (UIA AutomationId on Windows) |
classname: | classname:Edit | UI class name |
process: | process:chrome | Scope chain to a process (notepad, chrome, EXCEL, ...) |
attr: | attr:HelpText=Search | Arbitrary key=value attribute (or just key=true) |
visible: | visible:true | Filter by on-screen visibility |
nth: | nth:0 | Pick the Nth match (0-based) |
has: | has:role:Edit | Element has at least one descendant matching the inner selector |
rightof: | rightof:name:DOB | Element to the right of an anchor (also leftof:, above:, below:) |
near: | near:name:Email | Element within a small radius of an anchor |
&& | role:Button && name:Save | Logical AND, precedence 2 |
|| or , | name:Save, name:Submit | Logical OR, precedence 1 |
! | role:Button && !name:Cancel | Logical NOT, precedence 3 (binds tightest) |
>> | process:notepad >> role:Edit | Descendant chain. Split BEFORE boolean parsing |
.. | role:Button && name:OK >> .. | Walk up to parent element |
The AST: 25 variants in one Rust enum
Every parsed selector is an instance of this enum. The locator engine pattern-matches on it to dispatch to the right UI Automation call. There is nothing else: no hidden config, no string-based fallback inside the engine, no XPath evaluator.
// crates/terminator/src/selector.rs lines 5 to 56
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Selector {
Role { role: String, name: Option<String> },
Id(String),
Name(String),
Text(String),
Path(String),
NativeId(String),
Attributes(BTreeMap<String, String>),
Filter(usize),
Chain(Vec<Selector>),
ClassName(String),
Visible(bool),
LocalizedRole(String),
Process(String),
RightOf(Box<Selector>),
LeftOf(Box<Selector>),
Above(Box<Selector>),
Below(Box<Selector>),
Near(Box<Selector>),
Nth(i32),
Has(Box<Selector>),
Parent,
And(Vec<Selector>),
Or(Vec<Selector>),
Not(Box<Selector>),
Invalid(String),
}Most variants take a value or a sub-selector. Five take another full Selector inside a Box: the four positional anchors and Has. Three take a Vec<Selector>: Chain, And, Or. The other 17 are leaves. Boolean parsers flatten nested And and Or on the way up (lines 290 to 304, 314 to 327), so a chain of three ANDs is one And(Vec[a, b, c]) rather than a deep tree.
Operator precedence is explicit, not whatever the parser felt like
One of the things that makes recorded RPA selectors fragile is that the precedence rules of the underlying matcher are usually undocumented and sometimes inconsistent. Terminator pins precedence in code:
// crates/terminator/src/selector.rs lines 206 to 213
fn operator_precedence(token: &Token) -> i32 {
match token {
Token::Or => 1,
Token::And => 2,
Token::Not => 3,
_ => 0,
}
}The shunting-yard loop at lines 216 to 272 reads tokens, pushes operators with this precedence, pops higher-precedence ones to the output queue, and at the end builds the AST. So the rule of thumb is the same as in any C-family language: !a && b || c is ((!a) && b) || c. Wrap with parentheses any time you want a different grouping. The tokenizer at lines 107 to 120 understands parens; the parser at lines 226 to 252 handles them via the operator stack.
The chain >> is split before boolean parsing
This is the part that surprises people. The chain operator >> does not participate in shunting yard. It is split out first, in From<&str>:
// crates/terminator/src/selector.rs lines 478 to 501
impl From<&str> for Selector {
fn from(s: &str) -> Self {
let s = s.trim();
// Handle chained selectors first (>> has highest priority)
if s.contains(">>") {
let parts: Vec<&str> = s.split(">>").map(|p| p.trim()).collect();
if parts.len() > 1 {
let cleaned_parts: Vec<Selector> = parts
.into_iter()
.map(|part| {
let trimmed = part.trim();
if trimmed.starts_with('(') && trimmed.ends_with(')') {
let inner = &trimmed[1..trimmed.len() - 1];
if !has_unbalanced_parens(inner) {
return Selector::from(inner);
}
}
Selector::from(trimmed)
})
.collect();
return Selector::Chain(cleaned_parts);
}
}
// ... boolean ops parsed here, AFTER chain split
}
}The practical consequence: inside one chain segment, you can use &&, ||, and ! freely and they describe one element. Across chain segments, the boolean ops do not cross. So role:Window && name:Calc >> role:Button && name:Seven parses as find a Window that's also named Calc, then under it find a Button that's also named Seven. The && on each side is local to its own element. That's not how shunting-yard would have it if you fed the whole thing in. Splitting first preserves the developer's left-to-right reading.
The exception is when you wrap a chain step in parentheses. Lines 482 to 497 unwrap balanced outer parens before parsing each step, so (role:Window && name:Calc) >> nativeid:btnSeven is identical to the previous example with one less Button. Use parens for clarity, not for behavior.
The parse pipeline, end to end
Here is what happens when you call desktop.locator("process:notepad >> role:Edit && name:Body") on the selector string. Each arrow is a real function call inside selector.rs.
Parsing process:notepad >> role:Edit && name:Body
The tokenizer never sees >>. The shunting-yard parser never sees a chain. Each layer does one job and hands a smaller problem to the next. That is also why the parser is fast in practice: most selectors are one or two chain segments long, and inside a segment the token count rarely exceeds five or six.
“Selector AST variants in selector.rs lines 5 to 56. Sixteen prefix forms map to leaves, three boolean ops to And/Or/Not, two structural ops to Chain/Parent, and four positional anchors to RightOf/LeftOf/Above/Below plus Near. The 25th is Invalid, used for parser errors.”
crates/terminator/src/selector.rs
A working example, in three SDKs
The same selector grammar works from Rust, Python, and the Node.js bindings, because the parser is in the core crate and every binding routes through it. The locator API surface is also identical: build a locator from a selector, call .first(timeout) or .all(timeout), then act on the element.
// Node.js, identical shape in Python and Rust
const { Desktop } = require('@mediar-ai/terminator');
const desktop = new Desktop();
// 1. Scope every chain to a process. Without this, your selector
// can match the same role+name in any other running app.
const editor = desktop.locator(
'process:notepad >> role:Document >> role:Edit'
);
// 2. Boolean ops bind tighter than chain. Parens force the order.
const saveBtn = desktop.locator(
'(role:Button && name:Save) || (role:MenuItem && name:Save)'
);
// 3. has: takes another full selector as its argument.
const formWithEmail = desktop.locator(
'role:Group && has:name:Email'
);
// 4. Positional anchors: get the field rightof: a label.
const dobField = desktop.locator(
'role:Edit && rightof:name:Date Of Birth'
);
// .first() requires a timeout in ms. There is no default.
const el = await editor.first(5000);
await el.typeText('Hello, accessibility tree.');Coming from UiPath: the mechanical mapping
The UiPath selector format is XML; Terminator's is a single-line expression. They describe the same path through the same UI Automation tree. The mapping for the leaf node:
aaname='Save'becomesname:Saverole='button'becomesrole:Buttoncls='Edit'becomesclassname:Editctrlname='txtAmount'becomesid:txtAmountornativeid:txtAmountapp:exename='notepad.exe'on the outerwndbecomesprocess:notepad.exeat the start of the chain- Intermediate
<ui-element>nodes become additional>>chain steps in the same order idx='3'becomesnth:2(UiPath idx is 1-based, Terminator nth is 0-based)parentidand other UiPath-internal GUIDs are dropped; they don't exist in the underlying tree
A typical 12-line UiPath selector for a Save button in a custom WPF dialog collapses to roughly: process:MyApp.exe >> role:Window && name:Settings >> role:Button && name:Save. Five elements long, no XML, no recorded path. If the dialog reflows, you change the middle chain step instead of regenerating the whole selector.
Common patterns the grammar makes possible
A few patterns that traditional RPA selectors cannot express directly:
- Field by adjacent label.
role:Edit && rightof:name:Date Of Birth. Works when the Edit field has no AutomationId and no name of its own. The label does the addressing. - Form group containing a known field.
role:Group && has:name:Email && has:role:Button. Useful for scoping into a specific section of a complex form. - Negation against ambiguous matches.
role:Button && !name:Cancel && !name:Help. Picks the action button that isn't one of the standard exits. - Sibling lookup via parent.
name:Submit && role:Button >> .. >> role:Edit. Grab the Edit field that lives under the same parent as the Submit button, no matter where in the parent it sits. - Process-scoped wildcards via OR.
process:chrome >> (role:Tab && (name:GitHub || name:GitLab)). One selector, multiple acceptable matches inside the same browser process.
None of these are special-cased in the engine. They fall out of the grammar. !, ||, has:, and the positional anchors compose because they all return Selector and the parser handles each one the same way.
What to read next, in source
If you want the full picture of how a selector turns into a UI Automation call, the path through the codebase is:
crates/terminator/src/selector.rsfor the AST and parser (this guide).crates/terminator/src/locator.rsfor the high-levelLocatortype that wraps a Selector with a timeout, root scope, and.first()/.all()entry points.crates/terminator/src/platforms/for the platform engines that actually walk the UI Automation tree on Windows. The macOS adapter was deleted on 2025-12-16; if you need the AX path, see the companion guide on the macOS accessibility UI tree.docs/SELECTORS_CHEATSHEET.mdfor an example-led reference if you prefer copy-paste over grammar.
The full grammar is small enough to memorize. Most of what you write in a real automation is two chain steps: process:something >> role:X && name:Y. The boolean ops, positional anchors, and has: are there for the cases where the recorded path is not stable, which on real desktop apps is most of them.
Migrating off UiPath, Power Automate Desktop, or Automation Anywhere?
A 20-minute call to walk through the selector mapping for one of your real workflows. Bring the XML, leave with a one-line selector.
RPA accessibility tree selectors, FAQ
What is an accessibility tree selector and how is it different from a UiPath selector?
An accessibility tree selector picks an element by attributes the OS already publishes for screen readers (role, name, automation id, class name, process, position). A UiPath selector is an XML blob, generated by recording, that pins to a specific path through the same tree plus heuristic attributes like aaname, ctrlname, idx, and parentid. Both end up calling UI Automation on Windows or AX on macOS under the hood; the difference is that UiPath stores a long XML path that breaks when the app's window structure changes, and a Terminator selector is a short structural query against the live tree. The grammar is intentionally Playwright-shaped: prefix:value pairs combined with boolean operators and a descendant chain. Source: crates/terminator/src/selector.rs lines 5 to 56 for the AST, lines 474 to 531 for the parser entry point.
What are all the selector prefixes Terminator supports?
Sixteen prefixes, parsed in selector.rs at lines 362 to 470 of parse_atomic_selector. role: matches accessibility role. name: matches accessible name (case insensitive). text: matches visible text content (case sensitive). id: and the # shortcut match the accessibility/AutomationId. nativeid: matches the OS-level automation id (UIA AutomationId on Windows, AXIdentifier on macOS). classname: matches UI class name. process: and processname: scope to a process. attr: matches a key=value or key=true attribute. visible: filters by on-screen visibility (true or false). nth: or nth= picks the Nth match starting at 0. has: takes an inner selector and keeps elements with at least one descendant matching it (Playwright :has()). rightof:, leftof:, above:, below:, near: are positional anchors that take an inner selector. The bare path / is interpreted as Path (XPath-like). The bare .. is the Parent selector. role:Role|Name is legacy pipe syntax kept for backward compatibility at lines 340 to 359.
How are && (AND), || (OR), and ! (NOT) actually parsed, and what is the precedence?
By a shunting-yard expression parser at selector.rs lines 216 to 272, with operator precedence at lines 206 to 213: NOT = 3, AND = 2, OR = 1. So role:Button && name:Save || name:Submit parses as (role:Button && name:Save) || name:Submit, not role:Button && (name:Save || name:Submit). Comma is an OR alias inside boolean expressions (line 166 of the tokenizer), so name:Save, name:Submit is the same as name:Save || name:Submit. Parentheses override precedence and are tokenized at lines 107 to 120 of tokenize. The tokenizer has one special case at line 103: text: selector values can contain unbalanced parentheses and other operator characters, because they are quoted by the prefix; the parser only treats parens as operators when not currently building a text: selector.
What does the descendant chain >> do, and why does it parse differently from && and ||?
The chain >> separates a sequence of selectors that have to match in order down the accessibility tree, like CSS descendant combinators or Playwright's >> chain operator. Critically, >> is split BEFORE the boolean parser runs, at selector.rs lines 478 to 501. This means process:notepad >> role:Edit && name:Body parses as Chain(process:notepad, And(role:Edit, name:Body)), not as a flat shunting-yard mess. If you want boolean ops to bind across a chain you wrap each chain step in parentheses: (role:Window && name:Calculator) >> role:Button. This precedence is intentional: it matches how a developer reads the selector left to right, where >> is structural and && is local to one element.
What does the parent selector .. do?
It walks up one level in the accessibility tree from the element matched by the previous chain step. So role:Button && name:Submit >> .. matches the parent of the Submit button, useful when the form container has no addressable id and you need to scope a sibling lookup to it. The Parent variant is at selector.rs line 47, and the parser maps the bare token .. to Selector::Parent at line 467. Chain it with another step to get sibling lookups: role:Button && name:Submit >> .. >> role:Edit grabs the Edit field that lives under the same parent as the Submit button.
How does has: work? Is it the same as Playwright's :has() pseudo-class?
Yes, structurally. has: takes an inner selector and matches an element if at least one of its descendants matches that inner selector. The grammar is has:role:Edit (no parentheses required, the rest of the string after has: is the inner selector). Implementation is at selector.rs lines 439 to 442. A common use case is finding the form group that contains a specific field: role:Group && has:role:Edit && has:name:Email matches a Group element that contains both an Edit and a name with Email. has: is the only selector that takes another full selector as its argument, which is why the parser handles it specially before the boolean tokenizer runs.
How do positional selectors (rightof:, leftof:, above:, below:, near:) work in practice?
Each takes an inner selector that identifies an anchor element, and returns elements positioned in that direction relative to the anchor's bounding box. Implementation at selector.rs lines 419 to 438. The classic use case is unlabeled fields: in a legacy line of business app you find a static text label by name and then take the Edit field rightof: it. So role:Edit && rightof:name:Date Of Birth matches the Edit box visually to the right of the Date Of Birth label, even when neither has its own AutomationId. near: is the loosest match (any direction within a small radius); the four cardinal variants are stricter and faster.
Why are #id selectors flagged as non-deterministic in the Terminator docs?
Because the AutomationId for many Windows controls is generated at runtime from the WPF or WinUI element tree's structural position, not from a stable name set by the developer. Reload the app and you can get a new id for the same logical control. The fix is to use nativeid: (which maps to the same field but is named explicitly so a code review will catch its use), or to combine role: + name: + nativeid: with && so the selector still resolves when one of the three changes. The repo's llms.txt documents this rule under Critical Rules for Selectors near line 60.
How do I migrate a UiPath selector to a Terminator selector?
Open the UiPath selector XML and read the rightmost ui element node (the leaf). The aaname attribute becomes name:, the ctrlname becomes id: or nativeid: (try id: first), the role attribute becomes role:, the cls attribute becomes classname:. The intermediate ui element nodes become >> chain steps in the same order. The app:filename or wnd app:exename attribute on the outermost wnd node becomes process:. Drop the idx attribute (Terminator does not have a positional index by default; if you need it, add nth:N at the end of the chain). Strip the parentid GUIDs entirely, they are UiPath internal. Result: a one-line selector that describes the same path through the same UI Automation tree, but readable.
Does this selector grammar work with Microsoft Power Automate Desktop?
No, Power Automate Desktop has its own selector format (also XML, also stored on disk, with a slightly different schema than UiPath's). The Terminator selector grammar is specific to Terminator's locator engine. What is portable is the underlying tree: both Power Automate Desktop and Terminator read the Windows UI Automation tree, so the same role, name, AutomationId, and ClassName values are visible in both tools. To migrate, capture the UI Automation tree once with Inspect.exe or Accessibility Insights, then write the Terminator selector against the structural attributes you see there. The Process column in the inventory also matches: Power Automate Desktop's app process becomes Terminator's process: prefix.
What does selector parsing actually look like at runtime, and how slow is it?
Selector::from(&str) is the entry point at line 474. First it splits on >> to get chain segments (lines 478 to 501). Then for each segment it checks for boolean operators at lines 503 to 509. If there are none, it falls through to parse_atomic_selector, which is a sequence of starts_with checks against known prefixes (lines 362 to 470). If there are boolean ops, it tokenizes (lines 94 to 203) into Selector, And, Or, Not, LParen, RParen tokens, then runs shunting-yard to build the final Selector AST. Cost is roughly linear in the length of the selector string, in microseconds; the engine is designed so you can build selectors at runtime from agent-generated arguments without worrying about the parse step. The expensive part is matching, not parsing.
How do I scope every selector to one app so my automation stops randomly clicking the wrong window?
Always start the chain with process: or processname:. So instead of role:Button && name:Save (which can match a Save button in any of the dozens of running apps), use process:notepad >> role:Document >> role:Edit. Terminator's docs flag this as a critical rule at the CRITICAL line in llms.txt, and Inspect.exe captures the same path. The Process selector at selector.rs lines 30 to 31 and parse rule at lines 395 to 403 take a name like notepad, notepad.exe, or chrome. You can also use window: as a stricter filter when an app has multiple top-level windows and you want only one. Without one of these scoping prefixes, every action is a coin flip across the whole desktop tree.