Skip to main content

asc_engine/names/
denormalize.rs

1//! Names as decompiled Java / Kotlin source spells them, mapped back to the
2//! DEX names they stand for.
3//!
4//! Decompilers rename what the source language cannot spell: a nested class
5//! is `Outer.Inner` (DEX `Outer$Inner`), an anonymous one `AnonymousN`
6//! (`$N`), a reserved word or a name starting with a digit gets a `C` in
7//! front (`Cdo`, `Cif`, `C_`, `C1a`), same-named members a collision suffix
8//! (`a$2`), a constructor the class name, and Kotlin backticks a keyword
9//! (`` `in` ``) or escapes other characters as `_dex_<HEX>_`. An agent
10//! reading such source writes these names back into queries.
11//!
12//! Everything here produces *fallback candidates*: callers look the name up
13//! as written first and only try these when that finds nothing, so a DEX
14//! that really has a class `Cdo` or a method `m$2` is never second-guessed.
15
16use super::{class_inner, format_class_name, java_signature_to_dalvik};
17use asc_findrefs::{ClassQuery, MemberQuery, MemberSignature, Query, TypeMatch};
18
19/// Whether `value` is a Java reserved keyword or literal, which decompilers
20/// prefix with `C` when a DEX identifier is spelled like one.
21pub fn is_java_reserved(value: &str) -> bool {
22    matches!(
23        value,
24        "abstract"
25            | "assert"
26            | "boolean"
27            | "break"
28            | "byte"
29            | "case"
30            | "catch"
31            | "char"
32            | "class"
33            | "const"
34            | "continue"
35            | "default"
36            | "do"
37            | "double"
38            | "else"
39            | "enum"
40            | "extends"
41            | "final"
42            | "finally"
43            | "float"
44            | "for"
45            | "goto"
46            | "if"
47            | "implements"
48            | "import"
49            | "instanceof"
50            | "int"
51            | "interface"
52            | "long"
53            | "native"
54            | "new"
55            | "package"
56            | "private"
57            | "protected"
58            | "public"
59            | "return"
60            | "short"
61            | "static"
62            | "strictfp"
63            | "super"
64            | "switch"
65            | "synchronized"
66            | "this"
67            | "throw"
68            | "throws"
69            | "transient"
70            | "try"
71            | "void"
72            | "volatile"
73            | "while"
74            | "true"
75            | "false"
76            | "null"
77            | "_"
78    )
79}
80
81/// `name(params)` without a return type — a Java-style or abbreviated target
82/// such as `run(int)` or `entry(com.app.Outer.Inner)`. Anything unbalanced
83/// (`run(I`, `run`) is not a signature and returns `None`.
84fn partial_signature(spec: &str) -> Option<(&str, &str)> {
85    let open = spec.find('(')?;
86    let tail = &spec[open..];
87    let balanced = tail.ends_with(')') && tail.matches('(').count() == 1 && tail.matches(')').count() == 1;
88    let name = spec[..open].trim();
89    (balanced && !name.is_empty()).then_some((name, tail))
90}
91
92/// [`partial_signature`] with its Java-style or source-spelled parameter
93/// types put into Dalvik form: `onEvent(int)` → `("onEvent", "(I)")`,
94/// `entry(com.app.Outer.Inner)` → `("entry", "(Lcom/app/Outer$Inner;)")`.
95/// The proto has no return type: callers match it as a prefix.
96pub(crate) fn partial_proto(spec: &str) -> Option<(String, String)> {
97    let (name, tail) = partial_signature(spec)?;
98    let tail = java_signature_to_dalvik(spec)
99        .and_then(|dalvik| dalvik.find('(').map(|i| dalvik[i..].to_string()))
100        .unwrap_or_else(|| tail.split_whitespace().collect());
101    Some((name.to_string(), denormalize_signature_types(&tail)))
102}
103
104/// Whether DEX proto `have` (`(I)V`) is what `want` asks for: equal, or
105/// `want` is a return-less `(params)` prefix of it.
106pub(crate) fn proto_matches(have: &str, want: &str) -> bool {
107    have == want || (want.ends_with(')') && have.len() > want.len() && have.starts_with(want))
108}
109
110fn push_unique(out: &mut Vec<String>, item: String) {
111    if !item.is_empty() && !out.contains(&item) {
112        out.push(item);
113    }
114}
115
116/// `N` of `AnonymousN`, the decompiled name of anonymous class `$N`.
117fn anonymous_number(name: &str) -> Option<&str> {
118    name.strip_prefix("Anonymous").filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
119}
120
121/// The identifier a Kotlin `_dex_<HEX>_` escape stands for (`_dex_61_2D_62_`
122/// → `a-b`, `_dex_empty` → the empty name), if `s` has that shape.
123fn decode_kotlin_dex_hex(s: &str) -> Option<String> {
124    let rest = s.strip_prefix("_dex_")?;
125    if rest == "empty" {
126        return Some(String::new());
127    }
128    let rest = rest.strip_suffix('_')?;
129    let mut out = String::new();
130    for part in rest.split('_') {
131        let code = u32::from_str_radix(part, 16).ok()?;
132        out.push(char::from_u32(code)?);
133    }
134    (!out.is_empty()).then_some(out)
135}
136
137/// The DEX identifiers a decompiled identifier may stand for (`ident`
138/// itself excluded):
139/// - Kotlin backticks `` `in` `` → `in`
140/// - Kotlin `_dex_<HEX>_` → the decoded characters
141/// - a collision suffix `name$2`, `name$3` → `name`
142/// - a reserved-word or digit escape `Cdo`, `Cif`, `C_`, `C1a` → `do`, `if`, `_`, `1a`
143pub fn denormalize_identifier(ident: &str) -> Vec<String> {
144    let mut out = Vec::new();
145    let stripped = ident.strip_prefix('`').and_then(|s| s.strip_suffix('`')).unwrap_or(ident);
146    let mut seeds = Vec::new();
147    if stripped != ident {
148        push_unique(&mut out, stripped.to_string());
149    }
150    seeds.push(stripped.to_string());
151    if let Some(decoded) = decode_kotlin_dex_hex(stripped) {
152        push_unique(&mut out, decoded.clone());
153        seeds.push(decoded);
154    }
155    if let Some((base, num)) = stripped.rsplit_once('$')
156        && !base.is_empty()
157        && num.parse::<u32>().is_ok_and(|n| n >= 2)
158    {
159        push_unique(&mut out, base.to_string());
160        seeds.push(base.to_string());
161    }
162    for s in seeds {
163        if let Some(rest) = s.strip_prefix('C')
164            && (is_java_reserved(rest) || rest.starts_with(|c: char| c.is_ascii_digit()))
165        {
166            push_unique(&mut out, rest.to_string());
167        }
168    }
169    out.retain(|c| c != ident);
170    out
171}
172
173/// Most token sequences [`denormalize_class_candidates`] combines.
174const MAX_CLASS_SEQUENCES: usize = 16;
175
176/// The Dalvik descriptors a class written as decompiled source spells it may
177/// stand for, most likely first (the name as written excluded):
178/// - nested classes written with dots (`com.a.Outer.Inner` → `Lcom/a/Outer$Inner;`)
179/// - anonymous classes (`Outer.Anonymous1` → `Lcom/a/Outer$1;`)
180/// - escaped package / class segments (`Cdo` → `do`, `` `in` `` → `in`)
181pub fn denormalize_class_candidates(input: &str) -> Vec<String> {
182    let Ok(formatted) = format_class_name(input) else {
183        return Vec::new();
184    };
185    let dims = formatted.len() - formatted.trim_start_matches('[').len();
186    let array_prefix = &formatted[..dims];
187    let core = &formatted[dims..];
188    if !core.starts_with('L') || !core.ends_with(';') {
189        return Vec::new();
190    }
191    let inner = class_inner(core);
192    // Each token with the separator ('/' or '$') that precedes it.
193    let mut tokens: Vec<(char, &str)> = Vec::new();
194    for slash_seg in inner.split('/') {
195        for (j, dollar_seg) in slash_seg.split('$').enumerate() {
196            tokens.push((if j > 0 { '$' } else { '/' }, dollar_seg));
197        }
198    }
199
200    // Each token's readings, `(text, a '$' must precede it)`, the
201    // denormalized ones first and the token as written last.
202    let token_variants: Vec<Vec<(String, bool)>> = tokens
203        .iter()
204        .map(|&(_, raw)| {
205            let mut vars = Vec::new();
206            let unquoted = raw.strip_prefix('`').and_then(|s| s.strip_suffix('`')).unwrap_or(raw);
207            if let Some(num) = anonymous_number(unquoted) {
208                vars.push((num.to_string(), true));
209            }
210            for d in denormalize_identifier(raw) {
211                if !vars.iter().any(|(v, _)| v == &d) {
212                    vars.push((d, false));
213                }
214            }
215            if !vars.iter().any(|(v, _)| v == raw) {
216                vars.push((raw.to_string(), false));
217            }
218            vars
219        })
220        .collect();
221
222    // Sequences of the first two readings of each token, bounded.
223    let mut seqs: Vec<Vec<(String, bool)>> = vec![Vec::new()];
224    for vars in &token_variants {
225        let mut next = Vec::new();
226        for prefix in &seqs {
227            for v in vars.iter().take(2) {
228                let mut p = prefix.clone();
229                p.push(v.clone());
230                next.push(p);
231            }
232        }
233        next.truncate(MAX_CLASS_SEQUENCES);
234        seqs = next;
235    }
236
237    let mut out = Vec::new();
238    for seq in &seqs {
239        // `dollar_from`: every token after this one is a nested class.
240        let build = |dollar_from: Option<usize>| -> String {
241            let mut s = format!("{array_prefix}L");
242            for (idx, (tok, force_dollar)) in seq.iter().enumerate() {
243                if idx > 0 {
244                    let nested = tokens[idx].0 == '$' || *force_dollar || dollar_from.is_some_and(|df| idx > df);
245                    s.push(if nested { '$' } else { '/' });
246                }
247                s.push_str(tok);
248            }
249            s.push(';');
250            s
251        };
252        let mut push = |cand: String| {
253            if cand != formatted {
254                push_unique(&mut out, cand);
255            }
256        };
257
258        // 1. The separators as written, `AnonymousN` alone becoming `$N`.
259        push(build(None));
260
261        // 2. After the first capitalised segment (in the denormalized token
262        //    or as written) every segment is a nested class. Obfuscated code
263        //    may have no capitals at all, so the last two segments are
264        //    always tried as outer / inner too.
265        let upper = seq
266            .iter()
267            .position(|(t, _)| t.starts_with(|c: char| c.is_ascii_uppercase()))
268            .or_else(|| tokens.iter().position(|&(_, raw)| raw.trim_matches('`').starts_with(|c: char| c.is_ascii_uppercase())));
269        let mut splits: Vec<usize> = upper.map(|first| (first..seq.len().saturating_sub(1)).collect()).unwrap_or_default();
270        // A lowercase outer class (`com/app/a/B`) still nests, but only a
271        // path with a package segment to spare is split blindly.
272        if seq.len() >= 3 {
273            splits.push(seq.len() - 2);
274        }
275        splits.sort_unstable();
276        splits.dedup();
277        for split_at in splits {
278            push(build(Some(split_at)));
279        }
280    }
281    out
282}
283
284/// The source names a constructor of `class_descriptor` may be written as.
285fn class_constructor_names(class_descriptor: &str) -> Vec<String> {
286    let mut names = vec!["constructor".to_string()];
287    let inner = class_inner(class_descriptor.trim_start_matches('['));
288    let binary_cls = inner.rsplit('/').next().unwrap_or(inner);
289    let last = binary_cls.rsplit('$').next().unwrap_or(binary_cls);
290    if !last.is_empty() {
291        push_unique(&mut names, last.to_string());
292        if last.bytes().all(|b| b.is_ascii_digit()) {
293            push_unique(&mut names, format!("Anonymous{last}"));
294        }
295        if is_java_reserved(last) || last.starts_with(|c: char| c.is_ascii_digit()) {
296            push_unique(&mut names, format!("C{last}"));
297        }
298    }
299    if binary_cls != last {
300        push_unique(&mut names, binary_cls.to_string());
301    }
302    names
303}
304
305/// The DEX names or `name(params)ret` signatures a method written as
306/// decompiled source spells it may stand for (`Cdo`, `m$2`, `` `in` ``, the
307/// class name or `constructor` for `<init>`, Java parameter types), in the
308/// class `class_descriptor` when it is known. The method as written is
309/// excluded.
310pub fn denormalize_method_candidates(class_descriptor: Option<&str>, method: &str) -> Vec<String> {
311    let trimmed = method.trim();
312    let member = trimmed.rsplit_once("->").map_or(trimmed, |(_, m)| m);
313    let (raw_name, sig_tail) = match member.find('(') {
314        Some(i) => (member[..i].trim(), Some(&member[i..])),
315        None => (member.trim(), None),
316    };
317    let unquoted = raw_name.trim_matches('`');
318    let mut name_cands = vec![raw_name.to_string()];
319    for d in denormalize_identifier(raw_name) {
320        push_unique(&mut name_cands, d);
321    }
322    let constructor = match class_descriptor {
323        Some(cls) => {
324            let as_written = class_constructor_names(cls);
325            as_written.iter().any(|n| n == raw_name || n == unquoted)
326                || denormalize_class_candidates(cls)
327                    .iter()
328                    .any(|c| class_constructor_names(c).iter().any(|n| !as_written.contains(n) && (n == raw_name || n == unquoted)))
329        }
330        None => raw_name == "constructor" || anonymous_number(raw_name).is_some(),
331    };
332    if constructor {
333        push_unique(&mut name_cands, "<init>".to_string());
334    }
335
336    let mut sig_cands = Vec::new();
337    match sig_tail {
338        None => sig_cands.push(String::new()),
339        Some(sig) => {
340            sig_cands.push(sig.to_string());
341            push_unique(&mut sig_cands, denormalize_signature_types(sig));
342            if let Some(dalvik_sig) = java_signature_to_dalvik(&format!("m{sig}")).and_then(|d| d.strip_prefix('m').map(str::to_string)) {
343                push_unique(&mut sig_cands, denormalize_signature_types(&dalvik_sig));
344                push_unique(&mut sig_cands, dalvik_sig);
345            }
346        }
347    }
348
349    let mut out = Vec::new();
350    for n in &name_cands {
351        for s in &sig_cands {
352            let combined = format!("{n}{s}");
353            if combined != member && combined != trimmed {
354                push_unique(&mut out, combined.clone());
355            }
356            // A constructor returns void, and every lookup wants a full
357            // `(params)ret` proto.
358            if n == "<init>" && s.ends_with(')') {
359                push_unique(&mut out, format!("{combined}V"));
360            }
361        }
362    }
363    out
364}
365
366/// `sig` (`(params)ret` or `:type`) with each class descriptor in it
367/// replaced by its most likely DEX descriptor.
368pub(crate) fn denormalize_signature_types(sig: &str) -> String {
369    let mut out = String::with_capacity(sig.len());
370    let mut rest = sig;
371    while let Some(l_pos) = rest.find('L') {
372        out.push_str(&rest[..l_pos]);
373        let after_l = &rest[l_pos..];
374        let Some(semi) = after_l.find(';') else {
375            out.push_str(after_l);
376            rest = "";
377            break;
378        };
379        let desc = &after_l[..=semi];
380        match denormalize_class_candidates(desc).into_iter().next() {
381            Some(first) => out.push_str(&first),
382            None => out.push_str(desc),
383        }
384        rest = &after_l[semi + 1..];
385    }
386    out.push_str(rest);
387    out
388}
389
390fn denormalize_type_match(tm: &TypeMatch) -> Option<TypeMatch> {
391    match tm {
392        TypeMatch::Descriptor(d) => denormalize_class_candidates(d).into_iter().next().map(TypeMatch::Descriptor),
393        TypeMatch::Simple { name, dims } => {
394            let name = match anonymous_number(name) {
395                Some(num) => format!("${num}"),
396                None => denormalize_identifier(name).into_iter().next()?,
397            };
398            Some(TypeMatch::Simple { name, dims: *dims })
399        }
400    }
401}
402
403fn denormalize_member_signature(sig: &MemberSignature) -> Option<MemberSignature> {
404    match sig {
405        MemberSignature::Field(tm) => denormalize_type_match(tm).map(MemberSignature::Field),
406        MemberSignature::Method { params, ret } => {
407            let mut changed = false;
408            let mut map = |t: &TypeMatch| match denormalize_type_match(t) {
409                Some(n) => {
410                    changed = true;
411                    n
412                }
413                None => t.clone(),
414            };
415            let params = params.iter().map(&mut map).collect();
416            let ret = ret.as_ref().map(&mut map);
417            changed.then_some(MemberSignature::Method { params, ret })
418        }
419    }
420}
421
422/// The simple class name of a `[/L]Name;\x00` pattern (what
423/// `member_query` makes of a class without package), unescaped.
424fn simple_class_name(pattern: &str) -> Option<String> {
425    pattern.strip_prefix("[/L]").and_then(|s| s.strip_suffix(";\\x00")).map(|s| s.replace('\\', ""))
426}
427
428/// `mq` and its readings as decompiled source names, `mq` first.
429fn denormalize_member_query(mq: &MemberQuery, is_method: bool) -> Vec<MemberQuery> {
430    let mut class_cands: Vec<Option<ClassQuery>> = vec![mq.class.clone()];
431    // The class as a descriptor, to recognise constructor names.
432    let mut class_descriptor = None;
433    if let Some(cq) = &mq.class {
434        if cq.precise {
435            class_descriptor = Some(cq.pattern.clone());
436            class_cands.extend(denormalize_class_candidates(&cq.pattern).into_iter().map(|c| Some(ClassQuery::exact(c))));
437        } else if let Some(simple) = simple_class_name(&cq.pattern) {
438            class_descriptor = Some(format!("L{simple};"));
439            if let Some(num) = anonymous_number(&simple) {
440                class_cands.push(Some(ClassQuery::fuzzy(format!("\\${num};\\x00"))));
441            }
442            for d in denormalize_identifier(&simple) {
443                class_cands.push(Some(ClassQuery::fuzzy(format!("[/L]{};\\x00", regex::escape(&d)))));
444            }
445        } else {
446            for c in denormalize_class_candidates(&cq.pattern) {
447                class_cands.push(Some(ClassQuery::fuzzy(regex::escape(class_inner(&c)))));
448            }
449        }
450    }
451
452    let mut name_cands: Vec<Option<String>> = vec![mq.name.clone()];
453    if let Some(name) = &mq.name {
454        let extras =
455            if is_method { denormalize_method_candidates(class_descriptor.as_deref(), name) } else { denormalize_identifier(name) };
456        for e in extras {
457            // A method candidate may carry a signature (`<init>(I)V`): the
458            // query's own signature constraint covers that.
459            let e = if is_method { e.split('(').next().unwrap_or(&e).to_string() } else { e };
460            let opt = Some(e);
461            if !name_cands.contains(&opt) {
462                name_cands.push(opt);
463            }
464        }
465    }
466
467    let mut sig_cands: Vec<Option<MemberSignature>> = vec![mq.signature.clone()];
468    if let Some(ns) = mq.signature.as_ref().and_then(denormalize_member_signature) {
469        sig_cands.push(Some(ns));
470    }
471
472    let mut out = vec![mq.clone()];
473    for c in &class_cands {
474        for n in &name_cands {
475            for s in &sig_cands {
476                // A name this fallback made up (`a$2` → `a`, `Cdo` → `do`) is
477                // matched whole: as a substring on a precise class it would
478                // also hit every member whose name merely contains it.
479                let exact_name = mq.exact_name || n != &mq.name;
480                let cand =
481                    MemberQuery::default().with_class(c.clone()).with_name(n.clone()).with_exact_name(exact_name).with_signature(s.clone());
482                if !out.contains(&cand) {
483                    out.push(cand);
484                }
485            }
486        }
487    }
488    out
489}
490
491/// `q` with its class, method and field names also read as decompiled
492/// Java / Kotlin source spells them (see [`denormalize_class_candidates`],
493/// [`denormalize_method_candidates`], [`denormalize_identifier`]); `None`
494/// when no part of `q` reads differently. The result still matches what
495/// `q` matches: it is meant to be run when `q` found nothing.
496pub fn denormalize_query(q: &Query) -> Option<Query> {
497    match q {
498        Query::Type(pat) => {
499            let mut cands = denormalize_class_candidates(pat);
500            if cands.is_empty() {
501                cands = denormalize_identifier(pat);
502            }
503            if cands.is_empty() {
504                return None;
505            }
506            let alternatives: Vec<String> = std::iter::once(format!("(?:{pat})")).chain(cands.iter().map(|c| regex::escape(c))).collect();
507            Some(Query::Type(format!("(?:{})", alternatives.join("|"))))
508        }
509        Query::Extends(cls) => denormalize_class_candidates(cls).into_iter().next().map(Query::Extends),
510        Query::Implements(cls) => denormalize_class_candidates(cls).into_iter().next().map(Query::Implements),
511        Query::Method(mq) => {
512            let vars = denormalize_member_query(mq, true);
513            (vars.len() > 1).then_some(Query::MethodSet(vars))
514        }
515        Query::Field(mq) => {
516            let vars = denormalize_member_query(mq, false);
517            (vars.len() > 1).then(|| collapse_field_variants(&vars)).map(Query::Field)
518        }
519        Query::FieldAccess { field, access } => {
520            let vars = denormalize_member_query(field, false);
521            (vars.len() > 1).then(|| Query::FieldAccess { field: collapse_field_variants(&vars), access: *access })
522        }
523        Query::Call { target, constraints } => {
524            denormalize_query(target).map(|t| Query::Call { target: Box::new(t), constraints: constraints.clone() })
525        }
526        Query::All { all, none } => {
527            let mut changed = false;
528            let all = all
529                .iter()
530                .map(|part| match denormalize_query(part) {
531                    Some(np) => {
532                        changed = true;
533                        np
534                    }
535                    None => part.clone(),
536                })
537                .collect();
538            changed.then(|| Query::All { all, none: none.clone() })
539        }
540        _ => None,
541    }
542}
543
544/// One field query matching any of `vars` (there is no field set): class and
545/// name become alternations of whole-string patterns. The signature of the
546/// last variant that has one is kept.
547fn collapse_field_variants(vars: &[MemberQuery]) -> MemberQuery {
548    let anchored = |s: &str| format!("^{}\\x00", regex::escape(s));
549    let mut classes = Vec::new();
550    let mut names = Vec::new();
551    for v in vars {
552        if let Some(c) = &v.class {
553            push_unique(&mut classes, if c.precise { anchored(&c.pattern) } else { c.pattern.clone() });
554        }
555        if let Some(n) = &v.name {
556            push_unique(&mut names, if v.exact_name { anchored(n) } else { regex::escape(n) });
557        }
558    }
559    let first = &vars[0];
560    let signature = vars.iter().rev().find_map(|v| v.signature.clone());
561    let single_precise = classes.len() == 1 && first.class.as_ref().is_some_and(|c| c.precise);
562    if single_precise && names.len() <= 1 {
563        // One class and at most one name: keep the query as precise as it was.
564        return first.clone().with_signature(signature);
565    }
566    let class = (!classes.is_empty()).then(|| ClassQuery::fuzzy(format!("(?:{})", classes.join("|"))));
567    let name = (!names.is_empty()).then(|| format!("(?:{})", names.join("|")));
568    MemberQuery::default().with_class(class).with_name(name).with_signature(signature)
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn identifiers_lose_their_source_escapes() {
577        assert_eq!(denormalize_identifier("Cdo"), ["do"]);
578        assert_eq!(denormalize_identifier("C_"), ["_"]);
579        assert_eq!(denormalize_identifier("C1a"), ["1a"]);
580        assert_eq!(denormalize_identifier("`in`"), ["in"]);
581        assert_eq!(denormalize_identifier("_dex_61_2D_62_"), ["a-b"]);
582        assert_eq!(denormalize_identifier("m$2"), ["m"]);
583        assert_eq!(denormalize_identifier("Cdo$2"), ["Cdo", "do"]);
584        assert!(denormalize_identifier("Class").is_empty());
585        assert!(denormalize_identifier("m$1").is_empty());
586        assert!(denormalize_identifier("plain").is_empty());
587    }
588
589    #[test]
590    fn dotted_nested_and_anonymous_classes_become_descriptors() {
591        let c = denormalize_class_candidates("com.app.Outer.Inner");
592        assert_eq!(c.first().map(String::as_str), Some("Lcom/app/Outer$Inner;"));
593        let c = denormalize_class_candidates("com.app.Outer.Inner.Deep");
594        assert_eq!(c.first().map(String::as_str), Some("Lcom/app/Outer$Inner$Deep;"));
595        assert!(denormalize_class_candidates("com.app.Outer.Anonymous1").contains(&"Lcom/app/Outer$1;".to_string()));
596        assert!(denormalize_class_candidates("com.Cpkg.Cdo.Cif").contains(&"Lcom/Cpkg/do/if;".to_string()));
597        assert!(denormalize_class_candidates("com.kt.`in`.When").contains(&"Lcom/kt/in/When;".to_string()));
598        // Arrays keep their dimensions; the name as written is left out.
599        assert_eq!(denormalize_class_candidates("[Lcom/a/Outer/Inner;").first().map(String::as_str), Some("[Lcom/a/Outer$Inner;"));
600        assert!(!denormalize_class_candidates("Lcom/a/Outer$Inner;").contains(&"Lcom/a/Outer$Inner;".to_string()));
601        assert!(denormalize_class_candidates("I").is_empty());
602    }
603
604    #[test]
605    fn methods_read_as_constructors_escapes_and_java_signatures() {
606        let m = denormalize_method_candidates(Some("Lcom/app/Outer;"), "Outer");
607        assert!(m.contains(&"<init>".to_string()), "{m:?}");
608        let m = denormalize_method_candidates(Some("Lcom/app/Outer;"), "Outer(int)");
609        assert!(m.contains(&"<init>(I)V".to_string()), "{m:?}");
610        let m = denormalize_method_candidates(Some("Lcom/app/Outer$1;"), "Anonymous1");
611        assert!(m.contains(&"<init>".to_string()), "{m:?}");
612        assert_eq!(denormalize_method_candidates(None, "Cwhile"), ["while"]);
613        let m = denormalize_method_candidates(None, "entry(com.app.Outer.Inner)");
614        assert!(m.contains(&"entry(Lcom/app/Outer$Inner;)".to_string()), "{m:?}");
615        assert!(denormalize_method_candidates(Some("Lcom/app/Outer;"), "run").is_empty());
616    }
617
618    #[test]
619    fn return_less_signatures_become_proto_prefixes() {
620        assert_eq!(partial_proto("run(int)"), Some(("run".into(), "(I)".into())));
621        assert_eq!(partial_proto("entry(com.app.Outer.Inner)"), Some(("entry".into(), "(Lcom/app/Outer$Inner;)".into())));
622        assert_eq!(partial_proto("run(I"), None);
623        assert_eq!(partial_proto("run"), None);
624        assert!(proto_matches("(I)V", "(I)"));
625        assert!(proto_matches("(I)V", "(I)V"));
626        assert!(!proto_matches("(II)V", "(I)"));
627        assert!(!proto_matches("(I)V", "(I)Z"));
628    }
629
630    #[test]
631    fn queries_keep_what_they_matched_and_add_the_readings() {
632        let mq = MemberQuery::default().with_class(Some(ClassQuery::exact("Lcom/app/Outer/Inner;"))).with_name(Some("work".into()));
633        let Some(Query::MethodSet(set)) = denormalize_query(&Query::Method(mq.clone())) else { panic!("a method set") };
634        assert_eq!(set[0], mq);
635        assert!(set.iter().any(|m| m.class == Some(ClassQuery::exact("Lcom/app/Outer$Inner;"))), "{set:?}");
636        // Nothing to read differently: no fallback.
637        let plain = MemberQuery::default().with_name(Some("work".into()));
638        assert_eq!(denormalize_query(&Query::Method(plain)), None);
639        assert_eq!(denormalize_query(&Query::String("x".into())), None);
640        let Some(Query::Field(f)) = denormalize_query(&Query::Field(
641            MemberQuery::default().with_class(Some(ClassQuery::exact("Lcom/app/C;"))).with_name(Some("a$2".into())).with_exact_name(true),
642        )) else {
643            panic!("a field query")
644        };
645        assert_eq!(f.name.as_deref(), Some("(?:^a\\$2\\x00|^a\\x00)"));
646        assert!(!f.exact_name);
647    }
648}