String Calculator

EJFace

Not sure if the title accurately explains what this is.

Let me know if there are any issues/features you’d like. You’re free to use this with or without credit, so go mad!

In short, this will take any string input and output a number, such that "1" = 1, "1+1" = 2, "a=2;a+1" = 3 and "max(1,2)" = 2, useful when loadstring isn’t enabled.

This is useful for converting user-input into a number while supporting math functionality.

The following is supported:

  1. + - * / % ^ ! in the BIDMAS (BODMAS for non-UK?) order "(16*3)-8/4"=46
  2. Nested brackets "((1+1)*(2+2))*2"=16
  3. Math functions "max(1,2)"=2 – supports any function in the math. library, just put the name of the function without the math. before it
  4. Math variables "pi*2"=6.2831... – supports any variable in the math. library, just put the name of the variable without math. before it
  5. Supports variables "a=1;b=a*2;a+b"=3
  6. Supports functions "a(x,y)=x*5/y;a(5+1,2)"=15 "a(x)=x*pi/2;a(17)"=26.703537555513
  7. Has a set of default functions, Round, Truncate, Approach

You can get/require the following model ( It returns a function that you pass the string to ):

The code is also up on github:

Example of how I’m using it for number arguments in my admin:
image
image

local Calc = require( CalcModule )

function Calculate( String )
	
	local Ran, Num = pcall( Calc, String )
	
	if not Ran then return "Invalid formula" end
	
	return Num
	
end

print(Calculate("1+1*15")) --> 16
47 Likes

Fixed issues with 0/0 and math.huge, now works correctly :slight_smile:

Nice! I might use this. I wrote a slightly different implementation where functions with single arguments could be called just by sticking them together (e.g., cos x) and multiplications could be done by sticking them together (e.g., 5x). I also didn’t support the assignment operator, but that’s a nice feature. :slight_smile:

Could you add some documentation to the “Calculate” function so I can use it as-is? What’s Recursion? LocalVars? LocalFuncs?

EDIT: looks like I also support commas which are nice when you have a single text box to input values for vector3s.

Here’s my source:

Summary
function Debug() end

local PATTERNS = {
	hex = "^[ \t\r\n]*0x[0-9A-Fa-f]+";
	binary = "^[ \t\r\n]*0b[01]+";
	octal = "^[ \t\r\n]*0[0-7]+";
	decimal = "^[ \t\r\n]*[0-9]+%.?[0-9]*";
	decimal2 = "^[ \t\r\n]*%.?[0-9]+";
	operator = "^[ \t\r\n]*[-+*/^%%]";
	paren = "^[ \t\r\n]*[%(%)]";
	comma = "^[ \t\r\n]*[,]";
	variable = "^[ \t\r\n]*[a-zA-Z_][a-zA-Z_0-9]*";
};
local SPECIAL_FUNCTIONS = {
	cos = true;
	sin = true;
	tan = true;
	acos = true;
	asin = true;
	atan = true;
	atan2 = true;
	log2 = true;
	log10 = true;
	ln = true;
	exp = true;
};
local SPECIAL_VARIABLES = {
	pi = math.pi;
	inf = math.huge;
	e = 2.7182818284;
};

