PluginLoader.java

1
// SPDX-License-Identifier: Apache-2.0
2
// Copyright 2026 Egothor
3
// Copyright 2026 Accenture
4
package org.egothor.methodatlas.command;
5
6
import java.io.IOException;
7
import java.util.ArrayList;
8
import java.util.LinkedHashSet;
9
import java.util.List;
10
import java.util.ServiceLoader;
11
import java.util.Set;
12
import java.util.logging.Level;
13
import java.util.logging.Logger;
14
15
import org.egothor.methodatlas.api.SourcePatcher;
16
import org.egothor.methodatlas.api.TestDiscovery;
17
import org.egothor.methodatlas.api.TestDiscoveryConfig;
18
19
/**
20
 * Resolves and configures discovery plugins via {@link ServiceLoader}.
21
 *
22
 * <p>
23
 * Each plugin JAR ships a service registration file under
24
 * {@code META-INF/services/} listing its implementation of
25
 * {@link TestDiscovery} (and, optionally, {@link SourcePatcher}). This loader
26
 * walks the classpath, instantiates every registered provider, applies the
27
 * run-time {@link TestDiscoveryConfig} via {@code configure}, and verifies that
28
 * every provider declares a unique {@code pluginId()}.
29
 * </p>
30
 *
31
 * <h2>Lifecycle</h2>
32
 *
33
 * <p>
34
 * Instances are intended to be created once per CLI run and injected into the
35
 * {@link Command} implementations that need them. The loader itself is
36
 * stateless — no instance fields — so a single loader can be shared between
37
 * commands that participate in the same orchestration. The lifecycle of the
38
 * loaded providers is owned by the caller: a typical usage closes them in a
39
 * {@code finally} block via {@link #closeAll(List)}.
40
 * </p>
41
 *
42
 * <h2>Thread safety</h2>
43
 *
44
 * <p>
45
 * This class is thread-safe. {@link ServiceLoader#load(Class)} resolution is
46
 * idempotent per classloader, and no shared mutable state is maintained.
47
 * </p>
48
 *
49
 * @see TestDiscovery
50
 * @see SourcePatcher
51
 * @see Command
52
 * @since 1.0.0
53
 */
