From e93668c666b889e1bb36289bc5b631ab88b6f26a Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Fri, 24 Nov 2017 14:48:04 -0500 Subject: [PATCH] \hspace*, \@ifstar, \@ifnextchar, \@firstoftwo (#975) * \hspace*, \@ifstar, \@ifnextchar, \@firstoftwo * Support both \hspace and \hspace* (still aliasing to \kern) using new \@ifstar * \@ifstar is a macro using new \@ifnextchar and \@firstoftwo (same definition as LaTeX) * \@firstoftwo and \@ifnextchar use available MacroParser features to act as they do in LaTeX. * Also new method pushTokens (which I almost used but didn't end up), and moved pushToken next to it and popToken. * Fix flow errors; generalize MacroDefinition * Add tests for macros --- src/MacroExpander.js | 96 ++++++++++++++++++++++++++------------------ src/macros.js | 36 +++++++++++++++-- test/katex-spec.js | 27 +++++++++++++ 3 files changed, 115 insertions(+), 44 deletions(-) diff --git a/src/MacroExpander.js b/src/MacroExpander.js index 75fd57d6..166a58c4 100644 --- a/src/MacroExpander.js +++ b/src/MacroExpander.js @@ -42,6 +42,21 @@ export default class MacroExpander implements MacroContextInterface { return this.stack.pop(); } + /** + * Add a given token to the token stack. In particular, this get be used + * to put back a token returned from one of the other methods. + */ + pushToken(token: Token) { + this.stack.push(token); + } + + /** + * Append an array of tokens to the token stack. + */ + pushTokens(tokens: Token[]) { + this.stack.push(...tokens); + } + /** * Consume all following space tokens, without expansion. */ @@ -56,6 +71,45 @@ export default class MacroExpander implements MacroContextInterface { } } + /** + * Consume the specified number of arguments from the token stream, + * and return the resulting array of arguments. + */ + consumeArgs(numArgs: number): Token[][] { + const args: Token[][] = []; + // obtain arguments, either single token or balanced {…} group + for (let i = 0; i < numArgs; ++i) { + this.consumeSpaces(); // ignore spaces before each argument + const startOfArg = this.popToken(); + if (startOfArg.text === "{") { + const arg: Token[] = []; + let depth = 1; + while (depth !== 0) { + const tok = this.popToken(); + arg.push(tok); + if (tok.text === "{") { + ++depth; + } else if (tok.text === "}") { + --depth; + } else if (tok.text === "EOF") { + throw new ParseError( + "End of input in macro argument", + startOfArg); + } + } + arg.pop(); // remove last } + arg.reverse(); // like above, to fit in with stack order + args[i] = arg; + } else if (startOfArg.text === "EOF") { + throw new ParseError( + "End of input expecting macro argument"); + } else { + args[i] = [startOfArg]; + } + } + return args; + } + /** * Expand the next token only once if possible. * @@ -92,37 +146,7 @@ export default class MacroExpander implements MacroContextInterface { const {tokens, numArgs} = this._getExpansion(name); let expansion = tokens; if (numArgs) { - const args: Token[][] = []; - // obtain arguments, either single token or balanced {…} group - for (let i = 0; i < numArgs; ++i) { - this.consumeSpaces(); // ignore spaces before each argument - const startOfArg = this.popToken(); - if (startOfArg.text === "{") { - const arg: Token[] = []; - let depth = 1; - while (depth !== 0) { - const tok = this.popToken(); - arg.push(tok); - if (tok.text === "{") { - ++depth; - } else if (tok.text === "}") { - --depth; - } else if (tok.text === "EOF") { - throw new ParseError( - "End of input in macro argument", - startOfArg); - } - } - arg.pop(); // remove last } - arg.reverse(); // like above, to fit in with stack order - args[i] = arg; - } else if (startOfArg.text === "EOF") { - throw new ParseError( - "End of input expecting macro argument", topToken); - } else { - args[i] = [startOfArg]; - } - } + const args = this.consumeArgs(numArgs); // paste arguments in place of the placeholders expansion = expansion.slice(); // make a shallow copy for (let i = expansion.length - 1; i >= 0; --i) { @@ -148,7 +172,7 @@ export default class MacroExpander implements MacroContextInterface { } } // Concatenate expansion onto top of stack. - this.stack.push(...expansion); + this.pushTokens(expansion); return expansion; } @@ -222,13 +246,5 @@ export default class MacroExpander implements MacroContextInterface { return expansion; } - - /** - * Add a given token to the token stack. In particular, this get be used - * to put back a token returned from one of the other methods. - */ - pushToken(token: Token) { - this.stack.push(token); - } } diff --git a/src/macros.js b/src/macros.js index 6944e87f..05b275bb 100644 --- a/src/macros.js +++ b/src/macros.js @@ -25,22 +25,50 @@ export interface MacroContextInterface { * Similar in behavior to TeX's `\expandafter\futurelet`. */ expandAfterFuture(): Token; + + /** + * Consume the specified number of arguments from the token stream, + * and return the resulting array of arguments. + */ + consumeArgs(numArgs: number): Token[][]; } /** Macro tokens (in reverse order). */ export type MacroExpansion = {tokens: Token[], numArgs: number}; -type MacroDefinition = string | (MacroContextInterface => string) | MacroExpansion; +type MacroDefinition = string | MacroExpansion | + (MacroContextInterface => (string | MacroExpansion)); export type MacroMap = {[string]: MacroDefinition}; const builtinMacros: MacroMap = {}; export default builtinMacros; // This function might one day accept an additional argument and do more things. -function defineMacro(name: string, body: string | MacroContextInterface => string) { +function defineMacro(name: string, body: MacroDefinition) { builtinMacros[name] = body; } +////////////////////////////////////////////////////////////////////// +// macro tools + +defineMacro("\\@firstoftwo", function(context) { + const args = context.consumeArgs(2); + return {tokens: args[0], numArgs: 0}; +}); + +defineMacro("\\@ifnextchar", function(context) { + const args = context.consumeArgs(3); // symbol, if, else + const nextToken = context.future(); + if (args[0].length === 1 && args[0][0].text === nextToken.text) { + return {tokens: args[1], numArgs: 0}; + } else { + return {tokens: args[2], numArgs: 0}; + } +}); + +// \def\@ifstar#1{\@ifnextchar *{\@firstoftwo{#1}}} +defineMacro("\\@ifstar", "\\@ifnextchar *{\\@firstoftwo{#1}}"); + ////////////////////////////////////////////////////////////////////// // basics defineMacro("\\bgroup", "{"); @@ -244,8 +272,8 @@ defineMacro("\\thickspace", "\\;"); // \let\thickspace\; // \DeclareRobustCommand\hspace{\@ifstar\@hspacer\@hspace} // \def\@hspace#1{\hskip #1\relax} -// KaTeX doesn't do line breaks, so \hspace is the same as \kern -defineMacro("\\hspace", "\\kern{#1}"); +// KaTeX doesn't do line breaks, so \hspace and \hspace* are the same as \kern +defineMacro("\\hspace", "\\@ifstar\\kern\\kern"); ////////////////////////////////////////////////////////////////////// // mathtools.sty diff --git a/test/katex-spec.js b/test/katex-spec.js index d30798b9..c8d89ce8 100644 --- a/test/katex-spec.js +++ b/test/katex-spec.js @@ -2639,6 +2639,33 @@ describe("A macro expander", function() { "’": "'", }); }); + + it("\\@firstoftwo should consume both, and avoid errors", function() { + expect("\\@firstoftwo{yes}{no}").toParseLike("yes"); + expect("\\@firstoftwo{yes}{1'_2^3}").toParseLike("yes"); + }); + + it("\\@ifstar should consume star but nothing else", function() { + expect("\\@ifstar{yes}{no}*!").toParseLike("yes!"); + expect("\\@ifstar{yes}{no}?!").toParseLike("no?!"); + }); + + it("\\@ifnextchar should not consume anything", function() { + expect("\\@ifnextchar!{yes}{no}!!").toParseLike("yes!!"); + expect("\\@ifnextchar!{yes}{no}?!").toParseLike("no?!"); + }); + + it("\\@firstoftwwo should consume star but nothing else", function() { + expect("\\@ifstar{yes}{no}*!").toParseLike("yes!"); + expect("\\@ifstar{yes}{no}?!").toParseLike("no?!"); + }); + + // This may change in the future, if we support the extra features of + // \hspace. + it("should treat \\hspace, \\hspace*, \\hskip like \\kern", function() { + expect("\\hspace{1em}").toParseLike("\\kern1em"); + expect("\\hspace*{1em}").toParseLike("\\kern1em"); + }); }); describe("A parser taking String objects", function() {