From 8ca165c892ca4017cf241115c9fba0fc8570b58d Mon Sep 17 00:00:00 2001 From: Spexx Date: Sun, 5 Apr 2026 10:28:30 +0200 Subject: [PATCH 1/4] refactor: introduce GlobalConfigWatcher, improve config lifecycle, and standardize Javadoc --- pom.xml | 23 +- .../configurationAPI/config/YamlConfig.java | 85 ++--- .../difference/ConfigChangeSummary.java | 102 ----- .../difference/ConfigLineDifference.java | 177 --------- .../events/ConfigReloadedEvent.java | 167 ++++----- .../manager/ConfigManager.java | 252 ++++++------- .../properties/FileProperties.java | 281 -------------- .../configurationAPI/util/FileChecksum.java | 47 +-- .../watcher/GlobalConfigWatcher.java | 259 +++++++++++++ .../watcher/YamlConfigWatcher.java | 348 ------------------ src/main/resources/paper-plugin.yml | 2 +- 11 files changed, 529 insertions(+), 1214 deletions(-) delete mode 100644 src/main/java/dev/spexx/configurationAPI/difference/ConfigChangeSummary.java delete mode 100644 src/main/java/dev/spexx/configurationAPI/difference/ConfigLineDifference.java delete mode 100644 src/main/java/dev/spexx/configurationAPI/properties/FileProperties.java create mode 100644 src/main/java/dev/spexx/configurationAPI/watcher/GlobalConfigWatcher.java delete mode 100644 src/main/java/dev/spexx/configurationAPI/watcher/YamlConfigWatcher.java diff --git a/pom.xml b/pom.xml index b1e6b32..df645a1 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ dev.spexx ConfigurationAPI - 1.0.4 + 1.0.5 jar ConfigurationAPI @@ -22,7 +22,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.13.0 + 3.15.0 ${java.version} ${java.version} @@ -32,12 +32,10 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.7.0 + 3.12.0 ${java.version} - - apiNote @@ -71,6 +69,21 @@ + + org.apache.maven.plugins + maven-source-plugin + 3.4.0 + + + + attach-sources + + jar + + + + + diff --git a/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java b/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java index d9291ac..50c72a0 100644 --- a/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java +++ b/src/main/java/dev/spexx/configurationAPI/config/YamlConfig.java @@ -1,6 +1,5 @@ package dev.spexx.configurationAPI.config; -import dev.spexx.configurationAPI.properties.FileProperties; import org.bukkit.configuration.file.FileConfiguration; import org.jetbrains.annotations.NotNull; @@ -13,26 +12,33 @@ *
    *
  • The underlying {@link File} on disk
  • *
  • The parsed {@link FileConfiguration} instance
  • - *
  • Associated {@link FileProperties} metadata
  • *
* - *

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.

- * - *

Examples:

- *
    - *
  • {@code "value: 1"} vs {@code "value: 1 "} → {@code true}
  • - *
  • {@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

+ *
    + *
  1. Configurations are loaded via {@link #getOrLoad(File)}
  2. + *
  3. Each configuration is registered with the watcher
  4. + *
  5. The watcher updates configuration state when file changes occur
  6. + *
* - * @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:

+ *
    + *
  1. Ensures the file and its parent directories exist
  2. + *
  3. Parses the YAML file into a {@link YamlConfig}
  4. + *
  5. Stores the snapshot in the internal cache
  6. + *
  7. Registers the file with the {@link GlobalConfigWatcher}
  8. + *
* * @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.

+ * + * @param config configuration snapshot + * @throws IOException if directory registration fails + */ + public void register(@NotNull YamlConfig config) throws IOException { + + Path path = normalize(config.file().toPath()); + Path dir = path.getParent(); + + configs.put(path, config); + checksums.put(path, FileChecksum.sha256(config.file())); + + if (watchedDirs.add(dir)) { + dir.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE); + } + } + + /** + * Starts the watcher thread. + */ + public void start() { + if (running.get()) return; + + running.set(true); + + thread = new Thread(this::run, "GlobalConfigWatcher"); + thread.setDaemon(true); + thread.start(); + } + + /** + * Stops the watcher and releases system resources. + */ + public void stop() { + running.set(false); + + try { + watchService.close(); + } catch (IOException ignored) {} + + if (thread != null) { + thread.interrupt(); + } + } + + /** + * Main watcher loop. + */ + private void run() { + + while (running.get()) { + try { + WatchKey key = watchService.take(); + Path dir = (Path) key.watchable(); + + for (WatchEvent event : key.pollEvents()) { + + if (event.kind() == OVERFLOW) { + continue; + } + + Path path = normalize(dir.resolve((Path) event.context())); + + long now = System.currentTimeMillis(); + long last = lastReload.getOrDefault(path, 0L); + + if (now - last < DEBOUNCE_MS) { + continue; + } + + lastReload.put(path, now); + + handle(path, event.kind()); + } + + if (!key.reset()) { + plugin.getLogger().warning("[ConfigWatcher] Watch key no longer valid."); + } + + } catch (ClosedWatchServiceException e) { + return; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (Exception e) { + plugin.getLogger().warning("[ConfigWatcher] Unexpected error: " + e.getMessage()); + } + } + } + + /** + * Handles a file system event for a specific path. + * + * @param path affected file path + * @param kind event type + */ + private void handle(@NotNull Path path, WatchEvent.@NotNull Kind kind) { + + File file = path.toFile(); + + // DELETE + if (kind == ENTRY_DELETE) { + if (configs.remove(path) != null) { + checksums.remove(path); + plugin.getLogger().info("[ConfigWatcher] Removed: " + path.getFileName()); + } + return; + } + + // CREATE / MODIFY + if (!file.exists()) { + return; + } + + try { + String newChecksum = FileChecksum.sha256(file); + @Nullable String oldChecksum = checksums.get(path); + + if (oldChecksum != null && oldChecksum.equals(newChecksum)) { + return; + } + + long start = System.nanoTime(); + + @Nullable YamlConfig oldConfig = configs.get(path); + + YamlConfig newConfig = new YamlConfig( + file, + YamlConfiguration.loadConfiguration(file) + ); + + configs.put(path, newConfig); + checksums.put(path, newChecksum); + + 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 + ) + ) + ); + } + + plugin.getLogger().info("[ConfigWatcher] Reloaded: " + file.getName()); + + } catch (Exception e) { + plugin.getLogger().warning( + "[ConfigWatcher] Failed to load: " + file.getName() + ); + } + } + + /** + * Returns the current configuration snapshot for the given path. + * + * @param path configuration path + * @return configuration snapshot or {@code null} if not tracked + */ + public @Nullable YamlConfig get(@NotNull Path path) { + return configs.get(normalize(path)); + } + + /** + * Normalizes a path to ensure consistent identity. + * + * @param path raw path + * @return normalized absolute 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/watcher/YamlConfigWatcher.java b/src/main/java/dev/spexx/configurationAPI/watcher/YamlConfigWatcher.java deleted file mode 100644 index 55767fd..0000000 --- a/src/main/java/dev/spexx/configurationAPI/watcher/YamlConfigWatcher.java +++ /dev/null @@ -1,348 +0,0 @@ -package dev.spexx.configurationAPI.watcher; - -import dev.spexx.configurationAPI.config.YamlConfig; -import dev.spexx.configurationAPI.difference.ConfigChangeSummary; -import dev.spexx.configurationAPI.difference.ConfigLineDifference; -import dev.spexx.configurationAPI.events.ConfigReloadedEvent; -import dev.spexx.configurationAPI.util.FileChecksum; -import org.bukkit.plugin.java.JavaPlugin; -import org.jetbrains.annotations.NotNull; - -import java.io.File; -import java.io.IOException; -import java.nio.file.*; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * Watches a single configuration file and triggers atomic reload operations - * when file system changes are detected. - * - *

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.

- * - *

Features

- *
    - *
  • Checksum-based change detection (SHA-256)
  • - *
  • Debounced file system event handling
  • - *
  • Retry-based safe file reading (prevents partial reads)
  • - *
  • Lazy diff computation with O(n) complexity
  • - *
  • Diff size limiting to prevent memory pressure
  • - *
  • Thread-safe configuration reference updates
  • - *
- * - * @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

- *
    - *
  1. Configurations are loaded via {@link #getOrLoad(File)}
  2. - *
  3. Each configuration is registered with the watcher
  4. - *
  5. The watcher updates configuration state when file changes occur
  6. - *
+ *

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: + *

    + *
  1. Loaded from disk
  2. + *
  3. Registered with the watcher
  4. + *
* - * @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:

- *
    - *
  1. Ensures the file and its parent directories exist
  2. - *
  3. Parses the YAML file into a {@link YamlConfig}
  4. - *
  5. Stores the snapshot in the internal cache
  6. - *
  7. Registers the file with the {@link GlobalConfigWatcher}
  8. - *
- * - * @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());