\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
This commit is contained in:
Erik Demaine
2017-11-24 14:48:04 -05:00
committed by Kevin Barabash
parent c8249c389f
commit e93668c666
3 changed files with 115 additions and 44 deletions

View File

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

View File

@@ -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

View File

@@ -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() {