--[[ @brief Convert a string expression into a list of tokens.
     @param expr The expression to convert.
     @return An array containing the tokens themselves.
     @return A parallel array containing the classes of tokens. Values include number, operator, function, paren, comma, and variable.
     @note If an error occurs, the value false & a string error code will be returned.
--]]
function Tokenize(expr)
	local TokenStream = {};
	local TokenType = {};
	while #expr > 0 do
		local len = #expr;
		Debug("Searching for pattern at %s", expr);
		for i, v in pairs(PATTERNS) do
			local token = expr:match(v);
			if token then
				expr = expr:sub(#token+1);
				token = token:gsub("^[ \t\r\n]+", "");
				Debug("Pattern match at %s (%s)", token, i);
				if i=="hex" then
					i = "number";
					token = tonumber(token:sub(3), 16);
				elseif i=="binary" then
					i = "number";
					token = tonumber(token:sub(3), 2);
				elseif i=="octal" then
					i = "number";
					token = tonumber(token:sub(2), 8);
				elseif i=="decimal" or i=="decimal2" then
					i = "number";
					token = tonumber(token);
				end
				if i=="variable" then
					if SPECIAL_FUNCTIONS[token] then
						i = "function";
					elseif SPECIAL_VARIABLES[token] then
						i = "number";
						token = SPECIAL_VARIABLES[token];
					end
				end
				table.insert(TokenStream, token);
				table.insert(TokenType, i);
			end
		end
		if len == #expr then
			return false, string.format("Bad token: %s", expr);
		end
	end
	return TokenStream, TokenType;
end


--[[ @brief Returns the priority level of an operator. Unary operators always take precedence over binary operators (except for unary minus).
     @param A token including any binary operator, unary operator ('unary-', functions).
     @return A priority level for this operator where 1 is low priority, 4 is highest.
--]]
function GetPriority(token)
	if token=='+' or token=='-' or token=='unary-' then
		return 1;
	elseif token=='*' or token=="/" or token=='^' then
		return 2;
	elseif token=="^" then
		return 3;
	else
		return 4;
	end
end

--[[ @brief Searches for an operand at a given location. Unary operators are also allowed.
     @param tokens A list of all tokens.
     @param types A list of the token classes.
     @param i The index at which we start searching.
     @return operand: a table which can be injected into the table as the subject of the current operator.
     @return isOperator: whether operand itself is an operator (as is the case for unary operators).
     @return tokensConsumed: how many tokens were read in order to attain this operator.
     @return isFunction: true if operand represents a function which will likely be followed by a parentheses.
     @note When this function fails, it returns false followed by an error message.
--]]
function ParseOperand(tokens, types, i)
	local token, typ = tokens[i], types[i];
	if typ == "operator" and token=='-' or typ == 'function' then
		--We instead got a unary operator. All unary operators take precedence over binary operators.
		local newOperator = {op = (typ=='function' and token or 'unary-')};
		return newOperator, true, 1, typ == 'function';
	elseif typ == "paren" then
		if token == ')' then return false, string.format("Got unexpected close parenthesis token at index %d", i); end
		--It is possible we instead get a parenthesized argument. This is A-OK! Parse it.
		local j = i;
		local openParens = 1;
		--local limit = Utils.WhileLoopLimiter(1000, "parenthesisSearcher");
		while openParens > 0 --[[and limit()]] do
			j = j + 1;
			if types[j] == 'paren' then
				if tokens[j] == '(' then
					openParens = openParens + 1;
				elseif tokens[j] == ')' then
					openParens = openParens - 1;
				else
					return false, string.format("Token of form %s unexpectedly given type 'paren' at index %d", tokens[j], j);
				end
			end
		end
		if j == i+1 then
			return false, string.format("Found parentheses with no inner contents at index %d", i);
		end
		local results = {TreeifyTokens(tokens, types, i + 1, j - 1)};
		if not results[1] then
			--If we have been returned something of the form false, ... we just hit an assertion. Pass it to the calling function.
			return unpack(results);
		else
			--A parenthesized statement may have any number of comma-separated values. These can be interpreted as arguments or a vector based on the parent operation. E.g., in the expression max(1, 2), max will interpret them as arguments. However, in the expression -(1, 2), the unary minus will interpret them as a vector.
			local newOperand = {op = 'identity', unpack(results)};
			return newOperand, false, j - i + 1, false;
		end
	elseif typ == 'comma' then
		return false, string.format("Unexpected comma at token %d", i), 1;
	elseif typ == 'number' then
		return {op = 'identity', [1] = token}, false, 1, false;
	elseif typ == 'variable' then
		return {op = 'variable', [1] = token}, false, 1, false;
	elseif typ == 'operator' then
		return false, string.format("Unexpected binary operator %s at token %d", token, i);
	end
end

--[[ @brief Injects an operation into the tree.
     @param opToken An operator (string) with value +, -, *, /, %, or ^.
     @return newOperation: the newly added operation.
     @return replaceRoot: true if the operation belongs at the root of the expression tree.
--]]
function InjectOperation(opToken, root)
	local newOperation = {op = opToken};
	--If it is equal* or lower priority than the current root, then we become the new root & move root to the left.
	--If it is greater priority than the previous root then we trace down the right side of the tree until we are equal* or lower priority.
	--* equal priority only matters if the operator is left-associative.
	local isLeftAssociative = opToken~='^';
	local tokenPriority = GetPriority(opToken);
	local rootPriority = GetPriority(root.op);
	if (tokenPriority < rootPriority) or (isLeftAssociative and tokenPriority == rootPriority) then
		table.insert(newOperation, root);
		return newOperation, true;
	else
		local seek = root[#root];
		local seekPriority = GetPriority(seek.op);
		local seekParent = root;
		while tokenPriority > seekPriority or (not isLeftAssociative and tokenPriority >= seekPriority) do
			seekParent = seek;
			seek = seek[#seek];
			seekPriority = GetPriority(seek.op);
		end
		table.insert(newOperation, seek);
		for i = 1, #seekParent do
			if seekParent[i] == seek then
				table.remove(seekParent, i);
				break;
			end
		end
		table.insert(seekParent, newOperation);
		return newOperation, false;
	end
end

--[[ @brief Converts a stream of tokens & their type into an expression tree.
     @param tokens A stream of tokens.
     @param types A parallel array of token types.
     @param low The lowest index we should parse from.
     @param high The highest index we should parse to.
     @return Several trees representing each comma-separated expression.
--]]
function TreeifyTokens(tokens, types, low, high)
	local roots = {};
	local function TreeToString(root, depth, s)
		if not root then return; end
		local PrintAtFinish = false;
		if not s then
			PrintAtFinish = true;
			s = {};
			depth = 0;
		end
		if type(root)=='table' then
			table.insert(s, string.rep(".   ", depth) .. root.op .. " (" .. #root .. " operands)")
			for i = 1, #root do
				TreeToString(root[i], depth + 1, s);
			end
		else
			table.insert(s, string.rep(".   ", depth) .. tostring(root));
		end
		if PrintAtFinish then
			return table.concat(s, "\n");
		end
	end
	local operation = nil; --A table which contains operands as its children and the key 'op' indicating how to evaluate it.
	local root = nil;
	local isFunction = false; --A flag set to true if the current operation is a prefix-parenthesized function.
	local i = low;
--	local limit = Utils.WhileLoopLimiter(2000, "treeifyer");
	while i <= high --[[and limit()]] do
		local token, typ = tokens[i], types[i];
		--typ may be 'number', 'operator', 'variable', 'function', or 'paren'. operator means a binary operator except for the case of '-'.
		Debug("%s; token, type: %s, %s", i, token, typ);

		--[[
			Control flow:
				Expecting operand
					--> Parse an operand
				Expecting first operand (root)
					--> Parse an operand
				Expecting binary operator/comma
					Got binary operator
						--> Inject operation into tree & expect succeeding operand.
					Got comma
						--> Add the current expression tree to a list & start a new one.
					Got parentheses, number, or variable:
						--> Inject implicit multiplication. Re-evaluate this token.

			Parse an operand:
				Got unary minus or function
					--> Expect succeeding operand
				Got parentheses
					--> Parse it as the operand.
				Got number
					--> Treat as the operand.
				Got variable
					--> Treat as the operand.
				Got comma
					--> Error!
				Got binary operator
					--> Error!
		--]]

		if operation then
			--In this state, we are waiting for the final operand for either a binary or unary operation.
			local newOp, isOperation, consumedTokens, isFunc = ParseOperand(tokens, types, i);
			Debug("ParseOperand(tokens, types, %s) = %s, %s, %s, %s", i, newOp, isOperation, consumedTokens, isFunc);
			--> We may have encountered an error. Pass it upward.
			if not newOp then
				return newOp, isOperation;
			end
			i = i + consumedTokens -1;
			if isFunction and newOp.op == 'identity' then
				--If the operation is a prefix-parenthesized function (e.g., max(x, y) ), we should unpack newOp if it takes the form of 'identity'.
				for i, v in ipairs(newOp) do
					table.insert(operation, v);
				end
			else
				table.insert(operation, newOp);
			end
			if isOperation then
				operation = newOp;
			else
				operation = nil;
			end
			isFunction = isFunc;
		elseif root==nil then
			--In this state, we are waiting for the first operand.
			local newOp, isOperation, consumedTokens, isFunc = ParseOperand(tokens, types, i);
			--> We may have encountered an error. Pass it upward.
			if not newOp then
				return newOp, isOperation;
			end
			i = i + consumedTokens - 1;
			root = newOp;
			if isOperation then
				operation = newOp;
			else
				operation = nil;
			end
			isFunction = isFunc;
		else
			--When operation is nil, we are expecting a binary operator.
			if typ=='operator' then
				local newOperation, isRoot = InjectOperation(token, root);
				operation = newOperation;
				if isRoot then
					root = newOperation;
				end
			elseif typ=='comma' then
				Debug("Got comma. Previous tree:\n===================\n%s\n===================", TreeToString(root));
				table.insert(roots, root);
				root = nil;
				operation = nil;
			else
				Debug("Got %s ('%s') when expected binary operator", typ, token);
				--Jamming several numbers/function calls/variables together should be interpreted as a string of multiplications.
				--E.g., 5x cos x ==> 5 * x * cos(x)
				local newOperation, isRoot = InjectOperation("*", root);
				operation = newOperation;
				if isRoot then
					root = newOperation;
				end
				--We wish to reinterpret this token.
				i = i - 1;
			end
		end
		i = i + 1;
	end
	Debug("Complete Tree:\n===================\n%s\n===================", TreeToString(root));
	table.insert(roots, root);
	return unpack(roots);
end


--[[ @brief Returns the numerical value of an expession tree.
     @param tree The expression tree obtained from TreeifyTokens
     @param variables A map indicating the numerical values of several variables.
     @return The value of the tree.
--]]
function EvaluateTree(tree, variables)
	if type(tree)=='number' then
		return tree;
	end
	if tree.op == 'identity' then
		return EvaluateTree(tree[1], variables);
	elseif tree.op == "+" then
		return EvaluateTree(tree[1], variables) + EvaluateTree(tree[2], variables);
	elseif tree.op == "-" then
		return EvaluateTree(tree[1], variables) - EvaluateTree(tree[2], variables);
	elseif tree.op == "*" then
		return EvaluateTree(tree[1], variables) * EvaluateTree(tree[2], variables);
	elseif tree.op == "/" then
		return EvaluateTree(tree[1], variables) / EvaluateTree(tree[2], variables);
	elseif tree.op == "%" then
		return EvaluateTree(tree[1], variables) % EvaluateTree(tree[2], variables);
	elseif tree.op == "^" then
		return EvaluateTree(tree[1], variables) ^ EvaluateTree(tree[2], variables);
	elseif tree.op == "unary-" then
		return -EvaluateTree(tree[1], variables);
	elseif tree.op == 'cos' then
		return math.cos(EvaluateTree(tree[1], variables));
	elseif tree.op == 'sin' then
		return math.sin(EvaluateTree(tree[1], variables));
	elseif tree.op == 'tan' then
		return math.tan(EvaluateTree(tree[1], variables));
	elseif tree.op == 'acos' then
		return math.acos(EvaluateTree(tree[1], variables));
	elseif tree.op == 'asin' then
		return math.asin(EvaluateTree(tree[1], variables));
	elseif tree.op == 'atan' then
		return math.atan(EvaluateTree(tree[1], variables));
	elseif tree.op == 'atan2' then
		return math.atan2(EvaluateTree(tree[1], variables), EvaluateTree(tree[2], variables));
	elseif tree.op == 'log2' then
		return math.log(EvaluateTree(tree[1], variables)) / math.log(2);
	elseif tree.op == 'log10' then
		return math.log10(EvaluateTree(tree[1], variables));
	elseif tree.op == 'ln' then
		return math.log(EvaluateTree(tree[1], variables));
	elseif tree.op == 'exp' then
		return math.exp(EvaluateTree(tree[1], variables));
	elseif type(tree.op)=='number' then
		return tree.op;
	elseif tree.op=='variable' then
		Debug("Looking up variable: %s", tree[1]);
		return variables[tree[1]];
	else
		assert(false, string.format("Unknown operation: %s", tree.op));
	end
end


--[[ @brief Interprets & returns the value(s) of an expression.
     @param expr The expression to compute.
     @param variables Variable substitutions to make in dictionary form.
     @return Several values indicating the numerical values of each comma-separated expression.
--]]
function InterpretExpression(expr, variables)
	local TokenStream, TokenType = Tokenize(expr);
	if not TokenStream then
		return false, TokenType;
	end

	Debug("Token Stream: ");
	for i, v in pairs(TokenStream) do
		Debug("%s", v);
	end

	--When this is done, create a tree.
	local trees = {TreeifyTokens(TokenStream, TokenType, 1, #TokenStream)};
	if not trees[1] then
		return false, trees[2];
	end
	if #trees==1 then
		return EvaluateTree(trees[1], variables);
	else
		local values = {};
		for i = 1, #trees do
			values[i] = EvaluateTree(trees[i], variables);
		end
		return unpack(values);
	end
end

return {Parse = InterpretExpression};
5 Likes

I’ll document it when I get off work later.

I’ve added documentation.

Sorry it took so long, I completely forgot about this thread last night!

Also, please note I am AWFUL at documentation so GOOD LUCK.

Wait a gosh darn minute, that was this morning!
>.<

If anyone actually uses this, I’ve just updated the OP with a few bug fixes and optimisations. Also the example screenshots appear to have broken and I don’t remember what they were so I’ve just added some basic examples.

1 Like

You should have made it a module and put it in the library for people to use.

I’ve made it a module now. Honestly not sure why I didn’t originally.

1 Like

Good thing about using modules is that you’re the source code holder, also it’s easier for people to use & access.

1 Like

Just updated the module with a complete rewrite. The new system has the same features as before and is ran the exact same way however has a couple benefits:

  1. No more number to string inaccuracies as everything calculated is stored in a table as a number, instead of being appended to the string. ( means “(1/3)*3” will now equal 1 instead of 0.9999 )
  2. You can now use numbers in the names of variables / functions ( e.g. “a1=5!; a1+2” = 122 )
  3. Having a number directly in front of a bracket now acts as multiplication instead of requiring a “*” ( e.g. “(1+1)(3+3)” = 12 )

and the best part, it’s up to 2x faster

4 Likes

Thanks for this awesome module.
I created a graph calculator using this along another module :sweat_smile:
I hope you like it man!

3 Likes

Can this do double sided equations? For example: 1a+2=1a+2 and so it would equal: 2=2 because it would subtract 1a from both sides first.

I’ve tried this module, while it’s a good module to use, I have some issues:
"2 ^ 2 ^ 3" returns 64 instead of 256 as the power of operator is right-to-left associative.
"-2 ^ 2" returns 4 instead of -4 as the negate (unary minus) operator is calculated after the power of operator

local calculate = require(CalcModule)
print(calculate("2 ^ 2 ^ 3")) --> 64 (wrong, expected 256)
print(calculate("-2 ^ 2")) --> 4 (wrong, expected -4)

Also: Is there a plan for this to support reverse polish notation? As well as a plan to support grouping separator (so that 1 000 + 1 will return 1001) along with comma as a decimal (so that 1,5 + 1 return 2.5) along with ; as a argument separator.

Are NaN and Infinity intentional as "0 / 0" doesn’t return undefined or throw an error? (Did you intended these number in the expressions as a IEEE 754 double precision floating point value?).

Overall nice module, I like the fact that it has operator precedence and function support.

1 Like

It’s really awesome module. I don’t think there is any better string calculator module out there

Is this able to do Pythagorean Theorem?

@Partixel In the USA (at least the West Coast), we say PEMDAS.

  • Parenthesis
  • Exponents
  • Multiplication
  • Division
  • Addition
  • Subtraction

There’s a way school teaches you to remember it: “Please excuse my dear Aunt Sally”

(Sry I’m a complete math nerd :joy:)


This is pretty awesome though! Good work! :slight_smile:

2 Likes

Thanks for the feedback.

I’ve fixed the power operator not calculating right-to-left:

-2 ^ 2 is correct as far as I can see (-2 * 2 = 4):
image

There’s no plan to support reverse polish notation though it wouldn’t be hard for others to do with some minor edits.

It already supports grouping seperator (“1 000 + 1” = 1001)

No plan to support commas as decimals mostly due to backwards compatability however if there’s support for it I’ll consider it further in the future

Finally, “0 / 0” returns the same as 0/0 in lua

-2 ^ 2 is not the same as (-2) ^ 2 (or -2 * -2)
-2 ^ 2 is -(2 ^ 2) because of operator precedence, exponent is caluclated first before the unary negation (-), that caluclator either interpret -2 as a value or calculate unary negation before exponent and made the same mistake.

1 Like

I’ve fixed that:

2 Likes