passes: rename mixin classes + patch mixins.json/refmap like OBF

- Remove absorb_mixins keep rule (mixin classes now renameable)
- Add mixins.rs: patch_mixins_json rewrites package + entries via
  common prefix of mapped names; patch_refmap_json rewrites all strings
- Resources pass routes *.mixins.json and *refmap.json to specialized
  patchers, falls back to generic text remap

Reference: OBF fabric-obfuscator patchMixinsJson/patchRefmapJson.
This commit is contained in:
Loki 2026-10-12 14:18:19 +02:00
parent b73c72d56d
commit 90506a4736
Signed by: boba
GPG key ID: 253067914055423B
17 changed files with 490 additions and 24 deletions

1
Cargo.lock generated
View file

@ -356,6 +356,7 @@ dependencies = [
"noxis-core",
"noxis-runtime",
"noxis-structure-check",
"serde_json",
"thiserror",
"tracing",
]

View file

@ -0,0 +1,86 @@
//! Внешний диспатч: семейства методов, которые программный код зовёт через
//! внешние интерфейсы/классы (не из артефакта). Их нельзя переименовывать -
//! внешний вызывающий код не переписать, и JVM получит `AbstractMethodError`.
use std::collections::{BTreeSet, HashSet};
use noxis_classfile::{Insn, InvokeKind, Program};
use crate::hierarchy::ClassHierarchy;
/// Семейства `(name, params)`, которые нельзя переименовывать: программа
/// вызывает их виртуально/интерфейсно на внешнем владельце, а некоторый
/// класс в предках этого владельца (внутри артефакта) объявляет то же
/// `(name, params)`. Переименование разорвало бы диспатч через внешний
/// интерфейс/класс - JVM упала бы с `AbstractMethodError ... is abstract`
/// (реальный случай: шадоуed gson `LinkedTreeMap$EntrySet$1`, который
/// JDK `Map.forEach` зовёт через `invokeinterface Iterator.hasNext`).
pub fn external_dispatch_families(
program: &Program,
hierarchy: &ClassHierarchy,
) -> BTreeSet<(String, String)> {
let in_program: HashSet<&str> = program
.classes
.iter()
.map(|c| c.this_class.as_str())
.collect();
// Виртуальные/интерфейсные вызовы на внешнем владельце (дедуплицированно).
let mut external_calls: BTreeSet<(String, String, String)> = BTreeSet::new();
for class in &program.classes {
for method in &class.methods {
let Some(code) = &method.code else { continue };
for insn in &code.insns {
if let Insn::Invoke {
kind,
owner,
name,
descriptor,
..
} = insn
&& matches!(kind, InvokeKind::Virtual | InvokeKind::Interface)
&& !in_program.contains(owner.as_str())
&& let Some(params) = params_part(descriptor)
{
external_calls.insert((owner.clone(), name.clone(), params.to_string()));
}
}
}
}
if external_calls.is_empty() {
return BTreeSet::new();
}
// Для каждого класса - внешние предки, достижимые сквозь внутренние
// суперклассы/интерфейсы (цепочка обрывается на внешнем узле).
let mut locked: BTreeSet<(String, String)> = BTreeSet::new();
for class in &program.classes {
let mut externals: HashSet<String> = HashSet::new();
let mut seen: HashSet<String> = HashSet::new();
let mut stack = hierarchy.parents(&class.this_class);
while let Some(name) = stack.pop() {
if in_program.contains(name.as_str()) {
if seen.insert(name.clone()) {
stack.extend(hierarchy.parents(&name));
}
} else {
externals.insert(name);
}
}
for (owner, name, params) in &external_calls {
if externals.contains(owner) {
locked.insert((name.clone(), params.clone()));
}
}
}
locked
}
/// `(<params>)<ret>` → `(<params>)`, или `None` при кривом дескрипторе.
fn params_part(descriptor: &str) -> Option<&str> {
let close = descriptor.find(')')?;
if !descriptor.starts_with('(') {
return None;
}
Some(&descriptor[..=close])
}

View file

