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