Instances represent a point-in-time view of a configuration and are intended
- * to be treated as read-only. New instances are created during reload operations
- * and atomically swapped by the managing component.
+ *
Each instance represents a point-in-time view of a configuration file.
+ * When the file is reloaded, a new {@code YamlConfig} instance is created
+ * and atomically replaces the previous instance.
+ *
+ *
Immutability
+ *
This class is immutable. All fields are {@code final} and no mutator
+ * methods are provided. This ensures thread-safe access without requiring
+ * synchronization.
+ *
+ *
Usage
+ *
Instances should be treated as read-only snapshots. Consumers should not
+ * cache instances long-term if they require access to the most up-to-date
+ * configuration state.
*
* @apiNote
- * Consumers should avoid holding long-lived references if they require access to
- * the most up-to-date configuration. Instead, they should retrieve the current
- * instance from the managing component when needed.
+ * To obtain the latest configuration, retrieve a new instance from the managing
+ * component instead of reusing previously obtained references.
*
* @implSpec
- * This class is immutable. All fields are {@code final}, and no mutator methods
- * are provided. Thread-safety is achieved through immutability and atomic
- * replacement at a higher level.
+ * Thread-safety is achieved through immutability and atomic replacement of
+ * instances at a higher level (for example, by the configuration manager).
*
* @implNote
- * Atomic replacement of instances ensures that readers never observe partially
- * updated configuration state and do not require synchronization.
+ * The underlying {@link FileConfiguration} instance is assumed to be used in a
+ * read-only manner. Modifying it directly may lead to inconsistent behavior.
*
* @since 1.0.0
*/
@@ -40,72 +46,53 @@ public final class YamlConfig {
/**
* The underlying configuration file on disk.
- *
- * @since 1.0.0
- */
- private final File file;
-
- /**
- * Metadata describing the underlying file.
- *
- * @since 1.0.0
*/
- private final FileProperties properties;
+ private final @NotNull File file;
/**
* Parsed configuration snapshot.
- *
- * @since 1.0.0
*/
- private final FileConfiguration config;
+ private final @NotNull FileConfiguration config;
/**
- * Constructs a new immutable {@code YamlConfig} instance.
+ * Constructs a new {@code YamlConfig} instance.
*
- * @param file the configuration file, must not be {@code null}
- * @param config the parsed configuration snapshot, must not be {@code null}
+ *
The provided {@link FileConfiguration} is assumed to represent a fully
+ * parsed and valid snapshot of the given file.
*
- * @since 1.0.0
+ * @param file the configuration file, must not be {@code null}
+ * @param config the parsed configuration snapshot, must not be {@code null}
*/
public YamlConfig(
@NotNull File file,
- @NotNull FileConfiguration config) {
+ @NotNull FileConfiguration config
+ ) {
this.file = file;
- this.properties = new FileProperties(file);
this.config = config;
}
/**
* Returns the underlying configuration file.
*
- * @return the configuration file, never {@code null}
+ *
This refers to the physical file on disk from which this snapshot
+ * was created.
*
- * @since 1.0.0
+ * @return the configuration file, never {@code null}
*/
public @NotNull File file() {
return file;
}
- /**
- * Returns metadata associated with this configuration file.
- *
- * @return file properties, never {@code null}
- *
- * @since 1.0.0
- */
- public @NotNull FileProperties properties() {
- return properties;
- }
-
/**
* Returns the parsed configuration snapshot.
*
- *
The returned instance represents a stable view of the configuration
- * at the time this {@code YamlConfig} was created.
+ *
The returned {@link FileConfiguration} represents a stable, immutable
+ * view of the configuration at the time this {@code YamlConfig} instance
+ * was created.
*
- * @return the configuration snapshot, never {@code null}
+ *
This object should be treated as read-only.
*
- * @since 1.0.0
+ * @return the configuration snapshot, never {@code null}
*/
public @NotNull FileConfiguration config() {
return config;
diff --git a/src/main/java/dev/spexx/configurationAPI/difference/ConfigChangeSummary.java b/src/main/java/dev/spexx/configurationAPI/difference/ConfigChangeSummary.java
deleted file mode 100644
index b1e0e00..0000000
--- a/src/main/java/dev/spexx/configurationAPI/difference/ConfigChangeSummary.java
+++ /dev/null
@@ -1,102 +0,0 @@
-package dev.spexx.configurationAPI.difference;
-
-import org.jetbrains.annotations.NotNull;
-
-/**
- * Represents a summary of changes detected between two versions of a configuration file.
- *
- *
This class aggregates high-level change metrics, including the number of
- * modified, added, and removed lines.
- *
- * @param changedLines the number of lines that were modified
- * @param addedLines the number of lines that were added
- * @param removedLines the number of lines that were removed
- *
- * @apiNote
- * Instances are immutable and safe for concurrent use.
- *
- * @implSpec
- * All values are computed during diff analysis and remain constant for the lifetime
- * of the instance.
- *
- * @implNote
- * This class serves as a higher-level abstraction over raw diff data and is intended
- * to simplify consumer logic and improve API clarity.
- *
- * @since 1.0.2
- */
-public record ConfigChangeSummary(int changedLines, int addedLines, int removedLines) {
-
- /**
- * Returns the number of modified lines.
- *
- * @return number of changed lines
- *
- * @since 1.0.2
- */
- public int changedLines() {
- return changedLines;
- }
-
- /**
- * Returns the number of added lines.
- *
- * @return number of added lines
- *
- * @since 1.0.2
- */
- public int addedLines() {
- return addedLines;
- }
-
- /**
- * Returns the number of removed lines.
- *
- * @return number of removed lines
- *
- * @since 1.0.2
- */
- public int removedLines() {
- return removedLines;
- }
-
- /**
- * Returns the total number of affected lines.
- *
- *
This value is the sum of changed, added, and removed lines.
- *
- * @return total number of changes
- *
- * @since 1.0.2
- */
- public int getTotalChanges() {
- return changedLines + addedLines + removedLines;
- }
-
- /**
- * Indicates whether any changes were detected.
- *
- * @return {@code true} if changes exist, {@code false} otherwise
- *
- * @since 1.0.2
- */
- public boolean hasChanges() {
- return getTotalChanges() > 0;
- }
-
- /**
- * Returns a string representation of this summary.
- *
- * @return string representation of change summary
- *
- * @since 1.0.2
- */
- @Override
- public @NotNull String toString() {
- return "ConfigChangeSummary{" +
- "changed=" + changedLines +
- ", added=" + addedLines +
- ", removed=" + removedLines +
- '}';
- }
-}
\ No newline at end of file
diff --git a/src/main/java/dev/spexx/configurationAPI/difference/ConfigLineDifference.java b/src/main/java/dev/spexx/configurationAPI/difference/ConfigLineDifference.java
deleted file mode 100644
index 1a9fa80..0000000
--- a/src/main/java/dev/spexx/configurationAPI/difference/ConfigLineDifference.java
+++ /dev/null
@@ -1,177 +0,0 @@
-package dev.spexx.configurationAPI.difference;
-
-import org.jetbrains.annotations.NotNull;
-
-/**
- * Represents a single line-level difference between two versions of a file.
- *
- *
This class encapsulates the following components:
- *
- *
The line number where the change occurred
- *
The original line content
- *
The updated line content
- *
The character length delta between the two versions
- *
- *
- *
Instances of this class are immutable and represent a snapshot of a
- * detected difference at a specific point in time.
- *
- * @apiNote
- * Instances are safe for concurrent use and may be freely shared across threads.
- * This class is intended for diagnostic, logging, and event propagation purposes.
- *
- * @implSpec
- * The {@code charDelta} value is calculated as:
- * {@code newLine.length() - oldLine.length()}.
- * A positive value indicates an increase in length, while a negative value
- * indicates a reduction.
- *
- * @implNote
- * This class performs no deep or semantic diffing. It provides a lightweight,
- * line-based comparison model suitable for high-frequency change detection
- * scenarios without incurring significant computational overhead.
- *
- * @since 1.0.0
- */
-public final class ConfigLineDifference {
-
- /**
- * The 1-based line number where the change occurred.
- *
- * @since 1.0.0
- */
- private final int lineNumber;
-
- /**
- * The original content of the line prior to modification.
- *
- * @since 1.0.0
- */
- private final String oldLine;
-
- /**
- * The updated content of the line after modification.
- *
- * @since 1.0.0
- */
- private final String newLine;
-
- /**
- * The difference in character length between {@code newLine} and {@code oldLine}.
- *
- * @since 1.0.0
- */
- private final int charDelta;
-
- /**
- * Constructs a new {@code ConfigLineDifference} instance.
- *
- * @param lineNumber the 1-based line number where the change occurred
- * @param oldLine the original line content, must not be {@code null}
- * @param newLine the updated line content, must not be {@code null}
- *
- * @since 1.0.0
- */
- public ConfigLineDifference(int lineNumber,
- @NotNull String oldLine,
- @NotNull String newLine) {
- this.lineNumber = lineNumber;
- this.oldLine = oldLine;
- this.newLine = newLine;
- this.charDelta = newLine.length() - oldLine.length();
- }
-
- /**
- * Determines whether the difference between the old and new line
- * consists solely of whitespace changes.
- *
- *
This method ignores all whitespace characters (spaces, tabs,
- * and other Unicode whitespace) and compares the normalized content
- * of both lines.
{@code "value: 1"} vs {@code "value: 1"} → {@code true}
- *
{@code "value: 1"} vs {@code "value: 2"} → {@code false}
- *
- *
- * @return {@code true} if only whitespace differs, {@code false} otherwise
- *
- * @apiNote
- * This method performs a lightweight normalization by removing all
- * whitespace characters before comparison.
- *
- * @implSpec
- * The comparison is based on {@code String.replaceAll("\\s+", "")}.
- *
- * @implNote
- * This method does not perform semantic or structural comparison.
- * It is intended for quick detection of formatting-only changes.
- *
- * @since 1.0.1
- */
- public boolean isOnlyWhitespaceChange() {
- return !oldLine.equals(newLine)
- && normalize(oldLine).equals(normalize(newLine));
- }
-
- /**
- * Normalizes a line by removing all whitespace characters.
- *
- * @param input the input string, must not be {@code null}
- * @return normalized string without whitespace
- *
- * @since 1.0.1
- */
- private static @NotNull String normalize(@NotNull String input) {
- return input.replaceAll("\\s+", "");
- }
-
- /**
- * Returns the line number where the change occurred.
- *
- * @return the 1-based line number
- *
- * @since 1.0.0
- */
- public int getLineNumber() {
- return lineNumber;
- }
-
- /**
- * Returns the original line content.
- *
- * @return the previous line value, never {@code null}
- *
- * @since 1.0.0
- */
- public @NotNull String getOldLine() {
- return oldLine;
- }
-
- /**
- * Returns the updated line content.
- *
- * @return the new line value, never {@code null}
- *
- * @since 1.0.0
- */
- public @NotNull String getNewLine() {
- return newLine;
- }
-
- /**
- * Returns the character length delta between the new and old line.
- *
- *
A positive value indicates the new line is longer, while a negative
- * value indicates it is shorter.
- *
- * @return the character length difference
- *
- * @since 1.0.0
- */
- public int getCharDelta() {
- return charDelta;
- }
-}
\ No newline at end of file
diff --git a/src/main/java/dev/spexx/configurationAPI/events/ConfigReloadedEvent.java b/src/main/java/dev/spexx/configurationAPI/events/ConfigReloadedEvent.java
index d13fb7d..cd5b84e 100644
--- a/src/main/java/dev/spexx/configurationAPI/events/ConfigReloadedEvent.java
+++ b/src/main/java/dev/spexx/configurationAPI/events/ConfigReloadedEvent.java
@@ -1,181 +1,164 @@
package dev.spexx.configurationAPI.events;
import dev.spexx.configurationAPI.config.YamlConfig;
-import dev.spexx.configurationAPI.difference.ConfigChangeSummary;
-import dev.spexx.configurationAPI.difference.ConfigLineDifference;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
import org.jetbrains.annotations.NotNull;
-import java.util.List;
-
/**
* Event fired when a configuration file has been reloaded.
*
- *
This event is dispatched after a successful atomic replacement of the
- * underlying {@link YamlConfig} instance.
+ *
This event is dispatched after a successful configuration reload
+ * triggered by a file system change detected by the configuration watcher.
*
- *
Provides both a high-level summary of changes via
- * {@link ConfigChangeSummary} and detailed per-line differences via
- * {@link ConfigLineDifference}.
+ *
The event provides both the previous and updated {@link YamlConfig}
+ * instances, along with checksum information and the total time required
+ * to perform the reload operation.
*
- * @apiNote
- * This event is always fired synchronously on the main server thread.
+ *
Threading
+ *
This event is always fired synchronously on the main server thread.
+ * Consumers may safely interact with the Bukkit API within event handlers.
*
- * @implSpec
- * Instances are immutable. All state is captured at creation time and remains
- * constant for the lifetime of the event.
+ *
Immutability
+ *
All fields are immutable and represent a complete snapshot of the reload
+ * operation at the time the event was created.
*
- * @implNote
- * Diff and summary data are precomputed before event dispatch and are safe for
- * concurrent read access without additional synchronization.
+ * @apiNote
+ * Consumers should treat {@link YamlConfig} instances as read-only snapshots.
+ * To obtain the most recent configuration state, query the managing component
+ * rather than storing references long-term.
*
- * @since 1.0.0
+ * @since 1.0.5
*/
public final class ConfigReloadedEvent extends Event {
+ /**
+ * Static handler list required by the Bukkit event system.
+ */
private static final HandlerList HANDLERS = new HandlerList();
/**
- * The updated configuration snapshot.
- *
- * @since 1.0.0
+ * Previous configuration snapshot before reload.
*/
- private final YamlConfig config;
+ private final @NotNull YamlConfig oldConfig;
/**
- * SHA-256 checksum of the previous file state.
- *
- * @since 1.0.0
+ * Updated configuration snapshot after reload.
*/
- private final String oldChecksum;
+ private final @NotNull YamlConfig newConfig;
/**
- * SHA-256 checksum of the new file state.
- *
- * @since 1.0.0
+ * SHA-256 checksum of the configuration file before reload.
*/
- private final String newChecksum;
+ private final @NotNull String oldChecksum;
/**
- * Aggregated summary of changes between file versions.
- *
- * @since 1.0.2
+ * SHA-256 checksum of the configuration file after reload.
*/
- private final ConfigChangeSummary summary;
+ private final @NotNull String newChecksum;
/**
- * Immutable list of line-level differences.
- *
- * @since 1.0.1
+ * Time required to reload the configuration, measured in milliseconds.
*/
- private final List diffs;
+ private final int reloadTimeMs;
/**
* Constructs a new {@code ConfigReloadedEvent}.
*
- * @param config the updated configuration snapshot, must not be {@code null}
- * @param oldChecksum checksum of the previous file state, must not be {@code null}
- * @param newChecksum checksum of the new file state, must not be {@code null}
- * @param summary aggregated change summary, must not be {@code null}
- * @param diffs list of line-level differences, must not be {@code null}
+ *
This constructor is typically invoked internally by the configuration
+ * watcher after detecting and applying a file change.
*
- * @since 1.0.0
+ * @param oldConfig previous configuration snapshot, must not be {@code null}
+ * @param newConfig updated configuration snapshot, must not be {@code null}
+ * @param oldChecksum checksum of the file before reload, must not be {@code null}
+ * @param newChecksum checksum of the file after reload, must not be {@code null}
+ * @param reloadTimeMs time taken to reload the configuration in milliseconds
*/
public ConfigReloadedEvent(
- @NotNull YamlConfig config,
+ @NotNull YamlConfig oldConfig,
+ @NotNull YamlConfig newConfig,
@NotNull String oldChecksum,
@NotNull String newChecksum,
- @NotNull ConfigChangeSummary summary,
- @NotNull List diffs
+ int reloadTimeMs
) {
- this.config = config;
+ this.oldConfig = oldConfig;
+ this.newConfig = newConfig;
this.oldChecksum = oldChecksum;
this.newChecksum = newChecksum;
- this.summary = summary;
- this.diffs = List.copyOf(diffs);
+ this.reloadTimeMs = reloadTimeMs;
}
/**
- * Returns the updated configuration snapshot.
+ * Returns the configuration snapshot prior to reload.
*
- * @return configuration snapshot, never {@code null}
+ *
This represents the last known valid state before the file change
+ * was applied.
*
- * @since 1.0.0
+ * @return previous configuration snapshot, never {@code null}
*/
- public @NotNull YamlConfig getConfig() {
- return config;
+ public @NotNull YamlConfig getOldConfig() {
+ return oldConfig;
}
/**
- * Returns the checksum of the previous file state.
+ * Returns the configuration snapshot after reload.
+ *
+ *
This represents the newly loaded state reflecting the current
+ * contents of the configuration file.
*
- * @return previous checksum, never {@code null}
+ * @return updated configuration snapshot, never {@code null}
+ */
+ public @NotNull YamlConfig getNewConfig() {
+ return newConfig;
+ }
+
+ /**
+ * Returns the checksum of the configuration file before reload.
*
- * @since 1.0.0
+ * @return previous SHA-256 checksum, never {@code null}
*/
public @NotNull String getOldChecksum() {
return oldChecksum;
}
/**
- * Returns the checksum of the new file state.
+ * Returns the checksum of the configuration file after reload.
*
- * @return new checksum, never {@code null}
- *
- * @since 1.0.0
+ * @return new SHA-256 checksum, never {@code null}
*/
public @NotNull String getNewChecksum() {
return newChecksum;
}
/**
- * Returns the aggregated summary of detected changes.
- *
- *
This provides a high-level overview of modifications, additions,
- * and removals.
+ * Returns the time required to reload the configuration.
*
- * @return change summary, never {@code null}
+ *
The value is measured in milliseconds and represents the duration
+ * between the start of the reload process and completion of the new
+ * configuration snapshot.
*
- * @since 1.0.2
+ * @return reload duration in milliseconds
*/
- public @NotNull ConfigChangeSummary getSummary() {
- return summary;
+ public int getReloadTimeMs() {
+ return reloadTimeMs;
}
/**
- * Returns the list of detected line-level differences.
- *
- *
The returned list is immutable and reflects the exact state at the
- * time the event was created.
+ * Returns the handler list for this event instance.
*
- * @return immutable list of diffs, never {@code null}
- *
- * @apiNote
- * The returned list is safe for concurrent iteration without additional synchronization.
- *
- * @implSpec
- * Backed by {@link List#copyOf(java.util.Collection)} to guarantee immutability.
- *
- * @since 1.0.1
+ * @return handler list, never {@code null}
*/
- public @NotNull List getDiffs() {
- return diffs;
- }
-
@Override
public @NotNull HandlerList getHandlers() {
return HANDLERS;
}
/**
- * Returns the static handler list required by Bukkit.
- *
- * @return handler list
+ * Returns the static handler list required by the Bukkit event system.
*
- * @since 1.0.0
+ * @return static handler list, never {@code null}
*/
- public static HandlerList getHandlerList() {
+ public static @NotNull HandlerList getHandlerList() {
return HANDLERS;
}
}
\ No newline at end of file
diff --git a/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java b/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java
index 2132f06..b1d66c4 100644
--- a/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java
+++ b/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java
@@ -1,12 +1,13 @@
package dev.spexx.configurationAPI.manager;
import dev.spexx.configurationAPI.config.YamlConfig;
-import dev.spexx.configurationAPI.watcher.YamlConfigWatcher;
+import dev.spexx.configurationAPI.watcher.GlobalConfigWatcher;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.NotNull;
import java.io.File;
+import java.io.IOException;
import java.nio.file.Path;
import java.util.Collection;
import java.util.concurrent.ConcurrentHashMap;
@@ -14,82 +15,89 @@
/**
* Central registry and lifecycle manager for {@link YamlConfig} instances.
*
- *
This class is responsible for loading, caching, reloading, and unloading
- * configuration files. Configurations are indexed by their normalized
- * {@link Path}, ensuring consistent identity across different environments.
+ *
This class provides controlled access to configuration files by handling:
+ *
+ *
Initial loading of configuration files
+ *
Registration of configurations for file system monitoring
+ *
Thread-safe access to configuration snapshots
+ *
Basic lifecycle operations such as unload
+ *
*
- *
The manager provides thread-safe access to configuration snapshots and
- * coordinates integration with {@link YamlConfigWatcher} for automatic reloads.
+ *
File system monitoring and automatic reload behavior are delegated to
+ * {@link GlobalConfigWatcher}. This class does not perform direct file watching.
*
- * @apiNote
- * This class is designed for concurrent access. Consumers may safely retrieve
- * configuration instances from multiple threads without additional synchronization.
- * Returned {@link YamlConfig} instances are immutable snapshots and may become
- * outdated after reload operations.
- *
- * @implSpec
- * Configuration instances are replaced atomically using
- * {@link ConcurrentHashMap#computeIfPresent(Object, java.util.function.BiFunction)}.
- * This guarantees that readers either observe the old or the new configuration,
- * but never a partially updated state.
+ *
Thread Safety
+ *
This class is safe for concurrent use. Internally, a
+ * {@link ConcurrentHashMap} is used to store configuration snapshots.
+ * Each {@link YamlConfig} instance is immutable, ensuring that readers
+ * never observe partially updated state.
*
- *
Each loaded configuration is associated with a {@link YamlConfigWatcher}
- * that monitors file system changes and triggers reload operations via a callback.
+ *
Lifecycle
+ *
+ *
Configurations are loaded via {@link #getOrLoad(File)}
+ *
Each configuration is registered with the watcher
+ *
The watcher updates configuration state when file changes occur
+ *
*
- * @implNote
- * Watchers are created per configuration file and managed alongside the cached
- * configuration instances. When a configuration is unloaded, its associated
- * watcher is stopped and removed to prevent resource leaks.
+ * @apiNote
+ * Consumers should avoid caching {@link YamlConfig} instances long-term.
+ * Instead, they should retrieve the current snapshot when needed to ensure
+ * they are using the most recent configuration state.
*
- * @since 1.0.0
+ * @since 1.0.5
*/
public final class ConfigManager {
/**
- * Owning plugin instance used for scheduling and logging.
- *
- * @since 1.0.0
+ * Owning plugin instance used for resource access and logging.
*/
private final @NotNull JavaPlugin plugin;
/**
- * Cache of loaded configurations indexed by normalized path.
- *
- * @since 1.0.0
+ * Global watcher responsible for monitoring configuration file changes.
*/
- private final ConcurrentHashMap configs = new ConcurrentHashMap<>();
+ private final @NotNull GlobalConfigWatcher watcher;
/**
- * Active watchers associated with loaded configurations.
- *
- * @since 1.0.0
+ * Cache of configuration snapshots indexed by normalized file path.
*/
- private final ConcurrentHashMap watchers = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap configs = new ConcurrentHashMap<>();
/**
- * Constructs a new {@code ConfigManager}.
+ * Constructs a new {@code ConfigManager} instance.
+ *
+ *
This initializes and starts the {@link GlobalConfigWatcher}.
*
* @param plugin the owning plugin instance, must not be {@code null}
*
- * @since 1.0.0
+ * @throws RuntimeException if the watcher cannot be initialized
*/
public ConfigManager(@NotNull JavaPlugin plugin) {
this.plugin = plugin;
+
+ try {
+ this.watcher = new GlobalConfigWatcher(plugin);
+ this.watcher.start();
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to initialize GlobalConfigWatcher", e);
+ }
}
/**
- * Returns a loaded configuration for the given file.
+ * Returns the configuration associated with the specified file.
+ *
+ *
The file must have been previously loaded using
+ * {@link #getOrLoad(File)} or {@link #getOrLoadResource(String)}.
*
* @param file the configuration file, must not be {@code null}
- * @return the corresponding configuration snapshot, never {@code null}
- * @throws IllegalStateException if the configuration is not loaded
+ * @return the corresponding {@link YamlConfig} snapshot, never {@code null}
*
- * @since 1.0.0
+ * @throws IllegalStateException if the configuration has not been loaded
*/
public @NotNull YamlConfig get(@NotNull File file) {
Path path = normalize(file.toPath());
- YamlConfig config = configs.get(path);
+ YamlConfig config = configs.get(path);
if (config == null) {
throw new IllegalStateException("Config not loaded: " + path);
}
@@ -100,10 +108,11 @@ public ConfigManager(@NotNull JavaPlugin plugin) {
/**
* Returns an existing configuration or loads it if not already present.
*
- * @param file the configuration file, must not be {@code null}
- * @return the existing or newly loaded configuration snapshot, never {@code null}
+ *
If the configuration is not already cached, it is loaded from disk,
+ * registered with the watcher, and stored internally.
*
- * @since 1.0.0
+ * @param file the configuration file, must not be {@code null}
+ * @return the existing or newly loaded {@link YamlConfig} snapshot
*/
public @NotNull YamlConfig getOrLoad(@NotNull File file) {
Path path = normalize(file.toPath());
@@ -111,34 +120,15 @@ public ConfigManager(@NotNull JavaPlugin plugin) {
}
/**
- * Loads a configuration file from the plugin's bundled resources,
- * copying it to the plugin data folder if it does not already exist.
+ * Loads a configuration file from the plugin's bundled resources.
*
- *
The resource is resolved from the plugin JAR and written to the
- * data folder using {@link JavaPlugin#saveResource(String, boolean)}.
- * If the file already exists, it is not overwritten.
+ *
If the file does not exist in the plugin data folder, it is copied
+ * from the plugin JAR using {@link JavaPlugin#saveResource(String, boolean)}.
*
- *
After ensuring the file exists on disk, it is loaded and managed
- * as a regular configuration via {@link #getOrLoad(File)}.
+ *
The resulting file is then loaded and managed like any other configuration.
*
* @param name the resource name (for example, {@code "config.yml"}), must not be {@code null}
- * @return the loaded configuration snapshot, never {@code null}
- *
- * @apiNote
- * This method is intended for default configuration files packaged with
- * the plugin. The resource must exist within the plugin JAR under the
- * same path.
- *
- * @implSpec
- * The resource is copied only if the target file does not already exist.
- * Existing files are preserved to avoid overwriting user modifications.
- *
- * @implNote
- * This method bridges bundled resources and the configuration management
- * system by ensuring that all managed configurations operate on real
- * files within the plugin data folder.
- *
- * @since 1.0.0
+ * @return the loaded {@link YamlConfig} snapshot
*/
public @NotNull YamlConfig getOrLoadResource(@NotNull String name) {
plugin.saveResource(name, false);
@@ -146,117 +136,103 @@ public ConfigManager(@NotNull JavaPlugin plugin) {
}
/**
- * Reloads the specified configuration file.
+ * Unloads the configuration associated with the given file.
*
- *
A new {@link YamlConfig} instance is created and atomically replaces
- * the previous instance in the cache.
- *
- * @param file the configuration file, must not be {@code null}
+ *
This removes the configuration snapshot from the internal cache.
+ * The watcher will stop tracking the file once it is removed or no longer exists.
*
- * @since 1.0.0
- */
- public void reload(@NotNull File file) {
- Path path = normalize(file.toPath());
-
- configs.computeIfPresent(path, (p, oldConfig) -> {
-
- YamlConfig newConfig = new YamlConfig(
- file,
- YamlConfiguration.loadConfiguration(file)
- );
-
- YamlConfigWatcher watcher = watchers.get(path);
-
- if (watcher != null) {
- watcher.updateConfig(newConfig);
- watcher.updateChecksum();
- }
-
- return newConfig;
- });
- }
-
- /**
- * Unloads the specified configuration and stops its associated watcher.
- *
- * @param file the configuration file, must not be {@code null}
- *
- * @since 1.0.0
+ * @param file the configuration file to unload, must not be {@code null}
*/
public void unload(@NotNull File file) {
Path path = normalize(file.toPath());
-
configs.remove(path);
-
- YamlConfigWatcher watcher = watchers.remove(path);
- if (watcher != null) {
- watcher.stop();
- }
}
/**
* Returns all currently loaded configuration snapshots.
*
- * @return collection of configurations, never {@code null}
+ *
The returned collection reflects the current internal state and is
+ * backed by the underlying map.
*
- * @since 1.0.0
+ * @return collection of loaded configurations, never {@code null}
*/
public @NotNull Collection getAll() {
return configs.values();
}
/**
- * Loads a configuration file and initializes its watcher.
+ * Loads a configuration file and registers it with the watcher.
*
- *
If the file does not exist, parent directories are created as needed.
+ *
This method performs the following steps:
+ *
+ *
Ensures the file and its parent directories exist
+ *
Parses the YAML file into a {@link YamlConfig}
+ *
Stores the snapshot in the internal cache
+ *
Registers the file with the {@link GlobalConfigWatcher}
+ *
*
* @param file the configuration file, must not be {@code null}
- * @return the loaded configuration snapshot, never {@code null}
+ * @return the loaded {@link YamlConfig} snapshot
*
- * @since 1.0.0
+ * @throws RuntimeException if watcher registration fails
*/
private @NotNull YamlConfig load(@NotNull File file) {
Path path = normalize(file.toPath());
- if (!file.exists()) {
- File parent = file.getParentFile();
+ ensureFileExists(file);
- if (parent != null && !parent.exists()) {
- if (!parent.mkdirs() && !parent.exists()) {
- throw new IllegalStateException("Failed to create directories: " + parent);
- }
- }
+ YamlConfig config = new YamlConfig(
+ file,
+ YamlConfiguration.loadConfiguration(file)
+ );
+
+ configs.put(path, config);
+
+ try {
+ watcher.register(config);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to register watcher for " + file, e);
}
- YamlConfig config = new YamlConfig(file, YamlConfiguration.loadConfiguration(file));
+ return config;
+ }
- YamlConfigWatcher watcher = new YamlConfigWatcher(
- plugin,
- config,
- () -> reload(file)
- );
+ /**
+ * Ensures that the specified file and its parent directories exist.
+ *
+ *
If the file does not exist, parent directories are created if necessary.
+ * The file itself is not created.
+ *
+ * @param file the file to verify, must not be {@code null}
+ *
+ * @throws IllegalStateException if directory creation fails
+ */
+ private void ensureFileExists(@NotNull File file) {
- try {
- watcher.start();
- } catch (Exception e) {
- throw new RuntimeException("Failed to start watcher", e);
+ if (file.exists()) {
+ return;
}
- watchers.put(path, watcher);
+ File parent = file.getParentFile();
- return config;
+ if (parent != null && !parent.exists()) {
+ if (!parent.mkdirs() && !parent.exists()) {
+ throw new IllegalStateException("Failed to create directories: " + parent);
+ }
+ }
}
/**
* Normalizes a path to ensure consistent identity.
*
- * @param path the raw path, must not be {@code null}
- * @return normalized absolute path, never {@code null}
+ *
This method converts the path to an absolute, normalized form to avoid
+ * inconsistencies caused by relative paths or differing representations.
*
- * @since 1.0.0
+ * @param path the raw path, must not be {@code null}
+ * @return normalized absolute path
*/
- private @NotNull Path normalize(@NotNull Path path) {
+ private static @NotNull Path normalize(@NotNull Path path) {
return path.toAbsolutePath().normalize();
}
}
\ No newline at end of file
diff --git a/src/main/java/dev/spexx/configurationAPI/properties/FileProperties.java b/src/main/java/dev/spexx/configurationAPI/properties/FileProperties.java
deleted file mode 100644
index ed0eb7c..0000000
--- a/src/main/java/dev/spexx/configurationAPI/properties/FileProperties.java
+++ /dev/null
@@ -1,281 +0,0 @@
-package dev.spexx.configurationAPI.properties;
-
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-
-import java.io.File;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.attribute.BasicFileAttributes;
-
-/**
- * Represents metadata and derived properties of a file.
- *
- *
This class provides a read-only abstraction over {@link File} and exposes
- * commonly used attributes such as name, size, timestamps, permissions, and
- * normalized path information.
- *
- *
Instances are lightweight and may lazily resolve additional file system
- * attributes when required.
- *
- * @apiNote
- * This class does not actively monitor file system changes. Returned values
- * reflect the state of the file at the time of method invocation and may become
- * stale if the file is modified externally.
- *
- * @implSpec
- * The underlying {@link Path} is normalized using
- * {@link Path#toAbsolutePath()} and {@link Path#normalize()} to ensure consistent
- * identity across different operating systems and path representations.
- *
- * @implNote
- * File attribute access via {@link BasicFileAttributes} is performed lazily and
- * cached after the first successful read. If attribute retrieval fails, methods
- * gracefully fall back to alternative mechanisms or return sentinel values.
- *
- * @since 1.0.0
- */
-public final class FileProperties {
-
- /**
- * The underlying file.
- *
- * @since 1.0.0
- */
- private final @NotNull File file;
-
- /**
- * Normalized absolute path of the file.
- *
- * @since 1.0.0
- */
- private final @NotNull Path path;
-
- /**
- * Lazily initialized file attributes.
- *
- * @since 1.0.0
- */
- private BasicFileAttributes attributes;
-
- /**
- * Constructs a new {@code FileProperties} instance for the given file.
- *
- * @param file the file to inspect, must not be {@code null}
- *
- * @since 1.0.0
- */
- public FileProperties(@NotNull File file) {
- this.file = file;
- this.path = file.toPath().toAbsolutePath().normalize();
- }
-
- /**
- * Returns the file name including its extension.
- *
- * @return file name with extension, never {@code null}
- *
- * @since 1.0.0
- */
- public @NotNull String getName() {
- return file.getName();
- }
-
- /**
- * Returns the file name without its extension.
- *
- * @return base file name, never {@code null}
- *
- * @since 1.0.0
- */
- public @NotNull String getBaseName() {
- String name = file.getName();
- int dot = name.lastIndexOf('.');
- return (dot == -1) ? name : name.substring(0, dot);
- }
-
- /**
- * Returns the file extension without the leading dot.
- *
- * @return file extension, or an empty string if none exists
- *
- * @since 1.0.0
- */
- public @NotNull String getExtension() {
- String name = file.getName();
- int dot = name.lastIndexOf('.');
- return (dot == -1) ? "" : name.substring(dot + 1);
- }
-
- /**
- * Returns the absolute file path as a string.
- *
- * @return absolute path, never {@code null}
- *
- * @since 1.0.0
- */
- public @NotNull String getAbsolutePath() {
- return file.getAbsolutePath();
- }
-
- /**
- * Returns the parent directory of the file.
- *
- * @return parent directory, never {@code null}
- * @throws IllegalStateException if the file has no parent directory
- *
- * @since 1.0.0
- */
- public @NotNull File getParent() {
- File parent = file.getParentFile();
- if (parent == null) {
- throw new IllegalStateException("File has no parent: " + file);
- }
- return parent;
- }
-
- /**
- * Returns the file size in bytes.
- *
- * @return file size in bytes
- *
- * @since 1.0.0
- */
- public long getFileSize() {
- try {
- return Files.size(path);
- } catch (Exception e) {
- return file.length();
- }
- }
-
- /**
- * Returns whether the file is empty.
- *
- * @return {@code true} if file size is zero, {@code false} otherwise
- *
- * @since 1.0.0
- */
- public boolean isEmpty() {
- return getFileSize() == 0;
- }
-
- /**
- * Returns the last modified time of the file.
- *
- * @return last modified timestamp in milliseconds since epoch
- *
- * @since 1.0.0
- */
- public long getLastModified() {
- return file.lastModified();
- }
-
- /**
- * Returns whether the file exists.
- *
- * @return {@code true} if the file exists, {@code false} otherwise
- *
- * @since 1.0.0
- */
- public boolean exists() {
- return file.exists();
- }
-
- /**
- * Returns whether the file is hidden.
- *
- * @return {@code true} if the file is hidden, {@code false} otherwise
- *
- * @since 1.0.0
- */
- public boolean isHidden() {
- return file.isHidden();
- }
-
- /**
- * Returns whether the file is readable.
- *
- * @return {@code true} if readable, {@code false} otherwise
- *
- * @since 1.0.0
- */
- public boolean canRead() {
- return file.canRead();
- }
-
- /**
- * Returns whether the file is writable.
- *
- * @return {@code true} if writable, {@code false} otherwise
- *
- * @since 1.0.0
- */
- public boolean canWrite() {
- return file.canWrite();
- }
-
- /**
- * Returns whether the file is executable.
- *
- * @return {@code true} if executable, {@code false} otherwise
- *
- * @since 1.0.0
- */
- public boolean canExecute() {
- return file.canExecute();
- }
-
- /**
- * Returns the file creation time.
- *
- * @return creation time in milliseconds since epoch, or {@code -1} if unavailable
- *
- * @since 1.0.0
- */
- public long getCreationTime() {
- BasicFileAttributes attrs = attributes();
- return (attrs != null) ? attrs.creationTime().toMillis() : -1;
- }
-
- /**
- * Returns the last access time of the file.
- *
- * @return last access time in milliseconds since epoch, or {@code -1} if unavailable
- *
- * @since 1.0.0
- */
- public long getLastAccessTime() {
- BasicFileAttributes attrs = attributes();
- return (attrs != null) ? attrs.lastAccessTime().toMillis() : -1;
- }
-
- /**
- * Returns the normalized {@link Path} representation of the file.
- *
- * @return normalized path, never {@code null}
- *
- * @since 1.0.0
- */
- public @NotNull Path toPath() {
- return path;
- }
-
- /**
- * Returns cached file attributes, loading them if necessary.
- *
- * @return file attributes, or {@code null} if unavailable
- *
- * @since 1.0.0
- */
- private @Nullable BasicFileAttributes attributes() {
- if (attributes == null) {
- try {
- attributes = Files.readAttributes(path, BasicFileAttributes.class);
- } catch (Exception ignored) {
- return null;
- }
- }
- return attributes;
- }
-}
\ No newline at end of file
diff --git a/src/main/java/dev/spexx/configurationAPI/util/FileChecksum.java b/src/main/java/dev/spexx/configurationAPI/util/FileChecksum.java
index f90fc40..5d47d42 100644
--- a/src/main/java/dev/spexx/configurationAPI/util/FileChecksum.java
+++ b/src/main/java/dev/spexx/configurationAPI/util/FileChecksum.java
@@ -9,23 +9,27 @@
/**
* Utility class for computing cryptographic checksums of files.
*
- *
This class provides methods for generating hash values used to detect
- * changes in file contents.
+ *
This class provides methods for generating hash values that can be used
+ * to detect changes in file contents. The primary use case is configuration
+ * change detection where file content equality must be verified reliably.
+ *
+ *
Performance Considerations
+ *
Checksum computation requires reading the entire file. This operation
+ * may be expensive for large files and should not be performed excessively
+ * in performance-critical code paths without appropriate caching or throttling.
*
* @apiNote
- * Checksum computation performs a full file read operation. It should not be
- * used excessively on large files or in performance-critical paths without
- * appropriate caching or throttling.
+ * This class is stateless and thread-safe. All methods are static and do not
+ * maintain internal state.
*
* @implSpec
- * The {@link #sha256(File)} method computes a SHA-256 hash of the file contents
- * using a buffered stream. The returned value is encoded as a lowercase
- * hexadecimal string.
+ * The {@link #sha256(File)} method computes a SHA-256 hash using a buffered
+ * stream and returns the result as a lowercase hexadecimal string.
*
* @implNote
- * A fixed buffer size of 8192 bytes is used for streaming file data. The method
- * relies on {@link MessageDigest} and standard Java I/O without additional
- * optimizations such as memory-mapped files.
+ * A fixed buffer size of 8192 bytes is used for reading file data. The method
+ * relies on {@link MessageDigest} and standard Java I/O without using
+ * memory-mapped files or other advanced optimizations.
*
* @since 1.0.0
*/
@@ -33,24 +37,25 @@ public final class FileChecksum {
/**
* Private constructor to prevent instantiation.
- *
- * @since 1.0.0
*/
- private FileChecksum() {}
+ private FileChecksum() {
+ }
/**
- * Computes the SHA-256 checksum of the given file.
+ * Computes the SHA-256 checksum of the specified file.
+ *
+ *
The file is read in full using a buffered input stream and processed
+ * through a {@link MessageDigest} instance configured for SHA-256.
*
- *
The entire file is read and processed through a
- * {@link MessageDigest} instance.
+ *
The resulting hash is returned as a lowercase hexadecimal string.
*
* @param file the file to compute the checksum for, must not be {@code null}
* @return the SHA-256 checksum as a lowercase hexadecimal string, never {@code null}
- * @throws RuntimeException if an error occurs while reading the file or computing the hash
*
- * @since 1.0.0
+ * @throws RuntimeException if an error occurs while reading the file or
+ * computing the checksum
*/
- public static @NotNull String sha256(File file) {
+ public static @NotNull String sha256(@NotNull File file) {
try (FileInputStream fis = new FileInputStream(file)) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
@@ -64,7 +69,7 @@ private FileChecksum() {}
byte[] hash = digest.digest();
- StringBuilder hex = new StringBuilder();
+ StringBuilder hex = new StringBuilder(hash.length * 2);
for (byte b : hash) {
hex.append(String.format("%02x", b));
}
diff --git a/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java b/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
new file mode 100644
index 0000000..0bc5041
--- /dev/null
+++ b/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
@@ -0,0 +1,259 @@
+package dev.spexx.configurationAPI.watcher;
+
+import dev.spexx.configurationAPI.config.YamlConfig;
+import dev.spexx.configurationAPI.events.ConfigReloadedEvent;
+import dev.spexx.configurationAPI.util.FileChecksum;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.*;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static java.nio.file.StandardWatchEventKinds.*;
+
+/**
+ * Global watcher responsible for monitoring multiple configuration files
+ * using a single {@link WatchService} instance.
+ *
+ *
This implementation tracks configuration files by their normalized
+ * {@link Path} and performs atomic replacement of {@link YamlConfig} instances
+ * when changes are detected.
+ *
+ *
Behavior
+ *
+ *
{@code ENTRY_CREATE} → loads new configuration if tracked
+ *
{@code ENTRY_MODIFY} → reloads configuration if content changed
+ *
{@code ENTRY_DELETE} → removes configuration from tracking
+ *
+ *
+ *
Threading
+ *
File system events are processed on a dedicated daemon thread.
+ * Bukkit events are dispatched synchronously on the main server thread.
+ *
+ * @apiNote
+ * This watcher replaces {@link YamlConfig} instances rather than mutating them,
+ * ensuring thread-safe access for all consumers.
+ *
+ * @implSpec
+ * Uses SHA-256 checksum comparison to detect content changes and a debounce
+ * window to suppress duplicate file system events.
+ *
+ * @since 1.0.5
+ */
+public final class GlobalConfigWatcher {
+
+ private static final long DEBOUNCE_MS = 300;
+
+ private final @NotNull JavaPlugin plugin;
+ private final @NotNull WatchService watchService;
+
+ private final Map configs = new ConcurrentHashMap<>();
+ private final Map checksums = new ConcurrentHashMap<>();
+ private final Map lastReload = new ConcurrentHashMap<>();
+ private final Set watchedDirs = ConcurrentHashMap.newKeySet();
+
+ private final AtomicBoolean running = new AtomicBoolean(false);
+ private Thread thread;
+
+ /**
+ * Creates a new watcher instance.
+ *
+ * @param plugin owning plugin instance
+ * @throws IOException if the watch service cannot be initialized
+ */
+ public GlobalConfigWatcher(@NotNull JavaPlugin plugin) throws IOException {
+ this.plugin = plugin;
+ this.watchService = FileSystems.getDefault().newWatchService();
+ }
+
+ /**
+ * Registers a configuration for monitoring.
+ *
+ *
The parent directory is registered with the {@link WatchService}
+ * if not already tracked.
This watcher monitors the parent directory of a target configuration file
- * using {@link WatchService} and filters events specific to the file name.
- *
- *
It performs checksum validation and line-level diff analysis to detect changes.
- * Reload operations are delegated to an external callback and dispatched on the
- * main server thread.
- *
- * @apiNote
- * This watcher runs on a dedicated daemon thread. Reload callbacks and event
- * dispatching are always executed on the main server thread.
- *
- * @implSpec
- * File changes are detected via {@link WatchService} and validated using
- * SHA-256 checksums. Diff computation uses a manual line-splitting algorithm
- * to avoid regex overhead.
- *
- * @implNote
- * Some editors perform atomic file replacement (delete + recreate). This class
- * includes safeguards against transient file states and partial reads.
- *
- * @since 1.0.0
- */
-public final class YamlConfigWatcher {
-
- private final JavaPlugin plugin;
- private final Runnable reloadCallback;
-
- private volatile YamlConfig yamlConfig;
- private volatile String lastChecksum;
- private volatile String lastContent;
-
- private WatchService watchService;
- private Thread thread;
-
- private final AtomicBoolean running = new AtomicBoolean(false);
-
- private volatile long lastReload = 0;
- private volatile long lastEventTime = 0;
-
- private static final long DEBOUNCE_MS = 300;
- private static final long MIN_EVENT_INTERVAL_MS = 100;
- private static final int MAX_DIFFS = 10_000;
-
- /**
- * Constructs a new {@code YamlConfigWatcher}.
- *
- * @param plugin the owning plugin instance, must not be {@code null}
- * @param yamlConfig the configuration to monitor, must not be {@code null}
- * @param reloadCallback callback responsible for performing reload logic
- *
- * @since 1.0.0
- */
- public YamlConfigWatcher(@NotNull JavaPlugin plugin,
- @NotNull YamlConfig yamlConfig,
- @NotNull Runnable reloadCallback) {
- this.plugin = plugin;
- this.yamlConfig = yamlConfig;
- this.reloadCallback = reloadCallback;
- this.lastChecksum = FileChecksum.sha256(yamlConfig.file());
- this.lastContent = readContentSafe(yamlConfig.file());
- }
-
- /**
- * Starts monitoring the configuration file.
- *
- * @throws IOException if the watcher cannot be initialized
- *
- * @since 1.0.0
- */
- public void start() throws IOException {
-
- if (running.get()) return;
-
- Path filePath = yamlConfig.file().toPath().toAbsolutePath().normalize();
- Path dir = filePath.getParent();
-
- if (dir == null) {
- throw new IllegalStateException("No parent directory: " + filePath);
- }
-
- watchService = FileSystems.getDefault().newWatchService();
-
- dir.register(
- watchService,
- StandardWatchEventKinds.ENTRY_MODIFY,
- StandardWatchEventKinds.ENTRY_CREATE,
- StandardWatchEventKinds.ENTRY_DELETE
- );
-
- running.set(true);
-
- thread = new Thread(this::run, "ConfigWatcher-" + filePath.getFileName());
- thread.setDaemon(true);
- thread.start();
- }
-
- /**
- * Stops monitoring and releases resources.
- *
- * @since 1.0.0
- */
- public void stop() {
- running.set(false);
-
- try {
- if (watchService != null) watchService.close();
- } catch (IOException ignored) {}
-
- if (thread != null) thread.interrupt();
- }
-
- /**
- * Updates the tracked configuration reference.
- *
- * @param config new configuration instance
- *
- * @since 1.0.0
- */
- public void updateConfig(@NotNull YamlConfig config) {
- this.yamlConfig = config;
- }
-
- /**
- * Updates the stored checksum.
- *
- * @since 1.0.0
- */
- public void updateChecksum() {
- this.lastChecksum = FileChecksum.sha256(yamlConfig.file());
- }
-
- /**
- * Main watcher loop.
- */
- private void run() {
-
- final Path targetFileName = yamlConfig.file().toPath().getFileName();
-
- while (running.get()) {
- try {
- WatchKey key = watchService.take();
-
- for (WatchEvent> event : key.pollEvents()) {
-
- if (event.kind() == StandardWatchEventKinds.OVERFLOW) continue;
-
- Path changed = (Path) event.context();
-
- if (!changed.equals(targetFileName)) continue;
-
- long now = System.currentTimeMillis();
-
- if (now - lastEventTime < MIN_EVENT_INTERVAL_MS) continue;
- lastEventTime = now;
-
- if (now - lastReload < DEBOUNCE_MS) continue;
- lastReload = now;
-
- reload();
- }
-
- if (!key.reset()) {
- plugin.getLogger().warning("[ConfigWatcher] Watch key invalidated: "
- + targetFileName);
- break;
- }
-
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- break;
-
- } catch (ClosedWatchServiceException e) {
- break;
-
- } catch (Exception e) {
- plugin.getLogger().severe("[ConfigWatcher] Watch loop crashed: " + e.getMessage());
- break;
- }
- }
- }
-
- /**
- * Performs checksum validation and triggers reload if needed.
- *
- * @since 1.0.1
- */
- private void reload() {
-
- File file = yamlConfig.file();
-
- if (!file.exists()) {
- plugin.getLogger().fine(() ->
- "[ConfigWatcher] File missing, skipping reload: " + file.getName()
- );
- return;
- }
-
- String newChecksum = FileChecksum.sha256(file);
- if (newChecksum.equals(lastChecksum)) return;
-
- String oldChecksum = lastChecksum;
- String newContent = readContentSafe(file);
-
- if (newContent.equals(lastContent)) {
- lastChecksum = newChecksum;
- lastContent = newContent;
- return;
- }
-
- List oldLines = splitLines(lastContent);
- List newLines = splitLines(newContent);
-
- int max = Math.max(oldLines.size(), newLines.size());
- List diffs = null;
-
- int changed = 0;
- int added = 0;
- int removed = 0;
-
- for (int i = 0; i < max; i++) {
-
- String oldLine = i < oldLines.size() ? oldLines.get(i) : "";
- String newLine = i < newLines.size() ? newLines.get(i) : "";
-
- if (!oldLine.equals(newLine)) {
-
- if (diffs == null) diffs = new ArrayList<>(max);
-
- if (diffs.size() >= MAX_DIFFS) {
- plugin.getLogger().warning("[ConfigWatcher] Diff limit reached, truncating.");
- break;
- }
-
- diffs.add(new ConfigLineDifference(i + 1, oldLine, newLine));
-
- if (i >= oldLines.size()) added++;
- else if (i >= newLines.size()) removed++;
- else changed++;
- }
- }
-
- lastChecksum = newChecksum;
- lastContent = newContent;
-
- if (!plugin.isEnabled()) return;
-
- final ConfigChangeSummary summary = new ConfigChangeSummary(changed, added, removed);
- final List finalDiffs =
- diffs == null ? List.of() : List.copyOf(diffs);
-
- plugin.getServer().getScheduler().runTask(plugin, () -> {
-
- reloadCallback.run();
-
- plugin.getServer().getPluginManager().callEvent(
- new ConfigReloadedEvent(
- yamlConfig,
- oldChecksum,
- newChecksum,
- summary,
- finalDiffs
- )
- );
- });
- }
-
- /**
- * Splits content into lines without regex.
- *
- * @since 1.0.3
- */
- private static @NotNull List splitLines(@NotNull String content) {
-
- List lines = new ArrayList<>();
- int start = 0;
-
- for (int i = 0; i < content.length(); i++) {
- if (content.charAt(i) == '\n') {
- lines.add(content.substring(start, i));
- start = i + 1;
- }
- }
-
- lines.add(content.substring(start));
- return lines;
- }
-
- /**
- * Reads file content safely with retry mechanism.
- *
- * @since 1.0.4
- */
- private @NotNull String readContentSafe(@NotNull File file) {
-
- Path path = file.toPath();
-
- for (int i = 0; i < 3; i++) {
- try {
- return Files.readString(path);
- } catch (IOException e) {
- try {
- Thread.sleep(10);
- } catch (InterruptedException ignored) {
- Thread.currentThread().interrupt();
- break;
- }
- }
- }
-
- plugin.getLogger().warning(
- "[ConfigWatcher] Failed to read config file after retries: "
- + file.getAbsolutePath()
- );
-
- return "";
- }
-}
\ No newline at end of file
diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml
index 04af83a..5c58b8f 100644
--- a/src/main/resources/paper-plugin.yml
+++ b/src/main/resources/paper-plugin.yml
@@ -1,6 +1,6 @@
name: ConfigurationAPI
description: $description
-version: '$version'
+version: '1.0.5'
main: dev.spexx.configurationAPI.ConfigurationAPI
api-version: '1.21.11'
From 4066e0a033b5a6cec2414a215b14a3aadfef3497 Mon Sep 17 00:00:00 2001
From: Spexx
Date: Sun, 5 Apr 2026 10:34:10 +0200
Subject: [PATCH 2/4] refactor: replace per-file watchers with
GlobalConfigWatcher and unify config state
---
.../manager/ConfigManager.java | 148 +++++++-----------
.../watcher/GlobalConfigWatcher.java | 112 +++++++++----
2 files changed, 141 insertions(+), 119 deletions(-)
diff --git a/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java b/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java
index b1d66c4..8123821 100644
--- a/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java
+++ b/src/main/java/dev/spexx/configurationAPI/manager/ConfigManager.java
@@ -5,75 +5,57 @@
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Collection;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.Objects;
/**
- * Central registry and lifecycle manager for {@link YamlConfig} instances.
+ * Central access point for configuration files managed by the system.
*
- *
This class provides controlled access to configuration files by handling:
+ *
This class is responsible for:
*
*
Initial loading of configuration files
- *
Registration of configurations for file system monitoring
- *
Thread-safe access to configuration snapshots
- *
Basic lifecycle operations such as unload
+ *
Registering configurations with the global watcher
+ *
Providing access to the latest configuration snapshots
*
*
- *
File system monitoring and automatic reload behavior are delegated to
- * {@link GlobalConfigWatcher}. This class does not perform direct file watching.
+ *
Architecture
+ *
The {@link GlobalConfigWatcher} is the single source of truth for all
+ * configuration state. This class does not maintain its own cache.
*
- *
Thread Safety
- *
This class is safe for concurrent use. Internally, a
- * {@link ConcurrentHashMap} is used to store configuration snapshots.
- * Each {@link YamlConfig} instance is immutable, ensuring that readers
- * never observe partially updated state.
+ *
All calls to {@link #get(File)} delegate directly to the watcher,
+ * ensuring that consumers always receive the most up-to-date configuration.
*
- *
Lifecycle
- *
- *
Configurations are loaded via {@link #getOrLoad(File)}
- *
Each configuration is registered with the watcher
- *
The watcher updates configuration state when file changes occur
- *
+ *
Thread Safety
+ *
This class is thread-safe. The underlying watcher uses concurrent
+ * data structures and atomic replacement of {@link YamlConfig} instances.
*
* @apiNote
- * Consumers should avoid caching {@link YamlConfig} instances long-term.
- * Instead, they should retrieve the current snapshot when needed to ensure
- * they are using the most recent configuration state.
+ * {@link YamlConfig} instances are immutable snapshots. Consumers should
+ * retrieve them on demand rather than caching references long-term.
*
* @since 1.0.5
*/
public final class ConfigManager {
- /**
- * Owning plugin instance used for resource access and logging.
- */
private final @NotNull JavaPlugin plugin;
-
- /**
- * Global watcher responsible for monitoring configuration file changes.
- */
private final @NotNull GlobalConfigWatcher watcher;
/**
- * Cache of configuration snapshots indexed by normalized file path.
- */
- private final ConcurrentHashMap configs = new ConcurrentHashMap<>();
-
- /**
- * Constructs a new {@code ConfigManager} instance.
+ * Creates a new {@code ConfigManager}.
*
*
This initializes and starts the {@link GlobalConfigWatcher}.
*
- * @param plugin the owning plugin instance, must not be {@code null}
+ * @param plugin owning plugin instance, must not be {@code null}
*
- * @throws RuntimeException if the watcher cannot be initialized
+ * @throws RuntimeException if watcher initialization fails
*/
public ConfigManager(@NotNull JavaPlugin plugin) {
- this.plugin = plugin;
+ this.plugin = Objects.requireNonNull(plugin, "plugin");
try {
this.watcher = new GlobalConfigWatcher(plugin);
@@ -84,20 +66,20 @@ public ConfigManager(@NotNull JavaPlugin plugin) {
}
/**
- * Returns the configuration associated with the specified file.
+ * Returns the latest configuration snapshot for the given file.
*
- *
The file must have been previously loaded using
+ *
The configuration must have been previously loaded using
* {@link #getOrLoad(File)} or {@link #getOrLoadResource(String)}.
*
- * @param file the configuration file, must not be {@code null}
- * @return the corresponding {@link YamlConfig} snapshot, never {@code null}
+ * @param file configuration file, must not be {@code null}
+ * @return latest {@link YamlConfig} snapshot, never {@code null}
*
- * @throws IllegalStateException if the configuration has not been loaded
+ * @throws IllegalStateException if the configuration is not loaded
*/
public @NotNull YamlConfig get(@NotNull File file) {
Path path = normalize(file.toPath());
- YamlConfig config = configs.get(path);
+ YamlConfig config = watcher.get(path);
if (config == null) {
throw new IllegalStateException("Config not loaded: " + path);
}
@@ -106,29 +88,36 @@ public ConfigManager(@NotNull JavaPlugin plugin) {
}
/**
- * Returns an existing configuration or loads it if not already present.
+ * Returns an existing configuration or loads it if not already tracked.
*
- *
If the configuration is not already cached, it is loaded from disk,
- * registered with the watcher, and stored internally.
+ *
If the configuration is not already registered, it is:
+ *
+ *
Loaded from disk
+ *
Registered with the watcher
+ *
*
- * @param file the configuration file, must not be {@code null}
- * @return the existing or newly loaded {@link YamlConfig} snapshot
+ * @param file configuration file, must not be {@code null}
+ * @return latest {@link YamlConfig} snapshot
*/
public @NotNull YamlConfig getOrLoad(@NotNull File file) {
Path path = normalize(file.toPath());
- return configs.computeIfAbsent(path, p -> load(file));
+
+ YamlConfig existing = watcher.get(path);
+ if (existing != null) {
+ return existing;
+ }
+
+ return load(file);
}
/**
- * Loads a configuration file from the plugin's bundled resources.
+ * Loads a configuration file from plugin resources if necessary.
*
*
If the file does not exist in the plugin data folder, it is copied
- * from the plugin JAR using {@link JavaPlugin#saveResource(String, boolean)}.
- *
- *
The resulting file is then loaded and managed like any other configuration.
+ * from the plugin JAR. The file is then loaded and registered.
*
- * @param name the resource name (for example, {@code "config.yml"}), must not be {@code null}
- * @return the loaded {@link YamlConfig} snapshot
+ * @param name resource name (for example {@code "config.yml"}), must not be {@code null}
+ * @return latest {@link YamlConfig} snapshot
*/
public @NotNull YamlConfig getOrLoadResource(@NotNull String name) {
plugin.saveResource(name, false);
@@ -136,50 +125,37 @@ public ConfigManager(@NotNull JavaPlugin plugin) {
}
/**
- * Unloads the configuration associated with the given file.
+ * Stops tracking the specified configuration file.
*
- *
This removes the configuration snapshot from the internal cache.
- * The watcher will stop tracking the file once it is removed or no longer exists.
+ *
This removes the configuration from the watcher. If the file still
+ * exists, it may be reloaded again if explicitly registered.
*
- * @param file the configuration file to unload, must not be {@code null}
+ * @param file configuration file, must not be {@code null}
*/
public void unload(@NotNull File file) {
Path path = normalize(file.toPath());
- configs.remove(path);
+ watcher.unregister(path);
}
/**
- * Returns all currently loaded configuration snapshots.
+ * Returns all currently tracked configuration snapshots.
*
- *
The returned collection reflects the current internal state and is
- * backed by the underlying map.
- *
- * @return collection of loaded configurations, never {@code null}
+ * @return collection of configurations, never {@code null}
*/
public @NotNull Collection getAll() {
- return configs.values();
+ return watcher.getAll();
}
/**
* Loads a configuration file and registers it with the watcher.
*
- *
This method performs the following steps:
- *
- *
Ensures the file and its parent directories exist
- *
Parses the YAML file into a {@link YamlConfig}
- *
Stores the snapshot in the internal cache
- *
Registers the file with the {@link GlobalConfigWatcher}
- *
- *
- * @param file the configuration file, must not be {@code null}
- * @return the loaded {@link YamlConfig} snapshot
+ * @param file configuration file, must not be {@code null}
+ * @return loaded {@link YamlConfig} snapshot
*
* @throws RuntimeException if watcher registration fails
*/
private @NotNull YamlConfig load(@NotNull File file) {
- Path path = normalize(file.toPath());
-
ensureFileExists(file);
YamlConfig config = new YamlConfig(
@@ -187,8 +163,6 @@ public void unload(@NotNull File file) {
YamlConfiguration.loadConfiguration(file)
);
- configs.put(path, config);
-
try {
watcher.register(config);
} catch (IOException e) {
@@ -199,12 +173,9 @@ public void unload(@NotNull File file) {
}
/**
- * Ensures that the specified file and its parent directories exist.
- *
- *
If the file does not exist, parent directories are created if necessary.
- * The file itself is not created.
+ * Ensures that the parent directories of the file exist.
*
- * @param file the file to verify, must not be {@code null}
+ * @param file file to verify, must not be {@code null}
*
* @throws IllegalStateException if directory creation fails
*/
@@ -226,10 +197,7 @@ private void ensureFileExists(@NotNull File file) {
/**
* Normalizes a path to ensure consistent identity.
*
- *
This method converts the path to an absolute, normalized form to avoid
- * inconsistencies caused by relative paths or differing representations.
- *
- * @param path the raw path, must not be {@code null}
+ * @param path raw path, must not be {@code null}
* @return normalized absolute path
*/
private static @NotNull Path normalize(@NotNull Path path) {
diff --git a/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java b/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
index 0bc5041..a88b1f2 100644
--- a/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
+++ b/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
@@ -11,6 +11,7 @@
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
+import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -22,41 +23,61 @@
* Global watcher responsible for monitoring multiple configuration files
* using a single {@link WatchService} instance.
*
- *
This implementation tracks configuration files by their normalized
- * {@link Path} and performs atomic replacement of {@link YamlConfig} instances
- * when changes are detected.
+ *
This class acts as the single source of truth for all tracked
+ * {@link YamlConfig} instances. It maintains the latest configuration
+ * snapshots and updates them automatically when file system changes
+ * are detected.
*
*
Behavior
*
- *
{@code ENTRY_CREATE} → loads new configuration if tracked
- *
{@code ENTRY_MODIFY} → reloads configuration if content changed
- *
{@code ENTRY_DELETE} → removes configuration from tracking
+ *
{@link StandardWatchEventKinds#ENTRY_CREATE} — loads or reloads configuration
+ *
{@link StandardWatchEventKinds#ENTRY_MODIFY} — reloads configuration if content changed
+ *
{@link StandardWatchEventKinds#ENTRY_DELETE} — removes configuration from tracking
*
*
*
Threading
*
File system events are processed on a dedicated daemon thread.
* Bukkit events are dispatched synchronously on the main server thread.
*
- * @apiNote
- * This watcher replaces {@link YamlConfig} instances rather than mutating them,
- * ensuring thread-safe access for all consumers.
+ *
Consistency
+ *
Configuration instances are replaced atomically. Consumers will always
+ * observe either the previous or the updated snapshot, never a partially
+ * updated state.
*
- * @implSpec
- * Uses SHA-256 checksum comparison to detect content changes and a debounce
- * window to suppress duplicate file system events.
+ *
Change Detection
+ *
Changes are detected using SHA-256 checksums. Identical file contents
+ * will not trigger reloads even if file system events occur.
*
* @since 1.0.5
*/
public final class GlobalConfigWatcher {
+ /**
+ * Minimum time between reload attempts for the same file, in milliseconds.
+ */
private static final long DEBOUNCE_MS = 300;
private final @NotNull JavaPlugin plugin;
private final @NotNull WatchService watchService;
+ /**
+ * Current configuration snapshots indexed by normalized path.
+ */
private final Map configs = new ConcurrentHashMap<>();
+
+ /**
+ * Last known checksums for tracked files.
+ */
private final Map checksums = new ConcurrentHashMap<>();
+
+ /**
+ * Last reload timestamps used for debounce protection.
+ */
private final Map lastReload = new ConcurrentHashMap<>();
+
+ /**
+ * Set of directories currently registered with the watch service.
+ */
private final Set watchedDirs = ConcurrentHashMap.newKeySet();
private final AtomicBoolean running = new AtomicBoolean(false);
@@ -65,7 +86,7 @@ public final class GlobalConfigWatcher {
/**
* Creates a new watcher instance.
*
- * @param plugin owning plugin instance
+ * @param plugin owning plugin instance, must not be {@code null}
* @throws IOException if the watch service cannot be initialized
*/
public GlobalConfigWatcher(@NotNull JavaPlugin plugin) throws IOException {
@@ -74,12 +95,12 @@ public GlobalConfigWatcher(@NotNull JavaPlugin plugin) throws IOException {
}
/**
- * Registers a configuration for monitoring.
+ * Registers a configuration file for monitoring.
*
- *
The parent directory is registered with the {@link WatchService}
- * if not already tracked.
+ *
The parent directory of the file is registered with the
+ * {@link WatchService} if not already tracked.
*
- * @param config configuration snapshot
+ * @param config configuration snapshot to register, must not be {@code null}
* @throws IOException if directory registration fails
*/
public void register(@NotNull YamlConfig config) throws IOException {
@@ -95,11 +116,30 @@ public void register(@NotNull YamlConfig config) throws IOException {
}
}
+ /**
+ * Unregisters a configuration file from monitoring.
+ *
+ *
This removes all associated state for the specified path.
+ *
+ * @param path configuration path, must not be {@code null}
+ */
+ public void unregister(@NotNull Path path) {
+ Path normalized = normalize(path);
+
+ configs.remove(normalized);
+ checksums.remove(normalized);
+ lastReload.remove(normalized);
+ }
+
/**
* Starts the watcher thread.
+ *
+ *
This method is idempotent. Calling it multiple times has no effect.
*/
public void start() {
- if (running.get()) return;
+ if (running.get()) {
+ return;
+ }
running.set(true);
@@ -110,13 +150,17 @@ public void start() {
/**
* Stops the watcher and releases system resources.
+ *
+ *
The watcher thread is interrupted and the underlying
+ * {@link WatchService} is closed.
*/
public void stop() {
running.set(false);
try {
watchService.close();
- } catch (IOException ignored) {}
+ } catch (IOException ignored) {
+ }
if (thread != null) {
thread.interrupt();
@@ -124,7 +168,7 @@ public void stop() {
}
/**
- * Main watcher loop.
+ * Main watcher loop that processes file system events.
*/
private void run() {
@@ -169,25 +213,26 @@ private void run() {
}
/**
- * Handles a file system event for a specific path.
+ * Handles a file system event for a tracked configuration file.
*
- * @param path affected file path
- * @param kind event type
+ * @param path affected file path, must not be {@code null}
+ * @param kind event type, must not be {@code null}
*/
private void handle(@NotNull Path path, WatchEvent.@NotNull Kind> kind) {
File file = path.toFile();
- // DELETE
+ // Handle deletion
if (kind == ENTRY_DELETE) {
if (configs.remove(path) != null) {
checksums.remove(path);
+ lastReload.remove(path);
plugin.getLogger().info("[ConfigWatcher] Removed: " + path.getFileName());
}
return;
}
- // CREATE / MODIFY
+ // Ignore if file does not exist
if (!file.exists()) {
return;
}
@@ -238,19 +283,28 @@ private void handle(@NotNull Path path, WatchEvent.@NotNull Kind> kind) {
}
/**
- * Returns the current configuration snapshot for the given path.
+ * Returns the current configuration snapshot for the specified path.
*
- * @param path configuration path
- * @return configuration snapshot or {@code null} if not tracked
+ * @param path configuration path, must not be {@code null}
+ * @return configuration snapshot, or {@code null} if not tracked
*/
public @Nullable YamlConfig get(@NotNull Path path) {
return configs.get(normalize(path));
}
+ /**
+ * Returns all currently tracked configuration snapshots.
+ *
+ * @return collection of configurations, never {@code null}
+ */
+ public @NotNull Collection getAll() {
+ return configs.values();
+ }
+
/**
* Normalizes a path to ensure consistent identity.
*
- * @param path raw path
+ * @param path raw path, must not be {@code null}
* @return normalized absolute path
*/
private static @NotNull Path normalize(@NotNull Path path) {
From fb526eeedd3ec58c9ed5f47480c618975afd7e76 Mon Sep 17 00:00:00 2001
From: Spexx
Date: Sun, 5 Apr 2026 10:39:40 +0200
Subject: [PATCH 3/4] refactor: improve GlobalConfigWatcher tracking and
cleanup
---
.../configurationAPI/watcher/GlobalConfigWatcher.java | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java b/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
index a88b1f2..bb01377 100644
--- a/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
+++ b/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
@@ -193,8 +193,7 @@ private void run() {
}
lastReload.put(path, now);
-
- handle(path, event.kind());
+ handleFileEvent(path, event.kind());
}
if (!key.reset()) {
@@ -218,10 +217,14 @@ private void run() {
* @param path affected file path, must not be {@code null}
* @param kind event type, must not be {@code null}
*/
- private void handle(@NotNull Path path, WatchEvent.@NotNull Kind> kind) {
+ private void handleFileEvent(@NotNull Path path, WatchEvent.@NotNull Kind> kind) {
File file = path.toFile();
+ if (!configs.containsKey(path)) {
+ return;
+ }
+
// Handle deletion
if (kind == ENTRY_DELETE) {
if (configs.remove(path) != null) {
From aeb4e6343fbd048aa37fc57c41e936806ef50724 Mon Sep 17 00:00:00 2001
From: Spexx
Date: Sun, 5 Apr 2026 10:40:48 +0200
Subject: [PATCH 4/4] fix(watcher): prevent handling of unregistered files and
ensure proper cleanup
Signed-off-by: Spexx
---
.../watcher/GlobalConfigWatcher.java | 19 ++++++++-----------
1 file changed, 8 insertions(+), 11 deletions(-)
diff --git a/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java b/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
index bb01377..00b1e59 100644
--- a/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
+++ b/src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java
@@ -263,17 +263,14 @@ private void handleFileEvent(@NotNull Path path, WatchEvent.@NotNull Kind> kin
int timeMs = (int) ((System.nanoTime() - start) / 1_000_000);
if (oldConfig != null && oldChecksum != null) {
- plugin.getServer().getScheduler().runTask(plugin, () ->
- plugin.getServer().getPluginManager().callEvent(
- new ConfigReloadedEvent(
- oldConfig,
- newConfig,
- oldChecksum,
- newChecksum,
- timeMs
- )
- )
- );
+
+ var scheduler = plugin.getServer().getScheduler();
+ var pluginManager = plugin.getServer().getPluginManager();
+
+ scheduler.runTask(plugin, () ->
+ pluginManager.callEvent(new ConfigReloadedEvent(
+ oldConfig, newConfig, oldChecksum, newChecksum, timeMs
+ )));
}
plugin.getLogger().info("[ConfigWatcher] Reloaded: " + file.getName());