diff --git a/src/Ast/Nodes.php b/src/Ast/Nodes.php index 2a54098..ac0f3ea 100644 --- a/src/Ast/Nodes.php +++ b/src/Ast/Nodes.php @@ -360,3 +360,96 @@ public function getType(): string return 'Program'; } } + +final class TernaryExpr implements Node +{ + public function __construct( + public readonly Node $condition, + public readonly Node $consequent, + public readonly Node $alternative + ) { + } + + public function getType(): string + { + return 'TernaryExpr'; + } +} + +final class SpreadExpr implements Node +{ + public function __construct( + public readonly Node $value + ) { + } + + public function getType(): string + { + return 'SpreadExpr'; + } +} + +final class BreakStatement implements Node +{ + public function getType(): string + { + return 'BreakStatement'; + } +} + +final class ContinueStatement implements Node +{ + public function getType(): string + { + return 'ContinueStatement'; + } +} + +final class TryStatement implements Node +{ + public function __construct( + public readonly BlockStatement $body, + public readonly ?string $catchVar, + public readonly BlockStatement $catchBlock + ) { + } + + public function getType(): string + { + return 'TryStatement'; + } +} + +final class ArrayDestructure implements Node +{ + /** + * @param string[] $names + */ + public function __construct( + public readonly array $names, + public readonly Node $value + ) { + } + + public function getType(): string + { + return 'ArrayDestructure'; + } +} + +final class ObjectDestructure implements Node +{ + /** + * @param string[] $names + */ + public function __construct( + public readonly array $names, + public readonly Node $value + ) { + } + + public function getType(): string + { + return 'ObjectDestructure'; + } +} diff --git a/src/ErrorKind.php b/src/ErrorKind.php new file mode 100644 index 0000000..ff0794e --- /dev/null +++ b/src/ErrorKind.php @@ -0,0 +1,18 @@ +outputSink = $sink; + } + public function setVariable(string $name, mixed $value): void { $this->variables[$name] = $value; @@ -139,6 +174,9 @@ public function run(Program $program): ScriptResult return new ScriptResult($this->output, $result); } catch (ReturnException $e) { return new ScriptResult($this->output, $e->value); + } catch (BreakException | ContinueException $e) { + // A stray break/continue used outside any loop is ignored. + return new ScriptResult($this->output, null); } } @@ -159,6 +197,7 @@ public function evaluate(Node $node): mixed 'MemberExpr' => $this->evaluateMemberExpr($node), 'SafeMemberExpr' => $this->evaluateSafeMemberExpr($node), 'ElvisExpr' => $this->evaluateElvisExpr($node), + 'TernaryExpr' => $this->evaluateTernaryExpr($node), 'ArrayLiteral' => $this->evaluateArrayLiteral($node), 'ObjectLiteral' => $this->evaluateObjectLiteral($node), 'IndexExpr' => $this->evaluateIndexExpr($node), @@ -169,6 +208,11 @@ public function evaluate(Node $node): mixed 'ForStatement' => $this->evaluateForStatement($node), 'WhileStatement' => $this->evaluateWhileStatement($node), 'ReturnStatement' => $this->evaluateReturnStatement($node), + 'BreakStatement' => throw new BreakException(), + 'ContinueStatement' => throw new ContinueException(), + 'TryStatement' => $this->evaluateTryStatement($node), + 'ArrayDestructure' => $this->evaluateArrayDestructure($node), + 'ObjectDestructure' => $this->evaluateObjectDestructure($node), 'BlockStatement' => $this->evaluateBlockStatement($node), 'ExpressionStatement' => $this->evaluateExpressionStatement($node), 'Program' => $this->run($node)->value, @@ -223,24 +267,39 @@ private function evaluateIdentifier(Identifier $node): mixed private function evaluateBinaryExpr(BinaryExpr $node): mixed { $left = $this->evaluate($node->left); + + // Short-circuit logical operators (mirror Go: return boolean truthiness). + if ($node->operator === '&&') { + if (!$this->isTruthy($left)) { + return false; + } + return $this->isTruthy($this->evaluate($node->right)); + } + if ($node->operator === '||') { + if ($this->isTruthy($left)) { + return true; + } + return $this->isTruthy($this->evaluate($node->right)); + } + $right = $this->evaluate($node->right); return match ($node->operator) { + // KodiScript uses + for both numeric addition and string concatenation: + // if either operand is a string, concatenate using canonical stringification. '+' => is_string($left) || is_string($right) ? $this->stringify($left) . $this->stringify($right) : (float) $left + (float) $right, '-' => (float) $left - (float) $right, '*' => (float) $left * (float) $right, - '/' => (float) $right !== 0.0 ? (float) $left / (float) $right : throw new \RuntimeException("Division by zero"), - '%' => (float) $left % (float) $right, + '/' => (float) $right !== 0.0 ? (float) $left / (float) $right : throw new \RuntimeException("division by zero"), + '%' => (float) $right !== 0.0 ? fmod((float) $left, (float) $right) : throw new \RuntimeException("modulo by zero"), '==' => $left == $right, '!=' => $left != $right, '<' => $left < $right, '<=' => $left <= $right, '>' => $left > $right, '>=' => $left >= $right, - '&&' => $this->isTruthy($left) && $this->isTruthy($right), - '||' => $this->isTruthy($left) || $this->isTruthy($right), default => throw new \RuntimeException("Unknown operator: {$node->operator}"), }; } @@ -259,10 +318,95 @@ private function evaluateUnaryExpr(UnaryExpr $node): mixed private function evaluateCallExpr(CallExpr $node): mixed { + // Method-call syntax: receiver.method(args) + if ($node->callee instanceof MemberExpr) { + return $this->evaluateMethodCall($node->callee, $node->args); + } + $callee = $this->evaluate($node->callee); + $args = $this->evaluateArgs($node->args); + + return $this->callValue($callee, $args); + } + + /** + * Implements method-call syntax receiver.method(args), mirroring Go's + * evalMethodCall dispatch order: + * 1. a callable property stored on an object (map) wins, + * 2. a native (includes higher-order builtins) with the receiver prepended, + * 3. a bound PHP object's method/callable field via reflection. + */ + private function evaluateMethodCall(MemberExpr $callee, array $argNodes): mixed + { + $receiver = $this->evaluate($callee->object); + $method = $callee->property; + $args = $this->evaluateArgs($argNodes); + + // 1. A callable stored under that key on an object (map). + if (is_array($receiver) && !array_is_list($receiver) && array_key_exists($method, $receiver)) { + $value = $receiver[$method]; + if ($value instanceof FunctionValue || is_callable($value)) { + return $this->callValue($value, $args); + } + } + + $withReceiver = array_merge([$receiver], $args); + + // 2. A custom host function or native invoked as a method: prepend receiver. + if (isset($this->customFunctions[$method])) { + return ($this->customFunctions[$method])(...$withReceiver); + } + + $natives = $this->natives ?? Natives::instance(); + if ($natives->has($method)) { + $fn = $natives->get($method); + return $fn(...$withReceiver); + } + + // 3. Bound PHP object: method or callable field. + if (is_object($receiver)) { + if (method_exists($receiver, $method)) { + return $receiver->$method(...$args); + } + if (isset($receiver->$method) && is_callable($receiver->$method)) { + return ($receiver->$method)(...$args); + } + } - $args = array_map(fn($arg) => $this->evaluate($arg), $node->args); + if ($receiver === null) { + throw new \RuntimeException("cannot call method '{$method}' on null"); + } + + throw new \RuntimeException("undefined method '{$method}'"); + } + /** + * Evaluates a list of argument/element expressions, expanding ...spread. + * + * @param Node[] $nodes + * @return list + */ + private function evaluateArgs(array $nodes): array + { + $result = []; + foreach ($nodes as $node) { + if ($node instanceof SpreadExpr) { + $value = $this->evaluate($node->value); + if (!is_array($value)) { + throw new \RuntimeException("spread operator requires an array"); + } + foreach ($value as $element) { + $result[] = $element; + } + } else { + $result[] = $this->evaluate($node); + } + } + return $result; + } + + private function callValue(mixed $callee, array $args): mixed + { if ($callee instanceof FunctionValue) { return $this->applyFunction($callee, $args); } @@ -276,7 +420,13 @@ private function evaluateCallExpr(CallExpr $node): mixed private function applyFunction(FunctionValue $fn, array $args): mixed { + // Recursion guard: bound before PHP exhausts its native stack. + if ($this->callDepth >= self::MAX_CALL_DEPTH) { + throw new \RuntimeException("maximum call depth exceeded"); + } + $savedVariables = $this->variables; + $this->callDepth++; // Apply closure foreach ($fn->closure as $name => $value) { @@ -296,7 +446,11 @@ private function applyFunction(FunctionValue $fn, array $args): mixed return $result; } catch (ReturnException $e) { return $e->value; + } catch (BreakException | ContinueException $e) { + // A stray break/continue must not escape the function as a value. + return null; } finally { + $this->callDepth--; $this->variables = $savedVariables; } } @@ -358,9 +512,57 @@ private function evaluateElvisExpr(ElvisExpr $node): mixed return $this->evaluate($node->right); } + private function evaluateTernaryExpr(TernaryExpr $node): mixed + { + if ($this->isTruthy($this->evaluate($node->condition))) { + return $this->evaluate($node->consequent); + } + return $this->evaluate($node->alternative); + } + + private function evaluateTryStatement(TryStatement $node): mixed + { + try { + return $this->evaluate($node->body); + } catch (ReturnException | BreakException | ContinueException | LimitsExceededException $e) { + // return / break / continue / limit signals are not catchable errors. + throw $e; + } catch (\Throwable $e) { + if ($node->catchVar !== null) { + $this->variables[$node->catchVar] = $e->getMessage(); + } + return $this->evaluate($node->catchBlock); + } + } + + private function evaluateArrayDestructure(ArrayDestructure $node): mixed + { + $value = $this->evaluate($node->value); + if (!is_array($value)) { + throw new \RuntimeException("cannot destructure non-array value"); + } + $values = array_values($value); + foreach ($node->names as $i => $name) { + $this->variables[$name] = $values[$i] ?? null; + } + return $value; + } + + private function evaluateObjectDestructure(ObjectDestructure $node): mixed + { + $value = $this->evaluate($node->value); + if (!is_array($value)) { + throw new \RuntimeException("cannot destructure non-object value"); + } + foreach ($node->names as $name) { + $this->variables[$name] = $value[$name] ?? null; + } + return $value; + } + private function evaluateArrayLiteral(ArrayLiteral $node): array { - return array_map(fn($el) => $this->evaluate($el), $node->elements); + return $this->evaluateArgs($node->elements); } private function evaluateObjectLiteral(ObjectLiteral $node): array @@ -433,8 +635,10 @@ private function evaluateForStatement(ForStatement $node): mixed $this->variables[$node->variable->name] = $item; try { $result = $this->evaluate($node->body); - } catch (ReturnException $e) { - throw $e; + } catch (BreakException $e) { + break; + } catch (ContinueException $e) { + continue; } } @@ -448,8 +652,10 @@ private function evaluateWhileStatement(WhileStatement $node): mixed while ($this->isTruthy($this->evaluate($node->condition))) { try { $result = $this->evaluate($node->body); - } catch (ReturnException $e) { - throw $e; + } catch (BreakException $e) { + break; + } catch (ContinueException $e) { + continue; } } @@ -492,13 +698,7 @@ private function isTruthy(mixed $value): bool private function stringify(mixed $value): string { - if ($value === null) - return 'null'; - if (is_bool($value)) - return $value ? 'true' : 'false'; - if (is_array($value)) - return json_encode($value, JSON_UNESCAPED_UNICODE); - return (string) $value; + return Natives::stringify($value); } /** @@ -511,6 +711,9 @@ public function getOutput(): array public function addOutput(string $line): void { + if ($this->outputSink !== null) { + ($this->outputSink)($line); + } $this->output[] = $line; } } diff --git a/src/KodiScript.php b/src/KodiScript.php index 695e8b9..a326d48 100644 --- a/src/KodiScript.php +++ b/src/KodiScript.php @@ -17,6 +17,9 @@ final class KodiScriptBuilder private int $maxOps = 0; private int $timeout = 0; + /** @var (callable(string): void)|null */ + private $outputSink = null; + public function __construct(string $source) { $this->source = $source; @@ -63,37 +66,72 @@ public function withTimeout(int $timeoutMs): self return $this; } + /** + * Routes each print() line to the given callback. Output is still captured + * in ScriptResult::$output. Mirrors Go's WithOutput. + */ + public function withOutput(callable $sink): self + { + $this->outputSink = $sink; + return $this; + } + public function execute(): ScriptResult { + // Parse phase — failures here are classified as parse errors. try { $lexer = new Lexer($this->source); $tokens = $lexer->tokenize(); $parser = new Parser($tokens); $ast = $parser->parse(); + } catch (\Throwable $e) { + return new ScriptResult([], null, [$e->getMessage()], ErrorKind::Parse); + } - $natives = Natives::instance(); - $interpreter = new Interpreter($natives); - $natives->setInterpreter($interpreter); + $natives = Natives::instance(); + $interpreter = new Interpreter($natives); + $natives->setInterpreter($interpreter); - $interpreter->setVariables($this->variables); + $interpreter->setVariables($this->variables); - if ($this->maxOps > 0) { - $interpreter->setMaxOperations($this->maxOps); - } + if ($this->maxOps > 0) { + $interpreter->setMaxOperations($this->maxOps); + } - if ($this->timeout > 0) { - $interpreter->setDeadline(microtime(true) * 1000 + $this->timeout); - } + if ($this->timeout > 0) { + $interpreter->setDeadline(microtime(true) * 1000 + $this->timeout); + } - foreach ($this->functions as $name => $fn) { - $interpreter->registerFunction($name, $fn); - } + if ($this->outputSink !== null) { + $interpreter->setOutputSink($this->outputSink); + } + foreach ($this->functions as $name => $fn) { + $interpreter->registerFunction($name, $fn); + } + + // Run phase. + try { return $interpreter->run($ast); } catch (\Throwable $e) { - return new ScriptResult([], null, [$e->getMessage()]); + return new ScriptResult( + $interpreter->getOutput(), + null, + [$e->getMessage()], + self::classifyError($e) + ); + } + } + + private static function classifyError(\Throwable $e): ErrorKind + { + if ($e instanceof LimitsExceededException) { + return str_contains($e->getMessage(), 'timeout') + ? ErrorKind::Timeout + : ErrorKind::MaxOperations; } + return ErrorKind::Runtime; } } diff --git a/src/Lexer.php b/src/Lexer.php index 0c156f1..65ae2a8 100644 --- a/src/Lexer.php +++ b/src/Lexer.php @@ -21,6 +21,10 @@ final class Lexer 'for' => TokenType::FOR, 'in' => TokenType::IN, 'while' => TokenType::WHILE, + 'break' => TokenType::BREAK, + 'continue' => TokenType::CONTINUE, + 'try' => TokenType::TRY, + 'catch' => TokenType::CATCH, ]; private int $pos = 0; @@ -64,7 +68,7 @@ private function nextToken(): ?Token $char = $this->current(); // String - if ($char === '"' || $char === "'") { + if ($char === '"' || $char === "'" || $char === '`') { return $this->readString($char); } @@ -82,10 +86,22 @@ private function nextToken(): ?Token $this->advance(); return match ($char) { - '+' => new Token(TokenType::PLUS, '+', $startLine, $startColumn), - '-' => new Token(TokenType::MINUS, '-', $startLine, $startColumn), - '*' => new Token(TokenType::STAR, '*', $startLine, $startColumn), - '/' => new Token(TokenType::SLASH, '/', $startLine, $startColumn), + '+' => $this->match('+') + ? new Token(TokenType::PLUS_PLUS, '++', $startLine, $startColumn) + : ($this->match('=') + ? new Token(TokenType::PLUS_EQ, '+=', $startLine, $startColumn) + : new Token(TokenType::PLUS, '+', $startLine, $startColumn)), + '-' => $this->match('-') + ? new Token(TokenType::MINUS_MINUS, '--', $startLine, $startColumn) + : ($this->match('=') + ? new Token(TokenType::MINUS_EQ, '-=', $startLine, $startColumn) + : new Token(TokenType::MINUS, '-', $startLine, $startColumn)), + '*' => $this->match('=') + ? new Token(TokenType::STAR_EQ, '*=', $startLine, $startColumn) + : new Token(TokenType::STAR, '*', $startLine, $startColumn), + '/' => $this->match('=') + ? new Token(TokenType::SLASH_EQ, '/=', $startLine, $startColumn) + : new Token(TokenType::SLASH, '/', $startLine, $startColumn), '%' => new Token(TokenType::PERCENT, '%', $startLine, $startColumn), '(' => new Token(TokenType::LPAREN, '(', $startLine, $startColumn), ')' => new Token(TokenType::RPAREN, ')', $startLine, $startColumn), @@ -94,7 +110,9 @@ private function nextToken(): ?Token '[' => new Token(TokenType::LBRACKET, '[', $startLine, $startColumn), ']' => new Token(TokenType::RBRACKET, ']', $startLine, $startColumn), ',' => new Token(TokenType::COMMA, ',', $startLine, $startColumn), - '.' => new Token(TokenType::DOT, '.', $startLine, $startColumn), + '.' => ($this->current() === '.' && $this->peek(1) === '.') + ? $this->makeEllipsis($startLine, $startColumn) + : new Token(TokenType::DOT, '.', $startLine, $startColumn), ':' => new Token(TokenType::COLON, ':', $startLine, $startColumn), ';' => new Token(TokenType::SEMICOLON, ';', $startLine, $startColumn), '=' => $this->match('=') @@ -128,7 +146,14 @@ private function handleQuestion(int $startLine, int $startColumn): Token if ($this->match(':')) { return new Token(TokenType::ELVIS, '?:', $startLine, $startColumn); } - throw new \RuntimeException("Unexpected character '?' at line {$startLine}, column {$startColumn}"); + return new Token(TokenType::QUESTION, '?', $startLine, $startColumn); + } + + private function makeEllipsis(int $startLine, int $startColumn): Token + { + $this->advance(); // consume second '.' + $this->advance(); // consume third '.' + return new Token(TokenType::ELLIPSIS, '...', $startLine, $startColumn); } private function readString(string $quote): Token @@ -230,6 +255,20 @@ private function skipWhitespaceAndComments(): void while (!$this->isAtEnd() && $this->current() !== "\n") { $this->advance(); } + } elseif ($char === '/' && $this->peek(1) === '*') { + $this->advance(); // consume '/' + $this->advance(); // consume '*' + while (!$this->isAtEnd() && !($this->current() === '*' && $this->peek(1) === '/')) { + if ($this->current() === "\n") { + $this->line++; + $this->column = 0; + } + $this->advance(); + } + if (!$this->isAtEnd()) { + $this->advance(); // consume '*' + $this->advance(); // consume '/' + } } else { break; } diff --git a/src/Natives.php b/src/Natives.php index 3220680..3374aa1 100644 --- a/src/Natives.php +++ b/src/Natives.php @@ -44,7 +44,7 @@ private function registerDefaults(): void $this->functions['print'] = fn(...$args) => $this->printFn(...$args); // String functions - $this->functions['toString'] = fn($val) => $this->stringify($val); + $this->functions['toString'] = fn($val) => self::stringify($val); $this->functions['toNumber'] = fn($val) => is_numeric($val) ? (float) $val : 0.0; $this->functions['length'] = fn($val) => is_string($val) ? mb_strlen($val) : (is_array($val) ? count($val) : 0); $this->functions['substring'] = fn($str, $start, $end = null) => @@ -54,7 +54,10 @@ private function registerDefaults(): void $this->functions['trim'] = fn($str) => trim((string) $str); $this->functions['replace'] = fn($str, $old, $new) => str_replace($old, $new, (string) $str); $this->functions['split'] = fn($str, $sep) => explode($sep, (string) $str); - $this->functions['join'] = fn($arr, $sep) => implode($sep, (array) $arr); + $this->functions['join'] = fn($arr, $sep) => implode( + $sep, + array_map(static fn($e) => self::stringify($e), (array) $arr) + ); $this->functions['contains'] = fn($str, $substr) => str_contains((string) $str, $substr); $this->functions['startsWith'] = fn($str, $prefix) => str_starts_with((string) $str, $prefix); $this->functions['endsWith'] = fn($str, $suffix) => str_ends_with((string) $str, $suffix); @@ -160,25 +163,277 @@ private function registerDefaults(): void $this->functions['reduce'] = fn($arr, $fn, $init) => $this->reduceArray((array) $arr, $fn, $init); $this->functions['find'] = fn($arr, $fn) => $this->findInArray((array) $arr, $fn); $this->functions['findIndex'] = fn($arr, $fn) => $this->findIndexInArray((array) $arr, $fn); + $this->functions['some'] = fn($arr, $fn) => $this->someInArray((array) $arr, $fn); + $this->functions['every'] = fn($arr, $fn) => $this->everyInArray((array) $arr, $fn); + $this->functions['flatMap'] = fn($arr, $fn) => $this->flatMapArray((array) $arr, $fn); + + // Array aggregation / transformation + $this->functions['range'] = fn($a, $b = null) => $this->rangeFn($a, $b); + $this->functions['sum'] = fn($arr) => array_sum(array_map(fn($v) => (float) $v, (array) $arr)); + $this->functions['avg'] = function ($arr) { + $arr = (array) $arr; + if (count($arr) === 0) { + return 0.0; + } + return array_sum(array_map(fn($v) => (float) $v, $arr)) / count($arr); + }; + $this->functions['unique'] = fn($arr) => $this->uniqueFn((array) $arr); + $this->functions['flatten'] = fn($arr) => $this->flattenFn((array) $arr); + $this->functions['push'] = function ($arr, ...$items) { + $result = array_values((array) $arr); + foreach ($items as $item) { + $result[] = $item; + } + return $result; + }; + $this->functions['concat'] = function (...$arrs) { + $result = []; + foreach ($arrs as $a) { + foreach ((array) $a as $e) { + $result[] = $e; + } + } + return $result; + }; + + // Object functions + $this->functions['keys'] = fn($obj) => $this->sortedKeys((array) $obj); + $this->functions['values'] = function ($obj) { + $obj = (array) $obj; + $keys = $this->sortedKeys($obj); + return array_map(fn($k) => $obj[$k], $keys); + }; + $this->functions['entries'] = function ($obj) { + $obj = (array) $obj; + $keys = $this->sortedKeys($obj); + return array_map(fn($k) => [$k, $obj[$k]], $keys); + }; + $this->functions['has'] = fn($coll, $val) => $this->hasFn($coll, $val); + + // Number parsing + $this->functions['parseInt'] = fn($val) => $this->parseIntFn($val); + $this->functions['parseFloat'] = fn($val) => $this->parseFloatFn($val); + + // Regex + $this->functions['regexMatch'] = fn($str, $pat) => $this->regexMatchFn((string) $str, (string) $pat); + $this->functions['regexReplace'] = fn($str, $pat, $repl) => + $this->regexReplaceFn((string) $str, (string) $pat, (string) $repl); } - private function printFn(...$args): void + /** + * @return list + */ + private function rangeFn(mixed $a, mixed $b): array { - $output = implode(' ', array_map(fn($arg) => $this->stringify($arg), $args)); - if ($this->interpreter !== null) { - $this->interpreter->addOutput($output); + if ($b === null) { + $start = 0; + $end = (int) $a; + } else { + $start = (int) $a; + $end = (int) $b; } + $result = []; + for ($i = $start; $i < $end; $i++) { + $result[] = (float) $i; + } + return $result; } + /** + * Dedups while preserving first-seen order, using a type-aware key (mirrors + * Go's valueKey). + */ + private function uniqueFn(array $arr): array + { + $seen = []; + $result = []; + foreach ($arr as $v) { + $key = self::valueKey($v); + if (!isset($seen[$key])) { + $seen[$key] = true; + $result[] = $v; + } + } + return $result; + } - private function stringify(mixed $value): string + private function flattenFn(array $arr): array { - if ($value === null) + $result = []; + foreach ($arr as $v) { + if (is_array($v)) { + foreach ($v as $e) { + $result[] = $e; + } + } else { + $result[] = $v; + } + } + return $result; + } + + /** + * @return list + */ + private function sortedKeys(array $obj): array + { + $keys = array_map('strval', array_keys($obj)); + sort($keys, SORT_STRING); + return $keys; + } + + private function hasFn(mixed $coll, mixed $val): bool + { + if (is_array($coll)) { + if (array_is_list($coll)) { + $target = self::valueKey($val); + foreach ($coll as $item) { + if (self::valueKey($item) === $target) { + return true; + } + } + return false; + } + return array_key_exists((string) $val, $coll); + } + throw new \RuntimeException('has requires an object or array as first argument'); + } + + private function parseIntFn(mixed $val): float + { + if (is_int($val) || is_float($val)) { + return (float) (int) $val; + } + if (is_string($val)) { + $trimmed = trim($val); + if (!is_numeric($trimmed)) { + throw new \RuntimeException("cannot parse '{$val}' as integer"); + } + return (float) (int) (float) $trimmed; + } + throw new \RuntimeException('parseInt requires a string or number'); + } + + private function parseFloatFn(mixed $val): float + { + if (is_int($val) || is_float($val)) { + return (float) $val; + } + if (is_string($val)) { + $trimmed = trim($val); + if (!is_numeric($trimmed)) { + throw new \RuntimeException("cannot parse '{$val}' as number"); + } + return (float) $trimmed; + } + throw new \RuntimeException('parseFloat requires a string or number'); + } + + private function regexMatchFn(string $str, string $pat): bool + { + $result = @preg_match($this->compilePattern($pat), $str); + if ($result === false) { + throw new \RuntimeException("invalid regex: {$pat}"); + } + return $result === 1; + } + + private function regexReplaceFn(string $str, string $pat, string $repl): string + { + $result = @preg_replace($this->compilePattern($pat), $repl, $str); + if ($result === null) { + throw new \RuntimeException("invalid regex: {$pat}"); + } + return $result; + } + + private function compilePattern(string $pat): string + { + // Wrap the raw pattern in delimiters, escaping any occurrence of the + // delimiter within the pattern. + return '~' . str_replace('~', '\~', $pat) . '~'; + } + + /** + * Produces a comparison key for a value (mirrors Go's valueKey: type + value). + */ + private static function valueKey(mixed $v): string + { + if (is_array($v)) { + return 'array:' . serialize($v); + } + if (is_bool($v)) { + return 'bool:' . ($v ? '1' : '0'); + } + return gettype($v) . ':' . self::stringify($v); + } + + private function printFn(...$args): void + { + // Mirror Go's builtinPrint: each argument is emitted as its own line. + if ($this->interpreter === null) { + return; + } + foreach ($args as $arg) { + $this->interpreter->addOutput(self::stringify($arg)); + } + } + + /** + * Renders a KodiScript value in canonical form, identical across language + * implementations (mirrors Go's natives.Stringify): + * - integral numbers print without a trailing ".0" (3, not 3.0) + * - arrays print as "[1, 2, 3]" with unquoted string elements + * - objects print as "{a: 1, b: 2}" with keys sorted for determinism + * - booleans as true/false, null as null + */ + public static function stringify(mixed $value): string + { + if ($value === null) { return 'null'; - if (is_bool($value)) + } + if (is_bool($value)) { return $value ? 'true' : 'false'; - if (is_array($value)) - return json_encode($value, JSON_UNESCAPED_UNICODE); + } + if (is_int($value)) { + return (string) $value; + } + if (is_float($value)) { + if (is_nan($value)) { + return 'NaN'; + } + if (is_infinite($value)) { + return $value > 0 ? 'Inf' : '-Inf'; + } + // Whole numbers print without a trailing ".0". + if ($value === floor($value) && abs($value) < 1e15) { + return (string) (int) $value; + } + // Shortest round-trippable decimal (matches Go's FormatFloat(-1)). + return json_encode($value); + } + if (is_string($value)) { + return $value; + } + if (is_array($value)) { + if (array_is_list($value)) { + $parts = array_map(static fn($e) => self::stringify($e), $value); + return '[' . implode(', ', $parts) . ']'; + } + $keys = array_keys($value); + usort($keys, static fn($a, $b) => strcmp((string) $a, (string) $b)); + $parts = []; + foreach ($keys as $k) { + $parts[] = $k . ': ' . self::stringify($value[$k]); + } + return '{' . implode(', ', $parts) . '}'; + } + if (is_object($value)) { + if (method_exists($value, '__toString')) { + return (string) $value; + } + return get_class($value); + } return (string) $value; } @@ -206,11 +461,12 @@ private function sortArrayBy(array $arr, string $field, string $order): array private function filterArray(array $arr, mixed $fn): array { $result = []; + $i = 0; foreach ($arr as $item) { - $shouldInclude = $this->callFunction($fn, [$item]); - if ($shouldInclude) { + if ($this->isTruthy($this->callFunction($fn, [$item, (float) $i]))) { $result[] = $item; } + $i++; } return $result; } @@ -218,8 +474,10 @@ private function filterArray(array $arr, mixed $fn): array private function mapArray(array $arr, mixed $fn): array { $result = []; + $i = 0; foreach ($arr as $item) { - $result[] = $this->callFunction($fn, [$item]); + $result[] = $this->callFunction($fn, [$item, (float) $i]); + $i++; } return $result; } @@ -227,30 +485,89 @@ private function mapArray(array $arr, mixed $fn): array private function reduceArray(array $arr, mixed $fn, mixed $init): mixed { $acc = $init; + $i = 0; foreach ($arr as $item) { - $acc = $this->callFunction($fn, [$acc, $item]); + $acc = $this->callFunction($fn, [$acc, $item, (float) $i]); + $i++; } return $acc; } private function findInArray(array $arr, mixed $fn): mixed { + $i = 0; foreach ($arr as $item) { - if ($this->callFunction($fn, [$item])) { + if ($this->isTruthy($this->callFunction($fn, [$item, (float) $i]))) { return $item; } + $i++; } return null; } - private function findIndexInArray(array $arr, mixed $fn): int + private function findIndexInArray(array $arr, mixed $fn): float + { + $i = 0; + foreach ($arr as $item) { + if ($this->isTruthy($this->callFunction($fn, [$item, (float) $i]))) { + return (float) $i; + } + $i++; + } + return -1.0; + } + + private function someInArray(array $arr, mixed $fn): bool + { + $i = 0; + foreach ($arr as $item) { + if ($this->isTruthy($this->callFunction($fn, [$item, (float) $i]))) { + return true; + } + $i++; + } + return false; + } + + private function everyInArray(array $arr, mixed $fn): bool { - foreach ($arr as $i => $item) { - if ($this->callFunction($fn, [$item])) { - return $i; + $i = 0; + foreach ($arr as $item) { + if (!$this->isTruthy($this->callFunction($fn, [$item, (float) $i]))) { + return false; + } + $i++; + } + return true; + } + + private function flatMapArray(array $arr, mixed $fn): array + { + $result = []; + $i = 0; + foreach ($arr as $item) { + $mapped = $this->callFunction($fn, [$item, (float) $i]); + if (is_array($mapped)) { + foreach ($mapped as $e) { + $result[] = $e; + } + } else { + $result[] = $mapped; } + $i++; + } + return $result; + } + + private function isTruthy(mixed $value): bool + { + if ($value === null || $value === false) { + return false; + } + if ($value === 0 || $value === 0.0 || $value === '') { + return false; } - return -1; + return true; } private function callFunction(mixed $fn, array $args): mixed diff --git a/src/Parser.php b/src/Parser.php index f4e207f..d3448c0 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -18,6 +18,8 @@ MemberExpr, SafeMemberExpr, ElvisExpr, + TernaryExpr, + SpreadExpr, ArrayLiteral, ObjectLiteral, IndexExpr, @@ -28,6 +30,11 @@ ForStatement, WhileStatement, ReturnStatement, + BreakStatement, + ContinueStatement, + TryStatement, + ArrayDestructure, + ObjectDestructure, BlockStatement, ExpressionStatement, Program @@ -78,17 +85,117 @@ private function parseStatement(): ?Node TokenType::RETURN => $this->parseReturnStatement(), TokenType::FOR => $this->parseForStatement(), TokenType::WHILE => $this->parseWhileStatement(), + TokenType::TRY => $this->parseTryStatement(), + TokenType::BREAK => $this->parseBreakStatement(), + TokenType::CONTINUE => $this->parseContinueStatement(), TokenType::LBRACE => $this->parseBlockStatement(), - TokenType::IDENTIFIER => $this->peek(1)->type === TokenType::ASSIGN - ? $this->parseAssignmentStatement() - : $this->parseExpressionStatement(), + TokenType::FN => $this->peek(1)->type === TokenType::IDENTIFIER + ? $this->parseFunctionDeclaration() + : $this->parseExpressionStatement(), + TokenType::IDENTIFIER => match (true) { + $this->peek(1)->type === TokenType::ASSIGN => $this->parseAssignmentStatement(), + in_array($this->peek(1)->type, [ + TokenType::PLUS_EQ, + TokenType::MINUS_EQ, + TokenType::STAR_EQ, + TokenType::SLASH_EQ, + ], true) => $this->parseCompoundAssignment(), + in_array($this->peek(1)->type, [ + TokenType::PLUS_PLUS, + TokenType::MINUS_MINUS, + ], true) => $this->parseIncDecStatement(), + default => $this->parseExpressionStatement(), + }, default => $this->parseExpressionStatement(), }; } - private function parseLetStatement(): LetStatement + private function parseCompoundAssignment(): AssignmentStatement + { + $name = $this->advance()->value; + $op = match ($this->advance()->type) { + TokenType::PLUS_EQ => '+', + TokenType::MINUS_EQ => '-', + TokenType::STAR_EQ => '*', + TokenType::SLASH_EQ => '/', + }; + $right = $this->parseExpression(); + $this->consumeOptionalSemicolon(); + return new AssignmentStatement($name, new BinaryExpr($op, new Identifier($name), $right)); + } + + private function parseIncDecStatement(): AssignmentStatement + { + $name = $this->advance()->value; + $op = $this->advance()->type === TokenType::PLUS_PLUS ? '+' : '-'; + $this->consumeOptionalSemicolon(); + return new AssignmentStatement( + $name, + new BinaryExpr($op, new Identifier($name), new NumberLiteral(1.0)) + ); + } + + private function parseBreakStatement(): BreakStatement + { + $this->advance(); // consume 'break' + $this->consumeOptionalSemicolon(); + return new BreakStatement(); + } + + private function parseContinueStatement(): ContinueStatement + { + $this->advance(); // consume 'continue' + $this->consumeOptionalSemicolon(); + return new ContinueStatement(); + } + + private function parseTryStatement(): TryStatement + { + $this->advance(); // consume 'try' + $body = $this->parseBlockStatement(); + $this->expect(TokenType::CATCH, "Expected 'catch' after try block"); + + $catchVar = null; + if ($this->match(TokenType::LPAREN)) { + $catchVar = $this->expect(TokenType::IDENTIFIER, "Expected catch variable name")->value; + $this->expect(TokenType::RPAREN, "Expected ')' after catch variable"); + } + + $catchBlock = $this->parseBlockStatement(); + return new TryStatement($body, $catchVar, $catchBlock); + } + + private function parseFunctionDeclaration(): LetStatement + { + $this->advance(); // consume 'fn' + $name = $this->advance()->value; // function name + + $this->expect(TokenType::LPAREN, "Expected '(' after function name"); + $parameters = []; + if (!$this->check(TokenType::RPAREN)) { + do { + $paramName = $this->expect(TokenType::IDENTIFIER, "Expected parameter name")->value; + $parameters[] = new Identifier($paramName); + } while ($this->match(TokenType::COMMA)); + } + $this->expect(TokenType::RPAREN, "Expected ')' after parameters"); + + $body = $this->parseBlockStatement(); + return new LetStatement($name, new FunctionLiteral($parameters, $body)); + } + + private function parseLetStatement(): Node { $this->advance(); // consume 'let' + + // Destructuring: let [a, b] = expr / let {x, y} = expr + if ($this->check(TokenType::LBRACKET)) { + return $this->parseDestructure(true); + } + if ($this->check(TokenType::LBRACE)) { + return $this->parseDestructure(false); + } + $name = $this->expect(TokenType::IDENTIFIER, "Expected variable name")->value; $this->expect(TokenType::ASSIGN, "Expected '=' after variable name"); $value = $this->parseExpression(); @@ -96,6 +203,28 @@ private function parseLetStatement(): LetStatement return new LetStatement($name, $value); } + private function parseDestructure(bool $isArray): Node + { + $open = $isArray ? TokenType::LBRACKET : TokenType::LBRACE; + $close = $isArray ? TokenType::RBRACKET : TokenType::RBRACE; + + $this->expect($open, "Expected destructuring pattern"); + $names = []; + if (!$this->check($close)) { + do { + $names[] = $this->expect(TokenType::IDENTIFIER, "Expected identifier in destructuring pattern")->value; + } while ($this->match(TokenType::COMMA)); + } + $this->expect($close, "Expected closing bracket in destructuring pattern"); + $this->expect(TokenType::ASSIGN, "Expected '=' in destructuring assignment"); + $value = $this->parseExpression(); + $this->consumeOptionalSemicolon(); + + return $isArray + ? new ArrayDestructure($names, $value) + : new ObjectDestructure($names, $value); + } + private function parseAssignmentStatement(): AssignmentStatement { $name = $this->advance()->value; @@ -186,7 +315,21 @@ private function parseExpressionStatement(): ExpressionStatement private function parseExpression(): Node { - return $this->parseElvis(); + return $this->parseTernary(); + } + + private function parseTernary(): Node + { + $condition = $this->parseElvis(); + + if ($this->match(TokenType::QUESTION)) { + $consequent = $this->parseTernary(); + $this->expect(TokenType::COLON, "Expected ':' in ternary expression"); + $alternative = $this->parseTernary(); + return new TernaryExpr($condition, $consequent, $alternative); + } + + return $condition; } private function parseElvis(): Node @@ -305,7 +448,7 @@ private function parsePostfix(): Node $args = []; if (!$this->check(TokenType::RPAREN)) { do { - $args[] = $this->parseExpression(); + $args[] = $this->parseListElement(); } while ($this->match(TokenType::COMMA)); } $this->expect(TokenType::RPAREN, "Expected ')' after arguments"); @@ -446,7 +589,7 @@ private function parseArrayLiteral(): ArrayLiteral if (!$this->check(TokenType::RBRACKET)) { do { - $elements[] = $this->parseExpression(); + $elements[] = $this->parseListElement(); } while ($this->match(TokenType::COMMA)); } @@ -454,6 +597,19 @@ private function parseArrayLiteral(): ArrayLiteral return new ArrayLiteral($elements); } + /** + * Parses one element of an array literal or argument list, allowing a + * spread element (...expr). + */ + private function parseListElement(): Node + { + if ($this->check(TokenType::ELLIPSIS)) { + $this->advance(); // consume '...' + return new SpreadExpr($this->parseExpression()); + } + return $this->parseExpression(); + } + private function parseObjectLiteral(): ObjectLiteral { $this->advance(); // consume '{' @@ -461,7 +617,11 @@ private function parseObjectLiteral(): ObjectLiteral if (!$this->check(TokenType::RBRACE)) { do { - $key = $this->expect(TokenType::IDENTIFIER, "Expected property name")->value; + $keyToken = $this->current(); + if ($keyToken->type !== TokenType::IDENTIFIER && $keyToken->type !== TokenType::STRING) { + throw new \RuntimeException("Expected property name, got {$keyToken->type->value} at line {$keyToken->line}"); + } + $key = $this->advance()->value; $this->expect(TokenType::COLON, "Expected ':' after property name"); $value = $this->parseExpression(); $properties[] = ['key' => $key, 'value' => $value]; diff --git a/src/ScriptResult.php b/src/ScriptResult.php index d67df2f..368d3f5 100644 --- a/src/ScriptResult.php +++ b/src/ScriptResult.php @@ -13,7 +13,8 @@ final class ScriptResult public function __construct( public readonly array $output, public readonly mixed $value, - public readonly array $errors = [] + public readonly array $errors = [], + public readonly ErrorKind $kind = ErrorKind::None ) { } diff --git a/src/TokenType.php b/src/TokenType.php index 8dbbcf8..cce508c 100644 --- a/src/TokenType.php +++ b/src/TokenType.php @@ -22,6 +22,22 @@ enum TokenType: string case SLASH = 'SLASH'; case PERCENT = 'PERCENT'; + // Compound assignment + case PLUS_EQ = 'PLUS_EQ'; + case MINUS_EQ = 'MINUS_EQ'; + case STAR_EQ = 'STAR_EQ'; + case SLASH_EQ = 'SLASH_EQ'; + + // Increment / decrement + case PLUS_PLUS = 'PLUS_PLUS'; + case MINUS_MINUS = 'MINUS_MINUS'; + + // Spread + case ELLIPSIS = 'ELLIPSIS'; + + // Ternary + case QUESTION = 'QUESTION'; + // Comparison case EQ = 'EQ'; case NEQ = 'NEQ'; @@ -63,6 +79,10 @@ enum TokenType: string case FOR = 'FOR'; case IN = 'IN'; case WHILE = 'WHILE'; + case BREAK = 'BREAK'; + case CONTINUE = 'CONTINUE'; + case TRY = 'TRY'; + case CATCH = 'CATCH'; // Special case EOF = 'EOF'; diff --git a/tests/compliance_runner.php b/tests/compliance_runner.php index f4c750a..20a6bd0 100644 --- a/tests/compliance_runner.php +++ b/tests/compliance_runner.php @@ -17,6 +17,7 @@ 'control-flow', 'control_flow', 'data_types', + 'features', 'functions', 'higher_order', 'limits',