\newcommand, \renewcommand, \providecommand (#1382)

* \newcommand, \renewcommand, \providecommand

* Tests

* Add comment

* Add symbols to the set of already defined things

* Add implicitCommands, catch \hline outside array

* Add \relax

* Move isDefined to be a method of MacroExpander

* Namespace.has

* Reword error messages

* Add \hdashline given #1407
This commit is contained in:
Erik Demaine
2018-06-07 13:39:39 +02:00
committed by GitHub
parent a5ef29fab1
commit 65569249be
9 changed files with 212 additions and 46 deletions

View File

@@ -4,6 +4,8 @@
* until only non-macro tokens remain.
*/
import functions from "./functions";
import symbols from "./symbols";
import Lexer from "./Lexer";
import {Token} from "./Token";
import type {Mode} from "./types";
@@ -15,6 +17,16 @@ import type {MacroContextInterface, MacroDefinition, MacroExpansion}
from "./macros";
import type Settings from "./Settings";
// List of commands that act like macros but aren't defined as a macro,
// function, or symbol. Used in `isDefined`.
export const implicitCommands = {
"\\relax": true, // MacroExpander.js
"^": true, // Parser.js
"_": true, // Parser.js
"\\limits": true, // Parser.js
"\\nolimits": true, // Parser.js
};
export default class MacroExpander implements MacroContextInterface {
maxExpand: number;
lexer: Lexer;
@@ -316,5 +328,19 @@ export default class MacroExpander implements MacroContextInterface {
return expansion;
}
/**
* Determine whether a command is currently "defined" (has some
* functionality), meaning that it's a macro (in the current group),
* a function, a symbol, or one of the special commands listed in
* `implicitCommands`.
*/
isDefined(name: string): boolean {
return this.macros.has(name) ||
functions.hasOwnProperty(name) ||
symbols.math.hasOwnProperty(name) ||
symbols.text.hasOwnProperty(name) ||
implicitCommands.hasOwnProperty(name);
}
}

View File

@@ -56,7 +56,21 @@ export default class Namespace<Value> {
}
/**
* Get the current value of a name.
* Detect whether `name` has a definition. Equivalent to
* `get(name) != null`.
*/
has(name: string): boolean {
return this.current.hasOwnProperty(name) ||
this.builtins.hasOwnProperty(name);
}
/**
* Get the current value of a name, or `undefined` if there is no value.
*
* Note: Do not use `if (namespace.get(...))` to detect whether a macro
* is defined, as the definition may be the empty string which evaluates
* to `false` in JavaScript. Use `if (namespace.get(...) != null)` or
* `if (namespace.has(...))`.
*/
get(name: string): ?Value {
if (this.current.hasOwnProperty(name)) {

View File

@@ -1,6 +1,7 @@
// @flow
import buildCommon from "../buildCommon";
import defineEnvironment from "../defineEnvironment";
import defineFunction from "../defineFunction";
import mathMLTree from "../mathMLTree";
import ParseError from "../ParseError";
import ParseNode from "../ParseNode";
@@ -659,3 +660,18 @@ defineEnvironment({
htmlBuilder,
mathmlBuilder,
});
// Catch \hline outside array environment
defineFunction({
type: "text", // Doesn't matter what this is.
names: ["\\hline", "\\hdashline"],
props: {
numArgs: 0,
allowedInText: true,
allowedInMath: true,
},
handler(context, args) {
throw new ParseError(
`${context.funcName} valid only within array environment`);
},
});

View File

@@ -38,6 +38,11 @@ export interface MacroContextInterface {
*/
expandAfterFuture(): Token;
/**
* Recursively expand first token, then return first non-expandable token.
*/
expandNextToken(): Token;
/**
* Fully expand the given macro name and return the resulting list of
* tokens, or return `undefined` if no such macro is defined.
@@ -55,6 +60,14 @@ export interface MacroContextInterface {
* and return the resulting array of arguments.
*/
consumeArgs(numArgs: number): Token[][];
/**
* Determine whether a command is currently "defined" (has some
* functionality), meaning that it's a macro (in the current group),
* a function, a symbol, or one of the special commands listed in
* `implicitCommands`.
*/
isDefined(name: string): boolean;
}
/** Macro tokens (in reverse order). */
@@ -118,11 +131,12 @@ defineMacro("\\TextOrMath", function(context) {
}
});
// Basic support for global macro definitions:
// \gdef\macro{expansion}
// \gdef\macro#1{expansion}
// \gdef\macro#1#2{expansion}
// \gdef\macro#1#2#3#4#5#6#7#8#9{expansion}
// Basic support for macro definitions:
// \def\macro{expansion}
// \def\macro#1{expansion}
// \def\macro#1#2{expansion}
// \def\macro#1#2#3#4#5#6#7#8#9{expansion}
// Also the \gdef and \global\def equivalents
const def = (context, global: boolean) => {
let arg = context.consumeArgs(1)[0];
if (arg.length !== 1) {
@@ -161,6 +175,7 @@ defineMacro("\\global", (context) => {
throw new ParseError("Invalid command after \\global");
}
const command = next[0].text;
// TODO: Should expand command
if (command === "\\def") {
// \global\def is equivalent to \gdef
return def(context, true);
@@ -169,6 +184,55 @@ defineMacro("\\global", (context) => {
}
});
// \newcommand{\macro}[args]{definition}
// \renewcommand{\macro}[args]{definition}
// TODO: Optional arguments: \newcommand{\macro}[args][default]{definition}
const newcommand = (context, existsOK: boolean, nonexistsOK: boolean) => {
let arg = context.consumeArgs(1)[0];
if (arg.length !== 1) {
throw new ParseError(
"\\newcommand's first argument must be a macro name");
}
const name = arg[0].text;
const exists = context.isDefined(name);
if (exists && !existsOK) {
throw new ParseError(`\\newcommand{${name}} attempting to redefine ` +
`${name}; use \\renewcommand`);
}
if (!exists && !nonexistsOK) {
throw new ParseError(`\\renewcommand{${name}} when command ${name} ` +
`does not yet exist; use \\newcommand`);
}
let numArgs = 0;
arg = context.consumeArgs(1)[0];
if (arg.length === 1 && arg[0].text === "[") {
let argText = '';
let token = context.expandNextToken();
while (token.text !== "]" && token.text !== "EOF") {
// TODO: Should properly expand arg, e.g., ignore {}s
argText += token.text;
token = context.expandNextToken();
}
if (!argText.match(/^\s*[0-9]+\s*$/)) {
throw new ParseError(`Invalid number of arguments: ${argText}`);
}
numArgs = parseInt(argText);
arg = context.consumeArgs(1)[0];
}
// Final arg is the expansion of the macro
context.macros.set(name, {
tokens: arg,
numArgs,
});
return '';
};
defineMacro("\\newcommand", (context) => newcommand(context, false, true));
defineMacro("\\renewcommand", (context) => newcommand(context, true, false));
defineMacro("\\providecommand", (context) => newcommand(context, true, true));
//////////////////////////////////////////////////////////////////////
// Grouping
// \let\bgroup={ \let\egroup=}

View File

@@ -5,15 +5,10 @@ import ParseError from "../src/ParseError";
import parseTree from "../src/parseTree";
import Settings from "../src/Settings";
export const defaultSettings = new Settings({
strict: false, // deal with warnings only when desired
});
export const nonstrictSettings = new Settings({strict: false});
export const strictSettings = new Settings({strict: true});
export const _getBuilt = function(expr, settings = defaultSettings) {
if (settings === defaultSettings) {
settings.macros = {};
}
export const _getBuilt = function(expr, settings = new Settings()) {
let rootNode = katex.__renderToDomTree(expr, settings);
if (rootNode.classes.indexOf('katex-error') >= 0) {
@@ -43,7 +38,7 @@ export const _getBuilt = function(expr, settings = defaultSettings) {
* @param settings
* @returns {Object}
*/
export const getBuilt = function(expr, settings = defaultSettings) {
export const getBuilt = function(expr, settings = new Settings()) {
expect(expr).toBuild(settings);
return _getBuilt(expr, settings);
};
@@ -54,7 +49,7 @@ export const getBuilt = function(expr, settings = defaultSettings) {
* @param settings
* @returns {Object}
*/
export const getParsed = function(expr, settings = defaultSettings) {
export const getParsed = function(expr, settings = new Settings()) {
expect(expr).toParse(settings);
return parseTree(expr, settings);
};
@@ -73,7 +68,7 @@ export const stripPositions = function(expr) {
};
export const parseAndSetResult = function(expr, result,
settings = defaultSettings) {
settings = new Settings()) {
try {
return parseTree(expr, settings);
} catch (e) {
@@ -89,7 +84,7 @@ export const parseAndSetResult = function(expr, result,
};
export const buildAndSetResult = function(expr, result,
settings = defaultSettings) {
settings = new Settings()) {
try {
return _getBuilt(expr, settings);
} catch (e) {

View File

@@ -11,7 +11,7 @@ import Options from "../src/Options";
import Settings from "../src/Settings";
import Style from "../src/Style";
import {
defaultSettings, strictSettings,
strictSettings, nonstrictSettings,
_getBuilt, getBuilt, getParsed, stripPositions,
} from "./helpers";
@@ -1140,6 +1140,10 @@ describe("A begin/end parser", function() {
expect("\\begin{matrix}\\hdashline a&b\\\\ \\hdashline c&d\\end{matrix}").toParse();
});
it("should forbid hlines outside array environment", () => {
expect("\\hline").toNotParse();
});
it("should error when name is mismatched", function() {
expect("\\begin{matrix}a&b\\\\c&d\\end{pmatrix}").toNotParse();
});
@@ -2282,7 +2286,7 @@ describe("A smash builder", function() {
describe("A parser error", function() {
it("should report the position of an error", function() {
try {
parseTree("\\sqrt}", defaultSettings);
parseTree("\\sqrt}", new Settings());
} catch (e) {
expect(e.position).toEqual(5);
}
@@ -2490,7 +2494,7 @@ describe("A macro expander", function() {
const compareParseTree = function(actual, expected, macros) {
const settings = new Settings({macros: macros});
actual = stripPositions(parseTree(actual, settings));
expected = stripPositions(parseTree(expected, defaultSettings));
expected = stripPositions(parseTree(expected, new Settings()));
expect(actual).toEqual(expected);
};
@@ -2775,6 +2779,57 @@ describe("A macro expander", function() {
expect(macros["\\foo"]).toBeFalsy();
});
it("\\newcommand defines new macros", () => {
compareParseTree("\\newcommand\\foo{x^2}\\foo+\\foo", "x^2+x^2");
compareParseTree("\\newcommand{\\foo}{x^2}\\foo+\\foo", "x^2+x^2");
// Function detection
expect("\\newcommand\\bar{x^2}\\bar+\\bar").toNotParse();
expect("\\newcommand{\\bar}{x^2}\\bar+\\bar").toNotParse();
// Symbol detection
expect("\\newcommand\\lambda{x^2}\\lambda").toNotParse();
expect("\\newcommand\\textdollar{x^2}\\textdollar").toNotParse();
// Macro detection
expect("\\newcommand{\\foo}{1}\\foo\\newcommand{\\foo}{2}\\foo")
.toNotParse();
// Implicit detection
expect("\\newcommand\\limits{}").toNotParse();
});
it("\\renewcommand redefines macros", () => {
expect("\\renewcommand\\foo{x^2}\\foo+\\foo").toNotParse();
expect("\\renewcommand{\\foo}{x^2}\\foo+\\foo").toNotParse();
compareParseTree("\\renewcommand\\bar{x^2}\\bar+\\bar", "x^2+x^2");
compareParseTree("\\renewcommand{\\bar}{x^2}\\bar+\\bar", "x^2+x^2");
expect("\\newcommand{\\foo}{1}\\foo\\renewcommand{\\foo}{2}\\foo")
.toParseLike("12");
});
it("\\providecommand (re)defines macros", () => {
compareParseTree("\\providecommand\\foo{x^2}\\foo+\\foo", "x^2+x^2");
compareParseTree("\\providecommand{\\foo}{x^2}\\foo+\\foo", "x^2+x^2");
compareParseTree("\\providecommand\\bar{x^2}\\bar+\\bar", "x^2+x^2");
compareParseTree("\\providecommand{\\bar}{x^2}\\bar+\\bar", "x^2+x^2");
expect("\\newcommand{\\foo}{1}\\foo\\providecommand{\\foo}{2}\\foo")
.toParseLike("12");
expect("\\providecommand{\\foo}{1}\\foo\\renewcommand{\\foo}{2}\\foo")
.toParseLike("12");
expect("\\providecommand{\\foo}{1}\\foo\\providecommand{\\foo}{2}\\foo")
.toParseLike("12");
});
it("\\newcommand is local", () => {
expect("\\newcommand\\foo{1}\\foo{\\renewcommand\\foo{2}\\foo}\\foo")
.toParseLike("1{2}1");
});
it("\\newcommand accepts number of arguments", () => {
compareParseTree("\\newcommand\\foo[1]{#1^2}\\foo x+\\foo{y}",
"x^2+y^2");
compareParseTree("\\newcommand\\foo[10]{#1^2}\\foo 0123456789", "0^2");
expect("\\newcommand\\foo[x]{}").toNotParse();
expect("\\newcommand\\foo[1.5]{}").toNotParse();
});
// This may change in the future, if we support the extra features of
// \hspace.
it("should treat \\hspace, \\hskip like \\kern", function() {
@@ -2847,7 +2902,7 @@ describe("Unicode accents", function() {
"\\tilde n" +
"\\grave o\\acute o\\hat o\\tilde o\\ddot o" +
"\\grave u\\acute u\\hat u\\ddot u" +
"\\acute y\\ddot y");
"\\acute y\\ddot y", nonstrictSettings);
});
it("should parse Latin-1 letters in text mode", function() {
@@ -2877,19 +2932,19 @@ describe("Unicode accents", function() {
});
it("should parse combining characters", function() {
expect("A\u0301C\u0301").toParseLike("Á\\acute C");
expect("A\u0301C\u0301").toParseLike("Á\\acute C", nonstrictSettings);
expect("\\text{A\u0301C\u0301}").toParseLike("\\text{Á\\'C}", strictSettings);
});
it("should parse multi-accented characters", function() {
expect("ấā́ắ\\text{ấā́ắ}").toParse();
expect("ấā́ắ\\text{ấā́ắ}").toParse(nonstrictSettings);
// Doesn't parse quite the same as
// "\\text{\\'{\\^a}\\'{\\=a}\\'{\\u a}}" because of the ordgroups.
});
it("should parse accented i's and j's", function() {
expect("íȷ́").toParseLike("\\acute ı\\acute ȷ");
expect("ấā́ắ\\text{ấā́ắ}").toParse();
expect("íȷ́").toParseLike("\\acute ı\\acute ȷ", nonstrictSettings);
expect("ấā́ắ\\text{ấā́ắ}").toParse(nonstrictSettings);
});
});
@@ -3076,8 +3131,8 @@ describe("Symbols", function() {
describe("strict setting", function() {
it("should allow unicode text when not strict", () => {
expect("é").toParse(new Settings({strict: false}));
expect("試").toParse(new Settings({strict: false}));
expect("é").toParse(new Settings(nonstrictSettings));
expect("試").toParse(new Settings(nonstrictSettings));
expect("é").toParse(new Settings({strict: "ignore"}));
expect("試").toParse(new Settings({strict: "ignore"}));
expect("é").toParse(new Settings({strict: () => false}));
@@ -3103,7 +3158,7 @@ describe("strict setting", function() {
});
it("should always allow unicode text in text mode", () => {
expect("\\text{é試}").toParse(new Settings({strict: false}));
expect("\\text{é試}").toParse(nonstrictSettings);
expect("\\text{é試}").toParse(strictSettings);
expect("\\text{é試}").toParse();
});

View File

@@ -8,13 +8,9 @@ import Options from "../src/Options";
import Settings from "../src/Settings";
import Style from "../src/Style";
const defaultSettings = new Settings({});
const getMathML = function(expr, settings) {
const usedSettings = settings ? settings : defaultSettings;
const getMathML = function(expr, settings = new Settings()) {
let startStyle = Style.TEXT;
if (usedSettings.displayMode) {
if (settings.displayMode) {
startStyle = Style.DISPLAY;
}
@@ -24,7 +20,7 @@ const getMathML = function(expr, settings) {
maxSize: Infinity,
});
const built = buildMathML(parseTree(expr, usedSettings), expr, options);
const built = buildMathML(parseTree(expr, settings), expr, options);
// Strip off the surrounding <span>
return built.children[0].toMarkup();

View File

@@ -4,10 +4,10 @@
import katex from "../katex";
import ParseError from "../src/ParseError";
import parseTree from "../src/parseTree";
import Settings from "../src/Settings";
import Warning from "./Warning";
import stringify from 'json-stable-stringify';
import {
defaultSettings,
_getBuilt, buildAndSetResult, parseAndSetResult, stripPositions,
} from "./helpers";
@@ -44,7 +44,7 @@ global.console.warn = jest.fn((warning) => {
// Expect extensions
expect.extend({
toParse: function(actual, settings = defaultSettings) {
toParse: function(actual, settings = new Settings()) {
const result = {
pass: true,
message: () => `'${actual}' succeeded parsing`,
@@ -53,7 +53,7 @@ expect.extend({
return result;
},
toNotParse: function(actual, settings = defaultSettings) {
toNotParse: function(actual, settings = new Settings()) {
const result = {
pass: false,
message: () =>
@@ -79,7 +79,7 @@ expect.extend({
toFailWithParseError: function(actual, expected) {
const prefix = "KaTeX parse error: ";
try {
parseTree(actual, defaultSettings);
parseTree(actual, new Settings());
return {
pass: false,
message: () => `'${actual}' parsed without error`,
@@ -115,7 +115,7 @@ expect.extend({
}
},
toBuild: function(actual, settings = defaultSettings) {
toBuild: function(actual, settings = new Settings()) {
const result = {
pass: true,
message: () => `'${actual}' succeeded in building`,
@@ -124,7 +124,7 @@ expect.extend({
return result;
},
toNotBuild: function(actual, settings = defaultSettings) {
toNotBuild: function(actual, settings = new Settings()) {
const result = {
pass: false,
message: () =>
@@ -147,7 +147,7 @@ expect.extend({
return result;
},
toParseLike: function(actual, expected, settings = defaultSettings) {
toParseLike: function(actual, expected, settings = new Settings()) {
const result = {
pass: true,
message: () =>
@@ -174,7 +174,7 @@ expect.extend({
return result;
},
toBuildLike: function(actual, expected, settings = defaultSettings) {
toBuildLike: function(actual, expected, settings = new Settings()) {
const result = {
pass: true,
message: () =>
@@ -201,7 +201,7 @@ expect.extend({
return result;
},
toWarn: function(actual, settings = defaultSettings) {
toWarn: function(actual, settings = new Settings()) {
const result = {
pass: false,
message: () =>

View File

@@ -4,7 +4,7 @@
/* global describe: false */
import Settings from "../src/Settings";
import {scriptFromCodepoint, supportedCodepoint} from "../src/unicodeScripts";
import {strictSettings} from "./helpers";
import {strictSettings, nonstrictSettings} from "./helpers";
describe("unicode", function() {
it("should parse Latin-1 inside \\text{}", function() {
@@ -21,7 +21,7 @@ describe("unicode", function() {
it("should parse Latin-1 outside \\text{}", function() {
expect('ÀÁÂÃÄÅÈÉÊËÌÍÎÏÑÒÓÔÕÖÙÚÛÜÝàáâãäåèéêëìíîïñòóôõöùúûüýÿ' +
'ÇÐÞçðþ').toParse();
'ÇÐÞçðþ').toParse(nonstrictSettings);
});
it("should parse all lower case Greek letters", function() {