Support \( and fix instant mode switching for $, \(, \text (#1213)

* Fix #1212 by supporting `\(...\)` in addition to `$...$` inline math nested inside `\text`. This turned out to be harder than one would think because of the way `$` was being handled specially, while `\(` needed to be handled as a function, which immediately consumed the `\(` token, which meant that the first following token got consumed in text mode instead of math mode (unlike `$` where we could switch the mode before consumption).
* Added new `consumeMode` setting for functions which tells the parser to switch modes just for the consumption of the command token.
* Now that we're working with functions, move all the `$` handling code into `functions/math.js`.  Somewhat bizarrely, we can define `$` as a function (nearly identical to `\(`) and it all works (just like we can have single-character macros).  This move removed a lot of messy stuff from `Parser.js`: `ParsedDollar`, `ParsedFuncOrArgOrDollar`, `newDollar`, `assertFuncOrArg`, and at least three explicit checks for `if (... === "$")`.
* Moved the `consume()` for the command token from `parseSymbol` to `parseGivenFunction`.  This ended up not being strictly necessary, but seems conceptually cleaner.
* Partially address #1027 by setting `consumeMode: "text"` for `\text` commands.  As a result, `\text` now instantly switches the mode to `"text"`, so even an unbraced macro argument will properly detect text mode.  I added a test for this, which is a slightly weaker form of #1027.
This commit is contained in:
Erik Demaine
2018-03-14 10:54:36 -04:00
committed by GitHub
parent 5bcdeec4ad
commit 4f29c5a942
7 changed files with 133 additions and 67 deletions

View File

@@ -16,7 +16,7 @@ import Settings from "./Settings";
import { Token } from "./Token";
import type { Mode, ArgType, BreakToken } from "./types";
import type { FunctionContext, FunctionSpec } from "./defineFunction" ;
import type { FunctionContext, FunctionSpec } from "./defineFunction";
import type { EnvSpec } from "./defineEnvironment";
/**
@@ -48,7 +48,7 @@ import type { EnvSpec } from "./defineEnvironment";
*
* The earlier functions return ParseNodes.
* The later functions (which are called deeper in the parse) sometimes return
* ParsedFuncOrArgOrDollar, which contain a ParseNode as well as some data about
* ParsedFuncOrArg, which contain a ParseNode as well as some data about
* whether the parsed object is a function which is missing some arguments, or a
* standalone object which can be used as an argument to another function.
*/
@@ -63,13 +63,6 @@ type ParsedArg = {|
result: ParseNode,
token: Token,
|};
type ParsedDollar = {|
// Math mode switch
type: "$",
result: "$",
token: Token,
|};
type ParsedFuncOrArgOrDollar = ParsedFunc | ParsedArg | ParsedDollar;
type ParsedFuncOrArg = ParsedFunc | ParsedArg;
function newArgument(result: ParseNode, token: Token): ParsedArg {
@@ -80,17 +73,6 @@ function newFunction(token: Token): ParsedFunc {
return {type: "fn", result: token.text, token};
}
function newDollar(token: Token): ParsedDollar {
return {type: "$", result: "$", token};
}
function assertFuncOrArg(parsed: ParsedFuncOrArgOrDollar): ParsedFuncOrArg {
if (parsed.type === "$") {
throw new ParseError("Unexpected $", parsed.token);
}
return parsed;
}
export default class Parser {
mode: Mode;
gullet: MacroExpander;
@@ -294,20 +276,19 @@ export default class Parser {
}
}
const arg = assertFuncOrArg(group);
if (arg.type === "fn") {
if (group.type === "fn") {
// ^ and _ have a greediness, so handle interactions with functions'
// greediness
const funcGreediness = functions[arg.result].greediness;
const funcGreediness = functions[group.result].greediness;
if (funcGreediness > Parser.SUPSUB_GREEDINESS) {
return this.parseGivenFunction(group);
} else {
throw new ParseError(
"Got function '" + arg.result + "' with no arguments " +
"Got function '" + group.result + "' with no arguments " +
"as " + name, symbolToken);
}
} else {
return arg.result;
return group.result;
}
}
@@ -455,25 +436,7 @@ export default class Parser {
const func = start.result;
if (func === "$") {
if (this.mode === "math") {
throw new ParseError("$ within math mode");
}
const outerMode = this.mode;
this.switchMode("math");
// Expand next symbol now that we're in math mode.
this.consume();
const body = this.parseExpression(false, "$");
// We can't expand the next symbol after the $ until after
// switching modes back. So don't consume within expect.
this.expect("$", false);
this.switchMode(outerMode);
this.consume();
return new ParseNode("styling", {
style: "text",
value: body,
}, "math");
} else if (func === "\\begin") {
if (func === "\\begin") {
// begin...end is similar to left...right
const begin = this.parseGivenFunction(start);
const envName = begin.value.name;
@@ -524,10 +487,9 @@ export default class Parser {
* non-nullable result.
*/
parseGivenFunction(
baseGroup: ParsedFuncOrArgOrDollar,
baseGroup: ParsedFuncOrArg,
breakOnTokenText?: BreakToken,
): ParseNode {
baseGroup = assertFuncOrArg(baseGroup);
if (baseGroup.type === "fn") {
const func = baseGroup.result;
const funcData = functions[func];
@@ -542,10 +504,21 @@ export default class Parser {
baseGroup.token);
}
// Consume the command token after possibly switching to the
// mode specified by the function (for instant mode switching),
// and then immediately switch back.
if (funcData.consumeMode) {
const oldMode = this.mode;
this.switchMode(funcData.consumeMode);
this.consume();
this.switchMode(oldMode);
} else {
this.consume();
}
const {args, optArgs} = this.parseArguments(func, funcData);
const token = baseGroup.token;
const result =
this.callFunction(func, args, optArgs, token, breakOnTokenText);
const result = this.callFunction(
func, args, optArgs, token, breakOnTokenText);
return new ParseNode(result.type, result, this.mode);
} else {
return baseGroup.result;
@@ -632,7 +605,6 @@ export default class Parser {
}
}
let argNode: ParseNode;
arg = assertFuncOrArg(arg);
if (arg.type === "fn") {
const argGreediness =
functions[arg.result].greediness;
@@ -658,7 +630,7 @@ export default class Parser {
parseGroupOfType(
type: ArgType, // Used to describe the mode in error messages.
optional: boolean,
): ?ParsedFuncOrArgOrDollar {
): ?ParsedFuncOrArg {
// Handle `original` argTypes
if (type === "original") {
type = this.mode;
@@ -861,7 +833,7 @@ export default class Parser {
* If `mode` is present, switches to that mode while parsing the group,
* and switches back after.
*/
parseGroup(optional?: boolean, mode?: Mode): ?ParsedFuncOrArgOrDollar {
parseGroup(optional?: boolean, mode?: Mode): ?ParsedFuncOrArg {
const outerMode = this.mode;
const firstToken = this.nextToken;
// Try to parse an open brace
@@ -936,14 +908,15 @@ export default class Parser {
* Parse a single symbol out of the string. Here, we handle both the functions
* we have defined, as well as the single character symbols
*/
parseSymbol(): ?ParsedFuncOrArgOrDollar {
parseSymbol(): ?ParsedFuncOrArg {
const nucleus = this.nextToken;
let text = nucleus.text;
if (functions[text]) {
this.consume();
// If there exists a function with this name, we return the function and
// say that it is a function.
// If there exists a function with this name, we return the
// function and say that it is a function.
// The token will be consumed later in parseGivenFunction
// (after possibly switching modes).
return newFunction(nucleus);
} else if (/^\\verb[^a-zA-Z]/.test(text)) {
this.consume();
@@ -964,8 +937,6 @@ export default class Parser {
body: arg,
star: star,
}, "text"), nucleus);
} else if (text === "$") {
return newDollar(nucleus);
}
// At this point, we should have a symbol, possibly with accents.
// First expand any accented base symbol according to unicodeSymbols,

View File

@@ -2,11 +2,11 @@
import {groupTypes as htmlGroupTypes} from "./buildHTML";
import {groupTypes as mathmlGroupTypes} from "./buildMathML";
import type Parser from "./Parser" ;
import type ParseNode from "./ParseNode" ;
import type Parser from "./Parser";
import type ParseNode from "./ParseNode";
import type Options from "./Options";
import type {ArgType, BreakToken} from "./types" ;
import type {Token} from "./Token" ;
import type {ArgType, BreakToken, Mode} from "./types";
import type {Token} from "./Token";
/** Context provided to function handlers for error messages. */
export type FunctionContext = {|
@@ -70,6 +70,14 @@ export type FunctionPropSpec = {
// Must be true if the function is an infix operator.
infix?: boolean,
// Switch to the specified mode while consuming the command token.
// This is useful for commands that switch between math and text mode,
// for making sure that a switch happens early enough. Note that the
// mode is switched immediately back to its original value after consuming
// the command token, so that the argument parsing and/or function handler
// can easily access the old mode while doing their own mode switching.
consumeMode?: ?Mode,
};
type FunctionDefSpec = {|
@@ -119,6 +127,7 @@ export type FunctionSpec = {|
allowedInMath: boolean,
numOptionalArgs: number,
infix: boolean,
consumeMode: ?Mode,
// Must be specified unless it's handled directly in the parser.
handler: ?FunctionHandler,
|};
@@ -149,6 +158,7 @@ export default function defineFunction({
: props.allowedInMath,
numOptionalArgs: props.numOptionalArgs || 0,
infix: !!props.infix,
consumeMode: props.consumeMode,
handler: handler,
};
for (let i = 0; i < names.length; ++i) {

View File

@@ -8,7 +8,7 @@ import {
_functions,
} from "./defineFunction";
import type {FunctionPropSpec, FunctionHandler} from "./defineFunction" ;
import type {FunctionPropSpec, FunctionHandler} from "./defineFunction";
// WARNING: New functions should be added to src/functions and imported here.
@@ -33,6 +33,8 @@ import "./functions/color";
import "./functions/text";
import "./functions/math";
import "./functions/enclose";
import "./functions/overline";

44
src/functions/math.js Normal file
View File

@@ -0,0 +1,44 @@
// @flow
import defineFunction from "../defineFunction";
import ParseError from "../ParseError";
// Switching from text mode back to math mode
defineFunction({
names: ["\\(", "$"],
props: {
numArgs: 0,
allowedInText: true,
allowedInMath: false,
consumeMode: "math",
},
handler(context, args) {
const {funcName, parser} = context;
const outerMode = parser.mode;
parser.switchMode("math");
const close = (funcName === "\\(" ? "\\)" : "$");
const body = parser.parseExpression(false, close);
// We can't expand the next symbol after the closing $ until after
// switching modes back. So don't consume within expect.
parser.expect(close, false);
parser.switchMode(outerMode);
parser.consume();
return {
type: "styling",
style: "text",
value: body,
};
},
});
// Check for extra closing math delimiters
defineFunction({
names: ["\\)", "\\]"],
props: {
numArgs: 0,
allowedInText: true,
allowedInMath: false,
},
handler(context, args) {
throw new ParseError(`Mismatched ${context.funcName}`);
},
});

View File

@@ -35,6 +35,7 @@ defineFunction({
argTypes: ["text"],
greediness: 2,
allowedInText: true,
consumeMode: "text",
},
handler(context, args) {
const body = args[0];

View File

@@ -23,4 +23,5 @@ export type ArgType = "color" | "size" | "url" | "original" | Mode;
// LaTeX display style.
export type StyleStr = "text" | "display";
export type BreakToken = "]" | "}" | "$";
// Allowable token text for "break" arguments in parser
export type BreakToken = "]" | "}" | "$" | "\\)";

View File

@@ -812,7 +812,6 @@ describe("A text parser", function() {
const badTextExpression = "\\text{a b%}";
const badFunctionExpression = "\\text{\\sqrt{x}}";
const mathTokenAfterText = "\\text{sin}^2";
const textWithEmbeddedMath = "\\text{graph: $y = mx + b$}";
it("should not fail", function() {
expect(textExpression).toParse();
@@ -872,7 +871,40 @@ describe("A text parser", function() {
});
it("should parse math within text group", function() {
expect(textWithEmbeddedMath).toParse();
expect("\\text{graph: $y = mx + b$}").toParse();
expect("\\text{graph: \\(y = mx + b\\)}").toParse();
});
it("should parse math within text within math within text", function() {
expect("\\text{hello $x + \\text{world $y$} + z$}").toParse();
expect("\\text{hello \\(x + \\text{world $y$} + z\\)}").toParse();
expect("\\text{hello $x + \\text{world \\(y\\)} + z$}").toParse();
expect("\\text{hello \\(x + \\text{world \\(y\\)} + z\\)}").toParse();
});
it("should forbid \\( within math mode", function() {
expect("\\(").toNotParse();
expect("\\text{$\\(x\\)$}").toNotParse();
});
it("should forbid $ within math mode", function() {
expect("$x$").toNotParse();
expect("\\text{\\($x$\\)}").toNotParse();
});
it("should detect unbalanced \\)", function() {
expect("\\)").toNotParse();
expect("\\text{\\)}").toNotParse();
});
it("should detect unbalanced $", function() {
expect("$").toNotParse();
expect("\\text{$}").toNotParse();
});
it("should not mix $ and \\(..\\)", function() {
expect("\\text{$x\\)}").toNotParse();
expect("\\text{\\(x$}").toNotParse();
});
it("should parse spacing functions", function() {
@@ -2824,8 +2856,13 @@ describe("A macro expander", function() {
{"\\mode": "\\TextOrMath{text}{math}"});
});
// TODO(edemaine): This doesn't work yet. Parses like `\text math`,
// which doesn't even treat all four letters as an argument.
it("\\TextOrMath should work in a macro passed to \\text", function() {
compareParseTree("\\text\\mode", "\\text t",
{"\\mode": "\\TextOrMath{t}{m}"});
});
// TODO(edemaine): This doesn't work yet. Parses like `\text text`,
// which doesn't treat all four letters as an argument.
//it("\\TextOrMath should work in a macro passed to \\text", function() {
// compareParseTree("\\text\\mode", "\\text{text}",
// {"\\mode": "\\TextOrMath{text}{math}"});