Repository: Khan/KaTeX
Pull request #1348 (2018-05-23 19:43:48)

\def support (and \gdef and \global\def)
Solve most of #250.  Following [discussion there](https://github.com/Khan/KaTeX/issues/250#issuecomment-390637002), I implemented `\def` (and reimplemented `\gdef`) to behave as in LaTeX.
* Re-organize macros object as a new `Namespace` object.  TeX would call these "groups", but we use group all over the place in the parser, so I thought this would be too confusing.
* There is one `builtinNamespace` that loads the built-in macros once.  Unlike current code, this never gets copied!  So it should speed up repeated calls to KaTeX.
* Each namespace has a `parent` pointer, ultimately ending in `builtinNamespace`.  `getMacro` walks up this hierarchy as necessary.
* `setMacro` sets in the local namespace, or if `global` is specified, in *all* namespaces up the hierarchy.  I didn't realize how TeX behaves, but it is.

An alternative design would be to store the namespaces in an array (stack), so local `setMacro` would set within `namespaces[namespaces.length-1]`, while global `setMacro` would be a for loop over the namespaces array.  The advantage I see of the current approach is that the complexity can go into the `Namespace` class.  But I'm happy to rewrite to this alternative approach, which would put that complexity into `MacroExpander`.

One important note (also in the README) is that the `macros` object passed into `katex.render` etc. will be modified if the LaTeX code defines its own macros via `\def` or `\gdef`.  This is backwards-incompatible, and could be an issue if the same macros object gets passed in many times.  (I added `katex.__defineMacro` to define extra built-in macros, to provide a better way to provide the same macros many times.)  But I think it's also an important feature: it enables consecutive calls to KaTeX to share state.  For example, one math blob can define some macros, and later math blobs can use those macros.  This is extremely useful.  I believe MathJax behaves the same way.  It will also help with automatic equation numbering.

This PR paves the way for finishing #250, as well as length support (#687).

