diff --git a/CLAUDE.md b/CLAUDE.md index 037735d..ea812a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,8 @@ let result = vm.execute()?; // Returns last expression's value The dexpr language supports: - If/else conditionals with `if ... then ... else ... end` - String methods (e.g., `.upper()`, `.lower()`, `.trim()`, `.trimStart()`, `.trimEnd()`, `.split()`, `.replace()`, `.contains()`, `.startsWith()`, `.endsWith()`, `.length`, `.charAt()`, `.substring()`) -- Arithmetic (`+`, `-`, `*`, `/`, `%`, `**`), comparison, and logical operators +- Arithmetic (`+`, `-`, `*`, `/`, `%`, `**`), comparison, and logical operators. `==`/`!=` work on all types (structural, different types → `false`); `<`/`<=`/`>`/`>=` work on Number and String (lexicographic) +- `null` literal (`x == null`) - `in` operator for membership testing (`"finans" in categories`, `5 in numbers`, `"hello" in "hello world"`, `"key" in obj`) - Compound assignments (`+=`, `-=`, `*=`, `/=`, `%=`) - Built-in `log()` function for output and `rand(min, max)` for random integers diff --git a/Cargo.lock b/Cargo.lock index 35d0194..a01b1c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,7 +304,7 @@ checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" [[package]] name = "dexpr" -version = "0.3.0" +version = "0.4.0" dependencies = [ "bumpalo", "criterion", diff --git a/Cargo.toml b/Cargo.toml index afa3d37..03b2155 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dexpr" -version = "0.3.0" +version = "0.4.0" edition = "2021" description = "Embeddable expression evaluator and bytecode VM" license = "MIT" diff --git a/docs/editor.md b/docs/editor.md index 1730f53..8b5dea3 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -74,7 +74,7 @@ Dil küçük olduğundan (7 keyword, ~20 operatör) iki gramer dosyasını sync ### Keyword Yönetimi -Keyword'ler `@extend` ile tanımlanır — `identifier` token'ından türetilir ama **her zaman** keyword olarak parse edilir. dexpr'de keyword'ler reserved'dır (`if`, `then`, `else`, `end`, `in`, `true`, `false`). +Keyword'ler `@extend` ile tanımlanır — `identifier` token'ından türetilir ama **her zaman** keyword olarak parse edilir. dexpr'de keyword'ler reserved'dır (`if`, `then`, `else`, `end`, `in`, `true`, `false`, `null`). ### Error Recovery @@ -95,6 +95,7 @@ Lezer GLR parser kullanır. Bozuk/yarım kod yazılırken: |-------|-----------|-----------------| | `if`, `then`, `else`, `end`, `in`, `elseIf` | `keyword` | `#7c3aed` (mor) | | `true`, `false` | `bool` | `#d97706` (turuncu) | +| `null` | `null` | `#d97706` (turuncu) | | `"string"`, `'string'` | `string` | `#059669` (yeşil) | | `42`, `3.14` | `number` | `#2563eb` (mavi) | | `// comment`, `/* comment */` | `lineComment` / `blockComment` | `#9ca3af` (gri, italic) | diff --git a/docs/parser.md b/docs/parser.md index f905761..a9bcf65 100644 --- a/docs/parser.md +++ b/docs/parser.md @@ -72,6 +72,7 @@ En düşükten en yükseğe: | Sayı | `42`, `3.14` | `Expr::Value(Number)` | | String | `"hello"`, `'world'` | `Expr::Value(String)` | | Boolean | `true`, `false` | `Expr::Value(Boolean)` | +| Null | `null` | `Expr::Value(Null)` | | Parantezli ifade | `(a + b)` | İç ifade | | Tekli negatif | `-x` | `Expr::UnaryOp(Neg, x)` | | Tekli NOT | `!flag` | `Expr::UnaryOp(Not, flag)` | @@ -84,7 +85,7 @@ En düşükten en yükseğe: ### Ayrılmış Kelimeler -`if`, `then`, `else`, `end`, `true`, `false`, `in` +`if`, `then`, `else`, `end`, `true`, `false`, `null`, `in` ### Boşluk ve Yorumlar diff --git a/docs/vm.md b/docs/vm.md index d822761..e76ef4e 100644 --- a/docs/vm.md +++ b/docs/vm.md @@ -126,7 +126,8 @@ struct VM<'a> { - **`handle_neg()`** — Sadece Number tipinde tekli negatif ### Karşılaştırma -- **`compare_op(f, name)`** — Decimal değerler üzerinde karşılaştırma, Boolean döndürür +- **`equality_op(negate)`** — `==` / `!=`. Her tip için çalışır, `Value`'nun `PartialEq`'i ile yapısal eşitlik (list, object dahil derin karşılaştırma). Farklı tipler hata vermez, `false` döner (`"1" == 1` → `false`, `x == null` → `x` Null ise `true`) +- **`compare_op(f, name)`** — `<`, `<=`, `>`, `>=`. Number/Number ve String/String (lexicographic byte sırası) destekler; diğer kombinasyonlar `InvalidOperation` hatası ### Boolean - **`handle_and()`**, **`handle_or()`**, **`handle_not()`** — Boolean register'lar üzerinde mantık operasyonları diff --git a/editor/dist/index.cjs b/editor/dist/index.cjs index 7c82259..91251c6 100644 --- a/editor/dist/index.cjs +++ b/editor/dist/index.cjs @@ -41,7 +41,7 @@ var import_lr2 = require("@lezer/lr"); var import_lr = require("@lezer/lr"); // src/parser.terms.js -var elseIf = 44; +var elseIf = 46; // src/tokens.ts var CH_e = 101; @@ -81,32 +81,33 @@ var elseIfTokenizer = new import_lr.ExternalTokenizer((input) => { }); // src/parser.js -var spec_identifier = { __proto__: null, if: 11, true: 21, false: 21, in: 43, then: 69, else: 71, end: 73 }; +var spec_identifier = { __proto__: null, if: 11, true: 21, false: 21, null: 25, in: 47, then: 73, else: 75, end: 77 }; var parser = import_lr2.LRParser.deserialize({ version: 14, - states: "+^Q]QQOOOOQP'#Cb'#CbOOQP'#Ce'#CeOwQQO'#CiO`QQO'#CjO#TQRO'#DTO%bQRO'#D_OOQP'#D_'#D_O%iQRO'#D_OOQP'#D]'#D]OOQP'#DU'#DUQ]QQOOOwQQO'#C`O&eQQO,59TO&lQRO'#D_O(zQRO,59UO`QQO,59XO`QQO,59XO`QQO,59XO`QQO,59XO`QQO,59XO)wQQO,59hO)|QQO'#CzOOQP,59i,59iO`QQO,59mOOQP-E7S-E7SO*TQQO,58zOOQP1G.o1G.oO+oQRO1G.sO+vQRO1G.sO-bQRO1G.sO-iQRO1G.sO.[QRO1G.sOOQP'#Cy'#CyOOQP1G/S1G/SO/[QQO'#D`OOQP,59f,59fO/fQQO,59fO/kQRO1G/XO0bQRO1G.fO0oQQO1G/SOOQP7+$i7+$iOwQQO'#DVO1pQQO,59zOOQP1G/Q1G/QO1xQRO7+$QO2TQRO7+$QOwQQO'#DWOOQP7+$Q7+$QO2bQQO7+$QO2iQQO,59qOOQO-E7T-E7TOOQP-E7U-E7UOOQP< spec_identifier[value] || -1 }], - tokenPrec: 1033 + specialized: [{ term: 50, get: (value) => spec_identifier[value] || -1 }], + tokenPrec: 1063 }); // src/language.ts var dexprHighlighting = (0, import_highlight.styleTags)({ "if then else end in elseIf": import_highlight.tags.keyword, BooleanLiteral: import_highlight.tags.bool, + NullLiteral: import_highlight.tags.null, String: import_highlight.tags.string, Number: import_highlight.tags.number, LineComment: import_highlight.tags.lineComment, @@ -165,6 +166,7 @@ var KEYWORDS = [ { label: "end", type: "keyword" }, { label: "true", type: "keyword", detail: "Boolean" }, { label: "false", type: "keyword", detail: "Boolean" }, + { label: "null", type: "keyword", detail: "Null" }, { label: "in", type: "keyword", detail: "membership test" } ]; function inferVariableTypes(context, knownVars) { @@ -225,6 +227,10 @@ function inferExprType(node, doc, knownTypes) { if (rootType === "Object") { return knownTypes.get(`${varName}.${fieldName}`) ?? null; } + if (rootType === "List") { + const fieldType = knownTypes.get(`${varName}.${fieldName}`) ?? null; + return projectedListType(fieldType); + } } return null; } @@ -298,6 +304,11 @@ function inferMethodReturnType(method) { return null; } } +function projectedListType(fieldType) { + if (fieldType === "Number") return "NumberList"; + if (fieldType === "String") return "StringList"; + return "List"; +} function dedup(items) { const seen = /* @__PURE__ */ new Set(); return items.filter((item) => { @@ -367,12 +378,16 @@ function dexprCompletion(info) { if (path.length === 1) return { type: rootType, path }; let currentType = rootType; for (let i = 1; i < path.length; i++) { - if (currentType !== "Object") { + if (currentType === "Object") { + const key = `${path[i - 1]}.${path[i]}`; + currentType = varTypes.get(key) ?? null; + } else if (currentType === "List") { + const key = `${path[0]}.${path[i]}`; + const fieldType = varTypes.get(key) ?? null; + currentType = projectedListType(fieldType); + } else { return { type: currentType, path }; } - const key = `${path[i - 1]}.${path[i]}`; - const fieldType = varTypes.get(key) ?? null; - currentType = fieldType; } return { type: currentType, path }; } @@ -403,8 +418,9 @@ function dexprCompletion(info) { options = [...fieldItems, ...objMethods]; } else if (finalType === "List") { const rootVarName = path[0]; + const fieldItems = objectFieldCompletions.get(rootVarName) ?? []; const listMethods = methodsByType["List"] ?? []; - options = [...listMethods]; + options = [...fieldItems, ...listMethods]; } else if (finalType) { options = methodsByType[finalType] ?? allMethods; } else { @@ -436,6 +452,7 @@ var import_highlight2 = require("@lezer/highlight"); var dexprHighlightStyle = import_language3.HighlightStyle.define([ { tag: import_highlight2.tags.keyword, color: "#7c3aed" }, { tag: import_highlight2.tags.bool, color: "#d97706" }, + { tag: import_highlight2.tags.null, color: "#d97706" }, { tag: import_highlight2.tags.string, color: "#059669" }, { tag: import_highlight2.tags.number, color: "#2563eb" }, { tag: import_highlight2.tags.lineComment, color: "#9ca3af", fontStyle: "italic" }, diff --git a/editor/dist/index.js b/editor/dist/index.js index 15e913d..f8fcba7 100644 --- a/editor/dist/index.js +++ b/editor/dist/index.js @@ -12,7 +12,7 @@ import { LRParser } from "@lezer/lr"; import { ExternalTokenizer } from "@lezer/lr"; // src/parser.terms.js -var elseIf = 44; +var elseIf = 46; // src/tokens.ts var CH_e = 101; @@ -52,32 +52,33 @@ var elseIfTokenizer = new ExternalTokenizer((input) => { }); // src/parser.js -var spec_identifier = { __proto__: null, if: 11, true: 21, false: 21, in: 43, then: 69, else: 71, end: 73 }; +var spec_identifier = { __proto__: null, if: 11, true: 21, false: 21, null: 25, in: 47, then: 73, else: 75, end: 77 }; var parser = LRParser.deserialize({ version: 14, - states: "+^Q]QQOOOOQP'#Cb'#CbOOQP'#Ce'#CeOwQQO'#CiO`QQO'#CjO#TQRO'#DTO%bQRO'#D_OOQP'#D_'#D_O%iQRO'#D_OOQP'#D]'#D]OOQP'#DU'#DUQ]QQOOOwQQO'#C`O&eQQO,59TO&lQRO'#D_O(zQRO,59UO`QQO,59XO`QQO,59XO`QQO,59XO`QQO,59XO`QQO,59XO)wQQO,59hO)|QQO'#CzOOQP,59i,59iO`QQO,59mOOQP-E7S-E7SO*TQQO,58zOOQP1G.o1G.oO+oQRO1G.sO+vQRO1G.sO-bQRO1G.sO-iQRO1G.sO.[QRO1G.sOOQP'#Cy'#CyOOQP1G/S1G/SO/[QQO'#D`OOQP,59f,59fO/fQQO,59fO/kQRO1G/XO0bQRO1G.fO0oQQO1G/SOOQP7+$i7+$iOwQQO'#DVO1pQQO,59zOOQP1G/Q1G/QO1xQRO7+$QO2TQRO7+$QOwQQO'#DWOOQP7+$Q7+$QO2bQQO7+$QO2iQQO,59qOOQO-E7T-E7TOOQP-E7U-E7UOOQP< spec_identifier[value] || -1 }], - tokenPrec: 1033 + specialized: [{ term: 50, get: (value) => spec_identifier[value] || -1 }], + tokenPrec: 1063 }); // src/language.ts var dexprHighlighting = styleTags({ "if then else end in elseIf": tags.keyword, BooleanLiteral: tags.bool, + NullLiteral: tags.null, String: tags.string, Number: tags.number, LineComment: tags.lineComment, @@ -138,6 +139,7 @@ var KEYWORDS = [ { label: "end", type: "keyword" }, { label: "true", type: "keyword", detail: "Boolean" }, { label: "false", type: "keyword", detail: "Boolean" }, + { label: "null", type: "keyword", detail: "Null" }, { label: "in", type: "keyword", detail: "membership test" } ]; function inferVariableTypes(context, knownVars) { @@ -198,6 +200,10 @@ function inferExprType(node, doc, knownTypes) { if (rootType === "Object") { return knownTypes.get(`${varName}.${fieldName}`) ?? null; } + if (rootType === "List") { + const fieldType = knownTypes.get(`${varName}.${fieldName}`) ?? null; + return projectedListType(fieldType); + } } return null; } @@ -271,6 +277,11 @@ function inferMethodReturnType(method) { return null; } } +function projectedListType(fieldType) { + if (fieldType === "Number") return "NumberList"; + if (fieldType === "String") return "StringList"; + return "List"; +} function dedup(items) { const seen = /* @__PURE__ */ new Set(); return items.filter((item) => { @@ -340,12 +351,16 @@ function dexprCompletion(info) { if (path.length === 1) return { type: rootType, path }; let currentType = rootType; for (let i = 1; i < path.length; i++) { - if (currentType !== "Object") { + if (currentType === "Object") { + const key = `${path[i - 1]}.${path[i]}`; + currentType = varTypes.get(key) ?? null; + } else if (currentType === "List") { + const key = `${path[0]}.${path[i]}`; + const fieldType = varTypes.get(key) ?? null; + currentType = projectedListType(fieldType); + } else { return { type: currentType, path }; } - const key = `${path[i - 1]}.${path[i]}`; - const fieldType = varTypes.get(key) ?? null; - currentType = fieldType; } return { type: currentType, path }; } @@ -376,8 +391,9 @@ function dexprCompletion(info) { options = [...fieldItems, ...objMethods]; } else if (finalType === "List") { const rootVarName = path[0]; + const fieldItems = objectFieldCompletions.get(rootVarName) ?? []; const listMethods = methodsByType["List"] ?? []; - options = [...listMethods]; + options = [...fieldItems, ...listMethods]; } else if (finalType) { options = methodsByType[finalType] ?? allMethods; } else { @@ -409,6 +425,7 @@ import { tags as tags2 } from "@lezer/highlight"; var dexprHighlightStyle = HighlightStyle.define([ { tag: tags2.keyword, color: "#7c3aed" }, { tag: tags2.bool, color: "#d97706" }, + { tag: tags2.null, color: "#d97706" }, { tag: tags2.string, color: "#059669" }, { tag: tags2.number, color: "#2563eb" }, { tag: tags2.lineComment, color: "#9ca3af", fontStyle: "italic" }, diff --git a/editor/package.json b/editor/package.json index 190d3a2..aed6138 100644 --- a/editor/package.json +++ b/editor/package.json @@ -1,6 +1,6 @@ { "name": "@duhanbalci/codemirror-lang-dexpr", - "version": "0.3.0", + "version": "0.4.0", "description": "CodeMirror 6 language support for dexpr", "type": "module", "main": "dist/index.cjs", diff --git a/editor/src/completions.ts b/editor/src/completions.ts index e37d82e..e58f5b1 100644 --- a/editor/src/completions.ts +++ b/editor/src/completions.ts @@ -90,6 +90,7 @@ const KEYWORDS: Completion[] = [ { label: "end", type: "keyword" }, { label: "true", type: "keyword", detail: "Boolean" }, { label: "false", type: "keyword", detail: "Boolean" }, + { label: "null", type: "keyword", detail: "Null" }, { label: "in", type: "keyword", detail: "membership test" }, ]; diff --git a/editor/src/dexpr.grammar b/editor/src/dexpr.grammar index 5f2bf2b..1fd89da 100644 --- a/editor/src/dexpr.grammar +++ b/editor/src/dexpr.grammar @@ -43,6 +43,7 @@ expression[@isGroup=Expression] { Number | String | BooleanLiteral | + NullLiteral | ParenExpression | UnaryExpression | BinaryExpression | @@ -91,6 +92,8 @@ kw { @extend[@name={term}] } BooleanLiteral { @extend[@name=BooleanLiteral] } +NullLiteral { @extend[@name=NullLiteral] } + @tokens { space { $[ \t\n\r]+ } diff --git a/editor/src/highlight.ts b/editor/src/highlight.ts index fedec3a..5ea2ccf 100644 --- a/editor/src/highlight.ts +++ b/editor/src/highlight.ts @@ -5,6 +5,7 @@ import type { Extension } from "@codemirror/state"; export const dexprHighlightStyle = HighlightStyle.define([ { tag: tags.keyword, color: "#7c3aed" }, { tag: tags.bool, color: "#d97706" }, + { tag: tags.null, color: "#d97706" }, { tag: tags.string, color: "#059669" }, { tag: tags.number, color: "#2563eb" }, { tag: tags.lineComment, color: "#9ca3af", fontStyle: "italic" }, diff --git a/editor/src/language.ts b/editor/src/language.ts index a7099c2..7ffa79f 100644 --- a/editor/src/language.ts +++ b/editor/src/language.ts @@ -6,6 +6,7 @@ import { parser } from "./parser.js"; const dexprHighlighting = styleTags({ "if then else end in elseIf": tags.keyword, BooleanLiteral: tags.bool, + NullLiteral: tags.null, String: tags.string, Number: tags.number, LineComment: tags.lineComment, diff --git a/editor/src/parser.js b/editor/src/parser.js index ede7cb0..6229b83 100644 --- a/editor/src/parser.js +++ b/editor/src/parser.js @@ -1,24 +1,24 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. import {LRParser} from "@lezer/lr" import {elseIfTokenizer} from "./tokens" -const spec_identifier = {__proto__:null,if:11, true:21, false:21, in:43, then:69, else:71, end:73} +const spec_identifier = {__proto__:null,if:11, true:21, false:21, null:25, in:47, then:73, else:75, end:77} export const parser = LRParser.deserialize({ version: 14, - states: "+^Q]QQOOOOQP'#Cb'#CbOOQP'#Ce'#CeOwQQO'#CiO`QQO'#CjO#TQRO'#DTO%bQRO'#D_OOQP'#D_'#D_O%iQRO'#D_OOQP'#D]'#D]OOQP'#DU'#DUQ]QQOOOwQQO'#C`O&eQQO,59TO&lQRO'#D_O(zQRO,59UO`QQO,59XO`QQO,59XO`QQO,59XO`QQO,59XO`QQO,59XO)wQQO,59hO)|QQO'#CzOOQP,59i,59iO`QQO,59mOOQP-E7S-E7SO*TQQO,58zOOQP1G.o1G.oO+oQRO1G.sO+vQRO1G.sO-bQRO1G.sO-iQRO1G.sO.[QRO1G.sOOQP'#Cy'#CyOOQP1G/S1G/SO/[QQO'#D`OOQP,59f,59fO/fQQO,59fO/kQRO1G/XO0bQRO1G.fO0oQQO1G/SOOQP7+$i7+$iOwQQO'#DVO1pQQO,59zOOQP1G/Q1G/QO1xQRO7+$QO2TQRO7+$QOwQQO'#DWOOQP7+$Q7+$QO2bQQO7+$QO2iQQO,59qOOQO-E7T-E7TOOQP-E7U-E7UOOQP< spec_identifier[value] || -1}], - tokenPrec: 1033 + specialized: [{term: 50, get: (value) => spec_identifier[value] || -1}], + tokenPrec: 1063 }) diff --git a/editor/src/parser.terms.js b/editor/src/parser.terms.js index 99aaf1b..bf4e22b 100644 --- a/editor/src/parser.terms.js +++ b/editor/src/parser.terms.js @@ -1,6 +1,6 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. export const - elseIf = 44, + elseIf = 46, LineComment = 1, BlockComment = 2, Program = 3, @@ -10,20 +10,21 @@ export const Number = 7, String = 8, BooleanLiteral = 10, - ParenExpression = 13, - UnaryExpression = 14, - BinaryExpression = 17, - CompareOp = 20, - _in = 21, - Power = 26, - MethodCall = 27, - PropertyName = 29, - ArgList = 30, - PropertyAccess = 32, - FunctionCall = 33, - then = 34, - _else = 35, - end = 36, - Assignment = 37, - AssignOp = 38, - ExprStatement = 39 + NullLiteral = 12, + ParenExpression = 15, + UnaryExpression = 16, + BinaryExpression = 19, + CompareOp = 22, + _in = 23, + Power = 28, + MethodCall = 29, + PropertyName = 31, + ArgList = 32, + PropertyAccess = 34, + FunctionCall = 35, + then = 36, + _else = 37, + end = 38, + Assignment = 39, + AssignOp = 40, + ExprStatement = 41 diff --git a/src/parser/grammar.rs b/src/parser/grammar.rs index 4de2e03..d7fbb71 100644 --- a/src/parser/grammar.rs +++ b/src/parser/grammar.rs @@ -1,430 +1,431 @@ -use rust_decimal::Decimal; -use smol_str::SmolStr; -use std::str::FromStr; - -use crate::ast::{ - expr::{Expr, Op}, - stmt::Stmt, - value::Value, -}; - -peg::parser!( -pub grammar parser() for str { - pub rule program() -> Vec - = s:statement()* { s } - - /// Parse program with source location info for each statement - pub rule program_with_spans() -> Vec<(usize, Stmt)> - = s:statement_with_pos()* { s } - - /// Statement with position info (byte offset) - rule statement_with_pos() -> (usize, Stmt) - = whitespace()? - pos:position!() - s:( - assignment() - / if_stmt() - / expr_stmt() - ) - whitespace()? { (pos, s) } - - pub rule statement() -> Stmt - = whitespace()? - s:( - assignment() - / if_stmt() - / expr_stmt() - ) - whitespace()? { s } - - pub rule expression() -> Expr - = binary_op() - - pub rule mul_div() -> Expr = - left:power() mul_div_right:( - _ op:$("*" / "/" / "%") _ right:power() - { (op, right) } - )* { - let mut result = left; - for (op, right) in mul_div_right { - result = match op { - "*" => Expr::BinaryOp(Box::new(result), Op::Mul, Box::new(right)), - "/" => Expr::BinaryOp(Box::new(result), Op::Div, Box::new(right)), - "%" => Expr::BinaryOp(Box::new(result), Op::Mod, Box::new(right)), - _ => unreachable!() - }; - } - result - } - - pub rule power() -> Expr = - base:postfix() _ "**" _ exp:power() { Expr::BinaryOp(Box::new(base), Op::Pow, Box::new(exp)) } - / a:postfix() { a } - - - pub rule binary_op() -> Expr = precedence!{ - i:identifier() _ "(" args:((_ e:expression() _ {e}) ** ",") ")" { Expr::FunctionCall(i, args) } - -- - x:@ _ "&&" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::And, Box::new(y)) } - x:@ _ "||" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Or, Box::new(y)) } - -- - x:@ _ "==" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Eq, Box::new(y)) } - x:@ _ "!=" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Neq, Box::new(y)) } - x:@ _ "<" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Lt, Box::new(y)) } - x:@ _ "<=" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Lte, Box::new(y)) } - x:@ _ ">" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Gt, Box::new(y)) } - x:@ _ ">=" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Gte, Box::new(y)) } - x:@ _ "in" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::In, Box::new(y)) } - -- - x:@ _ "+" _ y:(@) { Expr::BinaryOp(Box::new(x),Op::Add, Box::new(y)) } - x:@ _ "-" _ y:(@) { Expr::BinaryOp(Box::new(x),Op::Sub, Box::new(y)) } - -- - x:mul_div() { x } - -- - p:postfix() { p } - } - - /// Postfix operations: property access and method calls with chaining - rule postfix() -> Expr - = base:atom() chain:( - "." m:identifier() "(" args:((_ e:expression() _ {e}) ** ",") ")" { (m, Some(args)) } - / "." p:identifier() { (p, None) } - )* { - let mut result = base; - for (name, args) in chain { - if let Some(args) = args { - result = Expr::MethodCall(Box::new(result), name, args); - } else { - result = Expr::PropertyAccess(Box::new(result), name); - } - } - result - } - - rule atom() -> Expr - = i:identifier() { Expr::Variable(i) } - / i:string() { Expr::Value(Value::String(i)) } - / i:number() { Expr::Value(Value::Number(i)) } - / i:boolean_literal() { Expr::Value(i) } - / "(" e:expression() ")" { e } - / "-" e:atom() { Expr::UnaryOp(Op::Neg, Box::new(e)) } - / "!" e:atom() { Expr::UnaryOp(Op::Not, Box::new(e)) } - - pub rule string() -> SmolStr - = "\"" s:$(([^'"'] / "\\\"")*) "\"" { - s.replace("\\\"", "\"").into() - } - / "'" s:$(([^'\''] / "\\''")*) "'" { - s.replace("\\'", "'").into() - } - - rule boolean_literal() -> Value - = "true" { Value::Boolean(true) } - / "false" { Value::Boolean(false) } - - - pub rule expr_stmt() -> Stmt - = e:expression() { Stmt::ExprStmt(Box::new(e)) } - - pub rule if_stmt() -> Stmt - = "if" _ cond:expression() whitespace()? "then" whitespace()? - then_body:statement()* whitespace()? - else_part:else_clause()? - "end" whitespace()? { - Stmt::If(Box::new(cond), then_body, else_part) - } - - pub rule else_clause() -> Vec - = "else if" whitespace()? cond:expression() whitespace()? "then" whitespace()? - then_body:statement()* whitespace()? - else_part:else_clause()? whitespace()? { - vec![Stmt::If(Box::new(cond), then_body, else_part)] - } - / "else" whitespace()? else_body:statement()* whitespace()? { - else_body - } - - pub rule assignment() -> Stmt - = i:identifier() path:("." p:identifier() { p })+ _ "=" _ value:expression() { - Stmt::PropertyAssignment(i, path, Box::new(value)) - } - / i:identifier() _ op:compound_op() _ value:expression() { - // Desugar compound assignment: x += 1 becomes x = x + 1 - let var_expr = Expr::Variable(i.clone()); - let combined = Expr::BinaryOp(Box::new(var_expr), op, Box::new(value)); - Stmt::Assignment(i, Box::new(combined)) - } - / i:identifier() _ "=" _ value:expression() { Stmt::Assignment(i, Box::new(value)) } - - rule compound_op() -> Op - = "+=" { Op::Add } - / "-=" { Op::Sub } - / "*=" { Op::Mul } - / "/=" { Op::Div } - / "%=" { Op::Mod } - - rule keyword() - = ("if" / "then" / "else" / "end" / "true" / "false" / "in") !['a'..='z' | 'A'..='Z' | '0'..='9' | '_'] - - rule identifier() -> SmolStr - = !keyword() s:$(['a'..='z' | 'A'..='Z' | '_']['a'..='z' | 'A'..='Z' | '0'..='9' | '_']*) - { s.into() } - - rule number() -> Decimal - = n:$(['0'..='9']+ ("." ['0'..='9']+)?) {? - Decimal::from_str(n).map_err(|_| "invalid decimal") - } - - rule whitespace() - = ([' ' | '\t' | '\n' | '\r'] / comment())+ - - rule comment() - = "//" [^'\n']* "\n"? - / "/*" (!"*/" [_])* "*/" - - rule _() = quiet!{([' ' | '\t'] / comment())*} - - // rule string_lit() -> Expr - // = "\"" s:$([^'"']*) "\"" - // { Expr::String(s.to_string()) } - } -); - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_binary_op() { - let res = parser::binary_op("1 + 2 * 3 - 4 / 5"); - if let Err(e) = &res { - println!("{}", e); - } - if let Ok(expr) = res { - match expr { - Expr::BinaryOp(left, Op::Add, right) => { - assert!(matches!(*left, Expr::Value(_))); - match *right { - Expr::BinaryOp(left2, Op::Sub, right2) => { - // Check 2 * 3 - match *left2 { - Expr::BinaryOp(left3, Op::Mul, right3) => { - assert!(matches!(*left3, Expr::Value(_))); - assert!(matches!(*right3, Expr::Value(_))); - } - _ => panic!("Expected multiplication"), - } - // Check 4 / 5 - match *right2 { - Expr::BinaryOp(left3, Op::Div, right3) => { - assert!(matches!(*left3, Expr::Value(_))); - assert!(matches!(*right3, Expr::Value(_))); - } - _ => panic!("Expected division"), - } - } - _ => panic!("Expected subtraction"), - } - } - _ => panic!("Expected addition at top level"), - } - } else { - panic!("Failed to parse expression"); - } - } - - #[test] - fn test_function_call() { - assert!(matches!( - parser::expression("add(1, 2)"), - Ok(Expr::FunctionCall(_, _)) - )); - } - - #[test] - fn test_var_decl() { - let input = "x = 1"; - let res = parser::assignment(input); - assert!(matches!(res, Ok(Stmt::Assignment(_, _)))); - } - - #[test] - fn test_simple_arithmetic() { - let input = "x = 1 + 2 * 3"; - let result = parser::program(input).unwrap(); - - assert_eq!(result.len(), 1); - if let Stmt::Assignment(name, expr) = &result[0] { - assert_eq!(name, "x"); - if let Expr::BinaryOp(left, op, right) = expr.as_ref() { - assert!(matches!(op, Op::Add)); - assert!(matches!(**left, Expr::Value(Value::Number(_)))); - if let Expr::BinaryOp(mul_left, mul_op, mul_right) = right.as_ref() { - assert!(matches!(mul_op, Op::Mul)); - assert!(matches!(**mul_left, Expr::Value(Value::Number(_)))); - assert!(matches!(**mul_right, Expr::Value(Value::Number(_)))); - } else { - panic!("Expected multiplication operation"); - } - } else { - panic!("Expected binary operation"); - } - } else { - panic!("Expected assignment statement"); - } - } - - #[test] - fn test_if_statement() { - let input = "if x < 10 then y = x else y = 0 end"; - let result = parser::program(input).unwrap(); - - assert_eq!(result.len(), 1); - if let Stmt::If(condition, then_branch, else_branch) = &result[0] { - // Check condition - if let Expr::BinaryOp(left, op, right) = condition.as_ref() { - assert!(matches!(op, Op::Lt)); - assert!(matches!(**left, Expr::Variable(_))); - assert!(matches!(**right, Expr::Value(Value::Number(_)))); - } else { - panic!("Expected binary operation in condition"); - } - - // Check then branch - assert_eq!(then_branch.len(), 1); - assert!(matches!(&then_branch[0], Stmt::Assignment(_, _))); - - // Check else branch - assert!(else_branch.is_some()); - let else_branch = else_branch.as_ref().unwrap(); - assert_eq!(else_branch.len(), 1); - assert!(matches!(&else_branch[0], Stmt::Assignment(_, _))); - } else { - panic!("Expected if statement"); - } - } - - #[test] - fn test_nested_function_calls() { - let input = "result = max(min(a, b), abs(c))"; - let result = parser::program(input).unwrap(); - - assert_eq!(result.len(), 1); - if let Stmt::Assignment(name, expr) = &result[0] { - assert_eq!(name, "result"); - if let Expr::FunctionCall(func_name, args) = expr.as_ref() { - assert_eq!(func_name, "max"); - assert_eq!(args.len(), 2); - - // Check first argument (min call) - if let Expr::FunctionCall(inner_func, inner_args) = &args[0] { - assert_eq!(inner_func, "min"); - assert_eq!(inner_args.len(), 2); - } else { - panic!("Expected min function call"); - } - - // Check second argument (abs call) - if let Expr::FunctionCall(inner_func, inner_args) = &args[1] { - assert_eq!(inner_func, "abs"); - assert_eq!(inner_args.len(), 1); - } else { - panic!("Expected abs function call"); - } - } else { - panic!("Expected function call"); - } - } - } - - #[test] - fn test_decimal_numbers() { - let input = "x = 123.456"; - let result = parser::program(input).unwrap(); - - if let Stmt::Assignment(_, expr) = &result[0] { - if let Expr::Value(Value::Number(n)) = expr.as_ref() { - assert_eq!(*n, Decimal::from_str("123.456").unwrap()); - } else { - panic!("Expected decimal number"); - } - } - } - - #[test] - fn test_complex_nested_if() { - let input = r#" - if x > 0 then - if y > 0 then - result = x + y - else - result = x - y - end - else - result = 0 - end - "#; - let result = parser::program(input).unwrap(); - - assert_eq!(result.len(), 1); - if let Stmt::If(_, then_branch, else_branch) = &result[0] { - // Check that then_branch contains another if statement - assert_eq!(then_branch.len(), 1); - assert!(matches!(&then_branch[0], Stmt::If(_, _, _))); - - // Check else branch - assert!(else_branch.is_some()); - let else_branch = else_branch.as_ref().unwrap(); - assert_eq!(else_branch.len(), 1); - assert!(matches!(&else_branch[0], Stmt::Assignment(_, _))); - } - } - - #[test] - fn test_syntax_errors() { - // Missing 'end' keyword - assert!(parser::program("if x < 10 then y = x").is_err()); - - // Invalid expression - assert!(parser::program("x = 1 + * 2").is_err()); - } - - #[test] - fn test_whitespace_handling() { - let input1 = "x=1+2"; - let input2 = "x = 1 + 2"; - let input3 = "x = 1 + 2"; - - let result1 = parser::program(input1).unwrap(); - let result2 = parser::program(input2).unwrap(); - let result3 = parser::program(input3).unwrap(); - - // All should produce equivalent ASTs - assert_eq!(result1, result2); - assert_eq!(result2, result3); - } - - #[test] - fn test_compound_assignment_parsing() { - let input = "x += 5"; - let result = parser::program(input); - println!("Result: {:?}", result); - let result = result.unwrap(); - assert_eq!(result.len(), 1); - if let Stmt::Assignment(name, expr) = &result[0] { - assert_eq!(name, "x"); - // Should be desugared to x + 5 - if let Expr::BinaryOp(left, op, right) = expr.as_ref() { - assert!(matches!(op, Op::Add)); - // left should be Variable("x") - assert!(matches!(**left, Expr::Variable(_))); - // right should be Number(5) - assert!(matches!(**right, Expr::Value(Value::Number(_)))); - } else { - panic!("Expected BinaryOp after desugaring, got {:?}", expr); - } - } else { - panic!("Expected Assignment, got {:?}", result[0]); - } - } -} +use rust_decimal::Decimal; +use smol_str::SmolStr; +use std::str::FromStr; + +use crate::ast::{ + expr::{Expr, Op}, + stmt::Stmt, + value::Value, +}; + +peg::parser!( +pub grammar parser() for str { + pub rule program() -> Vec + = s:statement()* { s } + + /// Parse program with source location info for each statement + pub rule program_with_spans() -> Vec<(usize, Stmt)> + = s:statement_with_pos()* { s } + + /// Statement with position info (byte offset) + rule statement_with_pos() -> (usize, Stmt) + = whitespace()? + pos:position!() + s:( + assignment() + / if_stmt() + / expr_stmt() + ) + whitespace()? { (pos, s) } + + pub rule statement() -> Stmt + = whitespace()? + s:( + assignment() + / if_stmt() + / expr_stmt() + ) + whitespace()? { s } + + pub rule expression() -> Expr + = binary_op() + + pub rule mul_div() -> Expr = + left:power() mul_div_right:( + _ op:$("*" / "/" / "%") _ right:power() + { (op, right) } + )* { + let mut result = left; + for (op, right) in mul_div_right { + result = match op { + "*" => Expr::BinaryOp(Box::new(result), Op::Mul, Box::new(right)), + "/" => Expr::BinaryOp(Box::new(result), Op::Div, Box::new(right)), + "%" => Expr::BinaryOp(Box::new(result), Op::Mod, Box::new(right)), + _ => unreachable!() + }; + } + result + } + + pub rule power() -> Expr = + base:postfix() _ "**" _ exp:power() { Expr::BinaryOp(Box::new(base), Op::Pow, Box::new(exp)) } + / a:postfix() { a } + + + pub rule binary_op() -> Expr = precedence!{ + i:identifier() _ "(" args:((_ e:expression() _ {e}) ** ",") ")" { Expr::FunctionCall(i, args) } + -- + x:@ _ "&&" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::And, Box::new(y)) } + x:@ _ "||" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Or, Box::new(y)) } + -- + x:@ _ "==" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Eq, Box::new(y)) } + x:@ _ "!=" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Neq, Box::new(y)) } + x:@ _ "<" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Lt, Box::new(y)) } + x:@ _ "<=" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Lte, Box::new(y)) } + x:@ _ ">" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Gt, Box::new(y)) } + x:@ _ ">=" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::Gte, Box::new(y)) } + x:@ _ "in" _ y:(@) { Expr::BinaryOp(Box::new(x), Op::In, Box::new(y)) } + -- + x:@ _ "+" _ y:(@) { Expr::BinaryOp(Box::new(x),Op::Add, Box::new(y)) } + x:@ _ "-" _ y:(@) { Expr::BinaryOp(Box::new(x),Op::Sub, Box::new(y)) } + -- + x:mul_div() { x } + -- + p:postfix() { p } + } + + /// Postfix operations: property access and method calls with chaining + rule postfix() -> Expr + = base:atom() chain:( + "." m:identifier() "(" args:((_ e:expression() _ {e}) ** ",") ")" { (m, Some(args)) } + / "." p:identifier() { (p, None) } + )* { + let mut result = base; + for (name, args) in chain { + if let Some(args) = args { + result = Expr::MethodCall(Box::new(result), name, args); + } else { + result = Expr::PropertyAccess(Box::new(result), name); + } + } + result + } + + rule atom() -> Expr + = i:identifier() { Expr::Variable(i) } + / i:string() { Expr::Value(Value::String(i)) } + / i:number() { Expr::Value(Value::Number(i)) } + / i:boolean_literal() { Expr::Value(i) } + / "(" e:expression() ")" { e } + / "-" e:atom() { Expr::UnaryOp(Op::Neg, Box::new(e)) } + / "!" e:atom() { Expr::UnaryOp(Op::Not, Box::new(e)) } + + pub rule string() -> SmolStr + = "\"" s:$(([^'"'] / "\\\"")*) "\"" { + s.replace("\\\"", "\"").into() + } + / "'" s:$(([^'\''] / "\\''")*) "'" { + s.replace("\\'", "'").into() + } + + rule boolean_literal() -> Value + = "true" { Value::Boolean(true) } + / "false" { Value::Boolean(false) } + / "null" { Value::Null } + + + pub rule expr_stmt() -> Stmt + = e:expression() { Stmt::ExprStmt(Box::new(e)) } + + pub rule if_stmt() -> Stmt + = "if" _ cond:expression() whitespace()? "then" whitespace()? + then_body:statement()* whitespace()? + else_part:else_clause()? + "end" whitespace()? { + Stmt::If(Box::new(cond), then_body, else_part) + } + + pub rule else_clause() -> Vec + = "else if" whitespace()? cond:expression() whitespace()? "then" whitespace()? + then_body:statement()* whitespace()? + else_part:else_clause()? whitespace()? { + vec![Stmt::If(Box::new(cond), then_body, else_part)] + } + / "else" whitespace()? else_body:statement()* whitespace()? { + else_body + } + + pub rule assignment() -> Stmt + = i:identifier() path:("." p:identifier() { p })+ _ "=" _ value:expression() { + Stmt::PropertyAssignment(i, path, Box::new(value)) + } + / i:identifier() _ op:compound_op() _ value:expression() { + // Desugar compound assignment: x += 1 becomes x = x + 1 + let var_expr = Expr::Variable(i.clone()); + let combined = Expr::BinaryOp(Box::new(var_expr), op, Box::new(value)); + Stmt::Assignment(i, Box::new(combined)) + } + / i:identifier() _ "=" _ value:expression() { Stmt::Assignment(i, Box::new(value)) } + + rule compound_op() -> Op + = "+=" { Op::Add } + / "-=" { Op::Sub } + / "*=" { Op::Mul } + / "/=" { Op::Div } + / "%=" { Op::Mod } + + rule keyword() + = ("if" / "then" / "else" / "end" / "true" / "false" / "null" / "in") !['a'..='z' | 'A'..='Z' | '0'..='9' | '_'] + + rule identifier() -> SmolStr + = !keyword() s:$(['a'..='z' | 'A'..='Z' | '_']['a'..='z' | 'A'..='Z' | '0'..='9' | '_']*) + { s.into() } + + rule number() -> Decimal + = n:$(['0'..='9']+ ("." ['0'..='9']+)?) {? + Decimal::from_str(n).map_err(|_| "invalid decimal") + } + + rule whitespace() + = ([' ' | '\t' | '\n' | '\r'] / comment())+ + + rule comment() + = "//" [^'\n']* "\n"? + / "/*" (!"*/" [_])* "*/" + + rule _() = quiet!{([' ' | '\t'] / comment())*} + + // rule string_lit() -> Expr + // = "\"" s:$([^'"']*) "\"" + // { Expr::String(s.to_string()) } + } +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_binary_op() { + let res = parser::binary_op("1 + 2 * 3 - 4 / 5"); + if let Err(e) = &res { + println!("{}", e); + } + if let Ok(expr) = res { + match expr { + Expr::BinaryOp(left, Op::Add, right) => { + assert!(matches!(*left, Expr::Value(_))); + match *right { + Expr::BinaryOp(left2, Op::Sub, right2) => { + // Check 2 * 3 + match *left2 { + Expr::BinaryOp(left3, Op::Mul, right3) => { + assert!(matches!(*left3, Expr::Value(_))); + assert!(matches!(*right3, Expr::Value(_))); + } + _ => panic!("Expected multiplication"), + } + // Check 4 / 5 + match *right2 { + Expr::BinaryOp(left3, Op::Div, right3) => { + assert!(matches!(*left3, Expr::Value(_))); + assert!(matches!(*right3, Expr::Value(_))); + } + _ => panic!("Expected division"), + } + } + _ => panic!("Expected subtraction"), + } + } + _ => panic!("Expected addition at top level"), + } + } else { + panic!("Failed to parse expression"); + } + } + + #[test] + fn test_function_call() { + assert!(matches!( + parser::expression("add(1, 2)"), + Ok(Expr::FunctionCall(_, _)) + )); + } + + #[test] + fn test_var_decl() { + let input = "x = 1"; + let res = parser::assignment(input); + assert!(matches!(res, Ok(Stmt::Assignment(_, _)))); + } + + #[test] + fn test_simple_arithmetic() { + let input = "x = 1 + 2 * 3"; + let result = parser::program(input).unwrap(); + + assert_eq!(result.len(), 1); + if let Stmt::Assignment(name, expr) = &result[0] { + assert_eq!(name, "x"); + if let Expr::BinaryOp(left, op, right) = expr.as_ref() { + assert!(matches!(op, Op::Add)); + assert!(matches!(**left, Expr::Value(Value::Number(_)))); + if let Expr::BinaryOp(mul_left, mul_op, mul_right) = right.as_ref() { + assert!(matches!(mul_op, Op::Mul)); + assert!(matches!(**mul_left, Expr::Value(Value::Number(_)))); + assert!(matches!(**mul_right, Expr::Value(Value::Number(_)))); + } else { + panic!("Expected multiplication operation"); + } + } else { + panic!("Expected binary operation"); + } + } else { + panic!("Expected assignment statement"); + } + } + + #[test] + fn test_if_statement() { + let input = "if x < 10 then y = x else y = 0 end"; + let result = parser::program(input).unwrap(); + + assert_eq!(result.len(), 1); + if let Stmt::If(condition, then_branch, else_branch) = &result[0] { + // Check condition + if let Expr::BinaryOp(left, op, right) = condition.as_ref() { + assert!(matches!(op, Op::Lt)); + assert!(matches!(**left, Expr::Variable(_))); + assert!(matches!(**right, Expr::Value(Value::Number(_)))); + } else { + panic!("Expected binary operation in condition"); + } + + // Check then branch + assert_eq!(then_branch.len(), 1); + assert!(matches!(&then_branch[0], Stmt::Assignment(_, _))); + + // Check else branch + assert!(else_branch.is_some()); + let else_branch = else_branch.as_ref().unwrap(); + assert_eq!(else_branch.len(), 1); + assert!(matches!(&else_branch[0], Stmt::Assignment(_, _))); + } else { + panic!("Expected if statement"); + } + } + + #[test] + fn test_nested_function_calls() { + let input = "result = max(min(a, b), abs(c))"; + let result = parser::program(input).unwrap(); + + assert_eq!(result.len(), 1); + if let Stmt::Assignment(name, expr) = &result[0] { + assert_eq!(name, "result"); + if let Expr::FunctionCall(func_name, args) = expr.as_ref() { + assert_eq!(func_name, "max"); + assert_eq!(args.len(), 2); + + // Check first argument (min call) + if let Expr::FunctionCall(inner_func, inner_args) = &args[0] { + assert_eq!(inner_func, "min"); + assert_eq!(inner_args.len(), 2); + } else { + panic!("Expected min function call"); + } + + // Check second argument (abs call) + if let Expr::FunctionCall(inner_func, inner_args) = &args[1] { + assert_eq!(inner_func, "abs"); + assert_eq!(inner_args.len(), 1); + } else { + panic!("Expected abs function call"); + } + } else { + panic!("Expected function call"); + } + } + } + + #[test] + fn test_decimal_numbers() { + let input = "x = 123.456"; + let result = parser::program(input).unwrap(); + + if let Stmt::Assignment(_, expr) = &result[0] { + if let Expr::Value(Value::Number(n)) = expr.as_ref() { + assert_eq!(*n, Decimal::from_str("123.456").unwrap()); + } else { + panic!("Expected decimal number"); + } + } + } + + #[test] + fn test_complex_nested_if() { + let input = r#" + if x > 0 then + if y > 0 then + result = x + y + else + result = x - y + end + else + result = 0 + end + "#; + let result = parser::program(input).unwrap(); + + assert_eq!(result.len(), 1); + if let Stmt::If(_, then_branch, else_branch) = &result[0] { + // Check that then_branch contains another if statement + assert_eq!(then_branch.len(), 1); + assert!(matches!(&then_branch[0], Stmt::If(_, _, _))); + + // Check else branch + assert!(else_branch.is_some()); + let else_branch = else_branch.as_ref().unwrap(); + assert_eq!(else_branch.len(), 1); + assert!(matches!(&else_branch[0], Stmt::Assignment(_, _))); + } + } + + #[test] + fn test_syntax_errors() { + // Missing 'end' keyword + assert!(parser::program("if x < 10 then y = x").is_err()); + + // Invalid expression + assert!(parser::program("x = 1 + * 2").is_err()); + } + + #[test] + fn test_whitespace_handling() { + let input1 = "x=1+2"; + let input2 = "x = 1 + 2"; + let input3 = "x = 1 + 2"; + + let result1 = parser::program(input1).unwrap(); + let result2 = parser::program(input2).unwrap(); + let result3 = parser::program(input3).unwrap(); + + // All should produce equivalent ASTs + assert_eq!(result1, result2); + assert_eq!(result2, result3); + } + + #[test] + fn test_compound_assignment_parsing() { + let input = "x += 5"; + let result = parser::program(input); + println!("Result: {:?}", result); + let result = result.unwrap(); + assert_eq!(result.len(), 1); + if let Stmt::Assignment(name, expr) = &result[0] { + assert_eq!(name, "x"); + // Should be desugared to x + 5 + if let Expr::BinaryOp(left, op, right) = expr.as_ref() { + assert!(matches!(op, Op::Add)); + // left should be Variable("x") + assert!(matches!(**left, Expr::Variable(_))); + // right should be Number(5) + assert!(matches!(**right, Expr::Value(Value::Number(_)))); + } else { + panic!("Expected BinaryOp after desugaring, got {:?}", expr); + } + } else { + panic!("Expected Assignment, got {:?}", result[0]); + } + } +} diff --git a/src/vm/vm.rs b/src/vm/vm.rs index 0644e7d..4ce698c 100644 --- a/src/vm/vm.rs +++ b/src/vm/vm.rs @@ -1,4 +1,5 @@ use crate::{ast::value::Value, bytecode::BytecodeReader, opcodes::OpCodeByte}; +use std::cmp::Ordering; use std::rc::Rc; use micromap::Map; use rust_decimal::{Decimal, MathematicalOps}; @@ -208,12 +209,12 @@ impl<'a> VM<'a> { "modulo", ), OpCodeByte::Pow => self.binary_op(|a, b| Ok(a.powd(b)), "power"), - OpCodeByte::Lt => self.compare_op(|a, b| a < b, "less than"), - OpCodeByte::Lte => self.compare_op(|a, b| a <= b, "less than or equal"), - OpCodeByte::Gt => self.compare_op(|a, b| a > b, "greater than"), - OpCodeByte::Gte => self.compare_op(|a, b| a >= b, "greater than or equal"), - OpCodeByte::Eq => self.compare_op(|a, b| a == b, "equal"), - OpCodeByte::Neq => self.compare_op(|a, b| a != b, "not equal"), + OpCodeByte::Lt => self.compare_op(|o| o == Ordering::Less, "less than"), + OpCodeByte::Lte => self.compare_op(|o| o != Ordering::Greater, "less than or equal"), + OpCodeByte::Gt => self.compare_op(|o| o == Ordering::Greater, "greater than"), + OpCodeByte::Gte => self.compare_op(|o| o != Ordering::Less, "greater than or equal"), + OpCodeByte::Eq => self.equality_op(false), + OpCodeByte::Neq => self.equality_op(true), OpCodeByte::Contains => self.handle_contains(), OpCodeByte::And => self.handle_and(), OpCodeByte::Or => self.handle_or(), @@ -893,11 +894,54 @@ impl<'a> VM<'a> { Ok(()) } - /// Helper for comparison operations + /// Helper for equality operations (`==`, `!=`). + /// Works on every value type via structural equality; values of different + /// types are never equal (no error). + #[inline] + fn equality_op(&mut self, negate: bool) -> Result<(), VMError> { + let dest = self + .reader + .read_register() + .map_err(|e| VMError::BytecodeError(e))? as usize; + let a = self + .reader + .read_register() + .map_err(|e| VMError::BytecodeError(e))? as usize; + let b = self + .reader + .read_register() + .map_err(|e| VMError::BytecodeError(e))? as usize; + + #[cfg(debug_assertions)] + if dest >= MAX_REGISTERS || a >= MAX_REGISTERS || b >= MAX_REGISTERS { + return Err(VMError::RuntimeError(format!( + "Invalid register: dest={}, a={}, b={}", + dest, a, b + ))); + } + + let equal = self.registers[a] == self.registers[b]; + self.registers[dest] = Value::Boolean(equal != negate); + + log_debug!( + self, + "{} r{} = r{} {} r{}", + if negate { "not equal" } else { "equal" }, + dest, + a, + if negate { "!=" } else { "==" }, + b + ); + + Ok(()) + } + + /// Helper for ordering comparisons (`<`, `<=`, `>`, `>=`). + /// Supported on Number/Number and String/String (lexicographic byte order). #[inline] fn compare_op(&mut self, op: F, op_name: &'static str) -> Result<(), VMError> where - F: FnOnce(&Decimal, &Decimal) -> bool, + F: FnOnce(Ordering) -> bool, { let dest = self .reader @@ -920,11 +964,9 @@ impl<'a> VM<'a> { ))); } - match (&self.registers[a], &self.registers[b]) { - (Value::Number(a_num), Value::Number(b_num)) => { - let result = op(a_num, b_num); - self.registers[dest] = Value::Boolean(result); - } + let ordering = match (&self.registers[a], &self.registers[b]) { + (Value::Number(a_num), Value::Number(b_num)) => a_num.cmp(b_num), + (Value::String(a_str), Value::String(b_str)) => a_str.as_str().cmp(b_str.as_str()), (a_val, b_val) => { return Err(VMError::InvalidOperation { operation: op_name, @@ -932,7 +974,8 @@ impl<'a> VM<'a> { right_type: b_val.type_name(), }); } - } + }; + self.registers[dest] = Value::Boolean(op(ordering)); log_debug!(self, "{} r{} = r{} {} r{}", op_name, dest, a, op_name, b); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 8a10c33..e12c0ad 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1939,4 +1939,133 @@ fn test_invoice_full_scenario() { ("kdvOrani", Value::Number(dec!(0.20))), ]); assert_eq!(result, Value::Number(dec!(272760.00))); -} \ No newline at end of file +} +// ==================== COMPARISON TESTS ==================== + +fn run_expr_err(code: &str, globals: Vec<(&str, Value)>) -> String { + let ast = parser::program(code).expect("Failed to parse"); + let mut compiler = Compiler::new(); + let bytecode = compiler.compile(ast).expect("Failed to compile"); + let mut vm = VM::new(&bytecode); + for (name, value) in globals { + vm.set_global(name, value); + } + vm.execute().unwrap_err().to_string() +} + +#[test] +fn test_compare_number_list_equality() { + let a = Value::NumberList(Rc::new(vec![dec!(1), dec!(2)])); + let b = Value::NumberList(Rc::new(vec![dec!(1), dec!(2)])); + let c = Value::NumberList(Rc::new(vec![dec!(1), dec!(3)])); + assert_eq!( + run_expr_with_globals("a == b", vec![("a", a.clone()), ("b", b.clone())]), + Value::Boolean(true) + ); + assert_eq!( + run_expr_with_globals("a == c", vec![("a", a.clone()), ("c", c.clone())]), + Value::Boolean(false) + ); + assert_eq!( + run_expr_with_globals("a != c", vec![("a", a), ("c", c)]), + Value::Boolean(true) + ); +} + +#[test] +fn test_compare_string_list_equality() { + let a = Value::StringList(Rc::new(vec![SmolStr::new("x"), SmolStr::new("y")])); + let b = Value::StringList(Rc::new(vec![SmolStr::new("x"), SmolStr::new("y")])); + let c = Value::StringList(Rc::new(vec![SmolStr::new("y"), SmolStr::new("x")])); + assert_eq!( + run_expr_with_globals("a == b", vec![("a", a.clone()), ("b", b)]), + Value::Boolean(true) + ); + assert_eq!( + run_expr_with_globals("a == c", vec![("a", a), ("c", c)]), + Value::Boolean(false) + ); +} + +#[test] +fn test_compare_list_of_objects_equality() { + let mk = |n: i64| { + let mut m = IndexMap::new(); + m.insert(SmolStr::new("id"), Value::Number(dec!(1) * rust_decimal::Decimal::from(n))); + Value::Object(Rc::new(m)) + }; + let a = Value::List(Rc::new(vec![mk(1), mk(2)])); + let b = Value::List(Rc::new(vec![mk(1), mk(2)])); + let c = Value::List(Rc::new(vec![mk(1)])); + assert_eq!( + run_expr_with_globals("a == b", vec![("a", a.clone()), ("b", b)]), + Value::Boolean(true) + ); + assert_eq!( + run_expr_with_globals("a == c", vec![("a", a), ("c", c)]), + Value::Boolean(false) + ); +} + +#[test] +fn test_compare_different_types_never_equal() { + let list = Value::NumberList(Rc::new(vec![dec!(1)])); + assert_eq!( + run_expr_with_globals("l == 1", vec![("l", list.clone())]), + Value::Boolean(false) + ); + assert_eq!( + run_expr_with_globals("l == \"1\"", vec![("l", list.clone())]), + Value::Boolean(false) + ); + assert_eq!( + run_expr_with_globals("l == null", vec![("l", list)]), + Value::Boolean(false) + ); +} + +#[test] +fn test_compare_ordering_string_vs_number_errors() { + let err = run_expr_err("\"a\" < 1", vec![]); + assert!(err.contains("less than"), "got: {err}"); + assert!(err.contains("String"), "got: {err}"); + assert!(err.contains("Number"), "got: {err}"); +} + +#[test] +fn test_compare_ordering_boolean_errors() { + let err = run_expr_err("true > false", vec![]); + assert!(err.contains("greater than"), "got: {err}"); + assert!(err.contains("Boolean"), "got: {err}"); +} + +#[test] +fn test_compare_ordering_null_errors() { + let err = run_expr_err("null <= 1", vec![]); + assert!(err.contains("less than or equal"), "got: {err}"); + assert!(err.contains("Null"), "got: {err}"); +} + +#[test] +fn test_compare_ordering_lists_errors() { + let list = Value::NumberList(Rc::new(vec![dec!(1)])); + let err = run_expr_err("l >= l", vec![("l", list)]); + assert!(err.contains("greater than or equal"), "got: {err}"); +} + +#[test] +fn test_null_is_reserved_keyword() { + assert!(parser::program("null = 5").is_err()); + // identifiers that merely start with "null" are still fine + assert_eq!(run_expr("nullable = 3\nnullable"), Value::Number(dec!(3))); +} + +#[test] +fn test_string_compare_in_condition() { + let code = r#" + grade = "B" + result = 0 + if grade == "A" then result = 100 else if grade == "B" then result = 80 else result = 0 end + "#; + assert_eq!(run_and_get_result(code), Value::Number(dec!(80))); +} diff --git a/tests/test_cases.json b/tests/test_cases.json index e0df2f2..855c32c 100644 --- a/tests/test_cases.json +++ b/tests/test_cases.json @@ -1284,5 +1284,179 @@ "maxPrice": { "type": "number", "value": "200" } }, "expected": { "type": "number", "value": "200" } + }, + + { + "name": "compare: string equality", + "code": "\"a\" == \"a\"", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: string inequality", + "code": "\"a\" == \"b\"", + "expected": { "type": "boolean", "value": false } + }, + { + "name": "compare: string not equal", + "code": "\"a\" != \"b\"", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: string case sensitive", + "code": "\"A\" == \"a\"", + "expected": { "type": "boolean", "value": false } + }, + { + "name": "compare: string less than", + "code": "\"a\" < \"b\"", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: string greater than", + "code": "\"b\" > \"a\"", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: string lte equal", + "code": "\"a\" <= \"a\"", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: string gte", + "code": "\"abc\" >= \"abd\"", + "expected": { "type": "boolean", "value": false } + }, + { + "name": "compare: string prefix ordering", + "code": "\"ab\" < \"abc\"", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: boolean equality", + "code": "true == true", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: boolean inequality", + "code": "true == false", + "expected": { "type": "boolean", "value": false } + }, + { + "name": "compare: boolean not equal", + "code": "true != false", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: null literal", + "code": "null", + "expected": { "type": "null" } + }, + { + "name": "compare: null == null", + "code": "null == null", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: null != null", + "code": "null != null", + "expected": { "type": "boolean", "value": false } + }, + { + "name": "compare: number == null is false", + "code": "1 == null", + "expected": { "type": "boolean", "value": false } + }, + { + "name": "compare: number != null", + "code": "1 != null", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: string == number is false", + "code": "\"1\" == 1", + "expected": { "type": "boolean", "value": false } + }, + { + "name": "compare: string != number", + "code": "\"1\" != 1", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: boolean == number is false", + "code": "true == 1", + "expected": { "type": "boolean", "value": false } + }, + { + "name": "compare: number equality still works", + "code": "1 == 1", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: number decimal equality", + "code": "1.0 == 1", + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: string eq in if", + "code": "x = \"ok\"\nif x == \"ok\" then 1 else 2 end", + "expected": { "type": "number", "value": "1" } + }, + { + "name": "compare: string in condition with globals", + "code": "if status == \"active\" then \"yes\" else \"no\" end", + "globals": { + "status": {"type": "string", "value": "active"} + }, + "expected": { "type": "string", "value": "yes" } + }, + { + "name": "compare: null global", + "code": "x == null", + "globals": { + "x": {"type": "null"} + }, + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: non-null global vs null", + "code": "x == null", + "globals": { + "x": {"type": "number", "value": "5"} + }, + "expected": { "type": "boolean", "value": false } + }, + { + "name": "compare: object equality", + "code": "a == b", + "globals": { + "a": {"type": "object", "value": {"k": "1"}}, + "b": {"type": "object", "value": {"k": "1"}} + }, + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: object inequality", + "code": "a == b", + "globals": { + "a": {"type": "object", "value": {"k": "1"}}, + "b": {"type": "object", "value": {"k": "2"}} + }, + "expected": { "type": "boolean", "value": false } + }, + { + "name": "compare: object field string eq", + "code": "o.name == \"ali\"", + "globals": { + "o": {"type": "object", "value": {"name": "ali"}} + }, + "expected": { "type": "boolean", "value": true } + }, + { + "name": "compare: object field string ordering", + "code": "o.name < \"b\"", + "globals": { + "o": {"type": "object", "value": {"name": "ali"}} + }, + "expected": { "type": "boolean", "value": true } } ] diff --git a/wasm/Cargo.lock b/wasm/Cargo.lock index 5995659..51c0489 100644 --- a/wasm/Cargo.lock +++ b/wasm/Cargo.lock @@ -141,7 +141,7 @@ dependencies = [ [[package]] name = "dexpr" -version = "0.1.0" +version = "0.4.0" dependencies = [ "bumpalo", "indexmap", @@ -159,7 +159,7 @@ dependencies = [ [[package]] name = "dexpr-wasm" -version = "0.1.0" +version = "0.4.0" dependencies = [ "dexpr", "getrandom 0.3.4", diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index b5d7fde..d154498 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dexpr-wasm" -version = "0.3.0" +version = "0.4.0" edition = "2021" [lib]