This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new a3e5d09fb2 TIKA-4918 - clean up pf4j loading (#3226)
a3e5d09fb2 is described below

commit a3e5d09fb2927d779448e5d58aa9983693bdd9fb
Author: Tim Allison <[email protected]>
AuthorDate: Tue Oct 6 12:37:43 2026 -0400

    TIKA-4918 - clean up pf4j loading (#3226)
---
 CHANGES.txt                                        |  9 +++
 .../ROOT/pages/pipes/plugins/writing-a-plugin.adoc |  6 ++
 .../ROOT/pages/using-tika/java-api/pipes.adoc      | 49 ++++++++-------
 tika-e2e-tests/tika-grpc/pom.xml                   | 33 ++++++++++
 .../org/apache/tika/pipes/ExternalTestBase.java    | 16 ++++-
 .../tika/pipes/filesystem/HandlerTypeTest.java     |  2 +-
 .../tika/pipes/ignite/IgniteConfigStoreTest.java   |  2 +-
 tika-grpc/pom.xml                                  |  5 --
 ...PipesBiDirectionalStreamingIntegrationTest.java |  2 +
 .../tika/pipes/core/AbstractComponentManager.java  | 23 +++----
 .../tika/pipes/core/config/ConfigMerger.java       | 15 +++++
 .../tika/pipes/core/config/ConfigOverrides.java    | 17 ++++++
 .../tika/pipes/core/config/DefaultPluginsDir.java  | 43 +++++++++----
 .../tika/pipes/core/config/ConfigMergerTest.java   | 20 +++++++
 .../pipes/core/config/DefaultPluginsDirTest.java   |  9 +++
 tika-pipes/tika-pipes-fork-parser/pom.xml          |  2 +
 .../apache/tika/pipes/fork/PipesForkParser.java    | 35 ++++++++---
 .../tika/pipes/fork/ClasspathPluginsTest.java      | 70 ++++++++++++++++++++++
 .../tika/pipes/fork/PipesForkParserTest.java       | 30 ++++++++++
 .../test/resources/configs/tika-config-http.json   |  2 +-
 .../apache/tika/plugins/PluginComponentLoader.java | 23 +++----
 .../org/apache/tika/plugins/TikaPluginManager.java | 67 +++++++++++++++++----
 .../apache/tika/plugins/TestExtensionFactory.java  | 30 ++++++++++
 .../java/org/apache/tika/plugins/TestPlugin.java   | 23 +++++++
 .../apache/tika/plugins/TikaPluginManagerTest.java | 22 +++++++
 25 files changed, 463 insertions(+), 92 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 8cf92ee9a8..b08bbc4361 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,14 @@
 Release 4.2.0 - unreleased
 
+   * Pipes plugins load only from the zips in plugin-roots; a plugin jar on 
the classpath
+     needs -Dtika.plugins.classpath=true, which PipesForkParser sets on its 
fork when it
+     has no plugins directory. Dev mode loads an exploded target/classes 
(TIKA-4918).
+
+   * GeoGebraParser reads structure.json and inline text content with 
jackson-core's
+     streaming parser; tika-parser-miscoffice-module and 
tika-parser-cad-module no
+     longer depend on jackson-databind, and OSGi deployments of 
tika-bundle-standard
+     need only jackson-core (TIKA-4956).
+     
    * Forked pipes servers get their config and auth token over stdin; nothing 
is written
      to the temp dir. Server-manager and PipesServer signatures change 
accordingly (TIKA-4952).
      
diff --git a/docs/modules/ROOT/pages/pipes/plugins/writing-a-plugin.adoc 
b/docs/modules/ROOT/pages/pipes/plugins/writing-a-plugin.adoc
index aeca158585..ce36b0c7ec 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/writing-a-plugin.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/writing-a-plugin.adoc
@@ -316,6 +316,12 @@ all fill it in when you do not: a `plugins` directory 
beside the running jar, el
 working directory. Loading through `TikaPluginManager` directly with no 
`plugin-roots` fails with
 `plugin-roots must be specified`.
 
+Zips in `plugin-roots` are the only source of plugins. A plugin jar on the 
application classpath
+is not scanned, even though it carries the same `META-INF/extensions.idx`, 
unless the JVM runs
+with `-Dtika.plugins.classpath=true`. That switch exists so `PipesForkParser` 
can embed without a
+plugins directory; it drops the classloader isolation for every plugin found 
that way, and a
+process started with it owns whatever its classpath does.
+
 === tika-config.json
 
 The JSON never names a class. It names your factory's `getName()`.
diff --git a/docs/modules/ROOT/pages/using-tika/java-api/pipes.adoc 
b/docs/modules/ROOT/pages/using-tika/java-api/pipes.adoc
index b7516d78ce..d0e30f1d6a 100644
--- a/docs/modules/ROOT/pages/using-tika/java-api/pipes.adoc
+++ b/docs/modules/ROOT/pages/using-tika/java-api/pipes.adoc
@@ -50,31 +50,36 @@ API; use the classes above.
 
 == Where plugins come from
 
-Fetchers, emitters, iterators and reporters are 
xref:pipes/plugins/index.adoc[PF4J plugins]. An
-embedding application can supply them two ways:
-
-* **As Maven dependencies.** A plugin jar on the application's classpath is 
discovered through
-  its `META-INF/extensions.idx`, and the forked JVM inherits the parent's 
classpath.
-  `tika-pipes-fork-parser` already depends on `tika-pipes-file-system`, so 
`PipesForkParser`
-  works with no further setup. For another source or destination, add that 
plugin's jar
-  (`tika-pipes-s3`, `tika-pipes-opensearch`, ...) as an ordinary dependency.
-* **As zips in a plugins directory.** This is the distribution layout: 
`tika-app` and
-  `tika-server` ship a `plugins/` directory of plugin zips, and `plugin-roots` 
points at it.
-  Get the zips from the distribution zips on the 
https://tika.apache.org/download.html[download
-  page]; they are not on Maven Central. A zip plugin takes precedence over a 
classpath plugin of
-  the same name.
-
-Either way, `plugin-roots` must be present in the JSON config for 
`PipesParser` and
-`AsyncProcessor`, even if the directory is empty. `PipesForkParser` fills it 
in for you (a
-`plugins` directory beside the jar, else one in the working directory) and 
logs a warning when it
-finds none; with the plugins on the classpath the warning is harmless.
-`PipesForkParserConfig.setPluginsDir(Path)` sets it explicitly.
+Fetchers, emitters, iterators and reporters are 
xref:pipes/plugins/index.adoc[PF4J plugins], and
+Tika loads them from **zip files in the directories named by `plugin-roots`**. 
A plugin jar on
+your application's classpath is not scanned; the only thing a Maven dependency 
on a plugin gives
+you is its classes to call directly. The zips are distributed from the
+https://tika.apache.org/download.html[Apache download page], and the 
`tika-app` and `tika-server`
+distributions carry them in `plugins/`.
+
+----
+/opt/tika/plugins/
+  tika-pipes-file-system-{tika-version}.zip
+  tika-pipes-s3-{tika-version}.zip
+----
+
+`PipesParser` and `AsyncProcessor` require `plugin-roots` in the JSON config. 
`PipesForkParser`
+resolves it: an explicit `PipesForkParserConfig.setPluginsDir(Path)`, else a 
`plugins` directory
+beside the jar or in the working directory. When there is neither, which is 
the plain Maven case,
+it starts its fork with `-Dtika.plugins.classpath=true` so the file-system 
plugin it needs comes
+from the classpath, where the `tika-pipes-fork-parser` dependency put it.
+
+That property is the one deliberate exception. Set on any Tika process, it 
makes pf4j also
+accept factories found through `META-INF/extensions.idx` on the classpath, 
running in the
+application classloader with none of the plugin isolation; a zip in 
`plugin-roots` still wins over
+a classpath copy of the same plugin. It is there for embedding, not for 
installing plugins, and
+what happens on a classpath assembled that way is yours to own.
 
 == Dependencies
 
 [source,xml,subs=attributes+]
 ----
-<!-- PipesForkParser; pulls in tika-pipes-core, the file-system plugin and the 
standard parsers -->
+<!-- PipesForkParser; pulls in tika-pipes-core and the standard parsers -->
 <dependency>
     <groupId>org.apache.tika</groupId>
     <artifactId>tika-pipes-fork-parser</artifactId>
@@ -95,8 +100,8 @@ finds none; with the plugins on the classpath the warning is 
harmless.
 </dependency>
 ----
 
-The forked JVM is launched with the parent's classpath, so the parsers and 
plugins your application
-depends on are the ones the fork uses.
+The forked JVM is launched with the parent's classpath, so the parsers your 
application depends on
+are the parsers the fork uses.
 
 == PipesForkParser: one document, result returned
 
diff --git a/tika-e2e-tests/tika-grpc/pom.xml b/tika-e2e-tests/tika-grpc/pom.xml
index e4c91463e4..9805ec6a6a 100644
--- a/tika-e2e-tests/tika-grpc/pom.xml
+++ b/tika-e2e-tests/tika-grpc/pom.xml
@@ -63,6 +63,14 @@
             <artifactId>tika-pipes-core</artifactId>
             <version>${tika.version}</version>
         </dependency>
+        <!-- The local-server tests hand tika-grpc this zip through - 
-plugin-roots. -->
+        <dependency>
+            <groupId>org.apache.tika</groupId>
+            <artifactId>tika-pipes-file-system</artifactId>
+            <version>${tika.version}</version>
+            <scope>test</scope>
+            <type>zip</type>
+        </dependency>
 
         <!-- Jackson for JSON -->
         <dependency>
@@ -123,6 +131,31 @@
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-compiler-plugin</artifactId>
             </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-dependency-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>copy-plugins</id>
+                        <phase>process-test-resources</phase>
+                        <goals>
+                            <goal>copy</goal>
+                        </goals>
+                        <configuration>
+                            
<outputDirectory>${project.build.directory}/plugins</outputDirectory>
+                            <artifactItems>
+                                <artifactItem>
+                                    <groupId>org.apache.tika</groupId>
+                                    
<artifactId>tika-pipes-file-system</artifactId>
+                                    <version>${tika.version}</version>
+                                    <type>zip</type>
+                                    <overWrite>true</overWrite>
+                                </artifactItem>
+                            </artifactItems>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-surefire-plugin</artifactId>
diff --git 
a/tika-e2e-tests/tika-grpc/src/test/java/org/apache/tika/pipes/ExternalTestBase.java
 
b/tika-e2e-tests/tika-grpc/src/test/java/org/apache/tika/pipes/ExternalTestBase.java
index 8985c1d00b..5ebe3d569d 100644
--- 
a/tika-e2e-tests/tika-grpc/src/test/java/org/apache/tika/pipes/ExternalTestBase.java
+++ 
b/tika-e2e-tests/tika-grpc/src/test/java/org/apache/tika/pipes/ExternalTestBase.java
@@ -88,11 +88,25 @@ public abstract class ExternalTestBase {
         }
     }
     
