StringConvert.java

/*
 *  Copyright 2010-present Stephen Colebourne
 *
 *  Licensed 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.joda.convert;

import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.Optional;
import java.util.SimpleTimeZone;
import java.util.TimeZone;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArrayList;

import org.joda.convert.factory.BooleanArrayStringConverterFactory;
import org.joda.convert.factory.BooleanObjectArrayStringConverterFactory;
import org.joda.convert.factory.ByteObjectArrayStringConverterFactory;
import org.joda.convert.factory.CharObjectArrayStringConverterFactory;
import org.joda.convert.factory.NumericArrayStringConverterFactory;
import org.joda.convert.factory.NumericObjectArrayStringConverterFactory;

/**
 * Manager for conversion to and from a {@code String}, acting as the main client interface.
 * <p>
 * Support is provided for conversions based on the {@link StringConverter} interface
 * or the {@link ToString} and {@link FromString} annotations.
 * <p>
 * StringConvert is thread-safe with concurrent caches.
 */
public final class StringConvert {
    // NOTE!
    // There must be no references (direct or indirect) to RenameHandler
    // This class must be loaded first to avoid horrid loops in class initialization

    /**
     * Errors in class initialization are hard to debug.
     * Set -Dorg.joda.convert.debug=true on the command line to add extra logging to System.err
     */
    static final boolean LOG;
    static {
        String log = null;
        try {
            log = System.getProperty("org.joda.convert.debug");
        } catch (SecurityException ex) {
            // ignore
        }
        LOG = "true".equalsIgnoreCase(log);
    }
    /**
     * The cached null object. Ensure this is above public constants.
     */
    private static final TypedStringConverter<?> CACHED_NULL = new TypedStringConverter<>() {
        @Override
        public String convertToString(Object object) {
            return null;
        }
        @Override
        public Object convertFromString(Class<? extends Object> cls, String str) {
            return null;
        }
        @Override
        public Class<?> getEffectiveType() {
            return null;
        }
    };
    /**
     * An immutable global instance.
     * <p>
     * This instance cannot be added to using {@link #register}, however annotated classes
     * are picked up. To register your own converters, simply create an instance of this class.
     */
    public static final StringConvert INSTANCE = new StringConvert();

    /**
     * The list of factories.
     */
    private final CopyOnWriteArrayList<StringConverterFactory> factories = new CopyOnWriteArrayList<>();
    /**
     * The cache of converters.
     */
    private final ConcurrentMap<Class<?>, TypedStringConverter<?>> registered = new ConcurrentHashMap<>();
    /**
     * The cache of from-strings.
     */
    private final ConcurrentMap<Class<?>, TypedFromStringConverter<?>> fromStrings = new ConcurrentHashMap<>();

    //-----------------------------------------------------------------------
    /**
     * Creates a new conversion manager including the extended standard set of converters.
     * <p>
     * The returned converter is a new instance that includes additional converters:
     * <ul>
     * <li>JDK converters
     * <li>{@link NumericArrayStringConverterFactory}
     * <li>{@link NumericObjectArrayStringConverterFactory}
     * <li>{@link CharObjectArrayStringConverterFactory}
     * <li>{@link ByteObjectArrayStringConverterFactory}
     * <li>{@link BooleanArrayStringConverterFactory}
     * <li>{@link BooleanObjectArrayStringConverterFactory}
     * </ul>
     * <p>
     * The convert instance is mutable in a thread-safe manner.
     * Converters may be altered at any time, including the JDK converters.
     * It is strongly recommended to only alter the converters before performing
     * actual conversions.
     * 
     * @return the new converter, not null
     * @since 1.5
     */
    public static StringConvert create() {
        return new StringConvert(true, 
                        NumericArrayStringConverterFactory.INSTANCE,
                        NumericObjectArrayStringConverterFactory.INSTANCE,
                        CharObjectArrayStringConverterFactory.INSTANCE,
                        ByteObjectArrayStringConverterFactory.INSTANCE,
                        BooleanArrayStringConverterFactory.INSTANCE,
                        BooleanObjectArrayStringConverterFactory.INSTANCE);
    }

    //-----------------------------------------------------------------------
    /**
     * Creates a new conversion manager including the JDK converters.
     * <p>
     * The convert instance is mutable in a thread-safe manner.
     * Converters may be altered at any time, including the JDK converters.
     * It is strongly recommended to only alter the converters before performing
     * actual conversions.
     */
    public StringConvert() {
        this(true);
    }

