diff --git a/src/MacroExpander.js b/src/MacroExpander.js index 01d703be..acf2fbc1 100644 --- a/src/MacroExpander.js +++ b/src/MacroExpander.js @@ -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); + } } diff --git a/src/Namespace.js b/src/Namespace.js index 74d59d56..410dc42d 100644 --- a/src/Namespace.js +++ b/src/Namespace.js @@ -56,7 +56,21 @@ export default class Namespace { } /** - * 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)) { diff --git a/src/environments/array.js b/src/environments/array.js index 4be385dd..b0c959de 100644 --- a/src/environments/array.js +++ b/src/environments/array.js @@ -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`); + }, +}); diff --git a/src/macros.js b/src/macros.js index 5c16c97c..6feac39c 100644 --- a/src/macros.js +++ b/src/macros.js @@ -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=} diff --git a/test/helpers.js b/test/helpers.js index 4f6d2f46..0300eb50 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -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) { diff --git a/test/katex-spec.js b/test/katex-spec.js index c458ab2a..5ed1f342 100644 --- a/test/katex-spec.js +++ b/test/katex-spec.js @@ -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(); }); diff --git a/test/mathml-spec.js b/test/mathml-spec.js index a1f55196..cfd89ca2 100644 --- a/test/mathml-spec.js +++ b/test/mathml-spec.js @@ -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 return built.children[0].toMarkup(); diff --git a/test/setup.js b/test/setup.js index 731309ca..b79e429f 100644 --- a/test/setup.js +++ b/test/setup.js @@ -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: () => diff --git a/test/unicode-spec.js b/test/unicode-spec.js index 1453231e..5389d45a 100644 --- a/test/unicode-spec.js +++ b/test/unicode-spec.js @@ -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() {