浏览代码

Settings api for stable plugins (#91467)

Stable plugins do not have a dependency on server, therefore cannot access Settings, NodeSettings or IndexSettings classes. Plugins implementing new stable plugin api will use set of annotations to mark an interface that works a as a facade for settings used by their plugin.
This will allow to validate the values provided against the restrictions defined in the plugin's settings interface

This commit introduces set of annotations in libs/plugin-api that allow to annotate an interface in plugins that will be later injected into a plugin instance. These annotations can possibly be used not only by analysis plugins in the future.
The implementation of the interface generated in server is using dynamic proxy mechanism.

relates #88980
Przemyslaw Gomulka 2 年之前
父节点
当前提交
38f3b634c5
共有 22 个文件被更改,包括 1190 次插入267 次删除
  1. 5 0
      docs/changelog/91467.yaml
  2. 1 0
      libs/plugin-api/src/main/java/module-info.java
  3. 23 0
      libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/Inject.java
  4. 23 0
      libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/settings/AnalysisSettings.java
  5. 31 0
      libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/settings/BooleanSetting.java
  6. 31 0
      libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/settings/IntSetting.java
  7. 26 0
      libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/settings/ListSetting.java
  8. 31 0
      libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/settings/LongSetting.java
  9. 31 0
      libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/settings/StringSetting.java
  10. 76 0
      server/src/main/java/org/elasticsearch/indices/analysis/wrappers/SettingsInvocationHandler.java
  11. 43 4
      server/src/main/java/org/elasticsearch/indices/analysis/wrappers/StableApiWrappers.java
  12. 1 1
      server/src/test/java/org/elasticsearch/action/admin/indices/TransportAnalyzeActionTests.java
  13. 2 261
      server/src/test/java/org/elasticsearch/indices/analysis/AnalysisModuleTests.java
  14. 155 0
      server/src/test/java/org/elasticsearch/indices/analysis/IncorrectSetupStablePluginsTests.java
  15. 232 0
      server/src/test/java/org/elasticsearch/indices/analysis/StableAnalysisPluginsNoSettingsTests.java
  16. 296 0
      server/src/test/java/org/elasticsearch/indices/analysis/StableAnalysisPluginsWithSettingsTests.java
  17. 43 0
      server/src/test/java/org/elasticsearch/indices/analysis/lucene/AppendCharFilter.java
  18. 52 0
      server/src/test/java/org/elasticsearch/indices/analysis/lucene/AppendTokenFilter.java
  19. 27 0
      server/src/test/java/org/elasticsearch/indices/analysis/lucene/ReplaceCharToNumber.java
  20. 31 0
      server/src/test/java/org/elasticsearch/indices/analysis/lucene/SkipTokenFilter.java
  21. 29 0
      server/src/test/java/org/elasticsearch/indices/analysis/lucene/TestTokenizer.java
  22. 1 1
      server/src/test/java/org/elasticsearch/indices/analysis/wrappers/StableApiWrappersTests.java

+ 5 - 0
docs/changelog/91467.yaml

@@ -0,0 +1,5 @@
+pr: 91467
+summary: Settings api for stable plugins
+area: Infra/Plugins
+type: enhancement
+issues: []

+ 1 - 0
libs/plugin-api/src/main/java/module-info.java

@@ -8,4 +8,5 @@
 
 module org.elasticsearch.plugin.api {
     exports org.elasticsearch.plugin.api;
+    exports org.elasticsearch.plugin.api.settings;
 }

+ 23 - 0
libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/Inject.java

@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugin.api;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation used to mark constructor to inject plugin dependencies iee. settings.
+ * A constructor parameter has to be an interface marked with appropriate annotation (i.e AnalysisSetting)
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.CONSTRUCTOR)
+public @interface Inject {
+}

+ 23 - 0
libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/settings/AnalysisSettings.java

@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugin.api.settings;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.TYPE;
+
+/**
+ * An annotation used to mark analysis setting interface
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(value = { TYPE })
+public @interface AnalysisSettings {
+}

+ 31 - 0
libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/settings/BooleanSetting.java

@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugin.api.settings;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation used to mark a setting of type Boolean
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface BooleanSetting {
+    /**
+     * A name of a setting
+     */
+    String path();
+
+    /**
+     * A default value of a boolean setting
+     */
+    boolean defaultValue();
+}

+ 31 - 0
libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/settings/IntSetting.java

@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugin.api.settings;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation used to mark a setting of type integer
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface IntSetting {
+    /**
+     * A name of a setting
+     */
+    String path();
+
+    /**
+     * A default value of an int setting
+     */
+    int defaultValue();
+}

+ 26 - 0
libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/settings/ListSetting.java

@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugin.api.settings;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation used to mark a setting of type list.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface ListSetting {
+    /**
+     * A name of a setting
+     */
+    String path();
+}

+ 31 - 0
libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/settings/LongSetting.java

@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugin.api.settings;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation used to mark a setting of type Long
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface LongSetting {
+    /**
+     * A name of a setting
+     */
+    String path();
+
+    /**
+     * A default value of a long setting
+     */
+    long defaultValue();
+}

+ 31 - 0
libs/plugin-api/src/main/java/org/elasticsearch/plugin/api/settings/StringSetting.java

@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.plugin.api.settings;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation used to mark a setting of type String
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface StringSetting {
+    /**
+     * A name of a setting
+     */
+    String path();
+
+    /**
+     * A default value of a String setting
+     */
+    String defaultValue();
+}

+ 76 - 0
server/src/main/java/org/elasticsearch/indices/analysis/wrappers/SettingsInvocationHandler.java

@@ -0,0 +1,76 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.indices.analysis.wrappers;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.plugin.api.settings.BooleanSetting;
+import org.elasticsearch.plugin.api.settings.IntSetting;
+import org.elasticsearch.plugin.api.settings.ListSetting;
+import org.elasticsearch.plugin.api.settings.LongSetting;
+import org.elasticsearch.plugin.api.settings.StringSetting;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.Collections;
+import java.util.function.Function;
+
+public class SettingsInvocationHandler implements InvocationHandler {
+
+    private static Logger LOGGER = LogManager.getLogger(SettingsInvocationHandler.class);
+    private Settings settings;
+    private Environment environment;
+
+    public SettingsInvocationHandler(Settings settings, Environment environment) {
+        this.settings = settings;
+        this.environment = environment;
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    public static <T> T create(Settings settings, Class<T> parameterType, Environment environment) {
+        return (T) Proxy.newProxyInstance(
+            parameterType.getClassLoader(),
+            new Class[] { parameterType },
+            new SettingsInvocationHandler(settings, environment)
+        );
+    }
+
+    @Override
+    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
+        assert method.getAnnotations().length == 1;
+        Annotation annotation = method.getAnnotations()[0];
+
+        if (annotation instanceof IntSetting setting) {
+            return getValue(Integer::valueOf, setting.path(), setting.defaultValue());
+        } else if (annotation instanceof LongSetting setting) {
+            return getValue(Long::valueOf, setting.path(), setting.defaultValue());
+        } else if (annotation instanceof BooleanSetting setting) {
+            return getValue(Boolean::valueOf, setting.path(), setting.defaultValue());
+        } else if (annotation instanceof StringSetting setting) {
+            return getValue(String::valueOf, setting.path(), setting.defaultValue());
+        } else if (annotation instanceof ListSetting setting) {
+            return settings.getAsList(setting.path(), Collections.emptyList());
+        } else {
+            throw new IllegalArgumentException("Unrecognised annotation " + annotation);
+        }
+    }
+
+    private <T> T getValue(Function<String, T> parser, String path, T defaultValue) {
+        String key = path;
+        if (settings.get(key) != null) {
+            return parser.apply(settings.get(key));
+        }
+        return defaultValue;
+    }
+
+}