    /**
     * Creates a new conversion manager.
     * <p>
     * The convert instance is mutable in a thread-safe manner.
     * Converters may be altered at any time, including the JDK converters.
     * It is strongly recommended to only alter the converters before performing
     * actual conversions.
     * <p>
     * If specified, the factories will be queried in the order specified.
     * 
     * @param includeJdkConverters  true to include the JDK converters
     * @param factories  optional array of factories to use, not null
     */
    public StringConvert(boolean includeJdkConverters, StringConverterFactory... factories) {
        if (factories == null) {
            throw new IllegalArgumentException("StringConverterFactory array must not be null");
        }
        for (var factory : factories) {
            if (factory == null) {
                throw new IllegalArgumentException("StringConverterFactory array must not contain a null element");
            }
        }
        if (includeJdkConverters) {
            for (var conv : JDKStringConverter.values()) {
                registered.put(conv.getEffectiveType(), conv);
            }
            registered.put(Boolean.TYPE, JDKStringConverter.BOOLEAN);
            registered.put(Byte.TYPE, JDKStringConverter.BYTE);
            registered.put(Short.TYPE, JDKStringConverter.SHORT);
            registered.put(Integer.TYPE, JDKStringConverter.INTEGER);
            registered.put(Long.TYPE, JDKStringConverter.LONG);
            registered.put(Float.TYPE, JDKStringConverter.FLOAT);
            registered.put(Double.TYPE, JDKStringConverter.DOUBLE);
            registered.put(Character.TYPE, JDKStringConverter.CHARACTER);
            tryRegisterGuava();
            tryRegisterZoneSubclasses();
            tryRegisterThreeTenBackport();
        }
        if (factories.length > 0) {
            this.factories.addAll(Arrays.asList(factories));
        }
        this.factories.add(AnnotationStringConverterFactory.INSTANCE);
        if (includeJdkConverters) {
            this.factories.add(EnumStringConverterFactory.INSTANCE);
            this.factories.add(TypeStringConverterFactory.INSTANCE);
        }
    }

    /**
     * Tries to register the Guava converters class.
     */
    private void tryRegisterGuava() {
        try {
            var conv = new TypeTokenStringConverter();
            registered.put(conv.getEffectiveType(), conv);

        } catch (Throwable ex) {
            if (LOG) {
                System.err.println("Joda-Convert: Unable to register Guava: " + ex);
            }
        }
    }

    /**
     * Tries to register the subclasses of TimeZone and ZoneId.
     * Try various things, doesn't matter if the map entry gets overwritten.
     */
    private void tryRegisterZoneSubclasses() {
        try {
            registered.put(SimpleTimeZone.class, JDKStringConverter.TIME_ZONE);

        } catch (Throwable ex) {
            if (LOG) {
                System.err.println("Joda-Convert: Error registering SimpleTimeZone: " + ex);
            }
        }
        try {
            var zone = TimeZone.getDefault();
            registered.put(zone.getClass(), JDKStringConverter.TIME_ZONE);

        } catch (Throwable ex) {
            if (LOG) {
                System.err.println("Joda-Convert: Error registering TimeZone.getDefault(): " + ex);
            }
        }
        try {
            var zone = TimeZone.getTimeZone("Europe/London");
            registered.put(zone.getClass(), JDKStringConverter.TIME_ZONE);

        } catch (Throwable ex) {
            if (LOG) {
                System.err.println("Joda-Convert: Error registering TimeZone.getTimeZone(zoneId): " + ex);
            }
        }
        try {
            var zone = ZoneId.of("Europe/London");
            registered.put(zone.getClass(), JDKStringConverter.ZONE_ID);

        } catch (Throwable ex) {
            if (LOG) {
                System.err.println("Joda-Convert: Error registering ZoneId.of(zoneId): " + ex);
            }
        }
    }

    /**
     * Tries to register ThreeTen-Backport classes.
     */
    private void tryRegisterThreeTenBackport() {
        try {
            for (var conv : ThreeTenBpStringConverter.values()) {
                registered.put(conv.getEffectiveType(), conv);
            }
            var zone = org.threeten.bp.ZoneId.of("Europe/London");
            registered.put(zone.getClass(), ThreeTenBpStringConverter.ZONE_ID);

        } catch (Throwable ex) {
            if (LOG) {
                System.err.println("Joda-Convert: Unable to register ThreeTen-Backport: " + ex);
            }
        }
    }

