Skip to content

Conversation

@ThePandaOliver
Copy link
Contributor

@ThePandaOliver ThePandaOliver commented Nov 17, 2025

While thinking of a fix for the mixin issue where the mixin class paths in the mixin configs aren't being remapped to their new name, i thought of just implementing a custom file handling API for files that need special handling as a workaround.

It can be used like this (The example is written in Kotlin DSL)

forgix {
	// Mixin handler preset
	mixinHandler("**.mixins.json")

	// Custom file handler
	fileHandler("**/something.*.xml") { fileName, fileContent, replacementPaths ->
		return // The modified file content
	}
}

Im not sure how to implement this with CLI, but it will probably only be capable of using preset handlers.

Summary by CodeRabbit

  • New Features

    • Extensible custom file handler support to transform file contents during relocation, with APIs updated to accept handler mappings while preserving default behavior when none are provided.
    • Built-in mixin handler to automatically update mixin configuration paths.
    • Build/task flow updated to forward configured handlers through the relocation process.
  • Tests

    • Added tests validating custom file handling and mixin updates during relocation.

@coderabbitai
Copy link

coderabbitai bot commented Nov 17, 2025

Caution

Review failed

The head commit changed during the review from fd5e8d8 to 1e6b535.

Walkthrough

Threads a map of file-pattern -> CustomFileHandler through relocation APIs. Adds CustomFileHandler and MixinFileHandler, exposes registration on ForgixConfiguration, and updates Relocator and callsites (Facade, Multiversion, MergeJarsTask, tests) to accept and forward handler maps during resource relocation and multi-pass flows.

Changes

Cohort / File(s) Summary
File handler types
src/main/java/io/github/pacifistmc/forgix/core/filehandlers/CustomFileHandler.java, src/main/java/io/github/pacifistmc/forgix/core/filehandlers/MixinFileHandler.java
Add CustomFileHandler functional interface and MixinFileHandler implementation to transform resource contents (e.g., rewrite mixin JSON) using replacement mappings.
Relocation core
src/main/java/io/github/pacifistmc/forgix/core/Relocator.java
Change public API to relocate(List<RelocationConfig>, Map<String, CustomFileHandler>); update relocateClasses/relocateResources signatures to accept handler maps and multi-pass flags; integrate custom handlers when processing resources and thread handlers through passes.
Public facade & callers
src/main/java/io/github/pacifistmc/forgix/Forgix.java, src/main/java/io/github/pacifistmc/forgix/core/Multiversion.java, src/main/java/io/github/pacifistmc/forgix/plugin/tasks/MergeJarsTask.java
Update mergeLoaders and relocation callsites to accept/forward Map<String, CustomFileHandler> (default to empty maps where used) and adjust calls to Relocator.relocate(..., customFileHandlers).
Plugin configuration
src/main/java/io/github/pacifistmc/forgix/plugin/configurations/ForgixConfiguration.java
Add customFileHandlers map and methods mixin(String), fileHandler(String, CustomFileHandler), and getCustomFileHandlers() to register handlers from configuration.
Tests
src/test/java/io/github/pacifistmc/forgix/tests/CoreTest.java
Update relocation calls to pass handler maps; add testCustomFileHandling that uses MixinFileHandler and asserts transformed mixin JSON inside output JARs.
Minor imports/refs
src/main/java/io/github/pacifistmc/forgix/core/RelocationConfig.java
Add import for ForgixConfiguration (no behavioral change).
Facade signature change
src/main/java/io/github/pacifistmc/forgix/Forgix.java
mergeLoaders signature extended to accept Map<String, CustomFileHandler> customFileHandlers (default empty) and forwards it to Relocator.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Config as ForgixConfiguration
    participant Facade as Forgix / MergeJarsTask
    participant Relocator
    participant Handler as CustomFileHandler

    User->>Config: mixin(...) / fileHandler(...)
    Config-->>Config: store pattern -> handler

    User->>Facade: mergeLoaders(jars, out, config.getCustomFileHandlers())
    Facade->>Relocator: relocate(relocationConfigs, customFileHandlers)

    Relocator->>Relocator: relocateClasses(...)
    Relocator->>Relocator: relocateResources(..., customFileHandlers, ...)

    loop per resource entry
        Relocator->>Relocator: match resource name against patterns
        alt matched pattern
            Relocator->>Handler: handle(name, content, replacementMap)
            Handler-->>Relocator: transformed content
            Relocator->>Relocator: write transformed resource
        else no match
            Relocator->>Relocator: apply mapping-based replacements
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Focus areas:
    • Relocator.java — verify multi-pass logic, concurrency, correct propagation and precedence of customFileHandlers vs generic replacements.
    • MixinFileHandler — validate JSON parsing, optional fields, and replacement edge cases.
    • Forgix API & callsites — ensure default empty maps preserve previous behavior and tests reflect signature updates.