+ 43 - 4
server/src/main/java/org/elasticsearch/indices/analysis/wrappers/StableApiWrappers.java

@@ -15,6 +15,8 @@ import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.env.Environment;
 import org.elasticsearch.index.IndexSettings;
 import org.elasticsearch.indices.analysis.AnalysisModule;
+import org.elasticsearch.plugin.api.Inject;
+import org.elasticsearch.plugin.api.settings.AnalysisSettings;
 import org.elasticsearch.plugins.scanners.PluginInfo;
 import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 
@@ -204,10 +206,47 @@ public class StableApiWrappers {
         Environment environment
     ) {
         try {
-            Constructor<T> constructor = clazz.getConstructor();
-            return constructor.newInstance();
-        } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) {
-            throw new IllegalStateException("cannot create instance of " + clazz, e);
+
+            Constructor<?>[] constructors = clazz.getConstructors();
+            if (constructors.length > 1) {
+                throw new IllegalStateException("Plugin can only have one public constructor.");
+            }
+            Constructor<?> constructor = constructors[0];
+            if (constructor.getParameterCount() == 0) {
+                return (T) constructor.newInstance();
+            } else {
+                Inject inject = constructor.getAnnotation(Inject.class);
+                if (inject != null) {
+                    Class<?>[] parameterTypes = constructor.getParameterTypes();
+                    Object[] parameters = new Object[parameterTypes.length];
+                    for (int i = 0; i < parameterTypes.length; i++) {
+                        Object settings = createSettings(parameterTypes[i], indexSettings, nodeSettings, analysisSettings, environment);
+                        parameters[i] = settings;
+                    }
+                    return (T) constructor.newInstance(parameters);
+                } else {
+                    throw new IllegalStateException("Missing @Inject annotation for constructor with settings.");
+                }
+            }
+
+        } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
+            throw new IllegalStateException("Cannot create instance of " + clazz, e);
+        }
+
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    private static <T> T createSettings(
+        Class<T> settingsClass,
+        IndexSettings indexSettings,
+        Settings nodeSettings,
+        Settings analysisSettings,
+        Environment environment
+    ) {
+        if (settingsClass.getAnnotationsByType(AnalysisSettings.class).length > 0) {
+            return SettingsInvocationHandler.create(analysisSettings, settingsClass, environment);
         }
+
+        throw new IllegalArgumentException("Parameter is not instance of a class annotated with settings annotation.");
     }
 }

+ 1 - 1
server/src/test/java/org/elasticsearch/action/admin/indices/TransportAnalyzeActionTests.java

@@ -32,7 +32,7 @@ import org.elasticsearch.index.analysis.TokenFilterFactory;
 import org.elasticsearch.index.analysis.TokenizerFactory;
 import org.elasticsearch.indices.analysis.AnalysisModule;
 import org.elasticsearch.indices.analysis.AnalysisModule.AnalysisProvider;
-import org.elasticsearch.indices.analysis.AnalysisModuleTests.AppendCharFilter;
+import org.elasticsearch.indices.analysis.lucene.AppendCharFilter;
 import org.elasticsearch.plugins.AnalysisPlugin;
 import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.test.ESTestCase;

+ 2 - 261
server/src/test/java/org/elasticsearch/indices/analysis/AnalysisModuleTests.java

@@ -9,23 +9,14 @@
 package org.elasticsearch.indices.analysis;
 
 import org.apache.lucene.analysis.Analyzer;
-import org.apache.lucene.analysis.CharFilter;
-import org.apache.lucene.analysis.FilteringTokenFilter;
-import org.apache.lucene.analysis.TokenFilter;
-import org.apache.lucene.analysis.TokenStream;
 import org.apache.lucene.analysis.Tokenizer;
-import org.apache.lucene.analysis.charfilter.MappingCharFilter;
-import org.apache.lucene.analysis.charfilter.NormalizeCharMap;
 import org.apache.lucene.analysis.hunspell.Dictionary;
 import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
-import org.apache.lucene.analysis.util.CharTokenizer;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.tests.analysis.MockTokenizer;
 import org.elasticsearch.Version;
 import org.elasticsearch.cluster.metadata.IndexMetadata;
-import org.elasticsearch.common.io.Streams;
 import org.elasticsearch.common.settings.Settings;
-import org.elasticsearch.core.SuppressForbidden;
 import org.elasticsearch.env.Environment;
 import org.elasticsearch.env.TestEnvironment;
 import org.elasticsearch.index.IndexSettings;
@@ -43,12 +34,9 @@ import org.elasticsearch.index.analysis.StopTokenFilterFactory;
 import org.elasticsearch.index.analysis.TokenFilterFactory;
 import org.elasticsearch.index.analysis.TokenizerFactory;
 import org.elasticsearch.indices.analysis.AnalysisModule.AnalysisProvider;
-import org.elasticsearch.plugin.analysis.api.AnalysisMode;
-import org.elasticsearch.plugin.api.NamedComponent;
+import org.elasticsearch.indices.analysis.lucene.AppendCharFilter;
+import org.elasticsearch.indices.analysis.lucene.AppendTokenFilter;
 import org.elasticsearch.plugins.AnalysisPlugin;
-import org.elasticsearch.plugins.scanners.NameToPluginInfo;
-import org.elasticsearch.plugins.scanners.NamedComponentReader;
-import org.elasticsearch.plugins.scanners.PluginInfo;
 import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.IndexSettingsModule;
@@ -59,9 +47,6 @@ import org.hamcrest.MatcherAssert;
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.Reader;
-import java.io.StringReader;
-import java.io.UncheckedIOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -70,7 +55,6 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
-import static java.util.Collections.emptyList;
 import static java.util.Collections.singletonList;
 import static java.util.Collections.singletonMap;
 import static org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents;
@@ -476,247 +460,4 @@ public class AnalysisModuleTests extends ESTestCase {
         assertSame(dictionary, module.getHunspellService().getDictionary("foo"));
     }
 
