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());
    }
}