> providers) {
+ this.providers = providers;
+ }
+
+ /**
+ * Creates a context over the services available to the runtime.
+ *
+ * @return the new context
+ */
+ public static Context create() {
+ // NB: the lookup is on Service itself, which this module declares `uses`
+ // for. See Service's documentation for why it cannot be otherwise.
+ return new Context(ServiceLoader.load(Service.class).stream() //
+ .collect(Collectors.toList()));
+ }
+
+ /**
+ * Creates a context containing exactly the given services, bypassing
+ * discovery. Useful for tests and for embedding.
+ *
+ * @param services the services this context will contain
+ * @return the new context
+ */
+ public static Context of(final Service... services) {
+ final Context context = new Context(Collections.emptyList());
+ // NB: register everything before initializing anything. A service may
+ // depend on one declared after it, and initialization is where
+ // dependencies are acquired.
+ context.explicit.addAll(List.of(services));
+ return context;
+ }
+
+ /**
+ * Gets the service of the given type, creating it if necessary.
+ *
+ * @param the service type
+ * @param type the interface (or class) the service provides
+ * @return the service
+ * @throws NoSuchServiceException if no service provides that type
+ */
+ public S service(final Class type) {
+ return optionalService(type).orElseThrow( //
+ () -> new NoSuchServiceException(type));
+ }
+
+ /**
+ * Gets the service of the given type, creating it if necessary.
+ *
+ * @param the service type
+ * @param type the interface (or class) the service provides
+ * @return the service, or empty if none provides that type
+ */
+ public Optional optionalService(final Class type) {
+ if (type == null) throw new NullPointerException("type");
+ synchronized (this) {
+ checkNotDisposed();
+ final Service existing = byRequestedType.get(type);
+ if (existing != null) {
+ // NB: this may be a service still in the middle of initializing,
+ // when two services depend on each other. See initialize below.
+ return Optional.of(type.cast(existing));
+ }
+ final Optional service = findService(type);
+ service.ifPresent(s -> {
+ // NB: publish before initializing, so that two services depending
+ // on each other resolve instead of recursing.
+ byRequestedType.put(type, s);
+ inject(s);
+ // NB: a service's lifetime is the context's, so its handlers need
+ // no cleanup by the author: disposing the context closes the bus.
+ subscribe(s);
+ initialize(s);
+ // NB: record for disposal only once initialization has finished.
+ // A service's dependencies finish first, so they land earlier in
+ // this list, and disposing in reverse therefore tears down each
+ // service before the ones it depends on. Recording on the way in
+ // would order them exactly backwards.
+ if (!created.contains(s)) created.add(s);
+ });
+ return service;
+ }
+ }
+
+ /**
+ * Lists the plugins of the given type.
+ *
+ * The result describes what is available without loading any plugin class:
+ * each {@link Discovery} reports its implementation class name, its
+ * {@link Plugin} metadata and its priority, and loads or constructs the
+ * class only when asked to. Building a menu therefore costs no class
+ * loading at all.
+ *
+ *
+ * Plugins come back highest priority first.
+ *
+ *
+ * @param the extension type
+ * @param type the type of extension wanted
+ * @return the plugins of that type, highest priority first
+ */
+ public
List> plugins(final Class type) {
+ if (type == null) throw new NullPointerException("type");
+ synchronized (this) {
+ checkNotDisposed();
+ if (pluginDiscoverer == null) pluginDiscoverer = createPluginDiscoverer();
+ }
+ final List> plugins = new ArrayList<>(pluginDiscoverer
+ .discover(type));
+ plugins.sort(Comparator.comparingDouble(Discovery::priority).reversed());
+ return plugins;
+ }
+
+ /**
+ * Fills in the {@link Dependency}-annotated fields of the given object.
+ *
+ * Services and plugins the context creates are injected automatically; this
+ * is for objects it did not create.
+ *
+ *
+ * @param target the object whose dependencies to fill in
+ * @throws ServiceException if a required dependency is missing, or the
+ * target's package is not open to this module
+ */
+ public void inject(final Object target) {
+ if (target == null) throw new NullPointerException("target");
+ for (Class> c = target.getClass(); c != null; c = c.getSuperclass()) {
+ for (final Field field : c.getDeclaredFields()) {
+ final Dependency dependency = field.getAnnotation(Dependency.class);
+ if (dependency == null) continue;
+ injectField(target, field, dependency);
+ }
+ }
+ }
+
+ /**
+ * Subscribes the {@link EventHandler}-annotated methods of the given object
+ * to this context's {@link EventBus}.
+ *
+ * Services are subscribed automatically when the context creates them, and
+ * unsubscribed when it is disposed. This is for everything else: a plugin
+ * may be created and discarded many times in a session, so its handlers are
+ * the caller's to manage.
+ *
+ *
+ * @param target the object whose handlers to subscribe
+ * @return a subscription per handler; close them when the object is done
+ * @throws ServiceException if a handler is malformed, or the target's
+ * package is not open to this module
+ */
+ public List subscribe(final Object target) {
+ if (target == null) throw new NullPointerException("target");
+ final List subscriptions = new ArrayList<>();
+ for (Class> c = target.getClass(); c != null; c = c.getSuperclass()) {
+ for (final Method method : c.getDeclaredMethods()) {
+ final EventHandler handler = method.getAnnotation(EventHandler.class);
+ if (handler == null) continue;
+ subscriptions.add(subscribeHandler(target, method, handler));
+ }
+ }
+ return subscriptions;
+ }
+
+ /** Gets this context's event bus. */
+ public EventBus events() {
+ return events;
+ }
+
+ /**
+ * Gets the services initialized so far, in the order they finished
+ * initializing. A service that failed to initialize is not among them, and
+ * is not disposed.
+ */
+ public synchronized List services() {
+ return List.copyOf(created);
+ }
+
+ /** Gets whether this context has been disposed. */
+ public synchronized boolean isDisposed() {
+ return disposed;
+ }
+
+ /**
+ * Disposes every service, in reverse order of creation, and closes the
+ * event bus.
+ *
+ * Disposal is reversed so that a service can still reach its dependencies
+ * while shutting down. A service that throws on the way out does not
+ * prevent the others from being disposed.
+ *
+ */
+ @Override
+ public void dispose() {
+ if (disposed) return;
+ final List toDispose;
+ synchronized (this) {
+ if (disposed) return;
+ disposed = true;
+ // NB: Snapshot and clear under the lock, but dispose the services
+ // themselves outside of it. A service's dispose() may block on
+ // another thread (e.g. a Swing UI's disposal round-tripping through
+ // the EDT); holding this context's lock for that entire time would
+ // risk deadlock against any other thread synchronizing on this
+ // context, such as one blocked inside optionalService(...).
+ toDispose = new ArrayList<>(created);
+ created.clear();
+ byRequestedType.clear();
+ }
+ final List failures = new ArrayList<>();
+ for (int i = toDispose.size() - 1; i >= 0; i--) {
+ try {
+ toDispose.get(i).dispose();
+ }
+ catch (final Throwable exc) {
+ failures.add(exc);
+ }
+ }
+ // NB: closing the bus is what frees services from arranging their own
+ // unsubscription.
+ events.close();
+ if (!failures.isEmpty()) {
+ final ServiceException exc = new ServiceException(failures.size() + //
+ " service(s) failed to dispose", failures.get(0));
+ for (int i = 1; i < failures.size(); i++)
+ exc.addSuppressed(failures.get(i));
+ throw exc;
+ }
+ }
+
+ @Override
+ public void close() {
+ dispose();
+ }
+
+ // -- Helper methods --
+
+ /** Fills in one annotated field. */
+ private void injectField(final Object target, final Field field,
+ final Dependency dependency)
+ {
+ final Object value;
+ try {
+ value = resolve(field.getType());
+ }
+ catch (final NoSuchServiceException exc) {
+ if (!dependency.required()) return;
+ throw new ServiceException("Cannot satisfy " + target.getClass()
+ .getName() + "." + field.getName() + ": no service of type " + field
+ .getType().getName(), exc);
+ }
+ try {
+ // NB: deep reflection, so the target's package must be open to this
+ // module. `opens` is not `exports`: the package stays invisible to
+ // ordinary callers.
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+ catch (final RuntimeException | IllegalAccessException exc) {
+ throw new ServiceException("Cannot inject " + target.getClass()
+ .getName() + "." + field.getName() + //
+ ". Does its module declare `opens " + target.getClass()
+ .getPackageName() + " to org.scijava.context;`?", exc);
+ }
+ }
+
+ /** Subscribes one annotated method. */
+ private Subscription subscribeHandler(final Object target,
+ final Method method, final EventHandler handler)
+ {
+ final Class>[] params = method.getParameterTypes();
+ if (params.length != 1) {
+ throw new ServiceException("An @EventHandler takes exactly one" + //
+ " parameter, the event: " + method.getDeclaringClass().getName() + //
+ "." + method.getName() + " takes " + params.length);
+ }
+ try {
+ // NB: the same deep reflection, and the same `opens`, that
+ // @Dependency needs.
+ method.setAccessible(true);
+ }
+ catch (final RuntimeException exc) {
+ throw new ServiceException("Cannot subscribe " + method
+ .getDeclaringClass().getName() + "." + method.getName() + //
+ ". Does its module declare `opens " + method.getDeclaringClass()
+ .getPackageName() + " to org.scijava.context;`?", exc);
+ }
+ return subscribeTyped(params[0], handler.priority(), target, method);
+ }
+
+ /** Captures the event type, so that the bus sees a typed subscription. */
+ private Subscription subscribeTyped(final Class eventType,
+ final double priority, final Object target, final Method method)
+ {
+ return events.subscribe(eventType, priority, event -> {
+ try {
+ method.invoke(target, event);
+ }
+ catch (final IllegalAccessException exc) {
+ throw new ServiceException("Cannot invoke handler " + method
+ .getDeclaringClass().getName() + "." + method.getName(), exc);
+ }
+ catch (final InvocationTargetException exc) {
+ // NB: unwrap, so subscribers see what the handler actually threw
+ // rather than a reflection wrapper.
+ final Throwable cause = exc.getCause();
+ if (cause instanceof RuntimeException) throw (RuntimeException) cause;
+ if (cause instanceof Error) throw (Error) cause;
+ throw new ServiceException("Handler failed: " + method
+ .getDeclaringClass().getName() + "." + method.getName(), cause);
+ }
+ });
+ }
+
+ /** Resolves what to inject for a field of the given type. */
+ private Object resolve(final Class> type) {
+ if (type == Context.class) return this;
+ if (type == EventBus.class) return events;
+ if (Service.class.isAssignableFrom(type)) {
+ return service(type.asSubclass(Service.class));
+ }
+ throw new NoSuchServiceException(type);
+ }
+
+ /**
+ * Builds the index-backed plugin discoverer.
+ *
+ * NB: which type a plugin provides has to be answerable from the index
+ * alone, hence the function: reading {@code type()} from the recorded
+ * annotation loads the declared type, never the implementation.
+ *
+ */
+ private IndexDiscoverer createPluginDiscoverer() {
+ return new IndexDiscoverer<>(Plugin.class, //
+ item -> item.annotation().type().getName(), //
+ item -> attrsOf(item.annotation()), //
+ item -> item.annotation().priority(), //
+ Thread.currentThread().getContextClassLoader(), //
+ this::instantiate);
+ }
+
+ /**
+ * Constructs a plugin.
+ *
+ * NB: this must live in this module. Reflective construction is
+ * checked against the module that performs it, so a plugin package opened
+ * to this container cannot be constructed by the discovery or index module
+ * on our behalf - which is why {@link Plugin} tells users to open their
+ * packages to {@code org.scijava.context}.
+ *
+ */
+ private Object instantiate(final Class> type) {
+ final Object plugin;
+ try {
+ plugin = type.getDeclaredConstructor().newInstance();
+ }
+ catch (final ReflectiveOperationException exc) {
+ throw new ServiceException("Cannot construct plugin: " + //
+ type.getName() + ". Does its module declare `opens " + type
+ .getPackageName() + " to org.scijava.context;`?", exc);
+ }
+ inject(plugin);
+ return plugin;
+ }
+
+ /** Flattens a plugin's metadata into the discovery's attribute map. */
+ private static Map attrsOf(final Plugin plugin) {
+ final Map attrs = new LinkedHashMap<>();
+ if (!plugin.name().isEmpty()) attrs.put("name", plugin.name());
+ if (!plugin.label().isEmpty()) attrs.put("label", plugin.label());
+ if (!plugin.description().isEmpty()) {
+ attrs.put("description", plugin.description());
+ }
+ for (final Attr attr : plugin.attrs())
+ attrs.put(attr.name(), attr.value());
+ return attrs;
+ }
+
+ /**
+ * Initializes a service, unless that is already done or under way.
+ *
+ * NB: the guard is set before initializing, so that two services
+ * depending on each other resolve rather than recursing forever. The
+ * consequence is that a service caught in such a cycle may briefly observe
+ * a peer that has not finished initializing. Where that matters, look the
+ * peer up at point of use rather than holding it from
+ * {@link Service#initialize}.
+ *
+ *
+ * Cycles are resolvable here only because dependencies are acquired during
+ * initialization rather than in the constructor. Constructor injection
+ * could not resolve them at all.
+ *
+ */
+ private void initialize(final Service service) {
+ if (!initialized.add(service)) return;
+ try {
+ service.initialize(this);
+ }
+ catch (final Throwable exc) {
+ throw new ServiceException("Cannot initialize service: " + //
+ service.getClass().getName(), exc);
+ }
+ }
+
+ /**
+ * Finds the highest-priority service providing the given type, among those
+ * given explicitly and those discovered.
+ *
+ * NB: discovered candidates are constructed in order to compare their
+ * priorities, and all but the winner are discarded uninitialized.
+ * {@link Service} requires constructors to be free of side effects for
+ * exactly this reason.
+ *
+ */
+ private Optional findService(final Class type) {
+ Service best = null;
+ for (final Service service : explicit) {
+ if (!type.isInstance(service)) continue;
+ if (best == null || service.priority() > best.priority()) best = service;
+ }
+ for (final Provider provider : providers) {
+ // NB: type() loads the class but does not construct it, so
+ // implementations of other types cost nothing here.
+ if (!type.isAssignableFrom(provider.type())) continue;
+ final Service candidate;
+ try {
+ candidate = provider.get();
+ }
+ catch (final Throwable exc) {
+ throw new ServiceException("Cannot construct service: " + //
+ provider.type().getName(), exc);
+ }
+ if (best == null || candidate.priority() > best.priority()) best =
+ candidate;
+ }
+ return Optional.ofNullable(type.cast(best));
+ }
+
+ private void checkNotDisposed() {
+ if (disposed) {
+ throw new IllegalStateException("This context has been disposed");
+ }
+ }
+}
diff --git a/context/src/main/java/org/scijava/context/Dependency.java b/context/src/main/java/org/scijava/context/Dependency.java
new file mode 100644
index 000000000..5d0675aaa
--- /dev/null
+++ b/context/src/main/java/org/scijava/context/Dependency.java
@@ -0,0 +1,86 @@
+/*
+ * #%L
+ * An application container: services, discovered and wired.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.context;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a field to be filled in by the {@link Context}.
+ *
+ *
+ * public class MyService implements Service {
+ *
+ * @Dependency
+ * private LogService log;
+ * }
+ *
+ *
+ * The context injects services it owns, plus the {@link Context} itself and
+ * its {@link org.scijava.events.EventBus}. Injection happens after
+ * construction and before {@link Service#initialize(Context)}, so a dependency
+ * is ready to use by the time initialization runs.
+ *
+ *
+ * NB: this is deliberately not called {@code @Parameter}, as it was in SciJava
+ * Common. There it meant two unrelated things - a dependency to inject, and an
+ * input to a module - and the second is now the business of
+ * {@code org.scijava.struct}.
+ *
+ * JPMS
+ *
+ * Injecting a private field is deep reflection, so a modular project must open
+ * the package to the container:
+ *
+ *
+ *
+ * opens com.example.services to org.scijava.context;
+ *
+ *
+ * A package that is opened but not exported stays invisible to ordinary
+ * callers: they cannot import these classes or cast to them. Only the
+ * container gets in.
+ *
+ *
+ * @author Curtis Rueden
+ * @see Context#inject(Object)
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface Dependency {
+
+ /**
+ * Whether the context must have such a dependency. When false, a missing
+ * one leaves the field null rather than failing.
+ */
+ boolean required() default true;
+}
diff --git a/context/src/main/java/org/scijava/context/EventHandler.java b/context/src/main/java/org/scijava/context/EventHandler.java
new file mode 100644
index 000000000..83fea315d
--- /dev/null
+++ b/context/src/main/java/org/scijava/context/EventHandler.java
@@ -0,0 +1,86 @@
+/*
+ * #%L
+ * An application container: services, discovered and wired.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.context;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.scijava.priority.Priority;
+
+/**
+ * Marks a method as an event handler, to be subscribed to the context's
+ * {@link org.scijava.events.EventBus}.
+ *
+ *
+ * public class MyService implements Service {
+ *
+ * @EventHandler
+ * private void onDataOpened(final DataOpenedEvent event) { ... }
+ * }
+ *
+ *
+ * The method takes exactly one parameter: the type of event it wants, subtypes
+ * included.
+ *
+ * Lifetime
+ *
+ * A {@link Service}'s handlers are subscribed automatically when the context
+ * creates it, and unsubscribed when the context is disposed - a service's
+ * lifetime being the context's, there is nothing for the author to clean up.
+ *
+ *
+ * Other objects are not subscribed automatically, because a plugin may be
+ * created and discarded many times over a session, and handlers that outlived
+ * their objects would pile up. Call {@link Context#subscribe(Object)} for
+ * those, and close the returned subscriptions when the object is finished
+ * with.
+ *
+ * JPMS
+ *
+ * Invoking a non-public handler is deep reflection, so a modular project must
+ * open the package to the container - the same {@code opens} that
+ * {@link Dependency} needs, not an additional one.
+ *
+ *
+ * @author Curtis Rueden
+ * @see Context#subscribe(Object)
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface EventHandler {
+
+ /**
+ * Sorts this handler against others for the same event, highest first. See
+ * {@link Priority}.
+ */
+ double priority() default Priority.NORMAL;
+}
diff --git a/context/src/main/java/org/scijava/context/NoSuchServiceException.java b/context/src/main/java/org/scijava/context/NoSuchServiceException.java
new file mode 100644
index 000000000..a010e8c77
--- /dev/null
+++ b/context/src/main/java/org/scijava/context/NoSuchServiceException.java
@@ -0,0 +1,50 @@
+/*
+ * #%L
+ * An application container: services, discovered and wired.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.context;
+
+/** Thrown when a context has no service of the requested type. */
+public class NoSuchServiceException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final transient Class> serviceType;
+
+ public NoSuchServiceException(final Class> serviceType) {
+ super("No service of type " + serviceType.getName() + //
+ ". Is its module on the module path, and does it declare" + //
+ " `provides org.scijava.context.Service with ...`?");
+ this.serviceType = serviceType;
+ }
+
+ /** Gets the service type that was requested. */
+ public Class> serviceType() {
+ return serviceType;
+ }
+}
diff --git a/context/src/main/java/org/scijava/context/Plugin.java b/context/src/main/java/org/scijava/context/Plugin.java
new file mode 100644
index 000000000..9c7a4a637
--- /dev/null
+++ b/context/src/main/java/org/scijava/context/Plugin.java
@@ -0,0 +1,108 @@
+/*
+ * #%L
+ * An application container: services, discovered and wired.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.context;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.scijava.index.Indexable;
+import org.scijava.priority.Priority;
+
+/**
+ * Declares a class as an extension of some type, discoverable by that type.
+ *
+ * Annotate a class and it is discoverable - no configuration file to write, no
+ * {@code module-info} clause to remember:
+ *
+ *
+ *
+ * @Plugin(type = Converter.class, priority = Priority.HIGH)
+ * public class MyConverter implements Converter { ... }
+ *
+ *
+ * An annotation processor records these at build time, so the container can
+ * list what is available, and read each one's metadata, without loading a
+ * single plugin class . That is what keeps a menu of a thousand commands
+ * cheap to build.
+ *
+ * When to use this, and when to use a service
+ *
+ * This is for the long tail: commands, converters, widgets, formats, tools -
+ * many implementations, contributed widely, of which a given session uses few.
+ * {@link Service}s are different: a small, framework-level set, each the one
+ * implementation of its interface, declared with {@code provides} in a
+ * {@code module-info}. A service is not a plugin.
+ *
+ * JPMS
+ *
+ * The container constructs plugins reflectively, so a modular project must
+ * open the package containing them - to the container only:
+ *
+ *
+ *
+ * opens com.example.plugins to org.scijava.context;
+ *
+ *
+ * NB: {@code opens} is not {@code exports}. The package stays invisible to
+ * ordinary callers at compile time - they cannot import the class, cast to it,
+ * or call anything beyond the type it provides - while the container gets the
+ * reflective access it needs.
+ *
+ *
+ * @author Curtis Rueden
+ * @see Context#plugins(Class)
+ */
+@Indexable
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface Plugin {
+
+ /** The type of extension this plugin provides. */
+ Class> type();
+
+ /** An identifier for this plugin, unique among those of its type. */
+ String name() default "";
+
+ /** A human-readable label, for display. */
+ String label() default "";
+
+ /** A longer human-readable description. */
+ String description() default "";
+
+ /**
+ * Sorts plugins of the same type, highest first. See {@link Priority}.
+ */
+ double priority() default Priority.NORMAL;
+
+ /** Arbitrary additional metadata. */
+ Attr[] attrs() default {};
+}
diff --git a/context/src/main/java/org/scijava/context/Service.java b/context/src/main/java/org/scijava/context/Service.java
new file mode 100644
index 000000000..4ff05ac1f
--- /dev/null
+++ b/context/src/main/java/org/scijava/context/Service.java
@@ -0,0 +1,107 @@
+/*
+ * #%L
+ * An application container: services, discovered and wired.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.context;
+
+import org.scijava.priority.Priority;
+import org.scijava.spi.Disposable;
+
+/**
+ * A long-lived object owned by a {@link Context}, discoverable by the type it
+ * provides.
+ *
+ * Services are found with {@link java.util.ServiceLoader}, so a module
+ * contributing one declares it in its {@code module-info.java}:
+ *
+ *
+ *
+ * provides org.scijava.context.Service with com.example.MyLogService;
+ *
+ *
+ * Note that the declared service type is always {@code Service} itself, never
+ * the interface the implementation provides. {@link java.util.ServiceLoader}
+ * resolves {@code uses} against the module of its caller , so a
+ * container cannot look up an arbitrary third-party interface on that module's
+ * behalf: it would have to declare {@code uses com.example.MyLogService},
+ * which it cannot know. Declaring one base type that the container does
+ * {@code uses} is what makes discovery work under JPMS at all; the container
+ * then sorts implementations by the interfaces they implement.
+ *
+ *
+ * Because {@code provides} grants {@link java.util.ServiceLoader} the access
+ * it needs, the implementation's package need be neither exported nor opened:
+ * a service can stay entirely encapsulated.
+ *
+ * Lifecycle
+ *
+ * The container constructs a service with its no-argument constructor, then
+ * calls {@link #initialize(Context)}. A constructor must do nothing
+ * else. Where several implementations provide the same interface the
+ * container constructs each to compare {@link #priority()}, and discards all
+ * but the winner without ever initializing them - so construction must be free
+ * of side effects. Everything real belongs in {@code initialize}, where the
+ * context is available to look up dependencies.
+ *
+ *
+ * {@link #dispose()} is called when the context is disposed, in reverse order
+ * of creation, so that a service is still able to use its dependencies while
+ * shutting down.
+ *
+ *
+ * @author Curtis Rueden
+ */
+public interface Service extends Disposable {
+
+ /**
+ * Prepares this service for use. Dependencies are available here, via
+ * {@link Context#service(Class)}.
+ *
+ * @param context the context that owns this service
+ */
+ default void initialize(final Context context) {
+ // NB: nothing to do by default.
+ }
+
+ /**
+ * Gets this service's priority, used to choose between implementations of
+ * the same interface. See {@link Priority}.
+ */
+ default double priority() {
+ return Priority.NORMAL;
+ }
+
+ /**
+ * Releases anything this service holds. Called when the context is
+ * disposed.
+ */
+ @Override
+ default void dispose() {
+ // NB: nothing to do by default.
+ }
+}
diff --git a/context/src/main/java/org/scijava/context/ServiceException.java b/context/src/main/java/org/scijava/context/ServiceException.java
new file mode 100644
index 000000000..7f02fb53e
--- /dev/null
+++ b/context/src/main/java/org/scijava/context/ServiceException.java
@@ -0,0 +1,44 @@
+/*
+ * #%L
+ * An application container: services, discovered and wired.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.context;
+
+/** Thrown when a service cannot be constructed or initialized. */
+public class ServiceException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ public ServiceException(final String message) {
+ super(message);
+ }
+
+ public ServiceException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/context/src/test/java/org/scijava/context/ContextTest.java b/context/src/test/java/org/scijava/context/ContextTest.java
new file mode 100644
index 000000000..4f4e821f3
--- /dev/null
+++ b/context/src/test/java/org/scijava/context/ContextTest.java
@@ -0,0 +1,507 @@
+/*
+ * #%L
+ * An application container: services, discovered and wired.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.context;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.scijava.events.EventDeliveryException;
+import org.scijava.events.Subscription;
+import org.scijava.priority.Priority;
+
+/**
+ * Tests {@link Context}.
+ *
+ * @author Curtis Rueden
+ */
+public class ContextTest {
+
+ // -- Test services --
+
+ public interface Greeter extends Service {
+
+ String greet();
+ }
+
+ public interface Recorder extends Service {
+
+ List log();
+ }
+
+ public static class DefaultRecorder implements Recorder {
+
+ private final List log = new ArrayList<>();
+
+ @Override
+ public List log() {
+ return log;
+ }
+
+ @Override
+ public void dispose() {
+ log.add("disposed recorder");
+ }
+ }
+
+ /** Depends on {@link Recorder}, acquired during initialization. */
+ public static class DefaultGreeter implements Greeter {
+
+ private Recorder recorder;
+
+ @Override
+ public void initialize(final Context context) {
+ recorder = context.service(Recorder.class);
+ recorder.log().add("greeter initialized");
+ }
+
+ @Override
+ public String greet() {
+ return "hello";
+ }
+
+ @Override
+ public void dispose() {
+ // NB: dependencies must still work while shutting down.
+ recorder.log().add("disposed greeter");
+ }
+ }
+
+ @Test
+ public void testServiceLookupAndDependency() {
+ try (final Context context = Context.of(new DefaultGreeter(),
+ new DefaultRecorder()))
+ {
+ final Greeter greeter = context.service(Greeter.class);
+ assertEquals("hello", greeter.greet());
+ assertTrue(context.service(Recorder.class).log().contains(
+ "greeter initialized"));
+ }
+ }
+
+ /** The same instance comes back every time. */
+ @Test
+ public void testServicesAreSingletonsPerContext() {
+ try (final Context context = Context.of(new DefaultRecorder())) {
+ assertSame(context.service(Recorder.class), context.service(
+ Recorder.class));
+ }
+ }
+
+ @Test
+ public void testMissingService() {
+ try (final Context context = Context.of()) {
+ final NoSuchServiceException exc = assertThrows(
+ NoSuchServiceException.class, () -> context.service(Greeter.class));
+ assertEquals(Greeter.class, exc.serviceType());
+ assertTrue(context.optionalService(Greeter.class).isEmpty());
+ }
+ }
+
+ /** Disposal runs in reverse creation order, so dependencies outlive users. */
+ @Test
+ public void testDisposalOrder() {
+ final DefaultRecorder recorder = new DefaultRecorder();
+ final Context context = Context.of(recorder, new DefaultGreeter());
+ context.service(Greeter.class);
+ context.dispose();
+
+ final int greeterAt = recorder.log().indexOf("disposed greeter");
+ final int recorderAt = recorder.log().indexOf("disposed recorder");
+ assertTrue(greeterAt >= 0, "greeter was never disposed");
+ assertTrue(recorderAt >= 0, "recorder was never disposed");
+ assertTrue(greeterAt < recorderAt,
+ "a service was disposed before the one that depends on it");
+ }
+
+ @Test
+ public void testDisposeIsIdempotentAndBlocksLookups() {
+ final Context context = Context.of(new DefaultRecorder());
+ context.dispose();
+ assertTrue(context.isDisposed());
+ // Disposing twice is harmless.
+ context.dispose();
+ assertThrows(IllegalStateException.class, () -> context.service(
+ Recorder.class));
+ }
+
+ /** Two contexts share nothing: not services, not the event bus. */
+ @Test
+ public void testContextsAreIsolated() {
+ try (final Context first = Context.of(new DefaultRecorder());
+ final Context second = Context.of(new DefaultRecorder()))
+ {
+ assertNotSame(first.service(Recorder.class), second.service(
+ Recorder.class));
+ assertNotSame(first.events(), second.events());
+
+ first.service(Recorder.class).log().add("only in first");
+ assertFalse(second.service(Recorder.class).log().contains(
+ "only in first"));
+ }
+ }
+
+ /** Disposing the context closes its bus, so services need no cleanup. */
+ @Test
+ public void testDisposeClosesTheEventBus() {
+ final Context context = Context.of(new DefaultRecorder());
+ final List heard = new ArrayList<>();
+ context.events().subscribe(String.class, heard::add);
+ context.events().publish("before");
+ context.dispose();
+ context.events().publish("after");
+ assertEquals(List.of("before"), heard);
+ }
+
+ // -- Injection --
+
+ public interface Counter extends Service {
+
+ int count();
+ }
+
+ public static class DefaultCounter implements Counter {
+
+ @Override
+ public int count() {
+ return 42;
+ }
+ }
+
+ public static class InjectedService implements Service {
+
+ @Dependency
+ private Counter counter;
+
+ @Dependency
+ private Context context;
+
+ @Dependency(required = false)
+ private Greeter absent;
+
+ int countViaDependency() {
+ return counter.count();
+ }
+ }
+
+ @Test
+ public void testFieldInjection() {
+ try (final Context context = Context.of(new InjectedService(),
+ new DefaultCounter()))
+ {
+ final InjectedService service = context.service(InjectedService.class);
+ assertEquals(42, service.countViaDependency());
+ assertSame(context, service.context);
+ assertNull(service.absent, "an optional dependency should stay null");
+ }
+ }
+
+ /** Dependencies are in place before initialize runs. */
+ public static class EarlyUser implements Service {
+
+ @Dependency
+ private Counter counter;
+
+ private int seenDuringInit = -1;
+
+ @Override
+ public void initialize(final Context context) {
+ seenDuringInit = counter.count();
+ }
+ }
+
+ @Test
+ public void testInjectionHappensBeforeInitialize() {
+ try (final Context context = Context.of(new EarlyUser(),
+ new DefaultCounter()))
+ {
+ assertEquals(42, context.service(EarlyUser.class).seenDuringInit);
+ }
+ }
+
+ public static class Demanding implements Service {
+
+ @Dependency
+ private Greeter greeter;
+ }
+
+ /** A missing required dependency names the field and the type. */
+ @Test
+ public void testMissingRequiredDependency() {
+ try (final Context context = Context.of(new Demanding())) {
+ final ServiceException exc = assertThrows(ServiceException.class, //
+ () -> context.service(Demanding.class));
+ assertTrue(exc.getMessage().contains("greeter"), exc.getMessage());
+ assertTrue(exc.getMessage().contains("Greeter"), exc.getMessage());
+ }
+ }
+
+ // -- Event handlers --
+
+ public static class Listening implements Service {
+
+ final List heard = new ArrayList<>();
+
+ @EventHandler
+ private void onString(final String event) {
+ heard.add(event);
+ }
+
+ @EventHandler(priority = Priority.HIGH)
+ private void onStringFirst(final String event) {
+ heard.add("first:" + event);
+ }
+ }
+
+ /** A service's handlers are subscribed for it, with no cleanup to write. */
+ @Test
+ public void testServiceHandlersAreSubscribedAutomatically() {
+ final Context context = Context.of(new Listening());
+ final Listening service = context.service(Listening.class);
+ context.events().publish("hello");
+ // NB: higher priority first.
+ assertEquals(List.of("first:hello", "hello"), service.heard);
+
+ // Disposing the context unsubscribes them.
+ context.dispose();
+ assertEquals(2, service.heard.size());
+ }
+
+ public static class Manual {
+
+ final List heard = new ArrayList<>();
+
+ @EventHandler
+ private void onString(final String event) {
+ heard.add(event);
+ }
+ }
+
+ /** Objects the context did not create are subscribed on request. */
+ @Test
+ public void testManualSubscription() {
+ try (final Context context = Context.of()) {
+ final Manual target = new Manual();
+ // NB: not subscribed until asked.
+ context.events().publish("ignored");
+ assertTrue(target.heard.isEmpty());
+
+ final List subscriptions = context.subscribe(target);
+ assertEquals(1, subscriptions.size());
+ context.events().publish("heard");
+ assertEquals(List.of("heard"), target.heard);
+
+ // The caller owns these, and can end them early.
+ subscriptions.forEach(Subscription::close);
+ context.events().publish("after");
+ assertEquals(List.of("heard"), target.heard);
+ }
+ }
+
+ public static class Malformed implements Service {
+
+ @EventHandler
+ private void twoParameters(final String a, final String b) {
+ // NB: not a valid handler.
+ }
+ }
+
+ /** A malformed handler is reported, naming the method. */
+ @Test
+ public void testMalformedHandler() {
+ try (final Context context = Context.of(new Malformed())) {
+ final ServiceException exc = assertThrows(ServiceException.class, //
+ () -> context.service(Malformed.class));
+ assertTrue(exc.getMessage().contains("twoParameters"), exc.getMessage());
+ }
+ }
+
+ public static class Throwing implements Service {
+
+ @EventHandler
+ private void onString(final String event) {
+ throw new IllegalStateException("handler blew up");
+ }
+ }
+
+ /** What a handler throws reaches the publisher, not a reflection wrapper. */
+ @Test
+ public void testHandlerExceptionIsUnwrapped() {
+ try (final Context context = Context.of(new Throwing())) {
+ context.service(Throwing.class);
+ final EventDeliveryException exc = assertThrows(
+ EventDeliveryException.class, () -> context.events().publish("boom"));
+ assertInstanceOf(IllegalStateException.class, exc.getCause());
+ assertEquals("handler blew up", exc.getCause().getMessage());
+ }
+ }
+
+ // -- Mutual dependency --
+
+ public interface Ping extends Service {
+
+ Pong pong();
+ }
+
+ public interface Pong extends Service {
+
+ Ping ping();
+ }
+
+ public static class DefaultPing implements Ping {
+
+ private Pong pong;
+
+ @Override
+ public void initialize(final Context context) {
+ pong = context.service(Pong.class);
+ }
+
+ @Override
+ public Pong pong() {
+ return pong;
+ }
+ }
+
+ public static class DefaultPong implements Pong {
+
+ private Ping ping;
+
+ @Override
+ public void initialize(final Context context) {
+ ping = context.service(Ping.class);
+ }
+
+ @Override
+ public Ping ping() {
+ return ping;
+ }
+ }
+
+ /**
+ * Two services may depend on each other. This resolves only because
+ * dependencies are acquired during initialization rather than in the
+ * constructor - constructor injection could not do it at all.
+ */
+ @Test
+ public void testMutualDependencyResolves() {
+ try (final Context context = Context.of(new DefaultPing(),
+ new DefaultPong()))
+ {
+ final Ping ping = context.service(Ping.class);
+ final Pong pong = context.service(Pong.class);
+ assertSame(pong, ping.pong());
+ assertSame(ping, pong.ping());
+ }
+ }
+
+ // -- Failure during initialization --
+
+ public interface Fragile extends Service {}
+
+ public static class DefaultFragile implements Fragile {
+
+ @Override
+ public void initialize(final Context context) {
+ throw new IllegalStateException("cannot start");
+ }
+ }
+
+ /** A service that fails to initialize says which one, and why. */
+ @Test
+ public void testInitializationFailureIsReported() {
+ try (final Context context = Context.of(new DefaultFragile())) {
+ final ServiceException exc = assertThrows(ServiceException.class, //
+ () -> context.service(Fragile.class));
+ assertTrue(exc.getMessage().contains("DefaultFragile"), exc.getMessage());
+ assertEquals("cannot start", exc.getCause().getMessage());
+ }
+ }
+
+ // -- Priority --
+
+ public static class LowGreeter implements Greeter {
+
+ @Override
+ public String greet() {
+ return "low";
+ }
+
+ @Override
+ public double priority() {
+ return Priority.LOW;
+ }
+ }
+
+ public static class HighGreeter implements Greeter {
+
+ @Override
+ public String greet() {
+ return "high";
+ }
+
+ @Override
+ public double priority() {
+ return Priority.HIGH;
+ }
+ }
+
+ /** Where several implementations provide a type, the highest wins. */
+ @Test
+ public void testHighestPriorityWins() {
+ try (final Context context = Context.of(new LowGreeter(),
+ new HighGreeter()))
+ {
+ assertEquals("high", context.service(Greeter.class).greet());
+ }
+ // NB: declaration order must not decide it.
+ try (final Context context = Context.of(new HighGreeter(),
+ new LowGreeter()))
+ {
+ assertEquals("high", context.service(Greeter.class).greet());
+ }
+ }
+
+ @Test
+ public void testServiceIsFoundByItsInterface() {
+ try (final Context context = Context.of(new LowGreeter())) {
+ assertInstanceOf(LowGreeter.class, context.service(Greeter.class));
+ }
+ }
+}
diff --git a/convert3/pom.xml b/convert3/pom.xml
new file mode 100644
index 000000000..00f871a90
--- /dev/null
+++ b/convert3/pom.xml
@@ -0,0 +1,154 @@
+
+
+ 4.0.0
+
+
+ org.scijava
+ pom-scijava
+ 44.0.0
+
+
+
+ scijava-convert3
+
+ 3.0.0-SNAPSHOT
+
+ SciJava Convert
+ Converting a value to the type something else wants.
+ https://github.com/scijava/scijava
+ 2026
+
+ SciJava
+ https://scijava.org/
+
+
+
+ Simplified BSD License
+ repo
+
+
+
+
+
+ ctrueden
+ Curtis Rueden
+
+ founder
+ lead
+ reviewer
+ support
+ maintainer
+
+
+
+ gselzer
+ Gabriel Selzer
+
+ founder
+ debugger
+ reviewer
+ support
+
+
+
+
+
+ Johannes Schindelin
+ dscho
+
+
+
+
+
+ Image.sc Forum
+ https://forum.image.sc/tag/scijava
+
+
+
+
+ scm:git:https://github.com/scijava/scijava
+ scm:git:git@github.com:scijava/scijava
+ HEAD
+ https://github.com/scijava/scijava
+
+
+ GitHub Issues
+ https://github.com/scijava/scijava/issues
+
+
+ GitHub Actions
+ https://github.com/scijava/scijava/actions
+
+
+
+ org.scijava.convert3
+
+ bsd_2
+ SciJava developers.
+
+
+ 1.0.1-SNAPSHOT
+ 1.0.1-SNAPSHOT
+
+
+
+
+
+ org.scijava
+ scijava-common3
+ ${scijava-common3.version}
+
+
+ org.scijava
+ scijava-priority
+ ${scijava-priority.version}
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+
+
+
+
+ maven-enforcer-plugin
+
+
+ enforce-package-rules
+
+ enforce
+
+ test
+
+
+
+
+
+
+
+
+
+
+ maven-javadoc-plugin
+
+
+ https://javadoc.scijava.org/Java11/
+
+
+
+
+
+
diff --git a/convert3/src/main/java/module-info.java b/convert3/src/main/java/module-info.java
new file mode 100644
index 000000000..e3d1f8382
--- /dev/null
+++ b/convert3/src/main/java/module-info.java
@@ -0,0 +1,51 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+module org.scijava.convert3 {
+
+ exports org.scijava.convert3;
+
+ requires transitive org.scijava.common3;
+ requires org.scijava.priority;
+
+ uses org.scijava.convert3.Converter;
+
+ provides org.scijava.convert3.Converter with
+ org.scijava.convert3.CastConverter,
+ org.scijava.convert3.NumberConverter,
+ org.scijava.convert3.StringConverter,
+ org.scijava.convert3.ToStringConverter,
+ org.scijava.convert3.ArrayConverter,
+ org.scijava.convert3.CollectionConverter,
+ org.scijava.convert3.PathConverters.FileToPath,
+ org.scijava.convert3.PathConverters.PathToFile,
+ org.scijava.convert3.PathConverters.StringToPath,
+ org.scijava.convert3.ConstructorConverter;
+
+}
diff --git a/convert3/src/main/java/org/scijava/convert3/ArrayConverter.java b/convert3/src/main/java/org/scijava/convert3/ArrayConverter.java
new file mode 100644
index 000000000..e0e13244d
--- /dev/null
+++ b/convert3/src/main/java/org/scijava/convert3/ArrayConverter.java
@@ -0,0 +1,107 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.convert3;
+
+import java.lang.reflect.Array;
+import java.lang.reflect.Type;
+import java.util.Collection;
+
+import org.scijava.common3.Types;
+import org.scijava.priority.Priority;
+
+/**
+ * Converts to an array, converting each element on the way.
+ *
+ * A collection becomes an array, an array becomes an array of another
+ * component type, and a lone value becomes an array of one - which is what
+ * makes {@code "3"} usable where {@code int[]} is wanted, a thing scripts and
+ * command lines do constantly.
+ *
+ *
+ * @author Curtis Rueden
+ */
+public class ArrayConverter implements Converter {
+
+ @Override
+ public Type sourceType() {
+ return Object.class;
+ }
+
+ @Override
+ public Type destType() {
+ return Object[].class;
+ }
+
+ @Override
+ public boolean supports(final Object source, final Type dest) {
+ return source != null && Types.component(dest) != null;
+ }
+
+ @Override
+ public Object convert(final Object source, final Type dest) {
+ final Type componentType = Types.component(dest);
+ final Class> component = Types.raw(componentType);
+ if (component == null) return null;
+
+ final Object[] elements = elements(source);
+ final Object array = Array.newInstance(component, elements.length);
+ final Converters converters = Converters.get();
+ for (int i = 0; i < elements.length; i++) {
+ // NB: each element is converted in turn, so a List reaches an
+ // int[] without anything knowing about that pair specifically.
+ final Object element = converters.tryConvert(elements[i], componentType)
+ .orElse(null);
+ if (element == null && elements[i] != null) return null;
+ Array.set(array, i, element);
+ }
+ return array;
+ }
+
+ @Override
+ public double priority() {
+ return Priority.HIGH;
+ }
+
+ // -- Helper methods --
+
+ private static Object[] elements(final Object source) {
+ if (source instanceof Collection) {
+ return ((Collection>) source).toArray();
+ }
+ if (source.getClass().isArray()) {
+ final int length = Array.getLength(source);
+ final Object[] elements = new Object[length];
+ for (int i = 0; i < length; i++)
+ elements[i] = Array.get(source, i);
+ return elements;
+ }
+ return new Object[] { source };
+ }
+}
diff --git a/convert3/src/main/java/org/scijava/convert3/CastConverter.java b/convert3/src/main/java/org/scijava/convert3/CastConverter.java
new file mode 100644
index 000000000..ec2f8f0aa
--- /dev/null
+++ b/convert3/src/main/java/org/scijava/convert3/CastConverter.java
@@ -0,0 +1,97 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.convert3;
+
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Collection;
+
+import org.scijava.common3.Classes;
+import org.scijava.common3.Types;
+import org.scijava.priority.Priority;
+
+/**
+ * Converts a value that is already of the wanted type: it returns it.
+ *
+ * NB: first, and so the answer whenever no conversion is needed at all. That
+ * is the common case by far - most values arrive already right - and it costs
+ * one assignability check.
+ *
+ *
+ * @author Curtis Rueden
+ */
+public class CastConverter implements Converter {
+
+ @Override
+ public Type sourceType() {
+ return Object.class;
+ }
+
+ @Override
+ public Type destType() {
+ return Object.class;
+ }
+
+ @Override
+ public boolean supports(final Object source, final Type dest) {
+ if (source == null || dest == null) return false;
+ final Class> raw = Types.raw(dest);
+ if (raw == null) return false;
+ if (!Classes.box(raw).isInstance(source)) return false;
+ // NB: a collection is only "already the right type" if what it holds is
+ // too, and erasure means we cannot tell by looking at the type. A
+ // List handed to a List would otherwise be returned
+ // unchanged -- the right raw type full of the wrong things -- so the
+ // element-wise converter gets it instead, and returns it untouched when
+ // the elements do match.
+ return !(source instanceof Collection && isParameterized(dest));
+ }
+
+ /** Gets whether the type says anything about what it contains. */
+ private static boolean isParameterized(final Type dest) {
+ if (!(dest instanceof ParameterizedType)) return false;
+ for (final Type argument : ((ParameterizedType) dest)
+ .getActualTypeArguments())
+ {
+ if (argument != Object.class) return true;
+ }
+ return false;
+ }
+
+ @Override
+ public Object convert(final Object source, final Type dest) {
+ return source;
+ }
+
+ @Override
+ public double priority() {
+ return Priority.VERY_HIGH;
+ }
+}
diff --git a/convert3/src/main/java/org/scijava/convert3/CollectionConverter.java b/convert3/src/main/java/org/scijava/convert3/CollectionConverter.java
new file mode 100644
index 000000000..558ff2f0d
--- /dev/null
+++ b/convert3/src/main/java/org/scijava/convert3/CollectionConverter.java
@@ -0,0 +1,123 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.convert3;
+
+import java.lang.reflect.Array;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.scijava.common3.Types;
+import org.scijava.priority.Priority;
+
+/**
+ * Converts to a collection, converting each element on the way.
+ *
+ * The element type comes from the destination's type parameter where there is
+ * one, so a {@code List} given a list of strings arrives holding
+ * doubles rather than strings that will fail later.
+ *
+ *
+ * @author Curtis Rueden
+ */
+public class CollectionConverter implements Converter> {
+
+ @Override
+ public Type sourceType() {
+ return Object.class;
+ }
+
+ @Override
+ public Type destType() {
+ return Collection.class;
+ }
+
+ @Override
+ public boolean supports(final Object source, final Type dest) {
+ if (source == null) return false;
+ final Class> raw = Types.raw(dest);
+ if (raw == null || !Collection.class.isAssignableFrom(raw)) return false;
+ // NB: only the shapes we can actually instantiate. A caller wanting its
+ // own collection type can contribute a converter for it.
+ return raw.isAssignableFrom(ArrayList.class) || raw.isAssignableFrom(
+ LinkedHashSet.class);
+ }
+
+ @Override
+ public Object convert(final Object source, final Type dest) {
+ final Class> raw = Types.raw(dest);
+ final Type elementType = elementType(dest);
+ final Collection collection = Set.class.isAssignableFrom(raw)
+ ? new LinkedHashSet<>() : new ArrayList<>();
+
+ final Converters converters = Converters.get();
+ for (final Object element : elements(source)) {
+ if (elementType == null || elementType == Object.class) {
+ collection.add(element);
+ continue;
+ }
+ final Object converted = converters.tryConvert(element, elementType)
+ .orElse(null);
+ if (converted == null && element != null) return null;
+ collection.add(converted);
+ }
+ return collection;
+ }
+
+ @Override
+ public double priority() {
+ return Priority.HIGH;
+ }
+
+ // -- Helper methods --
+
+ /** Gets what the collection holds, if the type says. */
+ private static Type elementType(final Type dest) {
+ final Type[] params = Types.typeParamsOf(dest, Collection.class);
+ return params == null || params.length == 0 ? null : params[0];
+ }
+
+ private static List elements(final Object source) {
+ final List elements = new ArrayList<>();
+ if (source instanceof Collection) {
+ elements.addAll((Collection>) source);
+ }
+ else if (source.getClass().isArray()) {
+ final int length = Array.getLength(source);
+ for (int i = 0; i < length; i++)
+ elements.add(Array.get(source, i));
+ }
+ else elements.add(source);
+ return elements;
+ }
+}
diff --git a/convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java b/convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java
new file mode 100644
index 000000000..1c4d861e5
--- /dev/null
+++ b/convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java
@@ -0,0 +1,103 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.convert3;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Type;
+
+import org.scijava.common3.Classes;
+import org.scijava.common3.Types;
+import org.scijava.priority.Priority;
+
+/**
+ * Converts by handing the value to a constructor of the wanted type.
+ *
+ * This is what turns a {@code String} into a {@code File}, a {@code URI} into
+ * whatever wraps one, and a value into any class written to wrap it. It is
+ * last, because it is a guess - a constructor taking the source type is not a
+ * promise that the result means the same thing.
+ *
+ *
+ * @author Curtis Rueden
+ */
+public class ConstructorConverter implements Converter {
+
+ @Override
+ public Type sourceType() {
+ return Object.class;
+ }
+
+ @Override
+ public Type destType() {
+ return Object.class;
+ }
+
+ @Override
+ public boolean supports(final Object source, final Type dest) {
+ return source != null && constructor(source, dest) != null;
+ }
+
+ @Override
+ public Object convert(final Object source, final Type dest) {
+ final Constructor> ctor = constructor(source, dest);
+ if (ctor == null) return null;
+ try {
+ return ctor.newInstance(source);
+ }
+ catch (final ReflectiveOperationException | IllegalArgumentException exc) {
+ // NB: the constructor existed and refused the value; the caller sees
+ // this as "cannot convert", which is what it amounts to.
+ return null;
+ }
+ }
+
+ @Override
+ public double priority() {
+ return Priority.VERY_LOW;
+ }
+
+ // -- Helper methods --
+
+ /** Finds a public constructor of the destination taking the source. */
+ private static Constructor> constructor(final Object source,
+ final Type dest)
+ {
+ final Class> raw = Types.raw(dest);
+ if (raw == null || raw.isInterface() || raw.isPrimitive() || //
+ Modifier.isAbstract(raw.getModifiers())) return null;
+ for (final Constructor> ctor : raw.getConstructors()) {
+ if (ctor.getParameterCount() != 1) continue;
+ final Class> param = Classes.box(ctor.getParameterTypes()[0]);
+ if (param.isInstance(source)) return ctor;
+ }
+ return null;
+ }
+}
diff --git a/convert3/src/main/java/org/scijava/convert3/ConversionException.java b/convert3/src/main/java/org/scijava/convert3/ConversionException.java
new file mode 100644
index 000000000..0e38bb284
--- /dev/null
+++ b/convert3/src/main/java/org/scijava/convert3/ConversionException.java
@@ -0,0 +1,59 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.convert3;
+
+import java.lang.reflect.Type;
+
+import org.scijava.common3.Types;
+
+/**
+ * Thrown when a value cannot be turned into the type something else wants.
+ *
+ * @author Curtis Rueden
+ */
+public class ConversionException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ public ConversionException(final String message) {
+ super(message);
+ }
+
+ public ConversionException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+
+ /** Creates an exception naming what could not be converted into what. */
+ public static ConversionException of(final Object source, final Type dest) {
+ return new ConversionException("Cannot convert " + //
+ (source == null ? "null" : Types.name(source.getClass())) + " to " + //
+ Types.name(dest));
+ }
+}
diff --git a/convert3/src/main/java/org/scijava/convert3/Converter.java b/convert3/src/main/java/org/scijava/convert3/Converter.java
new file mode 100644
index 000000000..f4c5100f9
--- /dev/null
+++ b/convert3/src/main/java/org/scijava/convert3/Converter.java
@@ -0,0 +1,150 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.convert3;
+
+import java.lang.reflect.Type;
+import java.util.function.Function;
+
+import org.scijava.common3.Types;
+import org.scijava.priority.Priority;
+
+/**
+ * Turns a value of one type into a value of another.
+ *
+ * Contribute one through {@link java.util.ServiceLoader}: a module that
+ * {@code provides org.scijava.convert3.Converter} is enough, and no container
+ * is involved. That matters because conversion is needed below the
+ * container - binding an input to a parameter converts, and a script or a
+ * command line supplies strings for everything.
+ *
+ *
+ * NB: SciJava Common's {@code Converter} had eight {@code canConvert}
+ * overloads across {@code Object}/{@code Class}/{@code Type} and a
+ * {@code ConversionRequest} to carry the combinations. Here there is one
+ * question - can you turn this value into that type - and
+ * the types are declared, so the common case needs no code at all:
+ *
+ *
+ *
+ * Converter.of(String.class, Integer.class, Integer::valueOf)
+ *
+ *
+ * @param the type converted from
+ * @param the type converted to
+ * @author Curtis Rueden
+ */
+public interface Converter {
+
+ /** Gets the type this converter accepts. */
+ Type sourceType();
+
+ /** Gets the type this converter produces. */
+ Type destType();
+
+ /**
+ * Converts the given value.
+ *
+ * @param source the value to convert, never null
+ * @param dest the type wanted, which {@link #supports} has accepted
+ * @return the converted value, or null if this converter cannot after all
+ */
+ Object convert(Object source, Type dest);
+
+ /**
+ * Gets whether this converter can turn the given value into the given type.
+ *
+ * The default answers from the declared types: the value must be one this
+ * converter accepts, and what it produces must fit where the result is
+ * going.
+ *
+ */
+ default boolean supports(final Object source, final Type dest) {
+ if (source == null || dest == null) return false;
+ return Types.isAssignable(source.getClass(), sourceType()) && //
+ Types.isAssignable(destType(), Types.raw(dest) == null ? dest //
+ : org.scijava.common3.Classes.box(Types.raw(dest)));
+ }
+
+ /** Sorts converters, highest first. See {@link Priority}. */
+ default double priority() {
+ return Priority.NORMAL;
+ }
+
+ /**
+ * Creates a converter from a function.
+ *
+ * @param the type converted from
+ * @param the type converted to
+ * @param sourceType the type accepted
+ * @param destType the type produced
+ * @param function what does the converting
+ * @return the new converter
+ */
+ static Converter of(final Class sourceType,
+ final Class destType, final Function function)
+ {
+ return of(sourceType, destType, function, Priority.NORMAL);
+ }
+
+ /** Creates a converter from a function, at the given priority. */
+ static Converter of(final Class sourceType,
+ final Class destType, final Function function,
+ final double priority)
+ {
+ return new Converter<>() {
+
+ @Override
+ public Type sourceType() {
+ return sourceType;
+ }
+
+ @Override
+ public Type destType() {
+ return destType;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public Object convert(final Object source, final Type dest) {
+ return function.apply((I) source);
+ }
+
+ @Override
+ public double priority() {
+ return priority;
+ }
+
+ @Override
+ public String toString() {
+ return Types.name(sourceType) + " -> " + Types.name(destType);
+ }
+ };
+ }
+}
diff --git a/convert3/src/main/java/org/scijava/convert3/Converters.java b/convert3/src/main/java/org/scijava/convert3/Converters.java
new file mode 100644
index 000000000..3926b20f4
--- /dev/null
+++ b/convert3/src/main/java/org/scijava/convert3/Converters.java
@@ -0,0 +1,166 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.convert3;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.ServiceLoader;
+
+import org.scijava.common3.Classes;
+import org.scijava.common3.Types;
+
+/**
+ * Converts values to the types other code wants, using the
+ * {@link Converter}s available to the runtime.
+ *
+ * Converters come from {@link ServiceLoader}, so this needs no container and
+ * no configuration. For an embedded or tested setting where implicit discovery
+ * is unwanted, construct an instance with an explicit list instead.
+ *
+ *
+ * @author Curtis Rueden
+ */
+public class Converters {
+
+ private static class DefaultHolder {
+
+ static final Converters INSTANCE = new Converters(discoverConverters());
+ }
+
+ private final List> converters;
+
+ /** Creates an instance that uses exactly the given converters. */
+ public Converters(final List extends Converter, ?>> converters) {
+ this.converters = new ArrayList<>(converters);
+ // NB: highest priority first, so the first supporting converter wins.
+ this.converters.sort(Comparator.comparingDouble(
+ (Converter, ?> c) -> c.priority()).reversed());
+ }
+
+ /** Gets the shared instance, backed by {@link ServiceLoader} discovery. */
+ public static Converters get() {
+ return DefaultHolder.INSTANCE;
+ }
+
+ /**
+ * Converts the given value to the given type.
+ *
+ * @param source the value to convert
+ * @param dest the type wanted
+ * @return the converted value
+ * @throws ConversionException if nothing can convert it
+ */
+ public Object convert(final Object source, final Type dest) {
+ return tryConvert(source, dest).orElseThrow(() -> ConversionException.of(
+ source, dest));
+ }
+
+ /**
+ * Converts the given value to the given type.
+ *
+ * @param the type wanted
+ * @param source the value to convert
+ * @param dest the type wanted
+ * @return the converted value
+ * @throws ConversionException if nothing can convert it
+ */
+ public T convert(final Object source, final Class dest) {
+ @SuppressWarnings("unchecked")
+ final T result = (T) convert(source, (Type) dest);
+ return result;
+ }
+
+ /**
+ * Converts the given value, if anything can.
+ *
+ * NB: this is the form for a user interface, where a half-typed value is
+ * not an error but simply not a value yet. A caller that needs the
+ * conversion to happen should use {@link #convert} and let it throw.
+ *
+ *
+ * @param source the value to convert
+ * @param dest the type wanted
+ * @return the converted value, or empty if nothing can convert it, or if
+ * the conversion itself declined
+ */
+ public Optional tryConvert(final Object source, final Type dest) {
+ if (dest == null) return Optional.empty();
+ if (source == null) {
+ // NB: null converts to null, except for a primitive, which cannot
+ // hold one: there, the type's own zero is the nearest thing.
+ final Class> raw = Types.raw(dest);
+ return raw != null && raw.isPrimitive() //
+ ? Optional.ofNullable(Classes.nullValue(raw)) : Optional.empty();
+ }
+ for (final Converter, ?> converter : converters) {
+ if (!converter.supports(source, dest)) continue;
+ final Object result = converter.convert(source, dest);
+ if (result != null) return Optional.of(result);
+ // NB: a converter that accepted the value and then produced nothing -
+ // unparseable text, most often - lets the next one try.
+ }
+ return Optional.empty();
+ }
+
+ /** Converts the given value, if anything can. */
+ public Optional tryConvert(final Object source, final Class dest) {
+ @SuppressWarnings("unchecked")
+ final Optional result = (Optional) tryConvert(source, (Type) dest);
+ return result;
+ }
+
+ /** Gets whether the given value can be converted to the given type. */
+ public boolean supports(final Object source, final Type dest) {
+ if (dest == null) return false;
+ if (source == null) {
+ final Class> raw = Types.raw(dest);
+ return raw != null && raw.isPrimitive();
+ }
+ return converters.stream().anyMatch(c -> c.supports(source, dest));
+ }
+
+ /** Gets the converters in use, highest priority first. */
+ public List> converters() {
+ return List.copyOf(converters);
+ }
+
+ // -- Helper methods --
+
+ private static List> discoverConverters() {
+ final List> found = new ArrayList<>();
+ // NB: loaded here, in this module, because ServiceLoader resolves `uses`
+ // against the calling module.
+ ServiceLoader.load(Converter.class).forEach(found::add);
+ return found;
+ }
+}
diff --git a/convert3/src/main/java/org/scijava/convert3/NumberConverter.java b/convert3/src/main/java/org/scijava/convert3/NumberConverter.java
new file mode 100644
index 000000000..0e80a8d6a
--- /dev/null
+++ b/convert3/src/main/java/org/scijava/convert3/NumberConverter.java
@@ -0,0 +1,108 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.convert3;
+
+import java.lang.reflect.Type;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+
+import org.scijava.common3.Classes;
+import org.scijava.common3.Types;
+import org.scijava.priority.Priority;
+
+/**
+ * Converts a number to another kind of number.
+ *
+ * NB: narrowing is allowed and silent, as it is in Java itself: a
+ * {@code double} parameter given a {@code long} is the ordinary case, and a
+ * {@code long} parameter given a {@code double} rounds toward zero exactly as
+ * a cast would. SciJava Common spread this over eight converter classes, one
+ * per target type.
+ *
+ *
+ * @author Curtis Rueden
+ */
+public class NumberConverter implements Converter {
+
+ @Override
+ public Type sourceType() {
+ return Number.class;
+ }
+
+ @Override
+ public Type destType() {
+ return Number.class;
+ }
+
+ @Override
+ public boolean supports(final Object source, final Type dest) {
+ if (!(source instanceof Number) || dest == null) return false;
+ final Class> raw = Types.raw(dest);
+ return raw != null && isNumeric(Classes.box(raw));
+ }
+
+ @Override
+ public Object convert(final Object source, final Type dest) {
+ final Number number = (Number) source;
+ final Class> type = Classes.box(Types.raw(dest));
+ if (type == Byte.class) return number.byteValue();
+ if (type == Short.class) return number.shortValue();
+ if (type == Integer.class) return number.intValue();
+ if (type == Long.class) return number.longValue();
+ if (type == Float.class) return number.floatValue();
+ if (type == Double.class) return number.doubleValue();
+ if (type == BigInteger.class) return BigInteger.valueOf(number
+ .longValue());
+ if (type == BigDecimal.class) return toBigDecimal(number);
+ return null;
+ }
+
+ @Override
+ public double priority() {
+ return Priority.HIGH;
+ }
+
+ // -- Helper methods --
+
+ private static boolean isNumeric(final Class> type) {
+ return type == Byte.class || type == Short.class || type == Integer.class ||
+ type == Long.class || type == Float.class || type == Double.class ||
+ type == BigInteger.class || type == BigDecimal.class;
+ }
+
+ private static BigDecimal toBigDecimal(final Number number) {
+ if (number instanceof BigDecimal) return (BigDecimal) number;
+ if (number instanceof BigInteger) return new BigDecimal(
+ (BigInteger) number);
+ // NB: through the string, so that 0.1 is 0.1 and not the double nearest
+ // to it. A BigDecimal parameter asked for exactness by its type.
+ return new BigDecimal(number.toString());
+ }
+}
diff --git a/convert3/src/main/java/org/scijava/convert3/PathConverters.java b/convert3/src/main/java/org/scijava/convert3/PathConverters.java
new file mode 100644
index 000000000..2386adea6
--- /dev/null
+++ b/convert3/src/main/java/org/scijava/convert3/PathConverters.java
@@ -0,0 +1,135 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.convert3;
+
+import java.io.File;
+import java.lang.reflect.Type;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.scijava.common3.Types;
+import org.scijava.priority.Priority;
+
+/**
+ * Converts between the three ways Java names a file.
+ *
+ * NB: {@code String} to {@code File} would work through
+ * {@link ConstructorConverter} anyway, but {@code String} to {@code Path} has
+ * no constructor to find, and neither direction between {@code File} and
+ * {@code Path} does. They are the same thing said three ways, and code should
+ * not have to care which way a caller said it.
+ *
+ *
+ * @author Curtis Rueden
+ */
+public final class PathConverters {
+
+ private PathConverters() {
+ // prevent instantiation of utility class
+ }
+
+ /** Converts a file to a path. */
+ public static class FileToPath implements Converter {
+
+ @Override
+ public Type sourceType() {
+ return File.class;
+ }
+
+ @Override
+ public Type destType() {
+ return Path.class;
+ }
+
+ @Override
+ public Object convert(final Object source, final Type dest) {
+ return ((File) source).toPath();
+ }
+
+ @Override
+ public double priority() {
+ return Priority.HIGH;
+ }
+ }
+
+ /** Converts a path to a file. */
+ public static class PathToFile implements Converter {
+
+ @Override
+ public Type sourceType() {
+ return Path.class;
+ }
+
+ @Override
+ public Type destType() {
+ return File.class;
+ }
+
+ @Override
+ public Object convert(final Object source, final Type dest) {
+ return ((Path) source).toFile();
+ }
+
+ @Override
+ public double priority() {
+ return Priority.HIGH;
+ }
+ }
+
+ /** Converts text to a path. */
+ public static class StringToPath implements Converter {
+
+ @Override
+ public Type sourceType() {
+ return String.class;
+ }
+
+ @Override
+ public Type destType() {
+ return Path.class;
+ }
+
+ @Override
+ public boolean supports(final Object source, final Type dest) {
+ return source instanceof String && !((String) source).isEmpty() && //
+ Types.raw(dest) == Path.class;
+ }
+
+ @Override
+ public Object convert(final Object source, final Type dest) {
+ return Paths.get((String) source);
+ }
+
+ @Override
+ public double priority() {
+ return Priority.HIGH;
+ }
+ }
+}
diff --git a/convert3/src/main/java/org/scijava/convert3/StringConverter.java b/convert3/src/main/java/org/scijava/convert3/StringConverter.java
new file mode 100644
index 000000000..6ec771b1c
--- /dev/null
+++ b/convert3/src/main/java/org/scijava/convert3/StringConverter.java
@@ -0,0 +1,119 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.convert3;
+
+import java.lang.reflect.Type;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+
+import org.scijava.common3.Classes;
+import org.scijava.common3.Types;
+
+/**
+ * Converts text to a value: a number, a character, a boolean, an enum
+ * constant.
+ *
+ * This is the converter a script, a command line and a text field all need,
+ * since each of them has only strings to offer.
+ *
+ *
+ * NB: text that is not a value of the wanted type converts to nothing, rather
+ * than to zero. A half-typed number is not a zero, and a caller that wants to
+ * know the difference gets to.
+ *
+ *
+ * @author Curtis Rueden
+ */
+public class StringConverter implements Converter {
+
+ @Override
+ public Type sourceType() {
+ return String.class;
+ }
+
+ @Override
+ public Type destType() {
+ return Object.class;
+ }
+
+ @Override
+ public boolean supports(final Object source, final Type dest) {
+ if (!(source instanceof String) || dest == null) return false;
+ final Class> raw = Types.raw(dest);
+ if (raw == null) return false;
+ final Class> type = Classes.box(raw);
+ return type == Character.class || type == Boolean.class || type
+ .isEnum() || isNumeric(type);
+ }
+
+ @Override
+ public Object convert(final Object source, final Type dest) {
+ final String text = (String) source;
+ final Class> type = Classes.box(Types.raw(dest));
+ if (type == Character.class) {
+ return text.isEmpty() ? null : text.charAt(0);
+ }
+ if (type == Boolean.class) {
+ if (text.equalsIgnoreCase("true")) return Boolean.TRUE;
+ if (text.equalsIgnoreCase("false")) return Boolean.FALSE;
+ return null;
+ }
+ if (type.isEnum()) {
+ try {
+ return Types.enumValue(text, type);
+ }
+ catch (final IllegalArgumentException exc) {
+ return null; // NB: no such constant
+ }
+ }
+ try {
+ if (type == Byte.class) return Byte.valueOf(text);
+ if (type == Short.class) return Short.valueOf(text);
+ if (type == Integer.class) return Integer.valueOf(text);
+ if (type == Long.class) return Long.valueOf(text);
+ if (type == Float.class) return Float.valueOf(text);
+ if (type == Double.class) return Double.valueOf(text);
+ if (type == BigInteger.class) return new BigInteger(text);
+ if (type == BigDecimal.class) return new BigDecimal(text);
+ }
+ catch (final NumberFormatException exc) {
+ return null; // NB: not a number yet, or not a number at all
+ }
+ return null;
+ }
+
+ // -- Helper methods --
+
+ private static boolean isNumeric(final Class> type) {
+ return type == Byte.class || type == Short.class || type == Integer.class ||
+ type == Long.class || type == Float.class || type == Double.class ||
+ type == BigInteger.class || type == BigDecimal.class;
+ }
+}
diff --git a/convert3/src/main/java/org/scijava/convert3/ToStringConverter.java b/convert3/src/main/java/org/scijava/convert3/ToStringConverter.java
new file mode 100644
index 000000000..340ab2bc3
--- /dev/null
+++ b/convert3/src/main/java/org/scijava/convert3/ToStringConverter.java
@@ -0,0 +1,72 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.convert3;
+
+import java.lang.reflect.Type;
+
+import org.scijava.common3.Types;
+import org.scijava.priority.Priority;
+
+/**
+ * Converts anything to text, with {@link Object#toString()}.
+ *
+ * NB: low priority, since it accepts everything. Anything more specific -
+ * formatting a number, naming a file - should outrank it.
+ *
+ *
+ * @author Curtis Rueden
+ */
+public class ToStringConverter implements Converter {
+
+ @Override
+ public Type sourceType() {
+ return Object.class;
+ }
+
+ @Override
+ public Type destType() {
+ return String.class;
+ }
+
+ @Override
+ public boolean supports(final Object source, final Type dest) {
+ return source != null && Types.raw(dest) == String.class;
+ }
+
+ @Override
+ public Object convert(final Object source, final Type dest) {
+ return source.toString();
+ }
+
+ @Override
+ public double priority() {
+ return Priority.LOW;
+ }
+}
diff --git a/convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter b/convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter
new file mode 100644
index 000000000..c734bd51c
--- /dev/null
+++ b/convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter
@@ -0,0 +1,12 @@
+# NB: for the classpath. The module path reads module-info's `provides`
+# instead, but a great deal of scientific Java still runs unmodularized.
+org.scijava.convert3.CastConverter
+org.scijava.convert3.NumberConverter
+org.scijava.convert3.StringConverter
+org.scijava.convert3.ToStringConverter
+org.scijava.convert3.ArrayConverter
+org.scijava.convert3.CollectionConverter
+org.scijava.convert3.PathConverters$FileToPath
+org.scijava.convert3.PathConverters$PathToFile
+org.scijava.convert3.PathConverters$StringToPath
+org.scijava.convert3.ConstructorConverter
diff --git a/convert3/src/test/java/org/scijava/convert3/ConvertersTest.java b/convert3/src/test/java/org/scijava/convert3/ConvertersTest.java
new file mode 100644
index 000000000..fe560260f
--- /dev/null
+++ b/convert3/src/test/java/org/scijava/convert3/ConvertersTest.java
@@ -0,0 +1,222 @@
+/*
+ * #%L
+ * Converting a value to the type something else wants.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.convert3;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.math.BigDecimal;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.scijava.common3.Types;
+
+/**
+ * Tests conversion.
+ *
+ * @author Curtis Rueden
+ */
+public class ConvertersTest {
+
+ private final Converters converters = Converters.get();
+
+ /** The converters are found without a container of any kind. */
+ @Test
+ public void testDiscovery() {
+ assertFalse(converters.converters().isEmpty(),
+ "converters should come from ServiceLoader");
+ }
+
+ /** A value already of the wanted type is returned, not rebuilt. */
+ @Test
+ public void testNoConversionNeeded() {
+ final List list = List.of("a");
+ assertSame(list, converters.convert(list, List.class));
+ assertEquals("x", converters.convert("x", String.class));
+ }
+
+ /** A boxed value satisfies a primitive parameter, and the reverse. */
+ @Test
+ public void testPrimitives() {
+ assertEquals(5, converters.convert(5, int.class));
+ assertEquals(5.0, converters.convert(5, double.class));
+ assertEquals(5L, converters.convert(5.9, long.class), "narrows as a cast");
+ assertEquals(true, converters.convert(true, boolean.class));
+ }
+
+ /** Text becomes whatever kind of value was asked for. */
+ @Test
+ public void testFromString() {
+ assertEquals(42, converters.convert("42", int.class));
+ assertEquals(4.5, converters.convert("4.5", double.class));
+ assertEquals('a', converters.convert("abc", char.class));
+ assertEquals(true, converters.convert("TRUE", boolean.class));
+ assertEquals(Season.WINTER, converters.convert("WINTER", Season.class));
+ assertEquals(new BigDecimal("0.1"), converters.convert("0.1",
+ BigDecimal.class));
+ }
+
+ /** A BigDecimal from a double keeps what the text said, not the binary. */
+ @Test
+ public void testExactness() {
+ assertEquals(new BigDecimal("0.1"), converters.convert(0.1,
+ BigDecimal.class));
+ }
+
+ /** Text that is not a value of that type converts to nothing. */
+ @Test
+ public void testUnparseableText() {
+ assertEquals(Optional.empty(), converters.tryConvert("-", int.class));
+ assertEquals(Optional.empty(), converters.tryConvert("", int.class));
+ assertEquals(Optional.empty(), converters.tryConvert("SPRINGISH",
+ Season.class));
+ assertThrows(ConversionException.class, () -> converters.convert("nope",
+ int.class));
+ }
+
+ /** Anything can be described as text. */
+ @Test
+ public void testToString() {
+ assertEquals("42", converters.convert(42, String.class));
+ assertEquals("WINTER", converters.convert(Season.WINTER, String.class));
+ }
+
+ /** A lone value becomes an array of one, which is what scripts pass. */
+ @Test
+ public void testArrays() {
+ assertArrayEquals(new int[] { 3 }, (int[]) converters.convert("3",
+ int[].class));
+ assertArrayEquals(new double[] { 1, 2, 3 }, (double[]) converters.convert(
+ List.of("1", "2", "3"), double[].class), 0);
+ assertArrayEquals(new String[] { "1", "2" }, (String[]) converters.convert(
+ new int[] { 1, 2 }, String[].class));
+ }
+
+ /** A collection's element type is honored, not merely its raw type. */
+ @Test
+ public void testCollections() {
+ // NB: the element type comes from the destination type, so this is
+ // List rather than a raw List.
+ final List> list = (List>) converters.convert(new String[] { "1", "2" },
+ Types.parameterize(List.class, Integer.class));
+ assertEquals(List.of(1, 2), list);
+
+ final Set> set = (Set>) converters.convert(List.of("1", "1", "2"),
+ Types.parameterize(Set.class, Integer.class));
+ assertEquals(Set.of(1, 2), set);
+ }
+
+ /**
+ * A collection of the wrong element type is converted, not waved through.
+ *
+ * NB: erasure makes {@code List} and {@code List} the same
+ * raw type, so a cast would return the right kind of container full of the
+ * wrong things - which is the sort of bug that surfaces much later, as a
+ * ClassCastException somewhere else entirely.
+ *
+ */
+ @Test
+ public void testCollectionElementsAreConverted() {
+ final List> converted = (List>) converters.convert(List.of("1", "2"),
+ Types.parameterize(List.class, Integer.class));
+
+ assertEquals(List.of(1, 2), converted);
+
+ // and a list that is already right comes back equal
+ assertEquals(List.of(1, 2), converters.convert(List.of(1, 2), Types
+ .parameterize(List.class, Integer.class)));
+ }
+
+ /** The three ways of naming a file all convert to one another. */
+ @Test
+ public void testFilesAndPaths() {
+ final File file = new File("/tmp/x.txt");
+ assertEquals(file.toPath(), converters.convert(file, Path.class));
+ assertEquals(file, converters.convert(file.toPath(), File.class));
+ assertEquals(file, converters.convert("/tmp/x.txt", File.class));
+ assertEquals(file.toPath(), converters.convert("/tmp/x.txt", Path.class));
+ }
+
+ /** A constructor taking the value is the last resort, and it works. */
+ @Test
+ public void testWrappingConstructor() {
+ final Object wrapped = converters.convert("hello", Wrapper.class);
+ assertEquals("hello", ((Wrapper) wrapped).value);
+ }
+
+ /** Null is null, except where a primitive cannot hold one. */
+ @Test
+ public void testNull() {
+ assertEquals(Optional.empty(), converters.tryConvert(null, String.class));
+ assertEquals(0, converters.convert(null, int.class));
+ assertEquals(false, converters.convert(null, boolean.class));
+ }
+
+ /** What cannot be converted says so, rather than returning null. */
+ @Test
+ public void testUnsupported() {
+ assertFalse(converters.supports(new Object(), Season.class));
+ assertThrows(ConversionException.class, () -> converters.convert(
+ new Object(), Season.class));
+ }
+
+ /** An explicit list of converters is the form for a test or an embedder. */
+ @Test
+ public void testExplicitConverters() {
+ final Converters only = new Converters(List.of( //
+ Converter.of(String.class, Season.class, s -> Season.SUMMER)));
+ assertEquals(Season.SUMMER, only.convert("anything", Season.class));
+ assertTrue(only.tryConvert(42, String.class).isEmpty(),
+ "nothing else should be available");
+ }
+
+ // -- Test fixtures --
+
+ public enum Season {
+ WINTER, SPRING, SUMMER, AUTUMN
+ }
+
+ public static class Wrapper {
+
+ public final String value;
+
+ public Wrapper(final String value) {
+ this.value = value;
+ }
+ }
+}
diff --git a/scijava-discovery-test/.gitignore b/discovery-test/.gitignore
similarity index 100%
rename from scijava-discovery-test/.gitignore
rename to discovery-test/.gitignore
diff --git a/scijava-discovery-test/LICENSE.txt b/discovery-test/LICENSE.txt
similarity index 100%
rename from scijava-discovery-test/LICENSE.txt
rename to discovery-test/LICENSE.txt
diff --git a/discovery-test/README.md b/discovery-test/README.md
new file mode 100644
index 000000000..1b3e5db20
--- /dev/null
+++ b/discovery-test/README.md
@@ -0,0 +1,12 @@
+# SciJava Discovery Test: Integration tests for SciJava Discovery
+
+This module exercises `Discoverer` against Java's [`ServiceLoader`](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/ServiceLoader.html) across real module boundaries. It is not intended to be used as a dependency.
+
+Its `module-info.java` `provides` sample implementations of `org.scijava.ops.spi.Op` and `org.scijava.ops.spi.OpCollection`, and its tests check that a `Discoverer` built with `Discoverer.usingProviders(c -> ServiceLoader.load(c).stream())` finds them.
+
+Note that `ServiceLoader` can only discover implementations of interfaces that the calling module declares it `uses`. This module therefore declares:
+
+```java
+uses org.scijava.ops.spi.Op;
+uses org.scijava.ops.spi.OpCollection;
+```
diff --git a/scijava-discovery-test/pom.xml b/discovery-test/pom.xml
similarity index 98%
rename from scijava-discovery-test/pom.xml
rename to discovery-test/pom.xml
index ae8daba40..1dabf7ccb 100644
--- a/scijava-discovery-test/pom.xml
+++ b/discovery-test/pom.xml
@@ -92,6 +92,7 @@
1.0.1-SNAPSHOT
1.0.1-SNAPSHOT
+ 1.0.1-SNAPSHOT
diff --git a/scijava-discovery-test/src/main/java/module-info.java b/discovery-test/src/main/java/module-info.java
similarity index 100%
rename from scijava-discovery-test/src/main/java/module-info.java
rename to discovery-test/src/main/java/module-info.java
diff --git a/scijava-discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java b/discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java
similarity index 87%
rename from scijava-discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java
rename to discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java
index ea6478c2a..8708c9c08 100644
--- a/scijava-discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java
+++ b/discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java
@@ -39,6 +39,16 @@ public class ServiceBasedAdder implements BiFunction,
Op
{
+ /**
+ * Whether this class has ever been constructed, so that tests can assert
+ * that discovery does not instantiate what it discovers.
+ */
+ public static volatile boolean constructed = false;
+
+ public ServiceBasedAdder() {
+ constructed = true;
+ }
+
@Override
public Double apply(Number t, Number u) {
return t.doubleValue() + u.doubleValue();
diff --git a/scijava-discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedMultipliers.java b/discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedMultipliers.java
similarity index 100%
rename from scijava-discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedMultipliers.java
rename to discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedMultipliers.java
diff --git a/scijava-discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.Op b/discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.Op
similarity index 100%
rename from scijava-discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.Op
rename to discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.Op
diff --git a/scijava-discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection b/discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection
similarity index 100%
rename from scijava-discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection
rename to discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection
diff --git a/discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java b/discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java
new file mode 100644
index 000000000..afb0ef9fd
--- /dev/null
+++ b/discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java
@@ -0,0 +1,83 @@
+/*-
+ * #%L
+ * Integration tests for the scijava-discovery library.
+ * %%
+ * Copyright (C) 2021 - 2025 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.discovery.test;
+
+import java.util.List;
+import java.util.ServiceLoader;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.scijava.discovery.Discoverer;
+import org.scijava.discovery.Discovery;
+import org.scijava.ops.spi.Op;
+
+/**
+ * Tests that {@link Discovery} reports what is available without constructing
+ * it - the property that lets metadata-only consumers, such as menu building,
+ * avoid paying for objects they will never use.
+ *
+ * @author Curtis Rueden
+ */
+public class DiscoveryLazinessTest {
+
+ /**
+ * A {@link Discoverer} backed by {@link ServiceLoader} must report the
+ * implementation class name without constructing the implementation.
+ */
+ @Test
+ public void testServiceLoaderDiscoveryIsLazy() {
+ // Note: Other tests in this JVM may already have constructed one.
+ ServiceBasedAdder.constructed = false;
+
+ final Discoverer d = Discoverer.usingProviders( //
+ c -> ServiceLoader.load(c).stream());
+
+ final List> discoveries = d.discover(Op.class);
+ Assertions.assertEquals(1, discoveries.size());
+
+ final Discovery discovery = discoveries.get(0);
+ Assertions.assertEquals(ServiceBasedAdder.class.getName(), //
+ discovery.implClassName());
+ Assertions.assertFalse(ServiceBasedAdder.constructed,
+ "Discovery must not construct the implementation");
+
+ // Only now should the object come into existence.
+ Assertions.assertNotNull(discovery.get());
+ Assertions.assertTrue(ServiceBasedAdder.constructed);
+ }
+
+ /** Discoveries of an unknown type yield nothing rather than failing. */
+ @Test
+ public void testUnknownTypeYieldsNothing() {
+ final Discoverer d = Discoverer.usingProviders( //
+ c -> ServiceLoader.load(c).stream());
+ Assertions.assertTrue(d.discover(Runnable.class).isEmpty());
+ }
+}
diff --git a/scijava-discovery-test/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java b/discovery-test/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java
similarity index 93%
rename from scijava-discovery-test/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java
rename to discovery-test/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java
index 4451a4582..4a12c1ebd 100644
--- a/scijava-discovery-test/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java
+++ b/discovery-test/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java
@@ -43,7 +43,7 @@ public class ServiceLoaderDiscovererTest {
@Test
public void testServiceLoaderWithBoth() {
- Discoverer d = Discoverer.using(ServiceLoader::load);
+ Discoverer d = Discoverer.usingProviders(c -> ServiceLoader.load(c).stream());
assertDiscoveryRequirements(d, OpCollection.class,
ServiceBasedMultipliers.class);
assertDiscoveryRequirements(d, Op.class, ServiceBasedAdder.class);
@@ -53,7 +53,7 @@ public void testServiceLoaderWithBoth() {
private static