-    @NamedComponent("stableCharFilterFactory")
-    public static class TestCharFilterFactory implements org.elasticsearch.plugin.analysis.api.CharFilterFactory {
-        @SuppressForbidden(reason = "need a public constructor")
-        public TestCharFilterFactory() {}
-
-        @Override
-        public Reader create(Reader reader) {
-            return new ReplaceHash(reader);
-        }
-
-        @Override
-        public Reader normalize(Reader reader) {
-            return new ReplaceHash(reader);
-        }
-
-    }
-
-    static class ReplaceHash extends MappingCharFilter {
-
-        ReplaceHash(Reader in) {
-            super(charMap(), in);
-        }
-
-        private static NormalizeCharMap charMap() {
-            NormalizeCharMap.Builder builder = new NormalizeCharMap.Builder();
-            builder.add("#", "3");
-            return builder.build();
-        }
-    }
-
-    @NamedComponent("stableTokenFilterFactory")
-    public static class TestTokenFilterFactory implements org.elasticsearch.plugin.analysis.api.TokenFilterFactory {
-
-        @SuppressForbidden(reason = "need a public constructor")
-        public TestTokenFilterFactory() {}
-
-        @Override
-        public TokenStream create(TokenStream tokenStream) {
-
-            return new Skip1TokenFilter(tokenStream);
-        }
-
-        @Override
-        public TokenStream normalize(TokenStream tokenStream) {
-            return new AppendTokenFilter(tokenStream, "1");
-        }
-
-        @Override
-        public AnalysisMode getAnalysisMode() {
-            return org.elasticsearch.plugin.analysis.api.TokenFilterFactory.super.getAnalysisMode();
-        }
-
-    }
-
-    static class Skip1TokenFilter extends FilteringTokenFilter {
-
-        private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class);
-
-        Skip1TokenFilter(TokenStream in) {
-            super(in);
-        }
-
-        @Override
-        protected boolean accept() throws IOException {
-            return termAtt.buffer()[0] != '1';
-        }
-    }
-
-    @NamedComponent("stableTokenizerFactory")
-    public static class TestTokenizerFactory implements org.elasticsearch.plugin.analysis.api.TokenizerFactory {
-        @SuppressForbidden(reason = "need a public constructor")
-        public TestTokenizerFactory() {}
-
-        @Override
-        public Tokenizer create() {
-            return new UnderscoreTokenizer();
-        }
-
-    }
-
-    static class UnderscoreTokenizer extends CharTokenizer {
-
-        @Override
-        protected boolean isTokenChar(int c) {
-            return c != '_';
-        }
-    }
-
-    @NamedComponent("stableAnalyzerFactory")
-    public static class TestAnalyzerFactory implements org.elasticsearch.plugin.analysis.api.AnalyzerFactory {
-
-        @Override
-        public Analyzer create() {
-            return new CustomAnalyzer();
-        }
-
-        static class CustomAnalyzer extends Analyzer {
-
-            @Override
-            protected TokenStreamComponents createComponents(String fieldName) {
-                var tokenizer = new UnderscoreTokenizer();
-                var tokenFilter = new Skip1TokenFilter(tokenizer);
-                return new TokenStreamComponents(r -> tokenizer.setReader(new ReplaceHash(r)), tokenFilter);
-            }
-        }
-    }
-
-    public void testStablePlugins() throws IOException {
-        ClassLoader classLoader = getClass().getClassLoader();
-        AnalysisRegistry registry = new AnalysisModule(
-            TestEnvironment.newEnvironment(emptyNodeSettings),
-            emptyList(),
-            new StablePluginsRegistry(
-                new NamedComponentReader(),
-                Map.of(
-                    org.elasticsearch.plugin.analysis.api.CharFilterFactory.class.getCanonicalName(),
-                    new NameToPluginInfo(
-                        Map.of(
-                            "stableCharFilterFactory",
-                            new PluginInfo("stableCharFilterFactory", TestCharFilterFactory.class.getName(), classLoader)
-                        )
-                    ),
-                    org.elasticsearch.plugin.analysis.api.TokenFilterFactory.class.getCanonicalName(),
-                    new NameToPluginInfo(
-                        Map.of(
-                            "stableTokenFilterFactory",
-                            new PluginInfo("stableTokenFilterFactory", TestTokenFilterFactory.class.getName(), classLoader)
-                        )
-                    ),
-                    org.elasticsearch.plugin.analysis.api.TokenizerFactory.class.getCanonicalName(),
-                    new NameToPluginInfo(
-                        Map.of(
-                            "stableTokenizerFactory",
-                            new PluginInfo("stableTokenizerFactory", TestTokenizerFactory.class.getName(), classLoader)
-                        )
-                    ),
-                    org.elasticsearch.plugin.analysis.api.AnalyzerFactory.class.getCanonicalName(),
-                    new NameToPluginInfo(
-                        Map.of(
-                            "stableAnalyzerFactory",
-                            new PluginInfo("stableAnalyzerFactory", TestAnalyzerFactory.class.getName(), classLoader)
-                        )
-                    )
-                )
-            )
-        ).getAnalysisRegistry();
-
-        Version version = VersionUtils.randomVersion(random());
-        IndexAnalyzers analyzers = getIndexAnalyzers(
-            registry,
-            Settings.builder()
-                .put("index.analysis.analyzer.char_filter_test.tokenizer", "standard")
-                .put("index.analysis.analyzer.char_filter_test.char_filter", "stableCharFilterFactory")
-
-                .put("index.analysis.analyzer.token_filter_test.tokenizer", "standard")
-                .put("index.analysis.analyzer.token_filter_test.filter", "stableTokenFilterFactory")
-
-                .put("index.analysis.analyzer.tokenizer_test.tokenizer", "stableTokenizerFactory")
-
-                .put("index.analysis.analyzer.analyzer_provider_test.type", "stableAnalyzerFactory")
-
-                .put(IndexMetadata.SETTING_VERSION_CREATED, version)
-                .build()
-        );
-        assertTokenStreamContents(analyzers.get("char_filter_test").tokenStream("", "t#st"), new String[] { "t3st" });
-        assertTokenStreamContents(
-            analyzers.get("token_filter_test").tokenStream("", "1test 2test 1test 3test "),
-            new String[] { "2test", "3test" }
-        );
-        assertTokenStreamContents(analyzers.get("tokenizer_test").tokenStream("", "x_y_z"), new String[] { "x", "y", "z" });
-        assertTokenStreamContents(analyzers.get("analyzer_provider_test").tokenStream("", "1x_y_#z"), new String[] { "y", "3z" });
-
-        assertThat(analyzers.get("char_filter_test").normalize("", "t#st").utf8ToString(), equalTo("t3st"));
-        assertThat(
-            analyzers.get("token_filter_test").normalize("", "1test 2test 1test 3test ").utf8ToString(),
-            equalTo("1test 2test 1test 3test 1")
-        );
-
-        // TODO does it makes sense to test normalize on tokenizer and analyzer?
-    }
-
-    // Simple char filter that appends text to the term
-    public static class AppendCharFilter extends CharFilter {
-
-        static Reader append(Reader input, String appendMe) {
-            try {
-                return new StringReader(Streams.copyToString(input) + appendMe);
-            } catch (IOException e) {
-                throw new UncheckedIOException(e);
-            }
-        }
-
-        public AppendCharFilter(Reader input, String appendMe) {
-            super(append(input, appendMe));
-        }
-
-        @Override
-        protected int correct(int currentOff) {
-            return currentOff;
-        }
-
-        @Override
-        public int read(char[] cbuf, int off, int len) throws IOException {
-            return input.read(cbuf, off, len);
-        }
-    }
-
-    // Simple token filter that appends text to the term
-    private static class AppendTokenFilter extends TokenFilter {
-        public static TokenFilterFactory factoryForSuffix(String suffix) {
-            return new TokenFilterFactory() {
-                @Override
-                public String name() {
-                    return suffix;
-                }
-
-                @Override
-                public TokenStream create(TokenStream tokenStream) {
-                    return new AppendTokenFilter(tokenStream, suffix);
-                }
-            };
-        }
-
-        private final CharTermAttribute term = addAttribute(CharTermAttribute.class);
-        private final char[] appendMe;
-
-        protected AppendTokenFilter(TokenStream input, String appendMe) {
-            super(input);
-            this.appendMe = appendMe.toCharArray();
-        }
-
-        @Override
-        public boolean incrementToken() throws IOException {
-            if (false == input.incrementToken()) {
-                return false;
-            }
-            term.resizeBuffer(term.length() + appendMe.length);
-            System.arraycopy(appendMe, 0, term.buffer(), term.length(), appendMe.length);
-            term.setLength(term.length() + appendMe.length);
-            return true;
-        }
-    }
-
 }

