InsAnalyzer.java

/*
 * Copyright © 2017-2026 The CTAN Team and individual authors
 *
 * This file is distributed under the 3-clause BSD license.
 * See file LICENSE for details.
 */
package minitex;

import java.io.EOFException;
import java.io.IOException;
import java.io.PushbackReader;
import java.io.Reader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * This class contains a parser to analyse {@code .ins} (installer) files as
 * used by the CTAN/LaTeX packaging tools.
 *
 * <p>
 * An {@code .ins} file is a small TeX-like script which, among other things,
 * declares which files a package generates or ships. This parser does not
 * attempt to interpret the full TeX macro language; instead it recognizes a
 * small, fixed set of macros ({@code \file} and {@code \generateFile}) that
 * name output files, skips TeX comments introduced by {@code %}, and silently
 * ignores everything else (including grouping braces <code>{</code> and
 * <code>}</code>, and unrecognized macros).
 * </p>
 *
 * @author <a href="gene@ctan.org">Gerd Neugebauer</a>
 */
public class InsAnalyzer {

    /**
     * This interface describes a piece of code associated with a macro name.
     * When the macro is encountered while scanning, its {@link #run} method is
     * invoked to consume any arguments belonging to the macro (e.g. the braced
     * file name) and to record the result, typically by adding a file name to
     * {@code files}.
     */
    private interface Code {

        /**
         * Executes the action associated with a macro.
         *
         * <p>
         * Implementations are responsible for consuming from {@code r} exactly
         * the input that belongs to the macro invocation (for example a
         * {@code {...}} argument), so that subsequent scanning resumes at the
         * correct position.
         * </p>
         *
         * @param r the reader positioned right after the macro name, from which
         *     any macro arguments should be read
         * @param files the list of file names accumulated so far; macros that
         *     declare a file should append to this list
         * @param dir the directory to prepend to file names found in the input,
         *     typically the directory containing the {@code .ins} file being
         *     parsed
         * @throws IOException in case of an I/O error while reading the macro's
         *     arguments
         */
        void run(PushbackReader r, List<String> files, String dir)
            throws IOException;
    }

    /**
     * The field <code>macros</code> contains the map of all known macros,
     * indexed by their name (without the leading backslash), to the
     * {@link Code} that should be executed when the macro is encountered.
     */
    private static final Code DUMMY_CODE = (r, files, dir) -> {

    };

    /**
     * The field <code>macros</code> contains the map of all known macros,
     * indexed by their name (without the leading backslash), to the
     * {@link Code} that should be executed when the macro is encountered.
     */
    private Map<String, Code> macros = new HashMap<>();

    /**
     * Creates a new <code>InsParser</code> and registers the built-in macros
     * {@code \file} and {@code \generateFile}. Both macros expect a single
     * braced argument, e.g. {@code \file{name.sty}}, and record {@code dir}
     * concatenated with the braced content as a file name in the resulting
     * list; they are treated identically since, for the purposes of this
     * parser, both simply declare a produced file.
     */
    public InsAnalyzer() {

        macros.put("file", (r, files, dir) -> {

            StringBuilder buffer = new StringBuilder();
            buffer.append(dir);
            int c = r.read();
            if (c == '{') {
                for (c = r.read(); c != '}'; c = r.read()) {
                    if (c < 0) {
                        throw new IOException("missing } for \\file");
                    }
                    buffer.append((char) c);
                }
                files.add(buffer.toString());
            } else {
                throw new IOException("missing { for \\file");
            }
        });
        macros.put("generateFile", (r, files, dir) -> {

            StringBuilder buffer = new StringBuilder();
            buffer.append(dir);
            int c = r.read();
            if (c == '{') {
                for (c = r.read(); c != '}'; c = r.read()) {
                    if (c < 0) {
                        throw new IOException("missing } for \\generateFile");
                    }
                    buffer.append((char) c);
                }
                files.add(buffer.toString());
            } else {
                throw new IOException("missing { for \\generateFile");
            }
        });
    }