54
public final class PluginLoader {
55
56
    private static final Logger LOG = Logger.getLogger(PluginLoader.class.getName());
57
58
    /**
59
     * Creates a new plugin loader. The loader carries no instance state and is
60
     * safe to share across commands within a single CLI run.
61
     */
62
    public PluginLoader() {
63
        // Intentionally empty; PluginLoader is stateless.
64
    }
65
66
    /**
67
     * Loads all {@link TestDiscovery} providers registered via
68
     * {@link ServiceLoader}, configures each one with {@code config}, and
69
     * returns them in registration order.
70
     *
71
     * <p>
72
     * The returned providers are open resources. Callers must close them
73
     * through {@link #closeAll(List)} in a {@code finally} block to release
74
     * any per-provider resources (file handles, native processes, etc.).
75
     * </p>
76
     *
77
     * <p>
78
     * Time complexity is {@code O(p)} in the number of providers; the
79
     * ServiceLoader lookup itself is dominated by classpath scanning.
80
     * </p>
81
     *
82
     * @param config run-time configuration forwarded to every provider via
83
     *               {@link TestDiscovery#configure}; must not be {@code null}
84
     * @return non-empty list of configured providers in registration order
85
     * @throws IllegalStateException if no providers are found on the classpath,
86
     *                               or if two providers share the same
87
     *                               {@link TestDiscovery#pluginId()}
88
     */
89
    @SuppressWarnings("PMD.CloseResource") // callers own the lifecycle and must close via closeAll()
90
    public List<TestDiscovery> loadProviders(TestDiscoveryConfig config) {
91
        List<TestDiscovery> providers = new ArrayList<>();
92
        for (TestDiscovery provider : ServiceLoader.load(TestDiscovery.class)) {
93 1 1. loadProviders : removed call to org/egothor/methodatlas/api/TestDiscovery::configure → KILLED
            provider.configure(config);
94
            providers.add(provider);
95
        }
96 2 1. loadProviders : removed conditional - replaced equality check with false → SURVIVED
2. loadProviders : removed conditional - replaced equality check with true → KILLED
        if (providers.isEmpty()) {
97
            throw new IllegalStateException(
98
                    "No TestDiscovery providers found on the classpath. "
99
                    + "Ensure at least one provider JAR ships the service registration file "
100
                    + "META-INF/services/org.egothor.methodatlas.api.TestDiscovery.");
101
        }
102 1 1. loadProviders : removed call to org/egothor/methodatlas/command/PluginLoader::requireUniqueDiscoveryIds → SURVIVED
        requireUniqueDiscoveryIds(providers);
103 1 1. loadProviders : replaced return value with Collections.emptyList for org/egothor/methodatlas/command/PluginLoader::loadProviders → KILLED
        return providers;
104
    }
105
106
    /**
107
     * Loads all {@link SourcePatcher} providers registered via
108
     * {@link ServiceLoader}, configures each one with {@code config}, and
109
     * returns them in registration order.
110
     *
111
     * <p>
112
     * Unlike {@link #loadProviders}, returning an empty list is legitimate:
113
     * languages that do not support source write-back (such as TypeScript or
114
     * Python) ship no patcher.
115
     * </p>
116
     *
117
     * @param config run-time configuration forwarded to every patcher via
118
     *               {@link SourcePatcher#configure}; must not be {@code null}
119
     * @return possibly-empty list of configured patchers in registration order
120
     * @throws IllegalStateException if two patchers share the same
121
     *                               {@link SourcePatcher#pluginId()}
122
     */
123
    public List<SourcePatcher> loadPatchers(TestDiscoveryConfig config) {
124
        List<SourcePatcher> patchers = new ArrayList<>();
125
        for (SourcePatcher patcher : ServiceLoader.load(SourcePatcher.class)) {
126 1 1. loadPatchers : removed call to org/egothor/methodatlas/api/SourcePatcher::configure → SURVIVED
            patcher.configure(config);
127
            patchers.add(patcher);
128
        }
129 1 1. loadPatchers : removed call to org/egothor/methodatlas/command/PluginLoader::requireUniquePatcherIds → SURVIVED
        requireUniquePatcherIds(patchers);
130 1 1. loadPatchers : replaced return value with Collections.emptyList for org/egothor/methodatlas/command/PluginLoader::loadPatchers → KILLED
        return patchers;
131
    }
132
133
    /**
134
     * Closes every provider in the list, logging any {@link IOException} at
135
     * {@link Level#FINE} and continuing so that all providers are attempted.
136
     *
137
     * <p>
138
     * This method never throws: a provider whose {@code close} fails leaves
139
     * its resources in an indeterminate state, but the orchestration layer
140
     * always exits cleanly. Failures are observable through the FINE-level
141
     * log.
142
     * </p>
143
     *
144
     * @param providers list of providers to close; must not be {@code null}
145
     */
146
    @SuppressWarnings("PMD.CloseResource") // this method IS the close mechanism; p.close() is called explicitly
147
    public void closeAll(List<TestDiscovery> providers) {
148
        for (TestDiscovery p : providers) {
149
            try {
150 1 1. closeAll : removed call to org/egothor/methodatlas/api/TestDiscovery::close → KILLED
                p.close();
151
            } catch (IOException e) {
152
                if (LOG.isLoggable(Level.FINE)) {
153
                    LOG.log(Level.FINE, "Failed to close provider " + p.pluginId(), e);
154
                }
155
            }
156
        }
157
    }
158
159
    /**
160
     * Verifies that every {@link TestDiscovery} provider in the list has a
161
     * unique {@link TestDiscovery#pluginId()}.
162
     *
163
     * <p>
164
     * This method is {@code static} because it is a pure validation with no
165
     * instance dependencies — test code calls it directly with handcrafted
166
     * provider lists, and the instance loader calls it after a
167
     * {@code ServiceLoader} sweep. Time complexity is {@code O(p)} in the
168
     * number of providers.
169
     * </p>
170
     *
171
     * @param providers list of providers to validate; must not be {@code null}
172
     * @throws IllegalStateException if two or more providers share the same id
173
     */
174
    @SuppressWarnings("PMD.CloseResource") // providers are owned by the caller; this method does not close them
175
    public static void requireUniqueDiscoveryIds(List<TestDiscovery> providers) {
176
        Set<String> seen = new LinkedHashSet<>();
177
        for (TestDiscovery p : providers) {
178
            String id = p.pluginId();
179 2 1. requireUniqueDiscoveryIds : removed conditional - replaced equality check with true → KILLED
2. requireUniqueDiscoveryIds : removed conditional - replaced equality check with false → KILLED
            if (!seen.add(id)) {
180
                throw new IllegalStateException(
181
                        "Duplicate TestDiscovery plugin ID \"" + id + "\": two or more "
182
                        + "registered providers claim the same pluginId(). "
183
                        + "Each provider must declare a unique identifier.");
184
            }
185
        }
186
    }
187
188
    /**
189
     * Verifies that every {@link SourcePatcher} in the list has a unique
190
     * {@link SourcePatcher#pluginId()}.
191
     *
192
     * @param patchers list of patchers to validate; must not be {@code null}
193
     * @throws IllegalStateException if two or more patchers share the same id
194
     */
195
    public static void requireUniquePatcherIds(List<SourcePatcher> patchers) {
196
        Set<String> seen = new LinkedHashSet<>();
197
        for (SourcePatcher p : patchers) {
198
            String id = p.pluginId();
199 2 1. requireUniquePatcherIds : removed conditional - replaced equality check with true → KILLED
2. requireUniquePatcherIds : removed conditional - replaced equality check with false → KILLED
            if (!seen.add(id)) {
200
                throw new IllegalStateException(
201
                        "Duplicate SourcePatcher plugin ID \"" + id + "\": two or more "
202
                        + "registered patchers claim the same pluginId(). "
203
                        + "Each patcher must declare a unique identifier.");
204
            }
205
        }
206
    }
207
}