+ 155 - 0
server/src/test/java/org/elasticsearch/indices/analysis/IncorrectSetupStablePluginsTests.java

@@ -0,0 +1,155 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.indices.analysis;
+
+import org.elasticsearch.cluster.metadata.IndexMetadata;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.env.TestEnvironment;
+import org.elasticsearch.index.IndexSettings;
+import org.elasticsearch.index.analysis.AnalysisRegistry;
+import org.elasticsearch.index.analysis.IndexAnalyzers;
+import org.elasticsearch.indices.analysis.lucene.ReplaceCharToNumber;
+import org.elasticsearch.plugin.api.Inject;
+import org.elasticsearch.plugin.api.NamedComponent;
+import org.elasticsearch.plugin.api.settings.AnalysisSettings;
+import org.elasticsearch.plugins.scanners.NameToPluginInfo;
+import org.elasticsearch.plugins.scanners.NamedComponentReader;
+import org.elasticsearch.plugins.scanners.PluginInfo;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.IndexSettingsModule;
+import org.elasticsearch.test.VersionUtils;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Map;
+
+import static java.util.Collections.emptyList;
+import static org.hamcrest.Matchers.equalTo;
+
+public class IncorrectSetupStablePluginsTests extends ESTestCase {
+    ClassLoader classLoader = getClass().getClassLoader();
+
+    private final Settings emptyNodeSettings = Settings.builder()
+        .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+        .build();
+
+    public @interface IncorrectAnnotation {
+    }
+
+    @IncorrectAnnotation
+    public interface IncorrectlyAnnotatedSettings {}
+
+    @NamedComponent("incorrectlyAnnotatedSettings")
+    public static class IncorrectlyAnnotatedSettingsCharFilter extends AbstractCharFilterFactory {
+        @Inject
+        public IncorrectlyAnnotatedSettingsCharFilter(IncorrectlyAnnotatedSettings settings) {}
+    }
+
+    public void testIncorrectlyAnnotatedSettingsClass() throws IOException {
+        var e = expectThrows(
+            IllegalArgumentException.class,
+            () -> getIndexAnalyzers(
+                Settings.builder()
+                    .put("index.analysis.analyzer.char_filter_test.tokenizer", "standard")
+                    .put("index.analysis.analyzer.char_filter_test.char_filter", "incorrectlyAnnotatedSettings")
+                    .put(IndexMetadata.SETTING_VERSION_CREATED, VersionUtils.randomVersion(random()))
+                    .build(),
+                Map.of(
+                    "incorrectlyAnnotatedSettings",
+                    new PluginInfo("incorrectlyAnnotatedSettings", IncorrectlyAnnotatedSettingsCharFilter.class.getName(), classLoader)
+                )
+            )
+        );
+        assertThat(e.getMessage(), equalTo("Parameter is not instance of a class annotated with settings annotation."));
+    }
+
+    @AnalysisSettings
+    public interface OkAnalysisSettings {}
+
+    @NamedComponent("noInjectCharFilter")
+    public static class NoInjectCharFilter extends AbstractCharFilterFactory {
+
+        public NoInjectCharFilter(OkAnalysisSettings settings) {}
+    }
+
+    public void testIncorrectlyAnnotatedConstructor() throws IOException {
+        var e = expectThrows(
+            IllegalStateException.class,
+            () -> getIndexAnalyzers(
+                Settings.builder()
+                    .put("index.analysis.analyzer.char_filter_test.tokenizer", "standard")
+                    .put("index.analysis.analyzer.char_filter_test.char_filter", "noInjectCharFilter")
+                    .put(IndexMetadata.SETTING_VERSION_CREATED, VersionUtils.randomVersion(random()))
+                    .build(),
+                Map.of("noInjectCharFilter", new PluginInfo("noInjectCharFilter", NoInjectCharFilter.class.getName(), classLoader))
+            )
+        );
+        assertThat(e.getMessage(), equalTo("Missing @Inject annotation for constructor with settings."));
+    }
+
+    @NamedComponent("multipleConstructors")
+    public static class MultipleConstructors extends AbstractCharFilterFactory {
+        public MultipleConstructors() {}
+
+        public MultipleConstructors(OkAnalysisSettings settings) {}
+    }
+
+    public void testMultiplePublicConstructors() throws IOException {
+        var e = expectThrows(
+            IllegalStateException.class,
+            () -> getIndexAnalyzers(
+                Settings.builder()
+                    .put("index.analysis.analyzer.char_filter_test.tokenizer", "standard")
+                    .put("index.analysis.analyzer.char_filter_test.char_filter", "multipleConstructors")
+                    .put(IndexMetadata.SETTING_VERSION_CREATED, VersionUtils.randomVersion(random()))
+                    .build(),
+                Map.of("multipleConstructors", new PluginInfo("multipleConstructors", MultipleConstructors.class.getName(), classLoader))
+            )
+        );
+        assertThat(e.getMessage(), equalTo("Plugin can only have one public constructor."));
+    }
+
+    public IndexAnalyzers getIndexAnalyzers(Settings settings, Map<String, PluginInfo> mapOfCharFilters) throws IOException {
+        AnalysisRegistry registry = setupRegistry(mapOfCharFilters);
+
+        IndexSettings idxSettings = IndexSettingsModule.newIndexSettings("test", settings);
+        return registry.build(idxSettings);
+    }
+
+    private AnalysisRegistry setupRegistry(Map<String, PluginInfo> mapOfCharFilters) throws IOException {
+
+        AnalysisRegistry registry = new AnalysisModule(
+            TestEnvironment.newEnvironment(emptyNodeSettings),
+            emptyList(),
+            new StablePluginsRegistry(
+                new NamedComponentReader(),
+                Map.of(
+                    org.elasticsearch.plugin.analysis.api.CharFilterFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(mapOfCharFilters)
+                )
+            )
+        ).getAnalysisRegistry();
+        return registry;
+    }
+
+    public abstract static class AbstractCharFilterFactory implements org.elasticsearch.plugin.analysis.api.CharFilterFactory {
+
+        @Override
+        public Reader create(Reader reader) {
+            return new ReplaceCharToNumber(reader, "#", 3);
+        }
+
+        @Override
+        public Reader normalize(Reader reader) {
+            return new ReplaceCharToNumber(reader, "#", 3);
+        }
+    }
+}

