Skip to main content

asc_engine/
condition.rs

1//! The condition language of findrefs rules and callgraph endpoints.
2//!
3//! ```text
4//! rule  := part ( '&' part )*
5//! part  := [ '!' ] KIND ':' value
6//! value := ['=' | '*'] '{' rule '}' calls  method only: calls of the methods whose code matches the rule
7//!        | ['=' | '*'] '@' PATH calls    method only: calls of the methods listed in a file
8//!        | TEXT calls        method: a method target, e.g. Lc;->m(I)V
9//!        | TEXT              the other KINDs' own syntax, e.g. a regex
10//! calls := ( '[' constraint ( ',' constraint )* ']' )*
11//! constraint := ( 'arg' N | 'this' ) ( '=' | '==' ) arg
12//!             | 'ret' ( '=' | '==' ) ( 'used' | 'unused' )
13//! arg   := '*' | INT | 'true' | 'false' | 'null' | '"' TEXT '"' | '~' REGEX
14//!        | 'param' [ K ] | 'class:' TYPE | 'new:' TYPE | 'ret:' METHOD | 'field:' FIELD
15//! ```
16//!
17//! A rule of one positive part is that condition; several parts make a
18//! compound ([`Query::All`]) whose `!` parts are its `none` list. TEXT runs
19//! to the next `&` outside `{}` / `()`, or to the `}` closing the enclosing
20//! body; `\` keeps the next character from ending it (`string:a\&b` is the
21//! regex `a\&b`).
22//!
23//! A `{rule}` or `@PATH` set also takes the calls that may dispatch to its
24//! methods ([`Query::Virtual`]): those naming a method on a supertype of its
25//! class, or on a subclass inheriting it. A supertype method more than
26//! [`DEFAULT_FAN_OUT`](crate::compose::DEFAULT_FAN_OUT) classes define is
27//! not followed (the search notes it); `*` in front follows every one, `=`
28//! keeps only the calls naming the set's methods themselves.
29//!
30//! Constraints keep the calls whose values they accept ([`Query::Call`]):
31//! `argN` is the Nth declared parameter of the called method (from 0),
32//! `this` its receiver, `paramK` the calling method's own `pK` (smali
33//! numbering), `ret:` / `field:` the result of a call / a field read the
34//! target matches. Values are what the call site may hold: `=` keeps a site
35//! the analysis cannot decide (marked `?`), `==` drops it. `*` only shows
36//! the values. A REGEX runs to the next `,` / `]` outside `()` / `{}`, or is
37//! quoted like TEXT (`\"`, `\\` escape).
38
39use crate::names::{exact_method, format_class_name, member_query, parse_kind};
40use crate::{EngineError, Result};
41use asc_findrefs::{CallConstraint, Certainty, Defined, MemberQuery, Query, ResultWant, Slot, Want};
42use std::path::Path;
43
44/// Parse a rule (see the module docs). `@PATH` files are read relative to
45/// `dir` (the current directory when `None`).
46pub fn parse_rule_in(spec: &str, dir: Option<&Path>) -> Result<Query> {
47    let mut p = Parser { src: spec, pos: 0, dir };
48    let q = p.rule(false)?;
49    match p.peek() {
50        None => Ok(q),
51        Some(c) => Err(p.error(&format!("unexpected {c:?}"))),
52    }
53}
54
55/// Parse one condition (a rule of a single part without `!`).
56pub fn parse_condition_in(spec: &str, dir: Option<&Path>) -> Result<Query> {
57    match parse_rule_in(spec, dir)? {
58        Query::All { .. } => Err(EngineError::Query(format!("{spec:?} is several conditions; give one KIND:VALUE here"))),
59        q => Ok(q),
60    }
61}
62
63/// The methods of an `@file`: one per line, blank lines and `#` comments
64/// skipped. A line may be any method target spelling, or a line findrefs or
65/// callgraph printed: its first `Lcls;->name...` token is the method.
66pub fn method_set(text: &str) -> Result<Vec<MemberQuery>> {
67    let mut targets: Vec<&str> = text
68        .lines()
69        .map(str::trim)
70        .filter(|l| !l.is_empty() && !l.starts_with('#'))
71        .map(|l| l.split('|').flat_map(str::split_whitespace).find(|t| t.len() > 2 && t.contains("->")).unwrap_or(l))
72        .collect();
73    targets.sort_unstable();
74    targets.dedup();
75    targets
76        .into_iter()
77        .map(|t| if t.contains(";->") && t.contains('(') { exact_method(t) } else { member_query("method", None, false, Some(t), false) })
78        .collect()
79}
80
81struct Parser<'a> {
82    src: &'a str,
83    pos: usize,
84    dir: Option<&'a Path>,
85}
86
87impl<'a> Parser<'a> {
88    fn rest(&self) -> &str {
89        &self.src[self.pos..]
90    }
91
92    fn skip_ws(&mut self) {
93        self.pos = self.src.len() - self.rest().trim_start().len();
94    }
95
96    /// The next non-blank character, not consumed.
97    fn peek(&mut self) -> Option<char> {
98        self.skip_ws();
99        self.rest().chars().next()
100    }
101
102    fn eat(&mut self, c: char) -> bool {
103        let found = self.peek() == Some(c);
104        if found {
105            self.pos += c.len_utf8();
106        }
107        found
108    }
109
110    fn error(&self, what: &str) -> EngineError {
111        EngineError::Query(format!("bad rule {:?}: {what} at column {}", self.src, self.pos + 1))
112    }
113
114    /// `part ('&' part)*`, up to the end or (`nested`) the closing `}`.
115    fn rule(&mut self, nested: bool) -> Result<Query> {
116        let mut all = Vec::new();
117        let mut none = Vec::new();
118        loop {
119            if self.eat('!') {
120                none.push(self.condition(nested)?);
121            } else {
122                all.push(self.condition(nested)?);
123            }
124            if !self.eat('&') {
125                break;
126            }
127        }
128        match (all.len(), none.is_empty()) {
129            (0, _) => Err(self.error("a rule needs a condition without `!` (`!` only removes methods)")),
130            (1, true) => Ok(all.pop().expect("one condition")),
131            _ => Ok(Query::All { all, none }),
132        }
133    }
134
135    /// `KIND ':' value`.
136    fn condition(&mut self, nested: bool) -> Result<Query> {
137        self.skip_ws();
138        let kind_len = self.rest().find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(self.rest().len());
139        if kind_len == 0 || !self.rest()[kind_len..].starts_with(':') {
140            return Err(self.error("expected KIND:VALUE, KIND one of string, literal, type, method, field, extends, impl"));
141        }
142        let kind = parse_kind(&self.rest()[..kind_len])?;
143        self.pos += kind_len + 1;
144        self.skip_ws();
145        if kind == "method" {
146            return self.method(nested);
147        }
148        // `in:{CLASS-CONDITION}`: the methods of a matching class.
149        if kind == "in" {
150            if !self.eat('{') {
151                return Err(self.error("in: takes a class condition in braces, e.g. in:{extends:android.app.Activity}"));
152            }
153            let inner = self.condition(true)?;
154            if !self.eat('}') {
155                return Err(self.error("expected `}`"));
156            }
157            return Ok(Query::Defined(Defined::In(Box::new(inner))));
158        }
159        let value = self.text(nested)?.trim();
160        Ok(match kind {
161            "string" => Query::String(value.to_string()),
162            "literal" => Query::StringLiteral(value.to_string()),
163            "type" => Query::Type(value.to_string()),
164            "extends" => Query::Extends(crate::names::format_class_name(value)?),
165            "impl" => Query::Implements(crate::names::format_class_name(value)?),
166            "override" => Query::Defined(Defined::Override(member_query("method", None, false, Some(value), false)?)),
167            "annotation" => Query::Defined(Defined::Annotation(crate::defs::annotation_descriptor(value)?)),
168            "flag" => Query::Defined(Defined::Flags(crate::defs::parse_flags(value)?)),
169            _ => Query::Field(member_query("field", None, false, Some(value), false)?),
170        })
171    }
172
173    /// The value of a `method:` condition: `{RULE}`, `@PATH` or a target,
174    /// then its constraints.
175    fn method(&mut self, nested: bool) -> Result<Query> {
176        // `={...}` / `=@PATH`: the set's own methods, without dispatch;
177        // `*{...}` / `*@PATH`: dispatch through every supertype.
178        let mode = ["={", "=@", "*{", "*@"].iter().find(|p| self.rest().starts_with(**p)).map(|p| p.as_bytes()[0]);
179        if let Some(m) = mode {
180            self.eat(char::from(m));
181        }
182        let dispatch = |set| match mode {
183            Some(b'=') => set,
184            Some(_) => Query::Virtual { target: Box::new(set), via: Default::default(), fan_out: None },
185            None => Query::Virtual { target: Box::new(set), via: Default::default(), fan_out: Some(crate::compose::DEFAULT_FAN_OUT) },
186        };
187        let target = if self.eat('{') {
188            let inner = self.rule(true)?;
189            if !self.eat('}') {
190                return Err(self.error("expected `}`"));
191            }
192            dispatch(Query::Nested(Box::new(inner)))
193        } else if self.eat('@') {
194            let path = self.text_until(nested, Stop::Constraint)?.trim();
195            dispatch(self.method_file(path)?)
196        } else {
197            let target = self.text_until(nested, Stop::Constraint)?.trim();
198            Query::Method(member_query("method", None, false, Some(target), false)?)
199        };
200        let mut constraints = Vec::new();
201        while self.eat('[') {
202            loop {
203                constraints.push(self.constraint()?);
204                if self.eat(']') {
205                    break;
206                }
207                if !self.eat(',') {
208                    return Err(self.error("expected `,` or `]` after a constraint"));
209                }
210            }
211        }
212        if !self.rest()[..text_len(self.rest(), nested)].trim().is_empty() {
213            return Err(self.error("unexpected text after the method"));
214        }
215        Ok(if constraints.is_empty() { target } else { Query::Call { target: Box::new(target), constraints } })
216    }
217
218    /// One constraint inside `[...]`.
219    fn constraint(&mut self) -> Result<CallConstraint> {
220        self.skip_ws();
221        let key_len = self.rest().find(|c: char| !c.is_ascii_alphanumeric()).unwrap_or(self.rest().len());
222        let key = &self.rest()[..key_len];
223        let slot = match key {
224            "ret" => None,
225            "this" => Some(Slot::This),
226            _ => match key.strip_prefix("arg").and_then(|n| n.parse().ok()) {
227                Some(n) => Some(Slot::Arg(n)),
228                None => return Err(self.error("expected argN, this or ret in a constraint")),
229            },
230        };
231        self.pos += key_len;
232        let certainty = if self.eat('=') {
233            if self.rest().starts_with('=') {
234                self.pos += 1;
235                Certainty::Must
236            } else {
237                Certainty::May
238            }
239        } else {
240            return Err(self.error("expected `=` or `==` in a constraint"));
241        };
242        self.skip_ws();
243        let Some(slot) = slot else {
244            let len = item_len(self.rest());
245            let want = match self.rest()[..len].trim() {
246                "used" => ResultWant::Used,
247                "unused" => ResultWant::Unused,
248                _ => return Err(self.error("ret takes used or unused")),
249            };
250            self.pos += len;
251            return Ok(CallConstraint::Result { want, certainty });
252        };
253        let want = self.want()?;
254        Ok(CallConstraint::Value { slot, want, certainty })
255    }
256
257    /// The value an argument has to hold.
258    fn want(&mut self) -> Result<Want> {
259        if self.rest().starts_with('"') {
260            return Ok(Want::Str(self.quoted()?));
261        }
262        let regex = self.rest().starts_with('~');
263        if regex {
264            self.pos += 1;
265            if self.rest().starts_with('"') {
266                return Ok(Want::StrRegex(self.quoted()?));
267            }
268        }
269        let len = item_len(self.rest());
270        let src: &'a str = self.src;
271        let raw = src[self.pos..self.pos + len].trim();
272        if raw.is_empty() {
273            return Err(self.error("empty value in a constraint"));
274        }
275        self.pos += len;
276        if regex {
277            return Ok(Want::StrRegex(raw.to_string()));
278        }
279        let bad = |what: &str| EngineError::Query(format!("bad rule {:?}: {what} in constraint value {raw:?}", self.src));
280        if let Some((kind, target)) = raw.split_once(':') {
281            let target = target.trim();
282            return Ok(match kind {
283                "class" => Want::Class(format_class_name(target)?),
284                "new" => Want::New(format_class_name(target)?),
285                "ret" => Want::Ret(member_query("method", None, false, Some(target), false)?),
286                "field" => Want::Field(member_query("field", None, false, Some(target), false)?),
287                _ => return Err(bad("expected class:, new:, ret: or field:")),
288            });
289        }
290        Ok(match raw {
291            "*" => Want::Any,
292            "true" => Want::Int(1),
293            "false" | "null" => Want::Int(0),
294            "param" => Want::Param(None),
295            _ => match raw.strip_prefix("param") {
296                Some(k) => Want::Param(Some(k.parse().map_err(|_| bad("expected paramK"))?)),
297                None => Want::Int(
298                    parse_int(raw).ok_or_else(|| bad("expected *, a number, \"text\", ~regex, param, class:, new:, ret: or field:"))?,
299                ),
300            },
301        })
302    }
303
304    /// A `"..."` string at the cursor, its `\"` / `\\` unescaped.
305    fn quoted(&mut self) -> Result<String> {
306        let mut out = String::new();
307        let mut chars = self.rest().char_indices().skip(1);
308        while let Some((i, c)) = chars.next() {
309            match c {
310                '"' => {
311                    self.pos += i + 1;
312                    return Ok(out);
313                }
314                '\\' => match chars.next() {
315                    Some((_, 'n')) => out.push('\n'),
316                    Some((_, 't')) => out.push('\t'),
317                    Some((_, c)) => out.push(c),
318                    None => break,
319                },
320                c => out.push(c),
321            }
322        }
323        Err(self.error("unterminated `\"`"))
324    }
325
326    /// Consume TEXT: up to a `&` outside brackets, or the `}` that closes
327    /// the enclosing body (`nested`).
328    fn text(&mut self, nested: bool) -> Result<&'a str> {
329        self.text_until(nested, Stop::Text)
330    }
331
332    /// [`Parser::text`], ending before the first constraint too with
333    /// [`Stop::Constraint`].
334    fn text_until(&mut self, nested: bool, stop: Stop) -> Result<&'a str> {
335        let start = self.pos;
336        let len = text_len(self.rest(), nested);
337        self.pos += match stop {
338            Stop::Text => len,
339            Stop::Constraint => constraint_start(&self.rest()[..len]).unwrap_or(len),
340        };
341        let value = &self.src[start..self.pos];
342        if value.trim().is_empty() {
343            return Err(self.error("empty value"));
344        }
345        Ok(value)
346    }
347
348    /// The method set of `method:@PATH`, read now so the query (and its
349    /// cache key) holds the file's content.
350    fn method_file(&self, path: &str) -> Result<Query> {
351        // An absolute `path` replaces `dir` when joined.
352        let full = self.dir.map_or_else(|| Path::new(path).to_path_buf(), |d| d.join(path));
353        let text = std::fs::read_to_string(&full).map_err(|e| EngineError::Query(format!("cannot read method set {path}: {e}")))?;
354        Ok(Query::MethodSet(method_set(&text).map_err(|e| EngineError::Query(format!("{path}: {e}")))?))
355    }
356}
357
358/// Where a value's TEXT ends.
359#[derive(Clone, Copy)]
360enum Stop {
361    /// At the end of the TEXT.
362    Text,
363    /// Also at the `[` of a call constraint.
364    Constraint,
365}
366
367/// Whether a method target carries call constraints (`X[arg0=1]`).
368pub fn has_constraints(target: &str) -> bool {
369    constraint_start(target).is_some()
370}
371
372/// The offset of the first `[argN`, `[this` or `[ret` in a method target.
373/// Dalvik names hold no `[`, and in a descriptor one is followed by a type
374/// (`[B`, `[Lx;`, `[[I`), never by these words.
375fn constraint_start(s: &str) -> Option<usize> {
376    s.match_indices('[').map(|(i, _)| i).find(|&i| {
377        let c = s[i + 1..].trim_start();
378        c.starts_with("ret") || c.starts_with("this") || c.strip_prefix("arg").is_some_and(|n| n.starts_with(|d: char| d.is_ascii_digit()))
379    })
380}
381
382/// Bytes of a constraint value at the start of `s`: up to a `,` or `]`
383/// outside `()` / `{}`; `\` keeps the next character in.
384fn item_len(s: &str) -> usize {
385    let mut depth = 0usize;
386    let mut chars = s.char_indices();
387    while let Some((i, c)) = chars.next() {
388        match c {
389            '\\' => {
390                chars.next();
391            }
392            '(' | '{' => depth += 1,
393            ')' | '}' if depth > 0 => depth -= 1,
394            ',' | ']' if depth == 0 => return i,
395            _ => {}
396        }
397    }
398    s.len()
399}
400
401/// A decimal or `0x` integer, maybe negative.
402fn parse_int(s: &str) -> Option<i64> {
403    let (neg, digits) = s.strip_prefix('-').map_or((false, s), |d| (true, d));
404    let n = match digits.strip_prefix("0x").or_else(|| digits.strip_prefix("0X")) {
405        // Hex is the bits of the value: 0xffffffff is -1 as an int.
406        Some(hex) => {
407            let n = u64::from_str_radix(hex, 16).ok()?;
408            match u32::try_from(n) {
409                Ok(int) => i64::from(i32::from_ne_bytes(int.to_ne_bytes())),
410                Err(_) => i64::from_ne_bytes(n.to_ne_bytes()),
411            }
412        }
413        None => digits.parse().ok()?,
414    };
415    Some(if neg { n.checked_neg()? } else { n })
416}
417
418/// Bytes of TEXT at the start of `s`: up to a `&` outside `{}` / `()`, or an
419/// unmatched `}` when `nested`. A `\` keeps the next character in.
420fn text_len(s: &str, nested: bool) -> usize {
421    let mut depth = 0usize;
422    let mut chars = s.char_indices();
423    while let Some((i, c)) = chars.next() {
424        match c {
425            '\\' => {
426                chars.next();
427            }
428            '{' | '(' => depth += 1,
429            '}' | ')' if depth > 0 => depth -= 1,
430            '}' if nested => return i,
431            '&' if depth == 0 => return i,
432            _ => {}
433        }
434    }
435    s.len()
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::names::{format_class_name, parse_condition, parse_rule};
442
443    fn method_query(spec: &str) -> MemberQuery {
444        member_query("method", None, false, Some(spec), false).unwrap()
445    }
446
447    fn method(spec: &str) -> Query {
448        Query::Method(method_query(spec))
449    }
450
451    /// A method set wrapped in its default (unresolved) dispatch query.
452    fn vq(target: Query) -> Query {
453        Query::Virtual { target: Box::new(target), via: Default::default(), fan_out: Some(crate::compose::DEFAULT_FAN_OUT) }
454    }
455
456    #[test]
457    fn parts_split_at_top_level_ampersands_only() {
458        let q = parse_rule(" string:a & !literal:b&method:Lc;->m(I)V ").unwrap();
459        assert_eq!(
460            q,
461            Query::All { all: vec![Query::String("a".into()), method("Lc;->m(I)V")], none: vec![Query::StringLiteral("b".into())] }
462        );
463        // Inside `{}` / `()` or after `\`, `&` is part of the value.
464        assert_eq!(parse_rule("string:(a&b)").unwrap(), Query::String("(a&b)".into()));
465        assert_eq!(parse_rule(r"string:a{1}\&b").unwrap(), Query::String(r"a{1}\&b".into()));
466        assert_eq!(
467            parse_condition("extends:android.app.Activity").unwrap(),
468            Query::Extends(format_class_name("android.app.Activity").unwrap())
469        );
470        // Array descriptors are not brackets.
471        assert_eq!(
472            parse_rule("method:Lc;->m([B)V & type:X").unwrap(),
473            Query::All { all: vec![method("Lc;->m([B)V"), Query::Type("X".into())], none: vec![] }
474        );
475    }
476
477    #[test]
478    fn method_bodies_nest_whole_rules() {
479        let inner = Query::All { all: vec![Query::StringLiteral("imei".into())], none: vec![Query::Type("Lx;".into())] };
480        // A `{...}` body nests the rule and, by default, follows dispatch.
481        assert_eq!(parse_rule("method:{literal:imei & !type:Lx;}").unwrap(), vq(Query::Nested(Box::new(inner.clone()))));
482        let q = parse_rule("method:{ method:{literal:imei & !type:Lx;} } & string:http").unwrap();
483        assert_eq!(
484            q,
485            Query::All {
486                all: vec![vq(Query::Nested(Box::new(vq(Query::Nested(Box::new(inner)))))), Query::String("http".into())],
487                none: vec![]
488            }
489        );
490        // A regex brace inside the body stays in its value.
491        assert_eq!(parse_rule("method:{string:a{2}}").unwrap(), vq(Query::Nested(Box::new(Query::String("a{2}".into())))));
492    }
493
494    fn call(target: Query, constraints: Vec<CallConstraint>) -> Query {
495        Query::Call { target: Box::new(target), constraints }
496    }
497
498    fn arg(n: u16, want: Want, certainty: Certainty) -> CallConstraint {
499        CallConstraint::Value { slot: Slot::Arg(n), want, certainty }
500    }
501
502    #[test]
503    fn method_targets_take_call_constraints() {
504        use Certainty::{May, Must};
505        let target = || method("Lc;->m([BLjava/lang/String;)V");
506        assert_eq!(
507            parse_rule(r#"method:Lc;->m([BLjava/lang/String;)V [arg1="a,]\"b", this==new:x.Y][ret=unused]"#).unwrap(),
508            call(
509                target(),
510                vec![
511                    arg(1, Want::Str("a,]\"b".into()), May),
512                    CallConstraint::Value { slot: Slot::This, want: Want::New("Lx/Y;".into()), certainty: Must },
513                    CallConstraint::Result { want: ResultWant::Unused, certainty: May },
514                ]
515            )
516        );
517        for (value, want) in [
518            ("*", Want::Any),
519            ("-12", Want::Int(-12)),
520            ("0x10", Want::Int(16)),
521            ("0xffffffff", Want::Int(-1)),
522            ("true", Want::Int(1)),
523            ("null", Want::Int(0)),
524            ("param", Want::Param(None)),
525            ("param3", Want::Param(Some(3))),
526            ("~^ro\\.(serial|boot){1,2}", Want::StrRegex("^ro\\.(serial|boot){1,2}".into())),
527            ("~\"a]b\"", Want::StrRegex("a]b".into())),
528            ("class:java.lang.String", Want::Class("Ljava/lang/String;".into())),
529            ("ret:android.content.Context->getSystemService", Want::Ret(method_query("android.content.Context->getSystemService"))),
530            ("field:Lc;->f:I", Want::Field(member_query("field", None, false, Some("Lc;->f:I"), false).unwrap())),
531        ] {
532            assert_eq!(parse_rule(&format!("method:x[arg0={value}]")).unwrap(), call(method("x"), vec![arg(0, want, May)]), "{value}");
533        }
534        // `&` inside a constraint stays in it; after `]` it joins the next part.
535        assert_eq!(
536            parse_rule("method:x[arg0=~a&b] & string:s").unwrap(),
537            Query::All {
538                all: vec![call(method("x"), vec![arg(0, Want::StrRegex("a&b".into()), May)]), Query::String("s".into())],
539                none: vec![]
540            }
541        );
542        // Nested bodies and method sets take them too, under the dispatch wrapper.
543        let q = parse_rule("method:{literal:imei}[arg0=*]").unwrap();
544        assert_eq!(q, call(vq(Query::Nested(Box::new(Query::StringLiteral("imei".into())))), vec![arg(0, Want::Any, May)]));
545        let dir = tempfile::tempdir().unwrap();
546        std::fs::write(dir.path().join("set.txt"), "Lc;->m()V\n").unwrap();
547        let q = parse_rule_in("method:@set.txt[ret==used]", Some(dir.path())).unwrap();
548        assert!(matches!(&q, Query::Call { target, .. } if matches!(**target, Query::Virtual { .. })), "{q:?}");
549        // Array descriptors and regex classes are not constraints.
550        assert!(!has_constraints("Lc;->m([B[Ljava/lang/String;)V") && has_constraints("x[ret=used]"));
551        assert_eq!(parse_rule("string:[arg0]").unwrap(), Query::String("[arg0]".into()));
552    }
553
554    #[test]
555    fn definition_predicates_parse_into_defined_queries() {
556        use asc_dex::access;
557        assert_eq!(parse_rule("flag:public,static").unwrap(), Query::Defined(Defined::Flags(access::PUBLIC | access::STATIC)));
558        assert_eq!(parse_rule("annotation:a.B").unwrap(), Query::Defined(Defined::Annotation("La/B;".into())));
559        let Query::Defined(Defined::Override(m)) = parse_rule("override:a.B->run").unwrap() else { panic!("an override") };
560        assert_eq!((m.name.as_deref(), m.class.unwrap().pattern.as_str()), (Some("run"), "La/B;"));
561        assert_eq!(parse_rule("in:{extends:a.Base}").unwrap(), Query::Defined(Defined::In(Box::new(format_extends("a.Base")))));
562        // A definition predicate joins a code condition in a compound.
563        let Query::All { all, none } = parse_rule("string:x & flag:native & !annotation:a.B").unwrap() else { panic!("a compound") };
564        assert_eq!(all.len(), 2);
565        assert_eq!(none, vec![Query::Defined(Defined::Annotation("La/B;".into()))]);
566        // Bad spellings are reported at parse time; a non-class `in:{...}` is
567        // caught later, when the engine resolves it over the hierarchy.
568        for (spec, want) in [("flag:bogus", "use public"), ("in:extends:a.B", "in: takes a class condition")] {
569            assert!(parse_rule(spec).unwrap_err().to_string().contains(want), "{spec}");
570        }
571        assert!(matches!(parse_rule("in:{string:x}").unwrap(), Query::Defined(Defined::In(_))));
572    }
573
574    fn format_extends(c: &str) -> Query {
575        Query::Extends(format_class_name(c).unwrap())
576    }
577
578    #[test]
579    fn method_sets_follow_dispatch_unless_prefixed_with_eq() {
580        // `{...}` / `@` wrap the set in a dispatch query with the default
581        // fan-out; `*` lifts it; `=` keeps the set exact.
582        use crate::compose::DEFAULT_FAN_OUT;
583        let Query::Virtual { target, via, fan_out } = parse_rule("method:{literal:x}").unwrap() else { panic!("a dispatch set") };
584        assert!(matches!(*target, Query::Nested(_)) && via.is_empty() && fan_out == Some(DEFAULT_FAN_OUT));
585        let Query::Virtual { fan_out, .. } = parse_rule("method:*{literal:x}").unwrap() else { panic!("a dispatch set") };
586        assert_eq!(fan_out, None);
587        assert!(matches!(parse_rule("method:={literal:x}").unwrap(), Query::Nested(_)));
588        // A constraint applies to the dispatched calls.
589        let Query::Call { target, .. } = parse_rule("method:{literal:x}[arg0=1]").unwrap() else { panic!("constrained") };
590        assert!(matches!(*target, Query::Virtual { .. }));
591        let dir = tempfile::tempdir().unwrap();
592        std::fs::write(dir.path().join("s.txt"), "La/B;->m()V\n").unwrap();
593        assert!(matches!(parse_rule_in("method:@s.txt", Some(dir.path())).unwrap(), Query::Virtual { .. }));
594        assert!(matches!(parse_rule_in("method:=@s.txt", Some(dir.path())).unwrap(), Query::MethodSet(_)));
595        assert!(matches!(parse_rule_in("method:*@s.txt", Some(dir.path())).unwrap(), Query::Virtual { fan_out: None, .. }));
596    }
597
598    #[test]
599    fn errors_name_the_problem() {
600        for (spec, want) in [
601            ("bogus", "expected KIND:VALUE"),
602            ("method:", "empty value"),
603            ("metod:x", "closest: method"),
604            ("method:{string:a", "expected `}`"),
605            ("method:{string:a} x", "unexpected text after the method"),
606            ("method:x[arg0=1] y", "unexpected text after the method"),
607            ("method:x[arg0=1", "expected `,` or `]`"),
608            ("method:x[arg0=1, foo=2]", "expected argN, this or ret"),
609            ("method:x[arg0 1]", "expected `=` or `==`"),
610            ("method:x[arg0=]", "empty value"),
611            ("method:x[arg0=\"a]", "unterminated"),
612            ("method:x[arg0=bogus:y]", "expected class:, new:, ret: or field:"),
613            ("method:x[arg0=paramx]", "expected paramK"),
614            ("method:x[arg0=1.5]", "expected *, a number"),
615            ("method:x[ret=maybe]", "ret takes used or unused"),
616            ("!string:a", "needs a condition without `!`"),
617            ("string:a & ", "expected KIND:VALUE"),
618            ("method:@", "empty value"),
619            ("method:@/nonexistent/set.txt", "cannot read method set"),
620        ] {
621            let e = parse_rule(spec).unwrap_err().to_string();
622            assert!(e.contains(want), "{spec}: {e}");
623        }
624        assert!(parse_condition("string:a & string:b").unwrap_err().to_string().contains("several conditions"));
625    }
626
627    #[test]
628    fn method_files_take_any_listing() {
629        let dir = tempfile::tempdir().unwrap();
630        let text = "# from findrefs\n\
631            classes2.dex | Lb/Lib;->sink()V | \"x->y\"\n\
632            r1 | classes.dex | La/A;->run(I)V | Lb/Lib;->sink()V\n\
633            \n\
634            \x20 2 <- La/A;->run(I)V (via Lx;)\n\
635            com.a.B.go\n";
636        std::fs::write(dir.path().join("set.txt"), text).unwrap();
637        let Query::Virtual { target, .. } = parse_rule_in("method:@set.txt", Some(dir.path())).unwrap() else { panic!("a dispatch set") };
638        let Query::MethodSet(set) = *target else { panic!("a method set") };
639        let want = [exact_method("La/A;->run(I)V").unwrap(), exact_method("Lb/Lib;->sink()V").unwrap(), method_query("com.a.B.go")];
640        assert_eq!(set, want);
641        // Obfuscated names go by the arrow, not as identifiers.
642        let q = exact_method("LX/0TRC;->ʻ(I)Ljava/lang/String;").unwrap();
643        assert_eq!((q.name.as_deref(), q.exact_name, q.class.unwrap().pattern.as_str()), (Some("ʻ"), true, "LX/0TRC;"));
644    }
645}