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    // Size the class buffer up front: corpus-average classes render to
99    // ~1KB per method — without the reserve the String doubles through
100    // 4-6 realloc+copy rounds per class (~2× the final size in memmove).
101    // Cap the reserve: weixin's monster classes (hundreds of methods)
102    // would reserve megabytes of untouched capacity per class — large
103    // blocks take mimalloc's commit/purge path (madvise churn showed up
104    // in profiles). 256KB covers the corpus-average class dozens of
105    // times over; bigger classes pay a few extra doublings.
106    let mut out = String::with_capacity(
107        (class.all_methods().count() * 1024).min(256 * 1024) + 512,
108    );
109    if opts.provenance {
110        static BANNER: std::sync::OnceLock<String> = std::sync::OnceLock::new();
111        out.push_str(BANNER.get_or_init(|| {
112            format!(
113                "// Decompiled by https://github.com/ejfkdev/ddc {}\n",
114                env!("CARGO_PKG_VERSION")
115            )
116        }));
117        // Provenance: which input image this class was lifted from (jadx's
118        // `loaded from: classes.dex` pattern). Byte-stable across runs —
119        // deliberately NO timestamp so outputs diff cleanly. Direct
120        // push_str: the per-class format! temporaries were 3 allocations
121        // × every class in the corpus.
122        if let Some(dex) = pool.dex(class.dex_idx) {
123            if let Some(label) = pool.dex_labels.get(class.dex_idx) {
124                out.push_str("// From: ");
125                out.push_str(label);
126                out.push_str(" (DEX ");
127                out.push_str(&dex.version);
128                out.push_str(")\n");
129            }
130        }
131    }
132    if let Some(src) = &class.source_file {
133        out.push_str("// Source file: ");
134        out.push_str(src);
135        out.push('\n');
136    }
137    if class.is_synthetic() {
138        out.push_str("// synthetic\n");
139    }
140    let (pkg, _) = split_name(&class.name);
141    if !pkg.is_empty() {
142        out.push('\n');
143        out.push_str("package ");
144        let dotted = pkg.replace('/', ".");
145        out.push_str(&sanitize_fq(&dotted));
146        out.push_str(";\n");
147    }
148    out.push('\n');
149    // Emit straight into `out`: the intermediate body buffer copied the
150    // whole rendered class (corpus-average ~10KB) a second time — pure
151    // memmove traffic; the error path discards `out` either way.
152    emit_class_body(pool, class, &ctx, opts, &mut out, 0)?;
153    Ok(out)
154}
155
156/// Render the class header, fields, methods and nested member classes into
157/// `out` (used at top level and recursively for nested members).
158// `opts` is only consumed by the nested-member recursion below — that
159// is its purpose (propagating emission options into inline children).
160#[allow(clippy::only_used_in_recursion)]
161fn emit_class_body(
162    pool: &DexPool,
163    class: &PoolClass,
164    ctx: &DexCtx<'_>,
165    opts: &ClassOptions,
166    out: &mut String,
167    depth: usize,
168) -> anyhow::Result<()> {
169    let ind = indent(depth);
170    // The class's own header must use the RENAMED display name — the
171    // ctor path (is_init) already renames; a header declaring `class a`
172    // while the file/ctor say `a_2` leaves the ctor looking like a
173    // method with no return type (MinisApp's Y2.a vs y2.a package
174    // case-collision: 2,518 javac parse errors).
175    let cname = crate::apply_class_rename(&class.name);
176    let (_, simple) = split_name(&cname);
177    // A class emitted as its OWN top-level file (depth 0) must declare a
178    // flat `$` name — `class Outer.Inner` is not declarable at file
179    // scope. An INLINE nested member (depth > 0) declares its own
180    // segment inside the parent's body.
181    let simple = if depth == 0 {
182        simple.to_string()
183    } else {
184        // R8 names can END in `$`: an empty last `$`-segment would
185        // render `class  {` — keep the whole simple name.
186        let seg = simple.rsplit('$').next().unwrap_or(&simple);
187        if seg.is_empty() {
188            simple.to_string()
189        } else {
190            seg.to_string()
191        }
192    };
193    // A class NAMED `var`-style (taobao ships `tb.var`) cannot be
194    // declared — restricted contextual type names escape here and in
195    // the ctor, file name, and every type reference.
196    let simple = if is_restricted_type_name(&simple) {
197        format!("_{simple}")
198    } else {
199        simple
200    };
201    let is_iface = class.is_interface();
202    let is_enum = class.is_enum();
203
204    let mut head = String::new();
205    let a = class.access;
206    if a & ACC_PUBLIC != 0 {
207        head.push_str("public ");
208    }
209    // Inline nested members need their `static` (interfaces/annotations
210    // are implicitly static; a missing `static` on a member class makes
211    // every `new Report(...)` site an "outer instance required" error).
212    if depth > 0 && !is_iface && ctx.nested_is_static(&class.name) {
213        head.push_str("static ");
214    }
215    if a & ACC_FINAL != 0 && !is_enum {
216        head.push_str("final ");
217    }
218    if a & ACC_ABSTRACT != 0 && !is_iface {
219        head.push_str("abstract ");
220    }
221    if a & ACC_ANNOTATION != 0 {
222        head.push('@');
223    }
224    if is_enum {
225        // An enum with constant-specific bodies carries ACC_ABSTRACT —
226        // `abstract final` is an illegal modifier combination; abstract
227        // (already pushed above) suppresses the hardcoded final.
228        let abstract_ = a & ACC_ABSTRACT != 0;
229        head.push_str(if abstract_ {
230            "/* enum */ class "
231        } else {
232            "/* enum */ final class "
233        });
234        head.push_str(&java_ident(&simple));
235    } else if is_iface {
236        head.push_str("interface ");
237        head.push_str(&java_ident(&simple));
238    } else {
239        head.push_str("class ");
240        head.push_str(&java_ident(&simple));
241    }
242    if is_iface {
243        if !class.interfaces.is_empty() {
244            head.push_str(" extends ");
245            head.push_str(&join_dotted(pool, &class.interfaces));
246        }
247    } else {
248        if let Some(sup) = &class.super_name {
249            if sup != "java/lang/Object" && !is_enum {
250                head.push_str(" extends ");
251                head.push_str(&print_class_name(pool, sup));
252            }
253        }
254        if !class.interfaces.is_empty() {
255            head.push_str(if is_iface {
256                " extends "
257            } else {
258                " implements "
259            });
260            head.push_str(&join_dotted(pool, &class.interfaces));
261        }
262    }
263
264    out.push_str(&ind);
265    out.push_str(&head);
266    out.push_str(" {\n");
267
268    // Fields.
269    for (i, f) in class.static_fields.iter().enumerate() {
270        if i > 0 || !class.instance_fields.is_empty() {
271            out.push('\n');
272        }
273        emit_field(
274            pool,
275            f,
276            class.static_values.get(i),
277            &class.name,
278            out,
279            depth + 1,
280            true,
281            class.is_interface(),
282        );
283    }
284    if !class.instance_fields.is_empty() && !class.static_fields.is_empty() {
285        out.push('\n');
286    }
287    for (i, f) in class.instance_fields.iter().enumerate() {
288        if i > 0 {
289            out.push('\n');
290        }
291        emit_field(pool, f, None, &class.name, out, depth + 1, false, false);
292    }
293
294    // Methods.
295    // Duplicated (name, parameter-types) pairs in one class are javac
296    // "method is already defined" errors (weibo: 11.5k hits). Source
297    // cannot declare them, so exactly one survives: covariant bridges
298    // (`Object get(int)` beside `ByteString get(int)` — the compiler
299    // GENERATES bridges), plus R8 output that lost the bridge flag
300    // (okio Buffer.clone, interface getView re-declarations). A
301    // non-bridge method outranks a bridge for the same signature
302    // (bridge bodies are delegation stubs); first occurrence otherwise.
303    // Never drop a signature outright — the last method standing for a
304    // key is always rendered.
305    fn sig_key(m: &PoolMethod) -> (&str, &str) {
306        let d: &str = &m.desc;
307        let lo = d.find('(').map(|i| i + 1).unwrap_or(0);
308        let hi = d.find(')').unwrap_or(d.len());
309        (&*m.name, &d[lo..hi])
310    }
311    let methods: Vec<&PoolMethod> = class.all_methods().collect();
312    let mut claim: jdc_core::FxHashMap<(&str, &str), usize> = jdc_core::FxHashMap::default();
313    for (i, m) in methods.iter().enumerate() {
314        let key = sig_key(m);
315        match claim.get(&key) {
316            Some(&j)
317                if m.access & crate::access::ACC_BRIDGE == 0
318                    && methods[j].access & crate::access::ACC_BRIDGE != 0 =>
319            {
320                claim.insert(key, i);
321            }
322            None => {
323                claim.insert(key, i);
324            }
325            _ => {}
326        }
327    }
328    let mut emitted_any = !class.static_fields.is_empty() || !class.instance_fields.is_empty();
329    for (i, m) in methods.iter().enumerate() {
330        if &*m.name == "<clinit>" {
331            continue; // rendered after the fields
332        }
333        if claim.get(&sig_key(m)) != Some(&i) {
334            continue;
335        }
336        let mark = out.len();
337        if emitted_any {
338            out.push('\n');
339        }
340        if emit_method(pool, class, ctx, m, depth + 1, out)? {
341            emitted_any = true;
342        } else {
343            out.truncate(mark);
344        }
345    }
346    // Static initializer. INTERFACES cannot carry a `static { }` block in
347    // Java — their clinit only assigns constants, which static_values (or
348    // the `= null` default) already render as field initializers; skip
349    // the block entirely.
350    let skip_clinit = class.is_interface();
351    if let Some(clinit) = (!skip_clinit)
352        .then(|| class.all_methods().find(|m| &*m.name == "<clinit>"))
353        .flatten()
354    {
355        let mark = out.len();
356        if emitted_any {
357            out.push('\n');
358        }
359        if emit_method(pool, class, ctx, clinit, depth + 1, out)? {
360            emitted_any = true;
361        } else {
362            out.truncate(mark);
363        }
364    }
365
366    // Nested member classes (clean `$` tails only; anonymous/local/lambda
367    // classes are emitted as their own top-level files by the driver).
368    for nested in nested_members(pool, class, ctx) {
369        if emitted_any {
370            out.push('\n');
371        }
372        let nested_ctx = DexCtx::new(pool, nested);
373        out.push('\n');
374        emit_class_body(pool, nested, &nested_ctx, opts, out, depth + 1)?;
375        emitted_any = true;
376    }
377
378    out.push_str(&ind);
379    out.push_str("}\n");
380    Ok(())
381}
382
383/// Member classes of `class` (direct children by the `$` chain / member
384/// annotations), excluding anonymous / local / lambda shapes.
385fn nested_members<'a>(
386    pool: &'a DexPool,
387    class: &'a PoolClass,
388    ctx: &DexCtx<'_>,
389) -> Vec<&'a PoolClass> {
390    let mut out = Vec::new();
391    for name in pool.children_of(&class.name) {
392        let Some(pc) = pool.get(name) else { continue };
393        // Only a REAL `outer$tail` name is an inline member: the
394        // children index also carries annotation-derived outers
395        // (EnclosingClass) with no naming relationship to the child
396        // (obfuscated apps pair a 1-char name with a long enclosing
397        // descriptor) — the blind slice panicked there (alipay
398        // `a.a.a.a.c`, exposed once children_of actually returned data).
399        let Some(rest) = name
400            .strip_prefix(class.name.as_str())
401            .and_then(|t| t.strip_prefix('$'))
402        else {
403            continue;
404        };
405        if rest.is_empty() || rest.starts_with('-') {
406            continue;
407        }
408        if ctx.find_outer(name).as_deref() != Some(class.name.as_str()) {
409            continue;
410        }
411        let tail = rest.rsplit('$').next().unwrap_or(rest);
412        if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
413            continue; // anonymous
414        }
415        if tail.starts_with(|c: char| c.is_ascii_digit()) {
416            continue; // local
417        }
418        out.push(pc);
419    }
420    out
421}
422
423// Eight parameters are all load-bearing (pool, field, static value,
424// owner class, output, depth, staticness, interface-init requirement);
425// bundling them into a struct would obscure each call site.
426#[allow(clippy::too_many_arguments)]
427fn emit_field(
428    pool: &DexPool,
429    f: &crate::PoolField,
430    init: Option<&StaticValue>,
431    f_class: &str,
432    out: &mut String,
433    depth: usize,
434    is_static: bool,
435    require_init: bool,
436) {
437    let ind = indent(depth);
438    let mut line = String::new();
439    let a = f.access;
440    if a & ACC_PUBLIC != 0 {
441        line.push_str("public ");
442    } else if a & ACC_PRIVATE != 0 {
443        line.push_str("private ");
444    } else if a & ACC_PROTECTED != 0 {
445        line.push_str("protected ");
446    }
447    if is_static {
448        line.push_str("static ");
449    }
450    if a & ACC_FINAL != 0 {
451        line.push_str("final ");
452    }
453    if a & ACC_SYNTHETIC != 0 {
454        line.push_str("/* synthetic */ ");
455    }
456    if a & ACC_TRANSIENT_HINT != 0 {
457        line.push_str("transient ");
458    }
459    if a & ACC_VOLATILE_HINT != 0 {
460        line.push_str("volatile ");
461    }
462    let ty = type_name(pool, &desc_type(&f.desc));
463    line.push_str(&ty);
464    line.push(' ');
465    line.push_str(&java_ident(&f.name));
466    let mut rendered = None;
467    if let Some(v) = init {
468        rendered = render_static_value(pool, v, f_class);
469    }
470    if rendered.is_none() && require_init {
471        // Interface fields MUST have an initializer in Java; the dex may
472        // not carry a static_values entry for a compile-time-constant the
473        // compiler folded away. Keep it compilable.
474        let default = match desc_type(&f.desc) {
475            JavaType::Boolean => "false",
476            JavaType::Byte | JavaType::Short | JavaType::Char | JavaType::Int => "0",
477            JavaType::Long => "0L",
478            JavaType::Float => "0.0F",
479            JavaType::Double => "0.0",
480            _ => "null",
481        };
482        rendered = Some(default.to_string());
483    }
484    if let Some(text) = rendered {
485        // A long field holding an `Int(i64)` static value needs the `L`
486        // suffix: without it the literal is an int and overflows
487        // (`long d = -6343169151696340687` failed javac).
488        let text = if matches!(desc_type(&f.desc), JavaType::Long)
489            && text.chars().all(|c| c.is_ascii_digit() || c == '-')
490        {
491            format!("{text}L")
492        } else {
493            text
494        };
495        line.push_str(" = ");
496        line.push_str(&text);
497    }
498    line.push(';');
499    out.push_str(&ind);
500    out.push_str(&line);
501    out.push('\n');
502}
503
504// DEX field access_flags bits 0x40/0x80 are bridge/varargs for METHODS and
505// volatile(0x40)/transient(0x80) for fields.
506pub const ACC_VOLATILE_HINT: u32 = 0x40;
507pub const ACC_TRANSIENT_HINT: u32 = 0x80;
508
509fn render_static_value(pool: &DexPool, v: &StaticValue, owner: &str) -> Option<String> {
510    Some(match v {
511        StaticValue::Int(i) => i.to_string(),
512        StaticValue::Float(f) => format_float(*f as f64, true),
513        StaticValue::Double(d) => format_float(*d, false),
514        StaticValue::Str(s) => format!("\"{}\"", escape_string(s)),
515        StaticValue::Type(t) => format!("{}.class", dotted(t)),
516        StaticValue::Boolean(b) => b.to_string(),
517        StaticValue::Null => "null".into(),
518        StaticValue::Field(cls, name) => {
519            let n = java_ident(name);
520            if cls == owner {
521                n.into_owned()
522            } else {
523                format!("{}.{}", print_class_name(pool, cls), n)
524            }
525        }
526        StaticValue::Other => return None,
527    })
528}
529
530/// Render one method straight into the class buffer `out`. Returns
531/// whether anything was written (skips: no descriptor, deferred to a
532/// monitored thread). The old shape returned a per-method String that
533/// the caller copied in — one extra full copy of every method body.
534fn emit_method(
535    pool: &DexPool,
536    class: &PoolClass,
537    ctx: &DexCtx<'_>,
538    m: &PoolMethod,
539    depth: usize,
540    out: &mut String,
541) -> anyhow::Result<bool> {
542    let ind = indent(depth);
543    let desc = m.parsed_desc();
544
545    // Signature.
546    let mut sig = String::with_capacity(192);
547    let a = m.access;
548    if a & ACC_PUBLIC != 0 {
549        sig.push_str("public ");
550    } else if a & ACC_PRIVATE != 0 {
551        sig.push_str("private ");
552    } else if a & ACC_PROTECTED != 0 {
553        sig.push_str("protected ");
554    }
555    let is_clinit = &*m.name == "<clinit>";
556    let is_init = &*m.name == "<init>";
557    if a & ACC_STATIC != 0 || is_clinit {
558        sig.push_str("static ");
559    }
560    if a & ACC_FINAL != 0 {
561        sig.push_str("final ");
562    }
563    // `abstract synchronized` is an illegal combination — obfuscated
564    // builds mark abstract bridges synchronized (WhatsApp
565    // SQLiteOpenHelper).
566    if a & (ACC_SYNCHRONIZED | ACC_DECLARED_SYNCHRONIZED) != 0 && a & ACC_ABSTRACT == 0 {
567        sig.push_str("synchronized ");
568    }
569    if a & ACC_NATIVE != 0 {
570        sig.push_str("native ");
571    }
572    if a & ACC_ABSTRACT != 0 {
573        sig.push_str("abstract ");
574    }
575    if a & ACC_SYNTHETIC != 0 {
576        sig.push_str("/* synthetic */ ");
577    }
578
579    // Body (needed for parameter names even for abstract methods).
580    let body = decompile_method(pool, class, m).ok().flatten();
581    let param_names: Vec<String> = body
582        .as_ref()
583        .map(|b| {
584            let mut ps: Vec<(u16, String)> =
585                b.vt.vars
586                    .iter()
587                    .filter(|v| v.is_param && v.name != "this")
588                    .map(|v| (v.slot, v.name.clone()))
589                    .collect();
590            ps.sort_by_key(|(s, _)| *s);
591            ps.into_iter().map(|(_, n)| n).collect()
592        })
593        .unwrap_or_else(|| {
594            (0..desc.as_ref().map(|d| d.args.len()).unwrap_or(0))
595                .map(|i| format!("p{}", i + 1))
596                .collect()
597        });
598
599    if is_clinit {
600        // `static { ... }` — the caller strips the method name/params.
601    } else {
602        let Some(d) = &desc else { return Ok(false) };
603        if is_init {
604            // The ctor name must equal the DECLARED class name of its
605            // file: flat `$` at depth 0 (own file), own segment when
606            // inlined in the parent at depth > 0. Case-renamed classes
607            // use their display name.
608            let dname = crate::apply_class_rename(&class.name);
609            let (_, simple) = split_name(&dname);
610            // emit_method's depth is the METHOD indent = class depth + 1:
611            // own-file classes (depth 0 header → method depth 1) need the
612            // flat `$` ctor name; inline nested members use their segment.
613            let base = if depth <= 1 {
614                simple.to_string()
615            } else {
616                // R8 names can END in `$` (`ThreadMsg$$$`): the last
617                // `$`-segment is empty — keep the whole simple name.
618                let seg = simple.rsplit('$').next().unwrap_or(&simple);
619                if seg.is_empty() {
620                    simple.to_string()
621                } else {
622                    seg.to_string()
623                }
624            };
625            let name = if is_restricted_type_name(&base) {
626                format!("_{base}")
627            } else {
628                base
629            };
630            sig.push_str(&java_ident(&name));
631        } else {
632            sig.push_str(&type_name(pool, &d.ret));
633            sig.push(' ');
634            sig.push_str(&java_ident(&m.name));
635        }
636        sig.push('(');
637        let n = d.args.len();
638        let varargs = a & ACC_VARARGS != 0 && n > 0;
639        for (i, arg) in d.args.iter().enumerate() {
640            if i > 0 {
641                sig.push_str(", ");
642            }
643            let name = param_names
644                .get(i)
645                .cloned()
646                .unwrap_or_else(|| format!("p{}", i));
647            if varargs && i + 1 == n {
648                if let JavaType::Array(inner) = arg {
649                    sig.push_str(&type_name(pool, inner));
650                    sig.push_str("...");
651                } else {
652                    sig.push_str(&type_name(pool, arg));
653                }
654            } else {
655                sig.push_str(&type_name(pool, arg));
656            }
657            sig.push(' ');
658            // Param names come from dex debug info — obfuscated apps
659            // name them `_` (reserved since Java 9) or after keywords.
660            sig.push_str(&java_ident(&name));
661        }
662        sig.push(')');
663    }
664
665    if is_clinit {
666        let Some(b) = body else { return Ok(false) };
667        // Direct render: the printer starts at the method's ABSOLUTE
668        // indent and appends into the same buffer that carries the
669        // header — no intermediate body string, no per-line re-indent
670        // pass (two full copies of every method body saved).
671        out.push_str(&ind);
672        out.push_str("static {\n");
673        let hdr = out.len();
674        let printer = Printer::new(ctx, &b.vt)
675            .with_indent(depth + 1)
676            .with_output(std::mem::take(out));
677        let t_print = std::time::Instant::now();
678        let mut rendered = printer.into_string(&b.body);
679        crate::method::phase_hit(3, t_print);
680        if rendered[hdr..].trim().is_empty() {
681            rendered.truncate(hdr);
682        }
683        rendered.push_str(&ind);
684        rendered.push_str("}\n");
685        *out = rendered;
686        return Ok(true);
687    }
688    if a & (ACC_ABSTRACT | ACC_NATIVE) != 0 || body.is_none() {
689        out.push_str(&ind);
690        out.push_str(&sig);
691        out.push_str(";\n");
692        return Ok(true);
693    }
694    let Some(b) = body else { return Ok(false) };
695
696    // Direct render at the absolute indent level (see the clinit path).
697    let mut printer = Printer::new(ctx, &b.vt).with_indent(depth + 1);
698    match &b.desc.ret {
699        JavaType::Boolean => {
700            printer = printer.with_ret_bool(true);
701        }
702        JavaType::Char => {
703            printer = printer.with_ret_char(true);
704        }
705        JavaType::Byte => {
706            printer = printer.with_ret_narrow(true, false);
707        }
708        JavaType::Short => {
709            printer = printer.with_ret_narrow(false, true);
710        }
711        _ => {}
712    }
713    out.push_str(&ind);
714    out.push_str(&sig);
715    out.push_str(" {\n");
716    let hdr = out.len();
717    let printer = printer.with_output(std::mem::take(out));
718    let t_print = std::time::Instant::now();
719    let mut rendered = printer.into_string(&b.body);
720    crate::method::phase_hit(3, t_print);
721    if rendered[hdr..].trim().is_empty() {
722        rendered.truncate(hdr);
723    }
724    rendered.push_str(&ind);
725    rendered.push_str("}\n");
726    *out = rendered;
727    Ok(true)
728}
729
730// ---------------------------------------------------------------------------
731// Naming helpers
732// ---------------------------------------------------------------------------
733
734fn indent(depth: usize) -> String {
735    "    ".repeat(depth)
736}
737
738/// `$`-separated nesting rendered with dots — but ONLY when every
739/// segment is a clean Java identifier (a genuine member class chain).
740/// Anonymous (`Outer$1`), Kotlin synthetic (`Version$bigInteger$2`,
741/// `...$$inlined$collect$1`) and local-class tails are NOT member
742/// classes Java can name; the whole name stays flat with `$` (ddc emits
743/// them as their own top-level files).
744/// Kotlin emits method names like `invokeSuspend$lambda-0` — `-` (and
745/// any other non-identifier character) is not legal Java. Deterministic
746/// mapping, applied identically at declaration and call sites.
747pub(crate) fn java_ident(name: &str) -> std::borrow::Cow<'_, str> {
748    // env::var_os is an environ lock+scan — java_ident runs per IDENTIFIER
749    // (millions per APK), where it profiled as __NSGetEnviron.
750    static DBG_IDENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
751    if *DBG_IDENT.get_or_init(|| std::env::var_os("DDC_DBG_IDENT").is_some()) {
752        eprintln!("[ident] {name:?}");
753    }
754    let clean = name
755        .chars()
756        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$');
757    // Kotlin names fields/methods `default` (Companion.default) — no Java
758    // program can declare or reference a keyword; the identical mapping
759    // lives in jdc-core's call-site sanitizer.
760    let keyword = matches!(
761        name,
762        "abstract"
763            | "assert"
764            | "boolean"
765            | "break"
766            | "byte"
767            | "case"
768            | "catch"
769            | "char"
770            | "class"
771            | "const"
772            | "continue"
773            | "default"
774            | "do"
775            | "double"
776            | "else"
777            | "enum"
778            | "extends"
779            | "final"
780            | "finally"
781            | "float"
782            | "for"
783            | "goto"
784            | "if"
785            | "implements"
786            | "import"
787            | "instanceof"
788            | "int"
789            | "interface"
790            | "long"
791            | "native"
792            | "new"
793            | "package"
794            | "private"
795            | "protected"
796            | "public"
797            | "return"
798            | "short"
799            | "static"
800            | "strictfp"
801            | "super"
802            | "switch"
803            | "synchronized"
804            | "this"
805            | "throw"
806            | "throws"
807            | "transient"
808            | "try"
809            | "void"
810            | "volatile"
811            | "while"
812            | "true"
813            | "false"
814            | "null"
815            // `_` is a reserved identifier since Java 9 (Alipay's
816            // instant-run fields are named `_`) — the `_<name>` mapping
817            // turns it into `__`, matching jdc-core's call sites.
818            | "_"
819    );
820    // A simple name may not START with a digit either (WhatsApp nests
821    // `X/0Xx`): the declaration site and every reference (jdc-core's
822    // sanitize_source_name) prefix the same underscore.
823    let digit_start = name.chars().next().is_some_and(|c| c.is_ascii_digit());
824    if clean && !keyword && !digit_start {
825        std::borrow::Cow::Borrowed(name)
826    } else if keyword || digit_start {
827        std::borrow::Cow::Owned(format!("_{name}"))
828    } else {
829        // Non-ASCII single chars (Alipay names a field `支`) map to a
830        // lone `_` — itself reserved since Java 9. Escape it.
831        let mapped: String = name
832            .chars()
833            .map(|c| {
834                if c.is_ascii_alphanumeric() || c == '_' || c == '$' {
835                    c
836                } else {
837                    '_'
838                }
839            })
840            .collect();
841        if mapped == "_" {
842            std::borrow::Cow::Owned("__".to_string())
843        } else {
844            std::borrow::Cow::Owned(mapped)
845        }
846    }
847}
848
849fn split_name(internal: &str) -> (String, String) {
850    match internal.rfind('/') {
851        Some(i) => (internal[..i].to_string(), internal[i + 1..].to_string()),
852        None => (String::new(), internal.to_string()),
853    }
854}
855
856/// Dotted source form of an internal name.
857pub fn dotted(internal: &str) -> String {
858    let mut out = internal.replace('/', ".");
859    // `$` → `.` only when the following segment can start a Java
860    // identifier (anonymous/synthetic tails stay `$`).
861    let mut i = 0;
862    while let Some(p) = out[i..].find('$') {
863        let at = i + p;
864        // A LEADING `$` (ProGuard keeps `$Gson$Types`) is part of the
865        // source name — dotting it produced a leading `.Gson.Types`.
866        let head_ok = at > 0
867            && out[..at]
868                .chars()
869                .next_back()
870                .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
871        let tail_ok = out[at + 1..]
872            .chars()
873            .next()
874            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
875        if tail_ok && head_ok {
876            out.replace_range(at..at + 1, ".");
877            i = at + 1;
878        } else {
879            i = at + 1;
880        }
881    }
882    // Every `.`-segment must start an identifier: WhatsApp's `X/0Hl`
883    // reached `extends` with the digit start unmapped.
884    sanitize_fq(&out)
885}
886
887fn join_dotted(pool: &DexPool, names: &[String]) -> String {
888    names
889        .iter()
890        .map(|n| print_class_name(pool, n))
891        .collect::<Vec<_>>()
892        .join(", ")
893}
894
895/// A printable type name (arrays render with `[]` suffixes). Nested class
896/// names dot their `$` when the outer chain is known to the pool.
897pub fn type_name(pool: &DexPool, t: &JavaType) -> String {
898    match t {
899        JavaType::Void => "void".into(),
900        JavaType::Object(n) => print_class_name(pool, n),
901        JavaType::Array(inner) => format!("{}[]", type_name(pool, inner)),
902        other => other.to_java(false),
903    }
904}
905
906/// `com/foo/Outer$Inner` → `com.foo.Outer.Inner` — each `$` dots only when
907/// its left side names a known class (literal `$` top-level names survive).
908pub fn print_class_name(pool: &DexPool, internal: &str) -> String {
909    let cow = crate::apply_class_rename(internal);
910    let internal: &str = &cow;
911    let mut out = String::new();
912    // `known` must test the ACCUMULATED internal prefix, not the bare
913    // inter-`$` segment: the per-segment shape checked `pool.get("a")`
914    // for the second level of `s5/o$a$b`, missed, and rendered the
915    // undeclarable reference `s5.o.a$b` (找不到符号) for every nested-
916    // nested type.
917    let mut off = 0usize;
918    loop {
919        let rest = &internal[off..];
920        match rest.find('$') {
921            Some(i) => {
922                let seg = &rest[..i];
923                let prefix = &internal[..off + i];
924                let known = pool.get(prefix).is_some()
925                    || jdc_core::rename::is_renamed_display(prefix);
926                // The `$` may only become a nesting dot when the tail
927                // segment STARTS a Java identifier: R8's desugared-
928                // library names carry `$` inside PACKAGE paths
929                // (`j$/util/...` dotted into `j..util`) and suffixes
930                // like `Collection$-EL` or anonymous `RequestId$1`
931                // cannot be dotted under any reading.
932                let tail_ok = rest[i + 1..]
933                    .chars()
934                    .next()
935                    .is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
936                out.push_str(&seg.replace('/', "."));
937                out.push_str(if known && tail_ok { "." } else { "$" });
938                off += i + 1;
939            }
940            None => {
941                out.push_str(&rest.replace('/', "."));
942                return sanitize_ref(&out);
943            }
944        }
945    }
946}
947
948/// Class-file names may contain characters Java source identifiers
949/// cannot (`Collection$-EL`); the deterministic mapping matches the
950/// declaration sites (java_ident).
951/// Every `.`-segment of a fully-qualified name must start a Java
952/// identifier: obfuscators emit `package do;` and `..badge.new..` paths.
953pub(crate) fn sanitize_fq(dotted: &str) -> String {
954    dotted
955        .split('.')
956        .map(|seg| {
957            if is_java_keyword_name(seg)
958                || is_restricted_type_name(seg)
959                || seg.chars().next().is_some_and(|c| c.is_ascii_digit())
960            {
961                format!("_{seg}")
962            } else if seg
963                .chars()
964                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
965            {
966                seg.to_string()
967            } else {
968                // Non-ASCII single chars (rimet nests classes named `ˆ`
969                // / `ァ`) map to a lone `_` — reserved since Java 9.
970                let mapped: String = seg
971                    .chars()
972                    .map(|c| {
973                        if c.is_ascii_alphanumeric() || c == '_' || c == '$' {
974                            c
975                        } else {
976                            '_'
977                        }
978                    })
979                    .collect();
980                if mapped == "_" {
981                    "__".to_string()
982                } else {
983                    mapped
984                }
985            }
986        })
987        .collect::<Vec<_>>()
988        .join(".")
989}
990
991/// Restricted contextual TYPE names — legal as member/local names
992/// (rt.jar compiles `var` locals), illegal in class declarations and
993/// type references. Consulted only on CLASS-name paths.
994pub(crate) fn is_restricted_type_name(s: &str) -> bool {
995    matches!(s, "var" | "yield" | "record" | "sealed" | "permits")
996}
997
998fn is_java_keyword_name(s: &str) -> bool {
999    matches!(
1000        s,
1001        "abstract"
1002            | "assert"
1003            | "boolean"
1004            | "break"
1005            | "byte"
1006            | "case"
1007            | "catch"
1008            | "char"
1009            | "class"
1010            | "const"
1011            | "continue"
1012            | "default"
1013            | "do"
1014            | "double"
1015            | "else"
1016            | "enum"
1017            | "extends"
1018            | "final"
1019            | "finally"
1020            | "float"
1021            | "for"
1022            | "goto"
1023            | "if"
1024            | "implements"
1025            | "import"
1026            | "instanceof"
1027            | "int"
1028            | "interface"
1029            | "long"
1030            | "native"
1031            | "new"
1032            | "package"
1033            | "private"
1034            | "protected"
1035            | "public"
1036            | "return"
1037            | "short"
1038            | "static"
1039            | "strictfp"
1040            | "super"
1041            | "switch"
1042            | "synchronized"
1043            | "this"
1044            | "throw"
1045            | "throws"
1046            | "transient"
1047            | "try"
1048            | "void"
1049            | "volatile"
1050            | "while"
1051            | "true"
1052            | "false"
1053            | "null"
1054            // `_` is a reserved identifier since Java 9 (Alipay's
1055            // instant-run fields are named `_`) — the `_<name>` mapping
1056            // turns it into `__`, matching jdc-core's call sites.
1057            | "_"
1058    )
1059}
1060
1061fn sanitize_ref(name: &str) -> String {
1062    sanitize_fq(name)
1063}
1064
1065/// Dotted source form of an internal name.
1066pub fn dotted_pool(pool: &DexPool, internal: &str) -> String {
1067    print_class_name(pool, internal)
1068}
1069
1070/// Unused import silencer.
1071#[allow(dead_code)]
1072fn _unused(_: &dyn Fn(&JavaType) -> jdc_core::types::GenericType) {
1073    let _ = java_type_to_generic;
1074}