Цикл статей о создании собственного парсера выражений: от токенизации до полноценного синтаксического разбора.
Оглавление цикла
- Часть 1. Токенизация
- Часть 2. Вычисление выражения в обратной польской записи
- Часть 3. Алгоритм сортировочной станции
- Часть 4. Парсер рекурсивного спуска (вы находитесь здесь)
- Часть 5. Парсер Пратта
- Часть 6. Перевод из постфиксной формы в инфиксную
В предыдущих частях мы разобрали токенизацию математических выражений, научились вычислять выражения в обратной польской записи и построили алгоритм сортировочной станции для перевода в RPN.
Однако существует и другой, более выразительный способ синтаксического анализа – рекурсивный спуск. Этот метод лежит в основе множества настоящих парсеров, и его мощь в том, что он тесно связан с формальными грамматиками. В отличие от сортировочной станции, здесь правила синтаксиса можно выразить напрямую в виде вложенных функций, каждая из которых соответствует определённому уровню приоритета операций.
В этой части мы:
- познакомимся с понятием грамматик;
- поэтапно построим грамматику математических выражений;
- реализуем класс
RecursiveDescentParser, который будет на основе этой грамматики строить выражение в польской записи.
Что такое грамматика и рекурсивный спуск
Когда речь идёт о синтаксическом разборе, всё начинается с грамматики – формального описания допустимых конструкций языка. В контексте математических выражений грамматика определяет, в каком порядке и с какими правилами могут сочетаться числа, переменные, операции и скобки.
Простейший пример некоторого математического выражения, записанный на псевдо-BNF языке:
expression ::= term (("+" | "-") term)*
term ::= factor (("*" | "/") factor)*
factor ::= number | "(" expression ")"
Такую грамматику можно превратить в набор функций, каждая из которых разбирает свой уровень приоритета. Этот подход называется рекурсивным спуском: каждая функция вызывает другие, более "глубокие" функции, как бы "погружаясь" в структуру выражения.
Предиктивный разбор
Когда грамматика достаточно проста, мы можем обрабатывать её предиктивно – то есть на каждом шаге решения у нас есть достаточно информации, чтобы понять, какой путь разбора выбрать. Такие грамматики называются LL(1) (один токен взгляда вперёд), и именно с ними отлично работает рекурсивный спуск.
Такой подход даёт нам:
- простую отладку и поддержку;
- отличную читаемость и контроль;
- возможность построения польской записи прямо в процессе разбора.
Прежде чем сразу переходить к полной грамматике математических выражений, мы начнём с самой простой её формы и будем шаг за шагом расширять её, добавляя новые конструкции и соответствующую им логику в парсер. Такой подход позволит лучше понять устройство рекурсивного разбора и сохранить код читаемым и контролируемым на каждом этапе.
Рекурсивный парсер чисел
В качестве отправной точки возьмём минимальную грамматику, которая поддерживает только числовые литералы:
Expression = Primary Primary = NUMBER
Выражения, соответствующие такой грамматике максимально просты – 1, 3.141, 123.456, однако даже такой грамматики нам будет достаточно, чтобы погрузиться в мир рекурсивных парсеров. Давайте создадим файл recursive_descent_parser.js с классом RecursiveDescentParser:
class RecursiveDescentParser {
parse(tokens) {
this.tokens = tokens
this.index = 0
this.token = this.tokens[0]
this.rpn = []
this.parseExpression()
if (this.index < this.tokens.length)
throw new Error(`extra tokens after end of expression (${this.token.start})`)
return this.rpn
}
consume(type) {
const token = this.token
if (token === null)
throw new Error(`unexpected end of input, expected ${type}`)
if (token.type !== type)
throw new Error(`unexpected token: "${token.value}", expected ${type} (${token.start}:${token.end})`)
this.token = ++this.index < this.tokens.length ? this.tokens[this.index] : null
return token
}
/*
* Expression
* = Primary
*/
parseExpression() {
this.parsePrimary()
}
/*
* Primary
* = NUMBER
*/
parsePrimary() {
this.rpn.push(this.consume("number"))
}
}
Давайте посмотрим, что здесь происходит:
- Метод
parse(tokens):- Запоминает входной массив токенов.
- Обнуляет текущую позицию (
this.index) и сохраняет текущий токен (this.token). - Инициализирует выходной массив
this.rpn, в котором будет формироваться результат в виде польской записи. - Вызывает
parseExpression()– это точка входа для рекурсивного разбора всего выражения. - После завершения разбора проверяет, не остались ли необработанные токены. Если остались – значит в выражении синтаксическая ошибка.
- Метод
consume(type):- Проверяет, соответствует ли текущий токен ожидаемому типу.
- Если нет токенов или тип не совпадает – бросает исключение.
- Иначе возвращает текущий токен и переходит на следующий.
Обратите внимание: в качестве маркера конца ввода здесь используетсяnull– то естьthis.tokenстановитсяnull, когда выражение полностью прочитано. - Метод
parseExpression():- Просто вызывает
parsePrimary().
- Просто вызывает
- Метод
parsePrimary():- Съедает базовый терминал – число.
- Добавляет его в выходной массив RPN.
Обратите внимание на то, как мы добавили правила грамматики в виде комментариев непосредственно над методами parseExpression и parsePrimary. Такой приём помогает ясно представить, на каком этапе грамматического разбора мы находимся. По мере роста парсера это станет особенно полезным – ведь структура разбора будет прямо отражать структуру грамматики.
В данном случае Expression и Primary – это нетерминальные символы, а NUMBER – терминальный символ. Благодаря комментариям мы как бы накладываем текст грамматики поверх кода, превращая его в живую реализацию грамматических правил.
Мы последовательно вызываем методы, соответствующие нетерминальным символам грамматики, переходя от одного правила к другому – до тех пор, пока не будет обработано всё выражение. Именно такая структура, где разбор ведётся путём рекурсивных вызовов, и дала название подходу: анализатор рекурсивного спуска.
Хоть наша грамматика пока мала, но уже сейчас мы получили вполне работоспособный парсер. Давайте проверим его в деле:
const tokenizer = new ExpressionTokenizer({
functions: ["sin", "cos", "tan", "max"],
constants: ["pi", "e"]
})
const parser = new RecursiveDescentParser()
const rpn = parser.parse(tokenizer.tokenize("42")) // 42
Добавляем сложение и вычитание
Пусть рекурсия продолжается! Пришло время обновить нашу грамматическую «схему», чтобы обработать сложение и вычитание.
Expression
= Primary (("+" | "-") Primary)*
Primary
= NUMBER
Синтаксис (expression)* означает, что допускается ноль или более вхождений выражения. А ("+" | "-") означает любой из перечисленных символов, в нашем случае символ сложения или вычитания.
При работе с бесконечными последовательностями мы можем использовать цикл while, проверяющий, есть ли ещё один нужный символ. Давайте добавим это в наш парсер:
/*
* Expression
* = Primary (("+" | "-") Primary)*
*/
parseExpression() {
this.parsePrimary()
while (this.token?.type === "operator" && (this.token.value === "+" || this.token.value === "-")) {
const operator = this.consume("operator")
this.parsePrimary()
this.rpn.push(operator)
}
}
Как видите, изменился только метод parseExpression. По мере расширения грамматики и добавления новых конструкций количество рекурсивных вызовов увеличивается – это естественное следствие роста сложности выражения. Важно, чтобы после завершения разбора не осталось необработанных токенов: метод parse должен завершаться успешно только в случае полного соответствия всей строки заданной грамматике.
Проверим наш обновлённый парсер:
parser.parse(tokenizer.tokenize("1 + 2")) // 1 2 +
parser.parse(tokenizer.tokenize("1 + 2 - 5 + 8")) // 1 2 + 5 - 8 +
Добавляем умножение и деление
Добавим в нашу грамматику правило Term для обработки умножения и деления:
Expression
= Term (("+" | "-") Term)*
Term
= Primary (("*" | "/") Primary)*
Primary
= NUMBER
В нашем рекурсивном синтаксическом анализаторе нужно будет создать новый метод с именем parseTerm. Внутри метода parseExpression теперь будет вызываться parseTerm вместо parsePrimary:
/*
* Expression
* = Term (("+" | "-") Term)*
*/
parseExpression() {
this.parseTerm()
while (this.token?.type === "operator" && (this.token.value === "+" || this.token.value === "-")) {
const operator = this.consume("operator")
this.parseTerm()
this.rpn.push(operator)
}
}
/*
* Term
* = Primary (("*" | "/") Primary)*
*/
parseTerm() {
this.parsePrimary()
while (this.token?.type === "operator" && (this.token.value === "*" || this.token.value === "/")) {
const operator = this.consume("operator")
this.parsePrimary()
this.rpn.push(operator)
}
}
Проверим наш обновлённый парсер:
parser.parse(tokenizer.tokenize("1 + 2 * 3")) // 1 2 3 * +
parser.parse(tokenizer.tokenize("1 + 2 * 3 - 5 + 8 * 3 / 2.5")) // 1 2 3 * + 5 - 8 3 * 2.5 / +
Добавляем возведение в степень
Добавим в нашу грамматику правило Factor для обработки возведения в степень:
Expression
= Term (("+" | "-") Term)*
Term
= Factor (("*" | "/") Factor)*
Factor
= Primary ("^" Factor)*
Primary
= NUMBER
Обратите внимание, что второй "аргумент" в секции Factor не Primary, а Factor, поскольку возведение в степень – это правоассоциативная операция!
В нашем рекурсивном синтаксическом анализаторе нужно будет создать новый метод с именем parseFactor. Внутри метода parseTerm теперь будет вызываться parseFactor вместо parsePrimary:
/*
* Term
* = Factor (("*" | "/") Factor)*
*/
parseTerm() {
this.parseFactor()
while (this.token?.type === "operator" && (this.token.value === "*" || this.token.value === "/")) {
const operator = this.consume("operator")
this.parseFactor()
this.rpn.push(operator)
}
}
/*
* Factor
* = Primary ("^" Factor)*
*/
parseFactor() {
this.parsePrimary()
while (this.token?.type === "operator" && this.token.value === "^") {
const operator = this.consume("operator")
this.parseFactor()
this.rpn.push(operator)
}
}
Проверим наш обновлённый парсер:
parser.parse(tokenizer.tokenize("1 + 2*3^4")) // 1 2 3 4 ^ * +
parser.parse(tokenizer.tokenize("2^2^3")) // 2 2 3 ^ ^
Небольшой рефакторинг
Нетрудно заметить, что внутри методов parseExpression, parseFactor и parseTerm дублируется код. Давайте исправим это, создав вспомогательный метод parseBinaryOperation:
parseBinaryExpresson(parseLeft, parseRight, values) {
parseLeft()
while (this.token?.type === "operator" && values.has(this.token.value)) {
const operator = this.consume("operator")
parseRight()
this.rpn.push(operator)
}
}
/*
* Expression
* = Term (("+" | "-") Term)*
*/
parseExpression() {
this.parseBinaryExpresson(() => this.parseTerm(), () => this.parseTerm(), new Set(["+", "-"]))
}
/*
* Term
* = Factor (("*" | "/") Factor)*
*/
parseTerm() {
this.parseBinaryExpresson(() => this.parseFactor(), () => this.parseFactor(), new Set(["*", "/"]))
}
/*
* Factor
* = Primary ("^" Factor)*
*/
parseFactor() {
this.parseBinaryExpresson(() => this.parsePrimary(), () => this.parseFactor(), new Set(["^"]))
}
Стало гораздо чище, не правда ли? Давайте проверим, как наш парсер обрабатывает различные выражения:
parser.parse(tokenizer.tokenize("1 + 2 + 3")) // 1 2 3 + +
parser.parse(tokenizer.tokenize("7 - 4 - 2")) // 7 4 - 2 -
parser.parse(tokenizer.tokenize("1 * 2 * 3")) // 1 2 3 * *
parser.parse(tokenizer.tokenize("24 / 2 / 8")) // 24 2 / 8 /
parser.parse(tokenizer.tokenize("2^2")) // 2 2 ^
parser.parse(tokenizer.tokenize("1 + 2^3^4")) // 1 2 3 4 ^ ^ +
Добавляем обработку скобок
Теперь, когда мы немного привели наш код в порядок, давайте добавим в грамматику поддержку скобок:
Expression
= Term (("+" | "-") Term)*
Term
= Factor (("*" | "/") Factor)*
Factor
= Primary ("^" Factor)*
Primary
= ParenthesizedExpression
/ NUMBER
ParenthesizedExpression
= "(" expression ")"
Мы создадим новый метод parseParenthesizedExpression, который будет потреблять токены скобок ( и ) и парсить выражение между ними:
/*
* Primary
* = ParenthesizedExpression
* / NUMBER
*/
parsePrimary() {
if (this.token?.type === "left_parenthesis") {
this.parseParenthesizedExpression()
return
}
this.rpn.push(this.consume("number"))
}
/*
* ParenthesizedExpression
* = "(" expression ")"
*/
parseParenthesizedExpression() {
this.consume("left_parenthesis")
this.parseExpression()
this.consume("right_parenthesis")
}
И всё? И всё! В этом главная прелесть парсеров, использующих рекурсивный спуск. Давайте проверим обновлённый парсер:
parser.parse(tokenizer.tokenize("((1 + 2) + (3))")) // 1 2 + 3 +
parser.parse(tokenizer.tokenize("7 - (4 - 2)")) // 7 4 2 - -
parser.parse(tokenizer.tokenize("24 / (2 / 8)")) // 24 2 8 / /
parser.parse(tokenizer.tokenize("1 + (2^3)^4")) // 1 2 3 ^ 4 ^ +
Добавляем унарный минус
Как думаете, сложно его будет добавить? Вообще нет! Буквально так же, как мы только что добавили выражение в круглых скобках, добавим правило UnaryExpression в нашу грамматику:
Expression
= Term (("+" | "-") Term)*
Term
= Factor (("*" | "/") Factor)*
Factor
= Primary ("^" Factor)*
Primary
= ParenthesizedExpression
/ UnaryExpression
/ NUMBER
ParenthesizedExpression
= "(" expression ")"
UnaryExpression
= "-" Factor
Мы хотим, чтобы унарное выражение раскрывалось, как "-" Factor вместо "-" Expression, поскольку мы хотим, чтобы возведение в степень имело более высокий приоритет, чем унарные операции, так как математическое выражение -2 ^ 2 должно давать значение -4.
Давайте же добавим метод parseUnaryExpression в наш рекурсивный парсер:
/*
* Primary
* = ParenthesizedExpression
* / UnaryExpression
* / NUMBER
*/
parsePrimary() {
if (this.token?.type === "left_parenthesis") {
this.parseParenthesizedExpression()
return
}
if (this.token?.type === "operator" && this.token.value === "-") {
this.parseUnaryExpression()
return
}
this.rpn.push(this.consume("number"))
}
/*
* UnaryExpression
* = "-" Factor
*/
parseUnaryExpression() {
const token = this.consume("operator")
token.value = "~" // заменяем на унарный минус
this.parseFactor()
this.rpn.push(token)
}
Мы обновили функцию Primary, включив в нее проверку, является ли текущий токен знаком -. Если да, то выполняем парсинг унарного выражения, не забывая при этом поменять - на ~.
Проверим же наш новый парсер в деле:
parser.parse(tokenizer.tokenize("-5")) // 5 ~
parser.parse(tokenizer.tokenize("-(-(1 + 2) + -(-3))")) // 1 2 + ~ 3 ~ ~ + ~
parser.parse(tokenizer.tokenize("-----127")) // 127 ~ ~ ~ ~ ~
parser.parse(tokenizer.tokenize("-2^2")) // 2 2 ^ ~
parser.parse(tokenizer.tokenize("-2^-2^-3")) // 2 2 3 ~ ^ ~ ^ ~
Добавляем функции
Добавить поддержку функций будет не сложнее унарного минуса. Мы лишь ещё немного расширим наже правило Primary, добавив в него FunctionExpression:
Expression
= Term (("+" | "-") Term)*
Term
= Factor (("*" | "/") Factor)*
Factor
= Primary ("^" Factor)*
Primary
= ParenthesizedExpression
/ UnaryExpression
/ FunctionExpression
/ NUMBER
ParenthesizedExpression
= "(" expression ")"
UnaryExpression
= "-" Factor
FunctionExpression
= FUNCTION "(" expression ("," expression)* ")"
Строго говоря, наше правило позволяет добавлять функции с произвольным числом аргументов. Но мы будем обрабатывать то количество аргументов, которое требуется функции. Поэтому нам потребуется передавать в наш парсер словарь с обрабатываемыми функциями, в котором хранится информация о требуемых аргументах.
Добавим в наш класс конструктор и реализуем метод parseFunctionExpression:
constructor(functions) {
this.functions = functions
}
/*
* Primary
* = ParenthesizedExpression
* / UnaryExpression
* / FunctionExpression
* / NUMBER
*/
parsePrimary() {
if (this.token?.type === "left_parenthesis") {
this.parseParenthesizedExpression()
return
}
if (this.token?.type === "operator" && this.token.value === "-") {
this.parseUnaryExpression()
return
}
if (this.token?.type === "function") {
this.parseFunctionExpression()
return
}
this.rpn.push(this.consume("number"))
}
/*
* FunctionExpression
* = FUNCTION "(" expression ("," expression)* ")"
*/
parseFunctionExpression() {
const token = this.consume("function")
this.consume("left_parenthesis")
this.parseExpression()
// обрабатываем лишь необходимое количество аргументов
for (let i = 1; i < this.functions[token.value].args; i++) {
this.consume("delimeter")
this.parseExpression()
}
this.consume("right_parenthesis")
this.rpn.push(token)
}
Мы вновь расширили правило parsePrimary ещё одним условием – является ли текущий токен функцией. Если являестя, то запускаем парсинг функционального выражения.
Проверим, что парсер работает ожидаемым образом:
parser.parse(tokenizer.tokenize("sin(1)")) // 1 sin
parser.parse(tokenizer.tokenize("tan(max(sin(1), cos(-1)))")) // 1 sin 1 ~ cos max tan
parser.parse(tokenizer.tokenize("sin(1, 2, 3, 4)")) // Error: unexpected token: ",", expected right_parenthesis (5:6)
Парсер работает именно так, как от него ожидается – и особенно приятно то, что нам не пришлось вручную проверять количество переданных аргументов, чтобы отловить синтаксическую ошибку. Мы просто разбираем ровно столько аргументов, сколько требуется, а всё остальное обрабатывается естественным образом. Вспомните, насколько сложнее это выглядело в предыдущей статье!
Добавляем переменные и константы
Мы уже на финишной прямой. В парсер осталось добавить только поддержку констант и переменных и мы, конечно же, просто вновь расширим правило Primary:
Expression
= Term (("+" | "-") Term)*
Term
= Factor (("*" | "/") Factor)*
Factor
= Primary ("^" Factor)*
Primary
= ParenthesizedExpression
/ UnaryExpression
/ FunctionExpression
/ CONSTANT
/ VARIABLE
/ NUMBER
ParenthesizedExpression
= "(" expression ")"
UnaryExpression
= "-" Factor
FunctionExpression
= FUNCTION "(" expression ("," expression)* ")"
Да, вот так просто. Давайте наконец доделаем наш парсер:
/*
* Primary
* = ParenthesizedExpression
* / UnaryExpression
* / FunctionExpression
* / CONSTANT
* / VARIABLE
* / NUMBER
*/
parsePrimary() {
if (this.token?.type === "left_parenthesis") {
this.parseParenthesizedExpression()
return
}
if (this.token?.type === "operator" && this.token.value === "-") {
this.parseUnaryExpression()
return
}
if (this.token?.type === "function") {
this.parseFunctionExpression()
return
}
if (this.token?.type === "constant" || this.token?.type === "variable") {
this.rpn.push(this.consume(this.token.type))
return
}
this.rpn.push(this.consume("number"))
}
Мы вновь расширили метод parsePrimary, добавив в него проверку: он ожидает, что текущий токен окажется константой или переменной. Затем просто потребляем токен и добавляем его в rpn.
Давайте быстренько проверим, что наш парсер корректно работает после обновления:
parser.parse(tokenizer.tokenize("sin(pi*x)")) // pi x * sin
parser.parse(tokenizer.tokenize("e^-pi")) // e pi ~ ^
parser.parse(tokenizer.tokenize("e*sin(x)^2 + pi*cos(y)^2")) // e x sin 2 ^ * pi y cos 2 ^ * +
Обновляем парсер математических выражений
Пришло время изменить самую главную строчку кода в файле expression_parser.js:
ExpressionParser// было так const parser = new ShuntingYardParser(functions) // стало так const parser = new RecursiveDescentParser(functions)
Запустим вновь тесты из прошлой статьи, чтобы убедиться, что парсер работает точно так же:
function TestParser(expression, expected, variables = {}, eps = 1e-15) {
try {
const parser = new ExpressionParser(expression)
for (const [variable, value] of Object.entries(variables))
parser.setVariable(variable, value)
const result = parser.evaluate()
if (Math.abs(result - expected) < eps)
console.log(`%c${expression} = ${result}`, "color: green")
else
console.log(`%c${expression} = ${result}, but expected ${expected}`, "color: red")
}
catch (error) {
console.log(`%c"${expression}" is invalid: ${error.message}`, expected === null ? "color: green" : "color: red")
}
}
// проверяем корректные выражения
TestParser("1", 1) // 1 = 1
TestParser("1 + 2 * 3", 7) // 1 + 2 * 3 = 7
TestParser("-(1 + 2) * 3 - 4", -13) // -(1 + 2) * 3 - 4 = -13
TestParser("-2^2", -4) // -2^2 = -4
TestParser("(-2)^2", 4) // (-2)^2 = 4
TestParser("-2^-2", -0.25) // -2^-2 = -0.25
TestParser("(-2)^-2", 0.25) // (-2)^-2 = 0.25
TestParser("max(5 + 2^3, -7 * -9)", 63) // max(5 + 2^3, -7 * -9) = 63
TestParser("cos(7 - 5)^2 + sin(4^0.5)^2", 1) // cos(7 - 5)^2 + sin(4^0.5)^2 = 1
TestParser("sin(x) * (pi/-x - 5)^2", -9, {"x": -Math.PI / 2}) // sin(x) * (pi/-x - 5)^2 = -9
// проверяем некорректные выражения
TestParser("()", null) // "()" is invalid: unexpected token: ")", expected number (1:2)
TestParser("max(1)", null) // "max(1)" is invalid: unexpected token: ")", expected delimeter (5:6)
TestParser("sin(1, 5)", null) // "sin(1, 5)" is invalid: unexpected token: ",", expected right_parenthesis (5:6)
TestParser("sin cos 2 max 7", null) // "sin cos 2 max 7" is invalid: unexpected token: "cos", expected left_parenthesis (4:7)
Заключение
Рекурсивный спуск позволяет описывать парсер значительно яснее и выразительнее, чем алгоритм сортировочной станции. Такой подход не только обеспечивает большую гибкость в работе с синтаксисом выражений, но и позволяет гораздо элегантнее обрабатывать ошибки. Всего 103 строки кода (без учёта комментариев грамматики) против 167 – и это при той же функциональности! В следующей части мы сократим объём кода ещё больше, сохранив при этом полную поддержку всех выражений!
Куда же без исходников?
class RecursiveDescentParser {
constructor(functions) {
this.functions = functions
}
parse(tokens) {
this.tokens = tokens
this.index = 0
this.token = this.tokens[0]
this.rpn = []
this.parseExpression()
if (this.index < this.tokens.length)
throw new Error(`extra tokens after end of expression (${this.token.start})`)
return this.rpn
}
consume(type) {
const token = this.token
if (token === null)
throw new Error(`unexpected end of input, expected ${type}`)
if (token.type !== type)
throw new Error(`unexpected token: "${token.value}", expected ${type} (${token.start}:${token.end})`)
this.token = ++this.index < this.tokens.length ? this.tokens[this.index] : null
return token
}
parseBinaryExpresson(parseLeft, parseRight, values) {
parseLeft()
while (this.token?.type === "operator" && values.has(this.token.value)) {
const operator = this.consume("operator")
parseRight()
this.rpn.push(operator)
}
}
/*
* Expression
* = Term (("+" | "-") Term)*
*/
parseExpression() {
this.parseBinaryExpresson(() => this.parseTerm(), () => this.parseTerm(), new Set(["+", "-"]))
}
/*
* Term
* = Factor (("*" | "/") Factor)*
*/
parseTerm() {
this.parseBinaryExpresson(() => this.parseFactor(), () => this.parseFactor(), new Set(["*", "/"]))
}
/*
* Factor
* = Primary ("^" Factor)*
*/
parseFactor() {
this.parseBinaryExpresson(() => this.parsePrimary(), () => this.parseFactor(), new Set(["^"]))
}
/*
* Primary
* = ParenthesizedExpression
* / UnaryExpression
* / FunctionExpression
* / CONSTANT
* / VARIABLE
* / NUMBER
*/
parsePrimary() {
if (this.token?.type === "left_parenthesis") {
this.parseParenthesizedExpression()
return
}
if (this.token?.type === "operator" && this.token.value === "-") {
this.parseUnaryExpression()
return
}
if (this.token?.type === "function") {
this.parseFunctionExpression()
return
}
if (this.token?.type === "constant" || this.token?.type === "variable") {
this.rpn.push(this.consume(this.token.type))
return
}
this.rpn.push(this.consume("number"))
}
/*
* ParenthesizedExpression
* = "(" expression ")"
*/
parseParenthesizedExpression() {
this.consume("left_parenthesis")
this.parseExpression()
this.consume("right_parenthesis")
}
/*
* UnaryExpression
* = "-" Factor
*/
parseUnaryExpression() {
const token = this.consume("operator")
token.value = "~" // заменяем на унарный минус
this.parseFactor()
this.rpn.push(token)
}
/*
* FunctionExpression
* = FUNCTION "(" expression ("," expression)* ")"
*/
parseFunctionExpression() {
const token = this.consume("function")
this.consume("left_parenthesis")
this.parseExpression()
for (let i = 1; i < this.functions[token.value].args; i++) {
this.consume("delimeter")
this.parseExpression()
}
this.consume("right_parenthesis")
this.rpn.push(token)
}
}
А так же
- expression_tokenizer.js – токенизатор из первой части;
- expression_evaluator.js – калькулятор выражений, записаных в обратной польской записи из второй части;
- expression_parser.js – парсер математических выражений, использующий алгоритм рекурсивного спуска из этой части.