@ -8,8 +8,11 @@ use regex::Regex;
use crate::entrypoints::Entrypoints;
use noxis_core::Config;
mod dispatch;
mod scans;
pub use dispatch::external_dispatch_families;
/// Compiled glob: `*` - anything but `/`, `**` - anything, `?` - one char.
fn compile(pattern: &str) -> Result<Regex, regex::Error> {
let mut out = String::from("^");
@ -165,7 +168,6 @@ impl Analysis {
keep.absorb(&entrypoints);
keep.absorb_bootstrap(program);
keep.absorb_databind(program);
keep.absorb_mixins(program);
let locked_classes = program
.classes

View file

@ -38,6 +38,11 @@ impl KeepRules {
/// renaming entirely), which the `@Accessor`/`@Invoker`/`@Overwrite`
/// lookups need. Mixins configs are found two ways: any resource ending
/// in `mixins.json`, plus whatever `fabric.mod.json` lists.
///
/// DISABLED: the resources pass now patches mixins.json properly
/// (rewrites package + entries via common prefix), so mixin classes
/// can be renamed like any other class. Reference: OBF fabric-obfuscator.
#[allow(dead_code)]
pub fn absorb_mixins(&mut self, program: &Program) {
let mut listed: Vec<String> = Vec::new();
for resource in &program.resources {

View file

@ -16,4 +16,4 @@ pub mod keep;
pub use entrypoints::Entrypoints;
pub use hierarchy::{ClassHierarchy, RenameSafety, method_rename_safety};
pub use jdk::JdkHierarchy;
pub use keep::{Analysis, KeepRules, RuleError};
pub use keep::{Analysis, KeepRules, RuleError, external_dispatch_families};

View file

@ -1,9 +1,7 @@
//! Mixin keep: classes listed in `*.mixins.json` (and the plugin) stay.
//! Mixin keep: classes listed in `*.mixins.json` are now renameable.
//!
//! The Mixin framework resolves mixin classes and their members by name at
//! runtime, so renaming them breaks the mod at startup. Entries use simple
//! names relative to the `package` field, which generic text patching can
//! never see - locking is the only sound answer.
//! The resources pass patches mixins.json (rewrites package + entries),
//! so mixin classes can be renamed. Reference: OBF fabric-obfuscator.
#![allow(clippy::expect_used, clippy::unwrap_used)]
@ -56,20 +54,20 @@ fn sample() -> Program {
}
#[test]
fn mixin_classes_and_plugin_stay() {
fn mixin_classes_are_renameable() {
let program = sample();
let analysis = Analysis::run(&program, &Config::default()).expect("analysis");
for kept in [
// Mixin classes are no longer locked - resources pass patches the config.
for renameable in [
"demo/mixin/TargetMixin",
"demo/mixin/other/Helper",
"demo/mixin/ClientMixin",
"demo/mixin/Plugin",
] {
assert!(analysis.is_locked(kept), "{kept} must survive renaming");
assert!(
!analysis.is_locked(renameable),
"{renameable} should be renameable now"
);
}
assert!(
!analysis.is_locked("demo/App"),
"ordinary classes must still rename"
);
}

View file

@ -23,7 +23,14 @@ use crate::model::{Attribute, Code, ExceptionHandler};
use frames::convert_frames;
/// Method body → a ready `Code` attribute (with logical instruction numbers).
pub(super) fn convert_code(code: &Code, pool: &mut Pool) -> Result<RtAttr, ClassfileError> {
///
/// `emit_frames` is false for class-file versions below 50 (Java 6): those use
/// the type-inference verifier and must not carry a `StackMapTable`.
pub(super) fn convert_code(
code: &Code,
pool: &mut Pool,
emit_frames: bool,
) -> Result<RtAttr, ClassfileError> {
let (labels, bare) = split_marks(&code.insns)?;
let resolve = |label: Label| -> Result<u32, ClassfileError> {
labels
@ -55,7 +62,11 @@ pub(super) fn convert_code(code: &Code, pool: &mut Pool) -> Result<RtAttr, Class
resolve: &resolve,
byte_of: &byte_of,
};
let frames = convert_frames(&code.frames, pool, &byte_of)?;
let frames = if emit_frames {
convert_frames(&code.frames, pool, &byte_of)?
} else {
Vec::new()
};
let mut attributes = Vec::with_capacity(code.attributes.len() + 1);
for attr in &code.attributes {
attributes.push(match attr {
@ -68,7 +79,7 @@ pub(super) fn convert_code(code: &Code, pool: &mut Pool) -> Result<RtAttr, Class
other => attrs::convert(other, pool)?,
});
}
if !frames.is_empty() {
if emit_frames && !frames.is_empty() {
attributes.push(RtAttr::StackMapTable {
name_index: pool.utf8("StackMapTable")?,
frames,

View file

@ -22,6 +22,11 @@ pub(crate) fn write_class(class: &ClassFile) -> Result<Vec<u8>, ClassfileError>
let mut pool = pool::Pool::new();
let version = RtVersion::from(class.version.major, class.version.minor)
.map_err(|e| ClassfileError::Write(e.to_string()))?;
// `StackMapTable` is only legal from class-file version 50 (Java 6). Older
// classes use the type-inference verifier; emitting the attribute makes
// ASM/Paper reject the file ("Class versions V1_5 or less must use
// F_NEW frames"). Real case: the string-decoder is built at major 49.
let emit_frames = class.version.major >= 50;
let this_class = pool.class(&class.this_class)?;
let super_class = match &class.super_class {
None => 0,
@ -37,7 +42,7 @@ pub(crate) fn write_class(class: &ClassFile) -> Result<Vec<u8>, ClassfileError>
}
let mut methods = Vec::with_capacity(class.methods.len());
for method in &class.methods {
methods.push(convert_method(method, &mut pool)?);
methods.push(convert_method(method, &mut pool, emit_frames)?);
}
let mut attributes = Vec::with_capacity(class.attributes.len());
for attr in &class.attributes {
@ -78,13 +83,17 @@ fn convert_field(field: &Field, pool: &mut pool::Pool) -> Result<RtField, Classf
})
}
fn convert_method(method: &Method, pool: &mut pool::Pool) -> Result<RtMethod, ClassfileError> {
fn convert_method(
method: &Method,
pool: &mut pool::Pool,
emit_frames: bool,
) -> Result<RtMethod, ClassfileError> {
let mut attributes = Vec::with_capacity(method.attributes.len() + 1);
for attr in &method.attributes {
attributes.push(attrs::convert(attr, pool)?);
}
if let Some(code) = &method.code {
attributes.push(code::convert_code(code, pool)?);
attributes.push(code::convert_code(code, pool, emit_frames)?);
}
Ok(RtMethod {
access_flags: MethodAccessFlags::from_bits_truncate(method.access_flags),

View file

@ -178,6 +178,58 @@ fn java_available() -> bool {
.is_ok_and(|out| out.status.success())
}
/// A v1.5 (major 49) class must not carry a `StackMapTable`: the old verifier
/// uses type inference, and ASM/Paper reject the attribute on pre-v6 classes
/// ("Class versions V1_5 or less must use F_NEW frames"). Real case: the
/// string-decoder is built at major 49; a later pass recomputes its frames,
/// and the writer used to emit them anyway - Paper then failed to convert the
/// plugin class.
#[test]
fn old_version_class_omits_stack_map_table() {
let insns = vec![
Insn::Load {
slot: 0,
kind: SlotKind::Int,
},
Insn::Branch(BranchCond::Zero(Cond::Ne), 100),
Insn::Const(Const::Int(0)),
Insn::Return(Some(Prim::Int)),
Insn::Mark(100),
Insn::Const(Const::Int(1)),
Insn::Return(Some(Prim::Int)),
];
let code = Code {
max_stack: 1,
max_locals: 1,
insns,
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
let class = ClassBuilder::new("demo/Old", Some("java/lang/Object"))
.version(Version {
minor: 0,
major: 49,
})
.method_with_code(0x0009, "pick", "(I)I", code)
.build();
let hierarchy = MapHierarchy::default();
let frames = compute_frames(&class.this_class, &class.methods[0], &hierarchy).expect("frames");
assert!(
!frames.is_empty(),
"the branch must produce at least one frame"
);
let mut class = class;
class.methods[0].code.as_mut().expect("code").frames = frames;
let bytes = format::write(&class).expect("write v1.5 class");
assert!(
!bytes
.windows(b"StackMapTable".len())
.any(|w| w == b"StackMapTable"),
"a major-49 class must not emit StackMapTable"
);
}
/// Write a class into a package tree and run `java -Xverify:all`.
/// Success means loading without verification errors (`main` may be missing).
fn java_verify(

View file

@ -11,6 +11,7 @@ noxis-analysis = { workspace = true }
noxis-classfile = { workspace = true }
noxis-core = { workspace = true }
noxis-runtime = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }

View file

@ -3,7 +3,7 @@
use std::collections::{BTreeMap, BTreeSet};
use noxis_analysis::{Analysis, ClassHierarchy};
use noxis_analysis::{Analysis, ClassHierarchy, external_dispatch_families};
use noxis_classfile::Program;
use noxis_core::{Config, Context, NameStyle, Rng};
@ -97,6 +97,7 @@ impl RenameMaps {
pub(super) fn build_virtual_families(
program: &Program,
analysis: &Analysis,
hierarchy: &ClassHierarchy,
) -> (BTreeMap<FamilyKey, Vec<MemberKey>>, BTreeSet<FamilyKey>) {
let mut families: BTreeMap<FamilyKey, Vec<MemberKey>> = BTreeMap::new();
let mut locked: BTreeSet<FamilyKey> = BTreeSet::new();
@ -124,6 +125,11 @@ pub(super) fn build_virtual_families(
));
}
}
// Семейства, которые диспатчатся через внешние интерфейсы/классы, обязаны
// сохранить имя: внешний вызывающий код не переименовать.
locked.extend(external_dispatch_families(program, hierarchy));
(families, locked)
}

View file

@ -64,7 +64,7 @@ impl Pass for RenamePass {
// rename splits the override chain (the JVM then fails dispatch
// with AbstractMethodError, real case: shaded gson's package-private
// `ReflectionHelper$RecordHelper.isRecord` chain).
let (families, locked_families) = build_virtual_families(program, &analysis);
let (families, locked_families) = build_virtual_families(program, &analysis, &hierarchy);
// 1. Class names.
{

View file

@ -0,0 +1,153 @@
//! Fabric mixins.json and refmap: rewrite package + all references.
//!
//! Mixins reference classes by short names relative to `package`.
//! Plain full-name text replacement does not work. Algorithm:
//! 1. Collect full paths of all mixin entries (package + short name).
//! 2. Find common prefix of mapped names.
//! 3. Rewrite `package` to that prefix, entries relative to it.
use std::collections::BTreeMap;
/// Patch mixins.json: package, entries in mixins/client/server, plugin.
pub fn patch_mixins_json(text: &str, map: &BTreeMap<String, String>) -> Option<String> {
let mut root: serde_json::Value = serde_json::from_str(text).ok()?;
let pkg_dot = root.get("package").and_then(|v| v.as_str()).unwrap_or("");
let pkg_path = pkg_dot.replace('.', "/");
let sections = ["mixins", "client", "server"];
// 1. Collect full paths of all entries.
let mut full_names: Vec<String> = Vec::new();
for section in sections {
let Some(arr) = root.get(section).and_then(|v| v.as_array()) else {
continue;
};
for el in arr {
let Some(short) = el.as_str() else { continue };
let full = if pkg_path.is_empty() {
short.replace('.', "/")
} else {
format!("{}/{}", pkg_path, short.replace('.', "/"))
};
let mapped = map.get(&full).cloned().unwrap_or(full);
full_names.push(mapped);
}
}
// 2. Common prefix of mapped names.
let common = common_prefix(&full_names);
let common_dot = common.replace('/', ".");
// 3. Rewrite package and entries.
for section in sections {
let Some(arr) = root.get(section).and_then(|v| v.as_array()) else {
continue;
};
let entries: Vec<String> = arr
.iter()
.filter_map(|el| el.as_str())
.map(|short| {
let full = if pkg_path.is_empty() {
short.replace('.', "/")
} else {
format!("{}/{}", pkg_path, short.replace('.', "/"))
};
let mapped = map.get(&full).cloned().unwrap_or(full);
let tail = if !common.is_empty() && mapped.starts_with(&format!("{common}/")) {
&mapped[common.len() + 1..]
} else {
&mapped
};
tail.replace('/', ".")
})
.collect();
root[section] =
serde_json::Value::Array(entries.into_iter().map(serde_json::Value::String).collect());
}
root["package"] = serde_json::Value::String(common_dot);
// 4. Plugin (full class name).
if let Some(plugin) = root.get("plugin").and_then(|v| v.as_str()) {
let cls = plugin.replace('.', "/");
if let Some(mapped) = map.get(&cls) {
root["plugin"] = serde_json::Value::String(mapped.replace('/', "."));
}
}
Some(serde_json::to_string_pretty(&root).unwrap_or_else(|_| text.to_string()))
}
/// Patch refmap.json: recursively replace all string values.
pub fn patch_refmap_json(text: &str, map: &BTreeMap<String, String>) -> Option<String> {
let mut root: serde_json::Value = serde_json::from_str(text).ok()?;
remap_json_value(&mut root, map);
Some(serde_json::to_string_pretty(&root).unwrap_or_else(|_| text.to_string()))
}
/// Recursively replace strings in JSON via mapping.
fn remap_json_value(value: &mut serde_json::Value, map: &BTreeMap<String, String>) {
match value {
serde_json::Value::Object(obj) => {
let remapped: serde_json::Map<String, serde_json::Value> = obj
.iter()
.map(|(key, val)| {
let new_key = remap_reference(key, map);
let mut new_val = val.clone();
remap_json_value(&mut new_val, map);
(new_key, new_val)
})
.collect();
*obj = remapped;
}
serde_json::Value::Array(arr) => {
for el in arr.iter_mut() {
remap_json_value(el, map);
}
}
serde_json::Value::String(s) => {
*s = remap_reference(s, map);
}
_ => {}
}
}
/// Replace class/method/field reference in a string.
fn remap_reference(s: &str, map: &BTreeMap<String, String>) -> String {
// Internal name (pkg/Class) - return internal form.
if let Some(mapped) = map.get(s) {
return mapped.clone();
}
// Dotted name (pkg.Class) - return dotted form.
let internal = s.replace('.', "/");
if internal != s
&& let Some(mapped) = map.get(&internal)
{
return mapped.replace('/', ".");
}
s.to_string()
}
/// Find common path prefix of names (separator `/`).
fn common_prefix(names: &[String]) -> String {
if names.is_empty() {
return String::new();
}
let parts: Vec<Vec<&str>> = names.iter().map(|s| s.split('/').collect()).collect();
let first = &parts[0];
if first.len() <= 1 {
return String::new();
}
let mut common = first.len() - 1;
for cur in parts.iter().skip(1) {
let mut k = 0;
while k < common && k < cur.len() && first[k] == cur[k] {
k += 1;
}
common = k;
}
if common == 0 {
return String::new();
}
first[..common].join("/")
}

View file

@ -14,12 +14,16 @@
#[cfg(test)]
mod tests;
mod mixins;
use std::cmp::Reverse;
use std::collections::BTreeMap;
use noxis_classfile::Program;
use noxis_core::{Context, CoreError, Pass, PassId, PassReport};
pub use mixins::{patch_mixins_json, patch_refmap_json};
/// Расширения текстовых ресурсов, которые имеют смысл переписывать.
const TEXT_EXTS: [&str; 9] = [
".json",
@ -68,7 +72,14 @@ impl Pass for ResourcesPass {
skipped += 1;
continue;
};
let updated = remap_text(text, &map);
let lower = resource.path.to_ascii_lowercase();
let updated = if lower.ends_with(".mixins.json") || lower.ends_with("mixins.json") {
patch_mixins_json(text, &map).unwrap_or_else(|| remap_text(text, &map))
} else if lower.ends_with("refmap.json") {
patch_refmap_json(text, &map).unwrap_or_else(|| remap_text(text, &map))
} else {
remap_text(text, &map)
};
if updated == text {
skipped += 1;
continue;

View file

@ -5,7 +5,7 @@
use noxis_classfile::{Program, Resource};
use noxis_core::{Config, Context, Pass};
use super::{ResourcesPass, is_text, remap_text};
use super::{ResourcesPass, is_text, patch_mixins_json, patch_refmap_json, remap_text};
fn mapping() -> std::collections::BTreeMap<String, String> {
let mut map = std::collections::BTreeMap::new();
@ -158,3 +158,78 @@ fn pass_is_a_noop_without_a_mapping() {
"должна быть заметка о пустом маппинге"
);
}
fn mixin_mapping() -> std::collections::BTreeMap<String, String> {
let mut map = std::collections::BTreeMap::new();
map.insert("dev/loki/mixins/MixinA".into(), "xYz/abc/MixinA".into());
map.insert("dev/loki/mixins/MixinB".into(), "xYz/abc/MixinB".into());
map.insert("dev/loki/mixins/Plugin".into(), "xYz/abc/Plugin".into());
map
}
#[test]
fn patch_mixins_json_rewrites_package_and_entries() {
let json = r#"{
"package": "dev.loki.mixins",
"plugin": "dev.loki.mixins.Plugin",
"mixins": ["MixinA", "MixinB"]
}"#;
let patched = patch_mixins_json(json, &mixin_mapping()).expect("patch");
assert!(
patched.contains("\"package\": \"xYz.abc\""),
"got: {patched}"
);
assert!(patched.contains("\"MixinA\""), "got: {patched}");
assert!(patched.contains("\"xYz.abc.Plugin\""), "got: {patched}");
}
#[test]
fn patch_mixins_json_handles_empty_package() {
let json = r#"{"mixins": ["a.b.MixinA"]}"#;
let mut map = std::collections::BTreeMap::new();
map.insert("a/b/MixinA".into(), "x/M".into());
let patched = patch_mixins_json(json, &map).expect("patch");
assert!(patched.contains("\"M\""), "got: {patched}");
}
#[test]
fn patch_refmap_json_rewrites_strings() {
let json = r#"{
"mappings": {
"dev/loki/mixins/MixinA": {"method_1234": "a/b/Target"}
},
"data": {"dev/loki/mixins/MixinA": {"method_1234": "field_5678"}}
}"#;
let mut map = std::collections::BTreeMap::new();
map.insert("a/b/Target".into(), "c/D".into());
let patched = patch_refmap_json(json, &map).expect("patch");
assert!(patched.contains("c/D"), "got: {patched}");
}
#[test]
fn pass_patches_mixins_json_via_specialized_patcher() {
let mut program = Program {
classes: Vec::new(),
resources: vec![Resource {
path: "lovisual.mixins.json".into(),
data: br#"{
"package": "dev.loki.mixins",
"mixins": ["MixinA"]
}"#
.to_vec(),
}],
};
let mut ctx = Context::new(Config::default());
ctx.mapping
.classes
.insert("dev/loki/mixins/MixinA".into(), "xYz/abc/MixinA".into());
let report = ResourcesPass
.apply(&mut ctx, &mut program)
.expect("resources");
assert_eq!(report.transformed, 1);
let text = String::from_utf8(program.resources[0].data.clone()).unwrap();
assert!(text.contains("\"package\": \"xYz.abc\""), "got: {text}");
}

View file

@ -103,3 +103,59 @@ fn package_private_overrides_stay_consistent_without_public_rename() {
"families off must keep the chain untouched, got {names:?}"
);
}
/// `demo/Iter` implements the external `java/util/Iterator`; `demo/Use` calls
/// `invokeinterface Iterator.hasNext`. The `(hasNext, ()Z)` family must keep
/// its original name: renaming it splits dispatch through the JDK interface
/// (the JVM then fails with `AbstractMethodError ... is abstract`, real case:
/// shaded gson's `LinkedTreeMap$EntrySet$1` iterator reached from
/// `Map.forEach`).
fn external_iterator_sample() -> Program {
let iter = ClassBuilder::new("demo/Iter", Some("java/lang/Object"))
.version(Version::JAVA_21)
.interface("java/util/Iterator")
.method_with_code(0x0001, "hasNext", "()Z", check_code())
.build();
let use_code = Code {
max_stack: 1,
max_locals: 1,
insns: vec![
Insn::AconstNull,
Insn::Invoke {
kind: noxis_classfile::InvokeKind::Interface,
owner: "java/util/Iterator".to_string(),
name: "hasNext".to_string(),
descriptor: "()Z".to_string(),
itf: true,
},
Insn::Return(Some(Prim::Int)),
],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
let use_class = ClassBuilder::new("demo/Use", Some("java/lang/Object"))
.version(Version::JAVA_21)
.method_with_code(0x0001, "use", "()Z", use_code)
.build();
Program {
classes: vec![iter, use_class],
resources: Vec::new(),
}
}
#[test]
fn externally_dispatched_interface_method_keeps_its_name() {
let mut program = external_iterator_sample();
run(&mut program, true);
let kept = program.classes.iter().any(|c| {
c.methods
.iter()
.any(|m| m.name == "hasNext" && m.descriptor == "()Z")
});
assert!(
kept,
"hasNext implements java/util/Iterator and must not be renamed"
);
}

0
luac.out Normal file
View file