    //-----------------------------------------------------------------------
    /**
     * Converts the specified object to a {@code String}.
     * <p>
     * This uses {@link #findConverter} to provide the converter.
     * 
     * @param object  the object to convert, null returns null
     * @return the converted string, may be null
     * @throws RuntimeException (or subclass) if unable to convert
     */
    public String convertToString(Object object) {
        if (object == null) {
            return null;
        }
        var cls = object.getClass();
        var conv = findConverterNoGenerics(cls);
        return conv.convertToString(object);
    }

    /**
     * Converts the specified object to a {@code String}.
     * <p>
     * This uses {@link #findConverter} to provide the converter.
     * The class can be provided to select a more specific converter.
     * 
     * @param cls  the class to convert from, not null
     * @param object  the object to convert, null returns null
     * @return the converted string, may be null
     * @throws RuntimeException (or subclass) if unable to convert
     */
    public String convertToString(Class<?> cls, Object object) {
        if (object == null) {
            return null;
        }
        var conv = findConverterNoGenerics(cls);
        return conv.convertToString(object);
    }

    /**
     * Converts the specified object from a {@code String}.
     * <p>
     * This uses {@link #findFromStringConverter} to provide the converter.
     * 
     * @param <T>  the type to convert to
     * @param cls  the class to convert to, not null
     * @param str  the string to convert, null returns null
     * @return the converted object, may be null
     * @throws RuntimeException (or subclass) if unable to convert
     */
    public <T> T convertFromString(Class<T> cls, String str) {
        if (str == null) {
            return null;
        }
        var conv = findFromStringConverter(cls);
        return conv.convertFromString(cls, str);
    }

    //-----------------------------------------------------------------------
    /**
     * Checks if a suitable converter exists for the type.
     * <p>
     * This performs the same checks as the {@code findConverter} methods.
     * Calling this before {@code findConverter} will cache the converter.
     * <p>
     * Note that all exceptions, including developer errors are caught and hidden.
     * 
     * @param cls  the class to find a converter for, null returns false
     * @return true if convertible
     * @since 1.5
     */
    public boolean isConvertible(Class<?> cls) {
        try {
            return cls != null && findConverterQuiet(cls) != null;
        } catch (RuntimeException ex) {
            return false;
        }
    }

    /**
     * Finds a suitable converter for the type.
     * <p>
     * This returns an instance of {@code TypedStringConverter} for the specified class.
     * This is designed for user code where the {@code Class} object generics is known.
     * <p>
     * The search algorithm first searches the registered converters.
     * It then searches for {@code ToString} and {@code FromString} annotations on the
     * specified class, class hierarchy or immediate parent interfaces.
     * Finally, it handles {@code Enum} instances.
     * <p>
     * The returned converter may be queried for the effective type of the conversion.
     * This can be used to find the best type to send in a serialized form.
     * <p>
     * Unusually for a method returning {@code Optional}, the method can also throw an exception.
     * An empty result indicates that there is no converter for the specified class.
     * An exception indicates there is a developer error in one of the converter factories.
     * 
     * @param <T>  the type of the converter
     * @param cls  the class to find a converter for, not null
     * @return the converter, empty if not found
     * @throws IllegalArgumentException if the input class is null
     * @throws RuntimeException (or subclass) if there is a developer error in one of the converter factories
     * @since 3.0
     */
    public <T> Optional<TypedStringConverter<T>> converterFor(Class<T> cls) {
        // NOTE: ideally this method would have been called findConverter(), but that method name is already in use
        return Optional.ofNullable(findConverterQuiet(cls));
    }