Mutations

93

1.1
Location : loadProviders
Killed by : org.egothor.methodatlas.GitHubAnnotationsEmitterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.GitHubAnnotationsEmitterTest]/[method:app_githubAnnotationsMode_emptyDirectoryProducesNoOutput(java.nio.file.Path)]
removed call to org/egothor/methodatlas/api/TestDiscovery::configure → KILLED

96

1.1
Location : loadProviders
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:loadProviders_realClasspath_returnsAtLeastOneProvider()]
removed conditional - replaced equality check with true → KILLED

2.2
Location : loadProviders
Killed by : none
removed conditional - replaced equality check with false → SURVIVED
Covering tests

102

1.1
Location : loadProviders
Killed by : none
removed call to org/egothor/methodatlas/command/PluginLoader::requireUniqueDiscoveryIds → SURVIVED
Covering tests

103

1.1
Location : loadProviders
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:loadProviders_realClasspath_returnsAtLeastOneProvider()]
replaced return value with Collections.emptyList for org/egothor/methodatlas/command/PluginLoader::loadProviders → KILLED

126

1.1
Location : loadPatchers
Killed by : none
removed call to org/egothor/methodatlas/api/SourcePatcher::configure → SURVIVED
Covering tests

129

1.1
Location : loadPatchers
Killed by : none
removed call to org/egothor/methodatlas/command/PluginLoader::requireUniquePatcherIds → SURVIVED
Covering tests

130

1.1
Location : loadPatchers
Killed by : org.egothor.methodatlas.MethodAtlasAppApplyTagsFromCsvTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.MethodAtlasAppApplyTagsFromCsvTest]/[method:applyTagsFromCsv_addsDisplayNameImportWhenDisplayNameSet(java.nio.file.Path)]
replaced return value with Collections.emptyList for org/egothor/methodatlas/command/PluginLoader::loadPatchers → KILLED

150

1.1
Location : closeAll
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:closeAll_failureInOneProvider_doesNotPreventOthers()]
removed call to org/egothor/methodatlas/api/TestDiscovery::close → KILLED

179

1.1
Location : requireUniqueDiscoveryIds
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:requireUniqueDiscoveryIds_distinctIds_doesNotThrow()]
removed conditional - replaced equality check with true → KILLED

2.2
Location : requireUniqueDiscoveryIds
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:requireUniqueDiscoveryIds_duplicateId_throwsWithIdInMessage()]
removed conditional - replaced equality check with false → KILLED

199

1.1
Location : requireUniquePatcherIds
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:requireUniquePatcherIds_distinctIds_doesNotThrow()]
removed conditional - replaced equality check with true → KILLED

2.2
Location : requireUniquePatcherIds
Killed by : org.egothor.methodatlas.command.PluginLoaderTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.command.PluginLoaderTest]/[method:requireUniquePatcherIds_duplicateId_throwsWithIdInMessage()]
removed conditional - replaced equality check with false → KILLED

Active mutators

Tests examined


Report generated by PIT 1.22.1