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("&");
break;
case '<':
out.write("<");
break;
case '>':
out.write(">");
break;
default:
out.append((char) cc);
break;
}
cc = c;
}
switch (cc) {
case '&':
out.write("&");
break;
case '<':
out.write("<");
break;
case '>':
out.write(">");
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);
}
}