    /**
     * Finds a suitable from-string converter for the type.
     * <p>
     * This returns an instance of {@code FromStringConverter} for the specified class.
     * In most cases this is identical to {@link #findConverter(Class)}.
     * However, it is permitted to have a {@code FromString} annotation without a {@code ToString} annotation,
     * and this method handles that use case.
     * <p>
     * Unusually for a method returning {@code Optional}, the method can also throw an exception.
     * An empty result indicates that there is no converter for the specified class.
     * An exception indicates there is a developer error in one of the converter factories.
     * 
     * @param <T>  the type of the converter
     * @param cls  the class to find a converter for, not null
     * @return the converter, not null
     * @throws IllegalArgumentException if the input class is null
     * @throws RuntimeException (or subclass) if there is a developer error in one of the converters
     * @since 3.0
     */
    @SuppressWarnings("unchecked")
    public <T> Optional<TypedFromStringConverter<T>> fromStringConverterFor(Class<T> cls) {
        var converter = findConverterQuiet(cls);
        if (converter == null) {
            var fromStringConverter = (TypedFromStringConverter<T>) fromStrings.get(cls);
            return fromStringConverter == CACHED_NULL ? Optional.empty() : Optional.of(fromStringConverter);
        }
        return Optional.of(converter);
    }

    //-------------------------------------------------------------------------
    /**
     * Finds a suitable converter for the type.
     * <p>
     * This returns an instance of {@code StringConverter} for the specified class.
     * This is designed for user code where the {@code Class} object generics is known.
     * <p>
     * The search algorithm first searches the registered converters.
     * It then searches for {@code ToString} and {@code FromString} annotations on the
     * specified class, class hierarchy or immediate parent interfaces.
     * Finally, it handles {@code Enum} instances.
     * 
     * @param <T>  the type of the converter
     * @param cls  the class to find a converter for, not null
     * @return the converter, not null
     * @throws RuntimeException (or subclass) if no converter found
     */
    public <T> StringConverter<T> findConverter(Class<T> cls) {
        return findTypedConverter(cls);
    }

    /**
     * Finds a suitable converter for the type with open generics.
     * <p>
     * This returns an instance of {@code StringConverter} for the specified class.
     * This is designed for framework usage where the {@code Class} object generics are unknown'?'.
     * The returned type is declared with {@code Object} instead of '?' to
     * allow the {@link ToStringConverter} to be invoked.
     * <p>
     * The search algorithm first searches the registered converters.
     * It then searches for {@code ToString} and {@code FromString} annotations on the
     * specified class, class hierarchy or immediate parent interfaces.
     * Finally, it handles {@code Enum} instances.
     * 
     * @param cls  the class to find a converter for, not null
     * @return the converter, using {@code Object} to avoid generics, not null
     * @throws RuntimeException (or subclass) if no converter found
     * @since 1.5
     */
    public StringConverter<Object> findConverterNoGenerics(Class<?> cls) {
        return findTypedConverterNoGenerics(cls);
    }

    /**
     * Finds a suitable converter for the type.
     * <p>
     * This returns an instance of {@code TypedStringConverter} for the specified class.
     * This is designed for user code where the {@code Class} object generics is known.
     * <p>
     * The search algorithm first searches the registered converters.
     * It then searches for {@code ToString} and {@code FromString} annotations on the
     * specified class, class hierarchy or immediate parent interfaces.
     * Finally, it handles {@code Enum} instances.
     * <p>
     * The returned converter may be queried for the effective type of the conversion.
     * This can be used to find the best type to send in a serialized form.
     * <p>
     * NOTE: Changing the method return type of {@link #findConverter(Class)}
     * would be source compatible but not binary compatible. As this is a low-level
     * library, binary compatibility is important, hence the addition of this method.
     * 
     * @param <T>  the type of the converter
     * @param cls  the class to find a converter for, not null
     * @return the converter, not null
     * @throws RuntimeException (or subclass) if no converter found
     * @since 1.7
     */
    public <T> TypedStringConverter<T> findTypedConverter(Class<T> cls) {
        // NOTE: Not using converterFor() to avoid any kind of performance regression
        var conv = findConverterQuiet(cls);
        if (conv == null) {
            throw new IllegalStateException("No registered converter found: " + cls);
        }
        return conv;
    }

