diff --git a/src/main/java/com/eliotlash/molang/Parser.java b/src/main/java/com/eliotlash/molang/Parser.java index 05d2892..61bcc9a 100644 --- a/src/main/java/com/eliotlash/molang/Parser.java +++ b/src/main/java/com/eliotlash/molang/Parser.java @@ -387,6 +387,10 @@ private Expr primary() { return new Expr.Constant(Double.parseDouble(previous().lexeme())); } + if (match(STRING)) { + return new Expr.Str(previous().lexeme()); + } + if (match(OPEN_PAREN)) { Expr expr = expression(); consume(CLOSE_PAREN, "Expect ')' after expression."); diff --git a/src/main/java/com/eliotlash/molang/ast/ASTTransformation.java b/src/main/java/com/eliotlash/molang/ast/ASTTransformation.java index 48f9c18..47d5a4f 100644 --- a/src/main/java/com/eliotlash/molang/ast/ASTTransformation.java +++ b/src/main/java/com/eliotlash/molang/ast/ASTTransformation.java @@ -104,6 +104,11 @@ public Expr visitVariable(Expr.Variable expr) { return expr; } + @Override + public String visitString(Expr.Str str) { + return str.val(); + } + @Override public Expr visitSwitchContext(Expr.SwitchContext expr) { return new Expr.SwitchContext(expr.left(), visit(expr.right())); diff --git a/src/main/java/com/eliotlash/molang/ast/Evaluator.java b/src/main/java/com/eliotlash/molang/ast/Evaluator.java index 229adc7..e2a6092 100644 --- a/src/main/java/com/eliotlash/molang/ast/Evaluator.java +++ b/src/main/java/com/eliotlash/molang/ast/Evaluator.java @@ -136,6 +136,11 @@ else if (access.target() instanceof Expr.Struct struct) { @Override public Double visitBinOp(Expr.BinOp expr) { + + // Evaluate binops on strings if the expressions are strings + if (expr.left() instanceof Expr.Str lhs && expr.right() instanceof Expr.Str rhs) { + return expr.operator().applyString(lhs.val(), rhs.val()); + } return expr.operator().apply(() -> evaluate(expr.left()), () -> evaluate(expr.right())); } @@ -213,11 +218,23 @@ public Double visitVariable(Expr.Variable expr) { return context.getVariableMap().getOrDefault(runtimeVariable, 0); } + @Override + public String visitString(Expr.Str str) { + return str.val(); + } + public Double evaluate(Expr expr) { Double result = expr.accept(this); return result == null ? 0 : result; } + public String evaluateString(Expr expr) { + if (expr instanceof Expr.Str) { + return ((Expr.Str) expr).val(); + } + return expr.accept(this).toString(); + } + public Double evaluateNullable(Expr expr) { return expr.accept(this); } diff --git a/src/main/java/com/eliotlash/molang/ast/Expr.java b/src/main/java/com/eliotlash/molang/ast/Expr.java index c5d2dd5..9cb1d5f 100644 --- a/src/main/java/com/eliotlash/molang/ast/Expr.java +++ b/src/main/java/com/eliotlash/molang/ast/Expr.java @@ -40,9 +40,9 @@ public boolean equals(Object o) { Struct struct = (Struct) o; if (!target.equals(struct.target)) return false; - if(parent == null) { - return struct.parent == null; - } + if(parent == null) { + return struct.parent == null; + } return parent.equals(struct.parent); } @@ -213,6 +213,13 @@ public int hashCode() { } } + record Str(String val) implements Expr { + @Override + public R accept(Visitor visitor) { + return null; + } + } + interface Visitor { default R visit(Expr node) { return node.accept(this); @@ -245,5 +252,7 @@ default R visit(Expr node) { R visitSwitchContext(SwitchContext expr); R visitVariable(Variable expr); + + String visitString(Str str); } } diff --git a/src/main/java/com/eliotlash/molang/ast/Operator.java b/src/main/java/com/eliotlash/molang/ast/Operator.java index 208056e..c5e7e71 100644 --- a/src/main/java/com/eliotlash/molang/ast/Operator.java +++ b/src/main/java/com/eliotlash/molang/ast/Operator.java @@ -78,6 +78,21 @@ public double apply(DoubleSupplier lhs, DoubleSupplier rhs) { }; } + + public double applyString(String lhs, String rhs) { + switch (this) { + case EQ -> { + return bool(lhs.equals(rhs)); + } + case NEQ -> { + return bool(!lhs.equals(rhs)); + } + default -> { + return 0.0; + } + } + } + private static double bool(boolean b) { return b ? 1.0 : 0.0; } diff --git a/src/main/java/com/eliotlash/molang/functions/strings/Length.java b/src/main/java/com/eliotlash/molang/functions/strings/Length.java new file mode 100644 index 0000000..13d5135 --- /dev/null +++ b/src/main/java/com/eliotlash/molang/functions/strings/Length.java @@ -0,0 +1,21 @@ +package com.eliotlash.molang.functions.strings; + +import com.eliotlash.molang.ast.Expr; +import com.eliotlash.molang.functions.Function; +import com.eliotlash.molang.variables.ExecutionContext; + +public class Length extends Function { + public Length(String name) { + super(name); + } + + @Override + public double _evaluate(Expr[] arguments, ExecutionContext ctx) { + return ctx.getEvaluator().evaluateString(arguments[0]).length(); + } + + @Override + public int getRequiredArguments() { + return 1; + } +} diff --git a/src/main/java/com/eliotlash/molang/functions/strings/Print.java b/src/main/java/com/eliotlash/molang/functions/strings/Print.java new file mode 100644 index 0000000..386bcdc --- /dev/null +++ b/src/main/java/com/eliotlash/molang/functions/strings/Print.java @@ -0,0 +1,26 @@ +package com.eliotlash.molang.functions.strings; + +import com.eliotlash.molang.ast.Expr; +import com.eliotlash.molang.functions.Function; +import com.eliotlash.molang.variables.ExecutionContext; + +public class Print extends Function { + public Print(String name) { + super(name); + } + + @Override + public double _evaluate(Expr[] arguments, ExecutionContext ctx) { + if (arguments[0] instanceof Expr.Str) { + System.out.println(ctx.getEvaluator().evaluateString(arguments[0])); + } else { + System.out.println(this.evaluateArgument(arguments, ctx, 0)); + } + return 1.0; + } + + @Override + public int getRequiredArguments() { + return 1; + } +} diff --git a/src/main/java/com/eliotlash/molang/functions/strings/StrEquals.java b/src/main/java/com/eliotlash/molang/functions/strings/StrEquals.java new file mode 100644 index 0000000..7eacb8e --- /dev/null +++ b/src/main/java/com/eliotlash/molang/functions/strings/StrEquals.java @@ -0,0 +1,24 @@ +package com.eliotlash.molang.functions.strings; + +import com.eliotlash.molang.ast.Expr; +import com.eliotlash.molang.functions.Function; +import com.eliotlash.molang.utils.MolangUtils; +import com.eliotlash.molang.variables.ExecutionContext; + +public class StrEquals extends Function { + public StrEquals(String name) { + super(name); + } + + @Override + public double _evaluate(Expr[] arguments, ExecutionContext ctx) { + final String first = ctx.getEvaluator().evaluateString(arguments[0]); + final String second = ctx.getEvaluator().evaluateString(arguments[1]); + return MolangUtils.booleanToFloat(first.equals(second)); + } + + @Override + public int getRequiredArguments() { + return 2; + } +} diff --git a/src/main/java/com/eliotlash/molang/functions/strings/StrEqualsIgnoreCase.java b/src/main/java/com/eliotlash/molang/functions/strings/StrEqualsIgnoreCase.java new file mode 100644 index 0000000..b731274 --- /dev/null +++ b/src/main/java/com/eliotlash/molang/functions/strings/StrEqualsIgnoreCase.java @@ -0,0 +1,24 @@ +package com.eliotlash.molang.functions.strings; + +import com.eliotlash.molang.ast.Expr; +import com.eliotlash.molang.functions.Function; +import com.eliotlash.molang.utils.MolangUtils; +import com.eliotlash.molang.variables.ExecutionContext; + +public class StrEqualsIgnoreCase extends Function { + public StrEqualsIgnoreCase(String name) { + super(name); + } + + @Override + public double _evaluate(Expr[] arguments, ExecutionContext ctx) { + final String first = ctx.getEvaluator().evaluateString(arguments[0]); + final String second = ctx.getEvaluator().evaluateString(arguments[1]); + return MolangUtils.booleanToFloat(first.equalsIgnoreCase(second)); + } + + @Override + public int getRequiredArguments() { + return 2; + } +} diff --git a/src/main/java/com/eliotlash/molang/lexer/Lexer.java b/src/main/java/com/eliotlash/molang/lexer/Lexer.java index 744fc3e..7be0652 100644 --- a/src/main/java/com/eliotlash/molang/lexer/Lexer.java +++ b/src/main/java/com/eliotlash/molang/lexer/Lexer.java @@ -68,9 +68,19 @@ private void scanToken() { return; } - var token = tryOperator(c); + + var token = tryOperator(c); if (token != null) { + if (token == TokenType.QUOTE) { + try { + eatString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + return; + } + setToken(token); } else if (isDigit(c)) { eatNumeral(); @@ -79,6 +89,38 @@ private void scanToken() { } } + + private void eatString() throws Exception { + // skip over the current character ('), we don't want that to part of the string. + // The lexer seems to always include the startPos. + // Other option would be to make it like a state machine where if it comes across a quote + // it'll consume everything within it until it reaches another quote. + // But that would be behavior specific to Strings, and doesn't fit with + // current behavior for numerals. + startPos++; + + // while we have a next character, and the next character does not equal "'" + // we are safe to advance. + while (hasNextChar() && peek() != '\'') { + advance(); + } + + // Early fail for unclosed strings + if (!hasNextChar() || peek() != '\'') { + System.out.println(peek()); + // We want to skip over the closing quote. + // We don't however want to include it in the substring, so we skip over, after we set token. + // However, if we find that there isn't another quote, we mark it as an unclosed string + throw new Exception("string not closed"); + } + + setToken(TokenType.STRING); + + + // Skip over the next quote so the lexer doesn't pick it up as a new string. + advance(); + } + private void eatWhitespace() { while (Character.isWhitespace(peek())) { advance(); @@ -111,54 +153,54 @@ private void eatIdentifier() { private TokenType tryOperator(char c) { switch (c) { - case '!' -> { - if (match('=')) { - return TokenType.BANG_EQUAL; + case '!' -> { + if (match('=')) { + return TokenType.BANG_EQUAL; + } + return TokenType.NOT; } - return TokenType.NOT; - } - case '=' -> { - if (match('=')) { - return TokenType.EQUAL_EQUAL; + case '=' -> { + if (match('=')) { + return TokenType.EQUAL_EQUAL; + } + return TokenType.EQUALS; } - return TokenType.EQUALS; - } - case '<' -> { - if (match('=')) { - return TokenType.LESS_EQUAL; + case '<' -> { + if (match('=')) { + return TokenType.LESS_EQUAL; + } + return TokenType.LESS_THAN; } - return TokenType.LESS_THAN; - } - case '>' -> { - if (match('=')) { - return TokenType.GREATER_EQUAL; + case '>' -> { + if (match('=')) { + return TokenType.GREATER_EQUAL; + } + return TokenType.GREATER_THAN; } - return TokenType.GREATER_THAN; - } - case '&' -> { - if (match('&')) { + case '&' -> { + if (match('&')) { + return TokenType.AND; + } return TokenType.AND; } - return TokenType.AND; - } - case '|' -> { - if (match('|')) { + case '|' -> { + if (match('|')) { + return TokenType.OR; + } return TokenType.OR; } - return TokenType.OR; - } - case '-' -> { - if (match('>')) { - return TokenType.ARROW; + case '-' -> { + if (match('>')) { + return TokenType.ARROW; + } + return TokenType.MINUS; } - return TokenType.MINUS; - } - case '?' -> { - if (match('?')) { - return TokenType.COALESCE; + case '?' -> { + if (match('?')) { + return TokenType.COALESCE; + } + return TokenType.QUESTION; } - return TokenType.QUESTION; - } } return switch (c) { @@ -177,6 +219,7 @@ private TokenType tryOperator(char c) { case ';' -> TokenType.SEMICOLON; case ':' -> TokenType.COLON; case '.' -> TokenType.DOT; + case '\'' -> TokenType.QUOTE; default -> null; }; } diff --git a/src/main/java/com/eliotlash/molang/lexer/TokenType.java b/src/main/java/com/eliotlash/molang/lexer/TokenType.java index c4ce00c..c55fec8 100644 --- a/src/main/java/com/eliotlash/molang/lexer/TokenType.java +++ b/src/main/java/com/eliotlash/molang/lexer/TokenType.java @@ -13,6 +13,11 @@ public enum TokenType { */ IDENTIFIER, + /** + * A token for a string + */ + STRING, + // Single character tokens: // '!' @@ -77,6 +82,8 @@ public enum TokenType { // '??' COALESCE, + QUOTE, + EOF, /** diff --git a/src/main/java/com/eliotlash/molang/variables/ExecutionContext.java b/src/main/java/com/eliotlash/molang/variables/ExecutionContext.java index d166e6b..3c0bacb 100644 --- a/src/main/java/com/eliotlash/molang/variables/ExecutionContext.java +++ b/src/main/java/com/eliotlash/molang/variables/ExecutionContext.java @@ -14,7 +14,12 @@ import com.eliotlash.molang.functions.rounding.Floor; import com.eliotlash.molang.functions.rounding.Round; import com.eliotlash.molang.functions.rounding.Trunc; +import com.eliotlash.molang.functions.strings.Length; +import com.eliotlash.molang.functions.strings.Print; +import com.eliotlash.molang.functions.strings.StrEquals; +import com.eliotlash.molang.functions.strings.StrEqualsIgnoreCase; import com.eliotlash.molang.functions.utility.*; + import com.eliotlash.molang.functions.utility.Random; import com.eliotlash.molang.utils.MolangUtils; import it.unimi.dsi.fastutil.Pair; @@ -24,9 +29,9 @@ import java.util.*; public class ExecutionContext { - - private static final Map MATH_FUNCTIONS; - + + private static final Map BUILTIN_FUNCTIONS; + private final Evaluator evaluator; public final Stack contextStack = new Stack<>(); @@ -42,7 +47,7 @@ public class ExecutionContext { public ExecutionContext(Evaluator evaluator) { this.evaluator = evaluator; - registerFunctions(MATH_FUNCTIONS); + registerFunctions(BUILTIN_FUNCTIONS); } public Evaluator getEvaluator() { @@ -118,7 +123,7 @@ public void registerFunctions(Map map) { public Function getFunction(FunctionDefinition definition) { return this.functionMap.get(definition); } - + private static FunctionDefinition asFunctionDefinition(String target, Function function) { return new FunctionDefinition(new Expr.Variable(target), function.getName()); } @@ -126,7 +131,7 @@ private static FunctionDefinition asFunctionDefinition(String target, Function f private static void addFunction(Map map, String target, Function func) { map.put(asFunctionDefinition(target, func), func); } - + static { Map map = new HashMap<>(); addFunction(map, "math", new Abs("abs")); @@ -157,6 +162,10 @@ private static void addFunction(Map map, String ta addFunction(map, "math", new RandomInteger("random_integer")); addFunction(map, "math", new DiceRoll("dice_roll")); addFunction(map, "math", new DiceRollInteger("dice_roll_integer")); - MATH_FUNCTIONS = Map.copyOf(map); + addFunction(map, "system", new Print("print")); + addFunction(map, "string", new StrEquals("equals")); + addFunction(map, "string", new StrEqualsIgnoreCase("equalsIgnoreCase")); + addFunction(map, "string", new Length("length")); + BUILTIN_FUNCTIONS = Map.copyOf(map); } } diff --git a/src/test/java/com/eliotlash/molang/FunctionsTest.java b/src/test/java/com/eliotlash/molang/FunctionsTest.java index f5652e2..f75056d 100644 --- a/src/test/java/com/eliotlash/molang/FunctionsTest.java +++ b/src/test/java/com/eliotlash/molang/FunctionsTest.java @@ -108,11 +108,24 @@ void testMiscFunctions() throws Exception { assertEquals(10.0, evaluate("math.abs(-10)")); assertEquals(10.0, evaluate("math.abs(10)")); - assertEquals(Math.E, evaluate("math.exp(1)")); + assertEquals(Math.exp(1), evaluate("math.exp(1)")); assertEquals(0.0, evaluate("math.ln(1)")); assertEquals(2.0, evaluate("math.sqrt(4)")); assertEquals(2.0, evaluate("math.mod(5, 3)")); assertEquals(100.0, evaluate("math.pow(10, 2)")); + + assertEquals(1.0, evaluate("!string.equals('minecraft:pig', 'minecraft:cow')")); + assertEquals(1.0, evaluate("string.equals('minecraft:cow', 'minecraft:cow')")); + assertEquals(0.0, evaluate("string.equals('COW', 'cow')")); + assertEquals(1.0, evaluate("string.equalsIgnoreCase('COW', 'cow')")); + + assertEquals(13, evaluate("string.length('minecraft:cow')")); + assertEquals(0.0, evaluate("string.length('')")); + assertEquals(10.0, evaluate("'I am nothing' + 10")); + + assertEquals(1.0, evaluate("system.print('\"1+1-1/1*1->([{9+1}]);\"')")); + assertEquals(1.0, evaluate("system.print('test')")); + assertEquals(1.0, evaluate("system.print('oh no' + 'math is hard')")); } private double evaluate(String expression) throws Exception { diff --git a/src/test/java/com/eliotlash/molang/LexerTest.java b/src/test/java/com/eliotlash/molang/LexerTest.java index ef24a8b..5651511 100644 --- a/src/test/java/com/eliotlash/molang/LexerTest.java +++ b/src/test/java/com/eliotlash/molang/LexerTest.java @@ -13,6 +13,14 @@ public class LexerTest { + @Test + void testString() { + assertTokens("''", STRING); + assertTokens("'minecraft:pig'", STRING); + assertTokens("'something long arbitrary and unlikely to be used'", STRING); + assertTokens("'anything goes really'", STRING); + } + @Test void testIdentifier() { assertTokens("two", IDENTIFIER); diff --git a/src/test/java/com/eliotlash/molang/ParserTest.java b/src/test/java/com/eliotlash/molang/ParserTest.java index d2474e6..7526207 100644 --- a/src/test/java/com/eliotlash/molang/ParserTest.java +++ b/src/test/java/com/eliotlash/molang/ParserTest.java @@ -10,6 +10,28 @@ public class ParserTest extends TestBase { + @Test + void testString() { + assertEquals(new Expr.Str("minecraft:pig"), e("'minecraft:pig'")); + assertEquals(new Expr.Str("hello, world. this is a string"), e("'hello, world. this is a string'")); + // empty strings work + assertEquals(new Expr.Str(""), e("''")); + + assertThrows(Exception.class, () -> { + e("'this string is not closed "); + }); + + assertThrows(Exception.class, () -> { + e("'''"); + }); + + + assertThrows(Exception.class, () -> { + e("'"); + }); + + assertEquals(new Expr.Str("this string is was closed "), e("'this string is was closed '")); + } @Test void testStmt() { Expr setThing = call("v", "set_thing", access("q", "thing")); @@ -31,6 +53,7 @@ void testStmt() { assertThrows(ParseException.class, () -> s("if(5 * 5)")); + } @Test @@ -161,6 +184,9 @@ void testAssignment() { assertThrows(ParseException.class, () -> e("fail = 20")); assertThrows(ParseException.class, () -> e("20 = 20")); assertThrows(ParseException.class, () -> e("(query.fail) = 20")); + + Expr.Access ridingAcc = access("query", "riding"); + assertEquals(new Expr.Assignment(ridingAcc, new Expr.Str("minecraft:pig")), e("query.riding = 'minecraft:pig'")); } @Test @@ -173,6 +199,14 @@ void testCall() { c(40), op(c(20), Operator.MUL, c(40))) ), e("q.count(20, 40, 20 * 40)")); + + Expr.Call stringCall = new Expr.Call(v("q"), "str", List.of(new Expr.Str("hello, world"), new Expr.Str("minecraft:pig"))); + + assertEquals(stringCall, e("q.str('hello, world', 'minecraft:pig')")); + + assertThrows(Exception.class, () -> { + e("q.str('hello, world', 'minecraft:pig)"); + }); } @Test diff --git a/src/test/java/com/eliotlash/molang/ast/EvaluatorTest.java b/src/test/java/com/eliotlash/molang/ast/EvaluatorTest.java index 5b11607..ae19b51 100644 --- a/src/test/java/com/eliotlash/molang/ast/EvaluatorTest.java +++ b/src/test/java/com/eliotlash/molang/ast/EvaluatorTest.java @@ -263,4 +263,14 @@ void visitBinOp(BinOpTest args) { Double result = new Expr.BinOp(args.op(), c(args.lhs()), c(args.rhs())).accept(eval); assertEquals(args.expectedResult(), result, 0.000001); } + + + @Test + void stringEqualityTest() { + // the binops != & == use String#equals behind the scenes + assertEquals(1.0, eval.evaluate(parseE("'minecraft:pig' != 'minecraft:cow'"))); + assertEquals(0.0, eval.evaluate(parseE("'minecraft:pig' == 'minecraft:cow'"))); + assertEquals(1.0, eval.evaluate(parseE("'minecraft:pig' != ''"))); + assertEquals(1.0, eval.evaluate(parseE("'' == ''"))); + } }