LogProviderRef.java

package io.jstach.rainbowgum;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collection;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.TreeSet;

import org.jspecify.annotations.Nullable;

import io.jstach.rainbowgum.LogProperty.PropertyProblem;
import io.jstach.rainbowgum.LogProperty.Result;

/**
 * Log LogProvider Source.
 */
public sealed interface LogProviderRef {

	/**
	 * URI of the log provider.
	 * @return uri not <code>null</code>.
	 */
	URI uri();

	/**
	 * Property key from where the URI came from or <code>null</code>.
	 * @return property key.
	 */
	@Nullable String keyOrNull();

	/**
	 * Creates a log provider ref from URI.
	 * @param uri uri.
	 * @return ref.
	 */
	public static LogProviderRef of(URI uri) {
		return new DefaultLogProviderRef(uri, null);
	}

	/**
	 * Create a provider ref from a property result.
	 * @param property successful URI result.
	 * @return provider ref.
	 */
	public static LogProviderRef of(Result.Success<? extends URI> property) {
		return new DefaultLogProviderRef(property.value(), property.key());
	}

	/**
	 * Creates a log provider ref from URI.
	 * @param uri uri.
	 * @param key property key where URI came from.
	 * @return ref.
	 */
	public static LogProviderRef of(URI uri, @Nullable String key) {
		return new DefaultLogProviderRef(uri, key);
	}

	/**
	 * Thrown if a provider could not be found for the ref.
	 */
	public static final class NotFoundException extends NoSuchElementException implements PropertyProblem {

		private static final long serialVersionUID = 5484668688480940159L;

		NotFoundException(String s) {
			super(s);
		}

		@Override
		public String errorName() {
			return "NotFoundException";
		}

		/**
		 * Builds a {@link NotFoundException} for an unregistered scheme, with a message
		 * consistent across every kind of provider registry (output/publisher/encoder/
		 * etc). If the scheme happens to be one known to be provided by an optional
		 * module not currently on the classpath (see {@link ProviderModule}), the message
		 * is extended to say which dependency to add. Otherwise it suggests a similarly
		 * spelled scheme and lists the registered ones.
		 * @param component the kind of thing not found.
		 * @param scheme the URI scheme that had no provider registered for it.
		 * @param uri the full URI that was being resolved.
		 * @param registered the schemes registered for the component.
		 * @return exception, not thrown.
		 */
		static NotFoundException of(ProviderModule.ComponentType component, String scheme, URI uri,
				Collection<? extends String> registered) {
			String message = "No " + component.label() + " found. Scheme not registered. scheme: '" + scheme
					+ "', URI: '" + LogRedactor.redactUri(uri) + "'";
			var module = ProviderModule.find(component, scheme);
			if (module.isPresent()) {
				return new NotFoundException(message + moduleHint(module.get()));
			}
			var candidates = new TreeSet<String>(registered);
			for (var m : ProviderModule.values()) {
				if (m.componentType() == component) {
					candidates.add(m.scheme());
				}
			}
			String suggested = similarlySpelledScheme(scheme, candidates);
			if (suggested != null) {
				message += ". Did you mean '" + suggested + "'?";
			}
			// core. prefixed aliases only repeat the plain schemes.
			var listed = new TreeSet<String>(registered);
			listed.removeIf(r -> r.startsWith("core."));
			if (!listed.isEmpty()) {
				message += (suggested == null ? ". " : " ") + "Registered " + component.label() + " schemes: "
						+ String.join(", ", listed.stream().map(r -> "'" + r + "'").toList());
			}
			if (suggested != null && !registered.contains(suggested)) {
				var suggestedModule = ProviderModule.find(component, suggested);
				if (suggestedModule.isPresent()) {
					message += moduleHint(suggestedModule.get());
				}
			}
			return new NotFoundException(message);
		}

		private static String moduleHint(ProviderModule m) {
			return ". Scheme '" + m.scheme() + "' is provided by module '" + m.moduleName() + "' (Maven: '"
					+ m.mavenGav() + "'). Add that dependency. See " + m.docUrl();
		}

		/*
		 * The same spelling distance as unused key suggestions, but scaled to the
		 * scheme's length so a short unknown scheme is not matched to an unrelated short
		 * one.
		 */
		private static @Nullable String similarlySpelledScheme(String scheme, Set<String> candidates) {
			String normalized = UnusedKeyCheck.normalize(scheme);
			String best = null;
			int bestDistance = UnusedKeyCheck.MAX_DISTANCE + 1;
			for (String candidate : candidates) {
				if (UnusedKeyCheck.normalize(candidate).equals(normalized)) {
					return candidate;
				}
				int limit = Math.min(UnusedKeyCheck.MAX_DISTANCE, Math.max(1, candidate.length() / 3));
				int distance = UnusedKeyCheck.distance(scheme, candidate, bestDistance);
				if (distance <= limit && distance < bestDistance) {
					best = candidate;
					bestDistance = distance;
				}
			}
			return best;
		}

	}

}

record DefaultLogProviderRef(URI uri, @Nullable String keyOrNull) implements LogProviderRef {
	static URI normalize(URI uri) {
		String scheme = uri.getScheme();
		String path = uri.getPath();
		try {
			if (scheme == null) {
				if (path == null) {
					throw new IllegalArgumentException("URI is not proper: " + LogRedactor.redactUri(uri));
				}
				uri = new URI(path + ":///");
				if (uri.getScheme() == null) {
					/*
					 * A leading "/" or "." keeps the URI parser from ever attempting
					 * scheme detection, so appending ":///" above silently produces
					 * another scheme-less URI instead of throwing. Fail clearly here
					 * rather than letting an unresolvable URI reach a provider registry.
					 */
					throw new IllegalArgumentException("URI is not proper: " + LogRedactor.redactUri(uri));
				}
			}
		}
		catch (URISyntaxException e) {
			throw new IllegalArgumentException("URI is not proper: " + LogRedactor.redactUri(uri));
		}
		return uri;
	}

	static LogProviderRef normalize(LogProviderRef ref) {
		var uri = ref.uri();
		uri = DefaultLogProviderRef.normalize(uri);
		return new DefaultLogProviderRef(uri, ref.keyOrNull());
	}
}