    /**
     * Finds a suitable converter for the type with open generics.
     * <p>
     * This returns an instance of {@code TypedStringConverter} for the specified class.
     * This is designed for framework usage where the {@code Class} object generics are unknown'?'.
     * The returned type is declared with {@code Object} instead of '?' to
     * allow the {@link ToStringConverter} to be invoked.
     * <p>
     * The search algorithm first searches the registered converters.
     * It then searches for {@code ToString} and {@code FromString} annotations on the
     * specified class, class hierarchy or immediate parent interfaces.
     * Finally, it handles {@code Enum} instances.
     * <p>
     * The returned converter may be queried for the effective type of the conversion.
     * This can be used to find the best type to send in a serialized form.
     * <p>
     * NOTE: Changing the method return type of {@link #findConverterNoGenerics(Class)}
     * would be source compatible but not binary compatible. As this is a low-level
     * library, binary compatibility is important, hence the addition of this method.
     * 
     * @param cls  the class to find a converter for, not null
     * @return the converter, using {@code Object} to avoid generics, not null
     * @throws RuntimeException (or subclass) if no converter found
     * @since 1.7
     */
    @SuppressWarnings("unchecked")
    public TypedStringConverter<Object> findTypedConverterNoGenerics(Class<?> cls) {
        // NOTE: Not using converterFor() to avoid any kind of performance regression
        var conv = (TypedStringConverter<Object>) findConverterQuiet(cls);
        if (conv == null) {
            throw new IllegalStateException("No registered converter found: " + cls);
        }
        return conv;
    }

    /**
     * Finds a suitable from-string converter for the type.
     * <p>
     * This returns an instance of {@code FromStringConverter} for the specified class.
     * In most cases this is identical to {@link #findConverter(Class)}.
     * However, it is permitted to have a {@code FromString} annotation without a {@code ToString} annotation,
     * and this method handles that use case.
     * 
     * @param <T>  the type of the converter
     * @param cls  the class to find a converter for, not null
     * @return the converter, not null
     * @throws RuntimeException (or subclass) if no converter found
     */
    @SuppressWarnings("unchecked")
    public <T> FromStringConverter<T> findFromStringConverter(Class<T> cls) {
        return fromStringConverterFor(cls)
                .orElseThrow(() -> new IllegalStateException("No registered converter found: " + cls));
    }

    //-------------------------------------------------------------------------
    // low-level method to find a converter, returning null if not found, unless a factory threw an exception
    // the result is a full-featured converter
    // if the class only has a from-string converter, the fromStrings map is updated
    @SuppressWarnings("unchecked")
    private <T> TypedStringConverter<T> findConverterQuiet(Class<T> cls) {
        if (cls == null) {
            throw new IllegalArgumentException("Class must not be null");
        }
        var conv = (TypedStringConverter<T>) registered.get(cls);
        if (conv == CACHED_NULL) {
            return null;
        }
        if (conv == null) {
            try {
                conv = lookupConverter(cls);
            } catch (RuntimeException ex) {
                registered.putIfAbsent(cls, CACHED_NULL);
                throw ex;
            }
            if (conv == null) {
                registered.putIfAbsent(cls, CACHED_NULL);
                // search for from-string only converters now, so that our cache is accurate for all kinds of converter
                fromStrings.computeIfAbsent(
                        cls,
                        key -> {
                            var fromString = AnnotationStringConverterFactory.INSTANCE.findFromStringConverter(cls);
                            return fromString != null ? fromString : CACHED_NULL;
                        });
                return null;  // caller must lookup fromStrings map
            }
            registered.putIfAbsent(cls, conv);
        }
        return conv;
    }

    /**
     * Lookup a converter searching registered and annotated.
     * 
     * @param <T>  the type of the converter
     * @param cls  the class to find a method for, not null
     * @return the converter, not null
     * @throws RuntimeException if invalid
     */
    @SuppressWarnings("unchecked")
    private <T> TypedStringConverter<T> lookupConverter(Class<T> cls) {
        // check factories
        for (var factory : factories) {
            var factoryConv = (StringConverter<T>) factory.findConverter(cls);
            if (factoryConv != null) {
                return TypedAdapter.adapt(cls, factoryConv);
            }
        }
        return null;
    }

    //-----------------------------------------------------------------------
    /**
     * Registers a converter factory.
     * <p>
     * This will be registered ahead of all existing factories.
     * <p>
     * No new factories may be registered for the global singleton.
     * 
     * @param factory  the converter factory, not null
     * @throws IllegalStateException if trying to alter the global singleton
     * @since 1.5
     */
    public void registerFactory(StringConverterFactory factory) {
        if (factory == null) {
            throw new IllegalArgumentException("Factory must not be null");
        }
        if (this == INSTANCE) {
            throw new IllegalStateException("Global singleton cannot be extended");
        }
        factories.add(0, factory);
    }