+ 232 - 0
server/src/test/java/org/elasticsearch/indices/analysis/StableAnalysisPluginsNoSettingsTests.java

@@ -0,0 +1,232 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.indices.analysis;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.FilteringTokenFilter;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.Tokenizer;
+import org.apache.lucene.analysis.charfilter.MappingCharFilter;
+import org.apache.lucene.analysis.charfilter.NormalizeCharMap;
+import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
+import org.apache.lucene.analysis.util.CharTokenizer;
+import org.elasticsearch.Version;
+import org.elasticsearch.cluster.metadata.IndexMetadata;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.env.TestEnvironment;
+import org.elasticsearch.index.IndexSettings;
+import org.elasticsearch.index.analysis.AnalysisRegistry;
+import org.elasticsearch.index.analysis.IndexAnalyzers;
+import org.elasticsearch.indices.analysis.lucene.AppendTokenFilter;
+import org.elasticsearch.plugin.analysis.api.AnalysisMode;
+import org.elasticsearch.plugin.api.NamedComponent;
+import org.elasticsearch.plugins.scanners.NameToPluginInfo;
+import org.elasticsearch.plugins.scanners.NamedComponentReader;
+import org.elasticsearch.plugins.scanners.PluginInfo;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.IndexSettingsModule;
+import org.elasticsearch.test.VersionUtils;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Map;
+
+import static java.util.Collections.emptyList;
+import static org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents;
+import static org.hamcrest.Matchers.equalTo;
+
+public class StableAnalysisPluginsNoSettingsTests extends ESTestCase {
+    private final Settings emptyNodeSettings = Settings.builder()
+        .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+        .build();
+
+    public IndexAnalyzers getIndexAnalyzers(Settings settings) throws IOException {
+        AnalysisRegistry registry = setupRegistry();
+
+        IndexSettings idxSettings = IndexSettingsModule.newIndexSettings("test", settings);
+        return registry.build(idxSettings);
+    }
+
+    public void testStablePlugins() throws IOException {
+        Version version = VersionUtils.randomVersion(random());
+        IndexAnalyzers analyzers = getIndexAnalyzers(
+            Settings.builder()
+                .put("index.analysis.analyzer.char_filter_test.tokenizer", "standard")
+                .put("index.analysis.analyzer.char_filter_test.char_filter", "stableCharFilterFactory")
+
+                .put("index.analysis.analyzer.token_filter_test.tokenizer", "standard")
+                .put("index.analysis.analyzer.token_filter_test.filter", "stableTokenFilterFactory")
+
+                .put("index.analysis.analyzer.tokenizer_test.tokenizer", "stableTokenizerFactory")
+
+                .put("index.analysis.analyzer.analyzer_provider_test.type", "stableAnalyzerFactory")
+
+                .put(IndexMetadata.SETTING_VERSION_CREATED, version)
+                .build()
+        );
+        assertTokenStreamContents(analyzers.get("char_filter_test").tokenStream("", "t#st"), new String[] { "t3st" });
+        assertTokenStreamContents(
+            analyzers.get("token_filter_test").tokenStream("", "1test 2test 1test 3test "),
+            new String[] { "2test", "3test" }
+        );
+        assertTokenStreamContents(analyzers.get("tokenizer_test").tokenStream("", "x_y_z"), new String[] { "x", "y", "z" });
+        assertTokenStreamContents(analyzers.get("analyzer_provider_test").tokenStream("", "1x_y_#z"), new String[] { "y", "3z" });
+
+        assertThat(analyzers.get("char_filter_test").normalize("", "t#st").utf8ToString(), equalTo("t3st"));
+        assertThat(
+            analyzers.get("token_filter_test").normalize("", "1test 2test 1test 3test ").utf8ToString(),
+            equalTo("1test 2test 1test 3test 1")
+        );
+    }
+
+    @NamedComponent("stableCharFilterFactory")
+    public static class TestCharFilterFactory implements org.elasticsearch.plugin.analysis.api.CharFilterFactory {
+
+        @Override
+        public Reader create(Reader reader) {
+            return new ReplaceHash(reader);
+        }
+
+        @Override
+        public Reader normalize(Reader reader) {
+            return new ReplaceHash(reader);
+        }
+
+    }
+
+    static class ReplaceHash extends MappingCharFilter {
+
+        ReplaceHash(Reader in) {
+            super(charMap(), in);
+        }
+
+        private static NormalizeCharMap charMap() {
+            NormalizeCharMap.Builder builder = new NormalizeCharMap.Builder();
+            builder.add("#", "3");
+            return builder.build();
+        }
+    }
+
+    @NamedComponent("stableTokenFilterFactory")
+    public static class TestTokenFilterFactory implements org.elasticsearch.plugin.analysis.api.TokenFilterFactory {
+
+        @Override
+        public TokenStream create(TokenStream tokenStream) {
+
+            return new Skip1TokenFilter(tokenStream);
+        }
+
+        @Override
+        public TokenStream normalize(TokenStream tokenStream) {
+            return new AppendTokenFilter(tokenStream, "1");
+        }
+
+        @Override
+        public AnalysisMode getAnalysisMode() {
+            return org.elasticsearch.plugin.analysis.api.TokenFilterFactory.super.getAnalysisMode();
+        }
+
+    }
+
+    static class Skip1TokenFilter extends FilteringTokenFilter {
+
+        private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class);
+
+        Skip1TokenFilter(TokenStream in) {
+            super(in);
+        }
+
+        @Override
+        protected boolean accept() throws IOException {
+            return termAtt.buffer()[0] != '1';
+        }
+    }
+
+    @NamedComponent("stableTokenizerFactory")
+    public static class TestTokenizerFactory implements org.elasticsearch.plugin.analysis.api.TokenizerFactory {
+
+        @Override
+        public Tokenizer create() {
+            return new UnderscoreTokenizer();
+        }
+
+    }
+
+    static class UnderscoreTokenizer extends CharTokenizer {
+
+        @Override
+        protected boolean isTokenChar(int c) {
+            return c != '_';
+        }
+    }
+
+    @NamedComponent("stableAnalyzerFactory")
+    public static class TestAnalyzerFactory implements org.elasticsearch.plugin.analysis.api.AnalyzerFactory {
+
+        @Override
+        public Analyzer create() {
+            return new CustomAnalyzer();
+        }
+
+        static class CustomAnalyzer extends Analyzer {
+
+            @Override
+            protected TokenStreamComponents createComponents(String fieldName) {
+                var tokenizer = new UnderscoreTokenizer();
+                var tokenFilter = new Skip1TokenFilter(tokenizer);
+                return new TokenStreamComponents(r -> tokenizer.setReader(new ReplaceHash(r)), tokenFilter);
+            }
+        }
+    }
+
+    private AnalysisRegistry setupRegistry() throws IOException {
+        ClassLoader classLoader = getClass().getClassLoader();
+
+        AnalysisRegistry registry = new AnalysisModule(
+            TestEnvironment.newEnvironment(emptyNodeSettings),
+            emptyList(),
+            new StablePluginsRegistry(
+                new NamedComponentReader(),
+                Map.of(
+                    org.elasticsearch.plugin.analysis.api.CharFilterFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(
+                        Map.of(
+                            "stableCharFilterFactory",
+                            new PluginInfo("stableCharFilterFactory", TestCharFilterFactory.class.getName(), classLoader)
+                        )
+                    ),
+                    org.elasticsearch.plugin.analysis.api.TokenFilterFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(
+                        Map.of(
+                            "stableTokenFilterFactory",
+                            new PluginInfo("stableTokenFilterFactory", TestTokenFilterFactory.class.getName(), classLoader)
+                        )
+                    ),
+                    org.elasticsearch.plugin.analysis.api.TokenizerFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(
+                        Map.of(
+                            "stableTokenizerFactory",
+                            new PluginInfo("stableTokenizerFactory", TestTokenizerFactory.class.getName(), classLoader)
+                        )
+                    ),
+                    org.elasticsearch.plugin.analysis.api.AnalyzerFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(
+                        Map.of(
+                            "stableAnalyzerFactory",
+                            new PluginInfo("stableAnalyzerFactory", TestAnalyzerFactory.class.getName(), classLoader)
+                        )
+                    )
+                )
+            )
+        ).getAnalysisRegistry();
+        return registry;
+    }
+}

