CompiledInflector.java
package org.atteo.evo.inflector;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
final class CompiledInflector {
@FunctionalInterface
interface WordCondition {
boolean matches(String lowerWord, int suffixStart);
}
sealed interface Transform permits SuffixTransform, WholeWordTransform {
String apply(String word);
}
record SuffixTransform(int removeLength, String append) implements Transform {
@Override
public String apply(String word) {
var prefixLength = word.length() - removeLength;
var builder = new StringBuilder(prefixLength + append.length());
builder.append(word, 0, prefixLength);
builder.append(adaptCase(append, word.substring(prefixLength)));
return builder.toString();
}
private static String adaptCase(String value, String pattern) {
if (value.isEmpty() || pattern.isEmpty()) {
return value;
}
var chars = value.toCharArray();
var max = Math.min(chars.length, pattern.length());
for (var i = 0; i < max; i++) {
if (Character.isUpperCase(pattern.charAt(i))) {
chars[i] = Character.toUpperCase(chars[i]);
}
}
return new String(chars);
}
}
record WholeWordTransform(String replacement) implements Transform {
@Override
public String apply(String word) {
if (word.isEmpty() || replacement.isEmpty()) {
return replacement;
}
if (Character.isUpperCase(word.charAt(0))) {
return Character.toUpperCase(replacement.charAt(0)) + replacement.substring(1);
}
return replacement;
}
}
private record CompiledRule(int priority, int suffixLength, WordCondition condition, Transform transform) {
private boolean matches(String lowerWord) {
return condition.matches(lowerWord, lowerWord.length() - suffixLength);
}
private String apply(String word) {
return transform.apply(word);
}
}
private static final WordCondition ALWAYS = (lowerWord, suffixStart) -> true;
private static final WordCondition EXACT_WORD = (lowerWord, suffixStart) -> suffixStart == 0;
private final TrieNode root;
private CompiledInflector(TrieNode root) {
this.root = root;
}
public String pluralize(String word) {
var lowerWord = word.toLowerCase(Locale.ROOT);
CompiledRule best = match(root.rules, lowerWord, null);
var node = root;
for (var i = lowerWord.length() - 1; i >= 0; i--) {
node = node.children.get(lowerWord.charAt(i));
if (node == null) {
break;
}
best = match(node.rules, lowerWord, best);
}
if (best == null) {
return word;
}
return best.apply(word);
}
private static CompiledRule match(List<CompiledRule> rules, String lowerWord, CompiledRule currentBest) {
var best = currentBest;
for (CompiledRule rule : rules) {
if (best != null && best.priority() < rule.priority()) {
continue;
}
if (rule.matches(lowerWord)) {
best = rule;
}
}
return best;
}
public static Builder builder() {
return new Builder();
}
public static WordCondition previousCharIn(String chars) {
return (lowerWord, suffixStart) -> suffixStart > 0 && chars.indexOf(lowerWord.charAt(suffixStart - 1)) >= 0;
}
public static WordCondition previousCharNot(char excluded) {
return (lowerWord, suffixStart) -> suffixStart > 0 && lowerWord.charAt(suffixStart - 1) != excluded;
}
public static WordCondition suffixStartAtLeast(int minimum) {
return (lowerWord, suffixStart) -> suffixStart >= minimum;
}
public static WordCondition and(WordCondition... conditions) {
return (lowerWord, suffixStart) -> {
for (WordCondition condition : conditions) {
if (!condition.matches(lowerWord, suffixStart)) {
return false;
}
}
return true;
};
}
public static final class Builder {
private final TrieNode root = new TrieNode();
private int priority;
private Builder() {}
public Builder addSuffixRule(String suffix, int removeLength, String append) {
return addSuffixRule(suffix, ALWAYS, new SuffixTransform(removeLength, append));
}
public Builder addSuffixRule(String suffix, WordCondition condition, int removeLength, String append) {
return addSuffixRule(suffix, condition, new SuffixTransform(removeLength, append));
}
public Builder addSuffixRule(String suffix, WordCondition condition, Transform transform) {
var node = root;
for (var i = suffix.length() - 1; i >= 0; i--) {
node = node.children.computeIfAbsent(suffix.charAt(i), ignored -> new TrieNode());
}
node.rules.add(new CompiledRule(priority++, suffix.length(), condition, transform));
return this;
}
public Builder addWholeWordRule(String singular, String plural) {
return addSuffixRule(singular, EXACT_WORD, new WholeWordTransform(plural));
}
public Builder addPreservedInitialRule(String singular, String plural) {
return addSuffixRule(singular, singular.length() - 1, plural.substring(1));
}
public Builder addCategoryRule(String[] suffixes, int removeLength, String append) {
for (String suffix : suffixes) {
addSuffixRule(suffix, removeLength, append);
}
return this;
}
public Builder addIdentityCategory(String[] suffixes) {
return addCategoryRule(suffixes, 0, "");
}
public CompiledInflector build() {
sortRules(root);
return new CompiledInflector(root);
}
private static void sortRules(TrieNode node) {
node.rules.sort((left, right) -> Integer.compare(left.priority(), right.priority()));
for (TrieNode child : node.children.values()) {
sortRules(child);
}
}
}
private static final class TrieNode {
private final Map<Character, TrieNode> children = new HashMap<>();
private final List<CompiledRule> rules = new ArrayList<>();
}
}