    //-----------------------------------------------------------------------
    /**
     * Registers a converter for a specific type.
     * <p>
     * The converter will be used for subclasses unless overidden.
     * <p>
     * No new converters may be registered for the global singleton.
     * 
     * @param <T>  the type of the converter
     * @param cls  the class to register a converter for, not null
     * @param converter  the String converter, not null
     * @throws IllegalArgumentException if the class or converter are null
     * @throws IllegalStateException if trying to alter the global singleton
     */
    public <T> void register(Class<T> cls, StringConverter<T> converter) {
        if (cls == null) {
            throw new IllegalArgumentException("Class must not be null");
        }
        if (converter == null) {
            throw new IllegalArgumentException("StringConverter must not be null");
        }
        if (this == INSTANCE) {
            throw new IllegalStateException("Global singleton cannot be extended");
        }
        registered.put(cls, TypedAdapter.adapt(cls, converter));
    }

    /**
     * Registers a converter for a specific type using two separate converters.
     * <p>
     * This method registers a converter for the specified class.
     * It is primarily intended for use with JDK 1.8 method references or lambdas:
     * <pre>
     *  sc.register(Distance.class, Distance::toString, Distance::parse);
     * </pre>
     * The converter will be used for subclasses unless overidden.
     * <p>
     * No new converters may be registered for the global singleton.
     * 
     * @param <T>  the type of the converter
     * @param cls  the class to register a converter for, not null
     * @param toString  the to String converter, typically a method reference, not null
     * @param fromString  the from String converter, typically a method reference, not null
     * @throws IllegalArgumentException if the class or converter are null
     * @throws IllegalStateException if trying to alter the global singleton
     * @since 1.3
     */
    public <T> void register(Class<T> cls, ToStringConverter<T> toString, FromStringConverter<T> fromString) {
        if (fromString == null || toString == null) {
            throw new IllegalArgumentException("Converters must not be null");
        }
        register(cls, new TypedStringConverter<T>() {
            @Override
            public String convertToString(T object) {
                return toString.convertToString(object);
            }
            @Override
            public T convertFromString(Class<? extends T> cls, String str) {
                return fromString.convertFromString(cls, str);
            }
            @Override
            public Class<?> getEffectiveType() {
                return cls;
            }
        });
    }

    /**
     * Registers a converter for a specific type by method names.
     * <p>
     * This method allows the converter to be used when the target class cannot have annotations added.
     * The two method names must obey the same rules as defined by the annotations
     * {@link ToString} and {@link FromString}.
     * The converter will be used for subclasses unless overidden.
     * <p>
     * No new converters may be registered for the global singleton.
     * <p>
     * For example, {@code convert.registerMethods(Distance.class, "toString", "parse");}
     * 
     * @param <T>  the type of the converter
     * @param cls  the class to register a converter for, not null
     * @param toStringMethodName  the name of the method converting to a string, not null
     * @param fromStringMethodName  the name of the method converting from a string, not null
     * @throws IllegalArgumentException if the class or method name are null or invalid
     * @throws IllegalStateException if trying to alter the global singleton
     */
    public <T> void registerMethods(Class<T> cls, String toStringMethodName, String fromStringMethodName) {
        if (cls == null) {
            throw new IllegalArgumentException("Class must not be null");
        }
        if (toStringMethodName == null || fromStringMethodName == null) {
            throw new IllegalArgumentException("Method names must not be null");
        }
        if (this == INSTANCE) {
            throw new IllegalStateException("Global singleton cannot be extended");
        }
        var toString = findToStringMethod(cls, toStringMethodName);
        var fromString = findFromStringMethod(cls, fromStringMethodName);
        var fromStringConverter = new MethodFromStringConverter<>(cls, fromString, cls);
        var converter = new ReflectionStringConverter<>(cls, toString, fromStringConverter);
        registered.putIfAbsent(cls, converter);
    }