codecov[bot]: # [Codecov](https://codecov.io/gh/Khan/KaTeX/pull/1348?src=pr&el=h1) Report
> Merging [#1348](https://codecov.io/gh/Khan/KaTeX/pull/1348?src=pr&el=desc) into [master](https://codecov.io/gh/Khan/KaTeX/commit/ef666c18da17e74dcfb3b95169aa3cc699362b28?src=pr&el=desc) will **decrease** coverage by `0.03%`.
> The diff coverage is `85.24%`.


[![Impacted file tree graph](https://codecov.io/gh/Khan/KaTeX/pull/1348/graphs/tree.svg?width=650&token=vfDnj2P77o&height=150&src=pr)](https://codecov.io/gh/Khan/KaTeX/pull/1348?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##           master   #1348      +/-   ##
=========================================
- Coverage   81.33%   81.3%   -0.04%     
=========================================
  Files          76      77       +1     
  Lines        4212    4248      +36     
  Branches      727     736       +9     
=========================================
+ Hits         3426    3454      +28     
- Misses        666     672       +6     
- Partials      120     122       +2
```


| [Impacted Files](https://codecov.io/gh/Khan/KaTeX/pull/1348?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/environments/array.js](https://codecov.io/gh/Khan/KaTeX/pull/1348/diff?src=pr&el=tree#diff-c3JjL2Vudmlyb25tZW50cy9hcnJheS5qcw==) | `43.55% <100%> (ø)` | :arrow_up: |
| [src/parseTree.js](https://codecov.io/gh/Khan/KaTeX/pull/1348/diff?src=pr&el=tree#diff-c3JjL3BhcnNlVHJlZS5qcw==) | `100% <100%> (ø)` | :arrow_up: |
| [src/Parser.js](https://codecov.io/gh/Khan/KaTeX/pull/1348/diff?src=pr&el=tree#diff-c3JjL1BhcnNlci5qcw==) | `93.08% <100%> (+0.07%)` | :arrow_up: |
| [src/Namespace.js](https://codecov.io/gh/Khan/KaTeX/pull/1348/diff?src=pr&el=tree#diff-c3JjL05hbWVzcGFjZS5qcw==) | `100% <100%> (ø)` | |
| [src/macros.js](https://codecov.io/gh/Khan/KaTeX/pull/1348/diff?src=pr&el=tree#diff-c3JjL21hY3Jvcy5qcw==) | `83.67% <46.15%> (-2.89%)` | :arrow_down: |
| [src/MacroExpander.js](https://codecov.io/gh/Khan/KaTeX/pull/1348/diff?src=pr&el=tree#diff-c3JjL01hY3JvRXhwYW5kZXIuanM=) | `92.15% <90.47%> (-0.71%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/Khan/KaTeX/pull/1348?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/Khan/KaTeX/pull/1348?src=pr&el=footer). Last update [ef666c1...7c2a176](https://codecov.io/gh/Khan/KaTeX/pull/1348?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

kevinbarabash: Looking forward to reviewing this this evening. :)
ylemkimon: How about:

Level | Scope | Macro definition in JS | in LaTeX
----- | ----- | ---------------------- | --------
**0** | global(document, KaTeX instance) | `builtinNamespace`,<br>`__defineMacro` | `\gdef`
**1** | expression(function call) | `settings.macro` | `\def` at the top level
**>2** | group(lazily instantiated) | N/A | `\def`

?
edemaine: 1. I have a plan for making lookups and local defs constant time, though gdefs will still take O(k). Essentially, the idea is to have an undo stack. This is inspired by how TeX does it (based on a rough reading of the source code, though I still don't fully understand it), which I believe achieves the same performance.

2. I really don't like the idea of built-ins being modifiable by LaTeX code. This makes it difficult or impossible to isolate multiple LaTeX documents with the same KaTeX module. This would be an issue in any system with multiple LaTeX authors. We don't want macros defined by one document to adversely affect the other documents. The current system puts the caller in control of when to share state and when to reset. 
ylemkimon: > I really don't like the idea of built-ins being modifiable by LaTeX code. This makes it difficult or impossible to isolate multiple LaTeX documents with the same KaTeX module. This would be an issue in any system with multiple LaTeX authors.

Now I think, it isn't good idea. I didn't thought of server-side rendering, which may share single KaTeX instance, or single page containing multiple documents.
kevinbarabash: @edemaine do you want to save the optimization for a separate PR?
edemaine: I'm happy to work on it first, unless you think this is high priority. It will probably involve rewriting much of the code. 
kevinbarabash: I thought it might be more of an incremental change, but if it's a rewrite let's wait.
edemaine: OK, I did the constant-time lookup rewrite.

Now we use an "undo" stack similar to TeX, so get and set are constant-time operations.  `get()` still has to check two objects: one with all current settings, and then the built-ins.  Local `set()` sets the current value and (when appropriate) adds an undo operation to the undo stack.  Global `set()` still takes time linear in the number of groups, possibly changing the undo operation at every level.

`Namespace` now refers to a space of nameable things like macros or lengths.  This is a totally different meaning from before, but still a reasonable name for the thing, I think, and still encapsulates all the complexity into the `Namespace` class.
edemaine: I think I got all the comments, but let me know if I missed anything.

Patch:
diff --git a/README.md b/README.md
index f1981754..db883a59 100644
--- a/README.md
+++ b/README.md
@@ -81,7 +81,7 @@ You can provide an object of options as the last argument to `katex.render` and
 - `displayMode`: `boolean`. If `true` the math will be rendered in display mode, which will put the math in display style (so `\int` and `\sum` are large, for example), and will center the math on the page on its own line. If `false` the math will be rendered in inline mode. (default: `false`)
 - `throwOnError`: `boolean`. If `true` (the default), KaTeX will throw a `ParseError` when it encounters an unsupported command or invalid LaTeX. If `false`, KaTeX will render unsupported commands as text, and render invalid LaTeX as its source code with hover text giving the error, in the color given by `errorColor`.
 - `errorColor`: `string`. A color string given in the format `"#XXX"` or `"#XXXXXX"`. This option determines the color that unsupported commands and invalid LaTeX are rendered in when `throwOnError` is set to `false`. (default: `#cc0000`)
-- `macros`: `object`. A collection of custom macros. Each macro is a property with a name like `\name` (written `"\\name"` in JavaScript) which maps to a string that describes the expansion of the macro. Single-character keys can also be included in which case the character will be redefined as the given macro (similar to TeX active characters).
+- `macros`: `object`. A collection of custom macros. Each macro is a property with a name like `\name` (written `"\\name"` in JavaScript) which maps to a string that describes the expansion of the macro. Single-character keys can also be included in which case the character will be redefined as the given macro (similar to TeX active characters). *This object will be modified* if the LaTeX code defines its own macros via `\gdef`, which enables consecutive calls to KaTeX to share state.
 - `colorIsTextColor`: `boolean`. If `true`, `\color` will work like LaTeX's `\textcolor`, and take two arguments (e.g., `\color{blue}{hello}`), which restores the old behavior of KaTeX (pre-0.8.0). If `false` (the default), `\color` will work like LaTeX's `\color`, and take one argument (e.g., `\color{blue}hello`).  In both cases, `\textcolor` works as in LaTeX (e.g., `\textcolor{blue}{hello}`).
 - `maxSize`: `number`. All user-specified sizes, e.g. in `\rule{500em}{500em}`, will be capped to `maxSize` ems. If set to `Infinity` (the default), users can make elements and spaces arbitrarily large.
 - `maxExpand`: `number`. Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to `Infinity` (the default), the macro expander will try to fully expand as in LaTeX.
diff --git a/katex.js b/katex.js
index a68b934e..d8fe19d8 100644
--- a/katex.js
+++ b/katex.js
@@ -21,6 +21,7 @@ import type {SettingsOptions} from "./src/Settings";
 import type ParseNode from "./src/ParseNode";
 
 import { defineSymbol } from './src/symbols';
+import { defineMacro } from './src/macros';
 
 import { version } from "./package.json";
 
@@ -176,6 +177,10 @@ export default {
     __renderToHTMLTree: renderToHTMLTree,
     /**
      * adds a new symbol to internal symbols table
-    */
+     */
     __defineSymbol: defineSymbol,
+    /**
+     * adds a new macro to builtin macro list
+     */
+    __defineMacro: defineMacro,
 };
diff --git a/src/MacroExpander.js b/src/MacroExpander.js
index f91a9e3a..67a8dec8 100644
--- a/src/MacroExpander.js
+++ b/src/MacroExpander.js
@@ -6,23 +6,26 @@
 
 import Lexer from "./Lexer";
 import {Token} from "./Token";
-import builtinMacros from "./macros";
 import type {Mode} from "./types";
 import ParseError from "./ParseError";
+import Namespace from "./Namespace";
+import builtinMacros from "./macros";
 
-import type {MacroContextInterface, MacroMap, MacroExpansion} from "./macros";
+import type {MacroContextInterface, MacroDefinition, MacroExpansion}
+    from "./macros";
 import type Settings from "./Settings";
 
 export default class MacroExpander implements MacroContextInterface {
-    lexer: Lexer;
-    macros: MacroMap;
     maxExpand: number;
+    lexer: Lexer;
+    macros: Namespace<MacroDefinition>;
     stack: Token[];
     mode: Mode;
 
     constructor(input: string, settings: Settings, mode: Mode) {
         this.feed(input);
-        this.macros = Object.assign({}, builtinMacros, settings.macros);
+        // Make new global namespace
+        this.macros = new Namespace(builtinMacros, settings.macros);
         this.maxExpand = settings.maxExpand;
         this.mode = mode;
         this.stack = []; // contains tokens in REVERSE order
@@ -43,6 +46,20 @@ export default class MacroExpander implements MacroContextInterface {
         this.mode = newMode;
     }
 
+    /**
+     * Start a new group nesting within all namespaces.
+     */
+    beginGroup() {
+        this.macros.beginGroup();
+    }
+
+    /**
+     * End current group nesting within all namespaces.
+     */
+    endGroup() {
+        this.macros.endGroup();
+    }
+
     /**
      * Returns the topmost token on the stack, without expanding it.
      * Similar in behavior to TeX's `\futurelet`.
@@ -153,12 +170,12 @@ export default class MacroExpander implements MacroContextInterface {
     expandOnce(): Token | Token[] {
         const topToken = this.popToken();
         const name = topToken.text;
-        if (!this.macros.hasOwnProperty(name)) {
+        const expansion = this._getExpansion(name);
+        if (expansion == null) { // mainly checking for undefined here
             // Fully expanded
             this.pushToken(topToken);
             return topToken;
         }
-        const {tokens, numArgs} = this._getExpansion(name);
         if (this.maxExpand !== Infinity) {
             this.maxExpand--;
             if (this.maxExpand < 0) {
@@ -166,25 +183,25 @@ export default class MacroExpander implements MacroContextInterface {
                     "need to increase maxExpand setting");
             }
         }
-        let expansion = tokens;
-        if (numArgs) {
-            const args = this.consumeArgs(numArgs);
+        let tokens = expansion.tokens;
+        if (expansion.numArgs) {
+            const args = this.consumeArgs(expansion.numArgs);
             // paste arguments in place of the placeholders
-            expansion = expansion.slice(); // make a shallow copy
-            for (let i = expansion.length - 1; i >= 0; --i) {
-                let tok = expansion[i];
+            tokens = tokens.slice(); // make a shallow copy
+            for (let i = tokens.length - 1; i >= 0; --i) {
+                let tok = tokens[i];
                 if (tok.text === "#") {
                     if (i === 0) {
                         throw new ParseError(
                             "Incomplete placeholder at end of macro body",
                             tok);
                     }
-                    tok = expansion[--i]; // next token on stack
+                    tok = tokens[--i]; // next token on stack
                     if (tok.text === "#") { // ## → #
-                        expansion.splice(i + 1, 1); // drop first #
+                        tokens.splice(i + 1, 1); // drop first #
                     } else if (/^[1-9]$/.test(tok.text)) {
                         // replace the placeholder with the indicated argument
-                        expansion.splice(i, 2, ...args[+tok.text - 1]);
+                        tokens.splice(i, 2, ...args[+tok.text - 1]);
                     } else {
                         throw new ParseError(
                             "Not a valid argument number",
@@ -194,8 +211,8 @@ export default class MacroExpander implements MacroContextInterface {
             }
         }
         // Concatenate expansion onto top of stack.
-        this.pushTokens(expansion);
-        return expansion;
+        this.pushTokens(tokens);
+        return tokens;
     }
 
     /**
@@ -234,11 +251,13 @@ export default class MacroExpander implements MacroContextInterface {
 
     /**
      * Returns the expanded macro as a reversed array of tokens and a macro
-     * argument count.
-     * Caches macro expansions for those that were defined simple TeX strings.
+     * argument count.  Or returns `null` if no such macro.
      */
-    _getExpansion(name: string): MacroExpansion {
-        const definition = this.macros[name];
+    _getExpansion(name: string): ?MacroExpansion {
+        const definition = this.macros.get(name);
+        if (definition == null) { // mainly checking for undefined here
+            return definition;
+        }
         const expansion =
             typeof definition === "function" ? definition(this) : definition;
         if (typeof expansion === "string") {
@@ -258,11 +277,6 @@ export default class MacroExpander implements MacroContextInterface {
             }
             tokens.reverse(); // to fit in with stack using push and pop
             const expanded = {tokens, numArgs};
-            // Cannot cache a macro defined using a function since it relies on
-            // parser context.
-            if (typeof definition !== "function") {
-                this.macros[name] = expanded;
-            }
             return expanded;
         }
 
diff --git a/src/Namespace.js b/src/Namespace.js
new file mode 100644
index 00000000..74d59d56
--- /dev/null
+++ b/src/Namespace.js
@@ -0,0 +1,98 @@
+// @flow
+
+/**
+ * A `Namespace` refers to a space of nameable things like macros or lengths,
+ * which can be `set` either globally or local to a nested group, using an
+ * undo stack similar to how TeX implements this functionality.
+ * Performance-wise, `get` and local `set` take constant time, while global
+ * `set` takes time proportional to the depth of group nesting.
+ */
+
+import ParseError from "./ParseError";
+
+export type Mapping<Value> = {[string]: Value};
+
+export default class Namespace<Value> {
+    current: Mapping<Value>;
+    builtins: Mapping<Value>;
+    undefStack: Mapping<Value>[];
+
+    /**
+     * Both arguments are optional.  The first argument is an object of
+     * built-in mappings which never change.  The second argument is an object
+     * of initial (global-level) mappings, which will constantly change
+     * according to any global/top-level `set`s done.
+     */
+    constructor(builtins: Mapping<Value> = {},
+                globalMacros: Mapping<Value> = {}) {
+        this.current = globalMacros;
+        this.builtins = builtins;
+        this.undefStack = [];
+    }
+
+    /**
+     * Start a new nested group, affecting future local `set`s.
+     */
+    beginGroup() {
+        this.undefStack.push({});
+    }
+
+    /**
+     * End current nested group, restoring values before the group began.
+     */
+    endGroup() {
+        if (this.undefStack.length === 0) {
+            throw new ParseError("Unbalanced namespace destruction: attempt " +
+                "to pop global namespace; please report this as a bug");
+        }
+        const undefs = this.undefStack.pop();
+        for (const undef of Object.getOwnPropertyNames(undefs)) {
+            if (undefs[undef] === undefined) {
+                delete this.current[undef];
+            } else {
+                this.current[undef] = undefs[undef];
+            }
+        }
+    }
+
+    /**
+     * Get the current value of a name.
+     */
+    get(name: string): ?Value {
+        if (this.current.hasOwnProperty(name)) {
+            return this.current[name];
+        } else {
+            return this.builtins[name];
+        }
+    }
+
+    /**
+     * Set the current value of a name, and optionally set it globally too.
+     * Local set() sets the current value and (when appropriate) adds an undo
+     * operation to the undo stack.  Global set() may change the undo
+     * operation at every level, so takes time linear in their number.
+     */
+    set(name: string, value: Value, global: boolean = false) {
+        if (global) {
+            // Global set is equivalent to setting in all groups.  Simulate this
+            // by destroying any undos currently scheduled for this name,
+            // and adding an undo with the *new* value (in case it later gets
+            // locally reset within this environment).
+            for (let i = 0; i < this.undefStack.length; i++) {
+                delete this.undefStack[i][name];
+            }
+            if (this.undefStack.length > 0) {
+                this.undefStack[this.undefStack.length - 1][name] = value;
+            }
+        } else {
+            // Undo this set at end of this group (possibly to `undefined`),
+            // unless an undo is already in place, in which case that older
+            // value is the correct one.
+            const top = this.undefStack[this.undefStack.length - 1];
+            if (top && !top.hasOwnProperty(name)) {
+                top[name] = this.current[name];
+            }
+        }
+        this.current[name] = value;
+    }
+}
diff --git a/src/Parser.js b/src/Parser.js
index cc07672c..069d7417 100644
--- a/src/Parser.js
+++ b/src/Parser.js
@@ -86,11 +86,6 @@ export default class Parser {
         // Create a new macro expander (gullet) and (indirectly via that) also a
         // new lexer (mouth) for this parser (stomach, in the language of TeX)
         this.gullet = new MacroExpander(input, settings, this.mode);
-        // Use old \color behavior (same as LaTeX's \textcolor) if requested.
-        // We do this after the macros object has been copied by MacroExpander.
-        if (settings.colorIsTextColor) {
-            this.gullet.macros["\\color"] = "\\textcolor";
-        }
         // Store the settings for use in parsing
         this.settings = settings;
         // Count leftright depth (for \middle errors)
@@ -133,21 +128,27 @@ export default class Parser {
      * Main parsing function, which parses an entire input.
      */
     parse(): ParseNode<*>[] {
+        // Create a group namespace for the math expression.
+        // (LaTeX creates a new group for every $...$, $$...$$, \[...\].)
+        this.gullet.beginGroup();
+
+        // Use old \color behavior (same as LaTeX's \textcolor) if requested.
+        // We do this within the group for the math expression, so it doesn't
+        // pollute settings.macros.
+        if (this.settings.colorIsTextColor) {
+            this.gullet.macros.set("\\color", "\\textcolor");
+        }
+
         // Try to parse the input
         this.consume();
-        const parse = this.parseInput();
-        return parse;
-    }
+        const parse = this.parseExpression(false);
 
-    /**
-     * Parses an entire input tree.
-     */
-    parseInput(): ParseNode<*>[] {
-        // Parse an expression
-        const expression = this.parseExpression(false);
         // If we succeeded, make sure there's an EOF at the end
         this.expect("EOF", false);
-        return expression;
+
+        // End the group namespace for the expression
+        this.gullet.endGroup();
+        return parse;
     }
 
     static endOfExpression = ["}", "\\end", "\\right", "&"];
@@ -851,6 +852,8 @@ export default class Parser {
             if (mode) {
                 this.switchMode(mode);
             }
+            // Start a new group namespace
+            this.gullet.beginGroup();
             // If we get a brace, parse an expression
             this.consume();
             const expression = this.parseExpression(false, optional ? "]" : "}");
@@ -859,6 +862,8 @@ export default class Parser {
             if (mode) {
                 this.switchMode(outerMode);
             }
+            // End group namespace before consuming symbol after close brace
+            this.gullet.endGroup();
             // Make sure we get a close brace
             this.expect(optional ? "]" : "}");
             if (mode === "text") {
diff --git a/src/environments/array.js b/src/environments/array.js
index 10c7de2b..8cdf3a4c 100644
--- a/src/environments/array.js
+++ b/src/environments/array.js
@@ -68,8 +68,8 @@ function parseArray(
     style: StyleStr,
 ): ParseNode<"array"> {
     // Parse body of array with \\ temporarily mapped to \cr
-    const oldNewline = parser.gullet.macros["\\\\"];
-    parser.gullet.macros["\\\\"] = "\\cr";
+    parser.gullet.beginGroup();
+    parser.gullet.macros.set("\\\\", "\\cr");
 
     let row = [];
     const body = [row];
@@ -125,7 +125,7 @@ function parseArray(
     result.numHLinesBeforeRow = numHLinesBeforeRow;
     // $FlowFixMe: The required fields were added immediately above.
     const res: ArrayEnvNodeData = result;
-    parser.gullet.macros["\\\\"] = oldNewline;
+    parser.gullet.endGroup();
     return new ParseNode("array", res, parser.mode);
 }
 
diff --git a/src/macros.js b/src/macros.js
index 88458bbc..70243bf8 100644
--- a/src/macros.js
+++ b/src/macros.js
@@ -9,6 +9,7 @@ import symbols from "./symbols";
 import utils from "./utils";
 import {Token} from "./Token";
 import ParseError from "./ParseError";
+import type Namespace from "./Namespace";
 
 import type {Mode} from "./types";
 
@@ -22,7 +23,7 @@ export interface MacroContextInterface {
     /**
      * Object mapping macros to their expansions.
      */
-    macros: MacroMap;
+    macros: Namespace<MacroDefinition>;
 
     /**
      * Returns the topmost token on the stack, without expanding it.
@@ -47,7 +48,7 @@ export interface MacroContextInterface {
 /** Macro tokens (in reverse order). */
 export type MacroExpansion = {tokens: Token[], numArgs: number};
 
-type MacroDefinition = string | MacroExpansion |
+export type MacroDefinition = string | MacroExpansion |
     (MacroContextInterface => (string | MacroExpansion));
 export type MacroMap = {[string]: MacroDefinition};
 
@@ -110,7 +111,7 @@ defineMacro("\\TextOrMath", function(context) {
 //     \gdef\macro#1{expansion}
 //     \gdef\macro#1#2{expansion}
 //     \gdef\macro#1#2#3#4#5#6#7#8#9{expansion}
-defineMacro("\\gdef", function(context) {
+const def = (context, global: boolean) => {
     let arg = context.consumeArgs(1)[0];
     if (arg.length !== 1) {
         throw new ParseError("\\gdef's first argument must be a macro name");
@@ -134,11 +135,26 @@ defineMacro("\\gdef", function(context) {
         arg = context.consumeArgs(1)[0];
     }
     // Final arg is the expansion of the macro
-    context.macros[name] = {
+    context.macros.set(name, {
         tokens: arg,
         numArgs,
-    };
+    }, global);
     return '';
+};
+defineMacro("\\gdef", (context) => def(context, true));
+defineMacro("\\def", (context) => def(context, false));
+defineMacro("\\global", (context) => {
+    const next = context.consumeArgs(1)[0];
+    if (next.length !== 1) {
+        throw new ParseError("Invalid command after \\global");
+    }
+    const command = next[0].text;
+    if (command === "\\def") {
+        // \global\def is equivalent to \gdef
+        return def(context, true);
+    } else {
+        throw new ParseError(`Invalid command '${command}' after \\global`);
+    }
 });
 
 //////////////////////////////////////////////////////////////////////
@@ -404,7 +420,7 @@ defineMacro("\\thickspace", "\\;");   //   \let\thickspace\;
 defineMacro("\\tag", "\\@ifstar\\tag@literal\\tag@paren");
 defineMacro("\\tag@paren", "\\tag@literal{({#1})}");
 defineMacro("\\tag@literal", (context) => {
-    if (context.macros["\\df@tag"]) {
+    if (context.macros.get("\\df@tag")) {
         throw new ParseError("Multiple \\tag");
     }
     return "\\gdef\\df@tag{\\text{#1}}";
diff --git a/src/parseTree.js b/src/parseTree.js
index f59baf92..00db328b 100644
--- a/src/parseTree.js
+++ b/src/parseTree.js
@@ -18,11 +18,13 @@ const parseTree = function(toParse: string, settings: Settings): ParseNode<*>[]
         throw new TypeError('KaTeX can only parse string typed expression');
     }
     const parser = new Parser(toParse, settings);
+    // Blank out any \df@tag to avoid spurious "Duplicate \tag" errors
+    delete parser.gullet.macros.current["\\df@tag"];
     let tree = parser.parse();
 
     // If the input used \tag, it will set the \df@tag macro to the tag.
     // In this case, we separately parse the tag and wrap the tree.
-    if (parser.gullet.macros["\\df@tag"]) {
+    if (parser.gullet.macros.get("\\df@tag")) {
         if (!settings.displayMode) {
             throw new ParseError("\\tag works only in display equations");
         }
diff --git a/static/main.js b/static/main.js
index c64a4c88..ff1a31cc 100644
--- a/static/main.js
+++ b/static/main.js
@@ -14,7 +14,8 @@ function init() {
     input.addEventListener("input", reprocess, false);
     permalink.addEventListener("click", setSearch);
 
-    const options = {displayMode: true, throwOnError: false, macros: {}};
+    const options = {displayMode: true, throwOnError: false};
+    const macros = {};
     const query = queryString.parse(window.location.search);
 
     if (query.text) {
@@ -65,7 +66,7 @@ function init() {
     // `c=expansion`.
     Object.getOwnPropertyNames(query).forEach((key) => {
         if (key.match(/^\\|^[^]$/)) {
-            options.macros[key] = query[key];
+            macros[key] = query[key];
         }
     });
 
@@ -78,6 +79,8 @@ function init() {
     }
 
     function reprocess() {
+        // Ignore changes to global macros caused by the expression
+        options.macros = Object.assign({}, macros);
         try {
             katex.render(input.value, math, options);
         } catch (e) {


Test patch:
diff --git a/test/helpers.js b/test/helpers.js
index c14c9df3..4f6d2f46 100644
--- a/test/helpers.js
+++ b/test/helpers.js
@@ -11,6 +11,9 @@ export const defaultSettings = new Settings({
 export const strictSettings = new Settings({strict: true});
 
 export const _getBuilt = function(expr, settings = defaultSettings) {
+    if (settings === defaultSettings) {
+        settings.macros = {};
+    }
     let rootNode = katex.__renderToDomTree(expr, settings);
 
     if (rootNode.classes.indexOf('katex-error') >= 0) {
diff --git a/test/katex-spec.js b/test/katex-spec.js
index e6d58096..5c846c85 100644
--- a/test/katex-spec.js
+++ b/test/katex-spec.js
@@ -757,6 +757,15 @@ describe("A color parser", function() {
             colorIsTextColor: true,
         });
     });
+
+    it("should not define \\color in global context", function() {
+        const macros = {};
+        expect(oldColorExpression).toParseLike("\\textcolor{#fA6}{x}y", {
+            colorIsTextColor: true,
+            macros: macros,
+        });
+        expect(macros).toEqual({});
+    });
 });
 
 describe("A tie parser", function() {
@@ -2690,6 +2699,62 @@ describe("A macro expander", function() {
         expect("\\gdef\\foo\\bar").toParse();
         expect("\\gdef{\\foo\\bar}{}").toNotParse();
         expect("\\gdef{}{}").toNotParse();
+        // TODO: These shouldn't work, but `1` and `{1}` are currently treated
+        // the same, as are `\foo` and `{\foo}`.
+        //expect("\\gdef\\foo1").toNotParse();
+        //expect("\\gdef{\\foo}{}").toNotParse();
+    });
+
+    it("\\def works locally", () => {
+        expect("\\def\\x{1}\\x{\\def\\x{2}\\x{\\def\\x{3}\\x}\\x}\\x")
+            .toParseLike("1{2{3}2}1");
+        expect("\\def\\x{1}\\x\\def\\x{2}\\x{\\def\\x{3}\\x\\def\\x{4}\\x}\\x")
+            .toParseLike("12{34}2");
+    });
+
+    it("\\gdef overrides at all levels", () => {
+        expect("\\def\\x{1}\\x{\\def\\x{2}\\x{\\gdef\\x{3}\\x}\\x}\\x")
+            .toParseLike("1{2{3}3}3");
+        expect("\\def\\x{1}\\x{\\def\\x{2}\\x{\\global\\def\\x{3}\\x}\\x}\\x")
+            .toParseLike("1{2{3}3}3");
+        expect("\\def\\x{1}\\x{\\def\\x{2}\\x{\\gdef\\x{3}\\x\\def\\x{4}\\x}" +
+            "\\x\\def\\x{5}\\x}\\x").toParseLike("1{2{34}35}3");
+    });
+
+    it("\\global needs to followed by \\def", () => {
+        expect("\\global\\def\\foo{}\\foo").toParseLike("");
+        // TODO: This doesn't work yet; \global needs to expand argument.
+        //expect("\\def\\DEF{\\def}\\global\\DEF\\foo{}\\foo").toParseLike("");
+        expect("\\global\\foo").toNotParse();
+        expect("\\global\\bar x").toNotParse();
+    });
+
+    it("Macro arguments do not generate groups", () => {
+        expect("\\def\\x{1}\\x\\def\\foo#1{#1}\\foo{\\x\\def\\x{2}\\x}\\x")
+            .toParseLike("1122");
+    });
+
+    it("\\textbf arguments do generate groups", () => {
+        expect("\\def\\x{1}\\x\\textbf{\\x\\def\\x{2}\\x}\\x")
+            .toParseLike("1\\textbf{12}1");
+    });
+
+    it("\\sqrt optional arguments generate groups", () => {
+        expect("\\def\\x{1}\\def\\y{1}\\x\\y" +
+            "\\sqrt[\\def\\x{2}\\x]{\\def\\y{2}\\y}\\x\\y")
+            .toParseLike("11\\sqrt[2]{2}11");
+    });
+
+    it("\\gdef changes settings.macros", () => {
+        const macros = {};
+        expect("\\gdef\\foo{1}").toParse(new Settings({macros}));
+        expect(macros["\\foo"]).toBeTruthy();
+    });
+
+    it("\\def doesn't change settings.macros", () => {
+        const macros = {};
+        expect("\\def\\foo{1}").toParse(new Settings({macros}));
+        expect(macros["\\foo"]).toBeFalsy();
     });
 
     // This may change in the future, if we support the extra features of
@@ -2967,6 +3032,12 @@ describe("Newlines via \\\\ and \\newline", function() {
     it("should not allow \\cr at top level", () => {
         expect("hello \\cr world").toNotBuild();
     });
+
+    it("array redefines and resets \\\\", () => {
+        expect("a\\\\b\\begin{matrix}x&y\\\\z&w\\end{matrix}\\\\c")
+            .toParseLike("a\\newline b\\begin{matrix}x&y\\cr z&w\\end{matrix}" +
+                         "\\newline c");
+    });
 });
 
 describe("Symbols", function() {