+ 296 - 0
server/src/test/java/org/elasticsearch/indices/analysis/StableAnalysisPluginsWithSettingsTests.java

@@ -0,0 +1,296 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.indices.analysis;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.Tokenizer;
+import org.elasticsearch.cluster.metadata.IndexMetadata;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.env.TestEnvironment;
+import org.elasticsearch.index.IndexSettings;
+import org.elasticsearch.index.analysis.AnalysisRegistry;
+import org.elasticsearch.index.analysis.IndexAnalyzers;
+import org.elasticsearch.indices.analysis.lucene.AppendTokenFilter;
+import org.elasticsearch.indices.analysis.lucene.ReplaceCharToNumber;
+import org.elasticsearch.indices.analysis.lucene.SkipTokenFilter;
+import org.elasticsearch.indices.analysis.lucene.TestTokenizer;
+import org.elasticsearch.plugin.analysis.api.AnalysisMode;
+import org.elasticsearch.plugin.api.Inject;
+import org.elasticsearch.plugin.api.NamedComponent;
+import org.elasticsearch.plugin.api.settings.AnalysisSettings;
+import org.elasticsearch.plugin.api.settings.BooleanSetting;
+import org.elasticsearch.plugin.api.settings.IntSetting;
+import org.elasticsearch.plugin.api.settings.ListSetting;
+import org.elasticsearch.plugin.api.settings.LongSetting;
+import org.elasticsearch.plugin.api.settings.StringSetting;
+import org.elasticsearch.plugins.scanners.NameToPluginInfo;
+import org.elasticsearch.plugins.scanners.NamedComponentReader;
+import org.elasticsearch.plugins.scanners.PluginInfo;
+import org.elasticsearch.plugins.scanners.StablePluginsRegistry;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.IndexSettingsModule;
+import org.elasticsearch.test.VersionUtils;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Map;
+
+import static java.util.Collections.emptyList;
+import static org.apache.lucene.tests.analysis.BaseTokenStreamTestCase.assertTokenStreamContents;
+import static org.hamcrest.Matchers.equalTo;
+
+public class StableAnalysisPluginsWithSettingsTests extends ESTestCase {
+
+    protected final Settings emptyNodeSettings = Settings.builder()
+        .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+        .build();
+
+    public void testCharFilters() throws IOException {
+        IndexAnalyzers analyzers = getIndexAnalyzers(
+            Settings.builder()
+                .put("index.analysis.char_filter.my_char_filter.type", "stableCharFilterFactory")
+                .put("index.analysis.char_filter.my_char_filter.old_char", "#")
+                .put("index.analysis.char_filter.my_char_filter.new_number", 3)
+
+                .put("index.analysis.analyzer.char_filter_test.tokenizer", "standard")
+                .put("index.analysis.analyzer.char_filter_test.char_filter", "my_char_filter")
+
+                .put("index.analysis.analyzer.char_filter_with_defaults_test.tokenizer", "standard")
+                .put("index.analysis.analyzer.char_filter_with_defaults_test.char_filter", "stableCharFilterFactory")
+
+                .put(IndexMetadata.SETTING_VERSION_CREATED, VersionUtils.randomVersion(random()))
+                .build()
+        );
+        assertTokenStreamContents(analyzers.get("char_filter_test").tokenStream("", "t#st"), new String[] { "t3st" });
+        assertTokenStreamContents(analyzers.get("char_filter_with_defaults_test").tokenStream("", "t t"), new String[] { "t0t" });
+        assertThat(analyzers.get("char_filter_test").normalize("", "t#st").utf8ToString(), equalTo("t3st"));
+    }
+
+    public void testTokenFilters() throws IOException {
+        IndexAnalyzers analyzers = getIndexAnalyzers(
+            Settings.builder()
+                .put("index.analysis.filter.my_token_filter.type", "stableTokenFilterFactory")
+                .put("index.analysis.filter.my_token_filter.token_filter_number", 1L)
+
+                .put("index.analysis.analyzer.token_filter_test.tokenizer", "standard")
+                .put("index.analysis.analyzer.token_filter_test.filter", "my_token_filter")
+                .put(IndexMetadata.SETTING_VERSION_CREATED, VersionUtils.randomVersion(random()))
+                .build()
+        );
+        assertTokenStreamContents(
+            analyzers.get("token_filter_test").tokenStream("", "1test 2test 1test 3test "),
+            new String[] { "2test", "3test" }
+        );
+
+        assertThat(
+            analyzers.get("token_filter_test").normalize("", "1test 2test 1test 3test ").utf8ToString(),
+            equalTo("1test 2test 1test 3test 1")
+        );
+    }
+
+    public void testTokenizer() throws IOException {
+        IndexAnalyzers analyzers = getIndexAnalyzers(
+            Settings.builder()
+                .put("index.analysis.tokenizer.my_tokenizer.type", "stableTokenizerFactory")
+                .putList("index.analysis.tokenizer.my_tokenizer.tokenizer_list_of_chars", "_", " ")
+
+                .put("index.analysis.analyzer.tokenizer_test.tokenizer", "my_tokenizer")
+                .put(IndexMetadata.SETTING_VERSION_CREATED, VersionUtils.randomVersion(random()))
+                .build()
+        );
+        assertTokenStreamContents(analyzers.get("tokenizer_test").tokenStream("", "x_y z"), new String[] { "x", "y", "z" });
+    }
+
+    public void testAnalyzer() throws IOException {
+        IndexAnalyzers analyzers = getIndexAnalyzers(
+            Settings.builder()
+                .put("index.analysis.analyzer.analyzer_provider_test.type", "stableAnalyzerFactory")
+                .putList("index.analysis.analyzer.analyzer_provider_test.tokenizer_list_of_chars", "_", " ")
+                .put("index.analysis.analyzer.analyzer_provider_test.token_filter_number", 1L)
+                .put("index.analysis.analyzer.analyzer_provider_test.old_char", "#")
+                .put("index.analysis.analyzer.analyzer_provider_test.new_number", 3)
+                .put("index.analysis.analyzer.analyzer_provider_test.analyzerUseTokenListOfChars", true)
+                .put(IndexMetadata.SETTING_VERSION_CREATED, VersionUtils.randomVersion(random()))
+                .build()
+        );
+        assertTokenStreamContents(analyzers.get("analyzer_provider_test").tokenStream("", "1x_y_#z"), new String[] { "y", "3z" });
+    }
+
+    protected IndexAnalyzers getIndexAnalyzers(Settings settings) throws IOException {
+        AnalysisRegistry registry = setupRegistry();
+
+        IndexSettings idxSettings = IndexSettingsModule.newIndexSettings("test", settings);
+        return registry.build(idxSettings);
+    }
+
+    private AnalysisRegistry setupRegistry() throws IOException {
+        ClassLoader classLoader = getClass().getClassLoader();
+
+        AnalysisRegistry registry = new AnalysisModule(
+            TestEnvironment.newEnvironment(emptyNodeSettings),
+            emptyList(),
+            new StablePluginsRegistry(
+                new NamedComponentReader(),
+                Map.of(
+                    org.elasticsearch.plugin.analysis.api.CharFilterFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(
+                        Map.of(
+                            "stableCharFilterFactory",
+                            new PluginInfo("stableCharFilterFactory", TestCharFilterFactory.class.getName(), classLoader)
+                        )
+                    ),
+                    org.elasticsearch.plugin.analysis.api.TokenFilterFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(
+                        Map.of(
+                            "stableTokenFilterFactory",
+                            new PluginInfo("stableTokenFilterFactory", TestTokenFilterFactory.class.getName(), classLoader)
+                        )
+                    ),
+                    org.elasticsearch.plugin.analysis.api.TokenizerFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(
+                        Map.of(
+                            "stableTokenizerFactory",
+                            new PluginInfo("stableTokenizerFactory", TestTokenizerFactory.class.getName(), classLoader)
+                        )
+                    ),
+                    org.elasticsearch.plugin.analysis.api.AnalyzerFactory.class.getCanonicalName(),
+                    new NameToPluginInfo(
+                        Map.of(
+                            "stableAnalyzerFactory",
+                            new PluginInfo("stableAnalyzerFactory", TestAnalyzerFactory.class.getName(), classLoader)
+                        )
+                    )
+                )
+            )
+        ).getAnalysisRegistry();
+        return registry;
+    }
+
+    @AnalysisSettings
+    public interface TestAnalysisSettings {
+        @StringSetting(path = "old_char", defaultValue = " ")
+        String oldChar();
+
+        @IntSetting(path = "new_number", defaultValue = 0)
+        int newNumber();
+
+        @LongSetting(path = "token_filter_number", defaultValue = 0L)
+        long tokenFilterNumber();
+
+        @ListSetting(path = "tokenizer_list_of_chars")
+        java.util.List<String> tokenizerListOfChars();
+
+        @BooleanSetting(path = "analyzerUseTokenListOfChars", defaultValue = false)
+        boolean analyzerUseTokenListOfChars();
+    }
+
+    @NamedComponent("stableAnalyzerFactory")
+    public static class TestAnalyzerFactory implements org.elasticsearch.plugin.analysis.api.AnalyzerFactory {
+
+        private final TestAnalysisSettings settings;
+
+        @Inject
+        public TestAnalyzerFactory(TestAnalysisSettings settings) {
+            this.settings = settings;
+        }
+
+        @Override
+        public Analyzer create() {
+            return new CustomAnalyzer(settings);
+        }
+
+        static class CustomAnalyzer extends Analyzer {
+
+            private final TestAnalysisSettings settings;
+
+            CustomAnalyzer(TestAnalysisSettings settings) {
+                this.settings = settings;
+            }
+
+            @Override
+            protected TokenStreamComponents createComponents(String fieldName) {
+                var tokenizer = new TestTokenizer(settings.tokenizerListOfChars());
+                long tokenFilterNumber = settings.analyzerUseTokenListOfChars() ? settings.tokenFilterNumber() : -1;
+                var tokenFilter = new SkipTokenFilter(tokenizer, tokenFilterNumber);
+                return new TokenStreamComponents(
+                    r -> tokenizer.setReader(new ReplaceCharToNumber(r, settings.oldChar(), settings.newNumber())),
+                    tokenFilter
+                );
+            }
+        }
+    }
+
+    @NamedComponent("stableCharFilterFactory")
+    public static class TestCharFilterFactory implements org.elasticsearch.plugin.analysis.api.CharFilterFactory {
+        private final String oldChar;
+        private final int newNumber;
+
+        @Inject
+        public TestCharFilterFactory(TestAnalysisSettings settings) {
+            oldChar = settings.oldChar();
+            newNumber = settings.newNumber();
+        }
+
+        @Override
+        public Reader create(Reader reader) {
+            return new ReplaceCharToNumber(reader, oldChar, newNumber);
+        }
+
+        @Override
+        public Reader normalize(Reader reader) {
+            return new ReplaceCharToNumber(reader, oldChar, newNumber);
+        }
+
+    }
+
+    @NamedComponent("stableTokenFilterFactory")
+    public static class TestTokenFilterFactory implements org.elasticsearch.plugin.analysis.api.TokenFilterFactory {
+
+        private final long tokenFilterNumber;
+
+        @Inject
+        public TestTokenFilterFactory(TestAnalysisSettings settings) {
+            this.tokenFilterNumber = settings.tokenFilterNumber();
+        }
+
+        @Override
+        public TokenStream create(TokenStream tokenStream) {
+            return new SkipTokenFilter(tokenStream, tokenFilterNumber);
+        }
+
+        @Override
+        public TokenStream normalize(TokenStream tokenStream) {
+            return new AppendTokenFilter(tokenStream, String.valueOf(tokenFilterNumber));
+        }
+
+        @Override
+        public AnalysisMode getAnalysisMode() {
+            return org.elasticsearch.plugin.analysis.api.TokenFilterFactory.super.getAnalysisMode();
+        }
+
+    }
+
+    @NamedComponent("stableTokenizerFactory")
+    public static class TestTokenizerFactory implements org.elasticsearch.plugin.analysis.api.TokenizerFactory {
+        private final java.util.List<String> tokenizerListOfChars;
+
+        @Inject
+        public TestTokenizerFactory(TestAnalysisSettings settings) {
+            this.tokenizerListOfChars = settings.tokenizerListOfChars();
+        }
+
+        @Override
+        public Tokenizer create() {
+            return new TestTokenizer(tokenizerListOfChars);
+        }
+
+    }
+}