+    /**
+     * The checked-in configs name the Docker image's plugins dir; the local 
server gets a copy
+     * pointed at the zip this module copies into target/plugins.
+     */
+    public static Path localConfig(String name) throws IOException {
+        Path pluginsDir = Path.of("target/plugins").toAbsolutePath();
+        String json = Files.readString(Path.of("src/test/resources", name));
+        String local = json.replace("/var/cache/tika/plugins",
+                pluginsDir.toString().replace("\\", "\\\\"));
+        Path out = Path.of("target", name).toAbsolutePath();
+        Files.writeString(out, local);
+        return out;
+    }
+
     private static void startLocalGrpcServer() throws Exception {
         LOG.info("Starting local tika-grpc server using Maven exec");
         
         Path tikaGrpcDir = findTikaGrpcDirectory();
-        Path configFile = 
Path.of("src/test/resources/tika-config.json").toAbsolutePath();
+        Path configFile = localConfig("tika-config.json");
         
         if (!Files.exists(configFile)) {
             throw new IllegalStateException("Config file not found: " + 
configFile);
diff --git 
a/tika-e2e-tests/tika-grpc/src/test/java/org/apache/tika/pipes/filesystem/HandlerTypeTest.java
 
b/tika-e2e-tests/tika-grpc/src/test/java/org/apache/tika/pipes/filesystem/HandlerTypeTest.java
index ece3f35bdd..d78fe6ab0a 100644
--- 
a/tika-e2e-tests/tika-grpc/src/test/java/org/apache/tika/pipes/filesystem/HandlerTypeTest.java
+++ 
b/tika-e2e-tests/tika-grpc/src/test/java/org/apache/tika/pipes/filesystem/HandlerTypeTest.java
@@ -119,7 +119,7 @@ class HandlerTypeTest {
         }
 
         Path tikaGrpcDir = tikaRootDir.resolve("tika-grpc");
-        Path configFile = 
Path.of("src/test/resources/tika-config-ignite-handlertype.json").toAbsolutePath();
+        Path configFile = 
ExternalTestBase.localConfig("tika-config-ignite-handlertype.json");
         if (!Files.exists(configFile)) {
             throw new IllegalStateException("Config file not found: " + 
configFile);
         }
diff --git 
a/tika-e2e-tests/tika-grpc/src/test/java/org/apache/tika/pipes/ignite/IgniteConfigStoreTest.java
 
b/tika-e2e-tests/tika-grpc/src/test/java/org/apache/tika/pipes/ignite/IgniteConfigStoreTest.java
index 43342dbfab..d8c5101089 100644
--- 
a/tika-e2e-tests/tika-grpc/src/test/java/org/apache/tika/pipes/ignite/IgniteConfigStoreTest.java
+++ 
b/tika-e2e-tests/tika-grpc/src/test/java/org/apache/tika/pipes/ignite/IgniteConfigStoreTest.java
@@ -131,7 +131,7 @@ class IgniteConfigStoreTest {
         }
         
         String configFileName = "tika-config-ignite-local.json";
-        Path configFile = Path.of("src/test/resources/" + 
configFileName).toAbsolutePath();
+        Path configFile = ExternalTestBase.localConfig(configFileName);
         
         if (!Files.exists(configFile)) {
             throw new IllegalStateException("Config file not found: " + 
configFile);
diff --git a/tika-grpc/pom.xml b/tika-grpc/pom.xml
index 988723ec22..ce2fa7c438 100644
--- a/tika-grpc/pom.xml
+++ b/tika-grpc/pom.xml
@@ -211,11 +211,6 @@
       <artifactId>tika-pipes-http</artifactId>
       <version>${project.version}</version>
     </dependency>
-    <dependency>
-      <groupId>org.apache.tika</groupId>
-      <artifactId>tika-pipes-file-system</artifactId>
-      <version>${project.version}</version>
-    </dependency>
     <dependency>
       <groupId>org.apache.tika</groupId>
       <artifactId>tika-pipes-file-system</artifactId>
diff --git 
a/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java
 
b/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java
index 6cc673de4a..a2dfb97679 100644
--- 
a/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java
+++ 
b/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java
@@ -103,6 +103,8 @@ class PipesBiDirectionalStreamingIntegrationTest {
         // Parse it as JSON to inject the correct javaPath
         @SuppressWarnings("unchecked")
         Map<String, Object> configMap = OBJECT_MAPPER.readValue(configContent, 
Map.class);
+        configMap.put("plugin-roots",
+                
Paths.get("target").toAbsolutePath().resolve("plugins").toString());
 
         // Get or create the pipes section
         @SuppressWarnings("unchecked")
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/AbstractComponentManager.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/AbstractComponentManager.java
index 2d27dd3a7a..e93b49a7c9 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/AbstractComponentManager.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/AbstractComponentManager.java
@@ -207,26 +207,19 @@ public abstract class AbstractComponentManager<T extends 
TikaExtension,
 
         Map<String, F> factories = new HashMap<>();
         for (F factory : pluginManager.getExtensions(getFactoryClass())) {
-            String name = factory.getName();
-            ClassLoader cl = factory.getClass().getClassLoader();
-            boolean isFromPlugin = cl instanceof org.pf4j.PluginClassLoader;
-
-            F existing = factories.get(name);
-            if (existing != null) {
-                boolean existingIsFromPlugin = 
existing.getClass().getClassLoader()
-                        instanceof org.pf4j.PluginClassLoader;
-                if (isFromPlugin && !existingIsFromPlugin) {
-                    // Replace classpath version with plugin version
-                    factories.put(name, factory);
-                }
-                // Otherwise skip duplicate (keep existing)
-                continue;
+            F existing = factories.get(factory.getName());
+            // a plugin wins over the same factory found on the classpath 
(tika.plugins.classpath)
+            if (existing == null || (isFromPlugin(factory) && 
!isFromPlugin(existing))) {
+                factories.put(factory.getName(), factory);
             }
-            factories.put(name, factory);
         }
         return factories;
     }
 
+    private static boolean isFromPlugin(Object factory) {
+        return factory.getClass().getClassLoader() instanceof 
org.pf4j.PluginClassLoader;
+    }
+
     private static String toJsonString(final JsonNode node) throws 
TikaConfigException {
         try {
             return 
TikaObjectMapperFactory.getMapper().writeValueAsString(node);
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigMerger.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigMerger.java
index 69a85940a2..458448956b 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigMerger.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigMerger.java
@@ -38,6 +38,7 @@ import org.apache.tika.config.loader.TikaJsonConfig;
 import org.apache.tika.config.loader.TikaObjectMapperFactory;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.pipes.api.ComponentIds;
+import org.apache.tika.plugins.TikaPluginManager;
 
 /**
  * Utility for merging configuration overrides with existing Tika JSON 
configuration.
@@ -210,6 +211,20 @@ public class ConfigMerger {
             LOG.debug("Applied pipes config: numClients={}", 
pc.getNumClients());
         }
 
+        // Appended, not set: the user config's forkedJvmArgs must survive the 
opt-in
+        if (overrides.isClasspathPlugins()) {
+            ObjectNode pipesNode = getOrCreateObject(mapper, root, "pipes");
+            ArrayNode argsArray = pipesNode.withArray("forkedJvmArgs");
+            String arg = "-D" + TikaPluginManager.CLASSPATH_PLUGINS_PROPERTY + 
"=true";
+            boolean present = false;
+            for (JsonNode existing : argsArray) {
+                present |= arg.equals(existing.asText());
+            }
+            if (!present) {
+                argsArray.add(arg);
+            }
+        }
+
         // Apply emit strategy
         if (overrides.getEmitStrategy() != null) {
             ObjectNode pipesNode = getOrCreateObject(mapper, root, "pipes");
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigOverrides.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigOverrides.java
index 779217b29a..40caad25f9 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigOverrides.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/ConfigOverrides.java
@@ -51,6 +51,7 @@ public class ConfigOverrides {
     private final String pluginRoots;
     private final EmitStrategy emitStrategy;
     private final TimeoutLimits timeoutLimits;
+    private final boolean classpathPlugins;
 
     private ConfigOverrides(Builder builder) {
         this.fetchers = Collections.unmodifiableList(new 
ArrayList<>(builder.fetchers));
@@ -59,6 +60,7 @@ public class ConfigOverrides {
         this.pluginRoots = builder.pluginRoots;
         this.emitStrategy = builder.emitStrategy;
         this.timeoutLimits = builder.timeoutLimits;
+        this.classpathPlugins = builder.classpathPlugins;
     }
 
     public static Builder builder() {
@@ -89,6 +91,11 @@ public class ConfigOverrides {
         return timeoutLimits;
     }
 
+    /** Whether the fork is told to load plugins from its classpath; see 
ConfigMerger. */
+    public boolean isClasspathPlugins() {
+        return classpathPlugins;
+    }
+
     /**
      * Represents a fetcher configuration override.
      */
@@ -207,10 +214,20 @@ public class ConfigOverrides {
         private String pluginRoots;
         private EmitStrategy emitStrategy;
         private TimeoutLimits timeoutLimits;
+        private boolean classpathPlugins;
 
         private Builder() {
         }
 
+        /**
+         * Appends {@code -Dtika.plugins.classpath=true} to the fork's JVM 
args, keeping whatever
+         * forkedJvmArgs the existing config or {@link #setPipesConfig} 
already carry.
+         */
+        public Builder setClasspathPlugins(boolean classpathPlugins) {
+            this.classpathPlugins = classpathPlugins;
+            return this;
+        }
+
         /**
          * Add a fetcher configuration.
          *
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/DefaultPluginsDir.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/DefaultPluginsDir.java
index 8e08f07f18..bc4400541a 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/DefaultPluginsDir.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/config/DefaultPluginsDir.java
@@ -18,6 +18,7 @@ package org.apache.tika.pipes.core.config;
 
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Optional;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -61,15 +62,26 @@ public final class DefaultPluginsDir {
      * none exists yet
      */
     public static String resolve(Class<?> anchor) {
-        Path codeSourceDir = null;
+        return resolve(codeSourceDir(anchor), Path.of("")).toString();
+    }
+
+    /**
+     * The same probe as {@link #resolve(Class)}, but empty rather than a 
warning and a
+     * fallback path when no {@code plugins} directory exists.
+     */
+    public static Optional<Path> find(Class<?> anchor) {
+        return find(codeSourceDir(anchor), Path.of(""));
+    }
+
+    private static Path codeSourceDir(Class<?> anchor) {
         try {
-            codeSourceDir = 
Path.of(anchor.getProtectionDomain().getCodeSource().getLocation()
+            return 
Path.of(anchor.getProtectionDomain().getCodeSource().getLocation()
                     .toURI()).getParent();
         } catch (Exception e) {
             //no code source (e.g. a repacked classloader): probe the working
             //directory only
+            return null;
         }
-        return resolve(codeSourceDir, Path.of("")).toString();
     }
 
     /**
@@ -80,24 +92,33 @@ public final class DefaultPluginsDir {
      * @return the absolute path of the resolved directory
      */
     public static Path resolve(Path codeSourceDir, Path cwd) {
+        Path fallback = cwd.resolve(PLUGINS_DIR_NAME).toAbsolutePath();
+        return find(codeSourceDir, cwd).orElseGet(() -> {
+            LOG.warn("no plugins directory found in the install layout or at 
{}; "
+                    + "pipes plugins will not load unless plugin-roots is 
configured", fallback);
+            return fallback;
+        });
+    }
+
+    /**
+     * @return the first existing {@code plugins} directory: beside the code 
source, beside its
+     * parent, or in {@code cwd}; empty when there is none
+     */
+    public static Optional<Path> find(Path codeSourceDir, Path cwd) {
         if (codeSourceDir != null) {
             Path nextToJar = codeSourceDir.resolve(PLUGINS_DIR_NAME);
             if (Files.isDirectory(nextToJar)) {
-                return nextToJar.toAbsolutePath();
+                return Optional.of(nextToJar.toAbsolutePath());
             }
             Path parent = codeSourceDir.getParent();
             if (parent != null) {
                 Path nextToParent = parent.resolve(PLUGINS_DIR_NAME);
                 if (Files.isDirectory(nextToParent)) {
-                    return nextToParent.toAbsolutePath();
+                    return Optional.of(nextToParent.toAbsolutePath());
                 }
             }
         }
-        Path fallback = cwd.resolve(PLUGINS_DIR_NAME).toAbsolutePath();
-        if (!Files.isDirectory(fallback)) {
-            LOG.warn("no plugins directory found in the install layout or at 
{}; "
-                    + "pipes plugins will not load unless plugin-roots is 
configured", fallback);
-        }
-        return fallback;
+        Path inCwd = cwd.resolve(PLUGINS_DIR_NAME).toAbsolutePath();
+        return Files.isDirectory(inCwd) ? Optional.of(inCwd) : 
Optional.empty();
     }
 }
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/ConfigMergerTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/ConfigMergerTest.java
index c7f551ed17..9d88fd17c3 100644
--- 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/ConfigMergerTest.java
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/ConfigMergerTest.java
@@ -422,4 +422,24 @@ public class ConfigMergerTest {
 
         Files.deleteIfExists(result.configPath());
     }
+
+    /** The classpath opt-in is appended; the user config's own JVM args 
survive. */
+    @Test
+    public void testClasspathPluginsKeepsUserJvmArgs() throws IOException {
+        Path userConfig = tempDir.resolve("user-config.json");
+        Files.writeString(userConfig, 
"{\"pipes\":{\"forkedJvmArgs\":[\"-Xmx4g\"]}}");
+        ConfigOverrides overrides = ConfigOverrides.builder()
+                .setPipesConfig(1, 100, null)
+                .setClasspathPlugins(true)
+                .build();
+
+        ConfigMerger.MergeResult result = 
ConfigMerger.mergeOrCreate(userConfig, overrides);
+
+        JsonNode args = new 
ObjectMapper().readTree(result.configPath().toFile())
+                .get("pipes").get("forkedJvmArgs");
+        assertEquals(List.of("-Xmx4g", "-Dtika.plugins.classpath=true"),
+                new ObjectMapper().convertValue(args, List.class));
+
+        Files.deleteIfExists(result.configPath());
+    }
 }
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/DefaultPluginsDirTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/DefaultPluginsDirTest.java
index fbe1beee39..a7df4d1a6c 100644
--- 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/DefaultPluginsDirTest.java
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/config/DefaultPluginsDirTest.java
@@ -51,6 +51,15 @@ public class DefaultPluginsDirTest {
                 DefaultPluginsDir.resolve(install.resolve("lib"), cwd));
     }
 
+    @Test
+    public void findIsEmptyWhenNothingExists(@TempDir Path install, @TempDir 
Path cwd)
+            throws Exception {
+        assertTrue(DefaultPluginsDir.find(install.resolve("lib"), 
cwd).isEmpty());
+        Path plugins = Files.createDirectories(cwd.resolve("plugins"));
+        assertEquals(plugins.toAbsolutePath(),
+                DefaultPluginsDir.find(install.resolve("lib"), cwd).get());
+    }
+
     @Test
     public void missingPluginsDirStaysAbsolute(@TempDir Path cwd) {
         //the forked pipes server must not re-resolve the path against its own 
cwd
diff --git a/tika-pipes/tika-pipes-fork-parser/pom.xml 
b/tika-pipes/tika-pipes-fork-parser/pom.xml
index 938a7aae0c..eca9b8f00f 100644
--- a/tika-pipes/tika-pipes-fork-parser/pom.xml
+++ b/tika-pipes/tika-pipes-fork-parser/pom.xml
@@ -48,6 +48,8 @@
       <artifactId>tika-pipes-core</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <!-- On the classpath on purpose: a Maven consumer has no plugins 
directory, and the fork is
+         told to load this one plugin from the classpath 
(tika.plugins.classpath). -->
     <dependency>
       <groupId>${project.groupId}</groupId>
       <artifactId>tika-pipes-file-system</artifactId>
diff --git 
a/tika-pipes/tika-pipes-fork-parser/src/main/java/org/apache/tika/pipes/fork/PipesForkParser.java
 
b/tika-pipes/tika-pipes-fork-parser/src/main/java/org/apache/tika/pipes/fork/PipesForkParser.java
index 9e9e893570..0454973652 100644
--- 
a/tika-pipes/tika-pipes-fork-parser/src/main/java/org/apache/tika/pipes/fork/PipesForkParser.java
+++ 
b/tika-pipes/tika-pipes-fork-parser/src/main/java/org/apache/tika/pipes/fork/PipesForkParser.java
@@ -21,8 +21,12 @@ import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Map;
+import java.util.Optional;
 import java.util.UUID;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import org.apache.tika.config.EmbeddedLimits;
 import org.apache.tika.config.loader.TikaJsonConfig;
 import org.apache.tika.exception.TikaConfigException;
@@ -120,6 +124,8 @@ public class PipesForkParser implements Closeable {
 
     public static final String DEFAULT_FETCHER_NAME = "fs";
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(PipesForkParser.class);
+
     private final PipesForkParserConfig config;
     private final PipesParser pipesParser;
     private final String internalFetcherId;
@@ -417,6 +423,23 @@ public class PipesForkParser implements Closeable {
     private ConfigMerger.MergedConfig createTikaConfig() throws IOException {
         PipesConfig pc = config.getPipesConfig();
 
+        String pluginRoots;
+        boolean classpathPlugins = false;
+        if (config.getPluginsDir() != null) {
+            pluginRoots = config.getPluginsDir().toAbsolutePath().toString();
+        } else {
+            Optional<Path> found = 
DefaultPluginsDir.find(PipesForkParser.class);
+            if (found.isPresent()) {
+                pluginRoots = found.get().toString();
+            } else {
+                // No install layout, so this is a Maven consumer: the 
file-system plugin this
+                // parser needs is on the classpath, and only the fork is told 
to look there.
+                pluginRoots = 
Path.of(DefaultPluginsDir.PLUGINS_DIR_NAME).toAbsolutePath().toString();
+                classpathPlugins = true;
+                LOG.info("no plugins directory found; the fork loads plugins 
from its classpath");
+            }
+        }
+
         // Build configuration overrides
         ConfigOverrides.Builder builder = ConfigOverrides.builder()
                 // Add internal fetcher with UUID-based name to avoid conflicts
@@ -431,22 +454,16 @@ public class PipesForkParser implements Closeable {
                         pc.getForkedJvmArgs(),
                         pc.getSocketTimeoutMillis(),
                         pc.getJavaPath())
+                .setClasspathPlugins(classpathPlugins)
                 // Use PASSBACK_ALL strategy - results returned through socket
-                .setEmitStrategy(EmitStrategy.PASSBACK_ALL);
+                .setEmitStrategy(EmitStrategy.PASSBACK_ALL)
+                .setPluginRoots(pluginRoots);
 
         // Set timeout limits if configured
         if (config.getTimeoutLimits() != null) {
             builder.setTimeoutLimits(config.getTimeoutLimits());
         }
 
-        // plugin-roots is mandatory downstream (TikaPluginManager.load throws 
without it), so an
-        // unset pluginsDir has to resolve to something rather than fail 
construction.
-        if (config.getPluginsDir() != null) {
-            
builder.setPluginRoots(config.getPluginsDir().toAbsolutePath().toString());
-        } else {
-            
builder.setPluginRoots(DefaultPluginsDir.resolve(PipesForkParser.class));
-        }
-
         ConfigOverrides overrides = builder.build();
 
         // Merge with user config if provided, otherwise create new
diff --git 
a/tika-pipes/tika-pipes-fork-parser/src/test/java/org/apache/tika/pipes/fork/ClasspathPluginsTest.java
 
b/tika-pipes/tika-pipes-fork-parser/src/test/java/org/apache/tika/pipes/fork/ClasspathPluginsTest.java
new file mode 100644
index 0000000000..50ea30a509
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-fork-parser/src/test/java/org/apache/tika/pipes/fork/ClasspathPluginsTest.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.pipes.fork;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.pf4j.DefaultPluginManager;
+
+import org.apache.tika.pipes.fetcher.fs.FileSystemFetcherFactory;
+import org.apache.tika.plugins.TikaExtensionFactory;
+import org.apache.tika.plugins.TikaPluginManager;
+
+/**
+ * The file-system plugin jar is a compile dependency here, so its 
extensions.idx sits on the
+ * application classpath exactly the way a Maven consumer of PipesForkParser 
sees it.
+ */
+public class ClasspathPluginsTest {
+
+    @Test
+    public void classpathExtensionsAreNotDiscovered(@TempDir Path emptyRoot) 
throws Exception {
+        DefaultPluginManager stock = new DefaultPluginManager(emptyRoot);
+        stock.loadPlugins();
+        stock.startPlugins();
+        assertFalse(stock.getExtensions(TikaExtensionFactory.class).isEmpty(),
+                "pf4j's stock manager sees the index; the test setup is wrong 
otherwise");
+
+        TikaPluginManager manager = new 
TikaPluginManager(Collections.singletonList(emptyRoot));
+        manager.loadPlugins();
+        manager.startPlugins();
+        assertTrue(manager.getExtensions(TikaExtensionFactory.class).isEmpty(),
+                "a factory on the application classpath must not be 
discovered");
+    }
+
+    @Test
+    public void classpathExtensionsDiscoveredWhenOptedIn(@TempDir Path 
emptyRoot) throws Exception {
+        System.setProperty(TikaPluginManager.CLASSPATH_PLUGINS_PROPERTY, 
"true");
+        try {
+            TikaPluginManager manager =
+                    new 
TikaPluginManager(Collections.singletonList(emptyRoot));
+            manager.loadPlugins();
+            manager.startPlugins();
+            List<TikaExtensionFactory> found = 
manager.getExtensions(TikaExtensionFactory.class);
+            assertTrue(found.stream().anyMatch(f -> f instanceof 
FileSystemFetcherFactory),
+                    "found: " + found);
+        } finally {
+            System.clearProperty(TikaPluginManager.CLASSPATH_PLUGINS_PROPERTY);
+        }
+    }
+}
diff --git 
a/tika-pipes/tika-pipes-fork-parser/src/test/java/org/apache/tika/pipes/fork/PipesForkParserTest.java
 
b/tika-pipes/tika-pipes-fork-parser/src/test/java/org/apache/tika/pipes/fork/PipesForkParserTest.java
index be5b24e640..7e9dd55c0a 100644
--- 
a/tika-pipes/tika-pipes-fork-parser/src/test/java/org/apache/tika/pipes/fork/PipesForkParserTest.java
+++ 
b/tika-pipes/tika-pipes-fork-parser/src/test/java/org/apache/tika/pipes/fork/PipesForkParserTest.java
@@ -44,6 +44,7 @@ import org.apache.tika.metadata.Metadata;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.pipes.api.ParseMode;
 import org.apache.tika.pipes.api.PipesResult;
+import org.apache.tika.pipes.core.PipesException;
 import org.apache.tika.pipes.core.fetcher.InlineBytes;
 import org.apache.tika.sax.BasicContentHandlerFactory;
 
@@ -75,6 +76,35 @@ public class PipesForkParserTest {
         return zipPath;
     }
 
+    @Test
+    public void testClasspathPluginsOnlyWhenOptedIn() throws Exception {
+        Path testFile = tempDir.resolve("test.txt");
+        Files.writeString(testFile, "classpath plugin");
+        Path noPlugins = 
Files.createDirectories(tempDir.resolve("no-plugins"));
+
+        PipesForkParserConfig optedIn = new PipesForkParserConfig()
+                .setPluginsDir(noPlugins)
+                .addJvmArg("-Dtika.plugins.classpath=true")
+                .addJvmArg("-Xmx256m");
+        try (PipesForkParser parser = new PipesForkParser(optedIn)) {
+            PipesForkResult result = parser.parse(testFile);
+            assertTrue(result.isSuccess(), "Status: " + result.getStatus()
+                    + ", message: " + result.getMessage());
+            assertTrue(result.getContent().contains("classpath plugin"));
+        }
+
+        PipesForkParserConfig zipsOnly = new PipesForkParserConfig()
+                .setPluginsDir(noPlugins)
+                .addJvmArg("-Xmx256m");
+        try (PipesForkParser parser = new PipesForkParser(zipsOnly)) {
+            PipesForkResult result = parser.parse(testFile);
+            assertFalse(result.isSuccess(), "the file-system plugin jar is on 
the classpath, "
+                    + "but without the opt-in the fork must not find it: " + 
result.getStatus());
+        } catch (PipesForkParserException | PipesException e) {
+            // fork refused to start without its fetcher: also the right answer
+        }
+    }
+
     @Test
     public void testParseTextFile() throws Exception {
         // Create a simple test file
diff --git 
a/tika-pipes/tika-pipes-plugins/tika-pipes-http/src/test/resources/configs/tika-config-http.json
 
b/tika-pipes/tika-pipes-plugins/tika-pipes-http/src/test/resources/configs/tika-config-http.json
index 7ebb4dd77c..dd8ac7ad06 100644
--- 
a/tika-pipes/tika-pipes-plugins/tika-pipes-http/src/test/resources/configs/tika-config-http.json
+++ 
b/tika-pipes/tika-pipes-plugins/tika-pipes-http/src/test/resources/configs/tika-config-http.json
@@ -17,5 +17,5 @@
       }
     }
   },
-  "plugin-roots": "target/plugins"
+  "plugin-roots": "target/classes"
 }
diff --git 
a/tika-plugins-core/src/main/java/org/apache/tika/plugins/PluginComponentLoader.java
 
b/tika-plugins-core/src/main/java/org/apache/tika/plugins/PluginComponentLoader.java
index 4b6809b6b5..0188495889 100644
--- 
a/tika-plugins-core/src/main/java/org/apache/tika/plugins/PluginComponentLoader.java
+++ 
b/tika-plugins-core/src/main/java/org/apache/tika/plugins/PluginComponentLoader.java
@@ -218,26 +218,19 @@ public class PluginComponentLoader {
 
         Map<String, TikaExtensionFactory<T>> factories = new HashMap<>();
         for (TikaExtensionFactory<T> factory : 
pluginManager.getExtensions(factoryClass)) {
-            String name = factory.getName();
-            ClassLoader cl = factory.getClass().getClassLoader();
-            boolean isFromPlugin = cl instanceof PluginClassLoader;
-
-            TikaExtensionFactory<T> existing = factories.get(name);
-            if (existing != null) {
-                boolean existingIsFromPlugin = 
existing.getClass().getClassLoader()
-                        instanceof PluginClassLoader;
-                if (isFromPlugin && !existingIsFromPlugin) {
-                    // Replace classpath version with plugin version
-                    factories.put(name, factory);
-                }
-                // Otherwise skip duplicate (keep existing)
-                continue;
+            TikaExtensionFactory<T> existing = 
factories.get(factory.getName());
+            // a plugin wins over the same factory found on the classpath 
(tika.plugins.classpath)
+            if (existing == null || (isFromPlugin(factory) && 
!isFromPlugin(existing))) {
+                factories.put(factory.getName(), factory);
             }
-            factories.put(name, factory);
         }
         return factories;
     }
 
+    private static boolean isFromPlugin(Object factory) {
+        return factory.getClass().getClassLoader() instanceof 
PluginClassLoader;
+    }
+
     private static String extractTypeName(JsonNode wrapper, String contextName)
             throws TikaConfigException {
         Iterator<String> fieldNames = wrapper.fieldNames();
diff --git 
a/tika-plugins-core/src/main/java/org/apache/tika/plugins/TikaPluginManager.java
 
b/tika-plugins-core/src/main/java/org/apache/tika/plugins/TikaPluginManager.java
index 423b84b97f..030a3c02fc 100644
--- 
a/tika-plugins-core/src/main/java/org/apache/tika/plugins/TikaPluginManager.java
+++ 
b/tika-plugins-core/src/main/java/org/apache/tika/plugins/TikaPluginManager.java
@@ -20,15 +20,25 @@ import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.DeserializationFeature;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.pf4j.BasePluginLoader;
+import org.pf4j.ClassLoadingStrategy;
 import org.pf4j.DefaultExtensionFinder;
 import org.pf4j.DefaultPluginManager;
 import org.pf4j.ExtensionFinder;
+import org.pf4j.IndexedExtensionFinder;
+import org.pf4j.PluginClassLoader;
+import org.pf4j.PluginClasspath;
+import org.pf4j.PluginDescriptor;
+import org.pf4j.PluginLoader;
 import org.pf4j.RuntimeMode;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -47,6 +57,14 @@ public class TikaPluginManager extends DefaultPluginManager {
     private static final Logger LOG = 
LoggerFactory.getLogger(TikaPluginManager.class);
     
     private static final String DEV_MODE_PROPERTY = "tika.plugin.dev.mode";
+
+    /**
+     * System property: {@code true} also accepts extension factories found 
through
+     * {@code META-INF/extensions.idx} on the application classpath. Those run 
in the application
+     * classloader with none of the plugin isolation; whoever sets it owns the 
consequences.
+     * {@code PipesForkParser} sets it on its fork when there is no plugins 
directory.
+     */
+    public static final String CLASSPATH_PLUGINS_PROPERTY = 
"tika.plugins.classpath";
     private static final String DEV_MODE_ENV = "TIKA_PLUGIN_DEV_MODE";
 
     //we're only using this to convert a single path or a list of paths to a 
list
@@ -231,20 +249,27 @@ public class TikaPluginManager extends 
DefaultPluginManager {
     }
 
     /**
-     * Override to disable classpath scanning for extensions.
-     * By default, PF4J's DefaultExtensionFinder scans both plugins AND the 
classpath:
-     * - LegacyExtensionFinder scans for extensions.idx files (causes errors 
for unpackaged JARs)
-     * - ServiceProviderExtensionFinder scans META-INF/services (finds Lombok 
and other libs)
-     *
-     * We only want to discover extensions from the configured plugin 
directories,
-     * not from the application classpath. The DefaultExtensionFinder without 
any
-     * additional finders will only scan the loaded plugins.
+     * Extensions come from the loaded plugins only, unless {@link 
#CLASSPATH_PLUGINS_PROPERTY}
+     * opts in to pf4j's stock finder, which also reads every {@code 
META-INF/extensions.idx} on
+     * the application classpath.
      */
     @Override
     protected ExtensionFinder createExtensionFinder() {
-        // Return a DefaultExtensionFinder without any classpath-scanning 
finders.
-        // This will only discover extensions within the loaded plugin JARs.
-        return new DefaultExtensionFinder(this);
+        if (Boolean.getBoolean(CLASSPATH_PLUGINS_PROPERTY)) {
+            return new DefaultExtensionFinder(this);
+        }
+        return new DefaultExtensionFinder(this) {
+            {
+                // the constructor registers the classpath-scanning finder; 
replace it
+                finders.clear();
+                finders.add(new IndexedExtensionFinder(TikaPluginManager.this) 
{
+                    @Override
+                    public Map<String, Set<String>> readClasspathStorages() {
+                        return Collections.emptyMap();
+                    }
+                });
+            }
+        };
     }
     
     /**
@@ -267,6 +292,26 @@ public class TikaPluginManager extends 
DefaultPluginManager {
         return super.createPluginRepository();
     }
     
+    /**
+     * In development mode each root is an exploded classes directory (e.g. 
{@code target/classes}),
+     * so the plugin's classpath is the root itself rather than pf4j's Maven 
module layout, and
+     * classes resolve application-first so a test in the same JVM shares the 
plugin's types.
+     */
+    @Override
+    protected PluginLoader createPluginLoader() {
+        if (getRuntimeMode() == RuntimeMode.DEVELOPMENT) {
+            return new BasePluginLoader(this, new 
PluginClasspath().addClassesDirectories("")) {
+                @Override
+                protected PluginClassLoader createPluginClassLoader(Path 
pluginPath,
+                                                                    
PluginDescriptor descriptor) {
+                    return new PluginClassLoader(pluginManager, descriptor,
+                            getClass().getClassLoader(), 
ClassLoadingStrategy.APD);
+                }
+            };
+        }
+        return super.createPluginLoader();
+    }
+
     /**
      * Override to use PropertiesPluginDescriptorFinder in development mode.
      * In development mode, plugins are in target/classes with 
plugin.properties,
diff --git 
a/tika-plugins-core/src/test/java/org/apache/tika/plugins/TestExtensionFactory.java
 
b/tika-plugins-core/src/test/java/org/apache/tika/plugins/TestExtensionFactory.java
new file mode 100644
index 0000000000..8aaaa18ab2
--- /dev/null
+++ 
b/tika-plugins-core/src/test/java/org/apache/tika/plugins/TestExtensionFactory.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.plugins;
+
+public class TestExtensionFactory implements 
TikaExtensionFactory<TikaExtension> {
+
+    @Override
+    public String getName() {
+        return "test-extension-factory";
+    }
+
+    @Override
+    public TikaExtension buildExtension(ExtensionConfig extensionConfig) {
+        return null;
+    }
+}
diff --git 
a/tika-plugins-core/src/test/java/org/apache/tika/plugins/TestPlugin.java 
b/tika-plugins-core/src/test/java/org/apache/tika/plugins/TestPlugin.java
new file mode 100644
index 0000000000..56f8287ec7
--- /dev/null
+++ b/tika-plugins-core/src/test/java/org/apache/tika/plugins/TestPlugin.java
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.plugins;
+
+import org.pf4j.Plugin;
+
+/** Named by plugin.properties in the development-mode test. */
+public class TestPlugin extends Plugin {
+}
diff --git 
a/tika-plugins-core/src/test/java/org/apache/tika/plugins/TikaPluginManagerTest.java
 
b/tika-plugins-core/src/test/java/org/apache/tika/plugins/TikaPluginManagerTest.java
index ca492d0550..58574e23ab 100644
--- 
a/tika-plugins-core/src/test/java/org/apache/tika/plugins/TikaPluginManagerTest.java
+++ 
b/tika-plugins-core/src/test/java/org/apache/tika/plugins/TikaPluginManagerTest.java
@@ -18,6 +18,7 @@ package org.apache.tika.plugins;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Collections;
 
@@ -54,4 +55,25 @@ public class TikaPluginManagerTest {
             System.clearProperty("tika.plugin.dev.mode");
         }
     }
+
+    @Test
+    public void developmentModeLoadsAnExplodedClassesDirectory(@TempDir Path 
classes)
+            throws Exception {
+        // the documented recipe: plugin-roots points at target/classes, no zip
+        Files.writeString(classes.resolve("plugin.properties"),
+                "plugin.id=exploded-test\nplugin.class=" + 
TestPlugin.class.getName()
+                        + "\nplugin.version=1\n");
+        Files.createDirectories(classes.resolve("META-INF"));
+        Files.writeString(classes.resolve("META-INF/extensions.idx"),
+                TestExtensionFactory.class.getName() + "\n");
+        System.setProperty("tika.plugin.dev.mode", "true");
+        try {
+            TikaPluginManager manager = new 
TikaPluginManager(Collections.singletonList(classes));
+            manager.loadPlugins();
+            manager.startPlugins();
+            assertEquals(1, 
manager.getExtensions(TikaExtensionFactory.class).size());
+        } finally {
+            System.clearProperty("tika.plugin.dev.mode");
+        }
+    }
 }

Reply via email to