Repository: Khan/KaTeX
Pull request #975 (2017-11-19 22:32:43)

\hspace*, \@ifstar, \@ifnextchar, \@firstoftwo
This PR starts to add some more advanced LaTeX macro support using existing features of `MacroParser`.  Getting fun!
* 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`.  Could remove these or the changes, but slightly cleaner?

edemaine: I added some quick tests.  Let me know if you'd like more!
kevinbarabash: Not sure why the firefox screenshot tests didn't work as I don't think any of the screenshot tests would be impacted by these changes.  I restarted the travis-ci build, hopefully it'll pass this time.
kevinbarabash: Firefox tests passed this time.  ¯\_(ツ)_/¯

Patch:
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


Test patch:
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() {