HighlighterService.java

/*
 * Copyright © 2014-2025 The CTAN Team and individual authors
 *
 * This file is distributed under the 3-clause BSD license.
 * See file LICENSE for details.
 */
package org.ctan.markup.markdown.syntax;

import java.io.IOException;
import java.io.PushbackReader;
import java.io.Writer;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import lombok.extern.slf4j.Slf4j;

/**
 * This class provides a singleton entry point for syntax highlighting.
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@Slf4j
public class HighlighterService {

    /**
     * The field <code>PLAIN</code> contains the plain highlighter which escaped
     * the HTML special characters only.
     */
    private static final Highlighter PLAIN = (reader, out) -> {
        var in = new HighlightReader(reader);
        var cc = in.read();
        for (var c = in.read(); c >= 0; c = in.read()) {
            switch (cc) {
                case '&':
                    out.write("&amp;");
                    break;
                case '<':
                    out.write("&lt;");
                    break;
                case '>':
                    out.write("&gt;");
                    break;
                default:
                    out.append((char) cc);
                    break;
            }
            cc = c;
        }
        switch (cc) {
            case '&':
                out.write("&amp;");
                break;
            case '<':
                out.write("&lt;");
                break;
            case '>':
                out.write("&gt;");
                break;
            default:
                if (cc >= 0 && cc != '\n') {
                    out.append((char) cc);
                }
                break;
        }
        in.close();
    };

    /**
     * The field <code>SERVICE_INSTANCE</code> contains the singleton instance.
     */
    private static final HighlighterService SERVICE_INSTANCE =
        new HighlighterService();

    /**
     * This method <code>getService</code> returns the singleton instance of
     * this class.
     *
     * @return the singleton instance
     */
    public static synchronized HighlighterService getService() {

        return SERVICE_INSTANCE;
    }

    /**
     * The field <code>factories</code> contains the {@link Highlighter}s
     * already created for the registered languages.
     */
    private Map<String, Highlighter> factories =
        new HashMap<String, Highlighter>();

    /**
     * The field <code>highlighterClassNames</code> contains the mapping from
     * language name to the full class name of the {@link Highlighter}.
     */
    private Properties highlighterClassNames;

    /**
     * This is the constructor for <code>HighlighterFactory</code>.
     *
     * @throws RuntimeException in case of an error
     */
    @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW")
    private HighlighterService() {

        highlighterClassNames = new Properties();
        var name =
            HighlighterService.class.getName().replaceAll("\\.", "/")
                + ".properties";
        var inStream =
            HighlighterService.class.getClassLoader()
                .getResourceAsStream(name);
        try {
            highlighterClassNames.load(inStream);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * This method whether a given language is supported.
     *
     * @param key the name of the language; not {@code null}
     *
     * @return {@code true} iff the given language can be treated specially
     */
    public boolean canHandle(String key) {

        return highlighterClassNames.get(key.toLowerCase()) != null;
    }

    /**
     * This method highlights a section for the given language.
     *
     * @param key the key of the language; not {@code null}
     * @param reader the input reader
     * @param out the target writer
     *
     * @throws IOException in case of an I/O error
     */
    public void highlight(String key, PushbackReader reader, Writer out)
        throws IOException {

        key = key.toLowerCase();
        var highlighter = factories.get(key);
        if (highlighter == null) {
            var clazz = highlighterClassNames.getProperty(key);
            if (clazz != null) {
                try {
                    Class<?> c = getClass().getClassLoader().loadClass(clazz);
                    highlighter = (Highlighter) c.getDeclaredConstructor()
                        .newInstance();
                } catch (ClassNotFoundException | InstantiationException
                    | IllegalAccessException | ClassCastException
                    | IllegalArgumentException
                    | InvocationTargetException
                    | NoSuchMethodException | SecurityException e) {
                    log.error(clazz + ": " + e.getMessage());
                }
            }
            if (highlighter != null) {
                factories.put(key, highlighter);
            } else {
                highlighter = PLAIN;
            }
        }
        highlighter.render(reader, out);
    }
}