Possibly related PRs

Poem

🐇 I nibbled paths and chased each glob,

Rewrote mixins with a tidy throb,
Patterns matched, content flipped with care,
Jars hopped home with handlers there,
A whiskered merge — now all's in bob!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary change: adding a custom file handler feature with mixin path remapping functionality.

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (10)
src/main/java/io/github/pacifistmc/forgix/core/RelocationConfig.java (1)

3-3: Unused import.

The ForgixConfiguration import doesn't appear to be used anywhere in this file.

Apply this diff to remove the unused import:

-import io.github.pacifistmc.forgix.plugin.configurations.ForgixConfiguration;
-
src/main/java/io/github/pacifistmc/forgix/core/filehandlers/MixinFileHandler.java (1)

36-37: Remove unused variables.

The mixinPlugin and mixinRef variables are read but never used.

Apply this diff to remove the dead code:

-        JsonPrimitive mixinPlugin = mixinJson.getAsJsonPrimitive("plugin");
-        JsonPrimitive mixinRef = mixinJson.getAsJsonPrimitive("refmap");
-
src/main/java/io/github/pacifistmc/forgix/plugin/configurations/ForgixConfiguration.java (1)

3-3: Unused import.

The Gson import doesn't appear to be used in this file. It may have been added in anticipation of future use or by mistake.

Apply this diff to remove the unused import:

-import com.google.gson.*;
src/main/java/io/github/pacifistmc/forgix/core/Relocator.java (7)

24-28: Static tempDir creation on class load can fail opaquely

Creating the temp directory in a static field initializer means any IOException during class loading will surface as an ExceptionInInitializerError and take the whole tool down without context. Consider lazy-initializing tempDir inside generateMappings (or a small helper) with explicit error handling/logging, and optionally recreating it if it was deleted between runs.


46-73: mappingsSnapshot lifecycle and keying by JarFile may leak state across runs

mappingsSnapshot is static, never cleared, and keyed by JarFile identity. Over multiple invocations of Relocator.relocate you can accumulate stale mappings keyed by closed JarFile instances, and those are never reclaimed until process exit. Also, if a future refactor reuses the same Relocator in a long-lived process, stale mappings could accidentally be observed.

Two concrete suggestions:

  • Clear mappingsSnapshot once the outer relocateClasses(...) call finishes all passes (after the final restoration of mappings).
  • Consider keying by the underlying jar path (relocationConfig.jarFile.getName() or File) instead of JarFile to avoid tying state to object identity.

49-60: RelocateClasses recursive multi-pass lacks hard cap and could recurse deeply

The recursive relocateClasses(relocationConfigs, true); at the end relies on the early-return condition that no mappings end with .class to terminate. If for any reason a new set of .class conflicts keeps appearing each pass (e.g., due to unforeseen mapping interactions), this becomes unbounded recursion and risks stack overflow.

A safer structure would:

  • Replace the recursive call with an explicit loop (e.g., while (true) with a break) and track whether the last pass introduced any new .class mappings.
  • Optionally guard with a reasonable maximum number of passes and fail fast with a clear error if exceeded.

This keeps the behavior but makes termination guarantees explicit and protects against pathological inputs.

Also applies to: 102-118


49-50: Use of List.getFirst() raises the minimum Java version

relocationConfigs.getFirst() relies on the JDK 21+ List#getFirst API. If the project’s intended baseline is Java 17 (common in the Minecraft ecosystem) or earlier, this will not compile without additional tooling.

If you still need to support JDK < 21, consider switching to the conventional relocationConfigs.get(0) with an explicit empty-list check at the public entry point(s).

Also applies to: 127-128


147-162: CustomFileHandler is invoked per-conflict, not per-file, and ignores predicates

The current handler invocation pattern:

  • Builds conflicts as a map from predicates to singleton replacement maps.
  • For each resource entry, loops over all conflicts, and when a handler is present calls handler.handle(entry.getName(), content, conflict.value) for each conflict, without checking conflict.getKey().test(content).

