Skip to main content

ddc_dec/
classdec.rs

1//! Class-level rendering: header, fields (with static initializers),
2//! constructors, methods and nested member classes.
3
4use jdc_core::emit::{escape_string, format_float, Printer};
5use jdc_core::types::JavaType;
6use jdc_core::Ctx;
7
8use crate::access::*;
9use crate::ctx::{java_type_to_generic, DexCtx};
10use crate::method::decompile_method;
11use crate::{desc_type, DexPool, PoolClass, PoolMethod, StaticValue};
12
13#[derive(Debug, Clone)]
14pub struct ClassOptions {
15    /// Prefix each file with a provenance comment.
16    pub provenance: bool,
17}
18
19impl Default for ClassOptions {
20    fn default() -> Self {
21        ClassOptions { provenance: true }
22    }
23}
24
25/// Decompile one class (plus nested member classes) to a Java source file.
26///
27/// Classes with large method bodies run in a monitored thread with a
28/// deadline: pathological CFGs can drive the shared structurer's walk into
29/// an exponential exploration that never returns. On timeout the class is
30/// reported failed and the thread is abandoned (reaped at process exit).
31/// One registered monitored decompile: the receiver the worker polls at
32/// the tail, the class name (for diagnostics), and the deadline counted
33/// from the spawn.
34pub type PendingMonitor = (
35    std::sync::mpsc::Receiver<Result<String, String>>,
36    String,
37    std::time::Instant,
38);
39
40pub fn decompile_class(
41    pool: &std::sync::Arc<DexPool>,
42    class: &PoolClass,
43    opts: &ClassOptions,
44    pending: &std::sync::Mutex<Vec<PendingMonitor>>,
45) -> anyhow::Result<String> {
46    if class_is_risky(pool, class) {
47        // Detached monitored thread: the CALLER registers the receiver and
48        // moves on (awaiting happens at the end of the run) — a spinning
49        // pathological method no longer stalls its worker.
50        let pool2 = pool.clone();
51        let cls = class.clone();
52        let opts2 = opts.clone();
53        let (tx, rx) = std::sync::mpsc::channel::<Result<String, String>>();
54        let name = class.name.clone();
55        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
56        let _ = std::thread::Builder::new()
57            .stack_size(64 * 1024 * 1024)
58            .spawn(move || {
59                let r = decompile_class_impl(&pool2, &cls, &opts2).map_err(|e| format!("{:#}", e));
60                let _ = tx.send(r);
61            });
62        pending.lock().unwrap().push((rx, name, deadline));
63        return Err(anyhow::anyhow!(
64            "deferred to monitored thread (pathological-CFG guard)"
65        ));
66    }
67    decompile_class_impl(pool, class, opts)
68}
69
70/// Any method in the exponential-walk danger zone → run under the
71/// deadline. Calibrated between real workloads: routine R8 classes top out
72/// near 11k insns (reqable's biggest `<clinit>`); the exponential hangs
73/// (weibo's gson TypeAdapters) sit at 24k+.
74fn class_is_risky(pool: &DexPool, class: &PoolClass) -> bool {
75    // Header peek only — decoding every body here would double the APK's
76    // total decode work.
77    for m in class.all_methods() {
78        if m.code_off == 0 {
79            continue;
80        }
81        if let Some(dex) = pool.dex(m.dex_idx) {
82            if let Some((_regs, insns)) = dex.code_stats(m.code_off) {
83                if insns > 16_000 {
84                    return true;
85                }
86            }
87        }
88    }
89    false
90}
91
92fn decompile_class_impl(
93    pool: &DexPool,
94    class: &PoolClass,
95    opts: &ClassOptions,
96) -> anyhow::Result<String> {
97    let ctx = DexCtx::new(pool, class);
98    let mut out = String::new();
99    if opts.provenance {
100        out.push_str(&format!(
101            "// Decompiled by https://github.com/ejfkdev/ddc {}\n",
102            env!("CARGO_PKG_VERSION")
103        ));
104        // Provenance: which input image this class was lifted from (jadx's
105        // `loaded from: classes.dex` pattern). Byte-stable across runs —
106        // deliberately NO timestamp so outputs diff cleanly.
107        let label = pool
108            .dex_labels
109            .get(class.dex_idx)
110            .cloned()
111            .unwrap_or_default();
112        if let Some(dex) = pool.dex(class.dex_idx) {
113            out.push_str(&format!("// From: {} (DEX {})\n", label, dex.version));
114        }
115    }
116    if let Some(src) = &class.source_file {
117        out.push_str(&format!("// Source file: {}\n", src));
118    }
119    if class.is_synthetic() {
120        out.push_str("// synthetic\n");
121    }
122    let (pkg, _) = split_name(&class.name);
123    if !pkg.is_empty() {
124        out.push('\n');
125        out.push_str(&format!(
126            "package {};\n",
127            sanitize_fq(&pkg.replace('/', "."))
128        ));
129    }
130    out.push('\n');
131    let mut body = String::new();
132    emit_class_body(pool, class, &ctx, opts, &mut body, 0)?;
133    out.push_str(&body);
134    Ok(out)
135}
136
137/// Render the class header, fields, methods and nested member classes into
138/// `out` (used at top level and recursively for nested members).
139// `opts` is only consumed by the nested-member recursion below — that
140// is its purpose (propagating emission options into inline children).
141#[allow(clippy::only_used_in_recursion)]
142fn emit_class_body(
143    pool: &DexPool,
144    class: &PoolClass,
145    ctx: &DexCtx<'_>,
146    opts: &ClassOptions,
147    out: &mut String,
148    depth: usize,
149) -> anyhow::Result<()> {
150    let ind = indent(depth);
151    // The class's own header must use the RENAMED display name — the
152    // ctor path (is_init) already renames; a header declaring `class a`
153    // while the file/ctor say `a_2` leaves the ctor looking like a
154    // method with no return type (MinisApp's Y2.a vs y2.a package
155    // case-collision: 2,518 javac parse errors).
156    let cname = crate::apply_class_rename(&class.name);
157    let (_, simple) = split_name(&cname);
158    // A class emitted as its OWN top-level file (depth 0) must declare a
159    // flat `$` name — `class Outer.Inner` is not declarable at file
160    // scope. An INLINE nested member (depth > 0) declares its own
161    // segment inside the parent's body.
162    let simple = if depth == 0 {
163        simple.to_string()
164    } else {
165        // R8 names can END in `$`: an empty last `$`-segment would
166        // render `class  {` — keep the whole simple name.
167        let seg = simple.rsplit('$').next().unwrap_or(&simple);
168        if seg.is_empty() {
169            simple.to_string()
170        } else {
171            seg.to_string()
172        }
173    };
174    // A class NAMED `var`-style (taobao ships `tb.var`) cannot be
175    // declared — restricted contextual type names escape here and in
176    // the ctor, file name, and every type reference.
177    let simple = if is_restricted_type_name(&simple) {
178        format!("_{simple}")
179    } else {
180        simple
181    };
182    let is_iface = class.is_interface();
183    let is_enum = class.is_enum();
184
185    let mut head = String::new();
186    let a = class.access;
187    if a & ACC_PUBLIC != 0 {
188        head.push_str("public ");
189    }
190    if a & ACC_FINAL != 0 && !is_enum {
191        head.push_str("final ");
192    }
193    if a & ACC_ABSTRACT != 0 && !is_iface {
194        head.push_str("abstract ");
195    }
196    if a & ACC_ANNOTATION != 0 {
197        head.push('@');
198    }
199    if is_enum {
200        // An enum with constant-specific bodies carries ACC_ABSTRACT —
201        // `abstract final` is an illegal modifier combination; abstract
202        // (already pushed above) suppresses the hardcoded final.
203        let abstract_ = a & ACC_ABSTRACT != 0;
204        head.push_str(if abstract_ {
205            "/* enum */ class "
206        } else {
207            "/* enum */ final class "
208        });
209        head.push_str(&java_ident(&simple));
210    } else if is_iface {
211        head.push_str("interface ");
212        head.push_str(&java_ident(&simple));
213    } else {
214        head.push_str("class ");
215        head.push_str(&java_ident(&simple));
216    }
217    if is_iface {
218        if !class.interfaces.is_empty() {
219            head.push_str(" extends ");
220            head.push_str(&join_dotted(pool, &class.interfaces));
221        }
222    } else {
223        if let Some(sup) = &class.super_name {
224            if sup != "java/lang/Object" && !is_enum {
225                head.push_str(" extends ");
226                head.push_str(&print_class_name(pool, sup));
227            }
228        }
229        if !class.interfaces.is_empty() {
230            head.push_str(if is_iface {
231                " extends "
232            } else {
233                " implements "
234            });
235            head.push_str(&join_dotted(pool, &class.interfaces));
236        }
237    }
238
239    out.push_str(&ind);
240    out.push_str(&head);
241    out.push_str(" {\n");
242
243    // Fields.
244    for (i, f) in class.static_fields.iter().enumerate() {
245        if i > 0 || !class.instance_fields.is_empty() {
246            out.push('\n');
247        }
248        emit_field(
249            pool,
250            f,
251            class.static_values.get(i),
252            &class.name,
253            out,
254            depth + 1,
255            true,
256            class.is_interface(),
257        );
258    }
259    if !class.instance_fields.is_empty() && !class.static_fields.is_empty() {
260        out.push('\n');
261    }
262    for (i, f) in class.instance_fields.iter().enumerate() {
263        if i > 0 {
264            out.push('\n');
265        }
266        emit_field(pool, f, None, &class.name, out, depth + 1, false, false);
267    }
268
269    // Methods.
270    let mut emitted_any = !class.static_fields.is_empty() || !class.instance_fields.is_empty();
271    for m in class.all_methods() {
272        if &*m.name == "<clinit>" {
273            continue; // rendered after the fields
274        }
275        let text = emit_method(pool, class, ctx, m, depth + 1)?;
276        if let Some(text) = text {
277            if emitted_any {
278                out.push('\n');
279            }
280            out.push_str(&text);
281            emitted_any = true;
282        }
283    }
284    // Static initializer. INTERFACES cannot carry a `static { }` block in
285    // Java — their clinit only assigns constants, which static_values (or
286    // the `= null` default) already render as field initializers; skip
287    // the block entirely.
288    let skip_clinit = class.is_interface();
289    if let Some(clinit) = (!skip_clinit)
290        .then(|| class.all_methods().find(|m| &*m.name == "<clinit>"))
291        .flatten()
292    {
293        if let Some(text) = emit_method(pool, class, ctx, clinit, depth + 1)? {
294            if emitted_any {
295                out.push('\n');
296            }
297            out.push_str(&text);
298            emitted_any = true;
299        }
300    }
301
302    // Nested member classes (clean `$` tails only; anonymous/local/lambda
303    // classes are emitted as their own top-level files by the driver).
304    for nested in nested_members(pool, class, ctx) {
305        if emitted_any {
306            out.push('\n');
307        }
308        let nested_ctx = DexCtx::new(pool, nested);
309        out.push('\n');
310        emit_class_body(pool, nested, &nested_ctx, opts, out, depth + 1)?;
311        emitted_any = true;
312    }
313
314    out.push_str(&ind);
315    out.push_str("}\n");
316    Ok(())
317}
318
319/// Member classes of `class` (direct children by the `$` chain / member
320/// annotations), excluding anonymous / local / lambda shapes.
321fn nested_members<'a>(
322    pool: &'a DexPool,
323    class: &'a PoolClass,
324    ctx: &DexCtx<'_>,
325) -> Vec<&'a PoolClass> {
326    let mut out = Vec::new();
327    for name in pool.children_of(&class.name) {
328        let Some(pc) = pool.get(name) else { continue };
329        let rest = &name[class.name.len() + 1..];
330        if rest.is_empty() || rest.starts_with('-') {
331            continue;
332        }
333        if ctx.find_outer(name).as_deref() != Some(class.name.as_str()) {
334            continue;
335        }
336        let tail = rest.rsplit('$').next().unwrap_or(rest);
337        if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
338            continue; // anonymous
339        }
340        if tail.starts_with(|c: char| c.is_ascii_digit()) {
341            continue; // local
342        }
343        out.push(pc);
344    }
345    out
346}
347
348// Eight parameters are all load-bearing (pool, field, static value,
349// owner class, output, depth, staticness, interface-init requirement);
350// bundling them into a struct would obscure each call site.
351#[allow(clippy::too_many_arguments)]
352fn emit_field(
353    pool: &DexPool,
354    f: &crate::PoolField,
355    init: Option<&StaticValue>,
356    f_class: &str,
357    out: &mut String,
358    depth: usize,
359    is_static: bool,
360    require_init: bool,
361) {
362    let ind = indent(depth);
363    let mut line = String::new();
364    let a = f.access;
365    if a & ACC_PUBLIC != 0 {
366        line.push_str("public ");
367    } else if a & ACC_PRIVATE != 0 {
368        line.push_str("private ");
369    } else if a & ACC_PROTECTED != 0 {
370        line.push_str("protected ");
371    }
372    if is_static {
373        line.push_str("static ");
374    }
375    if a & ACC_FINAL != 0 {
376        line.push_str("final ");
377    }
378    if a & ACC_SYNTHETIC != 0 {
379        line.push_str("/* synthetic */ ");
380    }
381    if a & ACC_TRANSIENT_HINT != 0 {
382        line.push_str("transient ");
383    }
384    if a & ACC_VOLATILE_HINT != 0 {
385        line.push_str("volatile ");
386    }
387    let ty = type_name(pool, &desc_type(&f.desc));
388    line.push_str(&ty);
389    line.push(' ');
390    line.push_str(&java_ident(&f.name));
391    let mut rendered = None;
392    if let Some(v) = init {
393        rendered = render_static_value(pool, v, f_class);
394    }
395    if rendered.is_none() && require_init {
396        // Interface fields MUST have an initializer in Java; the dex may
397        // not carry a static_values entry for a compile-time-constant the
398        // compiler folded away. Keep it compilable.
399        let default = match desc_type(&f.desc) {
400            JavaType::Boolean => "false",
401            JavaType::Byte | JavaType::Short | JavaType::Char | JavaType::Int => "0",
402            JavaType::Long => "0L",
403            JavaType::Float => "0.0F",
404            JavaType::Double => "0.0",
405            _ => "null",
406        };
407        rendered = Some(default.to_string());
408    }
409    if let Some(text) = rendered {
410        // A long field holding an `Int(i64)` static value needs the `L`
411        // suffix: without it the literal is an int and overflows
412        // (`long d = -6343169151696340687` failed javac).
413        let text = if matches!(desc_type(&f.desc), JavaType::Long)
414            && text.chars().all(|c| c.is_ascii_digit() || c == '-')
415        {
416            format!("{text}L")
417        } else {
418            text
419        };
420        line.push_str(" = ");
421        line.push_str(&text);
422    }
423    line.push(';');
424    out.push_str(&ind);
425    out.push_str(&line);
426    out.push('\n');
427}
428
429// DEX field access_flags bits 0x40/0x80 are bridge/varargs for METHODS and
430// volatile(0x40)/transient(0x80) for fields.
431pub const ACC_VOLATILE_HINT: u32 = 0x40;
432pub const ACC_TRANSIENT_HINT: u32 = 0x80;
433
434fn render_static_value(pool: &DexPool, v: &StaticValue, owner: &str) -> Option<String> {
435    Some(match v {
436        StaticValue::Int(i) => i.to_string(),
437        StaticValue::Float(f) => format_float(*f as f64, true),
438        StaticValue::Double(d) => format_float(*d, false),
439        StaticValue::Str(s) => format!("\"{}\"", escape_string(s)),
440        StaticValue::Type(t) => format!("{}.class", dotted(t)),
441        StaticValue::Boolean(b) => b.to_string(),
442        StaticValue::Null => "null".into(),
443        StaticValue::Field(cls, name) => {
444            let n = java_ident(name);
445            if cls == owner {
446                n
447            } else {
448                format!("{}.{}", print_class_name(pool, cls), n)
449            }
450        }
451        StaticValue::Other => return None,
452    })
453}
454
455fn emit_method(
456    pool: &DexPool,
457    class: &PoolClass,
458    ctx: &DexCtx<'_>,
459    m: &PoolMethod,
460    depth: usize,
461) -> anyhow::Result<Option<String>> {
462    let ind = indent(depth);
463    let desc = m.parsed_desc();
464
465    // Signature.
466    let mut sig = String::new();
467    let a = m.access;
468    if a & ACC_PUBLIC != 0 {
469        sig.push_str("public ");
470    } else if a & ACC_PRIVATE != 0 {
471        sig.push_str("private ");
472    } else if a & ACC_PROTECTED != 0 {
473        sig.push_str("protected ");
474    }
475    let is_clinit = &*m.name == "<clinit>";
476    let is_init = &*m.name == "<init>";
477    if a & ACC_STATIC != 0 || is_clinit {
478        sig.push_str("static ");
479    }
480    if a & ACC_FINAL != 0 {
481        sig.push_str("final ");
482    }
483    // `abstract synchronized` is an illegal combination — obfuscated
484    // builds mark abstract bridges synchronized (WhatsApp
485    // SQLiteOpenHelper).
486    if a & (ACC_SYNCHRONIZED | ACC_DECLARED_SYNCHRONIZED) != 0 && a & ACC_ABSTRACT == 0 {
487        sig.push_str("synchronized ");
488    }
489    if a & ACC_NATIVE != 0 {
490        sig.push_str("native ");
491    }
492    if a & ACC_ABSTRACT != 0 {
493        sig.push_str("abstract ");
494    }
495    if a & ACC_SYNTHETIC != 0 {
496        sig.push_str("/* synthetic */ ");
497    }
498
499    // Body (needed for parameter names even for abstract methods).
500    let body = decompile_method(pool, class, m).ok().flatten();
501    let param_names: Vec<String> = body
502        .as_ref()
503        .map(|b| {
504            let mut ps: Vec<(u16, String)> =
505                b.vt.vars
506                    .iter()
507                    .filter(|v| v.is_param && v.name != "this")
508                    .map(|v| (v.slot, v.name.clone()))
509                    .collect();
510            ps.sort_by_key(|(s, _)| *s);
511            ps.into_iter().map(|(_, n)| n).collect()
512        })
513        .unwrap_or_else(|| {
514            (0..desc.as_ref().map(|d| d.args.len()).unwrap_or(0))
515                .map(|i| format!("p{}", i + 1))
516                .collect()
517        });
518
519    if is_clinit {
520        // `static { ... }` — the caller strips the method name/params.
521    } else {
522        let Some(d) = &desc else { return Ok(None) };
523        if is_init {
524            // The ctor name must equal the DECLARED class name of its
525            // file: flat `$` at depth 0 (own file), own segment when
526            // inlined in the parent at depth > 0. Case-renamed classes
527            // use their display name.
528            let dname = crate::apply_class_rename(&class.name);
529            let (_, simple) = split_name(&dname);
530            // emit_method's depth is the METHOD indent = class depth + 1:
531            // own-file classes (depth 0 header → method depth 1) need the
532            // flat `$` ctor name; inline nested members use their segment.
533            let base = if depth <= 1 {
534                simple.to_string()
535            } else {
536                // R8 names can END in `$` (`ThreadMsg$$$`): the last
537                // `$`-segment is empty — keep the whole simple name.
538                let seg = simple.rsplit('$').next().unwrap_or(&simple);
539                if seg.is_empty() {
540                    simple.to_string()
541                } else {
542                    seg.to_string()
543                }
544            };
545            let name = if is_restricted_type_name(&base) {
546                format!("_{base}")
547            } else {
548                base
549            };
550            sig.push_str(&java_ident(&name));
551        } else {
552            sig.push_str(&type_name(pool, &d.ret));
553            sig.push(' ');
554            sig.push_str(&java_ident(&m.name));
555        }
556        sig.push('(');
557        let n = d.args.len();
558        let varargs = a & ACC_VARARGS != 0 && n > 0;
559        for (i, arg) in d.args.iter().enumerate() {
560            if i > 0 {
561                sig.push_str(", ");
562            }
563            let name = param_names
564                .get(i)
565                .cloned()
566                .unwrap_or_else(|| format!("p{}", i));
567            if varargs && i + 1 == n {
568                if let JavaType::Array(inner) = arg {
569                    sig.push_str(&type_name(pool, inner));
570                    sig.push_str("...");
571                } else {
572                    sig.push_str(&type_name(pool, arg));
573                }
574            } else {
575                sig.push_str(&type_name(pool, arg));
576            }
577            sig.push(' ');
578            // Param names come from dex debug info — obfuscated apps
579            // name them `_` (reserved since Java 9) or after keywords.
580            sig.push_str(&java_ident(&name));
581        }
582        sig.push(')');
583    }
584
585    if is_clinit {
586        let Some(b) = body else { return Ok(None) };
587        let printer = Printer::new(ctx, &b.vt);
588        let t_print = std::time::Instant::now();
589        let body_text = printer.into_string(&b.body);
590        crate::method::phase_hit(3, t_print);
591        let mut out = String::new();
592        out.push_str(&ind);
593        out.push_str("static {\n");
594        for line in body_text.lines() {
595            if line.is_empty() {
596                out.push('\n');
597            } else {
598                out.push_str(&ind);
599                out.push_str("    ");
600                out.push_str(line);
601                out.push('\n');
602            }
603        }
604        out.push_str(&ind);
605        out.push_str("}\n");
606        return Ok(Some(out));
607    }
608    if a & (ACC_ABSTRACT | ACC_NATIVE) != 0 || body.is_none() {
609        return Ok(Some(format!("{}{};\n", ind, sig)));
610    }
611    let Some(b) = body else { return Ok(None) };
612
613    // The printer indents relative to 0; emit_method adds the absolute
614    // prefix (ind + one level) per line.
615    let mut printer = Printer::new(ctx, &b.vt);
616    match &b.desc.ret {
617        JavaType::Boolean => {
618            printer = printer.with_ret_bool(true);
619        }
620        JavaType::Char => {
621            printer = printer.with_ret_char(true);
622        }
623        JavaType::Byte => {
624            printer = printer.with_ret_narrow(true, false);
625        }
626        JavaType::Short => {
627            printer = printer.with_ret_narrow(false, true);
628        }
629        _ => {}
630    }
631    let t_print = std::time::Instant::now();
632    let body_text = printer.into_string(&b.body);
633    crate::method::phase_hit(3, t_print);
634
635    let mut out = String::new();
636    out.push_str(&ind);
637    out.push_str(&sig);
638    out.push_str(" {\n");
639    if !body_text.trim().is_empty() {
640        for line in body_text.lines() {
641            if line.is_empty() {
642                out.push('\n');
643            } else {
644                out.push_str(&ind);
645                out.push_str("    ");
646                out.push_str(line);
647                out.push('\n');
648            }
649        }
650    }
651    out.push_str(&ind);
652    out.push_str("}\n");
653    Ok(Some(out))
654}
655
656// ---------------------------------------------------------------------------
657// Naming helpers
658// ---------------------------------------------------------------------------
659
660fn indent(depth: usize) -> String {
661    "    ".repeat(depth)
662}
663
664/// `$`-separated nesting rendered with dots — but ONLY when every
665/// segment is a clean Java identifier (a genuine member class chain).
666/// Anonymous (`Outer$1`), Kotlin synthetic (`Version$bigInteger$2`,
667/// `...$$inlined$collect$1`) and local-class tails are NOT member
668/// classes Java can name; the whole name stays flat with `$` (ddc emits
669/// them as their own top-level files).
670/// Kotlin emits method names like `invokeSuspend$lambda-0` — `-` (and
671/// any other non-identifier character) is not legal Java. Deterministic
672/// mapping, applied identically at declaration and call sites.
673pub(crate) fn java_ident(name: &str) -> String {
674    if std::env::var_os("DDC_DBG_IDENT").is_some() {
675        eprintln!("[ident] {name:?}");
676    }
677    let clean = name
678        .chars()
679        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$');
680    // Kotlin names fields/methods `default` (Companion.default) — no Java
681    // program can declare or reference a keyword; the identical mapping
682    // lives in jdc-core's call-site sanitizer.
683    let keyword = matches!(
684        name,
685        "abstract"
686            | "assert"
687            | "boolean"
688            | "break"
689            | "byte"
690            | "case"
691            | "catch"
692            | "char"
693            | "class"
694            | "const"
695            | "continue"
696            | "default"
697            | "do"
698            | "double"
699            | "else"
700            | "enum"
701            | "extends"
702            | "final"
703            | "finally"
704            | "float"
705            | "for"
706            | "goto"
707            | "if"
708            | "implements"
709            | "import"
710            | "instanceof"
711            | "int"
712            | "interface"
713            | "long"
714            | "native"
715            | "new"
716            | "package"
717            | "private"
718            | "protected"
719            | "public"
720            | "return"
721            | "short"
722            | "static"
723            | "strictfp"
724            | "super"
725            | "switch"
726            | "synchronized"
727            | "this"
728            | "throw"
729            | "throws"
730            | "transient"
731            | "try"
732            | "void"
733            | "volatile"
734            | "while"
735            | "true"
736            | "false"
737            | "null"
738            // `_` is a reserved identifier since Java 9 (Alipay's
739            // instant-run fields are named `_`) — the `_<name>` mapping
740            // turns it into `__`, matching jdc-core's call sites.
741            | "_"
742    );
743    // A simple name may not START with a digit either (WhatsApp nests
744    // `X/0Xx`): the declaration site and every reference (jdc-core's
745    // sanitize_source_name) prefix the same underscore.
746    let digit_start = name.chars().next().is_some_and(|c| c.is_ascii_digit());
747    if clean && !keyword && !digit_start {
748        name.to_string()
749    } else if keyword || digit_start {
750        format!("_{name}")
751    } else {
752        // Non-ASCII single chars (Alipay names a field `支`) map to a
753        // lone `_` — itself reserved since Java 9. Escape it.
754        let mapped: String = name
755            .chars()
756            .map(|c| {
757                if c.is_ascii_alphanumeric() || c == '_' || c == '$' {
758                    c
759                } else {
760                    '_'
761                }
762            })
763            .collect();
764        if mapped == "_" {
765            "__".to_string()
766        } else {
767            mapped
768        }
769    }
770}
771
772fn split_name(internal: &str) -> (String, String) {
773    match internal.rfind('/') {
774        Some(i) => (internal[..i].to_string(), internal[i + 1..].to_string()),
775        None => (String::new(), internal.to_string()),
776    }
777}
778
779/// Dotted source form of an internal name.
780pub fn dotted(internal: &str) -> String {
781    let mut out = internal.replace('/', ".");
782    // `$` → `.` only when the following segment can start a Java
783    // identifier (anonymous/synthetic tails stay `$`).
784    let mut i = 0;
785    while let Some(p) = out[i..].find('$') {
786        let at = i + p;
787        // A LEADING `$` (ProGuard keeps `$Gson$Types`) is part of the
788        // source name — dotting it produced a leading `.Gson.Types`.
789        let head_ok = at > 0
790            && out[..at]
791                .chars()
792                .next_back()
793                .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
794        let tail_ok = out[at + 1..]
795            .chars()
796            .next()
797            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
798        if tail_ok && head_ok {
799            out.replace_range(at..at + 1, ".");
800            i = at + 1;
801        } else {
802            i = at + 1;
803        }
804    }
805    // Every `.`-segment must start an identifier: WhatsApp's `X/0Hl`
806    // reached `extends` with the digit start unmapped.
807    sanitize_fq(&out)
808}
809
810fn join_dotted(pool: &DexPool, names: &[String]) -> String {
811    names
812        .iter()
813        .map(|n| print_class_name(pool, n))
814        .collect::<Vec<_>>()
815        .join(", ")
816}
817
818/// A printable type name (arrays render with `[]` suffixes). Nested class
819/// names dot their `$` when the outer chain is known to the pool.
820pub fn type_name(pool: &DexPool, t: &JavaType) -> String {
821    match t {
822        JavaType::Void => "void".into(),
823        JavaType::Object(n) => print_class_name(pool, n),
824        JavaType::Array(inner) => format!("{}[]", type_name(pool, inner)),
825        other => other.to_java(false),
826    }
827}
828
829/// `com/foo/Outer$Inner` → `com.foo.Outer.Inner` — each `$` dots only when
830/// its left side names a known class (literal `$` top-level names survive).
831pub fn print_class_name(pool: &DexPool, internal: &str) -> String {
832    let cow = crate::apply_class_rename(internal);
833    let internal: &str = &cow;
834    let mut out = String::new();
835    let mut rest: &str = internal;
836    loop {
837        match rest.find('$') {
838            Some(i) => {
839                let cand = &rest[..i];
840                let known = pool.get(cand).is_some() || cand == internal;
841                // The `$` may only become a nesting dot when the tail
842                // segment STARTS a Java identifier: R8's desugared-
843                // library names carry `$` inside PACKAGE paths
844                // (`j$/util/...` dotted into `j..util`) and suffixes
845                // like `Collection$-EL` or anonymous `RequestId$1`
846                // cannot be dotted under any reading.
847                let tail_ok = rest[i + 1..]
848                    .chars()
849                    .next()
850                    .is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
851                out.push_str(&cand.replace('/', "."));
852                out.push_str(if known && tail_ok { "." } else { "$" });
853                rest = &rest[i + 1..];
854            }
855            None => {
856                let seg = rest.replace('/', ".");
857                out.push_str(&seg);
858                return sanitize_ref(&out);
859            }
860        }
861    }
862}
863
864/// Class-file names may contain characters Java source identifiers
865/// cannot (`Collection$-EL`); the deterministic mapping matches the
866/// declaration sites (java_ident).
867/// Every `.`-segment of a fully-qualified name must start a Java
868/// identifier: obfuscators emit `package do;` and `..badge.new..` paths.
869pub(crate) fn sanitize_fq(dotted: &str) -> String {
870    dotted
871        .split('.')
872        .map(|seg| {
873            if is_java_keyword_name(seg)
874                || is_restricted_type_name(seg)
875                || seg.chars().next().is_some_and(|c| c.is_ascii_digit())
876            {
877                format!("_{seg}")
878            } else if seg
879                .chars()
880                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
881            {
882                seg.to_string()
883            } else {
884                // Non-ASCII single chars (rimet nests classes named `ˆ`
885                // / `ァ`) map to a lone `_` — reserved since Java 9.
886                let mapped: String = seg
887                    .chars()
888                    .map(|c| {
889                        if c.is_ascii_alphanumeric() || c == '_' || c == '$' {
890                            c
891                        } else {
892                            '_'
893                        }
894                    })
895                    .collect();
896                if mapped == "_" {
897                    "__".to_string()
898                } else {
899                    mapped
900                }
901            }
902        })
903        .collect::<Vec<_>>()
904        .join(".")
905}
906
907/// Restricted contextual TYPE names — legal as member/local names
908/// (rt.jar compiles `var` locals), illegal in class declarations and
909/// type references. Consulted only on CLASS-name paths.
910pub(crate) fn is_restricted_type_name(s: &str) -> bool {
911    matches!(s, "var" | "yield" | "record" | "sealed" | "permits")
912}
913
914fn is_java_keyword_name(s: &str) -> bool {
915    matches!(
916        s,
917        "abstract"
918            | "assert"
919            | "boolean"
920            | "break"
921            | "byte"
922            | "case"
923            | "catch"
924            | "char"
925            | "class"
926            | "const"
927            | "continue"
928            | "default"
929            | "do"
930            | "double"
931            | "else"
932            | "enum"
933            | "extends"
934            | "final"
935            | "finally"
936            | "float"
937            | "for"
938            | "goto"
939            | "if"
940            | "implements"
941            | "import"
942            | "instanceof"
943            | "int"
944            | "interface"
945            | "long"
946            | "native"
947            | "new"
948            | "package"
949            | "private"
950            | "protected"
951            | "public"
952            | "return"
953            | "short"
954            | "static"
955            | "strictfp"
956            | "super"
957            | "switch"
958            | "synchronized"
959            | "this"
960            | "throw"
961            | "throws"
962            | "transient"
963            | "try"
964            | "void"
965            | "volatile"
966            | "while"
967            | "true"
968            | "false"
969            | "null"
970            // `_` is a reserved identifier since Java 9 (Alipay's
971            // instant-run fields are named `_`) — the `_<name>` mapping
972            // turns it into `__`, matching jdc-core's call sites.
973            | "_"
974    )
975}
976
977fn sanitize_ref(name: &str) -> String {
978    sanitize_fq(name)
979}
980
981/// Dotted source form of an internal name.
982pub fn dotted_pool(pool: &DexPool, internal: &str) -> String {
983    print_class_name(pool, internal)
984}
985
986/// Unused import silencer.
987#[allow(dead_code)]
988fn _unused(_: &dyn Fn(&JavaType) -> jdc_core::types::GenericType) {
989    let _ = java_type_to_generic;
990}