    /**
     * Parses the contents of an {@code .ins} file, collecting the names of all
     * files declared via the {@code \file} and {@code \generateFile} macros.
     *
     * <p>
     * The given {@code reader} is read to its end (or until an error occurs)
     * and is closed by this method before returning, even if an exception is
     * thrown while parsing.
     * </p>
     *
     * @param dir the directory to prepend to each declared file name; use the
     *     empty string to obtain file names exactly as they appear in the input
     * @param reader the reader providing the contents of the {@code .ins} file;
     *     it is closed by this method
     *
     * @return the list of file names declared in the input, in the order in
     *     which they were encountered
     *
     * @throws IOException in case of an I/O error, or if the input is malformed
     *     (e.g. an unterminated macro argument)
     */
    public List<String> parse(String dir, Reader reader) throws IOException {

        List<String> files = new ArrayList<>();
        PushbackReader r = new PushbackReader(reader);
        try {
            for (Code t = scan(r); t != null; t = scan(r)) {
                t.run(r, files, dir);
            }

        } finally {
            reader.close();
        }
        return files;
    }

    /**
     * Scans the input for the next relevant token and returns the {@link Code}
     * to execute for it.
     *
     * <p>
     * Characters are consumed until one of the following happens:
     * </p>
     * <ul>
     * <li>A {@code %} is found, which starts a comment; the comment is skipped
     * up to and including the next newline (or end of input), and scanning
     * continues.</li>
     * <li>A {@code \} is found, which starts a macro; the macro name is read
     * and its associated {@link Code} is returned, see
     * {@link #scanMacro(PushbackReader)}.</li>
     * <li>Any other character, including {@code {} and {@code }}, is
     * encountered, in which case {@link #DUMMY_CODE} is returned and the
     * character is consumed without further effect.</li>
     * <li>The end of input is reached, in which case {@code null} is
     * returned.</li>
     * </ul>
     *
     * @param reader the reader providing the input characters
     *
     * @return the {@link Code} to run for the token found, or {@code null} if
     *     the end of input has been reached
     *
     * @throws IOException in case of an I/O error
     */
    private Code scan(PushbackReader reader) throws IOException {

        for (int c = reader.read(); c >= 0; c = reader.read()) {
            switch (c) {
                case '%':
                    for (c = reader.read(); c >= 0
                        && c != '\n'; c = reader.read()) {
                    }
                    break;
                case '\\':
                    return scanMacro(reader);
                case '{':
                case '}':
                default:
                    return DUMMY_CODE;
            }
        }
        return null;
    }

    /**
     * Scans the name of a macro immediately after the initial {@code \} has
     * been consumed by the caller, and looks up the corresponding {@link Code}.
     *
     * <p>
     * If the character following the {@code \} is alphabetic, the macro name
     * consists of that character plus all subsequent alphabetic characters (as
     * per {@link Character#isAlphabetic(int)}), following standard TeX
     * conventions for control word names. Otherwise, the macro name is the
     * single non-alphabetic character (a TeX control symbol), and one
     * additional character is consumed and discarded.
     * </p>
     *
     * <p>
     * Any whitespace immediately following the macro name is consumed and
     * discarded, mirroring TeX's handling of trailing spaces after control
     * words; the first non-whitespace character (if any) is pushed back onto
     * {@code reader} so it can be read again by the returned macro's
     * {@link Code#run} method or by subsequent scanning.
     * </p>
     *
     * @param reader the reader positioned right after the {@code \} that
     *     introduced the macro
     *
     * @return the {@link Code} registered for the macro name found, or
     *     {@link #DUMMY_CODE} if the name is not a known macro
     *
     * @throws EOFException if the end of input is reached immediately after the
     *     {@code \}, i.e. the macro name is missing
     * @throws IOException in case of an I/O error
     */
    private Code scanMacro(PushbackReader reader) throws IOException {

        int c = reader.read();
        if (c < 0) {
            throw new EOFException("found \\ at EOF");
        }
        StringBuilder buffer = new StringBuilder();
        buffer.append((char) c);
        if (Character.isAlphabetic(c)) {
            for (c = reader.read(); c >= 0
                && Character.isAlphabetic(c); c = reader.read()) {
                buffer.append((char) c);
            }
        } else {
            c = reader.read();
        }
        while (c >= 0 && Character.isWhitespace(c)) {
            c = reader.read();
        }
        if (c >= 0) {
            reader.unread(c);
        }
        Code m = macros.get(buffer.toString());

        return m != null ? m : DUMMY_CODE;
    }
}