#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CaptureSite {
pub name: String,
pub field: Option<String>,
}
#[must_use]
pub fn capture_sites(query: &str) -> Vec<CaptureSite> {
let mut scanner = Scanner {
text: query.as_bytes(),
pos: 0,
sites: Vec::new(),
};
scanner.sequence(None, None, 0);
scanner.sites
}
struct Scanner<'a> {
text: &'a [u8],
pos: usize,
sites: Vec<CaptureSite>,
}
const fn is_ident(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte >= 0x80 || matches!(byte, b'_' | b'-' | b'.' | b'/')
}
const MAX_DEPTH: usize = 512;
impl Scanner<'_> {
fn peek(&self) -> Option<u8> {
self.text.get(self.pos).copied()
}
fn sequence(&mut self, close: Option<u8>, inherited: Option<&str>, depth: usize) {
let mut pending_field: Option<String> = None;
loop {
self.skip_trivia();
let Some(byte) = self.peek() else {
return;
};
match byte {
b')' | b']' => {
self.pos += 1;
if Some(byte) == close {
return;
}
}
b'(' | b'[' if depth >= MAX_DEPTH => self.pos += 1,
b'(' => {
self.pos += 1;
self.skip_trivia();
if matches!(self.peek(), Some(b'#' | b'.')) {
self.skip_predicate();
continue;
}
self.ident();
self.sequence(Some(b')'), None, depth + 1);
let field = Self::slot(&mut pending_field, inherited);
self.trailing(field.as_deref());
}
b'[' => {
self.pos += 1;
let field = Self::slot(&mut pending_field, inherited);
self.sequence(Some(b']'), field.as_deref(), depth + 1);
self.trailing(field.as_deref());
}
b'"' => {
self.skip_string();
let field = Self::slot(&mut pending_field, inherited);
self.trailing(field.as_deref());
}
b'.' | b'*' | b'+' | b'?' => self.pos += 1,
b'!' => {
self.pos += 1;
self.ident();
pending_field = None;
}
b'@' => {
self.pos += 1;
let name = self.ident();
self.push(name, None);
}
_ if is_ident(byte) => {
let ident = self.ident();
if self.peek() == Some(b':') {
self.pos += 1;
pending_field = Some(ident);
} else {
let field = Self::slot(&mut pending_field, inherited);
self.trailing(field.as_deref());
}
}
_ => self.pos += 1,
}
}
}
fn slot(pending: &mut Option<String>, inherited: Option<&str>) -> Option<String> {
pending.take().or_else(|| inherited.map(str::to_owned))
}
fn trailing(&mut self, field: Option<&str>) {
loop {
self.skip_trivia();
match self.peek() {
Some(b'@') => {
self.pos += 1;
let name = self.ident();
self.push(name, field.map(str::to_owned));
}
Some(b'*' | b'+' | b'?') => self.pos += 1,
_ => return,
}
}
}
fn push(&mut self, name: String, field: Option<String>) {
if !name.is_empty() {
self.sites.push(CaptureSite { name, field });
}
}
fn ident(&mut self) -> String {
let start = self.pos;
while self.peek().is_some_and(is_ident) {
self.pos += 1;
}
String::from_utf8_lossy(&self.text[start..self.pos]).into_owned()
}
fn skip_trivia(&mut self) {
while let Some(byte) = self.peek() {
match byte {
b';' => {
while self.peek().is_some_and(|b| b != b'\n') {
self.pos += 1;
}
}
_ if byte.is_ascii_whitespace() => self.pos += 1,
_ => return,
}
}
}
fn skip_string(&mut self) {
self.pos += 1;
while let Some(byte) = self.peek() {
self.pos += 1;
match byte {
b'\\' => self.pos += 1,
b'"' => return,
_ => {}
}
}
}
fn skip_predicate(&mut self) {
let mut depth = 1_usize;
while let Some(byte) = self.peek() {
match byte {
b'"' => self.skip_string(),
b'(' => {
depth += 1;
self.pos += 1;
}
b')' => {
depth -= 1;
self.pos += 1;
if depth == 0 {
return;
}
}
_ => self.pos += 1,
}
}
}
}
#[cfg(test)]
mod tests {
use super::{CaptureSite, capture_sites};
fn sites(query: &str) -> Vec<(String, Option<String>)> {
capture_sites(query)
.into_iter()
.map(|CaptureSite { name, field }| (name, field))
.collect()
}
fn site(name: &str, field: Option<&str>) -> (String, Option<String>) {
(name.to_owned(), field.map(str::to_owned))
}
#[test]
fn a_capture_on_a_labeled_child_records_the_field() {
assert_eq!(
sites("(call_expression function: (identifier) @s)"),
vec![site("s", Some("function"))]
);
}
#[test]
fn a_capture_on_an_unlabeled_child_has_no_field() {
assert_eq!(
sites("(call_expression (arguments) @a)"),
vec![site("a", None)]
);
}
#[test]
fn a_capture_on_the_whole_pattern_has_no_field() {
assert_eq!(
sites("(call_expression function: (identifier) @fn) @call"),
vec![site("fn", Some("function")), site("call", None)]
);
}
#[test]
fn two_captures_on_one_pattern_share_its_slot() {
assert_eq!(
sites("(a b: (c) @x @y)"),
vec![site("x", Some("b")), site("y", Some("b"))]
);
}
#[test]
fn the_field_reaches_only_the_pattern_that_fills_it() {
assert_eq!(sites("(a b: (c) (d) @x)"), vec![site("x", None)]);
}
#[test]
fn a_capture_nested_inside_a_labeled_child_reports_its_own_slot() {
assert_eq!(sites("(a b: (c (d) @x))"), vec![site("x", None)]);
}
#[test]
fn an_alternation_in_a_slot_carries_the_field() {
assert_eq!(sites("(a b: [(c) (d)] @x)"), vec![site("x", Some("b"))]);
}
#[test]
fn an_alternation_in_a_slot_labels_each_branch() {
assert_eq!(
sites("(a b: [(c) @x (d) @y] @z)"),
vec![
site("x", Some("b")),
site("y", Some("b")),
site("z", Some("b"))
]
);
assert_eq!(sites("(a b: [[(c) @x]])"), vec![site("x", Some("b"))]);
assert_eq!(sites("(a b: [(c (e) @x)])"), vec![site("x", None)]);
}
#[test]
fn a_legacy_dot_predicate_is_not_a_binding_site() {
assert_eq!(
sites(r#"(a b: (c) @x (.eq? @x "y"))"#),
vec![site("x", Some("b"))]
);
}
#[test]
fn a_non_ascii_capture_name_is_read_whole() {
assert_eq!(sites("(a b: (c) @sinké)"), vec![site("sinké", Some("b"))]);
}
#[test]
fn nesting_beyond_the_cap_does_not_recurse() {
let deep = "(".repeat(100_000);
assert_eq!(sites(&deep), Vec::new());
let deep_capture = format!("{}(x) @y{}", "(".repeat(100_000), ")".repeat(100_000));
assert_eq!(sites(&deep_capture), vec![site("y", None)]);
}
#[test]
fn a_quantifier_between_pattern_and_capture_is_transparent() {
assert_eq!(sites("(a b: (c)* @x)"), vec![site("x", Some("b"))]);
assert_eq!(sites("(a b: (c)+ @x)"), vec![site("x", Some("b"))]);
assert_eq!(sites("(a b: (c)? @x)"), vec![site("x", Some("b"))]);
}
#[test]
fn an_anonymous_node_and_a_wildcard_fill_a_slot_too() {
assert_eq!(
sites(r#"(a b: "tok" @x c: _ @y)"#),
vec![site("x", Some("b")), site("y", Some("c"))]
);
}
#[test]
fn a_negated_field_labels_nothing() {
assert_eq!(sites("(a !b (c) @x)"), vec![site("x", None)]);
}
#[test]
fn an_anchor_labels_nothing() {
assert_eq!(sites("(a . (c) @x)"), vec![site("x", None)]);
assert_eq!(sites("(a b: (c) . (d) @x)"), vec![site("x", None)]);
}
#[test]
fn a_predicate_is_not_a_binding_site() {
assert_eq!(
sites(r#"(a b: (c) @x (#eq? @x "y") (#match? @x "\\)"))"#),
vec![site("x", Some("b"))]
);
}
#[test]
fn a_comment_is_skipped() {
assert_eq!(
sites("; (z: (q) @not)\n(a (b) @x) ; @nope\n"),
vec![site("x", None)]
);
}
#[test]
fn a_string_containing_a_paren_does_not_unbalance() {
assert_eq!(sites(r#"(a b: "(" @x)"#), vec![site("x", Some("b"))]);
assert_eq!(
sites(r#"(a b: "\"" @x c: (d) @y)"#),
vec![site("x", Some("b")), site("y", Some("c"))]
);
}
#[test]
fn a_grouped_pattern_records_the_inner_capture() {
assert_eq!(sites(r#"((a) @x (#eq? @x "y"))"#), vec![site("x", None)]);
}
#[test]
fn two_top_level_patterns_are_scanned_in_order() {
assert_eq!(
sites("(a) @x\n(b c: (d) @y)"),
vec![site("x", None), site("y", Some("c"))]
);
}
#[test]
fn a_supertype_pattern_is_one_pattern() {
assert_eq!(sites("(expression/identifier) @x"), vec![site("x", None)]);
assert_eq!(
sites("(a b: (expression/identifier) @x)"),
vec![site("x", Some("b"))]
);
}
#[test]
fn a_capture_name_may_carry_dots() {
assert_eq!(sites("(a) @x.y"), vec![site("x.y", None)]);
}
#[test]
fn malformed_text_still_returns_what_was_found() {
assert_eq!(sites("(a b: (c) @x"), vec![site("x", Some("b"))]);
assert_eq!(sites(") @x"), vec![site("x", None)]);
assert_eq!(sites(""), Vec::new());
}
}