Effects:

  • Handlers are called multiple times per file with one replacement mapping at a time instead of once with all applicable mappings, which is both less efficient and surprising given the API shape (replacementPaths sounds like “all replacements for this file”).
  • Handlers receive mappings that may not be relevant to the current file because the predicate is ignored in the handler path.
  • For larger mappings, this becomes O(#conflicts * size(content)) work per file.

A more predictable design would be:

  • First, collect the union of all conflict value maps that actually apply to a given file (respecting their predicates).
  • If a handler exists, call it once per file with replacementPaths equal to that aggregated map.
  • If no handler exists, apply the aggregated replacements generically.

Sketch:

Map<String, String> applicableReplacements = new HashMap<>();
for (var conflict : conflicts.entrySet()) {
  if (conflict.getKey().test(content)) {
    applicableReplacements.putAll(conflict.getValue());
  }
}

if (handler != null) {
  content = handler.handle(entry.getName(), content, applicableReplacements);
} else {
  for (var replacement : applicableReplacements.entrySet()) {
    content = content.replace(replacement.getKey(), replacement.getValue());
  }
}
contentMapping.put(entry, content);

This keeps the flexibility of custom handlers while aligning better with the advertised API and improving performance.

Also applies to: 164-190


167-175: PathMatcher allocation for every resource/pattern pair is unnecessarily expensive

Inside the resource loop you do:

CustomFileHandler handler = customFileHandlers.entrySet()
  .stream()
  .filter(entry1 -> {
    var matcher = FileSystems.getDefault().getPathMatcher("glob:${entry1.key}");
    return matcher.matches(Paths.get(entry.getName()));
  })
  ...

This constructs a new PathMatcher for each (resource, pattern) combination. With many resources or patterns this becomes a hotspot.

Consider precomputing the matchers once when the configuration is built or at the start of relocateResources, e.g. turn Map<String, CustomFileHandler> into Map<PathMatcher, CustomFileHandler> (or a small helper holder) so you only allocate matchers once per pattern.


209-264: generateMappings/mapConflicts logic is subtle; consider clearer append semantics and cleanup

The append flag drives several behaviors at once:

  • Whether to reuse file.jarFile or reopen via new JarFile(file.jarFile.getName()).
  • Whether to clear all existing RelocationConfig.mappings.
  • Whether new conflicts overwrite vs. accumulate across passes and between classes/resources.

This is hard to reason about, especially in combination with mappingsSnapshot and the multi-pass logic in relocateClasses/relocateResources.

Two concrete improvements:

  • Separate concerns: introduce two flags or an enum to distinguish “re-scan jars using current state” from “discard in-memory mappings but append to tiny file”. That will make the control flow far easier to follow.
  • After the outer relocate workflow completes, clear any temporary or pass-specific state (e.g., filesByPath local is fine, but mappingsSnapshot and any temp tiny files in tempDir may warrant cleanup) to avoid surprising behavior on repeated invocations.

Not urgent, but refactoring this will make future changes to conflict mapping less error‑prone.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 359857e and 637927d.

📒 Files selected for processing (9)
  • src/main/java/io/github/pacifistmc/forgix/Forgix.java (3 hunks)
  • src/main/java/io/github/pacifistmc/forgix/core/Multiversion.java (1 hunks)
  • src/main/java/io/github/pacifistmc/forgix/core/RelocationConfig.java (1 hunks)
  • src/main/java/io/github/pacifistmc/forgix/core/Relocator.java (2 hunks)
  • src/main/java/io/github/pacifistmc/forgix/core/filehandlers/CustomFileHandler.java (1 hunks)
  • src/main/java/io/github/pacifistmc/forgix/core/filehandlers/MixinFileHandler.java (1 hunks)
  • src/main/java/io/github/pacifistmc/forgix/plugin/configurations/ForgixConfiguration.java (3 hunks)
  • src/main/java/io/github/pacifistmc/forgix/plugin/tasks/MergeJarsTask.java (1 hunks)
  • src/test/java/io/github/pacifistmc/forgix/tests/CoreTest.java (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
src/main/java/io/github/pacifistmc/forgix/plugin/configurations/ForgixConfiguration.java (1)
src/main/java/io/github/pacifistmc/forgix/core/filehandlers/MixinFileHandler.java (1)
  • MixinFileHandler (7-41)
src/main/java/io/github/pacifistmc/forgix/Forgix.java (2)
src/main/java/io/github/pacifistmc/forgix/plugin/configurations/ForgixConfiguration.java (1)
  • ForgixConfiguration (19-354)
src/main/java/io/github/pacifistmc/forgix/core/Relocator.java (1)
  • Relocator (23-265)
src/main/java/io/github/pacifistmc/forgix/core/RelocationConfig.java (1)
src/main/java/io/github/pacifistmc/forgix/plugin/configurations/ForgixConfiguration.java (1)
  • ForgixConfiguration (19-354)
src/main/java/io/github/pacifistmc/forgix/plugin/tasks/MergeJarsTask.java (1)
src/main/java/io/github/pacifistmc/forgix/Forgix.java (1)
  • Forgix (17-145)
src/main/java/io/github/pacifistmc/forgix/core/Multiversion.java (1)
src/main/java/io/github/pacifistmc/forgix/core/Relocator.java (1)
  • Relocator (23-265)
src/test/java/io/github/pacifistmc/forgix/tests/CoreTest.java (2)
src/main/java/io/github/pacifistmc/forgix/core/filehandlers/MixinFileHandler.java (1)
  • MixinFileHandler (7-41)
src/main/java/io/github/pacifistmc/forgix/core/Relocator.java (1)
  • Relocator (23-265)
src/main/java/io/github/pacifistmc/forgix/core/Relocator.java (2)
src/main/java/io/github/pacifistmc/forgix/plugin/configurations/ForgixConfiguration.java (1)
  • ForgixConfiguration (19-354)
src/main/java/io/github/pacifistmc/forgix/utils/JAR.java (1)
  • JAR (25-644)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Analyze (actions)
  • GitHub Check: Analyze (java-kotlin)
🔇 Additional comments (8)
src/main/java/io/github/pacifistmc/forgix/plugin/tasks/MergeJarsTask.java (1)

77-77: LGTM!

The integration of custom file handlers into the merge operation is correct and aligns with the new API.

src/main/java/io/github/pacifistmc/forgix/core/Multiversion.java (1)

51-51: LGTM!

Passing an empty map for custom file handlers is appropriate for the multiversion use case and correctly adapts to the new API signature.

src/main/java/io/github/pacifistmc/forgix/core/filehandlers/CustomFileHandler.java (1)

1-8: LGTM!

Clean interface design with appropriate functional interface annotation. The signature provides the necessary flexibility for custom file handling during relocation.

src/main/java/io/github/pacifistmc/forgix/plugin/configurations/ForgixConfiguration.java (1)

336-346: LGTM!

The custom file handler API is well-designed with a clean separation of concerns. The mixin convenience method provides good developer ergonomics for the common use case.

src/test/java/io/github/pacifistmc/forgix/tests/CoreTest.java (3)

193-193: LGTM!

Correctly updated to use the new Relocator.relocate API with an empty map for custom file handlers.


251-251: LGTM!

Correctly updated to use the new Relocator.relocate API with an empty map for custom file handlers.


316-414: LGTM!

Comprehensive test coverage for the custom file handling feature. The test verifies that mixin class paths are correctly remapped in the JSON configuration files for both jars.

src/main/java/io/github/pacifistmc/forgix/Forgix.java (1)

22-34: LGTM!

The API extension maintains backward compatibility with the default parameter and correctly integrates custom file handlers into the relocation pipeline.

Comment on lines +125 to +145
public static void relocateResources(List<RelocationConfig> relocationConfigs, Map<String, CustomFileHandler> customFileHandlers, boolean anotherPass=false) {
// Generate mappings if they don't exist or this is another pass
if (anotherPass || relocationConfigs.getFirst().tinyFile == null) generateMappings(relocationConfigs, !anotherPass);

// Return if there are no new conflicts
// if (anotherPass && relocationConfigs.stream().allMatch(config -> config.mappings.isEmpty())) return;
// This will return if there are no conflicts at all (empty mappings) or there are only class and/or META-INF conflicts which we're ignoring
if (anotherPass && relocationConfigs.stream()
.flatMap(config -> config.mappings.keySet().stream())
.noneMatch(mapping -> !mapping.endsWith(".class") && !mapping.startsWith("META-INF/")) // Checking to see if there's any resource that isn't a class or META-INF, if there isn't then return
) return;

AtomicBoolean doAnotherPass = new AtomicBoolean(false);

// Process each JAR file in parallel
relocationConfigs.parallelStream().forEach(relocationConfig -> {
JarFile jarFile = JAR.isClosed(relocationConfig.jarFile) ? new JarFile(relocationConfig.jarFile.getName()) : relocationConfig.jarFile; // Reopen the JAR file if it's closed (it can be closed by the `relocateClasses` method)

Set<JarEntry> resources = JAR.getResources(jarFile);
Map<JarEntry, String> contentMapping = new HashMap<>();

// Create a map of conflicts with path alterations.
Map<Predicate<String>, Map<String, String>> conflicts = new HashMap<>();
Map<String, String> fileConflicts = new HashMap<>(); // Keep track of mixins to handle them specially
relocationConfig.mappings.forEach((originalPath, relocatedPath) -> {
if (originalPath.endsWith("META-INF/MANIFEST.MF")) return; // Skip manifest
if (!originalPath.endsWith(".class") && !originalPath.startsWith("META-INF/services/")) { // Is a regular file conflict
fileConflicts.put(originalPath, relocatedPath);
}
// replacing with `removeExtension()` would make the ones with extensions be replaced which is what we want
conflicts.put(content -> content.contains(originalPath.removeExtension()), Map.of(originalPath.removeExtension(), relocatedPath.removeExtension())); // Add the original path without the .class extension // the predicate should be _ -> true but for some reason that doesn't work
conflicts.put(_ -> originalPath.contains("/"), Map.of(originalPath.removeExtension().replace('/', '.'), relocatedPath.removeExtension().replace('/', '.'))); // If it's in a directory, add the original path without the .class extension and with dots instead of slashes
conflicts.put(_ -> originalPath.contains("/"), Map.of(originalPath.removeExtension().replace('/', '\\'), relocatedPath.removeExtension().replace('/', '\\'))); // If it's in a directory, add the original path without the .class extension and with backslashes instead of slashes
if (originalPath.endsWith(".class")) { // Is a class conflict
conflicts.put(content -> content.contains("\"${originalPath.getPath().replace('/', '.')}\""), // look for the path with dots and in quotes (for mixins), example: "com.example.mod.mixins"
Map.of("\"${originalPath.getBaseName().removeExtension()}\"", "\"${relocatedPath.getBaseName().removeExtension()}\"")); // Just the filename without the path & extension and in quotes (for mixins)
}
});

resources.parallelStream().forEach(entry -> {
String content = JAR.getResource(jarFile, entry);
for (var conflict : conflicts.entrySet()) {
// TODO: Smart replace
// If a file has "com.example.Meow" and "com.example.Meow2" and we're only replacing "com.example.Meow" then only replace all instances of "com.example.Meow" but not "com.example.Meow2"
if (conflict.getKey().test(content)) {
for (var replacement : conflict.getValue().entrySet()) {
content = content.replace(replacement.getKey(), replacement.getValue());
}
}
}
contentMapping.put(entry, content);
});

doAnotherPass.set(JAR.writeResources(jarFile, contentMapping));
jarFile.close();
JAR.renameResources(jarFile.getName(), fileConflicts);
});

// Do a multiple passes to handle conflicts that were created by the previous pass
if (doAnotherPass.get()) relocateResources(relocationConfigs, true);
}

/**
* Sets up the mappings for conflicting files in JARs.
* @param relocationConfigs The relocationConfigs to process
* @param append Isn't a good name, but we set it to false to check if we have any new conflicts,
* setting it to false will ignore previous mappings and overwrite them,
* but it will still append the mappings to the tiny file
*/
public static void generateMappings(List<RelocationConfig> relocationConfigs, boolean append = true) {
mapConflicts(relocationConfigs, append);
TinyClassWriter.write(relocationConfigs, tempDir);
}

/**
* Maps conflicting entries to their relocated paths.
* @param relocationConfigs The list of relocationConfigs to process
* @param append Whether to append to the existing mappings
*/
private static void mapConflicts(List<RelocationConfig> relocationConfigs, boolean append = true) {
record FileInfo(String path, byte[] hash, RelocationConfig source) { }

// Map to store all relocationConfigs and their hashes grouped by path
Map<String, List<FileInfo>> filesByPath = new ConcurrentHashMap<>();

// Process each JAR file in parallel
relocationConfigs.parallelStream().forEach(file -> {
var jarFile = append ? file.jarFile : new JarFile(file.jarFile.getName()); // If we're not appending, we need to reopen the JAR file
var entries = jarFile.entries();
while (entries.hasMoreElements()) {
var entry = entries.nextElement();
if (!entry.isDirectory()) {
// Normalize the path to use forward slashes (JAR standard)
String path = FilenameUtils.normalize(entry.getName(), true);
byte[] hash = JAR.computeHash(jarFile, entry);

filesByPath.compute(path, (_, existing) -> {
var list = existing == null ? new ArrayList<FileInfo>() : existing;
// Only add if the hash is different
if (list.stream().noneMatch(info -> Arrays.equals(info.hash, hash)))
list.add(new FileInfo(path, hash, file));
return list;
});
}
}
if (!append) jarFile.close(); // Close the jar if we opened it
});

if (!append) { // remove all mappings from the relocation configs as we're not appending
relocationConfigs.forEach(config -> config.setMappings(new HashMap<>()));
}

// Create mappings for conflicts
filesByPath.forEach((_, fileInfos) -> {
// Return if there are no conflicts
if (fileInfos.size() <= 1) return;

// Create mappings for all relocationConfigs
fileInfos.forEach(fileInfo ->
fileInfo.source.mappings.putIfAbsent(fileInfo.path, fileInfo.path.addPrefixExtension(fileInfo.source.conflictPrefix))
);
});
}
// This will return if there are no conflicts at all (empty mappings) or there are only class and/or META-INF conflicts which we're ignoring
if (anotherPass && relocationConfigs.stream()
.flatMap(config -> config.mappings.keySet().stream())
.noneMatch(mapping -> !mapping.endsWith(".class") && !mapping.startsWith("META-INF/")) // Checking to see if there's any resource that isn't a class or META-INF, if there isn't then return
) return;

AtomicBoolean doAnotherPass = new AtomicBoolean(false);

// Process each JAR file in parallel
relocationConfigs.parallelStream().forEach(relocationConfig -> {
JarFile jarFile = JAR.isClosed(relocationConfig.jarFile) ? new JarFile(relocationConfig.jarFile.getName()) : relocationConfig.jarFile; // Reopen the JAR file if it's closed (it can be closed by the `relocateClasses` method)

Set<JarEntry> resources = JAR.getResources(jarFile);
Map<JarEntry, String> contentMapping = new HashMap<>();

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Data races in relocateResources: shared HashMap and AtomicBoolean in parallel streams

There are two concurrency issues in relocateResources:

  1. contentMapping is a plain HashMap written from resources.parallelStream().forEach(...). Concurrent put calls on HashMap are undefined and can corrupt internal state.
  2. doAnotherPass.set(JAR.writeResources(jarFile, contentMapping)); is executed inside a parallelStream over relocationConfigs, so multiple threads race to set the same AtomicBoolean. A later false result can overwrite an earlier true, causing the second-pass recursion to be skipped even when at least one jar wrote changes.

Concrete fix:

- AtomicBoolean doAnotherPass = new AtomicBoolean(false);
+ AtomicBoolean doAnotherPass = new AtomicBoolean(false);

   relocationConfigs.parallelStream().forEach(relocationConfig -> {
     ...
-    Map<JarEntry, String> contentMapping = new HashMap<>();
+    Map<JarEntry, String> contentMapping = new ConcurrentHashMap<>();
     ...
-    resources.parallelStream().forEach(entry -> {
+    resources.forEach(entry -> { // or use a sequential stream
       ...
       contentMapping.put(entry, content);
     });
-
-    doAnotherPass.set(JAR.writeResources(jarFile, contentMapping));
+    if (JAR.writeResources(jarFile, contentMapping)) {
+      doAnotherPass.set(true); // once true, never reset to false
+    }

This removes the data race on contentMapping and ensures doAnotherPass reflects the OR of all jar results rather than the last one.

Also applies to: 192-199

🤖 Prompt for AI Agents
In src/main/java/io/github/pacifistmc/forgix/core/Relocator.java around lines
125-145 (and similarly at 192-199), the code uses a plain HashMap
(contentMapping) written to from a parallelStream and sets an AtomicBoolean by
overwriting it from multiple threads, causing data races and lost updates;
change contentMapping to a thread-safe map (e.g., ConcurrentHashMap) or collect
results in a thread-local structure and merge, and replace the overwrite of
doAnotherPass with an atomic OR operation (e.g., compute the boolean result from
JAR.writeResources(...) into a local variable then call
doAnotherPass.accumulateAndGet(result, (a,b)->a||b) or
doAnotherPass.updateAndGet(v -> v || result)) so multiple threads cannot corrupt
the map and a single false never wipes out a prior true.

…xinFileHandler.java

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
@Ran-Mewo
Copy link
Member

This seems more of a workaround than an actual solution.

I had an idea for a fix a few months ago
The only problem is that I did not write that idea down 😭 (it's gone forever my fren)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c1429d8 and 0d0befe.

📒 Files selected for processing (1)
  • src/main/java/io/github/pacifistmc/forgix/core/filehandlers/MixinFileHandler.java (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Analyze (actions)
  • GitHub Check: Analyze (java-kotlin)

public String handle(String fileName, String fileContent, Map<String, String> replacementPaths) {
Gson gson = new GsonBuilder().setPrettyPrinting().create();
JsonObject mixinJson = gson.fromJson(fileContent, JsonObject.class);
String packagePath = mixinJson.get("package").getAsString();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add null safety check for the "package" field.

The code will throw a NullPointerException if the "package" field is missing or null in the mixin configuration JSON. While this field should always be present in valid mixin configs, defensive coding requires validation.

Apply this diff to add validation:

-        String packagePath = mixinJson.get("package").getAsString();
+        JsonElement packageElement = mixinJson.get("package");
+        if (packageElement == null || !packageElement.isJsonPrimitive()) {
+            throw new IllegalArgumentException("Mixin config '" + fileName + "' is missing required 'package' field");
+        }
+        String packagePath = packageElement.getAsString();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
String packagePath = mixinJson.get("package").getAsString();
JsonElement packageElement = mixinJson.get("package");
if (packageElement == null || !packageElement.isJsonPrimitive()) {
throw new IllegalArgumentException("Mixin config '" + fileName + "' is missing required 'package' field");
}
String packagePath = packageElement.getAsString();
🤖 Prompt for AI Agents
In
src/main/java/io/github/pacifistmc/forgix/core/filehandlers/MixinFileHandler.java
around line 12, the code directly reads mixinJson.get("package").getAsString()
which will throw a NullPointerException if the "package" member is missing or
null; change this to first verify mixinJson.has("package") and that
mixinJson.get("package") is not JsonNull, then read getAsString(); if the field
is missing or null either throw a clear IllegalArgumentException (with the
file/context) or use a sensible default value, and ensure callers handle that
exception accordingly.

Comment on lines +14 to +25
JsonArray commonMixinPaths = mixinJson.getAsJsonArray("mixins");
if (commonMixinPaths != null) {
JsonArray newCommonMixinPaths = new JsonArray();
for (JsonElement mixinPath : commonMixinPaths) {
String fullPath = packagePath + "." + mixinPath.getAsString();
if (replacementPaths.containsKey(fullPath))
newCommonMixinPaths.add(replacementPaths.get(fullPath).substring(packagePath.length() + 1));
else
newCommonMixinPaths.add(mixinPath);
}
mixinJson.add("mixins", newCommonMixinPaths);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix potential StringIndexOutOfBoundsException and incorrect path handling.

The substring operation at line 20 assumes the replacement path is longer than packagePath.length() + 1 and starts with the package prefix. This will throw StringIndexOutOfBoundsException if the replacement path is shorter, or produce incorrect results if the replacement uses a different package structure.

Example failure case:

  • packagePath = "com.example.mixin" (18 chars)
  • fullPath = "com.example.mixin.MyMixin"
  • replacement = "org.other.New" (13 chars)
  • substring(19) throws exception

Apply this diff to safely handle replacement paths:

         JsonArray newCommonMixinPaths = new JsonArray();
         for (JsonElement mixinPath : commonMixinPaths) {
             String fullPath = packagePath + "." + mixinPath.getAsString();
-            if (replacementPaths.containsKey(fullPath))
-                newCommonMixinPaths.add(replacementPaths.get(fullPath).substring(packagePath.length() + 1));
-            else
+            if (replacementPaths.containsKey(fullPath)) {
+                String replacement = replacementPaths.get(fullPath);
+                String prefix = packagePath + ".";
+                if (replacement.startsWith(prefix) && replacement.length() > prefix.length()) {
+                    newCommonMixinPaths.add(replacement.substring(prefix.length()));
+                } else {
+                    // Replacement is in a different package or format; use the class name only
+                    int lastDot = replacement.lastIndexOf('.');
+                    newCommonMixinPaths.add(lastDot >= 0 ? replacement.substring(lastDot + 1) : replacement);
+                }
+            } else {
                 newCommonMixinPaths.add(mixinPath);
+            }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
JsonArray commonMixinPaths = mixinJson.getAsJsonArray("mixins");
if (commonMixinPaths != null) {
JsonArray newCommonMixinPaths = new JsonArray();
for (JsonElement mixinPath : commonMixinPaths) {
String fullPath = packagePath + "." + mixinPath.getAsString();
if (replacementPaths.containsKey(fullPath))
newCommonMixinPaths.add(replacementPaths.get(fullPath).substring(packagePath.length() + 1));
else
newCommonMixinPaths.add(mixinPath);
}
mixinJson.add("mixins", newCommonMixinPaths);
}
JsonArray commonMixinPaths = mixinJson.getAsJsonArray("mixins");
if (commonMixinPaths != null) {
JsonArray newCommonMixinPaths = new JsonArray();
for (JsonElement mixinPath : commonMixinPaths) {
String fullPath = packagePath + "." + mixinPath.getAsString();
if (replacementPaths.containsKey(fullPath)) {
String replacement = replacementPaths.get(fullPath);
String prefix = packagePath + ".";
if (replacement.startsWith(prefix) && replacement.length() > prefix.length()) {
newCommonMixinPaths.add(replacement.substring(prefix.length()));
} else {
// Replacement is in a different package or format; use the class name only
int lastDot = replacement.lastIndexOf('.');
newCommonMixinPaths.add(lastDot >= 0 ? replacement.substring(lastDot + 1) : replacement);
}
} else {
newCommonMixinPaths.add(mixinPath);
}
}
mixinJson.add("mixins", newCommonMixinPaths);
}
🤖 Prompt for AI Agents
In
src/main/java/io/github/pacifistmc/forgix/core/filehandlers/MixinFileHandler.java
around lines 14 to 25, the code assumes replacementPaths.get(fullPath) always
begins with packagePath + "." and is long enough for
substring(packagePath.length()+1), which can cause
StringIndexOutOfBoundsException or incorrect results; fix by retrieving the
replacement into a local variable, then if the replacement
startsWith(packagePath + ".") and its length is greater than
packagePath.length()+1 use replacement.substring(packagePath.length()+1),
otherwise add the replacement value as-is (no substring), preserving the
original mixinPath fallback if replacement is null.

Comment on lines +27 to +38
JsonArray clientMixinPaths = mixinJson.getAsJsonArray("client");
if (clientMixinPaths != null) {
JsonArray newClientMixinPaths = new JsonArray();
for (JsonElement mixinPath : clientMixinPaths) {
String fullPath = packagePath + "." + mixinPath.getAsString();
if (replacementPaths.containsKey(fullPath))
newClientMixinPaths.add(replacementPaths.get(fullPath).substring(packagePath.length() + 1));
else
newClientMixinPaths.add(mixinPath);
}
mixinJson.add("client", newClientMixinPaths);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix potential StringIndexOutOfBoundsException and incorrect path handling (client mixins).

The same substring issue from lines 14-25 exists here for client mixins. Line 33 will throw StringIndexOutOfBoundsException if the replacement path is shorter than packagePath.length() + 1 or produce incorrect results with different package structures.

Apply this diff to safely handle replacement paths:

         JsonArray newClientMixinPaths = new JsonArray();
         for (JsonElement mixinPath : clientMixinPaths) {
             String fullPath = packagePath + "." + mixinPath.getAsString();
-            if (replacementPaths.containsKey(fullPath))
-                newClientMixinPaths.add(replacementPaths.get(fullPath).substring(packagePath.length() + 1));
-            else
+            if (replacementPaths.containsKey(fullPath)) {
+                String replacement = replacementPaths.get(fullPath);
+                String prefix = packagePath + ".";
+                if (replacement.startsWith(prefix) && replacement.length() > prefix.length()) {
+                    newClientMixinPaths.add(replacement.substring(prefix.length()));
+                } else {
+                    // Replacement is in a different package or format; use the class name only
+                    int lastDot = replacement.lastIndexOf('.');
+                    newClientMixinPaths.add(lastDot >= 0 ? replacement.substring(lastDot + 1) : replacement);
+                }
+            } else {
                 newClientMixinPaths.add(mixinPath);
+            }
         }

@ThePandaOliver
Copy link
Contributor Author

ThePandaOliver commented Nov 17, 2025

Its the best solution i can think of.
If it doesn't meet your standards then you don't have to merge it, just thought you might be interested.

Also you are right, not a fix but a workaround.

@ThePandaOliver ThePandaOliver changed the title Custom file handler (Also fix for mixin paths in mixin configs) Custom file handler (Also workaround for issue with mixin paths in mixin configs) Nov 17, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants