NamedEntityInlineParser.java
/*
* Copyright © 2026 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.gfm.ext;
import java.util.Set;
import org.commonmark.parser.beta.InlineContentParser;
import org.commonmark.parser.beta.InlineContentParserFactory;
import org.commonmark.parser.beta.InlineParserState;
import org.commonmark.parser.beta.ParsedInline;
import org.commonmark.parser.beta.Scanner;
/**
* Inline content parser that recognises {@code &Name;} tokens and produces
* {@link NamedEntityNode} AST nodes.
*
* <p>
* It is triggered on every {@code &} character. When the text following
* {@code &} matches {@code [A-Za-z][A-Za-z0-9]*;} the name is extracted. The
* parser unconditionally produces a node for syntactically valid entities (i.e.
* well-formed {@code &Name;} sequences); resolution to HTML is the renderer's
* responsibility — unknown names fall back to the literal text.
*/
final class NamedEntityInlineParser implements InlineContentParser {
// -------------------------------------------------------------------------
// Factory (registered with Parser.Builder)
// -------------------------------------------------------------------------
static final class Factory implements InlineContentParserFactory {
@Override
public InlineContentParser create() {
return new NamedEntityInlineParser();
}
@Override
public Set<Character> getTriggerCharacters() {
return Set.of('&');
}
}
// -------------------------------------------------------------------------
// InlineContentParser
// -------------------------------------------------------------------------
/**
* Attempts to consume {@code &Name;} from the current scanner position.
*
* <p>
* The scanner is positioned <em>after</em> the trigger character
* ({@code &}) when this method is called.
*/
@Override
public ParsedInline tryParse(InlineParserState inlineParserState) {
Scanner scanner = inlineParserState.scanner();
// Read the entity name: must start with a letter.
// int nameStart = scanner.position().index();
if (!scanner.hasNext()) {
return ParsedInline.none();
}
char first = scanner.peek();
if (!Character.isLetter(first)) {
return ParsedInline.none();
}
// Consume [A-Za-z0-9]* until we hit ';' or end-of-input.
StringBuilder name = new StringBuilder();
while (scanner.hasNext()) {
char c = scanner.peek();
if (c == ';') {
break;
}
if (!Character.isLetterOrDigit(c)) {
// Not a valid entity character — bail out.
return ParsedInline.none();
}
name.append(c);
scanner.next();
}
if (name.isEmpty()) {
return ParsedInline.none();
}
// Consume the closing ';'.
if (!scanner.hasNext() || scanner.peek() != ';') {
return ParsedInline.none();
}
scanner.next(); // consume ';'
return ParsedInline.of(new NamedEntityNode(name.toString()),
scanner.position());
}
}