package dev.wpyw.dlss.cfg; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * Line-preserving, section-aware editor for ReShade's ini files. * *

Two shapes have to be handled, and they are different: *

* A {@code null} section therefore means "the preamble". Replacing either file wholesale would * destroy state we do not own (there are hundreds of keys here, and ReShade regenerates defaults * on exit), so edits are applied line by line and everything unknown is preserved verbatim. */ public final class IniFile { private final Path path; private final List lines = new ArrayList<>(); private IniFile(Path path, List lines) { this.path = path; this.lines.addAll(lines); } public static IniFile load(Path path) throws IOException { if (Files.isRegularFile(path)) { return new IniFile(path, Files.readAllLines(path, StandardCharsets.UTF_8)); } return new IniFile(path, new ArrayList<>()); } public Path path() { return path; } public boolean isNew() { return lines.isEmpty(); } private static boolean isHeader(String line) { String t = line.trim(); return t.length() >= 3 && t.startsWith("[") && t.endsWith("]"); } /** Index of the given section's header line, or -1. */ private int headerIndex(String section) { if (section == null || section.isEmpty()) { return -1; } String want = "[" + section + "]"; for (int i = 0; i < lines.size(); i++) { if (lines.get(i).trim().equalsIgnoreCase(want)) { return i; } } return -1; } /** Exclusive end of a section body (the next header, or the end of file). */ private int bodyEnd(int headerIdx) { for (int i = headerIdx + 1; i < lines.size(); i++) { if (isHeader(lines.get(i))) { return i; } } return lines.size(); } /** For the preamble: [first, last) index range that may contain keys. */ private int preambleEnd() { for (int i = 0; i < lines.size(); i++) { if (isHeader(lines.get(i))) { return i; } } return lines.size(); } private int findKey(String section, String key) { int from; int to; if (section == null || section.isEmpty()) { from = 0; to = preambleEnd(); } else { int h = headerIndex(section); if (h < 0) { return -1; } from = h + 1; to = bodyEnd(h); } for (int i = from; i < to; i++) { String line = lines.get(i); int eq = line.indexOf('='); if (eq > 0 && line.substring(0, eq).trim().equalsIgnoreCase(key)) { return i; } } return -1; } public String get(String section, String key, String fallback) { int i = findKey(section, key); if (i < 0) { return fallback; } String line = lines.get(i); return line.substring(line.indexOf('=') + 1).trim(); } /** Sets a key, creating the section (or preamble entry) when needed. */ public boolean set(String section, String key, String value) { String wanted = key + "=" + value; int i = findKey(section, key); if (i >= 0) { if (lines.get(i).equals(wanted)) { return false; } lines.set(i, wanted); return true; } if (section == null || section.isEmpty()) { // Insert at the end of the preamble, i.e. immediately before the first header. lines.add(preambleEnd(), wanted); return true; } int h = headerIndex(section); if (h < 0) { if (!lines.isEmpty() && !lines.get(lines.size() - 1).isBlank()) { lines.add(""); } lines.add("[" + section + "]"); lines.add(wanted); return true; } lines.add(bodyEnd(h), wanted); return true; } /** * Adds or updates one element of a comma-separated list value, preserving every other element * and their order. Used for {@code PreprocessorDefinitions} and {@code [ADDON] LoadFromDllMain}, * where other tools legitimately own entries too. * * @return true when something changed */ public boolean addToListElement(String section, String key, String element) { String current = get(section, key, ""); List parts = new ArrayList<>(); for (String p : current.split(",")) { String t = p.trim(); if (!t.isEmpty()) { parts.add(t); } } String wantedName = element.contains("=") ? element.substring(0, element.indexOf('=')).trim() : element; for (int k = 0; k < parts.size(); k++) { String p = parts.get(k); String name = p.contains("=") ? p.substring(0, p.indexOf('=')).trim() : p; if (name.equalsIgnoreCase(wantedName)) { if (p.equals(element)) { return false; } parts.set(k, element); return set(section, key, String.join(",", parts)); } } parts.add(element); return set(section, key, String.join(",", parts)); } /** Moves an element to the front of a comma-separated list. Used for TechniqueSorting. */ public boolean moveToFront(String section, String key, String element) { String current = get(section, key, ""); if (current.isEmpty()) { return false; } List parts = new ArrayList<>(); for (String p : current.split(",")) { String t = p.trim(); if (!t.isEmpty()) { parts.add(t); } } int idx = parts.indexOf(element); if (idx == 0) { return false; } if (idx > 0) { parts.remove(idx); } parts.add(0, element); return set(section, key, String.join(",", parts)); } public void removeKey(String section, String key) { int i = findKey(section, key); if (i >= 0) { lines.remove(i); } } /** All {@code key=value} pairs of a section (or of the preamble when section is null). */ public Map section(String section) { Map m = new LinkedHashMap<>(); int from; int to; if (section == null || section.isEmpty()) { from = 0; to = preambleEnd(); } else { int h = headerIndex(section); if (h < 0) { return m; } from = h + 1; to = bodyEnd(h); } for (int i = from; i < to; i++) { String line = lines.get(i); int eq = line.indexOf('='); if (eq > 0) { m.put(line.substring(0, eq).trim(), line.substring(eq + 1).trim()); } } return m; } public List rawLines() { return List.copyOf(lines); } public void save() throws IOException { Path parent = path.getParent(); if (parent != null) { Files.createDirectories(parent); } List out = new ArrayList<>(lines); while (!out.isEmpty() && out.get(out.size() - 1).isBlank()) { out.remove(out.size() - 1); } // ReShade writes CRLF; match it so its own diffs stay clean. Files.write(path, (String.join("\r\n", out) + "\r\n").getBytes(StandardCharsets.UTF_8)); } }