    /**
     * Registers a converter for a specific type by method and constructor.
     * <p>
     * This method allows the converter to be used when the target class cannot have annotations added.
     * The two method name and constructor must obey the same rules as defined by the annotations
     * {@link ToString} and {@link FromString}.
     * The converter will be used for subclasses unless overidden.
     * <p>
     * No new converters may be registered for the global singleton.
     * <p>
     * For example, {@code convert.registerMethodConstructor(Distance.class, "toString");}
     * 
     * @param <T>  the type of the converter
     * @param cls  the class to register a converter for, not null
     * @param toStringMethodName  the name of the method converting to a string, not null
     * @throws IllegalArgumentException if the class or method name are null or invalid
     * @throws IllegalStateException if trying to alter the global singleton
     */
    public <T> void registerMethodConstructor(Class<T> cls, String toStringMethodName) {
        if (cls == null) {
            throw new IllegalArgumentException("Class must not be null");
        }
        if (toStringMethodName == null) {
            throw new IllegalArgumentException("Method name must not be null");
        }
        if (this == INSTANCE) {
            throw new IllegalStateException("Global singleton cannot be extended");
        }
        var toString = findToStringMethod(cls, toStringMethodName);
        var fromString = findFromStringConstructorByType(cls);
        var fromStringConverter = new ConstructorFromStringConverter<>(cls, fromString);
        var converter = new ReflectionStringConverter<>(cls, toString, fromStringConverter);
        registered.putIfAbsent(cls, converter);
    }

    /**
     * Finds the conversion method.
     * 
     * @param cls  the class to find a method for, not null
     * @param methodName  the name of the method to find, not null
     * @return the method to call, null means use {@code toString}
     */
    private Method findToStringMethod(Class<?> cls, String methodName) {
        try {
            var m = cls.getMethod(methodName);
            if (Modifier.isStatic(m.getModifiers())) {
                throw new IllegalArgumentException("Method must not be static: " + methodName);
            }
            return m;
        } catch (NoSuchMethodException ex) {
            throw new IllegalArgumentException(ex);
        }
    }

    /**
     * Finds the conversion method.
     * 
     * @param cls  the class to find a method for, not null
     * @param methodName  the name of the method to find, not null
     * @return the method to call, null means use {@code toString}
     */
    private Method findFromStringMethod(Class<?> cls, String methodName) {
        Method m;
        try {
            m = cls.getMethod(methodName, String.class);
        } catch (NoSuchMethodException ex) {
            try {
                m = cls.getMethod(methodName, CharSequence.class);
            } catch (NoSuchMethodException ex2) {
                throw new IllegalArgumentException("Method not found", ex2);
            }
        }
        if (Modifier.isStatic(m.getModifiers()) == false) {
            throw new IllegalArgumentException("Method must be static: " + methodName);
        }
        return m;
    }

    /**
     * Finds the conversion method.
     * 
     * @param <T>  the type of the converter
     * @param cls  the class to find a method for, not null
     * @return the method to call, null means use {@code toString}
     */
    private <T> Constructor<T> findFromStringConstructorByType(Class<T> cls) {
        try {
            return cls.getDeclaredConstructor(String.class);
        } catch (NoSuchMethodException ex) {
            try {
                return cls.getDeclaredConstructor(CharSequence.class);
            } catch (NoSuchMethodException ex2) {
                throw new IllegalArgumentException("Constructor not found", ex2);
            }
        }
    }

    //-----------------------------------------------------------------------
    // loads a type avoiding nulls, context class loader if available
    static Class<?> loadType(String fullName) throws ClassNotFoundException {
        try {
            var loader = Thread.currentThread().getContextClassLoader();
            return loader != null && !fullName.startsWith("[") ? loader.loadClass(fullName) : Class.forName(fullName);
        } catch (ClassNotFoundException ex) {
            return loadPrimitiveType(fullName, ex);
        }
    }

    // handle primitive types
    private static Class<?> loadPrimitiveType(String fullName, ClassNotFoundException ex) throws ClassNotFoundException {
        if (fullName.equals("int")) {
            return int.class;
        } else if (fullName.equals("long")) {
            return long.class;
        } else if (fullName.equals("double")) {
            return double.class;
        } else if (fullName.equals("boolean")) {
            return boolean.class;
        } else if (fullName.equals("short")) {
            return short.class;
        } else if (fullName.equals("byte")) {
            return byte.class;
        } else if (fullName.equals("char")) {
            return char.class;
        } else if (fullName.equals("float")) {
            return float.class;
        } else if (fullName.equals("void")) {
            return void.class;
        }
        throw ex;
    }

    //-----------------------------------------------------------------------
    /**
     * Returns a simple string representation of the object.
     * 
     * @return the string representation, never null
     */
    @Override
    public String toString() {
        return getClass().getSimpleName();
    }

}