+ 43 - 0
server/src/test/java/org/elasticsearch/indices/analysis/lucene/AppendCharFilter.java

@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.indices.analysis.lucene;
+
+import org.apache.lucene.analysis.CharFilter;
+import org.elasticsearch.common.io.Streams;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.io.UncheckedIOException;
+
+// Simple char filter that appends text to the term
+public class AppendCharFilter extends CharFilter {
+
+    static Reader append(Reader input, String appendMe) {
+        try {
+            return new StringReader(Streams.copyToString(input) + appendMe);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    public AppendCharFilter(Reader input, String appendMe) {
+        super(append(input, appendMe));
+    }
+
+    @Override
+    protected int correct(int currentOff) {
+        return currentOff;
+    }
+
+    @Override
+    public int read(char[] cbuf, int off, int len) throws IOException {
+        return input.read(cbuf, off, len);
+    }
+}

+ 52 - 0
server/src/test/java/org/elasticsearch/indices/analysis/lucene/AppendTokenFilter.java

@@ -0,0 +1,52 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.indices.analysis.lucene;
+
+import org.apache.lucene.analysis.TokenFilter;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
+import org.elasticsearch.index.analysis.TokenFilterFactory;
+
+import java.io.IOException;
+
+// Simple token filter that appends text to the term
+public final class AppendTokenFilter extends TokenFilter {
+    public static TokenFilterFactory factoryForSuffix(String suffix) {
+        return new TokenFilterFactory() {
+            @Override
+            public String name() {
+                return suffix;
+            }
+
+            @Override
+            public TokenStream create(TokenStream tokenStream) {
+                return new AppendTokenFilter(tokenStream, suffix);
+            }
+        };
+    }
+
+    private final CharTermAttribute term = addAttribute(CharTermAttribute.class);
+    private final char[] appendMe;
+
+    public AppendTokenFilter(TokenStream input, String appendMe) {
+        super(input);
+        this.appendMe = appendMe.toCharArray();
+    }
+
+    @Override
+    public boolean incrementToken() throws IOException {
+        if (false == input.incrementToken()) {
+            return false;
+        }
+        term.resizeBuffer(term.length() + appendMe.length);
+        System.arraycopy(appendMe, 0, term.buffer(), term.length(), appendMe.length);
+        term.setLength(term.length() + appendMe.length);
+        return true;
+    }
+}

+ 27 - 0
server/src/test/java/org/elasticsearch/indices/analysis/lucene/ReplaceCharToNumber.java

@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.indices.analysis.lucene;
+
+import org.apache.lucene.analysis.charfilter.MappingCharFilter;
+import org.apache.lucene.analysis.charfilter.NormalizeCharMap;
+
+import java.io.Reader;
+
+public class ReplaceCharToNumber extends MappingCharFilter {
+
+    public ReplaceCharToNumber(Reader in, String oldChar, int newNumber) {
+        super(charMap(oldChar, newNumber), in);
+    }
+
+    private static NormalizeCharMap charMap(String oldChar, int newNumber) {
+        NormalizeCharMap.Builder builder = new NormalizeCharMap.Builder();
+        builder.add(oldChar, String.valueOf(newNumber));
+        return builder.build();
+    }
+}

+ 31 - 0
server/src/test/java/org/elasticsearch/indices/analysis/lucene/SkipTokenFilter.java

@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.indices.analysis.lucene;
+
+import org.apache.lucene.analysis.FilteringTokenFilter;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
+
+import java.io.IOException;
+
+public class SkipTokenFilter extends FilteringTokenFilter {
+
+    private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class);
+    private final long tokenFilterNumber;
+
+    public SkipTokenFilter(TokenStream in, long tokenFilterNumber) {
+        super(in);
+        this.tokenFilterNumber = tokenFilterNumber;
+    }
+
+    @Override
+    protected boolean accept() throws IOException {
+        return termAtt.buffer()[0] != (char) (tokenFilterNumber + '0');
+    }
+}

+ 29 - 0
server/src/test/java/org/elasticsearch/indices/analysis/lucene/TestTokenizer.java

@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.indices.analysis.lucene;
+
+import org.apache.lucene.analysis.util.CharTokenizer;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public class TestTokenizer extends CharTokenizer {
+
+    private final Set<Integer> setOfChars;
+
+    public TestTokenizer(List<String> tokenizerListOfChars) {
+        this.setOfChars = tokenizerListOfChars.stream().map(s -> (int) s.charAt(0)).collect(Collectors.toSet());
+    }
+
+    @Override
+    protected boolean isTokenChar(int c) {
+        return setOfChars.contains(c) == false;
+    }
+}

+ 1 - 1
server/src/test/java/org/elasticsearch/indices/analysis/wrappers/StableApiWrappersTests.java

@@ -74,7 +74,7 @@ public class StableApiWrappersTests extends ESTestCase {
             IllegalStateException.class,
             () -> oldTokenFilter.get(null, mock(Environment.class), null, null)
         );
-        assertThat(illegalStateException.getCause(), instanceOf(NoSuchMethodException.class));
+        assertThat(illegalStateException.getMessage(), equalTo("Missing @Inject annotation for constructor with settings."));
     }
 
     public void testAnalyzerFactoryDelegation() throws IOException {