diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 34ad9ef..b5b26b4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -32,11 +32,9 @@ Fixes # -- [ ] All existing tests pass (`composer test`) +- [ ] Root PHP validation passes (`composer test`) +- [ ] Playground build passes (`cd playground && npm run build`) when relevant - [ ] Added new tests for the changes -- [ ] Static analysis passes (`composer test:types`) -- [ ] Code coverage remains at 100% -- [ ] Mutation testing passes (`composer test:infection`) ## Code Quality Checklist @@ -47,6 +45,7 @@ Fixes # - [ ] My changes generate no new warnings or errors - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes +- [ ] I have noted whether the change affects the root implementation, the playground, the spec, or `legacy/` ## Documentation diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8d0e265..83564c9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,224 +1,40 @@ -# Copilot Agent Instructions for Axiom Library +# Copilot Agent Instructions for Axiom ## Repository Overview -This is a proprietary PHP library for **data transformation, type validation, and expression evaluation**. The library provides a flexible framework for defining data schemas, transforming values, and evaluating complex expressions with type safety using functional programming principles. +This repository is in transition toward a spec-driven Axiom v1 DSL project. -**Repository Stats:** -- **Language:** PHP (100%) -- **Size:** ~30 source files, ~20 test files -- **Type:** Library package (`gosuperscript/axiom`) -- **Architecture:** Functional programming with monadic error handling +There are three distinct surfaces: -**Key Features:** -- Type system for numbers, strings, booleans, lists, and dictionaries -- Expression evaluation with infix and unary expressions -- Pluggable resolver pattern for different data sources -- Symbol registry for named value resolution -- Operator overloading system -- Built on Result and Option monads for error handling +- `axiom-v1-spec.md`: the primary language source of truth +- `src/` and `tests/`: the fresh root PHP implementation surface +- `playground/`: the experimental TypeScript playground -## Build and Validation Instructions +The old PHP library has been archived under `legacy/`. Do not treat `legacy/` +as the active implementation unless a task explicitly targets it. -### Environment Requirements -- **PHP:** 8.4+ (strictly enforced) -- **Extensions:** ext-intl (required) -- **Docker:** Recommended for development (8.4-cli-alpine image) +## Working Rules -### Setup Commands -**ALWAYS run these commands in the specified order:** +- Prefer the current Axiom v1 specification over legacy implementation behavior. +- Keep the root PHP implementation small and deliberate. +- Treat the playground as a validation tool, not as the language definition. +- Avoid reintroducing old library abstractions just because they exist in + `legacy/`. -1. **Install Dependencies:** - ```bash - composer install - ``` - - **Precondition:** PHP 8.4+ must be available - - **Time:** ~30-60 seconds - - **Note:** May require GitHub token for private repositories +## Validation -2. **Docker Setup (if PHP 8.4 unavailable):** - ```bash - docker compose build - docker compose run --rm php composer install - ``` - - **Time:** 2-5 minutes for initial build - - **Note:** Network connectivity required for base image - -### Testing Commands -**100% code coverage is required for all new code.** +For root PHP changes: ```bash -# Run all tests (recommended) composer test - -# Individual test suites -composer test:unit # PHPUnit tests (requires 100% coverage) -composer test:types # PHPStan static analysis (level max) -composer test:infection # Mutation testing (100% MSI required) ``` -**Test Execution Times:** -- Unit tests: ~10-30 seconds -- Static analysis: ~5-15 seconds -- Mutation testing: ~1-3 minutes - -### Code Quality Tools - -1. **PHPStan (Static Analysis):** - ```bash - vendor/bin/phpstan analyse - ``` - - Level: max (strictest) - - **Always pass** before submitting changes - -2. **Laravel Pint (Code Formatting):** - ```bash - vendor/bin/pint - ``` - - Preset: PER (PHP Evolving Recommendations) - - Auto-fixes code style issues +For playground changes: -3. **Infection (Mutation Testing):** - ```bash - vendor/bin/infection --threads=max --show-mutations - ``` - - Minimum MSI: 100% (all mutants must be killed) - - **Critical:** Tests quality validation - -### Docker Environment ```bash -# Build environment -docker compose build - -# Run commands in container -docker compose run --rm php composer install -docker compose run --rm php composer test -docker compose run --rm php vendor/bin/phpstan analyse -``` - -## Project Layout and Architecture - -### Core Architecture Patterns -- **Strategy Pattern:** Different resolvers for different source types -- **Chain of Responsibility:** DelegatingResolver chains multiple resolvers -- **Factory Pattern:** Type system creates appropriate transformations -- **Functional Programming:** Result and Option monads throughout - -### Directory Structure +cd playground +npm run build ``` -src/ -├── Exceptions/ # Custom exception classes -├── Operators/ # Operator overloading system -├── Resolvers/ # Source resolution strategies -├── Sources/ # Data source definitions -├── Types/ # Type validation and transformation -├── Source.php # Base source interface -└── SymbolRegistry.php # Named value registry - -tests/ -├── KitchenSink/ # Integration tests -├── Operators/ # Operator tests -├── Resolvers/ # Resolver tests -├── Types/ # Type system tests -└── *Test.php # Unit tests -``` - -### Key Source Files - -**Core Interfaces:** -- `src/Source.php` - Base interface for all data sources -- `src/Resolvers/Resolver.php` - Resolver interface template -- `src/Types/Type.php` - Type transformation interface - -**Main Implementation:** -- `src/Resolvers/DelegatingResolver.php` - Main resolver chain -- `src/SymbolRegistry.php` - Symbol management -- `src/Types/NumberType.php` - Example type implementation - -### Configuration Files - -**Build Configuration:** -- `composer.json` - Dependencies and scripts -- `phpunit.xml.dist` - Test configuration -- `phpstan.neon.dist` - Static analysis rules -- `infection.json5` - Mutation testing config -- `pint.json` - Code style rules - -**Docker Configuration:** -- `Dockerfile` - PHP 8.4 Alpine development environment -- `docker-compose.yaml` - Development services - -### GitHub Workflows -Located in `.github/workflows/tests.yaml`: -- **Test Job:** Runs on PHP 8.4 with matrix for prefer-lowest/prefer-stable -- **Types Job:** Static analysis validation -- **Timeout:** 5 minutes per job -- **Extensions:** Includes intl, bcmath, and testing extensions - -### Dependencies - -**Production:** -- `azjezz/psl` - PHP Standard Library utilities -- `brick/math` - Arbitrary precision mathematics -- `gosuperscript/monads` - Result/Option monad implementation -- `illuminate/container` - Dependency injection container -- `sebastian/exporter` - Value exporting utilities - -**Development:** -- `phpunit/phpunit` (v12.0+) - Testing framework -- `phpstan/phpstan` (v2.1+) - Static analysis -- `infection/infection` - Mutation testing -- `laravel/pint` - Code formatting - -### Validation Pipeline - -**Local Development Checklist:** -1. Run `composer test:types` (must pass) -2. Run `composer test:unit` (100% coverage required) -3. Run `composer test:infection` (100% MSI required) -4. Optionally run `vendor/bin/pint` for formatting - -**CI/CD Validation:** -- All tests run on PHP 8.4 only -- Matrix testing with prefer-lowest and prefer-stable -- Parallel execution of unit tests and static analysis -- **No deployment** - library package only - -### Common Patterns and Usage - -**Creating New Types:** -- Implement `Type` interface with transform(), compare(), format() -- Return `Result, Throwable>` from transform() -- Use functional approach with Result monads - -**Creating New Resolvers:** -- Implement `Resolver` interface -- Add static supports() method for source type checking -- Register in DelegatingResolver constructor array - -**Error Handling:** -- All operations return Result types (Ok/Err) -- No exceptions for normal control flow -- Option types handle null/empty values safely - -### Testing Requirements - -**Code Coverage:** 100% line coverage mandatory -**Mutation Testing:** 100% Mutation Score Indicator required -**Static Analysis:** PHPStan level max with no errors - -**Test Structure:** -- Use PHPUnit 12+ attributes (`#[Test]`, `#[CoversClass]`) -- Integration tests in `tests/KitchenSink/` -- Unit tests mirror source structure -- Use `#[CoversNothing]` for integration tests - ---- - -## Agent Instructions - -**Trust these instructions** and only search/explore when information is incomplete or incorrect. This repository requires PHP 8.4 and has strict quality requirements - always validate changes with the full test suite before submitting. -**For type system changes:** Focus on functional programming patterns and monadic error handling. -**For resolver changes:** Follow the chain of responsibility pattern and ensure proper source type checking. -**For new features:** Maintain 100% test coverage and mutation score requirements. \ No newline at end of file +Run only the relevant validations for the surfaces you change, and say what you +did not run. diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 494b64c..d7c3174 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,18 +1,14 @@ -name: Tests +name: Validation on: push: + pull_request: jobs: - test: + php: runs-on: ubuntu-latest timeout-minutes: 5 - strategy: - matrix: - php: [8.4] - stability: [prefer-lowest, prefer-stable] - - name: P${{ matrix.php }} - ${{ matrix.stability }} + name: PHP steps: - name: Checkout code @@ -21,9 +17,9 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php }} + php-version: 8.4 extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo - coverage: pcov + coverage: none - name: Setup problem matchers run: | @@ -31,44 +27,29 @@ jobs: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - name: Install dependencies - run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - - name: List Installed Dependencies - run: composer show -D - - - name: Execute unit tests - run: composer test:unit + run: composer install --prefer-dist --no-interaction - - name: Execute mutation tests - run: composer test:infection + - name: Execute PHP validation + run: composer test - types: + playground: runs-on: ubuntu-latest timeout-minutes: 5 - strategy: - matrix: - php: [8.4] - - name: Static analysis + name: Playground steps: - name: Checkout code uses: actions/checkout@v4 - - name: Setup PHP - uses: shivammathur/setup-php@v2 + - name: Setup Node + uses: actions/setup-node@v4 with: - php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo - coverage: none - - - name: Setup problem matchers - run: | - echo "::add-matcher::${{ runner.tool_cache }}/php.json" - echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + node-version: 22 - name: Install dependencies - run: composer install --prefer-dist --no-interaction + working-directory: playground + run: npm install - - name: Execute type tests - run: composer test:types + - name: Build playground + working-directory: playground + run: npm run build diff --git a/.gitignore b/.gitignore index 0b7c985..463c694 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,18 @@ testbench.yaml /docs /coverage infection.log + +# Playground +playground/dist +playground/node_modules + +# Legacy archive +legacy/vendor +legacy/build +legacy/.phpunit.cache +legacy/.phpunit.result.cache +legacy/composer.lock +legacy/infection.log + +# Claude Code +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1531e2d..0655526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,59 +1,36 @@ # Changelog -All notable changes to this project will be documented in this file. +All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +and the project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +for released artifacts. ## [Unreleased] ### Added -- Initial open source release -- MIT License -- Contributing guidelines -- Security policy -- Comprehensive documentation + +- Axiom v1 language specification +- PHP implementation plan for a future reference runtime ### Changed -- Changed license from proprietary to MIT + +- repository documentation now reflects the current spec-first project state +- playground examples were realigned with the rewritten Axiom v1 direction +- the pre-v1 PHP runtime was archived under `legacy/` +- the root `src/` and `tests/` surfaces were reset for a fresh PHP + implementation ## [1.0.0] - Initial Release ### Added -- Type system for data validation and transformation - - NumberType for numeric coercion - - StringType for string conversion - - BooleanType for boolean validation - - ListType for array/list validation - - DictType for dictionary/map validation -- Expression evaluation system - - InfixExpression for binary operations - - UnaryExpression for unary operations - - Operator overloading support -- Source system - - StaticSource for direct values - - SymbolSource for named references with namespace support - - TypeDefinition for type-aware transformations -- Resolver pattern implementation - - DelegatingResolver for chaining resolvers - - StaticResolver for static value resolution - - ValueResolver for type coercion - - InfixResolver for expression evaluation - - SymbolResolver for symbol lookup -- SymbolRegistry for managing named values with namespace support -- Functional programming approach - - Result monad for error handling - - Option monad for null handling -- Comprehensive test suite - - 100% code coverage requirement - - PHPStan level max static analysis - - Mutation testing with Infection - -### Architecture -- Strategy pattern for resolvers -- Chain of responsibility for delegating resolvers -- Factory pattern for type creation -- Functional programming with monadic error handling + +- type system for data validation and transformation +- expression evaluation system +- source system +- resolver-based architecture +- symbol registry support +- comprehensive PHP test suite [Unreleased]: https://github.com/gosuperscript/axiom/compare/v1.0.0...HEAD [1.0.0]: https://github.com/gosuperscript/axiom/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16a2457..4d29609 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,253 +1,101 @@ -# Contributing to Axiom Library +# Contributing to Axiom -Thank you for your interest in contributing to the Axiom Library! We welcome contributions from the community. +This repository is currently transitioning from an older PHP expression library +toward a spec-driven Axiom v1 DSL project. Contributions are welcome, but +changes should be grounded in the current language direction rather than the +archived library surface alone. -## How Can I Contribute? +## Before You Start -### Reporting Bugs +Read the current project anchors first: -Before creating bug reports, please check existing issues as you might find that you don't need to create one. When you create a bug report, please include as many details as possible: +- [Axiom v1 Specification](./axiom-v1-spec.md) +- [PHP Implementation Plan](./axiom-php-implementation-plan.md) +- [README](./README.md) -* **Use a clear and descriptive title** -* **Describe the exact steps to reproduce the problem** -* **Provide specific examples to demonstrate the steps** -* **Describe the behavior you observed and what you expected** -* **Include PHP version and environment details** +If a proposed change conflicts with the current spec or with the planned PHP +direction, resolve that at the documentation level first. -### Suggesting Enhancements +## Current Contribution Priorities -Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include: +- improve the Axiom v1 specification +- keep examples aligned with the specification +- tighten playground behavior and diagnostics where it helps validate the spec +- prepare the repository for a clean PHP implementation +- add conformance-style tests and fixtures -* **Use a clear and descriptive title** -* **Provide a detailed description of the suggested enhancement** -* **Explain why this enhancement would be useful** -* **List any similar features in other libraries** +## Repository Areas -### Pull Requests +### Spec and Documentation -* Fill in the required template -* Follow the PHP coding style (PER/PSR-12) -* Include tests for new functionality -* Ensure all tests pass -* Update documentation as needed -* Write clear, descriptive commit messages +Changes to the language should update the relevant documentation in the same +pull request. The specification should not drift away from the examples or from +the implementation plan. -## Development Setup - -1. **Fork and clone the repository** - ```bash - git clone https://github.com/your-username/axiom.git - cd axiom - ``` - -2. **Install dependencies** - ```bash - composer install - ``` +### Playground -3. **Run tests to ensure everything works** - ```bash - composer test - ``` +The playground is exploratory. It is useful for validating syntax and semantics, +but it is not the canonical implementation target. Keep playground changes +clearly aligned with the spec and avoid using the playground as the de facto +language definition. -### Docker Development +### Existing PHP Codebase -If you don't have PHP 8.4 installed locally, you can use Docker: +The archived code now lives in [`legacy/`](./legacy). Treat it as groundwork +and reference material, not as the active Axiom v1 runtime. -```bash -docker compose build -docker compose run --rm php composer install -docker compose run --rm php composer test -``` +### Fresh Root PHP Implementation -## Development Workflow +The active PHP implementation surface now starts in [`src/`](./src) and +[`tests/`](./tests). Keep that surface intentionally small and aligned with the +current specification. -### Code Style +## Development Setup -We use Laravel Pint for code formatting: +### PHP Codebase ```bash -vendor/bin/pint +composer install +composer test ``` -This will automatically fix code style issues according to the PER (PHP Evolving Recommendations) preset. - -### Testing - -The project requires **100% code coverage** for all new code. We use three types of testing: - -1. **Unit Tests** (PHPUnit) - ```bash - composer test:unit - ``` - * All new code must have corresponding tests - * Tests must achieve 100% line coverage - * Use PHPUnit 12+ attributes (`#[Test]`, `#[CoversClass]`) - -2. **Static Analysis** (PHPStan) - ```bash - composer test:types - ``` - * Analysis level: max (strictest) - * All code must pass without errors - -3. **Mutation Testing** (Infection) - ```bash - composer test:infection - ``` - * Required Mutation Score Indicator (MSI): 100% - * Ensures test quality and effectiveness - -### Running All Tests +### TypeScript Playground ```bash -composer test +cd playground +npm install +npm run build ``` -This runs all three test suites in sequence. - -## Coding Guidelines - -### PHP Version - -* **Minimum PHP version:** 8.4 -* Use modern PHP features (readonly properties, enums, etc.) -* Follow strict typing (`declare(strict_types=1)`) +## Change Expectations -### Architecture Principles +- keep changes focused +- update docs when semantics or repository structure changes +- prefer explicit, reviewable designs over clever shortcuts +- do not widen the language surface casually +- add or update tests when changing behavior -1. **Functional Programming** - * Use Result and Option monads for error handling - * Avoid exceptions for control flow - * Prefer immutability +## Pull Requests -2. **Type Safety** - * All methods must have type declarations - * Use PHPStan level max compliance - * Return explicit Result/Option types +- use a clear title and description +- explain whether the change affects the spec, the playground, the PHP codebase, + or more than one of them +- call out any intentional divergence from existing behavior +- include follow-up work if the change is deliberately partial -3. **Design Patterns** - * Strategy Pattern for resolvers - * Chain of Responsibility for delegating resolvers - * Factory Pattern for type creation +## Testing -### Code Structure +For PHP changes, run: -* One class per file -* Follow PSR-4 autoloading -* Keep classes focused and single-purpose -* Write self-documenting code - -### Documentation - -* Update README.md if adding new features -* Include PHPDoc blocks for complex methods -* Add code examples for new functionality -* Keep documentation clear and concise - -## Testing Best Practices - -### Writing Tests - -```php -doSomething(); - - // Assert - self::assertTrue($result->isOk()); - } -} -``` - -### Test Organization - -* Unit tests mirror source structure -* Integration tests go in `tests/KitchenSink/` -* Use `#[CoversClass]` for unit tests -* Use `#[CoversNothing]` for integration tests - -## Commit Message Guidelines - -* Use present tense ("Add feature" not "Added feature") -* Use imperative mood ("Move cursor to..." not "Moves cursor to...") -* Limit first line to 72 characters -* Reference issues and pull requests when relevant - -Examples: +```bash +composer test ``` -Add support for custom operators in expressions -Fix null handling in StringType coercion +For playground changes, run: -Update documentation for SymbolRegistry namespaces +```bash +cd playground +npm run build ``` -## Pull Request Process - -1. **Create a feature branch** - ```bash - git checkout -b feature/your-feature-name - ``` - -2. **Make your changes** - * Write code - * Add tests - * Update documentation - -3. **Ensure quality** - ```bash - vendor/bin/pint # Format code - composer test:types # Check static analysis - composer test:unit # Run unit tests - composer test:infection # Check mutation testing - ``` - -4. **Commit and push** - ```bash - git add . - git commit -m "Your descriptive message" - git push origin feature/your-feature-name - ``` - -5. **Create Pull Request** - * Fill out the PR template completely - * Link related issues - * Await code review - -6. **Address feedback** - * Make requested changes - * Push updates to the same branch - * Respond to review comments - -## Additional Resources - -* [PHP Fig - PSR Standards](https://www.php-fig.org/psr/) -* [PHPStan Documentation](https://phpstan.org/) -* [Infection Mutation Testing](https://infection.github.io/) -* [azjezz/psl Library](https://github.com/azjezz/psl) - Our standard library - -## Questions? - -Feel free to open an issue for: -* Questions about contributing -* Clarifications on architecture -* Help with development setup - -Thank you for contributing! 🎉 +If you do not run a relevant validation step, say so in the pull request. diff --git a/README.md b/README.md index cbb46ff..7f3824b 100644 --- a/README.md +++ b/README.md @@ -1,496 +1,82 @@ -# Axiom Library - -A powerful PHP library for data transformation, type validation, and expression evaluation. This library provides a flexible framework for defining data schemas, transforming values, and evaluating complex expressions with type safety. - -## Features - -- **Type System**: Robust type validation and transformation for numbers, strings, booleans, lists, and dictionaries -- **Expression Evaluation**: Support for infix expressions with custom operators -- **Match Expressions**: Unified conditional logic — if/then/else, dispatch tables, and cond-style matching -- **Compiled Expressions**: Turn a source tree into a callable you invoke with inputs -- **Resolver Pattern**: Pluggable resolver system for different data sources -- **Operator Overloading**: Extensible operator system for custom evaluation logic -- **Monadic Error Handling**: Built on functional programming principles using Result and Option types - -## Requirements - -- PHP 8.4 or higher -- ext-intl extension - -## Installation - -```bash -composer require gosuperscript/axiom -``` - -## Quick Start - -### Expressions as callables - -The top-level API is `Expression`: wrap a `Source` tree with the resolver stack you want, then invoke it with inputs like a function: - -```php - StaticResolver::class, - SymbolSource::class => SymbolResolver::class, - InfixExpression::class => InfixResolver::class, -]); -$resolver->instance(OperatorOverloader::class, new DefaultOverloader()); - -// area = PI * radius * radius -$source = new InfixExpression( - left: new SymbolSource('PI'), - operator: '*', - right: new InfixExpression( - left: new SymbolSource('radius'), - operator: '*', - right: new SymbolSource('radius'), - ), -); - -$area = new Expression( - source: $source, - resolver: $resolver, - definitions: new Definitions(['PI' => new StaticSource(3.14159)]), -); - -$area->parameters(); // ['radius'] - -$area(['radius' => 5])->unwrap()->unwrap(); // ~78.54 -$area(['radius' => 10])->unwrap()->unwrap(); // ~314.16 -``` - -The key idea: the expression's inputs are its **parameters**, passed at the call site. - -### Basic Type Transformation - -```php - StaticResolver::class, - TypeDefinition::class => ValueResolver::class, -]); - -$source = new TypeDefinition( - type: new NumberType(), - source: new StaticSource('42'), -); - -$expression = new Expression($source, $resolver); - -$expression()->unwrap()->unwrap(); // 42 (as integer) -``` - -### Inputs, Definitions, and Namespaces - -Inputs are **bindings** — passed at the call site. Stable named expressions (constants, named sub-expressions) are **definitions** — bound once when the `Expression` is constructed. Both support flat names and dotted namespaces. - -```php -use Superscript\Axiom\Definitions; -use Superscript\Axiom\Expression; -use Superscript\Axiom\Sources\StaticSource; -use Superscript\Axiom\Sources\SymbolSource; - -$expression = new Expression( - source: /* ... */, - resolver: $resolver, - definitions: new Definitions([ - // Global scope - 'version' => new StaticSource('1.0.0'), - // Namespaced scope - 'math' => [ - 'pi' => new StaticSource(3.14159), - 'e' => new StaticSource(2.71828), - ], - ]), -); - -// Flat and namespaced inputs -$expression([ - 'tier' => 'small', - 'quote' => [ - 'claims' => 3, - 'turnover' => 600000, - ], -]); -``` - -`SymbolSource` looks up by name + optional namespace: - -```php -new SymbolSource('pi', 'math'); // -> math.pi -new SymbolSource('claims', 'quote'); // -> quote.claims -new SymbolSource('version'); // -> version (global) -``` - -**Bindings shadow definitions.** A binding with a `null` value is still a real binding — it intentionally shadows any definition of the same name. - -### Match Expressions - -`MatchExpression` provides a unified way to express conditionals, dispatch tables, and cond-style matching. A match expression has a **subject** and an ordered list of **arms**. Each arm pairs a pattern with a result expression. The first matching arm wins. - -**Patterns:** - -- **LiteralPattern**: Matches via strict equality (`===`) -- **WildcardPattern**: Always matches (the default/catch-all arm) -- **ExpressionPattern**: Wraps a `Source` — resolves it and compares to the subject - -**If/then/else:** - -```php -// if quote.claims > 2 then 100 * 0.25 else 0 -new MatchExpression( - subject: new StaticSource(true), - arms: [ - new MatchArm( - new ExpressionPattern( - new InfixExpression(new SymbolSource('claims', 'quote'), '>', new StaticSource(2)), - ), - new InfixExpression(new StaticSource(100), '*', new StaticSource(0.25)), - ), - new MatchArm(new WildcardPattern(), new StaticSource(0)), - ], -); -``` - -**Dispatch table:** - -```php -// match tier { "micro" => 1.3, "small" => 1.1, _ => 1.0 } -new MatchExpression( - subject: new SymbolSource('tier'), - arms: [ - new MatchArm(new LiteralPattern('micro'), new StaticSource(1.3)), - new MatchArm(new LiteralPattern('small'), new StaticSource(1.1)), - new MatchArm(new WildcardPattern(), new StaticSource(1.0)), - ], -); -``` - -**Extensible pattern matching:** The `MatchResolver` delegates pattern evaluation to a registry of `PatternMatcher` implementations. Extension packages can register their own pattern types (e.g. `IntervalPattern` from `axiom-interval`) without modifying core axiom: - -```php -$matchers = [ - new WildcardMatcher(), - new LiteralMatcher(), - new ExpressionMatcher($resolver), - // Add custom matchers from extension packages here -]; - -$resolver->instance(MatchResolver::class, new MatchResolver($resolver, $matchers)); -``` - -## Core Concepts - -### Types - -The library provides several built-in types for data validation and coercion: - -#### NumberType -Validates and coerces values to numeric types (int/float): -- Numeric strings: `"42"` → `42` -- Percentage strings: `"50%"` → `0.5` -- Numbers: `42.5` → `42.5` - -#### StringType -Validates and coerces values to strings: -- Numbers: `42` → `"42"` -- Stringable objects: converted to string representation -- Special handling for null and empty values - -#### BooleanType -Validates and coerces values to boolean: -- Truthy/falsy evaluation -- String representations: `"true"`, `"false"` - -#### ListType and DictType -For collections and associative arrays with nested type validation. - -### Type API: Assert vs Coerce - -The `Type` interface provides two methods for value processing, following the [@azjezz/psl](https://github.com/azjezz/psl) pattern: - -- **`assert(T $value): Result>`** - Validates that a value is already of the correct type -- **`coerce(mixed $value): Result>`** - Attempts to convert a value from any type to the target type - -**When to use:** -- Use `assert()` when you expect a value to already be the correct type and want strict validation -- Use `coerce()` when you want to transform values from various input types (permissive conversion) - -**Example:** -```php -$numberType = new NumberType(); - -$numberType->assert(42); // Ok(Some(42)) -$numberType->assert('42'); // Err(TransformValueException) - -$numberType->coerce(42); // Ok(Some(42)) -$numberType->coerce('42'); // Ok(Some(42)) -$numberType->coerce('45%'); // Ok(Some(0.45)) -``` - -Both methods return `Result, Throwable>` where: -- `Ok(Some(value))` - successful validation/coercion with a value -- `Ok(None())` - successful but no value (e.g., empty strings) -- `Err(exception)` - failed validation/coercion - -### Sources - -Sources represent different ways to provide data: - -- **StaticSource**: Direct values -- **SymbolSource**: Named references resolved from the context's bindings or definitions -- **TypeDefinition**: Combines a type with a source for validation and coercion -- **InfixExpression**: Mathematical/logical expressions -- **UnaryExpression**: Single-operand expressions -- **MatchExpression**: Conditional matching with ordered arms -- **MemberAccessSource**: Chained property/array-key access - -### Resolvers - -Resolvers handle the evaluation of sources. They are **stateless** — all per-call state (bindings, definitions, the inspector, and the symbol memo) lives on a `Context` threaded through `resolve(Source, Context)`: - -- **StaticResolver**: Resolves static values -- **ValueResolver**: Applies type coercion using the `coerce()` method -- **InfixResolver**: Evaluates binary expressions -- **UnaryResolver**: Evaluates unary expressions -- **SymbolResolver**: Looks up symbols from bindings (first) then definitions (with per-context memoization) -- **MemberAccessResolver**: Evaluates property/array-key access -- **MatchResolver**: Evaluates match expressions with extensible pattern matching -- **DelegatingResolver**: Chains multiple resolvers together - -### Context - -`Context` carries everything a single call needs: - -```php -use Superscript\Axiom\Bindings; -use Superscript\Axiom\Context; -use Superscript\Axiom\Definitions; - -$context = new Context( - bindings: new Bindings(['radius' => 5]), - definitions: new Definitions(['PI' => new StaticSource(3.14159)]), - inspector: $inspector, // optional -); - -$resolver->resolve($source, $context); -``` - -`Expression::call()` / `Expression::__invoke()` build the `Context` for you from the bindings you pass. - -### Operators - -The library supports various operators through the overloader system: - -- **Binary**: `+`, `-`, `*`, `/`, `%`, `**` -- **Comparison**: `==`, `!=`, `<`, `<=`, `>`, `>=` -- **Logical**: `&&`, `||` -- **Special**: `has`, `in`, `intersects` - -### Resolution Inspector - -The `ResolutionInspector` interface provides a zero-overhead observability primitive for resolution. Resolvers accept the inspector via the `Context` and annotate metadata about their work. When no inspector is present on the context, resolvers skip annotation entirely via null-safe calls. - -**Interface:** - -```php -interface ResolutionInspector -{ - public function annotate(string $key, mixed $value): void; -} -``` - -**Built-in annotations from first-party resolvers:** - -| Resolver | Annotations | -|----------|-------------| -| `StaticResolver` | `label`: `"static(int)"`, `"static(string)"`, etc. | -| `ValueResolver` | `label`: type class name (e.g. `"NumberType"`); `coercion`: type change (e.g. `"string -> int"`) | -| `InfixResolver` | `label`: operator (e.g. `"+"`, `"&&"`); `left`, `right`, `result` | -| `UnaryResolver` | `label`: operator (e.g. `"!"`, `"-"`); `result` | -| `SymbolResolver` | `label`: symbol name (e.g. `"A"`, `"math.pi"`); `memo`: `"hit"`/`"miss"`; `result` | -| `MatchResolver` | `label`: `"match"`; `subject`: resolved subject value; `matched_arm`: index of matched arm; `result`: final value | - -**Usage:** - -```php -use Superscript\Axiom\ResolutionInspector; - -final class ResolutionContext implements ResolutionInspector -{ - private array $annotations = []; - - public function annotate(string $key, mixed $value): void - { - $this->annotations[$key] = $value; - } - - public function get(string $key): mixed - { - return $this->annotations[$key] ?? null; - } -} - -$inspector = new ResolutionContext(); -$expression->withInspector($inspector)(['radius' => 5]); - -// Annotations are available via $inspector->get('label'), etc. -``` - -## Advanced Usage - -### Custom Types - -Implement the `Type` interface to create custom data validations and coercions: - -```php -, Throwable>` types: - -- `Result::Ok(Some(value))`: Successful validation/coercion with value -- `Result::Ok(None())`: Successful validation/coercion with no value (null/empty) -- `Result::Err(exception)`: Validation/coercion failed with error - -This approach ensures: -- No exceptions for normal control flow -- Explicit handling of success/failure cases -- Type-safe null handling +```bash +cd playground +npm install +npm run build +``` -## License +## Contributing -This library is open-sourced software licensed under the [MIT license](LICENSE). +Contributions are welcome, but the repository is currently in a reshape phase. +Changes should make the project more internally consistent, especially across: -## Contributing +- the language specification +- the worked examples +- the playground behavior +- the future PHP implementation direction +- the root/legacy split -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project. +See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution guidance. ## Security -If you discover any security-related issues, please review our [Security Policy](SECURITY.md) for information on how to responsibly report vulnerabilities. +See [SECURITY.md](./SECURITY.md) for vulnerability reporting guidance. diff --git a/SECURITY.md b/SECURITY.md index f37c097..f02727c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,50 +1,50 @@ # Security Policy -## Supported Versions - -We release patches for security vulnerabilities in the following versions: +## Repository Scope -| Version | Supported | -| ------- | ------------------ | -| 1.x | :white_check_mark: | +This repository currently contains three different surfaces: -## Reporting a Vulnerability +- a fresh root PHP implementation surface in [`src/`](./src) +- an experimental TypeScript playground in [`playground/`](./playground) +- specification and planning documents for the next Axiom v1 implementation +- an archived pre-v1 PHP runtime in [`legacy/`](./legacy) -If you discover a security vulnerability within this library, please send an email to the maintainers. All security vulnerabilities will be promptly addressed. +When reporting an issue, please state which surface is affected. -**Please do not report security vulnerabilities through public GitHub issues.** +## Supported Versions -### What to Include +We currently handle security issues for: -When reporting a vulnerability, please include: +| Surface | Supported | +| ------- | --------- | +| Current root PHP implementation surface | best effort | +| Current `main` branch development work | best effort | +| Archived legacy runtime | no new feature work; security triage only if still relevant | -* A description of the vulnerability -* Steps to reproduce the issue -* Potential impact of the vulnerability -* Any potential solutions you've considered +No stable Axiom v1 reference implementation has been released from this +repository yet. -### Response Timeline +## Reporting a Vulnerability -* **Initial Response**: We aim to acknowledge receipt of your vulnerability report within 48 hours -* **Status Updates**: We will send you regular updates about our progress -* **Disclosure**: Once the vulnerability is fixed, we will work with you on responsible disclosure +Please do not report security vulnerabilities in public issues. -### Security Update Process +Send the report privately to the maintainers and include: -1. The security report is received and assigned to a primary handler -2. The problem is confirmed and a list of affected versions is determined -3. Code is audited to find any similar problems -4. Fixes are prepared for all supported releases -5. New versions are released and announced +- a clear description of the issue +- the affected surface or component +- steps to reproduce +- likely impact +- any mitigation ideas you already have -## Security Best Practices +## Response Expectations -When using this library, we recommend: +- initial acknowledgement within 48 hours where possible +- follow-up once the issue is confirmed and scoped +- coordinated disclosure after a fix is available -* Keep your dependencies up to date -* Use the latest stable version of PHP (8.4+) -* Follow the principle of least privilege -* Validate and sanitize all user input -* Use type coercion methods appropriately +## Notes -Thank you for helping keep Axiom Library and its users safe! +- specification wording issues are usually not security issues unless they can + be shown to create an exploitable implementation weakness +- playground-only problems should be identified as such +- if the issue affects the archived runtime, say so explicitly diff --git a/axiom-canonical-program-format.md b/axiom-canonical-program-format.md new file mode 100644 index 0000000..cad0eb7 --- /dev/null +++ b/axiom-canonical-program-format.md @@ -0,0 +1,631 @@ +# Axiom Canonical Program Format + +## Purpose + +This document defines a UI-facing canonical program format for Axiom. + +It is intended to be the shared interchange layer between: + +- builder UIs +- imported legacy normalized JSON +- hand-authored DSL text +- the internal PHP implementation + +The canonical format is not the parser's raw AST and it is not a PHP class dump. +It is a versioned, stable, program-level JSON model that can be persisted, +rendered, migrated, and validated independently of the engine internals. + +## Role In The System + +The intended flow is: + +1. builder UI -> canonical program JSON +2. legacy normalized JSON -> canonical program JSON +3. DSL text -> parser -> canonical program JSON +4. canonical program JSON -> internal PHP AST/analyzed program +5. analyzed program -> evaluation +6. canonical program JSON -> optional DSL printer + +This gives Axiom one semantic center without forcing structured authoring tools +to emit DSL text directly. + +## Design Constraints + +The canonical format should: + +- be versioned explicitly +- represent complete programs, not just isolated expression trees +- separate stable machine identity from human-readable names +- represent tables as first-class program artifacts +- make references explicit by target kind +- avoid leaking parser-only or PHP-only implementation details +- be easy to construct from UI builders +- be easy to migrate from the legacy normalized JSON format + +## Non-Goals + +The canonical format should not: + +- mirror the internal PHP class layout one-to-one +- expose lazy evaluation or memoization internals +- encode formatter trivia or source-text round-tripping details +- preserve legacy node names when they no longer match the Axiom v1 semantics + +## Program Shape + +```json +{ + "format": "axiom.program/v1alpha1", + "meta": { + "source": "builder-ui" + }, + "inputs": [], + "tables": [], + "types": [], + "expressions": [] +} +``` + +### Top-Level Fields + +- `format`: required version tag +- `meta`: optional non-semantic metadata +- `inputs`: declared runtime inputs +- `tables`: declared external table artifacts +- `types`: optional named types used by the program +- `expressions`: named expression declarations + +## Identity Model + +Every user-authored top-level item should have: + +- `id`: stable machine identity used by persistence and migration +- `name`: printable identifier used for DSL export and diagnostics +- `label`: optional UI display text + +Example: + +```json +{ + "id": "01KHBRRYYCRA5KPZRP8650AH5J", + "name": "isMainIndustryOnlineRetailer", + "label": "Is Main Industry Online Retailer" +} +``` + +The engine should not rely on `label` for semantics. + +## Inputs + +Inputs replace the old implicit `answers` symbol namespace. + +```json +{ + "id": "01KHBRRYYDY6X95BQC50EASGHZ", + "name": "mainIndustry", + "label": "Main Industry", + "type": { + "kind": "list", + "element": { + "kind": "named", + "name": "IndustryRef" + } + } +} +``` + +### Input Invariants + +- inputs are declared once at the program level +- expression bodies refer to inputs by `input_ref` +- the old generic `SymbolSource(namespace="answers")` should not survive in the + canonical format + +## Tables + +Tables are first-class program declarations. + +```json +{ + "id": "01KHBRRY0V6Y3H4WAKKT8X3Q9C", + "name": "industryLookup", + "label": "Industry Lookup", + "artifact": { + "kind": "csv", + "path": "lookups/01KHBRRY0V6Y3H4WAKKT8X3Q9B/01KHBRRY0V6Y3H4WAKKT8X3Q9C.csv" + } +} +``` + +### Table Invariants + +- the table declaration identifies the artifact +- expressions refer to tables by `tableId` +- repeated legacy lookup nodes that reference the same table should collapse to + one top-level table declaration + +## Expressions + +Expressions are the main authored units. + +```json +{ + "id": "01KHBRRYYCRA5KPZRP8650AH5J", + "name": "isMainIndustryOnlineRetailer", + "label": "Is Main Industry Online Retailer", + "returnType": { + "kind": "primitive", + "name": "bool" + }, + "body": {} +} +``` + +### Expression Invariants + +- every expression has exactly one body node +- expression composition should use `call`, not generic symbol lookup +- the canonical format is declaration-oriented, not just a bag of anonymous + nodes + +## Type Nodes + +The UI-facing type layer should stay small and explicit. + +### Primitive Type + +```json +{ "kind": "primitive", "name": "bool" } +``` + +Supported primitive names should reflect the Axiom v1 spec, for example: + +- `number` +- `string` +- `bool` + +### Named Type + +```json +{ "kind": "named", "name": "IndustryRef" } +``` + +### List Type + +```json +{ + "kind": "list", + "element": { "kind": "named", "name": "IndustryRef" } +} +``` + +### Record Type + +```json +{ + "kind": "record", + "fields": [ + { "name": "industry", "type": { "kind": "named", "name": "IndustryRef" } }, + { "name": "turnover", "type": { "kind": "primitive", "name": "number" } } + ] +} +``` + +### Variant Type + +```json +{ + "kind": "variant", + "cases": [ + { "tag": "accept" }, + { + "tag": "refer", + "payload": { + "kind": "record", + "fields": [ + { "name": "reason", "type": { "kind": "primitive", "name": "string" } } + ] + } + } + ] +} +``` + +## Core Expression Nodes + +The initial canonical node set should cover the most common builder operations. + +### `literal` + +```json +{ "kind": "literal", "value": "DRI-749" } +``` + +### `list` + +```json +{ + "kind": "list", + "elements": [ + { "kind": "literal", "value": "DRI-749" }, + { "kind": "literal", "value": "DRI-1793" } + ] +} +``` + +### `record` + +```json +{ + "kind": "record", + "fields": [ + { "name": "industry", "value": { "kind": "literal", "value": "DRI-749" } }, + { "name": "turnover", "value": { "kind": "literal", "value": "500000" } } + ] +} +``` + +### `input_ref` + +```json +{ + "kind": "input_ref", + "inputId": "01KHBRRYYDY6X95BQC50EASGHZ" +} +``` + +Use `input_ref` for declared runtime inputs only. + +### `local_ref` + +```json +{ + "kind": "local_ref", + "name": "row" +} +``` + +Use `local_ref` for bound names introduced by `match` or table queries. + +### `call` + +```json +{ + "kind": "call", + "expressionId": "01KHBRRYYCRA5KPZRP8650AH5J", + "arguments": [] +} +``` + +Use `call` when one named expression depends on another named expression. + +### `field` + +```json +{ + "kind": "field", + "object": { "kind": "local_ref", "name": "row" }, + "name": "Product Group" +} +``` + +### `unary` + +```json +{ + "kind": "unary", + "operator": "not", + "operand": { "kind": "input_ref", "inputId": "01..." } +} +``` + +### `binary` + +```json +{ + "kind": "binary", + "operator": "intersects", + "left": { "kind": "input_ref", "inputId": "01..." }, + "right": { "kind": "list", "elements": [] } +} +``` + +Typical UI-facing binary operators include: + +- `==` +- `!=` +- `>` +- `>=` +- `<` +- `<=` +- `and` +- `or` +- `+` +- `-` +- `*` +- `/` +- `in` +- `intersects` + +### `match` + +```json +{ + "kind": "match", + "subject": { "kind": "input_ref", "inputId": "01..." }, + "arms": [ + { + "pattern": { "kind": "literal_pattern", "value": "micro" }, + "value": { "kind": "literal", "value": "1.3" } + }, + { + "pattern": { "kind": "wildcard_pattern" }, + "value": { "kind": "literal", "value": "1.0" } + } + ] +} +``` + +### `annotate` + +```json +{ + "kind": "annotate", + "type": { "kind": "list", "element": { "kind": "named", "name": "IndustryRef" } }, + "expression": { "kind": "list", "elements": [] } +} +``` + +`annotate` exists for cases where the authoring surface needs to pin a type +explicitly. It should not be used as a generic wrapper around every expression. + +### `table_query` + +```json +{ + "kind": "table_query", + "mode": "first", + "tableId": "01KHBRRY0V6Y3H4WAKKT8X3Q9C", + "binding": "row", + "where": { + "kind": "binary", + "operator": "==", + "left": { + "kind": "field", + "object": { "kind": "local_ref", "name": "row" }, + "name": "ID" + }, + "right": { + "kind": "input_ref", + "inputId": "01KHBRRYYDY6X95BQC50EASGHZ" + } + }, + "select": { + "kind": "field", + "object": { "kind": "local_ref", "name": "row" }, + "name": "Product Group" + } +} +``` + +`mode` should begin with: + +- `first` +- `all` + +This keeps the canonical format close to the current legacy lookup behavior +while still modeling tables as first-class declarations. + +## Pattern Nodes + +The initial pattern set should remain small: + +- `literal_pattern` +- `wildcard_pattern` +- `tag_pattern` + +If expression-based guard patterns are needed in the canonical format, define +them explicitly rather than carrying legacy pattern node names forward. + +## Canonical Example + +The following shows a simplified rewrite of two legacy variables into the +canonical format. + +```json +{ + "format": "axiom.program/v1alpha1", + "inputs": [ + { + "id": "01KHBRRYYDY6X95BQC50EASGHZ", + "name": "mainIndustry", + "type": { + "kind": "list", + "element": { + "kind": "named", + "name": "IndustryRef" + } + } + } + ], + "tables": [ + { + "id": "01KHBRRY0V6Y3H4WAKKT8X3Q9C", + "name": "industryLookup", + "artifact": { + "kind": "csv", + "path": "lookups/01KHBRRY0V6Y3H4WAKKT8X3Q9B/01KHBRRY0V6Y3H4WAKKT8X3Q9C.csv" + } + } + ], + "expressions": [ + { + "id": "01KHBRRYYCRA5KPZRP8650AH5J", + "name": "isMainIndustryOnlineRetailer", + "label": "Is Main Industry Online Retailer", + "returnType": { "kind": "primitive", "name": "bool" }, + "body": { + "kind": "binary", + "operator": "intersects", + "left": { + "kind": "input_ref", + "inputId": "01KHBRRYYDY6X95BQC50EASGHZ" + }, + "right": { + "kind": "list", + "elements": [ + { "kind": "literal", "value": "DRI-749" }, + { "kind": "literal", "value": "DRI-1793" }, + { "kind": "literal", "value": "DRI-1794" }, + { "kind": "literal", "value": "DRI-1795" } + ] + } + } + }, + { + "id": "01KHBRRYYCRA5KPZRP8650AH5M", + "name": "industryGroupMainIndustry", + "label": "Industry Group Lookup - Main Industry", + "body": { + "kind": "table_query", + "mode": "first", + "tableId": "01KHBRRY0V6Y3H4WAKKT8X3Q9C", + "binding": "row", + "where": { + "kind": "binary", + "operator": "==", + "left": { + "kind": "field", + "object": { "kind": "local_ref", "name": "row" }, + "name": "ID" + }, + "right": { + "kind": "input_ref", + "inputId": "01KHBRRYYDY6X95BQC50EASGHZ" + } + }, + "select": { + "kind": "field", + "object": { "kind": "local_ref", "name": "row" }, + "name": "Product Group" + } + } + } + ] +} +``` + +## Migration From Legacy Normalized JSON + +### Top-Level Legacy Shape + +Current legacy payloads appear to use: + +```json +{ + "variables": [ ... ] +} +``` + +Migration should lift this into a full program: + +- variable list -> `expressions` +- `answers` references -> `inputs` +- repeated `LookupSource` tables -> deduplicated `tables` + +### Legacy Node Mapping + +- `StaticValue` -> `literal` +- `ListSource` -> `list` +- `SymbolSource(namespace="answers")` -> `input_ref` +- `SymbolSource` pointing to another variable -> `call` +- `InfixExpression` -> `binary` +- `TypeDefinition` -> remove where redundant, otherwise `annotate` +- `LookupSource` -> top-level `table` + `table_query` + +### `SymbolSource` Rule + +The old generic symbol node should not survive in the canonical format. + +Use: + +- `input_ref` for runtime inputs +- `local_ref` for locally bound names +- `call` for dependencies on other named expressions + +This makes the reference target explicit and improves static analysis. + +### `TypeDefinition` Rule + +Legacy `TypeDefinition` often acts as a general wrapper rather than a meaningful +type annotation. The importer should: + +- drop it when the type is already implied by the surrounding node +- preserve it as `annotate` only when the type information is semantically + important + +### `LookupSource` Rule + +`LookupSource` is not a core expression node in the rewritten v1 language. It +is a legacy packaged lookup abstraction that should be lowered into: + +- a table declaration +- a table query expression + +Legacy fields: + +- `id`, `path` -> table declaration +- `aggregate` -> `table_query.mode` +- `filters` -> `table_query.where` +- `columns` -> `table_query.select` + +When `columns` contains exactly one field, the importer can select that field +directly. If multiple columns are requested, the importer should produce a +record-valued selection instead of inventing bespoke lookup semantics. + +## UI Authoring Guidance + +The builder UI should target the canonical format directly. + +Recommended builder primitives: + +- choose input +- call expression +- literal value +- list literal +- binary comparison/arithmetic +- boolean combination +- field selection from bound row +- table query with filters +- match with ordered arms +- explicit type annotation only when needed + +The UI should not need to know about: + +- lexer tokens +- parser recovery +- PHP AST classes +- engine memoization or runtime internals + +## Relationship To Internal PHP AST + +The PHP engine should hydrate this canonical format into internal classes under +`src/`. + +Those internal classes may evolve as implementation details change. + +The canonical format should remain the stable contract for: + +- UI authoring +- persistence +- import/export +- migration +- test fixtures + +## Suggested Next Steps + +1. Freeze the legacy format as an import-only compatibility layer. +2. Implement a PHP importer from legacy JSON into the canonical format. +3. Add canonical-format fixtures under `tests/Conformance/`. +4. Implement the internal hydrator from canonical JSON into PHP AST objects. +5. Add a DSL printer after the canonical format and internal AST are stable. diff --git a/axiom-php-implementation-plan.md b/axiom-php-implementation-plan.md new file mode 100644 index 0000000..79237f3 --- /dev/null +++ b/axiom-php-implementation-plan.md @@ -0,0 +1,662 @@ +# Axiom PHP Implementation Plan + +## Purpose + +This document describes a concrete plan for implementing the rewritten Axiom v1 +specification in PHP as the canonical runtime. + +The goal is not to keep the current playground and PHP library loosely aligned. +The goal is to make the PHP implementation the reference engine for: + +- parsing +- type checking +- artifact validation +- deterministic evaluation +- extension loading +- conformance testing + +## Why PHP + +PHP is a good fit for the real Axiom implementation because: + +- all current applications are already in PHP +- Axiom evaluation is primarily a server-side concern +- integration with product apps, persistence, deployment, and artifacts will be simpler +- `brick/math` is already present and gives a solid base for exact decimal semantics +- `brick/money` can be added later for the standardized money extension + +The browser playground can remain as: + +- a non-canonical prototype +- a thin client that calls the PHP engine +- or a disposable implementation once PHP becomes authoritative + +## Current Starting Point + +The repository now has: + +- a fresh root PHP scaffold under `src/` and `tests/` +- an archived pre-v1 runtime under `legacy/` +- a TypeScript playground under `playground/` + +Relevant archived legacy areas: + +- `legacy/src/Resolvers/*` +- `legacy/src/Patterns/*` +- `legacy/src/Operators/*` +- `legacy/src/Types/*` +- `legacy/src/Sources/*` + +Important mismatch between the archived runtime and the rewritten spec: + +- the legacy runtime includes `DictType` +- the legacy runtime includes `NullOverloader` +- the legacy runtime is expression-centric, not program/AST/typechecker-centric +- the legacy runtime does not model tables as ordered validated artifacts +- the legacy runtime does not enforce v1 semantics like no indexing and static division safety + +Because of that, the new PHP implementation should not be developed as an +incremental patch to the old mental model. The fresh root scaffold should stay +conceptually separate, with selective reuse from `legacy/` only where it still +fits the v1 design. + +## Recommended Strategy + +Build the Axiom v1 engine under a fresh root package structure in `src/`, then +migrate reusable pieces from `legacy/` deliberately if they still make sense. + +Recommended namespace: + +```php +Superscript\Axiom\ +``` + +The old runtime is already archived under `legacy/`, so the fresh root package +can use the direct namespace without inheriting conflicting concepts. + +## Guiding Constraints From The v1 Spec + +The PHP implementation must preserve these rules from the current spec: + +- exact decimal `number`, not float semantics +- `non_zero` refinement and static division safety +- no `dict(T)` core type +- no indexing +- records as the only object-shaped structured core value +- tables as ordered immutable artifact-backed lists +- no silent fallback semantics +- lazy evaluation with memoization +- narrow extension model: literals, types, operators, intrinsics, total coercions +- extension composition must not depend on registration order + +## Target Architecture + +The canonical PHP implementation should have these subsystems: + +1. `Lexer` +2. `Parser` +3. `AST` +4. `NameResolver` +5. `TypeSystem` +6. `TypeChecker` +7. `ArtifactValidator` +8. `InputValidator` +9. `Evaluator` +10. `Extensions` +11. `Diagnostics` +12. `Conformance` + +Recommended package layout: + +```text +src/Ast/ +src/Lexing/ +src/Parsing/ +src/Names/ +src/Types/ +src/Typing/ +src/Artifacts/ +src/Input/ +src/Eval/ +src/Extensions/ +src/Diagnostics/ +src/Runtime/ +src/Conformance/ +src/Values/ +``` + +## Core Data Model + +Do not use raw PHP arrays as the semantic model for Axiom values. + +Use explicit value objects: + +- `DecimalValue` +- `StringValue` +- `BooleanValue` +- `ListValue` +- `RecordValue` +- `VariantValue` +- extension values such as `MoneyValue` + +Use explicit type objects: + +- `NumberType` +- `NonZeroType` +- `StringType` +- `BooleanType` +- `ListType` +- `InlineRecordType` +- `NamedRecordType` +- `VariantType` +- `NamedTypeReference` +- extension types such as `MoneyType` + +Reason: + +- PHP arrays blur list, record, and map semantics +- the v1 spec explicitly does not +- explicit value and type objects will prevent semantic drift + +## Runtime Program Model + +The main production unit should be an analyzed program, not raw source text. + +Recommended objects: + +- `ProgramBundle` + - source text + - table artifacts + - enabled extensions +- `ParsedProgram` + - AST + - parse diagnostics +- `AnalyzedProgram` + - AST + - symbol tables + - resolved declarations + - typed nodes + - validated table schemas + - recursion analysis + - extension validation +- `Runtime` + - `evaluate(string $expressionName, array $input): Value` + +The engine should parse and analyze once, then evaluate many times. + +## Parser Approach + +Use a handwritten recursive-descent parser in PHP. + +Why: + +- the grammar is small and controlled +- diagnostics matter +- grammar changes are still likely during implementation +- a handwritten parser is easier to evolve than a generated one here + +Deliverables: + +- token definitions +- lexer with source locations +- AST node classes +- parser with declaration-level recovery +- parser diagnostics + +## Type System And Static Semantics + +The type checker is the heart of the implementation. It should be a real phase, not +runtime checking disguised as evaluation. + +Responsibilities: + +- declaration collection +- namespace resolution +- named type registration +- inline record shape checking +- named-vs-inline assignability rules +- variant constructor resolution +- variant pattern validation +- match exhaustiveness +- collection form typing +- table row typing +- recursion detection +- static division safety +- extension hook type participation + +The output should include resolved type information for every expression node. + +## Numeric Semantics + +Use `brick/math` and never evaluate Axiom `number` using PHP `float`. + +Rules: + +- literals parse to `BigDecimal` +- arithmetic uses exact decimal operations +- serialization at boundaries uses strings +- `non_zero` is a static type property, not a runtime convention + +Do not defer this. If the engine starts with native PHP floats, the implementation +will drift from the spec immediately. + +## Tables And Artifacts + +Tables are a first-class language feature and should be modeled as such. + +Recommended components: + +- `ArtifactRepository` +- `TableSchema` +- `ValidatedTable` +- `ArtifactValidator` +- `TableLoader` + +Rules to enforce: + +- artifacts are required for declared tables +- rows must conform to declared record shape +- artifact row order is preserved +- evaluation sees tables as immutable ordered lists + +The runtime may add internal indexes later, but only as an optimization. + +## Input Validation + +Separate boundary validation from evaluation. + +Recommended components: + +- `InputValidator` +- `InputCoercer` only if coercions are explicitly total and spec-approved + +Rules: + +- validate target expression parameters before evaluation +- reject invalid input before evaluation begins +- do not silently manufacture domain values +- keep parsing/normalization concerns at the boundary, not in business logic + +## Evaluation + +Start with direct AST evaluation. + +Recommended components: + +- `EvaluationContext` +- `Scope` +- `Thunk` +- `MemoizedBinding` +- `Evaluator` + +Required behavior: + +- lazy arguments +- lazy `where` bindings +- one computation per binding per scope +- no mutation +- no side effects +- deterministic list/table iteration order + +Do not compile to PHP code in the first implementation. + +## Extensions + +The extension system should match the narrowed v1 spec. + +Allowed extension capabilities: + +- literal recognition +- custom type families +- operator typing/runtime rules +- intrinsic overloads +- total coercions + +Disallowed in v1: + +- new control-flow forms +- new keywords +- new pattern syntax +- external data access +- registration-order-dependent behavior + +Recommended extension interfaces: + +- `LiteralExtension` +- `TypeExtension` +- `OperatorExtension` +- `IntrinsicExtension` +- `CoercionExtension` + +Recommended registry behavior: + +- validate overlaps at program load time +- fail fast on extension conflicts +- make extension composition deterministic + +## Standardized Money Extension + +The money extension should be implemented after the core engine is stable. + +Required additions: + +- add `brick/money` dependency +- `MoneyType` +- `MoneyValue` +- money literal parsing +- money arithmetic rules +- money comparison rules +- intrinsic overloads for `round`, `sum`, and other approved intrinsics + +Money should remain an extension, not a core type. + +## Conformance Testing + +This is critical. The spec, PHP engine, and playground will drift unless there is +one shared test suite. + +Build a conformance suite with: + +- lexer tests +- parser tests +- AST snapshot tests +- type-check tests +- negative diagnostics tests +- table artifact validation tests +- evaluation tests +- extension tests + +Recommended structure: + +```text +tests/Conformance/Lexing/ +tests/Conformance/Parsing/ +tests/Conformance/Typing/ +tests/Conformance/Artifacts/ +tests/Conformance/Eval/ +tests/Conformance/Extensions/ +``` + +The examples in `axiom-v1-spec.md` should become executable conformance tests. + +## Suggested Delivery Phases + +### Phase 0 - Project Setup + +Goals: + +- create `Superscript\Axiom\V1\` structure +- decide coexistence strategy with current runtime +- add fixtures and baseline test harness + +Deliverables: + +- directory structure +- base diagnostics types +- source location model +- test conventions + +Acceptance: + +- empty v1 package compiles +- PHPUnit and PHPStan include the new namespace cleanly + +### Phase 1 - AST, Lexer, Parser + +Goals: + +- parse the rewritten core grammar + +Deliverables: + +- token model +- lexer +- AST classes +- parser +- parse diagnostics + +Acceptance: + +- all spec examples parse +- parser recovery works at declaration boundaries + +### Phase 2 - Declaration And Name Resolution + +Goals: + +- resolve types, namespaces, expressions, and tables + +Deliverables: + +- declaration registry +- namespace-aware symbol resolution +- duplicate-name diagnostics + +Acceptance: + +- unresolved symbols are detected cleanly +- duplicate declarations are rejected + +### Phase 3 - Type System And Core Type Checking + +Goals: + +- implement the core v1 type system + +Deliverables: + +- type objects +- assignability rules +- record and variant checking +- constructor resolution +- match exhaustiveness +- collection-form typing + +Acceptance: + +- spec examples type-check or fail with correct diagnostics + +### Phase 4 - Numeric Refinement And Division Safety + +Goals: + +- implement `non_zero` and static division safety + +Deliverables: + +- refined numeric typing +- narrowing for approved forms +- division diagnostics + +Acceptance: + +- unsafe division is rejected statically +- safe division cases from conformance tests pass + +### Phase 5 - Artifacts And Input Validation + +Goals: + +- make tables and validated inputs real runtime boundaries + +Deliverables: + +- table schemas +- artifact loading and validation +- input validator + +Acceptance: + +- invalid artifacts fail before evaluation +- invalid inputs fail before evaluation +- table iteration preserves artifact order + +### Phase 6 - Evaluator + +Goals: + +- evaluate analyzed programs deterministically + +Deliverables: + +- lazy evaluator +- memoization model +- runtime values +- variant and record runtime representation + +Acceptance: + +- core spec example evaluates correctly +- evaluator uses no float arithmetic for `number` + +### Phase 7 - Extensions + +Goals: + +- add the narrow v1 extension model + +Deliverables: + +- extension registry +- overlap validation +- literal/type/operator/intrinsic extension hooks + +Acceptance: + +- extension conflicts fail at load time +- core language behavior does not depend on extension order + +### Phase 8 - Standardized Money Extension + +Goals: + +- implement the money extension against the stabilized extension API + +Deliverables: + +- money literal parser +- money type/value +- money arithmetic and comparisons +- intrinsic overloads + +Acceptance: + +- money example passes as conformance tests +- cross-currency violations fail statically + +### Phase 9 - Tooling Integration + +Goals: + +- connect the PHP engine to the rest of the developer workflow + +Deliverables: + +- API for evaluating programs from applications +- optional HTTP endpoint for playground/editor integration +- fixture export for cross-runtime comparison + +Acceptance: + +- playground can be backed by PHP if desired +- applications can evaluate analyzed programs without reparsing every request + +## Recommended Near-Term Decisions + +Before implementation starts, lock these in explicitly: + +1. The PHP engine is the canonical implementation. +2. The current TS playground is non-canonical. +3. v1 lives under a new namespace/package boundary. +4. Existing conflicting concepts such as `DictType` and `NullOverloader` are not + part of the new engine. +5. The spec examples become conformance fixtures. + +## Main Risks + +### 1. Reusing Too Much Of The Old Runtime + +Risk: + +- carrying over `dict`/`null`/resolver assumptions that now conflict with the spec + +Mitigation: + +- implement v1 in a new namespace with explicit imports from old code only when justified + +### 2. Cheating On Decimal Semantics + +Risk: + +- accidental use of PHP floats + +Mitigation: + +- wrap `BigDecimal` in a dedicated numeric value model from day one + +### 3. Type Checker Creep + +Risk: + +- pushing semantics into the evaluator because static analysis feels slower to build + +Mitigation: + +- require that every major semantic feature ships with type-check tests first + +### 4. Playground Drift + +Risk: + +- TypeScript and PHP becoming two dialects again + +Mitigation: + +- make the PHP engine canonical and test the playground against PHP outputs where needed + +## Recommended First Implementation Slice + +If the aim is to get a serious vertical slice quickly, build this first: + +- expression declarations +- record and variant types +- `if` +- subject `match` +- `where` +- `list(T)` +- `sum`, `product`, `round`, `len`, `flatten` +- tables +- exact decimal numbers +- `non_zero` +- direct evaluation + +Defer until the slice is stable: + +- namespaces beyond basics +- mixed call style ergonomics +- extension system +- money +- rich editor integration + +## Definition Of Done For v1 PHP Engine + +The PHP implementation is ready to be called the Axiom v1 reference runtime when: + +- it parses the normative grammar +- it enforces the core type system from the spec +- it validates inputs and table artifacts before evaluation +- it evaluates deterministically using exact decimals +- it rejects unsafe division statically +- it loads non-overlapping extensions deterministically +- it passes a conformance suite derived from the spec and examples + +## Next Step + +The root package skeleton now exists. The next concrete step should be: + +1. populate `tests/Conformance/` with the first fixture-driven cases +2. implement lexer tokenization and source locations +3. implement AST construction and parser diagnostics + +That keeps the implementation on a stable path without prematurely coupling it +to the older expression resolver model. diff --git a/axiom-v1-spec.md b/axiom-v1-spec.md new file mode 100644 index 0000000..9224f06 --- /dev/null +++ b/axiom-v1-spec.md @@ -0,0 +1,1426 @@ +# Axiom v1 - Language Specification + +Axiom is a statically typed expression language for declarative business computation. +It is designed for authored logic such as pricing, eligibility, financial calculation, +classification, and product rules. + +Axiom is not a general-purpose programming language. It is a small, reviewable +language for computing values from typed inputs and versioned table artifacts. + +The core guarantee of Axiom v1 is: + +> If a program parses, type-checks, all referenced table artifacts validate, and +> its runtime inputs validate, then evaluating a target expression deterministically +> produces a value of the declared result type without runtime exceptions. + +This document is normative unless a section is explicitly marked as informative. +The grammar in Section 13 is normative for syntax. + +--- + +## 1. Design Principles + +1. **Expressions are the unit of authorship.** An Axiom program is a collection of + named, typed expressions. Each expression evaluates to exactly one value. + +2. **Reviewability beats cleverness.** The language prefers explicit, local + constructs over compact or highly abstract ones. + +3. **The core is pure.** Expressions, operators, constructors, collection forms, + and expression calls are deterministic and side-effect free. + +4. **Tables are core.** Versioned table artifacts are part of the language model. + Arbitrary external IO is not. + +5. **Static proof first, runtime execution second.** Parsing, type checking, table + validation, and input validation establish the preconditions for safe execution. + +6. **The language stays narrow.** Axiom v1 intentionally excludes mutation, loops, + recursion as a control structure, exceptions, implicit IO, dynamic maps, and + syntax-extending plugins. + +7. **Named types are nominal.** Named variant and record types are identified by + name, not by accidental structural equivalence. + +8. **Meaningful failure is modeled in values.** Domain outcomes such as decline, + referral, or non-availability are represented in variants, not in an out-of-band + error channel. + +9. **No null.** There is no `null` type in Axiom v1. + +10. **Defaults must be explicit.** Axiom does not silently substitute domain values + for invalid or absent data. Fallbacks must be authored in the program. + +11. **General-purpose constructs over domain-specific ones.** Core language features + should be useful beyond a single business domain. + +--- + +## 2. Non-Goals + +Axiom v1 does not provide: + +- mutation or assignment statements +- loops (`for`, `while`) +- recursion as a user-facing control structure +- exceptions or `try/catch` +- implicit IO or side effects +- dynamic maps or dictionary types +- indexing (`xs[0]`, `record["field"]`) +- type coercions +- `null` or optional/nullable types (`T?`) +- syntax-extending plugins +- string interpolation or free-form text templating +- general-purpose higher-order functions (`map`, `filter`, `reduce`) + +--- + +## 3. Program Structure + +An Axiom program is a collection of top-level declarations. There is no `main` +expression. Any named expression may be targeted for evaluation. + +Top-level declarations are: + +- expression declarations +- type declarations +- namespace declarations +- table declarations + +### 3.1 Expression Declarations + +A named expression has a name, zero or more typed parameters, a declared return +type, and a body expression. + +```axiom +BasePremium(sum_insured: number, rate: number): number { + sum_insured / 1000 * rate +} +``` + +Zero-argument expressions omit the parameter list entirely: + +```axiom +AdminFee: number { + 35 +} +``` + +At use sites, zero-argument expressions are referenced by name, not called with +empty parentheses: + +```axiom +AdminFee +``` + +Parameters may use inline record shapes: + +```axiom +Rating(quote: { sum_insured: number, trade: string }): number { + quote.sum_insured / 1000 * 1.5 +} +``` + +Or named record types: + +```axiom +Rating(exposure: Exposure, limit: number): number { + exposure.turnover / 1000 * limit +} +``` + +Return types are required in v1. This keeps variant construction and expression +interfaces explicit. + +### 3.2 Type Declarations + +Type declarations bind a name directly to a type body. They do not use `=`. + +#### Variant types + +A variant type is a closed set of tagged alternatives. Each alternative has a tag +and an optional record payload. + +```axiom +type CoverOutcome + rated { + key: string, + name: string, + premium: number, + } + | not_available { reason: string } +``` + +Payload-less variants omit the payload: + +```axiom +type Status active | suspended | cancelled +``` + +#### Record types + +A record type declares a fixed set of named fields: + +```axiom +type Exposure { + industry: string, + turnover: number, + employees: number, +} +``` + +Records are the only object-shaped structured value in Axiom v1. + +### 3.3 Namespace Declarations + +Namespaces group related types and expressions. + +```axiom +namespace Industry { + BaseRate(industry: string): number { + match industry { + "DRI-945" => 0.85, + _ => 1.00, + } + } +} +``` + +Namespace members are accessed with qualified names: +`Industry.BaseRate("DRI-945")`. + +Namespaces may contain: + +- expression declarations +- type declarations + +### 3.4 Table Declarations + +A table declaration defines a named, typed, immutable list of records. The data +comes from a companion artifact bundled with the program version. + +```axiom +table industry_config: list({ + code: string, + base_rate: number, + min_premium: number, +}) +``` + +Or, using a named record type: + +```axiom +type IndustryRow { + code: string, + base_rate: number, + min_premium: number, +} + +table industry_config: list(IndustryRow) +``` + +Tables serve two purposes: + +1. **Schema declaration.** The type checker validates field access against the + declared row shape. +2. **Artifact binding.** The runtime loads the companion artifact, validates every + row against the declared schema, and exposes the rows as an immutable list. + +#### Table semantics + +- A table is semantically an ordered immutable list of records. +- Artifact row order is part of program semantics. +- `match row in table { ... }` returns the first matching row in artifact order. +- `collect row in table { ... }` preserves artifact order. +- Implementations may optimize physical storage or access strategy, but they must + produce the same result as evaluating against the table as an in-memory immutable + list in artifact order. + +#### Artifacts + +A program bundle consists of source code plus table artifacts. For a program to +load successfully: + +- every referenced artifact must be present +- every row must conform to the declared row schema +- the artifact must preserve row order + +Artifact validation failures are load-time failures, not evaluation-time failures. + +--- + +## 4. Type System + +### 4.1 Primitive Types + +| Type | Description | +|------|-------------| +| `number` | Arbitrary-precision decimal number | +| `non_zero` | Numeric value proven not equal to `0` | +| `string` | Text value | +| `bool` | Boolean value | + +`number` is exact decimal, not IEEE 754 float. Conforming implementations must use +arbitrary-precision decimal semantics. + +### 4.2 Compound Types + +| Type | Description | +|------|-------------| +| `list(T)` | Ordered collection of elements of type `T` | + +There is no core map or dictionary type in v1. + +### 4.3 Named Types + +Named record types and named variant types are nominal. + +```axiom +type Exposure { industry: string, turnover: number } + +type Decision + approved { premium: number } + | declined +``` + +### 4.4 Extension Types + +Extensions may define additional parameterized types such as `money(GBP)`. +Extension types are not part of the core type hierarchy unless explicitly imported +through the extension mechanism described in Section 15. + +### 4.5 Type Assignability + +Rules: + +1. **Primitives.** A type is assignable to itself. +2. **Numeric refinement.** `non_zero` is assignable to `number`. +3. **Lists.** `list(T)` is assignable to `list(U)` when `T` is assignable to `U`. +4. **Named variants.** Same declared name means same type. +5. **Named records.** Two differently named record types are not assignable to each + other, even if their fields are identical. +6. **Inline record shapes.** Inline record values are assignable to inline record + shapes when all required fields exist with assignable types. +7. **Named record to inline shape.** A value of named record type `R` is assignable + to an inline record shape when `R` contains at least the required fields with + assignable types. +8. **Inline record literal to named record.** An inline record literal is assignable + to a named record type when used in a context expecting that named type and it + provides exactly the fields of that type with assignable values. This is + contextual construction, not structural equivalence between named records. + +### 4.6 Member Access + +Member access (`expr.field`) is valid when the type has a known field set: + +- named record types +- inline record shapes +- variant types only when the field exists on every alternative with the same type + +If a field exists only on some variant alternatives, the value must be narrowed +with `match` first. + +### 4.7 Numeric Refinement and Division Safety + +Division is total in Axiom v1 because it is restricted statically. + +Rule: + +- `left / right` is valid only when `right` is assignable to `non_zero` + +Sources of `non_zero`: + +- a parameter, field, or expression explicitly annotated as `non_zero` +- a numeric literal other than `0` +- recognized narrowing forms such as: + - `if x != 0 then ... else ...` + - `if x > 0 then ... else ...` + - `if x < 0 then ... else ...` + - match arms whose range pattern excludes `0` + +If the type checker cannot prove that the divisor is `non_zero`, the program is +rejected. + +Arithmetic on `non_zero` values usually produces plain `number` unless another rule +proves a refined result. + +--- + +## 5. Expressions + +All computation in Axiom is expressed through expressions. There are no statements. + +### 5.1 Literals + +```axiom +42 +3.14 +"hello" +true +false +[1, 2, 3] +``` + +Numeric literals other than `0` are assignable to `non_zero`. + +### 5.2 Identifiers and Member Access + +```axiom +turnover +AdminFee +exposure.industry +row.base_rate +``` + +### 5.3 Arithmetic and Comparison + +```axiom +base_rate * (sum_insured / 1000) +turnover >= 50000 +industry not in ["asbestos", "demolition"] +``` + +See Section 6 for the operator table. + +### 5.4 If / Then / Else + +`if/then/else` is an expression. + +```axiom +if claims_count == 0 + then 0.95 + else 1.00 +``` + +Chained conditions are written with `else if`: + +```axiom +if claims == 0 + then 0.9 + else if claims <= 2 + then 1.0 + else 1.25 +``` + +The condition must be `bool`. Both branches must produce assignable types. + +### 5.5 Match Expressions + +#### Subject match + +```axiom +match industry { + "DRI-945" | "DRI-946" => "A", + _ => "B", +} +``` + +#### Subjectless condition match + +```axiom +match { + claims_count == 0 => 0.95, + claims_count <= 2 => 1.00, + _ => 1.25, +} +``` + +#### Tuple match + +```axiom +match (industry, employees) { + ("DRI-945", 1) => 0.85, + ("DRI-945", _) => 0.95, + _ => 1.00, +} +``` + +#### Variant match + +```axiom +match cover { + rated { premium: p } => p, + not_available { reason: _ } => 0, +} +``` + +#### Match over lists and tables (`match ... in`) + +`match binding in iterable` searches a list or table in iteration order and returns +the first matching result. + +```axiom +match row in industry_config { + row.code == industry => row.base_rate, + _ => 1.00, +} +``` + +Semantics: + +1. Iterate the list or table in order. +2. For each element, bind it to the name. +3. Evaluate the non-wildcard arms top to bottom as boolean conditions. +4. Return the first arm body whose condition is `true`. +5. If no condition matches, evaluate the wildcard arm. + +The wildcard arm is required. + +### 5.6 Expression Calls + +Parameterized expressions are called with one of two forms: + +- positional arguments +- named arguments + +Calls may also mix the forms, with one restriction: positional arguments must come +before named arguments. + +Zero-argument expressions are not called. They are referenced directly by their +name or qualified name. + +Positional example: + +```axiom +BasePremium(exposure, 500000) +``` + +Named example: + +```axiom +BasePremium(exposure: exposure, limit: 500000) +``` + +Mixed example: + +```axiom +Rate(industry, limit: 500000, employees: 3) +``` + +Qualified calls into namespaces: + +```axiom +Industry.BaseRate(industry) +``` + +Zero-argument namespace members are also referenced directly: + +```axiom +Pricing.AdminFee +``` + +### 5.7 Variant Construction + +Variant values are constructed with their tag name: + +```axiom +rated { + key: "PL", + name: "Public Liability", + premium: 500, +} +``` + +Payload-less alternatives are constructed with the tag alone: + +```axiom +declined +``` + +Qualified construction may be used for disambiguation: + +```axiom +CoverOutcome.not_available { reason: "industry_blocked" } +``` + +Unqualified constructors resolve from expected type when available. Otherwise the +tag must be unique in scope or be qualified explicitly. + +### 5.8 Record Literals + +```axiom +{ + key: "PL", + name: "Public Liability", + premium: 500, +} +``` + +Field shorthand is allowed when a variable name matches the field name: + +```axiom +{ + covers, + total, +} +``` + +### 5.9 List Literals + +```axiom +[1, 2, 3] +["A", "B"] +[cover_one, cover_two] +``` + +### 5.10 Where Clauses + +`where` introduces local bindings for intermediate values. + +```axiom +round(total, 2) + where base = exposure.turnover / 1000 * rate, + total = base * loading +``` + +Bindings are independent definitions inside the current scope. Their evaluation +order is determined by demand and data dependency, not by textual order alone. + +### 5.11 Parenthesized Expressions + +Parentheses override precedence: + +```axiom +(base + adjustment) * factor +``` + +--- + +## 6. Operators + +### 6.1 Precedence Table + +From lowest to highest precedence: + +| Precedence | Operators | Associativity | +|------------|-----------|---------------| +| 1 | `\|\|` | left | +| 2 | `&&` | left | +| 3 | `==`, `!=` | left | +| 4 | `<`, `>`, `<=`, `>=`, `in`, `not in` | left | +| 5 | `+`, `-` | left | +| 6 | `*`, `/` | left | + +### 6.2 Unary Operators + +| Operator | Operand | Result | +|----------|---------|--------| +| `-` | `number` | `number` | +| `not` | `bool` | `bool` | +| `!` | `bool` | `bool` | + +`not` is the preferred spelling. `!` is an alias. + +### 6.3 Arithmetic Operators + +| Operator | Left | Right | Result | +|----------|------|-------|--------| +| `+` | `number` | `number` | `number` | +| `-` | `number` | `number` | `number` | +| `*` | `number` | `number` | `number` | +| `/` | `number` | `non_zero` | `number` | + +Extensions may define additional operator rules for extension-defined types. + +### 6.4 Comparison Operators + +| Operator | Operands | Result | +|----------|----------|--------| +| `==` | same comparable type | `bool` | +| `!=` | same comparable type | `bool` | +| `<` | same ordered type | `bool` | +| `>` | same ordered type | `bool` | +| `<=` | same ordered type | `bool` | +| `>=` | same ordered type | `bool` | + +### 6.5 Logical Operators + +| Operator | Left | Right | Result | +|----------|------|-------|--------| +| `&&` | `bool` | `bool` | `bool` | +| `\|\|` | `bool` | `bool` | `bool` | + +### 6.6 Membership Operators + +| Operator | Left | Right | Result | +|----------|------|-------|--------| +| `in` | `T` | `list(T)` | `bool` | +| `not in` | `T` | `list(T)` | `bool` | + +`not in` is a first-class operator, not macro syntax. + +--- + +## 7. Patterns + +Patterns are used in subject `match`, collection forms, and aggregate collection. + +### 7.1 Wildcard Pattern + +```axiom +_ => default_value +``` + +### 7.2 Literal Patterns + +```axiom +42 => "exact" +"brick" => 1.0 +true => "yes" +``` + +### 7.3 Alternative Patterns + +```axiom +"DRI-945" | "DRI-946" => "A" +1 | 2 | 3 => "low" +``` + +### 7.4 Range Patterns + +Range patterns are part of the core language. + +```axiom +[0..100] +(0..100) +[0..100) +(0..100] +[5..] +[..10] +``` + +Range patterns operate over numeric subjects. + +### 7.5 Variant Patterns + +```axiom +rated { premium: p } => p +not_available { reason: r } => r +declined => 0 +``` + +Field shorthand is allowed: + +```axiom +rated { premium } => premium +``` + +Qualified variant patterns may be used for disambiguation: + +```axiom +CoverOutcome.rated { premium: p } => p +``` + +### 7.6 Tuple Patterns + +```axiom +match (industry, employees) { + ("DRI-945", 1) => 0.85, + (_, _) => 1.00, +} +``` + +### 7.7 Exhaustiveness + +For variant subjects, match arms must be exhaustive. A wildcard arm satisfies +exhaustiveness for all remaining alternatives. + +```axiom +match outcome { + offered { total: t } => t, + declined => 0, + referred { reasons: _ } => 0, +} +``` + +For non-variant subjects, a wildcard arm is recommended but not required. + +--- + +## 8. Collection Forms + +Collection forms operate on `list(T)` values and on tables, which are lists of +records. + +### 8.1 `any` - Existential Predicate + +Returns `true` if at least one element matches the pattern. + +```axiom +any referred in covers +any rated { premium: _ } in covers +``` + +Type: `bool` + +### 8.2 `all` - Universal Predicate + +Returns `true` if every element matches the pattern. + +```axiom +all rated in covers +``` + +Type: `bool` + +### 8.3 `collect` - Pattern Map + +Evaluates the body for each matching element and returns a list of results. + +```axiom +collect referred { reason: r } in covers => r +collect rated { premium: p } in covers => p +``` + +Type: `list(T)` where `T` is the body type. + +### 8.4 `collect` - Binding Map + +The binding form transforms every element: + +```axiom +collect cover in covers => CoverAmount(cover) +``` + +### 8.5 `collect` - Binding Filter Map + +The binding-arm form filters and transforms: + +```axiom +collect row in industry_config { + row.min_premium > 0 => row.code, +} +``` + +Non-matching elements are skipped. There is no implicit fallback value. + +### 8.6 Aggregate Collect + +Aggregate collect applies a core aggregator to a collected list. + +Core aggregators in v1: + +- `sum` +- `product` + +Examples: + +```axiom +sum collect rated { premium: p } in covers => p + +product collect factor in loadings => factor +``` + +Aggregate collect preserves the iteration order of the source collection, though +the current core aggregators are order-insensitive. + +### 8.7 Collection Form Typing + +- The iterable operand must be `list(T)` or a table. +- Patterns are checked against `T`. +- `any` and `all` return `bool`. +- `collect` returns `list(U)`. +- Aggregate collect returns the aggregator result type. + +--- + +## 9. Intrinsic Functions + +Axiom v1 includes a small set of built-in functions. + +| Function | Signature | Description | +|----------|-----------|-------------| +| `round(value, places)` | `(number, number) -> number` | Round to `places` decimal places | +| `len(values)` | `(list(T)) -> number` | Number of elements | +| `flatten(nested)` | `(list(list(T))) -> list(T)` | Flatten one nesting level | +| `sum(values)` | `(list(number)) -> number` | Sum of all values | +| `product(values)` | `(list(number)) -> number` | Product of all values | + +Semantics: + +- `round(value, places)` truncates `places` toward zero before rounding. +- `sum([])` is `0`. +- `product([])` is `1`. + +Additional intrinsics may be provided by extensions, but they must obey the same +determinism and totality requirements as the core language. + +--- + +## 10. Type Checking + +### 10.1 Inference Rules + +The type checker infers types bottom-up. + +| Expression | Inferred Type | +|------------|---------------| +| `42` | `non_zero` | +| `0` | `number` | +| `"hello"` | `string` | +| `true` | `bool` | +| `[1, 2, 3]` | `list(non_zero)` | +| `{ a: 1, b: "x" }` | inline record shape `{ a: non_zero, b: string }` | +| `identifier` | declared type from scope | +| `QualifiedName` | declared return type of a zero-argument expression | +| `object.field` | field type from record shape | +| `left OP right` | operator result type | +| `if/then/else` | common branch type | +| `match` | common arm type | +| `Name(args)` | declared return type of named expression | +| `tag { fields }` | resolved variant type | +| `any P in xs` | `bool` | +| `collect P in xs => body` | `list(T)` | +| `agg collect ...` | aggregator return type | +| `expr where bindings` | type of `expr` | + +### 10.2 Variant Resolution + +Variant tags are resolved in this order: + +1. Expected type from context +2. Explicit qualification +3. Unique visible tag + +If a tag is ambiguous, qualification is required. + +### 10.3 Call Checking + +For each call, the type checker validates: + +- the callee exists +- the referenced expression declares one or more parameters +- the argument ordering is valid +- positional arity or named parameter completeness +- argument types are assignable to parameter types +- the body type is assignable to the declared return type + +### 10.4 Table Checking + +For each table declaration, the checker validates: + +- the row type is well formed +- all table field references are valid against the row schema +- `match row in table` and `collect row in table` bind rows with the declared row type + +### 10.5 Division Safety + +The checker rejects division when the divisor is not statically proven to be +`non_zero`. + +```axiom +Ratio(a: number, b: number): number { + a / b +} +``` + +The expression above is a type error because `b` is only `number`. + +```axiom +Ratio(a: number, b: non_zero): number { + a / b +} +``` + +This is valid. + +### 10.6 Recursion Detection + +The checker builds the call graph of named expressions and rejects cyclic +dependencies. Mutual recursion and self-recursion are type errors in v1. + +### 10.7 Soundness Checks + +The checker enforces: + +- operator type validity +- member access validity +- match exhaustiveness for variant subjects +- match arm type consistency +- collection-form typing +- table row access validity +- division safety +- recursion absence +- unresolved symbol detection +- duplicate declaration detection + +--- + +## 11. Evaluation + +### 11.1 Execution Model + +Axiom v1 uses lazy evaluation with memoization. + +- Expression arguments are evaluated on first use. +- `where` bindings are evaluated on first use. +- Each value is computed at most once per scope. +- Each expression call creates a fresh child scope and memo table. + +Because Axiom is pure, lazy evaluation changes performance only, not meaning. + +### 11.2 Runtime Representation + +Records are plain associative structures: + +```json +{ "industry": "DRI-945", "turnover": "500000" } +``` + +Variants use a reserved `_tag` field: + +```json +{ + "_tag": "rated", + "key": "PL", + "premium": "500" +} +``` + +Payload-less variants are represented as: + +```json +{ "_tag": "declined" } +``` + +Authors may not declare a payload field named `_tag`. + +### 11.3 Evaluation Order + +- `if/then/else`: evaluate the condition, then the taken branch only +- subject `match`: evaluate the subject, then arms top to bottom +- subjectless `match`: evaluate arms top to bottom until a condition succeeds +- `match binding in iterable`: iterate in collection order, then arm order +- `where`: evaluate bindings on demand +- `&&` and `||`: short-circuit +- collection forms: iterate in collection order + +### 11.4 Table Evaluation + +Tables are evaluated as immutable ordered lists of rows. + +- Table artifacts are loaded and validated before any expression is evaluated. +- Row order is preserved exactly as declared by the artifact. +- Evaluation never mutates a table or derives side effects from reading it. + +--- + +## 12. Diagnostics + +All pipeline stages produce diagnostics with a uniform structure: + +- `severity`: `error`, `warning`, or `info` +- `code`: stable dotted identifier +- `message`: human-readable description +- `location`: line, column, offset, length + +### 12.1 Diagnostic Categories + +| Prefix | Stage | +|--------|-------| +| `parse.*` | Parser | +| `type.*` | Type checker | +| `validation.*` | Input and artifact validation | +| `extension.*` | Extension loading and overlap validation | + +### 12.2 Error Quality + +Diagnostics should: + +- name expected and actual types concretely +- identify the precise parameter, field, or arm involved +- list missing variant alternatives for non-exhaustive matches +- identify the specific divisor expression that is not proven `non_zero` +- identify the specific table and row field involved in artifact validation failures + +--- + +## 13. Grammar + +```ebnf +program = { declaration } ; +declaration = type_decl | namespace_decl | table_decl | expr_decl ; + +(* --- Declarations --- *) + +type_decl = "type" UPPER_IDENT type_decl_body ; +type_decl_body = record_shape | variant_alts | extension_type ; +record_shape = "{" field_decl { "," field_decl } [ "," ] "}" ; +variant_alts = variant_alt { "|" variant_alt } ; +variant_alt = LOWER_IDENT [ record_shape ] ; +field_decl = LOWER_IDENT ":" type_expr ; + +namespace_decl = "namespace" UPPER_IDENT "{" { namespace_member } "}" ; +namespace_member = type_decl | expr_decl ; + +table_decl = "table" LOWER_IDENT ":" "list" "(" table_row_type ")" ; +table_row_type = record_shape | qualified_upper ; + +expr_decl = zero_arg_expr_decl | param_expr_decl ; +zero_arg_expr_decl = UPPER_IDENT ":" type_expr "{" expression "}" ; +param_expr_decl = UPPER_IDENT "(" param_list ")" ":" type_expr + "{" expression "}" ; +param_list = param { "," param } ; +param = LOWER_IDENT ":" type_expr ; + +(* --- Type expressions --- *) + +type_expr = primitive_type + | list_type + | record_shape + | qualified_upper + | extension_type ; + +primitive_type = "number" | "non_zero" | "string" | "bool" ; +list_type = "list" "(" type_expr ")" ; +extension_type = LOWER_IDENT "(" type_arg { "," type_arg } ")" ; +type_arg = qualified_upper | LOWER_IDENT | type_expr ; + +qualified_upper = UPPER_IDENT { "." UPPER_IDENT } ; + +(* --- Expressions --- *) + +expression = where_expr ; +where_expr = or_expr [ "where" binding { "," binding } ] ; +binding = LOWER_IDENT "=" expression ; + +or_expr = and_expr { "||" and_expr } ; +and_expr = equality_expr { "&&" equality_expr } ; +equality_expr = comparison_expr { ( "==" | "!=" ) comparison_expr } ; +comparison_expr = additive_expr + { ( "<" | ">" | "<=" | ">=" | "in" | "not" "in" ) + additive_expr } ; +additive_expr = multiplicative_expr { ( "+" | "-" ) multiplicative_expr } ; +multiplicative_expr = unary_expr { ( "*" | "/" ) unary_expr } ; +unary_expr = ( "not" | "!" | "-" ) unary_expr | postfix_expr ; +postfix_expr = primary { "." LOWER_IDENT } ; + +primary = if_expr + | match_expr + | aggregate_collect_expr + | collect_expr + | any_expr + | all_expr + | call_expr + | variant_ctor + | list_literal + | record_literal + | NUMBER + | STRING + | BOOL + | LOWER_IDENT + | qualified_upper + | "(" expression ")" ; + +(* --- Control flow --- *) + +if_expr = "if" expression "then" expression + { "else" "if" expression "then" expression } + "else" expression ; + +match_expr = subject_match | condition_match | binding_match ; +subject_match = "match" match_subject "{" pattern_arm { "," pattern_arm } [ "," ] "}" ; +condition_match = "match" "{" condition_arm { "," condition_arm } [ "," ] "}" ; +binding_match = "match" LOWER_IDENT "in" expression + "{" condition_arm { "," condition_arm } [ "," ] "}" ; + +match_subject = expression | "(" expression { "," expression } ")" ; +pattern_arm = pattern "=>" expression ; +condition_arm = ( expression | "_" ) "=>" expression ; + +(* --- Collection forms --- *) + +any_expr = "any" pattern "in" expression ; +all_expr = "all" pattern "in" expression ; + +collect_expr = pattern_collect + | binding_collect + | binding_arm_collect ; + +pattern_collect = "collect" pattern "in" expression "=>" expression ; +binding_collect = "collect" LOWER_IDENT "in" expression "=>" expression ; +binding_arm_collect = "collect" LOWER_IDENT "in" expression + "{" condition_arm { "," condition_arm } [ "," ] "}" ; + +aggregate_collect_expr = + aggregator "collect" pattern "in" expression "=>" expression + | aggregator "collect" LOWER_IDENT "in" expression "=>" expression + | aggregator "collect" LOWER_IDENT "in" expression + "{" condition_arm { "," condition_arm } [ "," ] "}" ; + +aggregator = "sum" | "product" ; + +(* --- Calls and construction --- *) + +call_expr = qualified_call "(" [ arg_list ] ")" ; +qualified_call = UPPER_IDENT { "." UPPER_IDENT } ; +arg_list = positional_then_named | named_args ; +positional_then_named = + positional_args [ "," named_args ] ; +positional_args = expression { "," expression } ; +named_args = named_arg { "," named_arg } ; +named_arg = LOWER_IDENT ":" expression ; + +variant_ctor = [ qualified_upper "." ] LOWER_IDENT [ record_shape_expr ] ; +record_shape_expr = "{" [ record_entry { "," record_entry } [ "," ] ] "}" ; +record_entry = LOWER_IDENT ":" expression | LOWER_IDENT ; + +list_literal = "[" [ expression { "," expression } ] [ "," ] "]" ; +record_literal = "{" [ record_entry { "," record_entry } [ "," ] ] "}" ; + +(* --- Patterns --- *) + +pattern = alt_pattern ; +alt_pattern = single_pattern { "|" single_pattern } ; +single_pattern = wildcard_pat + | range_pat + | variant_pat + | tuple_pat + | literal_pat ; + +wildcard_pat = "_" ; +literal_pat = NUMBER | STRING | BOOL ; +range_pat = ( "[" | "(" ) [ NUMBER ] ".." [ NUMBER ] ( "]" | ")" ) ; +variant_pat = [ qualified_upper "." ] LOWER_IDENT [ pattern_record ] ; +pattern_record = "{" [ pattern_field { "," pattern_field } [ "," ] ] "}" ; +pattern_field = LOWER_IDENT [ ":" ( LOWER_IDENT | "_" ) ] ; +tuple_pat = "(" pattern "," pattern { "," pattern } ")" ; + +(* --- Lexical --- *) + +UPPER_IDENT = [A-Z] [a-zA-Z0-9_]* ; +LOWER_IDENT = [a-z_] [a-zA-Z0-9_]* ; +NUMBER = [0-9]+ [ "." [0-9]+ ] ; +STRING = '"' ( [^"\\] | '\\' . )* '"' ; +BOOL = "true" | "false" ; +COMMENT = "//" [^\n]* ; +``` + +### 13.1 Keywords + +```text +type namespace table +if then else +match in +any all collect +where +not true false +sum product +``` + +### 13.2 Reserved + +```text +_tag +_ +``` + +--- + +## 14. Example + +The following example uses only core v1 features. + +```axiom +type Exposure { + industry: string, + turnover: number, +} + +type CoverOutcome + rated { + key: string, + name: string, + premium: number, + } + | not_available { reason: string } + +type ProductOutcome + offered { + covers: list(CoverOutcome), + total: number, + } + | referred { reasons: list(string) } + +table industry_config: list({ + code: string, + base_rate: number, + minimum_premium: number, +}) + +BaseRate(industry: string): number { + match row in industry_config { + row.code == industry => row.base_rate, + _ => 1.00, + } +} + +MinimumPremium(industry: string): number { + match row in industry_config { + row.code == industry => row.minimum_premium, + _ => 0, + } +} + +LiabilityCover(exposure: Exposure): CoverOutcome { + if exposure.turnover == 0 + then not_available { reason: "turnover_zero" } + else rated { + key: "PL", + name: "Public Liability", + premium: exposure.turnover / 1000 * BaseRate(exposure.industry), + } +} + +Product(exposure: Exposure): ProductOutcome { + if any not_available in covers + then referred { + reasons: collect not_available { reason } in covers => reason, + } + else offered { + covers, + total: sum collect rated { premium } in covers => premium, + } + where covers = [ + LiabilityCover(exposure), + ] +} +``` + +--- + +## 15. Extensions + +Extensions are part of Axiom v1, but their scope is intentionally narrow. + +### 15.1 What Extensions May Add + +An extension may add: + +- custom literal forms +- custom types +- operator rules for those types +- intrinsic overloads for those types + +An extension may not add: + +- new keywords +- new control-flow forms +- new pattern syntax +- mutable state +- side effects +- external data access + +### 15.2 Extension Contract + +An extension participates in three stages: + +- literal recognition +- type checking +- evaluation + +Conceptually: + +```text +Extension + name: string + lexer?: literal hooks + checker?: type hooks + evaluator?: runtime hooks +``` + +The precise host-language API is implementation-defined, but all conforming +implementations must preserve the same source-level semantics. + +### 15.3 Non-Overlap Rule + +Extension meaning may not depend on registration order. + +At program load time: + +- if two extensions claim the same literal family, load fails +- if two extensions claim the same type constructor, load fails +- if two extensions define overlapping operator or intrinsic behavior for the same + operand types, load fails + +This keeps extension composition deterministic. + +--- + +## 16. Standardized Money Extension + +This section is normative for implementations that ship the standard money +extension. It is not part of the core language. + +### 16.1 Type + +`money(CURRENCY)` is a parameterized extension type. The currency is part of the +type. + +```axiom +type Premium money(GBP) +``` + +### 16.2 Literals + +Money literals use a currency prefix followed by a decimal amount: + +```axiom +£100 +GBP100 +USD1500.00 +``` + +### 16.3 Arithmetic Rules + +| Expression | Result | +|------------|--------| +| `money(C) + money(C)` | `money(C)` | +| `money(C) - money(C)` | `money(C)` | +| `money(C) * number` | `money(C)` | +| `number * money(C)` | `money(C)` | +| `money(C) / non_zero` | `money(C)` | +| `money(C) / money(C)` | `number` | + +Cross-currency arithmetic is a type error. + +### 16.4 Comparisons + +Comparison operators are valid only between values of the same `money(C)` type. + +### 16.5 Rounding and Aggregation + +The standard money extension overloads: + +- `round` +- `sum` +- `product` when explicitly defined by the implementation + +--- + +## 17. Summary + +Axiom v1 is a small, typed, deterministic DSL for authored business logic. + +Its core consists of: + +- named expressions with declared interfaces +- nominal record and variant types +- ordered immutable tables backed by validated artifacts +- `if`, `match`, and `where` +- list-oriented collection forms +- exact decimal numbers with static division safety +- a narrow extension model for value and type families + +Axiom v1 does not include: + +- mutation +- loops +- indexing +- dynamic maps +- implicit IO +- syntax-extending plugins +- silent fallback semantics + +The intent of v1 is to be small enough to specify precisely, implement consistently, +and review with confidence. diff --git a/composer.json b/composer.json index c523700..bd40159 100644 --- a/composer.json +++ b/composer.json @@ -1,26 +1,18 @@ { "name": "gosuperscript/axiom", - "description": "A PHP library for data transformation, type validation, and expression evaluation.", + "description": "PHP reference implementation workspace for the Axiom DSL.", "type": "library", "license": "MIT", - "keywords": ["php", "axiom", "validation", "coercion", "types"], + "keywords": ["php", "axiom", "dsl", "rules", "pricing"], "require": { "php": "^8.4", "ext-intl": "*", - "azjezz/psl": "^3.2 || ^4.0", - "brick/math": "^0.12.0 || ^0.13.0", - "gosuperscript/monads": "^1.0.0", - "illuminate/container": "^11.0 || ^12.0", - "illuminate/support": "^11.0 || ^12.0", - "psr/container": "^2.0", - "sebastian/exporter": "^6.0 || ^7.0" + "brick/math": "^0.12.0 || ^0.13.0" }, "require-dev": { - "infection/infection": "^0.29.14", "laravel/pint": "^1.22", "phpstan/phpstan": "2.1.45", - "phpunit/phpunit": "12.5.11", - "robiningelbrecht/phpunit-coverage-tools": "^1.9" + "phpunit/phpunit": "12.5.11" }, "autoload": { "psr-4": { @@ -33,20 +25,16 @@ } }, "scripts": { - "test:types": "vendor/bin/phpstan analyse", - "test:unit": "vendor/bin/phpunit -d --min-coverage=100", - "test:infection": "vendor/bin/infection --threads=max --show-mutations", + "test:types": "vendor/bin/phpstan analyse --configuration phpstan.neon.dist", + "test:unit": "vendor/bin/phpunit --configuration phpunit.xml.dist", + "format": "vendor/bin/pint", "test": [ "@test:types", - "@test:unit", - "@test:infection" + "@test:unit" ] }, "config": { "sort-packages": true, - "allow-plugins": { - "infection/extension-installer": true - }, "platform": { "php": "8.4.0" } diff --git a/legacy/README.md b/legacy/README.md new file mode 100644 index 0000000..76b69ef --- /dev/null +++ b/legacy/README.md @@ -0,0 +1,24 @@ +# Legacy Archive + +This directory contains the archived PHP library that predates the rewritten +Axiom v1 specification. + +It is preserved for reference while the new PHP implementation starts cleanly +from the root [`src/`](../src) and [`tests/`](../tests) directories. + +## Contents + +- `src/`: archived PHP source +- `tests/`: archived PHP tests +- `composer.json`: archived package manifest for the legacy runtime +- `phpunit.xml.dist`, `phpstan.neon.dist`, `infection.json5`, `pint.json`: + archived quality/config files + +## Status + +- not the active implementation +- not the root package surface +- useful as reference material during the rewrite + +If you need to inspect or run the legacy code, do so from this directory +explicitly rather than treating it as the active root runtime. diff --git a/legacy/composer.json b/legacy/composer.json new file mode 100644 index 0000000..ea6cf2e --- /dev/null +++ b/legacy/composer.json @@ -0,0 +1,56 @@ +{ + "name": "gosuperscript/axiom-legacy", + "description": "Archived pre-v1 PHP expression library for Axiom.", + "type": "library", + "license": "MIT", + "keywords": ["php", "axiom", "legacy", "validation", "coercion"], + "require": { + "php": "^8.4", + "ext-intl": "*", + "azjezz/psl": "^3.2 || ^4.0", + "brick/math": "^0.12.0 || ^0.13.0", + "gosuperscript/monads": "^1.0.0", + "illuminate/container": "^11.0 || ^12.0", + "illuminate/support": "^11.0 || ^12.0", + "psr/container": "^2.0", + "sebastian/exporter": "^6.0 || ^7.0" + }, + "require-dev": { + "infection/infection": "^0.29.14", + "laravel/pint": "^1.22", + "phpstan/phpstan": "2.1.44", + "phpunit/phpunit": "12.5.11", + "robiningelbrecht/phpunit-coverage-tools": "^1.9" + }, + "autoload": { + "psr-4": { + "Superscript\\Axiom\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Superscript\\Axiom\\Tests\\": "tests/" + } + }, + "scripts": { + "test:types": "vendor/bin/phpstan analyse --configuration phpstan.neon.dist", + "test:unit": "vendor/bin/phpunit --configuration phpunit.xml.dist --min-coverage=100", + "test:infection": "vendor/bin/infection --threads=max --show-mutations", + "test": [ + "@test:types", + "@test:unit", + "@test:infection" + ] + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true + }, + "platform": { + "php": "8.4.0" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/infection.json5 b/legacy/infection.json5 similarity index 100% rename from infection.json5 rename to legacy/infection.json5 diff --git a/legacy/phpstan.neon.dist b/legacy/phpstan.neon.dist new file mode 100644 index 0000000..4cad4cc --- /dev/null +++ b/legacy/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + paths: + - src/ + + level: max + + treatPhpDocTypesAsCertain: false \ No newline at end of file diff --git a/legacy/phpunit.xml.dist b/legacy/phpunit.xml.dist new file mode 100644 index 0000000..d311187 --- /dev/null +++ b/legacy/phpunit.xml.dist @@ -0,0 +1,43 @@ + + + + + tests + + + + + + + + + + + + + + + + + + ./src + + + \ No newline at end of file diff --git a/legacy/pint.json b/legacy/pint.json new file mode 100644 index 0000000..6a5955e --- /dev/null +++ b/legacy/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "per", + "rules": { + "declare_strict_types": true + } +} \ No newline at end of file diff --git a/src/Exceptions/TransformValueException.php b/legacy/src/Exceptions/TransformValueException.php similarity index 100% rename from src/Exceptions/TransformValueException.php rename to legacy/src/Exceptions/TransformValueException.php diff --git a/src/Operators/BinaryOverloader.php b/legacy/src/Operators/BinaryOverloader.php similarity index 100% rename from src/Operators/BinaryOverloader.php rename to legacy/src/Operators/BinaryOverloader.php diff --git a/src/Operators/ComparisonOverloader.php b/legacy/src/Operators/ComparisonOverloader.php similarity index 100% rename from src/Operators/ComparisonOverloader.php rename to legacy/src/Operators/ComparisonOverloader.php diff --git a/src/Operators/DefaultOverloader.php b/legacy/src/Operators/DefaultOverloader.php similarity index 100% rename from src/Operators/DefaultOverloader.php rename to legacy/src/Operators/DefaultOverloader.php diff --git a/src/Operators/HasOverloader.php b/legacy/src/Operators/HasOverloader.php similarity index 100% rename from src/Operators/HasOverloader.php rename to legacy/src/Operators/HasOverloader.php diff --git a/src/Operators/InOverloader.php b/legacy/src/Operators/InOverloader.php similarity index 100% rename from src/Operators/InOverloader.php rename to legacy/src/Operators/InOverloader.php diff --git a/src/Operators/IntersectsOverloader.php b/legacy/src/Operators/IntersectsOverloader.php similarity index 100% rename from src/Operators/IntersectsOverloader.php rename to legacy/src/Operators/IntersectsOverloader.php diff --git a/src/Operators/LogicalOverloader.php b/legacy/src/Operators/LogicalOverloader.php similarity index 100% rename from src/Operators/LogicalOverloader.php rename to legacy/src/Operators/LogicalOverloader.php diff --git a/src/Operators/NullOverloader.php b/legacy/src/Operators/NullOverloader.php similarity index 100% rename from src/Operators/NullOverloader.php rename to legacy/src/Operators/NullOverloader.php diff --git a/src/Operators/OperatorOverloader.php b/legacy/src/Operators/OperatorOverloader.php similarity index 100% rename from src/Operators/OperatorOverloader.php rename to legacy/src/Operators/OperatorOverloader.php diff --git a/src/Operators/OverloaderManager.php b/legacy/src/Operators/OverloaderManager.php similarity index 100% rename from src/Operators/OverloaderManager.php rename to legacy/src/Operators/OverloaderManager.php diff --git a/src/Patterns/ExpressionMatcher.php b/legacy/src/Patterns/ExpressionMatcher.php similarity index 100% rename from src/Patterns/ExpressionMatcher.php rename to legacy/src/Patterns/ExpressionMatcher.php diff --git a/src/Patterns/LiteralMatcher.php b/legacy/src/Patterns/LiteralMatcher.php similarity index 100% rename from src/Patterns/LiteralMatcher.php rename to legacy/src/Patterns/LiteralMatcher.php diff --git a/src/Patterns/PatternMatcher.php b/legacy/src/Patterns/PatternMatcher.php similarity index 100% rename from src/Patterns/PatternMatcher.php rename to legacy/src/Patterns/PatternMatcher.php diff --git a/src/Patterns/WildcardMatcher.php b/legacy/src/Patterns/WildcardMatcher.php similarity index 100% rename from src/Patterns/WildcardMatcher.php rename to legacy/src/Patterns/WildcardMatcher.php diff --git a/src/ResolutionInspector.php b/legacy/src/ResolutionInspector.php similarity index 100% rename from src/ResolutionInspector.php rename to legacy/src/ResolutionInspector.php diff --git a/src/Resolvers/BindableResolver.php b/legacy/src/Resolvers/BindableResolver.php similarity index 100% rename from src/Resolvers/BindableResolver.php rename to legacy/src/Resolvers/BindableResolver.php diff --git a/src/Resolvers/DelegatingResolver.php b/legacy/src/Resolvers/DelegatingResolver.php similarity index 100% rename from src/Resolvers/DelegatingResolver.php rename to legacy/src/Resolvers/DelegatingResolver.php diff --git a/src/Resolvers/InfixResolver.php b/legacy/src/Resolvers/InfixResolver.php similarity index 100% rename from src/Resolvers/InfixResolver.php rename to legacy/src/Resolvers/InfixResolver.php diff --git a/src/Resolvers/MatchResolver.php b/legacy/src/Resolvers/MatchResolver.php similarity index 100% rename from src/Resolvers/MatchResolver.php rename to legacy/src/Resolvers/MatchResolver.php diff --git a/src/Resolvers/MemberAccessResolver.php b/legacy/src/Resolvers/MemberAccessResolver.php similarity index 100% rename from src/Resolvers/MemberAccessResolver.php rename to legacy/src/Resolvers/MemberAccessResolver.php diff --git a/src/Resolvers/Resolver.php b/legacy/src/Resolvers/Resolver.php similarity index 100% rename from src/Resolvers/Resolver.php rename to legacy/src/Resolvers/Resolver.php diff --git a/src/Resolvers/StaticResolver.php b/legacy/src/Resolvers/StaticResolver.php similarity index 100% rename from src/Resolvers/StaticResolver.php rename to legacy/src/Resolvers/StaticResolver.php diff --git a/src/Resolvers/SymbolResolver.php b/legacy/src/Resolvers/SymbolResolver.php similarity index 100% rename from src/Resolvers/SymbolResolver.php rename to legacy/src/Resolvers/SymbolResolver.php diff --git a/src/Resolvers/UnaryResolver.php b/legacy/src/Resolvers/UnaryResolver.php similarity index 100% rename from src/Resolvers/UnaryResolver.php rename to legacy/src/Resolvers/UnaryResolver.php diff --git a/src/Resolvers/ValueResolver.php b/legacy/src/Resolvers/ValueResolver.php similarity index 100% rename from src/Resolvers/ValueResolver.php rename to legacy/src/Resolvers/ValueResolver.php diff --git a/src/Source.php b/legacy/src/Source.php similarity index 100% rename from src/Source.php rename to legacy/src/Source.php diff --git a/src/Sources/ExpressionPattern.php b/legacy/src/Sources/ExpressionPattern.php similarity index 100% rename from src/Sources/ExpressionPattern.php rename to legacy/src/Sources/ExpressionPattern.php diff --git a/src/Sources/InfixExpression.php b/legacy/src/Sources/InfixExpression.php similarity index 100% rename from src/Sources/InfixExpression.php rename to legacy/src/Sources/InfixExpression.php diff --git a/src/Sources/LiteralPattern.php b/legacy/src/Sources/LiteralPattern.php similarity index 100% rename from src/Sources/LiteralPattern.php rename to legacy/src/Sources/LiteralPattern.php diff --git a/src/Sources/MatchArm.php b/legacy/src/Sources/MatchArm.php similarity index 100% rename from src/Sources/MatchArm.php rename to legacy/src/Sources/MatchArm.php diff --git a/src/Sources/MatchExpression.php b/legacy/src/Sources/MatchExpression.php similarity index 100% rename from src/Sources/MatchExpression.php rename to legacy/src/Sources/MatchExpression.php diff --git a/src/Sources/MatchPattern.php b/legacy/src/Sources/MatchPattern.php similarity index 100% rename from src/Sources/MatchPattern.php rename to legacy/src/Sources/MatchPattern.php diff --git a/src/Sources/MemberAccessSource.php b/legacy/src/Sources/MemberAccessSource.php similarity index 100% rename from src/Sources/MemberAccessSource.php rename to legacy/src/Sources/MemberAccessSource.php diff --git a/src/Sources/StaticSource.php b/legacy/src/Sources/StaticSource.php similarity index 100% rename from src/Sources/StaticSource.php rename to legacy/src/Sources/StaticSource.php diff --git a/src/Sources/SymbolSource.php b/legacy/src/Sources/SymbolSource.php similarity index 100% rename from src/Sources/SymbolSource.php rename to legacy/src/Sources/SymbolSource.php diff --git a/src/Sources/TypeDefinition.php b/legacy/src/Sources/TypeDefinition.php similarity index 100% rename from src/Sources/TypeDefinition.php rename to legacy/src/Sources/TypeDefinition.php diff --git a/src/Sources/UnaryExpression.php b/legacy/src/Sources/UnaryExpression.php similarity index 100% rename from src/Sources/UnaryExpression.php rename to legacy/src/Sources/UnaryExpression.php diff --git a/src/Sources/WildcardPattern.php b/legacy/src/Sources/WildcardPattern.php similarity index 100% rename from src/Sources/WildcardPattern.php rename to legacy/src/Sources/WildcardPattern.php diff --git a/legacy/src/SymbolRegistry.php b/legacy/src/SymbolRegistry.php new file mode 100644 index 0000000..dd5d033 --- /dev/null +++ b/legacy/src/SymbolRegistry.php @@ -0,0 +1,65 @@ + */ + private array $symbols; + + /** + * @param array> $symbols + */ + public function __construct(array $symbols = []) + { + // Transform the array into internal storage format + $internalSymbols = []; + + foreach ($symbols as $key => $value) { + // If value is a Source, add it without namespace + if ($value instanceof Source) { + $internalSymbols[$key] = $value; + } + // If value is an array, the key is the namespace + elseif (is_array($value)) { + // Validate that the array contains only Sources + dict(string(), instance_of(Source::class))->assert($value); + + foreach ($value as $name => $source) { + $namespacedKey = $key . '.' . $name; + $internalSymbols[$namespacedKey] = $source; + } + } else { + throw new \InvalidArgumentException( + 'Symbol values must be either Source instances or arrays of Sources' + ); + } + } + + $this->symbols = $internalSymbols; + } + + /** + * @return Option + */ + public function get(string $name, ?string $namespace = null): Option + { + // When namespace is provided, look for it with format "namespace.name" + if ($namespace !== null) { + $namespacedKey = $namespace . '.' . $name; + return Option::from($this->symbols[$namespacedKey] ?? null); + } + + // When namespace is null, first try exact name match, + // then fall back to checking if there's a global namespace symbol + return Option::from($this->symbols[$name] ?? null); + } +} diff --git a/src/Types/BooleanType.php b/legacy/src/Types/BooleanType.php similarity index 100% rename from src/Types/BooleanType.php rename to legacy/src/Types/BooleanType.php diff --git a/src/Types/DictType.php b/legacy/src/Types/DictType.php similarity index 100% rename from src/Types/DictType.php rename to legacy/src/Types/DictType.php diff --git a/src/Types/ListType.php b/legacy/src/Types/ListType.php similarity index 100% rename from src/Types/ListType.php rename to legacy/src/Types/ListType.php diff --git a/legacy/src/Types/NumberType.php b/legacy/src/Types/NumberType.php new file mode 100644 index 0000000..ba44496 --- /dev/null +++ b/legacy/src/Types/NumberType.php @@ -0,0 +1,62 @@ + + */ +class NumberType implements Type +{ + public function assert(mixed $value): Result + { + if (!num()->matches($value)) { + return new Err(new TransformValueException(type: 'numeric', value: $value)); + } + + return Ok(Some($value)); + } + + public function coerce(mixed $value): Result + { + if (is_string($value) && ($value === '' || $value === 'null')) { + return Ok(None()); + } + + return (match (true) { + numeric_string()->matches($value) || num()->matches($value) => Ok(num()->coerce($value)), + is_string($value) && numeric_string()->matches(before($value, '%')) => Ok(num()->coerce(before($value, '%')) / 100), + default => new Err(new TransformValueException(type: 'numeric', value: $value)), + })->map(fn(int|float $value) => Some($value)); + } + + /** + * @inheritDoc + */ + public function compare(mixed $a, mixed $b): bool + { + return $a === $b; + } + + public function format(mixed $value): string + { + $formatter = new NumberFormatter('en_GB', NumberFormatter::DECIMAL); + + return string()->assert($formatter->format($value)); + } +} diff --git a/src/Types/StringType.php b/legacy/src/Types/StringType.php similarity index 100% rename from src/Types/StringType.php rename to legacy/src/Types/StringType.php diff --git a/legacy/src/Types/Type.php b/legacy/src/Types/Type.php new file mode 100644 index 0000000..dba6b11 --- /dev/null +++ b/legacy/src/Types/Type.php @@ -0,0 +1,42 @@ +, Throwable> + */ + public function assert(mixed $value): Result; + + /** + * Try to coerce a mixed value into type T + * @param mixed $value + * @return Result, Throwable> + */ + public function coerce(mixed $value): Result; + + /** + * @param T $a + * @param T $b + * @return bool + */ + public function compare(mixed $a, mixed $b): bool; + + /** + * @param T $value + * @return string + */ + public function format(mixed $value): string; +} diff --git a/tests/DefaultOverloaderTest.php b/legacy/tests/DefaultOverloaderTest.php similarity index 100% rename from tests/DefaultOverloaderTest.php rename to legacy/tests/DefaultOverloaderTest.php diff --git a/tests/Exceptions/TransformValueExceptionTest.php b/legacy/tests/Exceptions/TransformValueExceptionTest.php similarity index 100% rename from tests/Exceptions/TransformValueExceptionTest.php rename to legacy/tests/Exceptions/TransformValueExceptionTest.php diff --git a/tests/KitchenSink/KitchenSinkTest.php b/legacy/tests/KitchenSink/KitchenSinkTest.php similarity index 100% rename from tests/KitchenSink/KitchenSinkTest.php rename to legacy/tests/KitchenSink/KitchenSinkTest.php diff --git a/tests/LogicalOverloaderTest.php b/legacy/tests/LogicalOverloaderTest.php similarity index 100% rename from tests/LogicalOverloaderTest.php rename to legacy/tests/LogicalOverloaderTest.php diff --git a/tests/Operators/InOverloaderTest.php b/legacy/tests/Operators/InOverloaderTest.php similarity index 100% rename from tests/Operators/InOverloaderTest.php rename to legacy/tests/Operators/InOverloaderTest.php diff --git a/tests/OverloaderManagerTest.php b/legacy/tests/OverloaderManagerTest.php similarity index 100% rename from tests/OverloaderManagerTest.php rename to legacy/tests/OverloaderManagerTest.php diff --git a/tests/Patterns/ExpressionMatcherTest.php b/legacy/tests/Patterns/ExpressionMatcherTest.php similarity index 100% rename from tests/Patterns/ExpressionMatcherTest.php rename to legacy/tests/Patterns/ExpressionMatcherTest.php diff --git a/tests/Patterns/LiteralMatcherTest.php b/legacy/tests/Patterns/LiteralMatcherTest.php similarity index 100% rename from tests/Patterns/LiteralMatcherTest.php rename to legacy/tests/Patterns/LiteralMatcherTest.php diff --git a/tests/Patterns/WildcardMatcherTest.php b/legacy/tests/Patterns/WildcardMatcherTest.php similarity index 100% rename from tests/Patterns/WildcardMatcherTest.php rename to legacy/tests/Patterns/WildcardMatcherTest.php diff --git a/tests/Resolvers/DelegatingResolverTest.php b/legacy/tests/Resolvers/DelegatingResolverTest.php similarity index 100% rename from tests/Resolvers/DelegatingResolverTest.php rename to legacy/tests/Resolvers/DelegatingResolverTest.php diff --git a/tests/Resolvers/Fixtures/Dependency.php b/legacy/tests/Resolvers/Fixtures/Dependency.php similarity index 100% rename from tests/Resolvers/Fixtures/Dependency.php rename to legacy/tests/Resolvers/Fixtures/Dependency.php diff --git a/tests/Resolvers/Fixtures/ResolverWithDependency.php b/legacy/tests/Resolvers/Fixtures/ResolverWithDependency.php similarity index 100% rename from tests/Resolvers/Fixtures/ResolverWithDependency.php rename to legacy/tests/Resolvers/Fixtures/ResolverWithDependency.php diff --git a/tests/Resolvers/Fixtures/SpyInspector.php b/legacy/tests/Resolvers/Fixtures/SpyInspector.php similarity index 100% rename from tests/Resolvers/Fixtures/SpyInspector.php rename to legacy/tests/Resolvers/Fixtures/SpyInspector.php diff --git a/tests/Resolvers/InfixResolverTest.php b/legacy/tests/Resolvers/InfixResolverTest.php similarity index 100% rename from tests/Resolvers/InfixResolverTest.php rename to legacy/tests/Resolvers/InfixResolverTest.php diff --git a/tests/Resolvers/MatchResolverTest.php b/legacy/tests/Resolvers/MatchResolverTest.php similarity index 100% rename from tests/Resolvers/MatchResolverTest.php rename to legacy/tests/Resolvers/MatchResolverTest.php diff --git a/tests/Resolvers/MemberAccessResolverTest.php b/legacy/tests/Resolvers/MemberAccessResolverTest.php similarity index 100% rename from tests/Resolvers/MemberAccessResolverTest.php rename to legacy/tests/Resolvers/MemberAccessResolverTest.php diff --git a/tests/Resolvers/ResolutionInspectorTest.php b/legacy/tests/Resolvers/ResolutionInspectorTest.php similarity index 100% rename from tests/Resolvers/ResolutionInspectorTest.php rename to legacy/tests/Resolvers/ResolutionInspectorTest.php diff --git a/tests/Resolvers/StaticResolverTest.php b/legacy/tests/Resolvers/StaticResolverTest.php similarity index 100% rename from tests/Resolvers/StaticResolverTest.php rename to legacy/tests/Resolvers/StaticResolverTest.php diff --git a/tests/Resolvers/SymbolResolverTest.php b/legacy/tests/Resolvers/SymbolResolverTest.php similarity index 100% rename from tests/Resolvers/SymbolResolverTest.php rename to legacy/tests/Resolvers/SymbolResolverTest.php diff --git a/tests/Resolvers/UnaryResolverTest.php b/legacy/tests/Resolvers/UnaryResolverTest.php similarity index 100% rename from tests/Resolvers/UnaryResolverTest.php rename to legacy/tests/Resolvers/UnaryResolverTest.php diff --git a/tests/Resolvers/ValueResolverTest.php b/legacy/tests/Resolvers/ValueResolverTest.php similarity index 100% rename from tests/Resolvers/ValueResolverTest.php rename to legacy/tests/Resolvers/ValueResolverTest.php diff --git a/legacy/tests/SymbolRegistryTest.php b/legacy/tests/SymbolRegistryTest.php new file mode 100644 index 0000000..e90e190 --- /dev/null +++ b/legacy/tests/SymbolRegistryTest.php @@ -0,0 +1,146 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Symbol values must be either Source instances or arrays of Sources'); + + (new SymbolRegistry([ + 'test' => 42, + ])); + } + + #[Test] + public function it_can_get_a_symbol_without_namespace(): void + { + $registry = new SymbolRegistry([ + 'A' => new StaticSource(1), + 'B' => new StaticSource(2), + ]); + + $result = $registry->get('A'); + $this->assertTrue($result->isSome()); + $this->assertInstanceOf(StaticSource::class, $result->unwrap()); + $this->assertEquals(1, $result->unwrap()->value); + } + + #[Test] + public function it_returns_none_for_nonexistent_symbol(): void + { + $registry = new SymbolRegistry([ + 'A' => new StaticSource(1), + ]); + + $result = $registry->get('B'); + $this->assertTrue($result->isNone()); + } + + #[Test] + public function it_can_get_a_namespaced_symbol(): void + { + $registry = new SymbolRegistry([ + 'math' => [ + 'pi' => new StaticSource(3.14), + 'e' => new StaticSource(2.71), + ], + 'constants' => [ + 'c' => new StaticSource(299792458), + ], + ]); + + $result = $registry->get('pi', 'math'); + $this->assertTrue($result->isSome()); + $this->assertEquals(3.14, $result->unwrap()->value); + + $result = $registry->get('e', 'math'); + $this->assertTrue($result->isSome()); + $this->assertEquals(2.71, $result->unwrap()->value); + + $result = $registry->get('c', 'constants'); + $this->assertTrue($result->isSome()); + $this->assertEquals(299792458, $result->unwrap()->value); + } + + #[Test] + public function it_returns_none_for_nonexistent_namespaced_symbol(): void + { + $registry = new SymbolRegistry([ + 'math' => [ + 'pi' => new StaticSource(3.14), + ], + ]); + + // Wrong namespace + $result = $registry->get('pi', 'physics'); + $this->assertTrue($result->isNone()); + + // Wrong name in correct namespace + $result = $registry->get('e', 'math'); + $this->assertTrue($result->isNone()); + } + + #[Test] + public function it_distinguishes_between_namespaced_and_non_namespaced_symbols(): void + { + $registry = new SymbolRegistry([ + 'value' => new StaticSource(1), + 'ns' => [ + 'value' => new StaticSource(2), + ], + ]); + + // Getting without namespace should return the non-namespaced symbol + $result = $registry->get('value'); + $this->assertTrue($result->isSome()); + $this->assertEquals(1, $result->unwrap()->value); + + // Getting with namespace should return the namespaced symbol + $result = $registry->get('value', 'ns'); + $this->assertTrue($result->isSome()); + $this->assertEquals(2, $result->unwrap()->value); + } + + #[Test] + public function it_supports_nested_namespace_keys(): void + { + $registry = new SymbolRegistry([ + 'level1' => [ + 'level2.value' => new StaticSource(42), + ], + ]); + + $result = $registry->get('level2.value', 'level1'); + $this->assertTrue($result->isSome()); + $this->assertEquals(42, $result->unwrap()->value); + } + + #[Test] + public function it_must_validate_namespaced_array_contains_only_sources(): void + { + $this->expectException(AssertException::class); + + (new SymbolRegistry([ + 'math' => [ + 'pi' => new StaticSource(3.14), + 'invalid' => 42, // Invalid: not a Source instance + ], + ])); + } +} diff --git a/tests/Types/BooleanTypeTest.php b/legacy/tests/Types/BooleanTypeTest.php similarity index 100% rename from tests/Types/BooleanTypeTest.php rename to legacy/tests/Types/BooleanTypeTest.php diff --git a/tests/Types/DictTypeTest.php b/legacy/tests/Types/DictTypeTest.php similarity index 100% rename from tests/Types/DictTypeTest.php rename to legacy/tests/Types/DictTypeTest.php diff --git a/tests/Types/ListTypeTest.php b/legacy/tests/Types/ListTypeTest.php similarity index 100% rename from tests/Types/ListTypeTest.php rename to legacy/tests/Types/ListTypeTest.php diff --git a/tests/Types/NumberTypeTest.php b/legacy/tests/Types/NumberTypeTest.php similarity index 100% rename from tests/Types/NumberTypeTest.php rename to legacy/tests/Types/NumberTypeTest.php diff --git a/tests/Types/StringTypeTest.php b/legacy/tests/Types/StringTypeTest.php similarity index 100% rename from tests/Types/StringTypeTest.php rename to legacy/tests/Types/StringTypeTest.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 4cad4cc..97f8527 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,7 +1,8 @@ parameters: paths: - src/ + - tests/ level: max - treatPhpDocTypesAsCertain: false \ No newline at end of file + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d311187..9eb1d80 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,42 +2,23 @@ tests - - - - - - - - - - - - - ./src - \ No newline at end of file + diff --git a/pint.json b/pint.json index 6a5955e..3f49bd3 100644 --- a/pint.json +++ b/pint.json @@ -1,6 +1,6 @@ { "preset": "per", "rules": { - "declare_strict_types": true + "declare_strict_types": true } -} \ No newline at end of file +} diff --git a/playground/index.html b/playground/index.html new file mode 100644 index 0000000..13f2b81 --- /dev/null +++ b/playground/index.html @@ -0,0 +1,106 @@ + + + + + + Axiom Playground + + + +
+
+

Axiom Playground

+
+ + + + +
+
+
+
+
+
+
+
+
+

Input (JSON)

+ +
+
+

Output

+

+        
+
+

Diagnostics

+
No issues
+
+
+
+
+ + + diff --git a/playground/package-lock.json b/playground/package-lock.json new file mode 100644 index 0000000..e0297fd --- /dev/null +++ b/playground/package-lock.json @@ -0,0 +1,1128 @@ +{ + "name": "axiom-playground", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "axiom-playground", + "version": "0.1.0", + "dependencies": { + "monaco-editor": "^0.52.2" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/playground/package.json b/playground/package.json new file mode 100644 index 0000000..0232601 --- /dev/null +++ b/playground/package.json @@ -0,0 +1,18 @@ +{ + "name": "axiom-playground", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "monaco-editor": "^0.52.2" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/playground/src/editor/language.ts b/playground/src/editor/language.ts new file mode 100644 index 0000000..cc0d900 --- /dev/null +++ b/playground/src/editor/language.ts @@ -0,0 +1,115 @@ +import * as monaco from 'monaco-editor'; + +export function registerAxiomLanguage() { + monaco.languages.register({ id: 'axiom' }); + + monaco.languages.setLanguageConfiguration('axiom', { + comments: { lineComment: '//' }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"', notIn: ['string'] }, + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + ], + indentationRules: { + increaseIndentPattern: /[{([].*$/, + decreaseIndentPattern: /^\s*[})\]]/, + }, + }); + + monaco.languages.setMonarchTokensProvider('axiom', { + keywords: [ + 'type', 'namespace', 'source', 'table', 'if', 'then', 'else', 'match', + 'not', 'in', 'as', 'any', 'all', 'collect', 'where', + ], + + typeKeywords: [ + 'number', 'string', 'bool', 'list', 'dict', 'money', + ], + + constants: ['true', 'false'], + + operators: [ + '=', '==', '!=', '<', '>', '<=', '>=', + '&&', '||', '!', '+', '-', '*', '/', '%', '**', + '=>', '|', '..', '...', + ], + + symbols: /[=>/, 'delimiter.arrow'], + [/[=> match limit { 500000 => £1125, 1000000 => £1500, 2000000 => £1950, 3000000 => £2175, 4000000 => £2400, 5000000 => £2475, _ => £0 }, + "DRI-129" => match limit { 500000 => £750, 1000000 => £1000, 2000000 => £1300, 3000000 => £1450, 4000000 => £1600, 5000000 => £1650, _ => £0 }, + "DRI-138" => match limit { 500000 => £225, 1000000 => £300, 2000000 => £390, 3000000 => £435, 4000000 => £480, 5000000 => £495, _ => £0 }, + "DRI-139" => match limit { 500000 => £263, 1000000 => £350, 2000000 => £455, 3000000 => £508, 4000000 => £560, 5000000 => £578, _ => £0 }, + "DRI-140" => match limit { 500000 => £225, 1000000 => £300, 2000000 => £390, 3000000 => £435, 4000000 => £480, 5000000 => £495, _ => £0 }, + "DRI-170" => match limit { 500000 => £75, 1000000 => £100, 2000000 => £130, 3000000 => £145, 4000000 => £160, 5000000 => £165, _ => £0 }, + "DRI-198" => match limit { 500000 => £938, 1000000 => £1250, 2000000 => £1625, 3000000 => £1813, 4000000 => £2000, 5000000 => £2063, _ => £0 }, + "DRI-236" => match limit { 500000 => £45, 1000000 => £60, 2000000 => £78, 3000000 => £87, 4000000 => £96, 5000000 => £99, _ => £0 }, + "DRI-247" => match limit { 500000 => £563, 1000000 => £750, 2000000 => £975, 3000000 => £1088, 4000000 => £1200, 5000000 => £1238, _ => £0 }, + "DRI-253" => match limit { 500000 => £900, 1000000 => £1200, 2000000 => £1560, 3000000 => £1740, 4000000 => £1920, 5000000 => £1980, _ => £0 }, + "DRI-263" => match limit { 500000 => £750, 1000000 => £1000, 2000000 => £1300, 3000000 => £1450, 4000000 => £1600, 5000000 => £1650, _ => £0 }, + "DRI-295" => match limit { 500000 => £900, 1000000 => £1200, 2000000 => £1560, 3000000 => £1740, 4000000 => £1920, 5000000 => £1980, _ => £0 }, + "DRI-318" => match limit { 500000 => £563, 1000000 => £750, 2000000 => £975, 3000000 => £1088, 4000000 => £1200, 5000000 => £1238, _ => £0 }, + "DRI-319" => match limit { 500000 => £49, 1000000 => £65, 2000000 => £85, 3000000 => £94, 4000000 => £104, 5000000 => £107, _ => £0 }, + _ => £0, + } +} + +// Inline excess lookup table (from excess.csv) +ExcessLookup(industry: string): money(GBP) { + match industry { + "DRI-106" => £2000, + "DRI-129" => £1000, + "DRI-138" => £500, + "DRI-139" => £250, + "DRI-140" => £500, + "DRI-170" => £150, + "DRI-198" => £1000, + "DRI-236" => £500, + "DRI-247" => £1000, + "DRI-253" => £2000, + "DRI-263" => £1000, + "DRI-295" => £2000, + "DRI-318" => £1000, + "DRI-319" => £250, + _ => £0, + } +} + +// Industry-specific endorsements +IndustryEndorsements(industry: string): list(Endorsement) { + match industry { + "DRI-129" => [ + applied { code: "END-01", title: "Chiropodist / Podiatrist Exclusion" }, + ], + "DRI-138" | "DRI-139" | "DRI-140" => [ + applied { code: "END-03", title: "Direct Access Extension" }, + applied { code: "END-12", title: "Teeth Whitening Condition" }, + ], + "DRI-247" => [ + applied { code: "END-08", title: "Opticians / Optical Exclusion" }, + ], + "DRI-263" => [ + applied { code: "END-10", title: "Professional Sports Exclusion" }, + applied { code: "END-11", title: "Spinal Joint Manipulation Exclusion" }, + ], + "DRI-318" => [ + applied { code: "END-02", title: "Diagnostic and Interpretation Exclusion" }, + ], + _ => [], + } +} + +// Cover calculation +HealthcareProfessional( + industry: string, + limit: number, + commission_rate: number, +): CoverOutcome { + if PremiumLookup(industry: industry, limit: limit) == £0 + then referred { reason: "Industry not rated" } + else rated { + gross_premium: PremiumLookup(industry: industry, limit: limit), + net_premium: round(PremiumLookup(industry: industry, limit: limit) * (1 - commission_rate), 2), + commission: round(PremiumLookup(industry: industry, limit: limit) * commission_rate, 2), + excess: ExcessLookup(industry: industry), + limit: limit, + inquest_costs: £25000, + endorsements: IndustryEndorsements(industry: industry), + } +} + +// Product assembly — wraps cover outcome with product-level metadata +// Propagates cover referrals as product declines +BuildProduct(cover: CoverOutcome): ProductOutcome { + match cover { + referred { reason } => declined { reasons: [reason] }, + _ => offered { + cover: cover, + umr: "B1792SPR2500004A", + currency: "GBP", + jurisdiction: "Great Britain, Northern Ireland, Isle of Man and Channel Islands", + }, + } +} + +// Product entry point — validates exposure constraints, then rates +Product( + industry: string, + limit: number, + number_of_employees: number, +): ProductOutcome { + if number_of_employees > 1 + then declined { reasons: ["Maximum 1 employee allowed"] } + else BuildProduct(cover: HealthcareProfessional( + industry: industry, + limit: limit, + commission_rate: 0.33, + )) +} +`; + +// Scenario: Dental Hygienist (DRI-138) at £1,000,000 limit, sole trader +export const HEALTHCARE_INPUT = { + industry: "DRI-138", + limit: 1000000, + number_of_employees: 1, +}; diff --git a/playground/src/examples/hospitality.axiom.ts b/playground/src/examples/hospitality.axiom.ts new file mode 100644 index 0000000..ada1343 --- /dev/null +++ b/playground/src/examples/hospitality.axiom.ts @@ -0,0 +1,816 @@ +import industryConfigCSV from './industry_config.csv?raw'; +import { parseCSV } from '../utils/csv'; + +export const HOSPITALITY_EXAMPLE = `// AIG Hospitality V2 +// Translated from Abacus ProductSchemeBuilder + 6 CoverSchemeBuilders +// Subset: 4 industries, simplified BC (no alarm/construction rules) + +// --- Input record types --- + +type Exposure = { + industry: string, + number_of_employees: number, + turnover: number, + is_sole_trader: bool, + years_experience: number, +} + +type ClaimsHistory = { + number_of_claims: number, + total_claims_value: number, +} + +type RiskScores = { + flood_risk: number, + theft_risk: number, + terrorism_risk: number, +} + +type BCConfig = { + buildings_limit: number, + contents_limit: number, + stock_limit: number, + listed_type: string, + has_outdoor_play: bool, + has_functions: bool, + number_of_beds: string, + has_heavy_deep_frying: bool, +} + +type BIConfig = { + basis_of_cover: string, + basis_of_cover_limit: number, + indemnity_months: number, + rent_receivable_limit: number, + loss_of_licence_limit: number, +} + +// --- Outcome types --- + +type CoverOutcome = + rated { + key: string, + name: string, + base_premium: number, + limit: number, + excess: number, + } + | not_available { reason: string } + +type ProductOutcome = + offered { + covers: list(CoverOutcome), + subtotal: number, + minimum_premium: number, + total_gross_premium: number, + total_net_premium: number, + commission_rate: number, + currency: string, + } + | declined { reasons: list(string) } + | referred { reasons: list(string) } + +type MultiIndustrySummary = { + pl_classes: list(string), + el_classes: list(string), + deep_frying_rates: list(number), + minimum_premiums: list(number), +} + +// --- Industry configuration (from industry_config.csv, 28 columns x 8 industries) --- +// Table declaration — typed companion data, loaded from CSV artifact at deploy time + +table industry_config: list({ + code: string, + buildings_fire_class: string, + contents_fire_class: string, + contents_theft_class: string, + stock_theft_class: string, + bi_fire_class: string, + pl_class: string, + pl_severity: number, + el_class: string, + el_severity: number, + flood_banding: string, + loss_of_licence_class: string, + deep_frying_rate: number, + min_premium_claims_free: number, + min_premium_default: number, +}) + +// Each lookup is a pure expression that searches the table +namespace Industry { + BuildingsFireClass(industry: string): string { + match row in industry_config { row.code == industry => row.buildings_fire_class, _ => "" } + } + ContentsFireClass(industry: string): string { + match row in industry_config { row.code == industry => row.contents_fire_class, _ => "" } + } + ContentsTheftClass(industry: string): string { + match row in industry_config { row.code == industry => row.contents_theft_class, _ => "" } + } + StockTheftClass(industry: string): string { + match row in industry_config { row.code == industry => row.stock_theft_class, _ => "" } + } + BIFireClass(industry: string): string { + match row in industry_config { row.code == industry => row.bi_fire_class, _ => "" } + } + PLClass(industry: string): string { + match row in industry_config { row.code == industry => row.pl_class, _ => "" } + } + ELClass(industry: string): string { + match row in industry_config { row.code == industry => row.el_class, _ => "" } + } + FloodBanding(industry: string): string { + match row in industry_config { row.code == industry => row.flood_banding, _ => "" } + } + LossOfLicenceClass(industry: string): string { + match row in industry_config { row.code == industry => row.loss_of_licence_class, _ => "" } + } + DeepFryingRate(industry: string): number { + match row in industry_config { row.code == industry => row.deep_frying_rate, _ => 0 } + } + MinPremiumClaimsFree(industry: string): number { + match row in industry_config { row.code == industry => row.min_premium_claims_free, _ => 0 } + } + MinPremiumDefault(industry: string): number { + match row in industry_config { row.code == industry => row.min_premium_default, _ => 0 } + } + + // --- Multi-industry lookups --- + // Core v1 examples use collection queries directly rather than max/min aggregates. +} + +// --- Claims loading system (product-level, shared across all covers) --- + +namespace Claims { + // Years trading coefficient and loading (from years_trading_coefficient_and_loads.csv) + // Years of experience are capped at 5. + YearsTradingLoading(is_sole_trader: bool, years_experience: number): number { + match (is_sole_trader, capped_years) { + (false, [0..1]) => 1, + (false, 2) => 0.5, + (false, 3) => 0.25, + (false, 4) => 0, + (false, 5) => -0.25, + (true, [0..2]) => 1, + (true, 3) => 0.25, + (true, 4) => 0, + (true, 5) => -0.25, + _ => 0, + } + where capped_years = if years_experience > 5 then 5 else years_experience + } + + // Coefficient letter determines which column to use in claims cross-lookup + Coefficient(is_sole_trader: bool, years_experience: number): string { + match (is_sole_trader, capped_years) { + (false, [0..1]) | (true, [0..1]) => "A", + (false, 2) | (true, 2) => "B", + (false, 3) | (true, 3) => "C", + (false, 4) | (true, 4) => "D", + (false, 5) | (true, 5) => "E", + _ => "A", + } + where capped_years = if years_experience > 5 then 5 else years_experience + } + + // Claims x years-trading cross-lookup (from claims_years_trading_loadings.csv) + // Row = number_of_claims, column = coefficient letter + ClaimsYearsTradingLoading(number_of_claims: number, coefficient: string): number { + match (number_of_claims, coefficient) { + (0, "A") | (0, "B") => 0, + (0, "C") => -0.1, + (0, "D") | (0, "E") => -0.2, + (1, "A") => 1, + (1, "B") => 0.1, + (1, "C") => 0.1, + (1, "D") | (1, "E") => 0.05, + (2, "A") => 5, + (2, "B") => 0.675, + (2, "C") => 0.2, + (2, "D") | (2, "E") => 0.125, + (3, "A") => 6, + (3, "B") => 1.75, + (3, "C") => 0.675, + (3, "D") | (3, "E") => 0.3, + (4, "A") | (4, "B") => 10, + (4, "C") => 1.75, + (4, "D") => 0.68, + (4, "E") => 0.675, + (5, "A") | (5, "B") => 25, + (5, "C") => 10, + (5, "D") | (5, "E") => 1.75, + _ => 100, + } + } + + // Claims value loading (from claims_value_loadings.csv, range-based) + ClaimsValueLoading(total_claims_value: number): number { + match total_claims_value { + [0..400) => 0, + [400..600) => 0.1, + [600..700) => 0.2, + [700..800) => 0.3, + [800..900) => 0.4, + [900..1000) => 0.5, + [1000..2000) => 0.75, + [2000..3000) => 1.25, + [3000..4000) => 2, + [4000..5000) => 3.25, + [5000..6000) => 5.25, + [6000..8000) => 8.5, + [8000..9000) => 9.5, + [9000..10000) => 10, + [10000..) => 20, + _ => 0, + } + } + + // Total claims loading: sum of all three components + TotalLoading(exposure: Exposure, claims: ClaimsHistory): number { + YearsTradingLoading(exposure.is_sole_trader, exposure.years_experience) + + ClaimsYearsTradingLoading(claims.number_of_claims, coefficient) + + ClaimsValueLoading(claims.total_claims_value) + where coefficient = Coefficient(exposure.is_sole_trader, exposure.years_experience) + } +} + +// --- Minimum premium logic --- + +MinimumPremium(exposure: Exposure, claims: ClaimsHistory, bc: BCConfig): number { + if exposure.years_experience >= 2 && claims.number_of_claims == 0 && not bc.has_heavy_deep_frying + then Industry.MinPremiumClaimsFree(exposure.industry) + else Industry.MinPremiumDefault(exposure.industry) +} + +// --- Public Liability (PL) — mandatory --- + +namespace PublicLiability { + // Base rate by PL classification (from base_rates.csv) + BaseRate(classification: string): number { + match classification { + "A" | "B" => 0.01, + "C" => 0.0175, + _ => 0.01, + } + } + + // Turnover size discount (from discounts.csv) + TurnoverDiscount(turnover: number): number { + match turnover { + [0..100000) => 0, + [100000..150000) => -0.05, + [150000..200000) => -0.075, + [200000..250000) => -0.1, + _ => 0, + } + } + + // Limit loading (from loadings.csv) + LimitLoading(limit: number): number { + match limit { + 1000000 => -0.1, + 2000000 => 0, + 5000000 => 0.25, + _ => 0, + } + } + + Rate(exposure: Exposure, limit: number, total_claims_loading: number): CoverOutcome { + rated { + key: "PL", + name: "Public and products liability", + base_premium: (exposure.turnover / 100) * (base_rate * (1 + total_loads)), + limit: limit, + excess: 250, + } + where classification = Industry.PLClass(exposure.industry), + base_rate = BaseRate(classification), + total_loads = LimitLoading(limit) + total_claims_loading + TurnoverDiscount(exposure.turnover) + } +} + +// --- Buildings, Contents and Stock (BC) — mandatory --- + +namespace BuildingsContentsStock { + // Buildings rate by fire class (from buildingsRates.csv, 25 classes A-Y) + BuildingsRate(classification: string): number { + match classification { + "B" => 0.15, + "Y" => 0.21, + _ => 0.1, + } + } + + // Material damage (contents fire) rate (from materialDamageRates.csv) + MaterialDamageRate(classification: string): number { + match classification { + "B" => 0.0658, + "Y" => 0.15, + _ => 0.07, + } + } + + // Contents theft rate (from contentsAndStockTheftRates.csv) + ContentsTheftRate(classification: string): number { + match classification { + "B" => 0.05625, + "F" => 0.06066, + _ => 0.06, + } + } + + // Stock theft rate (from contentsAndStockTheftRates.csv) + StockTheftRate(classification: string): number { + match classification { + "C" => 0.065, + "V" => 0.5, + _ => 0.08, + } + } + + // Theft area rate adjustment by risk score (from contentsAndStockTheftAreaRates.csv) + TheftAreaRate(theft_risk: number): number { + match theft_risk { + 1 => 0.25, 2 => 0.2, 3 => 0.15, 4 => 0.1, + 5 | 6 => 0, + 7 => -0.1, 8 => -0.15, 9 => -0.2, 10 => -0.25, + _ => 0, + } + } + + // Buildings size discount (from buildingsSizeDiscounts.csv) + BuildingsSizeDiscount(sum_insured: number): number { + match sum_insured { + 0 => 0, + [1..125000) => -0.025, + [125000..250000) => -0.05, + [250000..375000) => -0.0625, + [375000..500000) => -0.075, + [500000..625000) => -0.0875, + [625000..750000) => -0.1, + [750000..875000) => -0.125, + [875000..) => -0.15, + _ => 0, + } + } + + // Contents and stock size discount (from contentsAndStockSizeDiscounts.csv) + ContentsSizeDiscount(sum_insured: number): number { + match sum_insured { + 0 => 0, + [1..125000) => -0.025, + [125000..250000) => -0.05, + [250000..375000) => -0.0625, + [375000..500000) => -0.075, + [500000..625000) => -0.0875, + [625000..750000) => -0.1, + [750000..875000) => -0.125, + [875000..) => -0.15, + _ => 0, + } + } + + // Flood rate adjustment (from floodTable.csv, by risk score and banding) + FloodAdjustment(flood_risk: number, flood_banding: string): number { + match flood_risk { + 7 | 8 => 0.1, + _ => 0, + } + } + + // Listed building loading (from listedBuildingTable.csv) + ListedBuildingLoading(listed_type: string): number { + match listed_type { + "notListed" => 0, + "grade2Listed" | "gradeB2Listed" | "gradeCsListed" => 0.4, + _ => 0, + } + } + + // Facility type loading (from facilitiesTable.csv, summed for selected types) + FacilityLoading(has_outdoor_play: bool, has_functions: bool): number { + (if has_outdoor_play then 1 else 0) + + (if has_functions then 0.25 else 0) + } + + // Number of beds discount (from numberOfBedsTable.csv) + BedsDiscount(number_of_beds: string): number { + match number_of_beds { + "upTo5" => -0.05, + "upTo10" => 0, + "upTo20" => 1, + _ => 0, + } + } + + // Premises factor: combined loading from building characteristics + PremisesFactor( + listed_type: string, + flood_risk: number, + flood_banding: string, + has_outdoor_play: bool, + has_functions: bool, + number_of_beds: string, + ): number { + ListedBuildingLoading(listed_type) + + FloodAdjustment(flood_risk, flood_banding) + + FacilityLoading(has_outdoor_play, has_functions) + + BedsDiscount(number_of_beds) + } + + // Buildings section premium + BuildingsPremium( + sum_insured: number, + classification: string, + premises_factor: number, + total_claims_loading: number, + ): number { + (sum_insured / 100) * (rate * (1 + total_loads)) + where rate = BuildingsRate(classification), + total_loads = premises_factor + + BuildingsSizeDiscount(sum_insured) + + total_claims_loading + } + + // Contents section premium + ContentsPremium( + sum_insured: number, + fire_class: string, + theft_class: string, + theft_risk: number, + premises_factor: number, + total_claims_loading: number, + ): number { + (sum_insured / 100) * (rate * (1 + total_loads)) + where rate = MaterialDamageRate(classification: fire_class) + ContentsTheftRate(classification: theft_class), + total_loads = premises_factor + + ContentsSizeDiscount(sum_insured) + + TheftAreaRate(theft_risk) + + total_claims_loading + } + + // Stock section premium + StockPremium( + sum_insured: number, + fire_class: string, + stock_theft_class: string, + theft_risk: number, + premises_factor: number, + total_claims_loading: number, + ): number { + (sum_insured / 100) * (rate * (1 + total_loads)) + where rate = MaterialDamageRate(classification: fire_class) + StockTheftRate(classification: stock_theft_class), + total_loads = premises_factor + + ContentsSizeDiscount(sum_insured) + + TheftAreaRate(theft_risk) + + total_claims_loading + } + + Rate(exposure: Exposure, bc: BCConfig, risks: RiskScores, total_claims_loading: number): CoverOutcome { + rated { + key: "BC", + name: "Buildings, contents and stock", + base_premium: buildings_prem + contents_prem + stock_prem, + limit: bc.buildings_limit + bc.contents_limit + bc.stock_limit, + excess: 400, + } + where flood_banding = Industry.FloodBanding(exposure.industry), + fire_class = Industry.BuildingsFireClass(exposure.industry), + contents_fire = Industry.ContentsFireClass(exposure.industry), + theft_class = Industry.ContentsTheftClass(exposure.industry), + stock_theft_class = Industry.StockTheftClass(exposure.industry), + pf = BuildingsContentsStock.PremisesFactor( + listed_type: bc.listed_type, + flood_risk: risks.flood_risk, + flood_banding, + has_outdoor_play: bc.has_outdoor_play, + has_functions: bc.has_functions, + number_of_beds: bc.number_of_beds, + ), + buildings_prem = BuildingsPremium( + sum_insured: bc.buildings_limit, + classification: fire_class, + premises_factor: pf, + total_claims_loading, + ), + contents_prem = ContentsPremium( + sum_insured: bc.contents_limit, + fire_class: contents_fire, + theft_class, theft_risk: risks.theft_risk, + premises_factor: pf, + total_claims_loading, + ), + stock_prem = StockPremium( + sum_insured: bc.stock_limit, + fire_class: contents_fire, + stock_theft_class, theft_risk: risks.theft_risk, + premises_factor: pf, + total_claims_loading, + ) + } +} + +// --- Business Interruption (BI) — optional --- + +namespace BusinessInterruption { + // BI fire rate (from bi_fire_rates.csv) + BIFireRate(classification: string): number { + match classification { + "C" => 0.15, + "I" => 0.154, + "Y" => 0.5, + _ => 0.08, + } + } + + // Basis of cover discount (from basis_of_cover_rates.csv) + BasisOfCoverDiscount(basis_of_cover: string): number { + match basis_of_cover { + "Gross Profit" => 0, + "Gross Revenue" => -0.5, + "Increased Cost of Working" => 0.5, + _ => 0, + } + } + + // Indemnity period discount and months (from indemnity_period_rates.csv) + IndemnityDiscount(indemnity_months: number): number { + match indemnity_months { + 12 => 0, + 18 => -0.1, + 24 => -0.2, + 36 => -0.3, + _ => 0, + } + } + + // Sum insured discount (from sum_insured_discounts.csv) + SumInsuredDiscount(sum_insured: number): number { + match sum_insured { + [1..125000) => -0.025, + [125000..250000) => -0.05, + [250000..375000) => -0.0625, + [375000..500000) => -0.075, + [500000..625000) => -0.0875, + [625000..750000) => -0.1, + [750000..875000) => -0.125, + [875000..1000000) => -0.15, + [1000000..1125000) => -0.1675, + [1125000..1250000) => -0.175, + [1250000..1375000) => -0.1875, + [1375000..1500001) => -0.2, + _ => 0, + } + } + + // Loss of licence rate (from loss_of_license_rates.csv) + LossOfLicenceDiscount(lol_class: string): number { + match lol_class { + "Low" => 0.1, + "Medium" => 0.125, + "High" => 0.15, + _ => 0.15, + } + } + + Rate(exposure: Exposure, bi: BIConfig, total_claims_loading: number): CoverOutcome { + rated { + key: "BI", + name: "Business interruption", + base_premium: bi_premium + lol_premium, + limit: sum_insured, + excess: 0, + } + where basis_si = bi.basis_of_cover_limit * (bi.indemnity_months / 12), + sum_insured = basis_si + bi.rent_receivable_limit, + bi_rate = BIFireRate(classification: Industry.BIFireClass(exposure.industry)), + bi_loads = SumInsuredDiscount(sum_insured) + + IndemnityDiscount(indemnity_months: bi.indemnity_months) + + BasisOfCoverDiscount(basis_of_cover: bi.basis_of_cover) + + total_claims_loading, + bi_premium = (sum_insured / 100) * (bi_rate * (1 + bi_loads)), + lol_class = Industry.LossOfLicenceClass(exposure.industry), + lol_rate = 0.0125, + lol_loads = LossOfLicenceDiscount(lol_class) + total_claims_loading, + lol_premium = if bi.loss_of_licence_limit > 0 + then (bi.loss_of_licence_limit / 100) * (lol_rate * (1 + lol_loads)) + else 0 + } +} + +// --- Employers Liability (EL) --- + +namespace EmployersLiability { + // Base rate by EL classification (from base_rates.csv) + BaseRate(classification: string): number { + match classification { + "A" => 0.005524, + "B" => 0.008825, + "C" => 0.017, + _ => 0.006825, + } + } + + // Turnover size discount (from discounts.csv) + TurnoverDiscount(turnover: number): number { + match turnover { + [0..100000) => 0, + [100000..150000) => -0.025, + [150000..200000) => -0.05, + [200000..250000) => -0.1, + [250000..500000) => -0.125, + [500000..750000) => -0.15, + [750000..) => -0.2, + _ => 0, + } + } + + Rate(exposure: Exposure, total_claims_loading: number): CoverOutcome { + rated { + key: "EL", + name: "Employers liability", + base_premium: (exposure.turnover / 100) * (base_rate * (1 + total_loads)), + limit: 10000000, + excess: 0, + } + where classification = Industry.ELClass(exposure.industry), + base_rate = BaseRate(classification), + total_loads = TurnoverDiscount(exposure.turnover) + total_claims_loading + } +} + +// --- Portable Business Equipment (PBE) --- + +namespace PortableEquipment { + Rate(limit: number, total_claims_loading: number): CoverOutcome { + if limit == 0 + then not_available { reason: "PBE not selected" } + else rated { + key: "EPE", + name: "Portable business equipment", + base_premium: (limit / 100) * (2.5 * (1 + total_claims_loading)), + limit: limit, + excess: 400, + } + } +} + +// --- Terrorism (TER) — optional, excluded from minimum premium floor --- + +namespace Terrorism { + // Postcode zone from terrorism risk score (from postcode_zone.csv + postcode_rates.csv) + PostcodeRate(terrorism_risk: number): number { + match terrorism_risk { + 1 => 0.00033, + 2 => 0.00029, + 3 | 4 => 0.00006, + _ => 0, + } + } + + Rate(risks: RiskScores, bc: BCConfig, bi: BIConfig): CoverOutcome { + if risks.terrorism_risk == 0 + then not_available { reason: "Terrorism risk zone unavailable" } + else if md_si == 0 + then not_available { reason: "No material damage sum insured" } + else rated { + key: "TER", + name: "Terrorism", + base_premium: md_si * PostcodeRate(risks.terrorism_risk) * 1.15, + limit: md_si + bi_si, + excess: 0, + } + where md_si = bc.buildings_limit + bc.contents_limit + bc.stock_limit, + bi_si = bi.basis_of_cover_limit * (bi.indemnity_months / 12) + } +} + +// --- Product entry point --- +// Validates exposure, rates all covers, applies claims loading, enforces minimum premium + +Product( + exposure: Exposure, + claims: ClaimsHistory, + risks: RiskScores, + bc: BCConfig, + bi: BIConfig, + pl_limit: number, + pbe_limit: number, +): ProductOutcome { + // Exposure validation + if exposure.number_of_employees > 49 + then declined { reasons: ["Maximum 49 employees allowed"] } + else if exposure.turnover > 5000000 + then declined { reasons: ["Maximum turnover 5,000,000"] } + else if claims.number_of_claims > 5 + then declined { reasons: ["Too many claims in history"] } + else if bc.number_of_beds == "over20" + then declined { reasons: ["Maximum 20 beds allowed"] } + // Rate all covers, check for failures, assemble product + else if any not_available in covers + then referred { + reasons: collect not_available { reason } in covers => reason, + } + else offered { + covers, + subtotal, + minimum_premium: min_prem, + total_gross_premium: round(total, 2), + total_net_premium: round(total * (1 - 0.35), 2), + commission_rate: 0.35, + currency: "GBP", + } + where total_claims_loading = Claims.TotalLoading(exposure, claims), + pl_cover = PublicLiability.Rate(exposure, limit: pl_limit, total_claims_loading), + bc_cover = BuildingsContentsStock.Rate(exposure, bc, risks, total_claims_loading), + bi_cover = BusinessInterruption.Rate(exposure, bi, total_claims_loading), + el_cover = EmployersLiability.Rate(exposure, total_claims_loading), + pbe_cover = PortableEquipment.Rate(limit: pbe_limit, total_claims_loading), + ter_cover = Terrorism.Rate(risks, bc, bi), + covers = [ + pl_cover, + bc_cover, + bi_cover, + el_cover, + pbe_cover, + ter_cover, + ], + base_sum = sum(collect rated { base_premium } in covers => base_premium), + ter_premium = match ter_cover { + rated { base_premium } => base_premium, + _ => 0, + }, + subtotal = base_sum - ter_premium, + min_prem = MinimumPremium(exposure, claims, bc), + floored_subtotal = if min_prem > subtotal then min_prem else subtotal, + total = floored_subtotal + ter_premium +} + +// --- Multi-industry demonstration --- +// Shows how a product can query multiple rows across selected industries. + +MultiIndustryDemo(industries: list(string)): MultiIndustrySummary { + { + pl_classes: collect row in industry_config { + row.code in industries => row.pl_class, + }, + el_classes: collect row in industry_config { + row.code in industries => row.el_class, + }, + deep_frying_rates: collect row in industry_config { + row.code in industries => row.deep_frying_rate, + }, + minimum_premiums: collect row in industry_config { + row.code in industries => row.min_premium_default, + }, + } +} +`; + +// Scenario: Café (DRI-945), 5 employees, £300k turnover, sole trader, 3 years exp, no claims +// Own premises: buildings £500k, contents £100k, stock £50k, not listed +// BI: Gross Profit £200k, 18 months indemnity, no loss of licence +// PBE: £5,000 | Terrorism risk zone 3 +export const HOSPITALITY_INPUT = { + exposure: { + industry: "DRI-945", + number_of_employees: 5, + turnover: 300000, + is_sole_trader: true, + years_experience: 3, + }, + claims: { + number_of_claims: 0, + total_claims_value: 0, + }, + risks: { + flood_risk: 4, + theft_risk: 6, + terrorism_risk: 3, + }, + bc: { + buildings_limit: 500000, + contents_limit: 100000, + stock_limit: 50000, + listed_type: "notListed", + has_outdoor_play: false, + has_functions: true, + number_of_beds: "none", + has_heavy_deep_frying: false, + }, + bi: { + basis_of_cover: "Gross Profit", + basis_of_cover_limit: 200000, + indemnity_months: 18, + rent_receivable_limit: 0, + loss_of_licence_limit: 0, + }, + pl_limit: 2000000, + pbe_limit: 5000, + // Table data loaded from CSV artifact (industry_config.csv) + _tables: { + industry_config: parseCSV(industryConfigCSV), + }, +}; diff --git a/playground/src/examples/industry_config.csv b/playground/src/examples/industry_config.csv new file mode 100644 index 0000000..4e8096a --- /dev/null +++ b/playground/src/examples/industry_config.csv @@ -0,0 +1,9 @@ +code,buildings_fire_class,contents_fire_class,contents_theft_class,stock_theft_class,bi_fire_class,pl_class,pl_severity,el_class,el_severity,flood_banding,loss_of_licence_class,deep_frying_rate,min_premium_claims_free,min_premium_default +DRI-945,B,B,B,C,C,A,1,A,1,A,High,0.2,400,500 +DRI-946,B,B,B,C,C,A,1,A,1,A,High,0.2,400,500 +DRI-1955,Y,Y,F,V,I,B,2,C,3,C,High,0.35,600,750 +DRI-1956,Y,Y,F,V,I,B,2,C,3,C,High,0.35,600,750 +DRI-1957,Y,Y,F,V,I,C,3,C,3,D,High,0.35,600,750 +DRI-1958,Y,Y,F,V,I,C,3,C,3,D,High,0.35,350,500 +DRI-2857,Y,Y,F,V,I,B,2,C,3,C,High,0.35,600,750 +DRI-2858,Y,Y,F,V,I,B,2,B,2,B,High,0.2,600,750 diff --git a/playground/src/examples/insurance.axiom.ts b/playground/src/examples/insurance.axiom.ts new file mode 100644 index 0000000..a35bfc7 --- /dev/null +++ b/playground/src/examples/insurance.axiom.ts @@ -0,0 +1,155 @@ +export const INSURANCE_EXAMPLE = `// Axiom v1 — Insurance Rating Example +// Try editing this code and see the output update live! + +type Endorsement = + required { code: string, title: string } + | waived { code: string, reason: string } + +type RuleResult = + ok { factor: number, notes: list(string), endorsements: list(Endorsement) } + | referred { message: string } + +type CoverOutcome = + not_selected + | rated { premium: number, loading: number, notes: list(string), endorsements: list(Endorsement) } + | referred { reasons: list(string) } + +type ProductOutcome = + offered { total: number, covers: list(CoverOutcome) } + | referred { reasons: list(string) } + +BuildingsConstructionRule(quote: { construction: string }): RuleResult { + match quote.construction { + "brick" => ok { factor: 1.00, notes: [], endorsements: [] }, + "stone" => ok { + factor: 1.05, + notes: ["stone_loading"], + endorsements: [ + required { code: "END-ST01", title: "Structural survey within 5 years" }, + ], + }, + "timber" => referred { message: "timber_construction" }, + _ => referred { message: "unknown_construction" } + } +} + +BuildingsClaimsRule(quote: { claims_count: number }): RuleResult { + match { + quote.claims_count == 0 => ok { factor: 0.95, notes: ["claims_free"], endorsements: [] }, + quote.claims_count <= 2 => ok { factor: 1.00, notes: [], endorsements: [] }, + quote.claims_count == 3 => ok { + factor: 1.20, + notes: ["claims_3"], + endorsements: [ + required { code: "END-CL01", title: "Claims history disclosure" }, + ], + }, + _ => referred { message: "claims_too_high" } + } +} + +AggregateRules( + rules: list(RuleResult), + base_premium: number, +): CoverOutcome { + if any referred in rules + then referred { + reasons: collect referred { message: m } in rules => m, + } + else rated { + premium: base_premium * product collect in rules { + ok { factor } => factor, + _ => 1.00, + }, + loading: product collect in rules { + ok { factor } => factor, + _ => 1.00, + }, + notes: flatten(collect ok { notes: n } in rules => n), + endorsements: flatten(collect ok { endorsements: e } in rules => e), + } +} + +BuildingsCover( + quote: { + has_buildings: bool, + construction: string, + claims_count: number, + buildings_sum_insured: number, + }, + base_rate: number, +): CoverOutcome { + if not quote.has_buildings + then not_selected + else AggregateRules( + rules: [ + BuildingsConstructionRule(quote: quote), + BuildingsClaimsRule(quote: quote), + ], + base_premium: quote.buildings_sum_insured / 1000 * base_rate, + ) +} + +ContentsCover( + quote: { + has_contents: bool, + contents_sum_insured: number, + }, + base_rate: number, +): CoverOutcome { + if not quote.has_contents + then not_selected + else rated { + premium: quote.contents_sum_insured / 1000 * base_rate, + loading: 1.00, + notes: [], + endorsements: [], + } +} + +AggregateCovers(covers: list(CoverOutcome)): ProductOutcome { + if any referred in covers + then referred { + reasons: flatten(collect referred { reasons: rs } in covers => rs), + } + else offered { + total: sum(collect rated { premium: p } in covers => p), + covers: covers, + } +} + +Product( + quote: { + has_buildings: bool, + construction: string, + claims_count: number, + buildings_sum_insured: number, + has_contents: bool, + contents_sum_insured: number, + }, + rates: { + buildings_rate: number, + contents_rate: number, + }, +): ProductOutcome { + AggregateCovers(covers: [ + BuildingsCover(quote: quote, base_rate: rates.buildings_rate), + ContentsCover(quote: quote, base_rate: rates.contents_rate), + ]) +} +`; + +export const INSURANCE_INPUT = { + quote: { + has_buildings: true, + construction: "brick", + claims_count: 1, + buildings_sum_insured: 500000, + has_contents: true, + contents_sum_insured: 50000, + }, + rates: { + buildings_rate: 0.50, + contents_rate: 0.75, + }, +}; diff --git a/playground/src/examples/landlords.axiom.ts b/playground/src/examples/landlords.axiom.ts new file mode 100644 index 0000000..6edf5da --- /dev/null +++ b/playground/src/examples/landlords.axiom.ts @@ -0,0 +1,349 @@ +import propertyTypeConfigCSV from './property_type_config.csv?raw'; +import { parseCSV } from '../utils/csv'; + +export const LANDLORDS_EXAMPLE = `// Landlords Property Owners +// Demonstrates nested exposures — each property is rated independently +// then aggregated with a multi-property discount + +// --- Input types --- + +type Property = { + address: string, + property_type: string, + buildings_sum_insured: number, + contents_sum_insured: number, + annual_rent: number, + year_built: number, + listed_type: string, + number_of_units: number, + flood_risk: number, + subsidence_risk: number, +} + +type LandlordExposure = { + number_of_employees: number, + turnover: number, + is_portfolio: bool, + properties: list(Property), +} + +type ClaimsHistory = { + number_of_claims: number, + total_claims_value: number, +} + +// --- Outcome types --- + +type PropertyBreakdown = { + address: string, + buildings_premium: number, + contents_premium: number, + rent_premium: number, + pol_premium: number, + property_total: number, +} + +type ProductOutcome = + offered { + property_details: list(PropertyBreakdown), + property_subtotal: number, + discount_rate: number, + property_net: number, + el_premium: number, + legal_premium: number, + total_gross: number, + total_net: number, + commission_rate: number, + currency: string, + } + | declined { reasons: list(string) } + +// --- Property type configuration (from property_type_config.csv) --- +// Rates and factors vary by property type: residential, commercial, HMO, mixed-use + +table property_type_config: list({ + property_type: string, + buildings_rate: number, + contents_rate: number, + pol_base: number, + flood_factor: number, + subsidence_factor: number, +}) + +namespace PropertyConfig { + BuildingsRate(property_type: string): number { + match row in property_type_config { row.property_type == property_type => row.buildings_rate, _ => 0.04 } + } + ContentsRate(property_type: string): number { + match row in property_type_config { row.property_type == property_type => row.contents_rate, _ => 0.05 } + } + POLBase(property_type: string): number { + match row in property_type_config { row.property_type == property_type => row.pol_base, _ => 50 } + } + FloodFactor(property_type: string): number { + match row in property_type_config { row.property_type == property_type => row.flood_factor, _ => 0.15 } + } + SubsidenceFactor(property_type: string): number { + match row in property_type_config { row.property_type == property_type => row.subsidence_factor, _ => 0.10 } + } +} + +// --- Per-property rating --- +// Each property is rated independently — buildings, contents, rent, property owners liability. +// Loadings are applied based on property characteristics (age, listed status, flood/subsidence risk). + +namespace PropertyRating { + // Listed building loading + ListedLoading(listed_type: string): number { + match listed_type { + "not_listed" => 0, + "grade_2" => 0.35, + "grade_1" => 0.75, + _ => 0, + } + } + + // Building age loading — older properties attract higher rates + AgeLoading(year_built: number): number { + match year_built { + [0..1900] => 0.25, + [1901..1945) => 0.15, + [1945..1980) => 0.05, + [1980..9999] => 0, + _ => 0, + } + } + + // Flood risk loading — scaled by property type flood factor + FloodRiskLoading(flood_risk: number, property_type: string): number { + if flood_risk <= 3 then 0 + else if flood_risk <= 6 then PropertyConfig.FloodFactor(property_type) * 0.5 + else PropertyConfig.FloodFactor(property_type) + } + + // Subsidence risk loading + SubsidenceRiskLoading(subsidence_risk: number, property_type: string): number { + if subsidence_risk <= 3 then 0 + else if subsidence_risk <= 6 then PropertyConfig.SubsidenceFactor(property_type) * 0.5 + else PropertyConfig.SubsidenceFactor(property_type) + } + + // Combined property loadings + TotalLoadings(prop: Property): number { + ListedLoading(prop.listed_type) + + AgeLoading(prop.year_built) + + FloodRiskLoading(prop.flood_risk, prop.property_type) + + SubsidenceRiskLoading(prop.subsidence_risk, prop.property_type) + } + + // Buildings section premium + BuildingsPremium(prop: Property): number { + (prop.buildings_sum_insured / 1000) * PropertyConfig.BuildingsRate(prop.property_type) * (1 + TotalLoadings(prop)) + } + + // Contents section premium + ContentsPremium(prop: Property): number { + (prop.contents_sum_insured / 1000) * PropertyConfig.ContentsRate(prop.property_type) * (1 + TotalLoadings(prop)) + } + + // Rent receivable premium + RentPremium(prop: Property): number { + if prop.annual_rent == 0 then 0 + else (prop.annual_rent / 1000) * 0.025 * (1 + TotalLoadings(prop)) + } + + // Property owners liability — per property, scaled by number of units + POLPremium(prop: Property): number { + PropertyConfig.POLBase(prop.property_type) * (1 + units_loading) + where units_loading = if prop.number_of_units > 4 then 0.25 + else if prop.number_of_units > 2 then 0.10 + else 0 + } + + // Total premium for a single property + Total(prop: Property): number { + BuildingsPremium(prop) + ContentsPremium(prop) + RentPremium(prop) + POLPremium(prop) + } + + // Per-property breakdown with rounded values + Breakdown(prop: Property): PropertyBreakdown { + { + address: prop.address, + buildings_premium: round(BuildingsPremium(prop), 2), + contents_premium: round(ContentsPremium(prop), 2), + rent_premium: round(RentPremium(prop), 2), + pol_premium: round(POLPremium(prop), 2), + property_total: round(Total(prop), 2), + } + } +} + +// --- Multi-property discount --- +// Portfolio customers (via broker arrangement) get enhanced discounts + +MultiPropertyDiscount(num_properties: number, is_portfolio: bool): number { + if is_portfolio then portfolio_discount + else standard_discount + where standard_discount = match num_properties { + 1 => 0, + 2 => 0.05, + 3 => 0.075, + [4..6] => 0.10, + [7..10] => 0.125, + [11..99] => 0.15, + _ => 0, + }, + portfolio_discount = match num_properties { + [1..3] => 0.10, + [4..6] => 0.15, + [7..10] => 0.175, + [11..99] => 0.20, + _ => 0, + } +} + +// --- Employers Liability (landlord-level, not per-property) --- + +namespace EmployersLiability { + BaseRate(number_of_employees: number): number { + match number_of_employees { + 0 => 0, + [1..5] => 0.008, + [6..10] => 0.007, + [11..25] => 0.0065, + [26..99] => 0.006, + _ => 0.008, + } + } + + Rate(turnover: number, number_of_employees: number): number { + if number_of_employees == 0 then 0 + else round((turnover / 100) * BaseRate(number_of_employees), 2) + } +} + +// --- Legal Expenses (flat rate by portfolio size) --- + +LegalExpensesPremium(num_properties: number): number { + match num_properties { + [1..3] => 95, + [4..6] => 150, + [7..10] => 225, + [11..99] => 350, + _ => 95, + } +} + +// --- Claims loading (applied to property subtotal) --- + +ClaimsLoading(claims: ClaimsHistory): number { + match claims.number_of_claims { + 0 => -0.10, + 1 => 0, + 2 => 0.15, + 3 => 0.35, + 4 => 0.60, + [5..99] => 1.00, + _ => 0, + } +} + +// --- Worst-case lookups across all properties --- +// Useful for underwriting rules that check the riskiest property + +TotalBuildingsSI(properties: list(Property)): number { + sum collect prop in properties => prop.buildings_sum_insured +} + +// --- Product entry point --- +// Validates exposure, rates each property, aggregates with discount, +// adds landlord-level covers (EL, legal expenses) + +Product(exposure: LandlordExposure, claims: ClaimsHistory): ProductOutcome { + if len(exposure.properties) == 0 + then declined { reasons: ["At least one property is required"] } + else if len(exposure.properties) > 25 + then declined { reasons: ["Maximum 25 properties per policy"] } + else if claims.number_of_claims > 5 + then declined { reasons: ["Too many claims — manual review required"] } + else if TotalBuildingsSI(exposure.properties) > 10000000 + then declined { reasons: ["Total buildings sum insured exceeds 10M limit"] } + else offered { + property_details: collect prop in exposure.properties => PropertyRating.Breakdown(prop), + property_subtotal: round(prop_subtotal, 2), + discount_rate: disc_rate, + property_net: round(prop_net, 2), + el_premium: el_prem, + legal_premium: legal_prem, + total_gross: round(total, 2), + total_net: round(total * (1 - 0.30), 2), + commission_rate: 0.30, + currency: "GBP", + } + where claims_load = ClaimsLoading(claims), + raw_property_total = sum collect prop in exposure.properties => PropertyRating.Total(prop), + prop_subtotal = raw_property_total * (1 + claims_load), + disc_rate = MultiPropertyDiscount(len(exposure.properties), exposure.is_portfolio), + prop_net = prop_subtotal * (1 - disc_rate), + el_prem = EmployersLiability.Rate(exposure.turnover, exposure.number_of_employees), + legal_prem = LegalExpensesPremium(len(exposure.properties)), + total = prop_net + el_prem + legal_prem +} +`; + +// Scenario: Small landlord with 3 properties +// - Residential flat in London (1960s build, 1 unit) +// - Commercial unit in Manchester (2005 build, 1 unit) +// - HMO in Bristol (Victorian, 6 units, grade 2 listed, higher flood risk) +export const LANDLORDS_INPUT = { + exposure: { + number_of_employees: 2, + turnover: 50000, + is_portfolio: false, + properties: [ + { + address: "Flat 4, 23 Camden Road, London NW1", + property_type: "residential", + buildings_sum_insured: 250000, + contents_sum_insured: 15000, + annual_rent: 14400, + year_built: 1962, + listed_type: "not_listed", + number_of_units: 1, + flood_risk: 2, + subsidence_risk: 3, + }, + { + address: "Unit 7, Enterprise Park, Manchester M4", + property_type: "commercial", + buildings_sum_insured: 400000, + contents_sum_insured: 30000, + annual_rent: 28000, + year_built: 2005, + listed_type: "not_listed", + number_of_units: 1, + flood_risk: 4, + subsidence_risk: 2, + }, + { + address: "12 Clifton Gardens, Bristol BS8", + property_type: "hmo", + buildings_sum_insured: 350000, + contents_sum_insured: 20000, + annual_rent: 42000, + year_built: 1885, + listed_type: "grade_2", + number_of_units: 6, + flood_risk: 7, + subsidence_risk: 5, + }, + ], + }, + claims: { + number_of_claims: 0, + total_claims_value: 0, + }, + _tables: { + property_type_config: parseCSV(propertyTypeConfigCSV), + }, +}; diff --git a/playground/src/examples/money.axiom.ts b/playground/src/examples/money.axiom.ts new file mode 100644 index 0000000..5a0eeed --- /dev/null +++ b/playground/src/examples/money.axiom.ts @@ -0,0 +1,87 @@ +export const MONEY_EXAMPLE = `// Money Type Plugin Demo +// Demonstrates: money literals, type-safe arithmetic, currency enforcement + +// --- Premium calculation with money types --- + +type MoneyBreakdown = { + base_premium: money(GBP), + discount: money(GBP), + admin_fee: money(GBP), + ipt: money(GBP), + total: money(GBP), + affordable: bool, +} + +BasePremium(risk_score: number): money(GBP) { + match risk_score { + [1..3] => £500, + [4..6] => £750, + [7..10] => £1250, + _ => £350, + } +} + +AdminFee(): money(GBP) { + £35 +} + +// Insurance premium tax (12% of premium) +IPT(premium: money(GBP)): money(GBP) { + premium * 0.12 +} + +// Multi-property discount +PropertyDiscount(num_properties: number, subtotal: money(GBP)): money(GBP) { + subtotal * discount_rate + where discount_rate = match num_properties { + 1 => 0, + 2 => 0.05, + 3 => 0.10, + [4..99] => 0.15, + _ => 0, + } +} + +// Minimum premium floor +MinPremium(): money(GBP) { + £250 +} + +// Full product calculation with money throughout +Product(risk_score: number, num_properties: number): money(GBP) { + round(total, 2) + where base = BasePremium(risk_score) * num_properties, + discount = PropertyDiscount(num_properties, base), + net = base - discount, + floor = if net > MinPremium() then net else MinPremium(), + ipt = IPT(floor), + total = floor + ipt + AdminFee() +} + +// ISO code form works too +EuroExample(): money(EUR) { + EUR1000 * 1.15 +} + +// Comparison operators +IsAffordable(premium: money(GBP)): bool { + premium <= £2000 +} + +// Full breakdown +Breakdown(risk_score: number, num_properties: number): MoneyBreakdown { + { + base_premium: BasePremium(risk_score) * num_properties, + discount: PropertyDiscount(num_properties, BasePremium(risk_score) * num_properties), + admin_fee: AdminFee(), + ipt: IPT(Product(risk_score, num_properties) - AdminFee()), + total: Product(risk_score, num_properties), + affordable: IsAffordable(Product(risk_score, num_properties)), + } +} +`; + +export const MONEY_INPUT = { + risk_score: 5, + num_properties: 3, +}; diff --git a/playground/src/examples/property_type_config.csv b/playground/src/examples/property_type_config.csv new file mode 100644 index 0000000..d722439 --- /dev/null +++ b/playground/src/examples/property_type_config.csv @@ -0,0 +1,5 @@ +property_type,buildings_rate,contents_rate,pol_base,flood_factor,subsidence_factor +residential,0.035,0.045,35,0.15,0.10 +commercial,0.055,0.060,75,0.20,0.15 +hmo,0.065,0.055,95,0.15,0.12 +mixed_use,0.060,0.058,85,0.18,0.13 diff --git a/playground/src/examples/tradespeople.axiom.ts b/playground/src/examples/tradespeople.axiom.ts new file mode 100644 index 0000000..2da0141 --- /dev/null +++ b/playground/src/examples/tradespeople.axiom.ts @@ -0,0 +1,373 @@ +export const TRADESPEOPLE_EXAMPLE = `// Covea Tradespeople Platinum V2 +// Translated from Abacus ProductSchemeBuilder + 6 CoverSchemeBuilders +// Subset: 2 industries (DRI-103, DRI-284), employees 1-3 + +type CoverOutcome = + rated { + key: string, + name: string, + base_premium: money(GBP), + limit: number, + excess: money(GBP), + } + | not_available { reason: string } + +type ProductOutcome = + offered { + covers: list(CoverOutcome), + total_gross_premium: money(GBP), + total_net_premium: money(GBP), + currency: string, + } + | declined { reasons: list(string) } + | referred { reasons: list(string) } + +// --- Product-level adjustment factors (from CSV lookup tables) --- + +namespace Adjustments { + // Postcode group relativity (from group_relativity.csv, 50 groups) + GroupRelativity(postcode_group: number): number { + match postcode_group { + 1 => 0.85, 5 => 0.885, 10 => 0.924, 15 => 0.962, + 20 => 0.992, 22 => 1, 25 => 1.012, 30 => 1.032, + 35 => 1.055, 40 => 1.08, 45 => 1.108, 50 => 1.15, + _ => 1, + } + } + + // Years of experience relativity (from years_experience_relativities.csv) + YearsExperienceRelativity(years_experience: number): number { + match years_experience { + [0..1] => 0.92, + 2 => 0.98, + 3 => 1.02, + 4 => 1.06, + 5 => 1.1, + 6 => 1.09, + 7 => 1.08, + 8 => 1.07, + 9 => 1.06, + 10 => 1.05, + 11 => 1.04, + 12 => 1.02, + 13 | 14 => 1.01, + [15..] => 1, + _ => 1, + } + } + + // Claims loading — applies when policyholder has prior claims + ClaimsLoading(number_of_claims: number, years_since_last_claim: number): number { + if number_of_claims == 0 + then 1 + else match years_since_last_claim { + [0..2) => 1.1, + [2..3) => 1.075, + [3..4) => 1.05, + [4..5) => 1.025, + _ => 1, + } + } + + // No-claims discount — applies when policyholder has zero claims + NoClaimsLoading(number_of_claims: number, years_experience: number): number { + if number_of_claims > 0 + then 1 + else match years_experience { + [0..1) => 1, + [1..2) => 0.95, + [2..3) => 0.9, + [3..4) => 0.85, + [4..5) => 0.8, + [5..] => 0.75, + _ => 1, + } + } + + // Combined adjustment factor: product of all relativities + Factor( + postcode_group: number, + years_experience: number, + number_of_claims: number, + years_since_last_claim: number, + ): number { + GroupRelativity(postcode_group) + * YearsExperienceRelativity(years_experience) + * ClaimsLoading(number_of_claims, years_since_last_claim) + * NoClaimsLoading(number_of_claims, years_experience) + } +} + +// --- Public Liability (PL) --- + +namespace PublicLiability { + // 3-dimensional lookup: industry x limit x employees (from premium.csv, ~5700 rows) + BasePremium(industry: string, limit: number, employees: number): money(GBP) { + match (industry, limit, employees) { + ("DRI-103", 1000000, 1) => £163, ("DRI-103", 1000000, 2) => £256, ("DRI-103", 1000000, 3) => £398, + ("DRI-103", 2000000, 1) => £200, ("DRI-103", 2000000, 2) => £313, ("DRI-103", 2000000, 3) => £485, + ("DRI-103", 5000000, 1) => £251, ("DRI-103", 5000000, 2) => £391, ("DRI-103", 5000000, 3) => £609, + ("DRI-284", 1000000, 1) => £275, ("DRI-284", 1000000, 2) => £428, ("DRI-284", 1000000, 3) => £666, + ("DRI-284", 2000000, 1) => £336, ("DRI-284", 2000000, 2) => £518, ("DRI-284", 2000000, 3) => £812, + ("DRI-284", 5000000, 1) => £479, ("DRI-284", 5000000, 2) => £910, ("DRI-284", 5000000, 3) => £1323, + _ => £0, + } + } + + Excess(industry: string): money(GBP) { + match industry { + "DRI-103" => £100, + "DRI-284" => £250, + _ => £100, + } + } + + Rate(industry: string, limit: number, employees: number): CoverOutcome { + if bp == £0 + then not_available { reason: "No PL rate for industry" } + else rated { + key: "PL", + name: "Public Liability", + base_premium: bp, + limit: limit, + excess: Excess(industry), + } + where bp = BasePremium(industry, limit, employees) + } +} + +// --- Employers Liability (EL) --- + +namespace EmployersLiability { + // Fixed limit 10M. Premium per industry. Sole traders exclude the proprietor. + BasePremium(industry: string): money(GBP) { + match industry { + "DRI-103" => £137, + "DRI-284" => £1023, + _ => £0, + } + } + + InsurableManualWorkers(manual_workers: number, business_type: string): number { + if business_type == "sole_trader" + then if manual_workers > 1 then manual_workers - 1 else 0 + else manual_workers + } + + Rate(industry: string, manual_workers: number, business_type: string): CoverOutcome { + if bp == £0 + then not_available { reason: "No EL rate for industry" } + else rated { + key: "EL", + name: "Employers Liability", + base_premium: bp * InsurableManualWorkers(manual_workers, business_type), + limit: 10000000, + excess: £0, + } + where bp = BasePremium(industry) + } +} + +// --- Portable Tools and Equipment (PTE) --- + +namespace PortableTools { + // Simple limit-based premium, no industry dependency (from premium.csv, 5 rows) + BasePremium(limit: number): money(GBP) { + match limit { + 1000 => £59.70, + 2500 => £126.35, + 5000 => £192.92, + 7500 => £244.86, + 10000 => £296.80, + _ => £0, + } + } + + Rate(limit: number): CoverOutcome { + if bp == £0 + then not_available { reason: "Invalid PTE limit" } + else rated { + key: "PTE", + name: "Portable Tools and Equipment", + base_premium: bp, + limit: limit, + excess: £60, + } + where bp = BasePremium(limit) + } +} + +// --- Own Plant and Machinery (OPM) --- + +namespace OwnPlant { + // Only available for eligible industries. Premium by limit x manual workers. + BasePremium(limit: number, manual_workers: number): money(GBP) { + match (limit, manual_workers) { + (5000, 1) => £78.11, (5000, 2) => £103.75, (5000, 3) => £128.20, + (10000, 1) => £104.15, (10000, 2) => £138.33, (10000, 3) => £170.93, + (25000, 1) => £115.28, (25000, 2) => £153.43, (25000, 3) => £190.00, + _ => £0, + } + } + + Rate(industry: string, limit: number, manual_workers: number): CoverOutcome { + if industry not in ["DRI-284"] + then not_available { reason: "Industry not eligible for OPM" } + else if bp == £0 + then not_available { reason: "Invalid OPM limit/workers combination" } + else rated { + key: "OPM", + name: "Own Plant and Machinery", + base_premium: bp, + limit: limit, + excess: £250, + } + where bp = BasePremium(limit, manual_workers) + } +} + +// --- Hired In Plant and Machinery (HPM) --- + +namespace HiredPlant { + // Only available for eligible industries. Premium by limit x manual workers. + BasePremium(limit: number, manual_workers: number): money(GBP) { + match (limit, manual_workers) { + (10000, 1) => £111.30, (10000, 2) => £145.48, (10000, 3) => £179.67, + (25000, 1) => £123.23, (25000, 2) => £162.18, (25000, 3) => £199.55, + (50000, 1) => £154.23, (50000, 2) => £202.73, (50000, 3) => £249.63, + _ => £0, + } + } + + Rate(industry: string, limit: number, manual_workers: number): CoverOutcome { + if industry not in ["DRI-284"] + then not_available { reason: "Industry not eligible for HPM" } + else if bp == £0 + then not_available { reason: "Invalid HPM limit/workers combination" } + else rated { + key: "HPM", + name: "Hired In Plant and Machinery", + base_premium: bp, + limit: limit, + excess: £250, + } + where bp = BasePremium(limit, manual_workers) + } +} + +// --- Contract Works (CW) --- + +namespace ContractWorks { + // Industry band determines premium tier (from industry_bands.csv) + IndustryBand(industry: string): number { + match industry { + "DRI-284" => 4, + _ => 0, + } + } + + // Premium by limit x employees x industry band (from premium.csv) + BasePremium(limit: number, employees: number, band: number): money(GBP) { + match (limit, band, employees) { + (100000, 1, 1) => £120.05, (100000, 1, 2) => £155.82, (100000, 1, 3) => £186.03, + (100000, 2, 1) => £141.51, (100000, 2, 2) => £183.65, (100000, 2, 3) => £218.63, + (100000, 3, 1) => £148.67, (100000, 3, 2) => £193.18, (100000, 3, 3) => £229.75, + (100000, 4, 1) => £162.98, (100000, 4, 2) => £211.47, (100000, 4, 3) => £251.22, + (250000, 1, 1) => £133.56, (250000, 1, 2) => £173.31, (250000, 1, 3) => £206.70, + (250000, 2, 1) => £157.41, (250000, 2, 2) => £204.32, (250000, 2, 3) => £243.27, + (250000, 3, 1) => £165.36, (250000, 3, 2) => £214.65, (250000, 3, 3) => £255.20, + (250000, 4, 1) => £181.26, (250000, 4, 2) => £235.32, (250000, 4, 3) => £279.84, + _ => £0, + } + } + + Rate(industry: string, limit: number, employees: number): CoverOutcome { + if band == 0 + then not_available { reason: "Industry not eligible for Contract Works" } + else if bp == £0 + then not_available { reason: "Invalid CW limit/employees combination" } + else rated { + key: "CW", + name: "Contract Works", + base_premium: bp, + limit: limit, + excess: £250, + } + where band = IndustryBand(industry), + bp = BasePremium(limit, employees, band) + } +} + +// --- Product entry point --- +// Validates exposure constraints, rates all covers, applies shared adjustments + +Product( + industry: string, + number_of_employees: number, + manual_workers: number, + business_type: string, + turnover: number, + postcode_group: number, + years_experience: number, + number_of_claims: number, + years_since_last_claim: number, + pl_limit: number, + pte_limit: number, + opm_limit: number, + hpm_limit: number, + cw_limit: number, +): ProductOutcome { + if number_of_employees > 10 + then declined { reasons: ["Maximum 10 employees allowed"] } + else if turnover > 2000000 + then declined { reasons: ["Maximum turnover 2,000,000"] } + else if manual_workers > number_of_employees + then declined { reasons: ["Manual workers cannot exceed total employees"] } + else if number_of_claims > 1 + then declined { reasons: ["Maximum 1 claim in last 5 years"] } + else if any not_available in covers + then referred { + reasons: collect not_available { reason } in covers => reason, + } + else offered { + covers: covers, + total_gross_premium: round(base_sum * adj, 2), + total_net_premium: round(base_sum * adj * 0.65, 2), + currency: "GBP", + } + where pl_cover = PublicLiability.Rate(industry, limit: pl_limit, employees: number_of_employees), + el_cover = EmployersLiability.Rate(industry, manual_workers, business_type), + pte_cover = PortableTools.Rate(limit: pte_limit), + opm_cover = OwnPlant.Rate(industry, limit: opm_limit, manual_workers), + hpm_cover = HiredPlant.Rate(industry, limit: hpm_limit, manual_workers), + cw_cover = ContractWorks.Rate(industry, limit: cw_limit, employees: number_of_employees), + covers = [ + pl_cover, + el_cover, + pte_cover, + opm_cover, + hpm_cover, + cw_cover, + ], + adj = Adjustments.Factor(postcode_group, years_experience, number_of_claims, years_since_last_claim), + base_sum = sum(collect rated { base_premium } in covers => base_premium) +} +`; + +// Scenario: Roofer (DRI-284), 2 employees, limited company, no claims, 5 years experience +export const TRADESPEOPLE_INPUT = { + industry: "DRI-284", + number_of_employees: 2, + manual_workers: 2, + business_type: "limited_company", + turnover: 500000, + postcode_group: 22, + years_experience: 5, + number_of_claims: 0, + years_since_last_claim: 0, + pl_limit: 2000000, + pte_limit: 5000, + opm_limit: 10000, + hpm_limit: 25000, + cw_limit: 100000, +}; diff --git a/playground/src/lang/ast.ts b/playground/src/lang/ast.ts new file mode 100644 index 0000000..e0ab7a7 --- /dev/null +++ b/playground/src/lang/ast.ts @@ -0,0 +1,314 @@ +import { Location } from './diagnostics'; + +// --- Top-level declarations --- + +export interface ProgramNode { + kind: 'Program'; + body: Declaration[]; +} + +export type Declaration = TypeDeclaration | ExpressionDeclaration | NamespaceDeclaration | SourceDeclaration | TableDeclaration; + +export interface TypeDeclaration { + kind: 'TypeDeclaration'; + name: string; + alternatives: VariantAlternative[]; + shape?: Record; // Record type (no variants, just fields) + location?: Location; +} + +export interface VariantAlternative { + tag: string; + shape: Record; +} + +export interface SourceDeclaration { + kind: 'SourceDeclaration'; + name: string; + params: Parameter[]; + returnType: TypeAnnotation; + location?: Location; +} + +export interface TableDeclaration { + kind: 'TableDeclaration'; + name: string; + elementType: TypeAnnotation; + location?: Location; +} + +export interface NamespaceDeclaration { + kind: 'NamespaceDeclaration'; + name: string; + symbols: SymbolDeclaration[]; + types: TypeDeclaration[]; + expressions: ExpressionDeclaration[]; + sources: SourceDeclaration[]; + location?: Location; +} + +export interface SymbolDeclaration { + name: string; + type: TypeAnnotation; + value: Expr; +} + +export interface ExpressionDeclaration { + kind: 'ExpressionDeclaration'; + name: string; + params: Parameter[]; + returnType?: TypeAnnotation; + body: Expr; + location?: Location; +} + +export interface Parameter { + name: string; + type: TypeAnnotation; +} + +// --- Type annotations --- + +export interface TypeAnnotation { + keyword: string; + args: Expr[]; + shape?: Record; +} + +// --- Expressions --- + +export type Expr = + | LiteralExpr + | PluginLiteralExpr + | IdentifierExpr + | MemberExpr + | IndexExpr + | InfixExpr + | UnaryExpr + | CoercionExpr + | IfExpr + | MatchExpr + | CallExpr + | ListLiteralExpr + | DictLiteralExpr + | VariantConstructionExpr + | AnyExpr + | AllExpr + | CollectExpr + | AggregateCollectExpr + | ParenExpr + | WhereExpr; + +export interface LiteralExpr { + kind: 'Literal'; + value: number | string | boolean; + raw: string; + location?: Location; +} + +export interface PluginLiteralExpr { + kind: 'PluginLiteral'; + tag: string; + value: unknown; + displayValue: string; + location?: Location; +} + +export interface IdentifierExpr { + kind: 'Identifier'; + name: string; + location?: Location; +} + +export interface MemberExpr { + kind: 'MemberExpression'; + object: Expr; + property: string; + location?: Location; +} + +export interface IndexExpr { + kind: 'IndexExpression'; + object: Expr; + index: Expr; + location?: Location; +} + +export interface InfixExpr { + kind: 'InfixExpression'; + left: Expr; + operator: string; + right: Expr; + location?: Location; +} + +export interface UnaryExpr { + kind: 'UnaryExpression'; + operator: string; + operand: Expr; + location?: Location; +} + +export interface CoercionExpr { + kind: 'CoercionExpression'; + expression: Expr; + targetType: TypeAnnotation; + location?: Location; +} + +export interface IfExpr { + kind: 'IfExpression'; + condition: Expr; + then: Expr; + elseIfs: { condition: Expr; then: Expr }[]; + else: Expr; + location?: Location; +} + +export interface MatchExpr { + kind: 'MatchExpression'; + subject?: Expr; + binding?: string; // match binding in iterable { ... } + iterable?: Expr; // the list to iterate over + arms: MatchArm[]; + location?: Location; +} + +export interface MatchArm { + pattern: Pattern; + expression: Expr; +} + +export interface CallExpr { + kind: 'CallExpression'; + callee: string; + args: Expr[]; + namedArgs: Record; + allArgs: Expr[]; // All arguments in original source order (for intrinsic calls) + spread?: boolean; // ... — fill remaining params from scope by matching name + location?: Location; +} + +export interface ListLiteralExpr { + kind: 'ListLiteral'; + elements: Expr[]; + location?: Location; +} + +export interface DictLiteralExpr { + kind: 'DictLiteral'; + entries: { key: string; value: Expr }[]; + location?: Location; +} + +export interface VariantConstructionExpr { + kind: 'VariantConstruction'; + typeName?: string; + tag: string; + entries: { key: string; value: Expr }[]; + location?: Location; +} + +export interface AnyExpr { + kind: 'AnyExpression'; + pattern: Pattern; + list: Expr; + location?: Location; +} + +export interface AllExpr { + kind: 'AllExpression'; + pattern: Pattern; + list: Expr; + location?: Location; +} + +export interface CollectExpr { + kind: 'CollectExpression'; + pattern?: Pattern; // standard form: collect pattern in list => body + list: Expr; + body?: Expr; + binding?: string; // binding form: collect row in list { arms } + arms?: { pattern: Pattern; body: Expr }[]; + location?: Location; +} + +export interface AggregateCollectExpr { + kind: 'AggregateCollectExpression'; + aggregator: string; + list: Expr; + arms: { pattern: Pattern; body: Expr }[]; + binding?: string; // binding form: agg collect row in list { arms } + location?: Location; +} + +export interface ParenExpr { + kind: 'ParenExpression'; + expression: Expr; + location?: Location; +} + +export interface WhereExpr { + kind: 'WhereExpression'; + body: Expr; + bindings: { name: string; value: Expr }[]; + location?: Location; +} + +// --- Patterns --- + +export type Pattern = + | WildcardPattern + | LiteralPattern + | ExpressionPattern + | VariantPattern + | RangePattern + | AlternativePattern + | TuplePattern; + +export interface WildcardPattern { + kind: 'WildcardPattern'; + location?: Location; +} + +export interface LiteralPattern { + kind: 'LiteralPattern'; + value: number | string | boolean; + raw: string; + location?: Location; +} + +export interface ExpressionPattern { + kind: 'ExpressionPattern'; + expression: Expr; + location?: Location; +} + +export interface VariantPattern { + kind: 'VariantPattern'; + typeName?: string; + tag: string; + bindings: Record; // field -> alias (null = wildcard binding) + location?: Location; +} + +export interface RangePattern { + kind: 'RangePattern'; + openLeft: boolean; // ( = exclusive + openRight: boolean; // ) = exclusive + left?: number; + right?: number; + location?: Location; +} + +export interface AlternativePattern { + kind: 'AlternativePattern'; + patterns: Pattern[]; + location?: Location; +} + +export interface TuplePattern { + kind: 'TuplePattern'; + elements: Pattern[]; + location?: Location; +} diff --git a/playground/src/lang/checker.ts b/playground/src/lang/checker.ts new file mode 100644 index 0000000..5ed6730 --- /dev/null +++ b/playground/src/lang/checker.ts @@ -0,0 +1,1277 @@ +import { + ProgramNode, Declaration, ExpressionDeclaration, TypeDeclaration, + SourceDeclaration, TableDeclaration, Expr, Pattern, TypeAnnotation, MatchArm, +} from './ast'; +import { Diagnostic, Location, error, warning } from './diagnostics'; +import { + TypeSig, TYPE_NUMBER, TYPE_STRING, TYPE_BOOL, TYPE_MIXED, + typeList, typeDict, typeVariant, typeMoney, typeToString, isAssignable, +} from './types'; + +interface ExprDeclInfo { + decl: ExpressionDeclaration | SourceDeclaration; + paramTypes: Record; + returnType?: TypeSig; +} + +// Intrinsic function signatures: [paramTypes, returnType] +interface IntrinsicSig { + params: { name: string; type: TypeSig }[]; + variadic?: boolean; + returnType: TypeSig | 'from_arg'; +} + +const INTRINSICS: Record = { + round: { params: [{ name: 'value', type: TYPE_NUMBER }, { name: 'decimals', type: TYPE_NUMBER }], returnType: TYPE_NUMBER }, + len: { params: [{ name: 'list', type: typeList() }], returnType: TYPE_NUMBER }, + flatten: { params: [{ name: 'list', type: typeList() }], returnType: 'from_arg' }, + product: { params: [{ name: 'collection', type: TYPE_MIXED }], returnType: TYPE_NUMBER }, + sum: { params: [{ name: 'collection', type: TYPE_MIXED }], returnType: TYPE_NUMBER }, + sum_money: { params: [{ name: 'collection', type: TYPE_MIXED }], returnType: TYPE_NUMBER }, + max: { params: [{ name: 'list', type: typeList(TYPE_NUMBER) }], variadic: true, returnType: TYPE_NUMBER }, + min: { params: [{ name: 'list', type: typeList(TYPE_NUMBER) }], variadic: true, returnType: TYPE_NUMBER }, +}; + +export interface CheckResult { + diagnostics: Diagnostic[]; + exprTypes: Map; + declTypes: Map; +} + +export function check(ast: ProgramNode, plugins?: import('./plugin').AxiomPlugin[]): CheckResult { + const diagnostics: Diagnostic[] = []; + const exprTypes = new Map(); + const declTypes = new Map(); + + // Track current namespace for unqualified name resolution + let currentCheckerNamespace: string | undefined; + + // Collect type declarations (top-level and namespaced) + const namedTypes = new Map(); + function registerTypeDecl(td: TypeDeclaration, name: string) { + // Record type: type Foo = { field: Type, ... } + if (td.shape) { + const shape: Record = {}; + for (const [field, ann] of Object.entries(td.shape)) { + shape[field] = resolveTypeAnnotation(ann); + } + namedTypes.set(name, { name, params: [], shape }); + return; + } + // Variant type: type Foo = tag { ... } | tag { ... } + const variants: Record> = {}; + for (const alt of td.alternatives) { + const shape: Record = {}; + for (const [field, ann] of Object.entries(alt.shape)) { + shape[field] = resolveTypeAnnotation(ann); + } + variants[alt.tag] = shape; + } + namedTypes.set(name, typeVariant(name, variants)); + } + + for (const decl of ast.body) { + if (decl.kind === 'TypeDeclaration') { + registerTypeDecl(decl, decl.name); + } + if (decl.kind === 'NamespaceDeclaration') { + currentCheckerNamespace = decl.name; + for (const typeDecl of decl.types) { + registerTypeDecl(typeDecl, `${decl.name}.${typeDecl.name}`); + } + currentCheckerNamespace = undefined; + } + } + + // Collect table declarations — typed list values in scope + const tableTypes = new Map(); + for (const decl of ast.body) { + if (decl.kind === 'TableDeclaration') { + const elemType = resolveTypeAnnotation(decl.elementType); + tableTypes.set(decl.name, typeList(elemType)); + } + } + + // Collect expression declarations (top-level and namespaced) + const exprDecls = new Map(); + for (const decl of ast.body) { + if (decl.kind === 'ExpressionDeclaration') { + const paramTypes: Record = {}; + for (const p of decl.params) { + paramTypes[p.name] = resolveTypeAnnotation(p.type); + } + const returnType = decl.returnType ? resolveTypeAnnotation(decl.returnType) : undefined; + exprDecls.set(decl.name, { decl, paramTypes, returnType }); + } + if (decl.kind === 'SourceDeclaration') { + const paramTypes: Record = {}; + for (const p of decl.params) { + paramTypes[p.name] = resolveTypeAnnotation(p.type); + } + const returnType = resolveTypeAnnotation(decl.returnType); + exprDecls.set(decl.name, { decl, paramTypes, returnType }); + } + if (decl.kind === 'NamespaceDeclaration') { + currentCheckerNamespace = decl.name; + for (const exprDecl of decl.expressions) { + const qualName = `${decl.name}.${exprDecl.name}`; + const paramTypes: Record = {}; + for (const p of exprDecl.params) { + paramTypes[p.name] = resolveTypeAnnotation(p.type); + } + const returnType = exprDecl.returnType ? resolveTypeAnnotation(exprDecl.returnType) : undefined; + exprDecls.set(qualName, { decl: exprDecl, paramTypes, returnType }); + } + for (const srcDecl of decl.sources) { + const qualName = `${decl.name}.${srcDecl.name}`; + const paramTypes: Record = {}; + for (const p of srcDecl.params) { + paramTypes[p.name] = resolveTypeAnnotation(p.type); + } + const returnType = resolveTypeAnnotation(srcDecl.returnType); + exprDecls.set(qualName, { decl: srcDecl, paramTypes, returnType }); + } + currentCheckerNamespace = undefined; + } + } + + // Check for duplicate expression names + { + const seen = new Map(); + for (const decl of ast.body) { + if (decl.kind === 'ExpressionDeclaration') { + if (seen.has(decl.name)) { + diagnostics.push(error('type.duplicate_expression', `Duplicate expression name '${decl.name}'`, decl.location)); + } + seen.set(decl.name, decl.location); + } + } + } + + // Check for duplicate type names + { + const seen = new Map(); + for (const decl of ast.body) { + if (decl.kind === 'TypeDeclaration') { + if (seen.has(decl.name)) { + diagnostics.push(error('type.duplicate_type', `Duplicate type name '${decl.name}'`, decl.location)); + } + seen.set(decl.name, decl.location); + } + } + } + + // Register payload-less variant tags as constants + const variantTagTypes = new Map(); + for (const decl of ast.body) { + const typeDecls = decl.kind === 'TypeDeclaration' ? [decl] + : decl.kind === 'NamespaceDeclaration' ? decl.types : []; + for (const td of typeDecls) { + const typeSig = namedTypes.get(td.name) ?? namedTypes.get( + decl.kind === 'NamespaceDeclaration' ? `${decl.name}.${td.name}` : td.name + ); + for (const alt of td.alternatives) { + if (Object.keys(alt.shape).length === 0 && typeSig) { + variantTagTypes.set(alt.tag, typeSig); + } + } + } + } + + // Collect namespace constants + const namespaceConstants = new Map(); + for (const decl of ast.body) { + if (decl.kind === 'NamespaceDeclaration') { + for (const sym of decl.symbols) { + namespaceConstants.set(`${decl.name}.${sym.name}`, resolveTypeAnnotation(sym.type)); + } + } + } + + // Type-check each expression declaration + for (const [name, info] of exprDecls) { + // Source declarations have no body to type-check — just register their return type + if (info.decl.kind === 'SourceDeclaration') { + if (info.returnType) declTypes.set(name, info.returnType); + continue; + } + + const scope = new Map(); + for (const [pname, ptype] of Object.entries(info.paramTypes)) { + scope.set(pname, ptype); + } + // Set namespace context for unqualified resolution + const dotIdx = name.lastIndexOf('.'); + currentCheckerNamespace = dotIdx >= 0 ? name.substring(0, dotIdx) : undefined; + + const expected = info.returnType ?? null; + const bodyType = inferType(info.decl.body, scope, expected); + if (bodyType) { + declTypes.set(name, info.returnType ?? bodyType); + if (info.returnType) { + if (!isAssignable(bodyType, info.returnType)) { + diagnostics.push(error( + 'type.return_type_mismatch', + `Expression '${name}' body has type ${typeToString(bodyType)}, expected ${typeToString(info.returnType)}`, + info.decl.location, + )); + } + } + } + } + currentCheckerNamespace = undefined; + + return { diagnostics, exprTypes, declTypes }; + + // --- Helpers --- + + function resolveTypeAnnotation(ann: TypeAnnotation): TypeSig { + if (ann.shape) { + const shape: Record = {}; + for (const [k, v] of Object.entries(ann.shape)) { + shape[k] = resolveTypeAnnotation(v); + } + if (ann.keyword === 'dict') return typeDict(shape); + return typeDict(shape); + } + + switch (ann.keyword) { + case 'number': return TYPE_NUMBER; + case 'string': return TYPE_STRING; + case 'bool': return TYPE_BOOL; + case 'list': { + if (ann.args.length > 0) { + const argType = ann.args[0]; + if (argType.kind === 'Identifier') { + const elemType = resolveTypeKeyword(argType.name) ?? resolveTypeName(argType.name); + if (elemType) return typeList(elemType); + diagnostics.push(error('type.unknown_type', `Unknown type '${argType.name}'`, argType.location)); + } + } + return typeList(); + } + case 'dict': { + if (ann.args.length > 0) { + const argType = ann.args[0]; + if (argType.kind === 'Identifier') { + const valueType = resolveTypeKeyword(argType.name) ?? resolveTypeName(argType.name); + if (valueType) return typeDict(undefined, valueType); + diagnostics.push(error('type.unknown_type', `Unknown type '${argType.name}'`, argType.location)); + } + } + return typeDict(); + } + case 'money': { + const currency = ann.args[0]; + if (currency && currency.kind === 'Identifier') { + return typeMoney(currency.name); + } + return typeMoney('?'); + } + default: { + const resolved = resolveTypeName(ann.keyword); + if (resolved) return resolved; + diagnostics.push(error('type.unknown_type', `Unknown type '${ann.keyword}'`)); + return { name: ann.keyword, params: [] }; + } + } + } + + function resolveTypeName(name: string): TypeSig | null { + const direct = namedTypes.get(name); + if (direct) return direct; + // Try qualifying with current namespace + if (currentCheckerNamespace) { + return namedTypes.get(`${currentCheckerNamespace}.${name}`) ?? null; + } + return null; + } + + function resolveTypeKeyword(name: string): TypeSig | null { + switch (name) { + case 'number': return TYPE_NUMBER; + case 'string': return TYPE_STRING; + case 'bool': return TYPE_BOOL; + case 'list': return typeList(); + case 'dict': return typeDict(); + default: return null; + } + } + + /** Resolve which variant type a tag belongs to, considering context. */ + function resolveVariantTag(tag: string, qualifiedTypeName: string | undefined, expected: TypeSig | null): TypeSig | null { + // 1. Expected type from context (return type annotation) + if (expected?.variants && expected.variants[tag]) return expected; + // 2. Qualified type name + if (qualifiedTypeName) { + const resolved = namedTypes.get(qualifiedTypeName); + if (resolved?.variants && resolved.variants[tag]) return resolved; + } + // 3. First named type containing this tag + for (const [, typeSig] of namedTypes) { + if (typeSig.variants && typeSig.variants[tag]) return typeSig; + } + return null; + } + + /** Extract the element/value type from a list or dict type. */ + function resolveCollectionElemType(collType: TypeSig | null): TypeSig | null { + if (!collType) return null; + if (collType.name === 'list' || collType.name === 'dict') { + // Prefer the full element type (e.g. inline record shape from table declarations) + if (collType.elementType) return collType.elementType; + if (collType.params.length > 0) { + const elemName = collType.params[0]; + return namedTypes.get(elemName) ?? resolveTypeKeyword(elemName) ?? { name: elemName, params: [] }; + } + } + return null; + } + + function inferType(expr: Expr, scope: Map, expected: TypeSig | null = null): TypeSig | null { + switch (expr.kind) { + case 'Literal': { + if (typeof expr.value === 'number') return setType(expr, TYPE_NUMBER); + if (typeof expr.value === 'string') return setType(expr, TYPE_STRING); + if (typeof expr.value === 'boolean') return setType(expr, TYPE_BOOL); + return null; + } + + case 'PluginLiteral': { + if (plugins) { + for (const plugin of plugins) { + const type = plugin.checker?.inferLiteralType?.(expr.tag, expr.value); + if (type) return setType(expr, type); + } + } + return setType(expr, TYPE_MIXED); + } + + case 'Identifier': { + const t = scope.get(expr.name) ?? namespaceConstants.get(expr.name) ?? variantTagTypes.get(expr.name) ?? tableTypes.get(expr.name); + if (t) return setType(expr, t); + // Try qualifying with current namespace + if (currentCheckerNamespace) { + const nsT = namespaceConstants.get(`${currentCheckerNamespace}.${expr.name}`); + if (nsT) return setType(expr, nsT); + } + // Auto-resolve parameterless expression declarations + const paramlessInfo = exprDecls.get(expr.name) + ?? (currentCheckerNamespace ? exprDecls.get(`${currentCheckerNamespace}.${expr.name}`) : undefined); + if ( + paramlessInfo + && paramlessInfo.decl.kind === 'ExpressionDeclaration' + && paramlessInfo.decl.params.length === 0 + ) { + const retType = paramlessInfo.returnType ?? inferType(paramlessInfo.decl.body, scope); + if (retType) return setType(expr, retType); + } + diagnostics.push(error('type.unresolved_symbol', `Unknown symbol '${expr.name}'`, expr.location)); + return null; + } + + case 'MemberExpression': { + const objType = inferType(expr.object, scope); + if (!objType) return null; + if (objType.shape && objType.shape[expr.property]) { + return setType(expr, objType.shape[expr.property]); + } + if (objType.variants) { + // Check if field exists on ALL alternatives (common field access) + const allHaveField = Object.values(objType.variants).every( + shape => expr.property in shape + ); + if (allHaveField) { + // All alternatives have this field — check they all have the same type + const types = Object.values(objType.variants).map(shape => shape[expr.property]); + if (types.length > 0) return setType(expr, types[0]); + } + diagnostics.push(error( + 'type.member_access_on_variant', + `Cannot access '${expr.property}' on variant type '${objType.name}' — narrow with match first`, + expr.location, + )); + return null; + } + diagnostics.push(error( + 'type.unknown_property', + `Property '${expr.property}' does not exist on ${typeToString(objType)}`, + expr.location, + )); + return null; + } + + case 'IndexExpression': { + const objType = inferType(expr.object, scope); + const idxType = inferType(expr.index, scope); + if (!objType) return null; + if (objType.name === 'list') { + if (idxType && !isAssignable(idxType, TYPE_NUMBER)) { + diagnostics.push(error('type.index_type', `List index must be number, got ${typeToString(idxType)}`, expr.location)); + } + return setType(expr, objType.params.length > 0 ? { name: objType.params[0], params: [] } : TYPE_MIXED); + } + if (objType.shape && idxType && expr.index.kind === 'Literal' && typeof expr.index.value === 'string') { + const fieldType = objType.shape[expr.index.value]; + if (fieldType) return setType(expr, fieldType); + diagnostics.push(error('type.unknown_property', `Key '${expr.index.value}' does not exist on ${typeToString(objType)}`, expr.location)); + return null; + } + return setType(expr, TYPE_MIXED); + } + + case 'InfixExpression': { + const leftType = inferType(expr.left, scope); + const rightType = inferType(expr.right, scope); + if (!leftType || !rightType) return null; + + // Let plugins handle operator type checking first + if (plugins) { + for (const plugin of plugins) { + const result = plugin.checker?.checkBinaryOp?.(expr.operator, leftType, rightType); + if (result) { + if ('error' in result) { + diagnostics.push(error('type.plugin_operator', (result as { error: string }).error, expr.location)); + return null; + } + return setType(expr, result as TypeSig); + } + } + } + + if (['+', '-', '*', '/', '%', '**'].includes(expr.operator)) { + if (isAssignable(leftType, TYPE_NUMBER) && isAssignable(rightType, TYPE_NUMBER)) { + return setType(expr, TYPE_NUMBER); + } + diagnostics.push(error( + 'type.operator_mismatch', + `Operator '${expr.operator}' requires number operands, got ${typeToString(leftType)} and ${typeToString(rightType)}`, + expr.location, + )); + return null; + } + + if (['==', '!=', '<', '>', '<=', '>='].includes(expr.operator)) { + return setType(expr, TYPE_BOOL); + } + if (['&&', '||'].includes(expr.operator)) { + if (!isAssignable(leftType, TYPE_BOOL)) { + diagnostics.push(error('type.operator_mismatch', `Left operand of '${expr.operator}' must be bool, got ${typeToString(leftType)}`, expr.location)); + } + if (!isAssignable(rightType, TYPE_BOOL)) { + diagnostics.push(error('type.operator_mismatch', `Right operand of '${expr.operator}' must be bool, got ${typeToString(rightType)}`, expr.location)); + } + return setType(expr, TYPE_BOOL); + } + if (expr.operator === 'in' || expr.operator === 'not in') { + if (rightType.name !== 'list') { + diagnostics.push(error('type.operator_mismatch', `Right operand of '${expr.operator}' must be a list, got ${typeToString(rightType)}`, expr.location)); + } + return setType(expr, TYPE_BOOL); + } + return setType(expr, TYPE_MIXED); + } + + case 'UnaryExpression': { + const operandType = inferType(expr.operand, scope); + if (expr.operator === '-') { + if (operandType && !isAssignable(operandType, TYPE_NUMBER)) { + diagnostics.push(error('type.operator_mismatch', `Unary '-' requires number, got ${typeToString(operandType)}`, expr.location)); + } + return setType(expr, TYPE_NUMBER); + } + if (expr.operator === 'not' || expr.operator === '!') { + if (operandType && !isAssignable(operandType, TYPE_BOOL)) { + diagnostics.push(error('type.operator_mismatch', `'${expr.operator}' requires bool, got ${typeToString(operandType)}`, expr.location)); + } + return setType(expr, TYPE_BOOL); + } + return operandType ? setType(expr, operandType) : null; + } + + case 'CoercionExpression': { + const sourceType = inferType(expr.expression, scope); + const targetType = resolveTypeAnnotation(expr.targetType); + if (sourceType) { + checkCoercionValidity(sourceType, targetType, expr.location); + } + return setType(expr, targetType); + } + + case 'IfExpression': { + const condType = inferType(expr.condition, scope); + if (condType && !isAssignable(condType, TYPE_BOOL)) { + diagnostics.push(error('type.condition_not_bool', `Condition must be bool, got ${typeToString(condType)}`, expr.condition.location)); + } + const thenType = inferType(expr.then, scope, expected); + const elseIfTypes: (TypeSig | null)[] = []; + for (const ei of expr.elseIfs) { + const eiCondType = inferType(ei.condition, scope); + if (eiCondType && !isAssignable(eiCondType, TYPE_BOOL)) { + diagnostics.push(error('type.condition_not_bool', `Condition must be bool, got ${typeToString(eiCondType)}`, ei.condition.location)); + } + elseIfTypes.push(inferType(ei.then, scope, expected)); + } + const elseType = inferType(expr.else, scope, expected); + + // If we have an expected variant type and all branches match, use it + if (expected?.variants) { + const allBranches = [thenType, elseType, ...elseIfTypes]; + const allMatch = allBranches.every(bt => bt && isAssignable(bt, expected)); + if (allMatch) return setType(expr, expected); + } + + // Check branch type consistency (non-variant case) + if (thenType && elseType && !expected?.variants) { + if (!isAssignable(thenType, elseType) && !isAssignable(elseType, thenType)) { + diagnostics.push(error( + 'type.branch_mismatch', + `'then' branch has type ${typeToString(thenType)}, 'else' branch has type ${typeToString(elseType)}`, + expr.location, + )); + } + } + + return setType(expr, thenType ?? elseType ?? TYPE_MIXED); + } + + case 'MatchExpression': { + // match binding in iterable { ... } — iteration form + if (expr.binding && expr.iterable) { + const iterableType = inferType(expr.iterable, scope); + const elemType = resolveCollectionElemType(iterableType) ?? TYPE_MIXED; + // Type-check arms with binding in scope + const armTypes: (TypeSig | null)[] = []; + for (const arm of expr.arms) { + const armScope = new Map(scope); + armScope.set(expr.binding, elemType); + armTypes.push(inferType(arm.expression, armScope, expected)); + } + const firstType = armTypes.find(t => t != null) ?? null; + if (firstType) { + for (let i = 1; i < armTypes.length; i++) { + const at = armTypes[i]; + if (at && !isAssignable(at, firstType) && !isAssignable(firstType, at)) { + diagnostics.push(error( + 'type.branch_mismatch', + `Match arm ${i + 1} has type ${typeToString(at)}, expected ${typeToString(firstType)}`, + expr.arms[i].expression.location, + )); + break; + } + } + } + return firstType ? setType(expr, firstType) : null; + } + + let subjectType: TypeSig | null = null; + if (expr.subject) { + if (expr.subject.kind === 'ListLiteral') { + // Tuple match subject — infer element types without consistency check + for (const el of expr.subject.elements) { + inferType(el, scope); + } + subjectType = typeList(TYPE_MIXED); + setType(expr.subject, subjectType); + } else { + subjectType = inferType(expr.subject, scope); + } + } + + // Check exhaustiveness for variant subjects + if (subjectType?.variants && expr.arms.length > 0) { + checkMatchExhaustiveness(subjectType, expr.arms, expr.location); + } + + const armTypes: (TypeSig | null)[] = []; + for (const arm of expr.arms) { + const armScope = new Map(scope); + if (subjectType) { + checkPatternAgainstType(arm.pattern, subjectType, expr.location); + } + bindPatternVars(arm.pattern, subjectType, armScope); + armTypes.push(inferType(arm.expression, armScope, expected)); + } + + if (expected?.variants) { + return setType(expr, expected); + } + + // Build combined variant from all arms and check for tag conflicts + const combinedVariants: Record> = {}; + const tagSeenAtArm: Record = {}; + let hasVariants = false; + + for (let i = 0; i < armTypes.length; i++) { + const armType = armTypes[i]; + if (!armType?.variants) continue; + hasVariants = true; + for (const [tag, shape] of Object.entries(armType.variants)) { + if (tag in combinedVariants) { + // Same tag seen before — check fields are consistent + const existing = combinedVariants[tag]; + const existingFields = Object.keys(existing).sort().join(','); + const newFields = Object.keys(shape).sort().join(','); + if (existingFields !== newFields) { + const prevArm = tagSeenAtArm[tag] + 1; + diagnostics.push(error( + 'type.branch_mismatch', + `Match arm ${i + 1} constructs '${tag}' with fields {${Object.keys(shape).join(', ')}}, but arm ${prevArm} has {${Object.keys(existing).join(', ')}}`, + expr.arms[i].expression.location, + )); + } else { + // Same fields — check types are compatible + for (const [field, fieldType] of Object.entries(shape)) { + const existingFieldType = existing[field]; + if (existingFieldType && !isAssignable(fieldType, existingFieldType)) { + diagnostics.push(error( + 'type.branch_mismatch', + `Match arm ${i + 1}: field '${field}' of '${tag}' has type ${typeToString(fieldType)}, but arm ${tagSeenAtArm[tag] + 1} has ${typeToString(existingFieldType)}`, + expr.arms[i].expression.location, + )); + } + } + } + } else { + combinedVariants[tag] = shape; + tagSeenAtArm[tag] = i; + } + } + } + + if (hasVariants && Object.keys(combinedVariants).length > 0) { + // Use first arm's name (tag) since these are ad-hoc + const firstName = armTypes.find(t => t?.variants)!.name; + const combined: TypeSig = { name: firstName, params: [], variants: combinedVariants }; + return setType(expr, combined); + } + + // Non-variant: check branch consistency and return first type + const firstType = armTypes.find(t => t != null) ?? null; + if (firstType) { + for (let i = 0; i < armTypes.length; i++) { + const at = armTypes[i]; + if (at && at !== firstType && !isAssignable(at, firstType) && !isAssignable(firstType, at)) { + diagnostics.push(error( + 'type.branch_mismatch', + `Match arm ${i + 1} has type ${typeToString(at)}, expected ${typeToString(firstType)}`, + expr.arms[i].expression.location, + )); + break; + } + } + } + + return firstType ? setType(expr, firstType) : null; + } + + case 'CallExpression': { + // Named expression calls (try direct, then qualify with current namespace) + let resolvedCallee = expr.callee; + let info = exprDecls.get(resolvedCallee); + if (!info && currentCheckerNamespace) { + resolvedCallee = `${currentCheckerNamespace}.${expr.callee}`; + info = exprDecls.get(resolvedCallee); + } + if (info) { + checkExpressionCallArgs(expr, info, scope); + const ret = info.returnType ?? declTypes.get(resolvedCallee) ?? TYPE_MIXED; + return setType(expr, ret); + } + + // Let plugins override intrinsic type checking + if (plugins) { + const allArgExprs = expr.allArgs || [...expr.args, ...Object.values(expr.namedArgs)]; + const pluginArgTypes: TypeSig[] = []; + for (const arg of allArgExprs) { + const t = inferType(arg, scope); + if (t) pluginArgTypes.push(t); + } + for (const plugin of plugins) { + const result = plugin.checker?.checkCall?.(expr.callee, pluginArgTypes); + if (result) return setType(expr, result); + } + } + + // Intrinsic functions + const intrinsicSig = INTRINSICS[expr.callee]; + if (intrinsicSig) { + // Collect all arg types (positional + named, for shorthand support) + const allArgExprs = [...expr.args, ...Object.values(expr.namedArgs)]; + const argTypes: TypeSig[] = []; + for (const arg of allArgExprs) { + const t = inferType(arg, scope); + if (t) argTypes.push(t); + } + + const totalArgs = allArgExprs.length; + + // Arity check (skip for variadic intrinsics with 2+ args) + if (!intrinsicSig.variadic && totalArgs !== intrinsicSig.params.length) { + diagnostics.push(error( + 'type.argument_count', + `'${expr.callee}' expects ${intrinsicSig.params.length} argument(s), got ${totalArgs}`, + expr.location, + )); + } + // Arg type checks — for variadic with multiple args, check each is number + if (intrinsicSig.variadic && totalArgs > 1) { + for (let i = 0; i < argTypes.length; i++) { + if (!isAssignable(argTypes[i], TYPE_NUMBER)) { + diagnostics.push(error( + 'type.argument_mismatch', + `Argument ${i + 1} of '${expr.callee}' expects number, got ${typeToString(argTypes[i])}`, + allArgExprs[i].location, + )); + } + } + } else { + for (let i = 0; i < Math.min(argTypes.length, intrinsicSig.params.length); i++) { + const expected = intrinsicSig.params[i].type; + if (expected.name !== 'mixed' && !isAssignable(argTypes[i], expected)) { + diagnostics.push(error( + 'type.argument_mismatch', + `Argument '${intrinsicSig.params[i].name}' of '${expr.callee}' expects ${typeToString(expected)}, got ${typeToString(argTypes[i])}`, + allArgExprs[i].location, + )); + } + } + } + + if (intrinsicSig.returnType === 'from_arg') { + if (expr.callee === 'flatten' && argTypes.length > 0 && argTypes[0].name === 'list') { + // flatten(list(list(T))) -> list(T): unwrap one nesting level + const innerType = argTypes[0].elementType; + if (innerType && innerType.name === 'list') { + return setType(expr, innerType); + } + return setType(expr, typeList(TYPE_MIXED)); + } + return setType(expr, argTypes.length > 0 ? argTypes[0] : TYPE_MIXED); + } + return setType(expr, intrinsicSig.returnType); + } + + // Unknown function + for (const arg of expr.args) inferType(arg, scope); + for (const argExpr of Object.values(expr.namedArgs)) inferType(argExpr, scope); + diagnostics.push(error('type.unknown_function', `Unknown function '${expr.callee}'`, expr.location)); + return setType(expr, TYPE_MIXED); + } + + case 'ListLiteral': { + // If the expected type is list(T), propagate T as context for elements + let expectedElemType: TypeSig | null = null; + if (expected?.name === 'list' && expected.params.length > 0) { + expectedElemType = namedTypes.get(expected.params[0]) + ?? resolveTypeKeyword(expected.params[0]) + ?? null; + } + + const elemTypes: TypeSig[] = []; + for (const el of expr.elements) { + const t = inferType(el, scope, expectedElemType); + if (t) elemTypes.push(t); + } + + // Validate each element against the expected element type + if (expectedElemType) { + for (let i = 0; i < elemTypes.length; i++) { + if (!isAssignable(elemTypes[i], expectedElemType)) { + diagnostics.push(error( + 'type.list_element_mismatch', + `List element at index ${i} has type ${typeToString(elemTypes[i])}, expected ${typeToString(expectedElemType)}`, + expr.elements[i].location, + )); + } + } + } else if (elemTypes.length > 1) { + // No expected type — check consistency among elements + const first = elemTypes[0]; + for (let i = 1; i < elemTypes.length; i++) { + if (!isAssignable(elemTypes[i], first) && !isAssignable(first, elemTypes[i])) { + diagnostics.push(error( + 'type.mixed_list', + `List element at index ${i} has type ${typeToString(elemTypes[i])}, expected ${typeToString(first)}`, + expr.elements[i].location, + )); + break; + } + } + } + + const inferredElem = expectedElemType ?? elemTypes[0] ?? TYPE_MIXED; + return setType(expr, typeList(inferredElem)); + } + + case 'DictLiteral': { + // If expected is dict(T), propagate T as context for values + const expectedValueType = (expected?.name === 'dict' && expected.elementType) ? expected.elementType : null; + + const shape: Record = {}; + const valueTypes: TypeSig[] = []; + for (const entry of expr.entries) { + const t = inferType(entry.value, scope, expectedValueType); + if (t) { + shape[entry.key] = t; + valueTypes.push(t); + } + } + + // If we have an expected value type, validate and return typed dict + if (expectedValueType) { + for (let i = 0; i < valueTypes.length; i++) { + if (!isAssignable(valueTypes[i], expectedValueType)) { + diagnostics.push(error( + 'type.dict_value_mismatch', + `Dict value '${expr.entries[i].key}' has type ${typeToString(valueTypes[i])}, expected ${typeToString(expectedValueType)}`, + expr.entries[i].value.location, + )); + } + } + return setType(expr, typeDict(shape, expectedValueType)); + } + + // Infer value type from entries if all values have the same type + if (valueTypes.length > 0) { + const first = valueTypes[0]; + const allSame = valueTypes.every(t => isAssignable(t, first)); + if (allSame) return setType(expr, typeDict(shape, first)); + } + + return setType(expr, typeDict(shape)); + } + + case 'VariantConstruction': { + const tag = expr.tag; + const resolvedType = resolveVariantTag(tag, expr.typeName, expected); + + // Infer types for all provided entries regardless of resolution + const providedFields = new Map(); + for (const entry of expr.entries) { + const t = inferType(entry.value, scope); + if (t) providedFields.set(entry.key, t); + } + + // Validate fields when the author explicitly opted into a named type: + // either via an expected type from context (return annotation, list element type) + // or via a qualified name (RuleResult.ok { ... }) + const hasExplicitType = expected !== null || expr.typeName !== undefined; + + if (!resolvedType || !hasExplicitType) { + // No explicit type context — build a structural ad-hoc variant + const shape: Record = {}; + for (const [k, v] of providedFields) shape[k] = v; + // Ad-hoc variant — use tag name so it's treated as anonymous, not as the named type + return setType(expr, { name: tag, params: [], variants: { [tag]: shape } }); + } + + // Validate fields against the resolved named type + const declaredShape = resolvedType.variants![tag]; + + // Check for missing required fields + for (const field of Object.keys(declaredShape)) { + if (!providedFields.has(field)) { + diagnostics.push(error( + 'type.missing_field', + `Variant '${tag}' is missing required field '${field}' (expected ${typeToString(declaredShape[field])})`, + expr.location, + )); + } + } + + // Check for extra fields + for (const [field] of providedFields) { + if (!(field in declaredShape)) { + diagnostics.push(error( + 'type.extra_field', + `Variant '${tag}' has no field '${field}'`, + expr.location, + )); + } + } + + // Check field types + for (const [field, actualType] of providedFields) { + const expectedType = declaredShape[field]; + if (expectedType && !isAssignable(actualType, expectedType)) { + diagnostics.push(error( + 'type.field_type_mismatch', + `Field '${field}' of '${tag}' expects ${typeToString(expectedType)}, got ${typeToString(actualType)}`, + expr.location, + )); + } + } + + return setType(expr, resolvedType); + } + + case 'AnyExpression': + case 'AllExpression': { + const collType = inferType(expr.list, scope); + if (collType && collType.name !== 'list' && collType.name !== 'dict') { + diagnostics.push(error('type.not_iterable', `'${expr.kind === 'AnyExpression' ? 'any' : 'all'}' requires a list or dict, got ${typeToString(collType)}`, expr.location)); + } + const elemType = resolveCollectionElemType(collType); + if (elemType) checkPatternAgainstType(expr.pattern, elemType, expr.location); + return setType(expr, TYPE_BOOL); + } + + case 'CollectExpression': { + const collType = inferType(expr.list, scope); + if (collType && collType.name !== 'list' && collType.name !== 'dict') { + diagnostics.push(error('type.not_iterable', `'collect' requires a list or dict, got ${typeToString(collType)}`, expr.location)); + } + + // Binding form: collect row in list { arms } + if (expr.binding && expr.arms) { + const elemType = resolveCollectionElemType(collType) ?? TYPE_MIXED; + const armTypes: (TypeSig | null)[] = []; + for (const arm of expr.arms) { + const armScope = new Map(scope); + armScope.set(expr.binding, elemType); + armTypes.push(inferType(arm.body, armScope)); + } + const bodyType = armTypes.find(t => t != null) ?? null; + return setType(expr, typeList(bodyType ?? TYPE_MIXED)); + } + + // Standard form: collect pattern in list => body + const collectScope = new Map(scope); + const elemType = resolveCollectionElemType(collType); + if (expr.pattern && elemType) checkPatternAgainstType(expr.pattern, elemType, expr.location); + if (expr.pattern) bindPatternVars(expr.pattern, elemType, collectScope); + const bodyType = expr.body ? inferType(expr.body, collectScope) : null; + // Collect from dict → dict, from list → list + const isDict = collType && (collType.name === 'dict' || (collType.shape && !Array.isArray(collType.shape))); + if (isDict) { + return setType(expr, typeDict(undefined, bodyType ?? TYPE_MIXED)); + } + return setType(expr, typeList(bodyType ?? TYPE_MIXED)); + } + + case 'AggregateCollectExpression': { + const collType = inferType(expr.list, scope); + if (collType && collType.name !== 'list' && collType.name !== 'dict') { + diagnostics.push(error('type.not_iterable', `Aggregate collect requires a list or dict, got ${typeToString(collType)}`, expr.location)); + } + const elemType = resolveCollectionElemType(collType); + for (const arm of expr.arms) { + const armScope = new Map(scope); + if (expr.binding) { + armScope.set(expr.binding, elemType ?? TYPE_MIXED); + } else { + if (elemType) checkPatternAgainstType(arm.pattern, elemType, expr.location); + bindPatternVars(arm.pattern, elemType, armScope); + } + inferType(arm.body, armScope); + } + // Validate aggregator exists + if (!INTRINSICS[expr.aggregator]) { + diagnostics.push(error('type.unknown_function', `Unknown aggregator '${expr.aggregator}'`, expr.location)); + } + const intrinsic = INTRINSICS[expr.aggregator]; + if (intrinsic) { + return setType(expr, intrinsic.returnType === 'from_arg' ? TYPE_MIXED : intrinsic.returnType); + } + return setType(expr, TYPE_MIXED); + } + + case 'ParenExpression': { + const inner = inferType(expr.expression, scope, expected); + return inner ? setType(expr, inner) : null; + } + + case 'WhereExpression': { + const whereScope = new Map(scope); + for (const binding of expr.bindings) { + const t = inferType(binding.value, whereScope); + if (t) whereScope.set(binding.name, t); + } + const bodyType = inferType(expr.body, whereScope, expected); + return bodyType ? setType(expr, bodyType) : null; + } + } + + return null; + } + + function setType(expr: Expr, t: TypeSig): TypeSig { + exprTypes.set(expr, t); + return t; + } + + // --- Expression call argument validation --- + + function checkExpressionCallArgs( + expr: Expr & { kind: 'CallExpression'; callee: string; args: Expr[]; namedArgs: Record }, + info: ExprDeclInfo, + scope: Map, + ) { + const paramNames = info.decl.params.map(p => p.name); + const namedArgNames = new Set(Object.keys(expr.namedArgs)); + const hasSpread = !!(expr as any).spread; + const hasNamed = namedArgNames.size > 0; + const hasPositional = expr.args.length > 0; + + if (hasNamed || hasSpread) { + // Validate named args (includes shorthand) + for (const name of namedArgNames) { + if (!info.paramTypes[name]) { + const suggestion = findClosest(name, paramNames); + const msg = suggestion + ? `Unknown argument '${name}' — did you mean '${suggestion}'?` + : `Unknown argument '${name}' for '${expr.callee}'`; + diagnostics.push(error('type.unknown_argument', msg, expr.location)); + } + } + + // Check for missing arguments (account for both named, positional, and spread) + const positionallyBound = new Set(); + for (let i = 0; i < Math.min(expr.args.length, paramNames.length); i++) { + positionallyBound.add(paramNames[i]); + } + for (const pname of paramNames) { + if (namedArgNames.has(pname) || positionallyBound.has(pname)) continue; + // If spread is active, try to resolve from scope + if (hasSpread) { + const scopeType = scope.get(pname); + if (scopeType) { + // Type-check the spread-resolved variable + const expectedType = info.paramTypes[pname]; + if (expectedType && expectedType.name !== 'mixed' && !isAssignable(scopeType, expectedType)) { + diagnostics.push(error( + 'type.argument_mismatch', + `Spread argument '${pname}' has type ${typeToString(scopeType)}, expected ${typeToString(expectedType)}`, + expr.location, + )); + } + continue; + } + } + diagnostics.push(error( + 'type.missing_argument', + `Missing argument '${pname}' in call to '${expr.callee}' (expected ${typeToString(info.paramTypes[pname])})`, + expr.location, + )); + } + + // Type-check named args + for (const [name, argExpr] of Object.entries(expr.namedArgs)) { + const expectedType = info.paramTypes[name] ?? null; + const argType = inferType(argExpr, scope, expectedType); + if (argType && expectedType) { + checkArgType(expr.callee, name, argType, expectedType, argExpr.location); + } + } + + // Type-check any positional args alongside named + for (let i = 0; i < Math.min(expr.args.length, paramNames.length); i++) { + if (namedArgNames.has(paramNames[i])) continue; + const expectedType = info.paramTypes[paramNames[i]] ?? null; + const argType = inferType(expr.args[i], scope, expectedType); + if (argType && expectedType) { + checkArgType(expr.callee, paramNames[i], argType, expectedType, expr.args[i].location); + } + } + } else if (hasPositional) { + if (expr.args.length !== paramNames.length) { + const sig = paramNames.map(n => `${n}: ${typeToString(info.paramTypes[n])}`).join(', '); + diagnostics.push(error( + 'type.argument_count', + `'${expr.callee}' expects ${paramNames.length} argument(s) (${sig}), got ${expr.args.length}`, + expr.location, + )); + } + + for (let i = 0; i < Math.min(expr.args.length, paramNames.length); i++) { + const expectedType = info.paramTypes[paramNames[i]] ?? null; + const argType = inferType(expr.args[i], scope, expectedType); + if (argType && expectedType) { + checkArgType(expr.callee, paramNames[i], argType, expectedType, expr.args[i].location); + } + } + } else if (paramNames.length > 0) { + const sig = paramNames.map(n => `${n}: ${typeToString(info.paramTypes[n])}`).join(', '); + diagnostics.push(error( + 'type.argument_count', + `'${expr.callee}' expects ${paramNames.length} argument(s) (${sig}), got 0`, + expr.location, + )); + } + } + + function checkArgType(callee: string, paramName: string, actual: TypeSig, expected: TypeSig, loc?: Location) { + if (expected.name === 'mixed') return; + // For shaped dict params, check structural compatibility + if (expected.shape && actual.shape) { + for (const [field, fieldType] of Object.entries(expected.shape)) { + if (!actual.shape[field]) { + diagnostics.push(error( + 'type.argument_mismatch', + `Argument '${paramName}' of '${callee}' is missing field '${field}' (expected ${typeToString(fieldType)})`, + loc, + )); + } else if (!isAssignable(actual.shape[field], fieldType)) { + diagnostics.push(error( + 'type.argument_mismatch', + `Field '${field}' of argument '${paramName}' expects ${typeToString(fieldType)}, got ${typeToString(actual.shape[field])}`, + loc, + )); + } + } + return; + } + if (!isAssignable(actual, expected)) { + diagnostics.push(error( + 'type.argument_mismatch', + `Argument '${paramName}' of '${callee}' expects ${typeToString(expected)}, got ${typeToString(actual)}`, + loc, + )); + } + } + + // --- Variant constructor / pattern validation --- + + function checkPatternAgainstType(pattern: Pattern, subjectType: TypeSig, loc?: Location) { + if (pattern.kind === 'AlternativePattern') { + for (const alt of pattern.patterns) { + checkPatternAgainstType(alt, subjectType, loc); + } + return; + } + if (pattern.kind === 'VariantPattern' && subjectType.variants) { + if (!subjectType.variants[pattern.tag]) { + const validTags = Object.keys(subjectType.variants); + const suggestion = findClosest(pattern.tag, validTags); + const msg = suggestion + ? `Unknown variant tag '${pattern.tag}' on type '${subjectType.name}' — did you mean '${suggestion}'?` + : `Unknown variant tag '${pattern.tag}' on type '${subjectType.name}'. Valid tags: ${validTags.join(', ')}`; + diagnostics.push(error('type.unknown_tag', msg, pattern.location)); + } else { + // Check that bindings reference valid fields + const declaredShape = subjectType.variants[pattern.tag]; + for (const field of Object.keys(pattern.bindings)) { + if (!(field in declaredShape)) { + const validFields = Object.keys(declaredShape); + const suggestion = findClosest(field, validFields); + const msg = suggestion + ? `Variant '${pattern.tag}' has no field '${field}' — did you mean '${suggestion}'?` + : `Variant '${pattern.tag}' has no field '${field}'`; + diagnostics.push(error('type.unknown_field', msg, pattern.location)); + } + } + } + } + } + + function checkMatchExhaustiveness(subjectType: TypeSig, arms: MatchArm[], loc?: Location) { + if (!subjectType.variants) return; + const allTags = new Set(Object.keys(subjectType.variants)); + let hasWildcard = false; + + for (const arm of arms) { + const patterns = arm.pattern.kind === 'AlternativePattern' ? arm.pattern.patterns : [arm.pattern]; + for (const pat of patterns) { + if (pat.kind === 'WildcardPattern') { + hasWildcard = true; + } else if (pat.kind === 'VariantPattern') { + allTags.delete(pat.tag); + } + } + } + + if (!hasWildcard && allTags.size > 0) { + const missing = Array.from(allTags).join(', '); + diagnostics.push(error( + 'type.non_exhaustive_match', + `Match is not exhaustive — missing tags: ${missing}`, + loc, + )); + } + } + + // --- Coercion validation --- + + function checkCoercionValidity(source: TypeSig, target: TypeSig, loc?: Location) { + // Valid coercions: string → number, string → money, number → money, number → string + const validCoercions: [string, string][] = [ + ['string', 'number'], + ['string', 'money'], + ['number', 'money'], + ['number', 'string'], + ['money', 'number'], + ['money', 'string'], + ]; + if (isAssignable(source, target)) return; // already compatible, coercion is redundant but fine + const isValid = validCoercions.some(([from, to]) => source.name === from && target.name === to); + if (!isValid) { + diagnostics.push(error( + 'type.invalid_coercion', + `Cannot coerce ${typeToString(source)} to ${typeToString(target)}`, + loc, + )); + } + } + + // --- Pattern variable binding --- + + function bindPatternVars(pattern: Pattern, subjectType: TypeSig | null, scope: Map): void { + if (pattern.kind === 'TuplePattern') { + for (const elem of pattern.elements) { + bindPatternVars(elem, null, scope); + } + return; + } + if (pattern.kind === 'VariantPattern') { + let payloadShape: Record | null = null; + if (subjectType?.variants) { + payloadShape = subjectType.variants[pattern.tag] ?? null; + } + if (!payloadShape) { + for (const [, typeSig] of namedTypes) { + if (typeSig.variants && typeSig.variants[pattern.tag]) { + payloadShape = typeSig.variants[pattern.tag]; + break; + } + } + } + + for (const [field, alias] of Object.entries(pattern.bindings)) { + if (alias === null) continue; + const fieldType = payloadShape?.[field] ?? TYPE_MIXED; + scope.set(alias, fieldType); + } + } + } + + // --- Utility --- + + function findClosest(name: string, candidates: string[]): string | null { + let best: string | null = null; + let bestDist = Infinity; + for (const c of candidates) { + const d = editDistance(name.toLowerCase(), c.toLowerCase()); + if (d < bestDist && d <= 3) { + bestDist = d; + best = c; + } + } + return best; + } + + function editDistance(a: string, b: string): number { + const m = a.length, n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; + } +} diff --git a/playground/src/lang/diagnostics.ts b/playground/src/lang/diagnostics.ts new file mode 100644 index 0000000..2f2e2e4 --- /dev/null +++ b/playground/src/lang/diagnostics.ts @@ -0,0 +1,23 @@ +export interface Location { + line: number; + column: number; + offset: number; + length: number; +} + +export type Severity = 'error' | 'warning' | 'info'; + +export interface Diagnostic { + severity: Severity; + code: string; + message: string; + location?: Location; +} + +export function error(code: string, message: string, location?: Location): Diagnostic { + return { severity: 'error', code, message, location }; +} + +export function warning(code: string, message: string, location?: Location): Diagnostic { + return { severity: 'warning', code, message, location }; +} diff --git a/playground/src/lang/evaluator.ts b/playground/src/lang/evaluator.ts new file mode 100644 index 0000000..c5b9a35 --- /dev/null +++ b/playground/src/lang/evaluator.ts @@ -0,0 +1,702 @@ +import { + ProgramNode, ExpressionDeclaration, SourceDeclaration, TableDeclaration, Expr, Pattern, MatchArm, +} from './ast'; + +type Value = number | string | boolean | ValueArray | ValueRecord | null; +interface ValueArray extends Array {} +interface ValueRecord { [key: string]: Value } + +interface Scope { + vars: Map; + parent?: Scope; +} + +function scopeGet(scope: Scope, name: string): Value | undefined { + const val = scope.vars.get(name); + if (val !== undefined) return val; + if (scope.parent) return scopeGet(scope.parent, name); + return undefined; +} + +function childScope(parent: Scope): Scope { + return { vars: new Map(), parent }; +} + +/** Extract numeric values from a list or dict. */ +function numericValues(v: Value): number[] { + if (Array.isArray(v)) return v as number[]; + if (v && typeof v === 'object') return Object.values(v) as number[]; + return []; +} + +// Built-in intrinsic functions +const INTRINSICS: Record Value> = { + round: (n, decimals) => { + const d = typeof decimals === 'number' ? decimals : 0; + const factor = Math.pow(10, d); + return Math.round((n as number) * factor) / factor; + }, + len: (list) => { + if (Array.isArray(list)) return list.length; + if (list && typeof list === 'object') return Object.keys(list).length; + return 0; + }, + flatten: (list) => { + if (!Array.isArray(list)) return []; + return list.flat(); + }, + product: (list) => { + const vals = numericValues(list); + return vals.reduce((a, b) => a * b, 1); + }, + sum: (list) => { + const vals = numericValues(list); + return vals.reduce((a, b) => a + b, 0); + }, + sum_money: (list) => { + const vals = numericValues(list); + return vals.reduce((a, b) => a + b, 0); + }, + max: (...args) => { + if (args.length === 1 && Array.isArray(args[0])) { + return (args[0] as number[]).length === 0 ? 0 : Math.max(...(args[0] as number[])); + } + return Math.max(...(args as number[])); + }, + min: (...args) => { + if (args.length === 1 && Array.isArray(args[0])) { + return (args[0] as number[]).length === 0 ? 0 : Math.min(...(args[0] as number[])); + } + return Math.min(...(args as number[])); + }, +}; + +export interface EvalResult { + value: Value; + error?: string; +} + +export function evaluate( + ast: ProgramNode, + expressionName: string, + inputData: Record, + sourceData?: Record>, + tableData?: Record, + plugins?: import('./plugin').AxiomPlugin[], +): EvalResult { + try { + // Collect expression declarations (top-level and namespaced) + const exprDecls = new Map(); + const srcDecls = new Map(); + const tableDeclNames = new Set(); + for (const decl of ast.body) { + if (decl.kind === 'ExpressionDeclaration') { + exprDecls.set(decl.name, decl); + } + if (decl.kind === 'SourceDeclaration') { + srcDecls.set(decl.name, decl); + } + if (decl.kind === 'TableDeclaration') { + tableDeclNames.add(decl.name); + } + if (decl.kind === 'NamespaceDeclaration') { + for (const expr of decl.expressions) { + exprDecls.set(`${decl.name}.${expr.name}`, expr); + } + for (const src of decl.sources) { + srcDecls.set(`${decl.name}.${src.name}`, src); + } + } + } + + // Register payload-less variant tags as constants + const rootScope: Scope = { vars: new Map() }; + for (const decl of ast.body) { + const typeDecls = decl.kind === 'TypeDeclaration' ? [decl] + : decl.kind === 'NamespaceDeclaration' ? decl.types : []; + for (const td of typeDecls) { + for (const alt of td.alternatives) { + if (Object.keys(alt.shape).length === 0) { + rootScope.vars.set(alt.tag, { _tag: alt.tag }); + } + } + } + } + + // Collect namespace constants + const namespaceValues = new Map(); + for (const decl of ast.body) { + if (decl.kind === 'NamespaceDeclaration') { + for (const sym of decl.symbols) { + const val = evalExpr(sym.value, rootScope); + namespaceValues.set(`${decl.name}.${sym.name}`, val); + rootScope.vars.set(`${decl.name}.${sym.name}`, val); + } + } + } + + // Register table data into root scope + if (tableData) { + for (const name of tableDeclNames) { + const data = tableData[name]; + if (data) { + rootScope.vars.set(name, data as unknown as Value); + } + } + } + + // Track current namespace for unqualified sibling resolution + let currentNamespace: string | undefined; + + const targetDecl = exprDecls.get(expressionName); + if (!targetDecl) { + return { value: null, error: `Expression '${expressionName}' not found` }; + } + + // Build scope from input data + const scope: Scope = { vars: new Map(rootScope.vars), parent: undefined }; + for (const param of targetDecl.params) { + const val = inputData[param.name]; + if (val !== undefined) { + scope.vars.set(param.name, val); + } else { + return { value: null, error: `Missing input parameter '${param.name}'` }; + } + } + + const value = evalExpr(targetDecl.body, scope); + return { value }; + + function evalExpr(expr: Expr, scope: Scope): Value { + switch (expr.kind) { + case 'Literal': + return expr.value; + + case 'PluginLiteral': + return expr.value as Value; + + case 'Identifier': { + const val = scopeGet(scope, expr.name); + if (val !== undefined) return val; + // Check namespace constants + const nsVal = namespaceValues.get(expr.name); + if (nsVal !== undefined) return nsVal; + // Try qualifying with current namespace + if (currentNamespace) { + const qualVal = namespaceValues.get(`${currentNamespace}.${expr.name}`); + if (qualVal !== undefined) return qualVal; + } + // Auto-evaluate parameterless expressions referenced by name + const paramlessDecl = exprDecls.get(expr.name) + ?? (currentNamespace ? exprDecls.get(`${currentNamespace}.${expr.name}`) : undefined); + if (paramlessDecl && paramlessDecl.params.length === 0) { + const prevNs = currentNamespace; + const dotIdx = expr.name.lastIndexOf('.'); + currentNamespace = dotIdx >= 0 ? expr.name.substring(0, dotIdx) : currentNamespace; + const result = evalExpr(paramlessDecl.body, childScope(scope)); + currentNamespace = prevNs; + return result; + } + throw new Error(`Undefined variable '${expr.name}'`); + } + + case 'MemberExpression': { + const obj = evalExpr(expr.object, scope); + if (obj && typeof obj === 'object' && !Array.isArray(obj)) { + return (obj as Record)[expr.property] ?? null; + } + throw new Error(`Cannot access property '${expr.property}' on ${typeof obj}`); + } + + case 'IndexExpression': { + const obj = evalExpr(expr.object, scope); + const idx = evalExpr(expr.index, scope); + if (Array.isArray(obj) && typeof idx === 'number') { + return obj[idx] ?? null; + } + if (obj && typeof obj === 'object' && typeof idx === 'string') { + return (obj as Record)[idx] ?? null; + } + return null; + } + + case 'InfixExpression': { + // Short-circuit for && and || + if (expr.operator === '&&') { + const left = evalExpr(expr.left, scope); + if (!left) return false; + return !!evalExpr(expr.right, scope); + } + if (expr.operator === '||') { + const left = evalExpr(expr.left, scope); + if (left) return true; + return !!evalExpr(expr.right, scope); + } + + const left = evalExpr(expr.left, scope); + const right = evalExpr(expr.right, scope); + + // Let plugins handle operator evaluation first + if (plugins) { + for (const plugin of plugins) { + if (plugin.evaluator?.supportsOp?.(left, right, expr.operator)) { + return plugin.evaluator.evaluateOp!(left, right, expr.operator) as Value; + } + } + } + + switch (expr.operator) { + case '+': return (left as number) + (right as number); + case '-': return (left as number) - (right as number); + case '*': return (left as number) * (right as number); + case '/': return (right as number) === 0 ? 0 : (left as number) / (right as number); + case '%': return (left as number) % (right as number); + case '**': return Math.pow(left as number, right as number); + case '==': return left === right; + case '!=': return left !== right; + case '<': return (left as number) < (right as number); + case '>': return (left as number) > (right as number); + case '<=': return (left as number) <= (right as number); + case '>=': return (left as number) >= (right as number); + case 'in': { + if (Array.isArray(right)) return right.includes(left); + return false; + } + case 'not in': { + if (Array.isArray(right)) return !right.includes(left); + return true; + } + default: + throw new Error(`Unknown operator '${expr.operator}'`); + } + } + + case 'UnaryExpression': { + const operand = evalExpr(expr.operand, scope); + switch (expr.operator) { + case '-': return -(operand as number); + case 'not': + case '!': return !operand; + default: return operand; + } + } + + case 'CoercionExpression': { + const val = evalExpr(expr.expression, scope); + const target = expr.targetType.keyword; + if (target === 'number' || target === 'money') { + if (typeof val === 'string') { + // Strip currency suffixes, percentage signs + const cleaned = val.replace(/[^0-9.\-]/g, ''); + return parseFloat(cleaned) || 0; + } + return typeof val === 'number' ? val : 0; + } + if (target === 'string') { + return String(val); + } + return val; + } + + case 'IfExpression': { + const cond = evalExpr(expr.condition, scope); + if (cond) return evalExpr(expr.then, scope); + for (const ei of expr.elseIfs) { + const eiCond = evalExpr(ei.condition, scope); + if (eiCond) return evalExpr(ei.then, scope); + } + return evalExpr(expr.else, scope); + } + + case 'MatchExpression': { + // match binding in iterable { ... } — iteration form + if (expr.binding && expr.iterable) { + const list = evalExpr(expr.iterable, scope); + if (!Array.isArray(list)) throw new Error('match-in requires a list'); + let fallbackArm: MatchArm | undefined; + for (const arm of expr.arms) { + if (arm.pattern.kind === 'WildcardPattern') { + fallbackArm = arm; + continue; + } + } + // Iterate over list, try non-wildcard arms for each element + for (const elem of list) { + const elemScope = childScope(scope); + elemScope.vars.set(expr.binding, elem); + for (const arm of expr.arms) { + if (arm.pattern.kind === 'WildcardPattern') continue; + const bindings = matchPattern(arm.pattern, null, elemScope); + if (bindings !== null) { + for (const [k, v] of Object.entries(bindings)) { + elemScope.vars.set(k, v); + } + return evalExpr(arm.expression, elemScope); + } + } + } + // No element matched — use wildcard fallback + if (fallbackArm) { + return evalExpr(fallbackArm.expression, scope); + } + throw new Error('No matching element in match-in expression'); + } + + // Standard match + const subject = expr.subject ? evalExpr(expr.subject, scope) : null; + for (const arm of expr.arms) { + const bindings = matchPattern(arm.pattern, subject, scope); + if (bindings !== null) { + const armScope = childScope(scope); + for (const [k, v] of Object.entries(bindings)) { + armScope.vars.set(k, v); + } + return evalExpr(arm.expression, armScope); + } + } + throw new Error('No matching arm in match expression'); + } + + case 'CallExpression': { + // Resolve callee: try direct, then qualify with current namespace + let qualifiedCallee = expr.callee; + let decl = exprDecls.get(qualifiedCallee); + if (!decl && currentNamespace) { + qualifiedCallee = `${currentNamespace}.${expr.callee}`; + decl = exprDecls.get(qualifiedCallee); + } + if (decl) { + const callScope = childScope(scope); + // Bind positional args + const positionallyBound = new Set(); + for (let i = 0; i < expr.args.length && i < decl.params.length; i++) { + callScope.vars.set(decl.params[i].name, evalExpr(expr.args[i], scope)); + positionallyBound.add(decl.params[i].name); + } + // Bind named args + const namedArgNames = new Set(Object.keys(expr.namedArgs)); + for (const [name, argExpr] of Object.entries(expr.namedArgs)) { + callScope.vars.set(name, evalExpr(argExpr, scope)); + } + // Spread: fill remaining params from caller scope by matching name + if (expr.spread) { + for (const param of decl.params) { + if (!positionallyBound.has(param.name) && !namedArgNames.has(param.name)) { + const val = scopeGet(scope, param.name); + if (val !== undefined) { + callScope.vars.set(param.name, val); + } + } + } + } + // Copy namespace constants + for (const [k, v] of namespaceValues) { + callScope.vars.set(k, v); + } + // Set namespace context from the resolved callee + const prevNamespace = currentNamespace; + const dotIdx = qualifiedCallee.lastIndexOf('.'); + currentNamespace = dotIdx >= 0 ? qualifiedCallee.substring(0, dotIdx) : undefined; + const result = evalExpr(decl.body, callScope); + currentNamespace = prevNamespace; + return result; + } + + // Source declarations — lookup in provided source data + { + let srcCallee = expr.callee; + let srcDecl = srcDecls.get(srcCallee); + if (!srcDecl && currentNamespace) { + srcCallee = `${currentNamespace}.${expr.callee}`; + srcDecl = srcDecls.get(srcCallee); + } + if (srcDecl && sourceData) { + const data = sourceData[srcCallee]; + if (!data) throw new Error(`No source data provided for '${srcCallee}'`); + // Build lookup key from evaluated args + const keyParts: string[] = []; + // Bind positional args + for (let i = 0; i < expr.args.length && i < srcDecl.params.length; i++) { + keyParts.push(String(evalExpr(expr.args[i], scope))); + } + // Bind named args + const positionalCount = Math.min(expr.args.length, srcDecl.params.length); + for (const param of srcDecl.params.slice(positionalCount)) { + const namedVal = expr.namedArgs[param.name]; + if (namedVal) { + keyParts.push(String(evalExpr(namedVal, scope))); + } else if (expr.spread) { + const val = scopeGet(scope, param.name); + if (val !== undefined) keyParts.push(String(val)); + } + } + const key = keyParts.join('|'); + const result = data[key]; + if (result === undefined) throw new Error(`Source '${srcCallee}' has no entry for key '${key}'`); + return result; + } + } + + // Plugin intrinsics (checked before built-ins, can override) + if (plugins) { + for (const plugin of plugins) { + const pluginFn = plugin.evaluator?.intrinsics?.[expr.callee]; + if (pluginFn) { + const args = (expr.allArgs || expr.args).map(a => evalExpr(a, scope)); + const result = pluginFn(...args); + if (result !== undefined) return result as Value; + } + } + } + + // Intrinsic functions + const intrinsic = INTRINSICS[expr.callee]; + if (intrinsic) { + // Use allArgs to preserve original source order + const args = (expr.allArgs || expr.args).map(a => evalExpr(a, scope)); + return intrinsic(...args); + } + + throw new Error(`Unknown function '${expr.callee}'`); + } + + case 'ListLiteral': + return expr.elements.map(e => evalExpr(e, scope)); + + case 'DictLiteral': { + const result: Record = {}; + for (const entry of expr.entries) { + result[entry.key] = evalExpr(entry.value, scope); + } + return result; + } + + case 'VariantConstruction': { + const result: Record = { _tag: expr.tag }; + for (const entry of expr.entries) { + result[entry.key] = evalExpr(entry.value, scope); + } + return result; + } + + case 'AnyExpression': { + const coll = evalExpr(expr.list, scope); + const items = iterableValues(coll); + return items.some(item => matchPattern(expr.pattern, item, scope) !== null); + } + + case 'AllExpression': { + const coll = evalExpr(expr.list, scope); + const items = iterableValues(coll); + return items.every(item => matchPattern(expr.pattern, item, scope) !== null); + } + + case 'CollectExpression': { + const coll = evalExpr(expr.list, scope); + + // Binding form: collect row in list { condition => body, ... } + if (expr.binding && expr.arms) { + const items = iterableValues(coll); + const result: Value[] = []; + const wildcardArm = expr.arms.find(a => a.pattern.kind === 'WildcardPattern'); + for (const item of items) { + const elemScope = childScope(scope); + elemScope.vars.set(expr.binding, item); + let matched = false; + for (const arm of expr.arms) { + if (arm.pattern.kind === 'WildcardPattern') continue; + const bindings = matchPattern(arm.pattern, null, elemScope); + if (bindings !== null) { + for (const [k, v] of Object.entries(bindings)) { + elemScope.vars.set(k, v); + } + result.push(evalExpr(arm.body, elemScope)); + matched = true; + break; + } + } + // Wildcard fallback: include element with default value + if (!matched && wildcardArm) { + result.push(evalExpr(wildcardArm.body, elemScope)); + } + } + return result; + } + + // Standard form: collect pattern in list => body + const isDict = coll && typeof coll === 'object' && !Array.isArray(coll); + + if (isDict) { + // Collect from dict → dict, preserving keys + const result: Record = {}; + for (const [key, item] of Object.entries(coll as Record)) { + const bindings = matchPattern(expr.pattern!, item, scope); + if (bindings !== null) { + const itemScope = childScope(scope); + for (const [k, v] of Object.entries(bindings)) { + itemScope.vars.set(k, v); + } + result[key] = evalExpr(expr.body!, itemScope); + } + } + return result; + } + + // Collect from list → list + const items = iterableValues(coll); + const result: Value[] = []; + for (const item of items) { + const bindings = matchPattern(expr.pattern!, item, scope); + if (bindings !== null) { + const itemScope = childScope(scope); + for (const [k, v] of Object.entries(bindings)) { + itemScope.vars.set(k, v); + } + result.push(evalExpr(expr.body!, itemScope)); + } + } + return result; + } + + case 'AggregateCollectExpression': { + const coll = evalExpr(expr.list, scope); + const items = iterableValues(coll); + const collected: Value[] = []; + + if (expr.binding) { + // Binding form: agg collect row in list { condition => body, ... } + const wildcardArm = expr.arms.find(a => a.pattern.kind === 'WildcardPattern'); + for (const item of items) { + const elemScope = childScope(scope); + elemScope.vars.set(expr.binding, item); + let matched = false; + for (const arm of expr.arms) { + if (arm.pattern.kind === 'WildcardPattern') continue; + const bindings = matchPattern(arm.pattern, null, elemScope); + if (bindings !== null) { + for (const [k, v] of Object.entries(bindings)) { + elemScope.vars.set(k, v); + } + collected.push(evalExpr(arm.body, elemScope)); + matched = true; + break; + } + } + if (!matched && wildcardArm) { + collected.push(evalExpr(wildcardArm.body, elemScope)); + } + } + } else { + // Standard form: agg collect in list { pattern => body, ... } + for (const item of items) { + for (const arm of expr.arms) { + const bindings = matchPattern(arm.pattern, item, scope); + if (bindings !== null) { + const armScope = childScope(scope); + for (const [k, v] of Object.entries(bindings)) { + armScope.vars.set(k, v); + } + collected.push(evalExpr(arm.body, armScope)); + break; + } + } + } + } + + // Apply aggregator + const agg = INTRINSICS[expr.aggregator]; + if (agg) return agg(collected); + throw new Error(`Unknown aggregator '${expr.aggregator}'`); + } + + case 'ParenExpression': + return evalExpr(expr.expression, scope); + + case 'WhereExpression': { + const whereScope = childScope(scope); + for (const binding of expr.bindings) { + whereScope.vars.set(binding.name, evalExpr(binding.value, whereScope)); + } + return evalExpr(expr.body, whereScope); + } + } + } + + /** Extract iterable values from a list (array) or dict (object values). */ + function iterableValues(val: Value): Value[] { + if (Array.isArray(val)) return val; + if (val && typeof val === 'object') return Object.values(val); + return []; + } + + function matchPattern( + pattern: Pattern, + value: Value, + scope: Scope, + ): Record | null { + switch (pattern.kind) { + case 'WildcardPattern': + return {}; + + case 'LiteralPattern': + return value === pattern.value ? {} : null; + + case 'VariantPattern': { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const obj = value as Record; + if (obj._tag !== pattern.tag) return null; + const bindings: Record = {}; + for (const [field, alias] of Object.entries(pattern.bindings)) { + if (!(field in obj)) return null; + if (alias !== null) { + bindings[alias] = obj[field]; + } + } + return bindings; + } + + case 'ExpressionPattern': { + // Evaluate the expression pattern as a condition + // For subject-less match, the expression IS the condition + if (value === null) { + const condValue = evalExpr(pattern.expression, scope); + return condValue ? {} : null; + } + const patternValue = evalExpr(pattern.expression, scope); + return value === patternValue ? {} : null; + } + + case 'RangePattern': { + if (typeof value !== 'number') return null; + const leftOk = pattern.left === undefined || (pattern.openLeft ? value > pattern.left : value >= pattern.left); + const rightOk = pattern.right === undefined || (pattern.openRight ? value < pattern.right : value <= pattern.right); + return leftOk && rightOk ? {} : null; + } + + case 'AlternativePattern': { + for (const alt of pattern.patterns) { + const result = matchPattern(alt, value, scope); + if (result !== null) return result; + } + return null; + } + + case 'TuplePattern': { + if (!Array.isArray(value)) return null; + if (value.length !== pattern.elements.length) return null; + const bindings: Record = {}; + for (let i = 0; i < pattern.elements.length; i++) { + const result = matchPattern(pattern.elements[i], value[i], scope); + if (result === null) return null; + Object.assign(bindings, result); + } + return bindings; + } + } + + return null; + } + } catch (e) { + return { value: null, error: e instanceof Error ? e.message : String(e) }; + } +} diff --git a/playground/src/lang/lexer.ts b/playground/src/lang/lexer.ts new file mode 100644 index 0000000..00365ae --- /dev/null +++ b/playground/src/lang/lexer.ts @@ -0,0 +1,271 @@ +import { Diagnostic, Location, error } from './diagnostics'; + +export enum TokenType { + // Literals + Number = 'Number', + String = 'String', + Bool = 'Bool', + + // Identifiers & keywords + Identifier = 'Identifier', + Type = 'Type', + Namespace = 'Namespace', + If = 'If', + Then = 'Then', + Else = 'Else', + Match = 'Match', + Not = 'Not', + In = 'In', + As = 'As', + Any = 'Any', + All = 'All', + Collect = 'Collect', + Where = 'Where', + Source = 'Source', + Table = 'Table', + True = 'True', + False = 'False', + + // Operators + Plus = 'Plus', + Minus = 'Minus', + Star = 'Star', + Slash = 'Slash', + Percent = 'Percent', + StarStar = 'StarStar', + Eq = 'Eq', + NotEq = 'NotEq', + Lt = 'Lt', + Gt = 'Gt', + LtEq = 'LtEq', + GtEq = 'GtEq', + And = 'And', + Or = 'Or', + Bang = 'Bang', + Arrow = 'Arrow', // => + Assign = 'Assign', // = + Pipe = 'Pipe', // | + + // Punctuation + LParen = 'LParen', + RParen = 'RParen', + LBracket = 'LBracket', + RBracket = 'RBracket', + LBrace = 'LBrace', + RBrace = 'RBrace', + Comma = 'Comma', + Colon = 'Colon', + Dot = 'Dot', + DotDot = 'DotDot', // .. + Spread = 'Spread', // ... + Underscore = 'Underscore', + + // Plugin + PluginLiteral = 'PluginLiteral', + + // Special + EOF = 'EOF', +} + +export interface Token { + type: TokenType; + value: string; + tag?: string; // For PluginLiteral: plugin-defined tag (e.g., 'money') + payload?: unknown; // For PluginLiteral: structured data for AST/evaluator + location: Location; +} + +const KEYWORDS: Record = { + type: TokenType.Type, + namespace: TokenType.Namespace, + if: TokenType.If, + then: TokenType.Then, + else: TokenType.Else, + match: TokenType.Match, + not: TokenType.Not, + in: TokenType.In, + as: TokenType.As, + any: TokenType.Any, + all: TokenType.All, + collect: TokenType.Collect, + where: TokenType.Where, + source: TokenType.Source, + table: TokenType.Table, + true: TokenType.True, + false: TokenType.False, +}; + +export function tokenize(source: string, plugins?: import('./plugin').AxiomPlugin[]): { tokens: Token[]; diagnostics: Diagnostic[] } { + const tokens: Token[] = []; + const diagnostics: Diagnostic[] = []; + let pos = 0; + let line = 1; + let col = 1; + + function loc(start: number, length: number): Location { + // Compute line/col for start position + let l = 1, c = 1; + for (let i = 0; i < start; i++) { + if (source[i] === '\n') { l++; c = 1; } else { c++; } + } + return { line: l, column: c, offset: start, length }; + } + + function peek(offset = 0): string { + return source[pos + offset] ?? '\0'; + } + + function advance(): string { + const ch = source[pos++]; + if (ch === '\n') { line++; col = 1; } else { col++; } + return ch; + } + + function match(expected: string): boolean { + if (source[pos] === expected) { advance(); return true; } + return false; + } + + while (pos < source.length) { + // Skip whitespace + if (/\s/.test(peek())) { advance(); continue; } + + // Skip comments + if (peek() === '/' && peek(1) === '/') { + while (pos < source.length && peek() !== '\n') advance(); + continue; + } + + const start = pos; + + // Try plugin tokenizers first + if (plugins) { + let handled = false; + for (const plugin of plugins) { + const result = plugin.lexer?.tryTokenize(source, pos); + if (result) { + tokens.push({ + type: TokenType.PluginLiteral, + value: result.value, + tag: result.tag, + payload: result.payload, + location: loc(start, result.length), + }); + for (let i = 0; i < result.length; i++) advance(); + handled = true; + break; + } + } + if (handled) continue; + } + + // Numbers + if (/[0-9]/.test(peek())) { + while (/[0-9]/.test(peek())) advance(); + if (peek() === '.' && peek(1) !== '.') { + advance(); + while (/[0-9]/.test(peek())) advance(); + } + tokens.push({ type: TokenType.Number, value: source.slice(start, pos), location: loc(start, pos - start) }); + continue; + } + + // Strings + if (peek() === '"') { + advance(); + let value = ''; + while (pos < source.length && peek() !== '"') { + if (peek() === '\\') { advance(); value += advance(); } + else { value += advance(); } + } + if (peek() === '"') advance(); + else diagnostics.push(error('parse.unterminated_string', 'Unterminated string', loc(start, pos - start))); + tokens.push({ type: TokenType.String, value, location: loc(start, pos - start) }); + continue; + } + + // Identifiers / keywords / underscore + if (/[a-zA-Z_]/.test(peek())) { + while (/[a-zA-Z0-9_]/.test(peek())) advance(); + const word = source.slice(start, pos); + + if (word === '_') { + tokens.push({ type: TokenType.Underscore, value: word, location: loc(start, pos - start) }); + } else if (word === 'true' || word === 'false') { + tokens.push({ type: TokenType.Bool, value: word, location: loc(start, pos - start) }); + } else if (KEYWORDS[word]) { + tokens.push({ type: KEYWORDS[word], value: word, location: loc(start, pos - start) }); + } else { + tokens.push({ type: TokenType.Identifier, value: word, location: loc(start, pos - start) }); + } + continue; + } + + // Multi-char operators + const ch = peek(); + switch (ch) { + case '=': + advance(); + if (match('=')) { tokens.push({ type: TokenType.Eq, value: '==', location: loc(start, 2) }); } + else if (match('>')) { tokens.push({ type: TokenType.Arrow, value: '=>', location: loc(start, 2) }); } + else { tokens.push({ type: TokenType.Assign, value: '=', location: loc(start, 1) }); } + continue; + case '!': + advance(); + if (match('=')) { tokens.push({ type: TokenType.NotEq, value: '!=', location: loc(start, 2) }); } + else { tokens.push({ type: TokenType.Bang, value: '!', location: loc(start, 1) }); } + continue; + case '<': + advance(); + if (match('=')) { tokens.push({ type: TokenType.LtEq, value: '<=', location: loc(start, 2) }); } + else { tokens.push({ type: TokenType.Lt, value: '<', location: loc(start, 1) }); } + continue; + case '>': + advance(); + if (match('=')) { tokens.push({ type: TokenType.GtEq, value: '>=', location: loc(start, 2) }); } + else { tokens.push({ type: TokenType.Gt, value: '>', location: loc(start, 1) }); } + continue; + case '&': + advance(); + if (match('&')) { tokens.push({ type: TokenType.And, value: '&&', location: loc(start, 2) }); } + else { diagnostics.push(error('parse.unexpected_char', `Unexpected character '&'`, loc(start, 1))); } + continue; + case '|': + advance(); + if (match('|')) { tokens.push({ type: TokenType.Or, value: '||', location: loc(start, 2) }); } + else { tokens.push({ type: TokenType.Pipe, value: '|', location: loc(start, 1) }); } + continue; + case '*': + advance(); + if (match('*')) { tokens.push({ type: TokenType.StarStar, value: '**', location: loc(start, 2) }); } + else { tokens.push({ type: TokenType.Star, value: '*', location: loc(start, 1) }); } + continue; + case '.': + advance(); + if (match('.')) { + if (match('.')) { tokens.push({ type: TokenType.Spread, value: '...', location: loc(start, 3) }); } + else { tokens.push({ type: TokenType.DotDot, value: '..', location: loc(start, 2) }); } + } + else { tokens.push({ type: TokenType.Dot, value: '.', location: loc(start, 1) }); } + continue; + case '+': advance(); tokens.push({ type: TokenType.Plus, value: '+', location: loc(start, 1) }); continue; + case '-': advance(); tokens.push({ type: TokenType.Minus, value: '-', location: loc(start, 1) }); continue; + case '/': advance(); tokens.push({ type: TokenType.Slash, value: '/', location: loc(start, 1) }); continue; + case '%': advance(); tokens.push({ type: TokenType.Percent, value: '%', location: loc(start, 1) }); continue; + case '(': advance(); tokens.push({ type: TokenType.LParen, value: '(', location: loc(start, 1) }); continue; + case ')': advance(); tokens.push({ type: TokenType.RParen, value: ')', location: loc(start, 1) }); continue; + case '[': advance(); tokens.push({ type: TokenType.LBracket, value: '[', location: loc(start, 1) }); continue; + case ']': advance(); tokens.push({ type: TokenType.RBracket, value: ']', location: loc(start, 1) }); continue; + case '{': advance(); tokens.push({ type: TokenType.LBrace, value: '{', location: loc(start, 1) }); continue; + case '}': advance(); tokens.push({ type: TokenType.RBrace, value: '}', location: loc(start, 1) }); continue; + case ',': advance(); tokens.push({ type: TokenType.Comma, value: ',', location: loc(start, 1) }); continue; + case ':': advance(); tokens.push({ type: TokenType.Colon, value: ':', location: loc(start, 1) }); continue; + default: + diagnostics.push(error('parse.unexpected_char', `Unexpected character '${ch}'`, loc(start, 1))); + advance(); + } + } + + tokens.push({ type: TokenType.EOF, value: '', location: loc(pos, 0) }); + return { tokens, diagnostics }; +} diff --git a/playground/src/lang/parser.ts b/playground/src/lang/parser.ts new file mode 100644 index 0000000..aecc2e1 --- /dev/null +++ b/playground/src/lang/parser.ts @@ -0,0 +1,1009 @@ +import { Token, TokenType } from './lexer'; +import { Diagnostic, Location, error } from './diagnostics'; +import { + ProgramNode, Declaration, TypeDeclaration, ExpressionDeclaration, + NamespaceDeclaration, SourceDeclaration, TableDeclaration, SymbolDeclaration, + VariantAlternative, Parameter, TypeAnnotation, Expr, MatchArm, Pattern, MatchExpr, +} from './ast'; + +export function parse(tokens: Token[]): { ast: ProgramNode; diagnostics: Diagnostic[] } { + const diagnostics: Diagnostic[] = []; + let pos = 0; + + function current(): Token { return tokens[pos] ?? tokens[tokens.length - 1]; } + function peek(offset = 0): Token { return tokens[pos + offset] ?? tokens[tokens.length - 1]; } + function at(type: TokenType): boolean { return current().type === type; } + function atValue(value: string): boolean { return current().value === value; } + + function advance(): Token { + const tok = current(); + if (pos < tokens.length - 1) pos++; + return tok; + } + + function expect(type: TokenType, msg?: string): Token { + if (at(type)) return advance(); + const tok = current(); + diagnostics.push(error('parse.expected', msg ?? `Expected ${type}, got '${tok.value}'`, tok.location)); + return tok; + } + + function expectValue(value: string, msg?: string): Token { + if (current().value === value) return advance(); + const tok = current(); + diagnostics.push(error('parse.expected', msg ?? `Expected '${value}', got '${tok.value}'`, tok.location)); + return tok; + } + + function loc(start: Token, end?: Token): Location { + const e = end ?? tokens[pos - 1] ?? start; + return { + line: start.location.line, + column: start.location.column, + offset: start.location.offset, + length: (e.location.offset + e.location.length) - start.location.offset, + }; + } + + // Peek ahead to check if a '{' starts a variant/dict (has key:value pairs) + // versus something else (match body, etc.) + function looksLikeVariantOrDict(): boolean { + const saved = pos; + pos++; // skip '{' + const result = at(TokenType.RBrace) || (at(TokenType.Identifier) && ( + peek(1).type === TokenType.Colon || // key: value + peek(1).type === TokenType.Comma || // shorthand: key, + peek(1).type === TokenType.RBrace // shorthand: key } + )); + pos = saved; + return result; + } + + // --- Top Level --- + + /** Break out of a loop if no progress was made since last check. */ + function guardProgress(lastPos: number): boolean { + if (pos === lastPos) { advance(); return false; } + return true; + } + + function parseProgram(): ProgramNode { + const body: Declaration[] = []; + while (!at(TokenType.EOF)) { + const before = pos; + try { + body.push(parseDeclaration()); + } catch { + // Recovery: skip to next declaration boundary + while (!at(TokenType.EOF) && !at(TokenType.Type) && !at(TokenType.Namespace) && !at(TokenType.Source) && !at(TokenType.Table) && !isExprDeclStart()) { + advance(); + } + } + if (!guardProgress(before)) continue; + } + return { kind: 'Program', body }; + } + + function isExprDeclStart(): boolean { + // An expression declaration starts with IDENT ( + return at(TokenType.Identifier) && peek(1).type === TokenType.LParen; + } + + function parseDeclaration(): Declaration { + if (at(TokenType.Type)) return parseTypeDeclaration(); + if (at(TokenType.Namespace)) return parseNamespaceDeclaration(); + if (at(TokenType.Source)) return parseSourceDeclaration(); + if (at(TokenType.Table)) return parseTableDeclaration(); + return parseExpressionDeclaration(); + } + + function parseTypeDeclaration(): TypeDeclaration { + const start = advance(); // 'type' + const name = expect(TokenType.Identifier).value; + expect(TokenType.Assign); + // Record type: type Name = { field: Type, ... } + if (at(TokenType.LBrace)) { + const shape = parseShapeFields(); + return { kind: 'TypeDeclaration', name, alternatives: [], shape, location: loc(start) }; + } + // Variant type: type Name = tag { ... } | tag { ... } + const alternatives: VariantAlternative[] = []; + const first = tryParseVariantAlternative(); + if (first) alternatives.push(first); + while (at(TokenType.Pipe)) { + advance(); + const alt = tryParseVariantAlternative(); + if (alt) alternatives.push(alt); + else break; // incomplete alternative (e.g. typing `| ` with no tag yet) + } + return { kind: 'TypeDeclaration', name, alternatives, location: loc(start) }; + } + + function tryParseVariantAlternative(): VariantAlternative | null { + if (!at(TokenType.Identifier)) { + // No tag yet — incomplete variant alternative, bail gracefully + if (!at(TokenType.EOF) && !at(TokenType.Pipe)) { + diagnostics.push(error('parse.expected', `Expected variant tag name, got '${current().value}'`, current().location)); + } + return null; + } + const tag = advance().value; + // Payload-less variant: tag without {} (next is | or newline/EOF/different statement) + if (!at(TokenType.LBrace)) { + return { tag, shape: {} }; + } + const shape = parseShapeFields(); + return { tag, shape }; + } + + function parseShapeFields(): Record { + expect(TokenType.LBrace); + const shape: Record = {}; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + if (!at(TokenType.Identifier)) { + // Unexpected token inside shape — skip it to avoid infinite loop + diagnostics.push(error('parse.expected', `Expected field name, got '${current().value}'`, current().location)); + advance(); + continue; + } + const fname = advance().value; + expect(TokenType.Colon); + shape[fname] = parseTypeAnnotation(); + if (!at(TokenType.RBrace)) expect(TokenType.Comma); + } + expect(TokenType.RBrace); + return shape; + } + + function parseTypeAnnotation(): TypeAnnotation { + // Handle qualified names: foo.bar.Baz + let keyword = expect(TokenType.Identifier).value; + while (at(TokenType.Dot) && peek(1).type === TokenType.Identifier) { + advance(); // '.' + keyword += '.' + advance().value; + } + const args: Expr[] = []; + + if (at(TokenType.LParen)) { + advance(); + while (!at(TokenType.RParen) && !at(TokenType.EOF)) { + const before = pos; + args.push(parseExpression()); + if (!at(TokenType.RParen)) expect(TokenType.Comma); + if (!guardProgress(before)) break; + } + expect(TokenType.RParen); + } + + return { keyword, args }; + } + + function parseNamespaceDeclaration(): NamespaceDeclaration { + const start = advance(); // 'namespace' + const name = expect(TokenType.Identifier).value; + expect(TokenType.LBrace); + const symbols: SymbolDeclaration[] = []; + const types: TypeDeclaration[] = []; + const expressions: ExpressionDeclaration[] = []; + const sources: SourceDeclaration[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + // Type declaration inside namespace + if (at(TokenType.Type)) { + types.push(parseTypeDeclaration()); + if (!guardProgress(before)) break; + continue; + } + // Source declaration inside namespace + if (at(TokenType.Source)) { + sources.push(parseSourceDeclaration()); + if (!guardProgress(before)) break; + continue; + } + // Expression declaration: Name(...) + if (isExprDeclStart()) { + expressions.push(parseExpressionDeclaration()); + if (!guardProgress(before)) break; + continue; + } + // Symbol declaration: Name: Type = Expression + const sname = expect(TokenType.Identifier).value; + expect(TokenType.Colon); + const stype = parseTypeAnnotation(); + expect(TokenType.Assign); + const value = parseExpression(); + symbols.push({ name: sname, type: stype, value }); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + return { kind: 'NamespaceDeclaration', name, symbols, types, expressions, sources, location: loc(start) }; + } + + function parseExpressionDeclaration(): ExpressionDeclaration { + const start = current(); + const name = expect(TokenType.Identifier).value; + const params: Parameter[] = []; + + // Parameters are optional — parse only if ( follows + if (at(TokenType.LParen)) { + advance(); + while (!at(TokenType.RParen) && !at(TokenType.EOF)) { + const before = pos; + const pname = expect(TokenType.Identifier).value; + expect(TokenType.Colon); + let ptype: TypeAnnotation; + if (at(TokenType.LBrace)) { + // Inline shape parameter + ptype = { keyword: 'dict', args: [], shape: parseShapeFields() }; + } else { + ptype = parseTypeAnnotation(); + } + params.push({ name: pname, type: ptype }); + if (!at(TokenType.RParen)) expect(TokenType.Comma); + if (!guardProgress(before)) break; + } + expect(TokenType.RParen); + } + + let returnType: TypeAnnotation | undefined; + if (at(TokenType.Colon)) { + advance(); + returnType = parseTypeAnnotation(); + } + + expect(TokenType.LBrace); + const body = parseExpression(); + expect(TokenType.RBrace); + return { kind: 'ExpressionDeclaration', name, params, returnType, body, location: loc(start) }; + } + + function parseSourceDeclaration(): SourceDeclaration { + const start = advance(); // 'source' + const name = expect(TokenType.Identifier).value; + expect(TokenType.LParen); + const params: Parameter[] = []; + while (!at(TokenType.RParen) && !at(TokenType.EOF)) { + const before = pos; + const pname = expect(TokenType.Identifier).value; + expect(TokenType.Colon); + let ptype: TypeAnnotation; + if (at(TokenType.LBrace)) { + ptype = { keyword: 'dict', args: [], shape: parseShapeFields() }; + } else { + ptype = parseTypeAnnotation(); + } + params.push({ name: pname, type: ptype }); + if (!at(TokenType.RParen)) expect(TokenType.Comma); + if (!guardProgress(before)) break; + } + expect(TokenType.RParen); + expect(TokenType.Colon); + let returnType: TypeAnnotation; + if (at(TokenType.LBrace)) { + returnType = { keyword: 'dict', args: [], shape: parseShapeFields() }; + } else { + returnType = parseTypeAnnotation(); + } + return { kind: 'SourceDeclaration', name, params, returnType, location: loc(start) }; + } + + function parseTableDeclaration(): TableDeclaration { + const start = advance(); // 'table' + const name = expect(TokenType.Identifier).value; + expect(TokenType.Colon); + // Expect list({field: type, ...}) or list(TypeName) + const keyword = expect(TokenType.Identifier).value; + let elementType: TypeAnnotation; + if (keyword === 'list' && at(TokenType.LParen)) { + advance(); // '(' + if (at(TokenType.LBrace)) { + // list({field: type, ...}) — inline record shape + const shape = parseShapeFields(); + elementType = { keyword: 'dict', args: [], shape }; + } else { + // list(TypeName) — named element type + elementType = parseTypeAnnotation(); + } + expect(TokenType.RParen); + } else { + // Bare type name (shouldn't normally happen for tables) + elementType = { keyword, args: [] }; + } + return { kind: 'TableDeclaration', name, elementType, location: loc(start) }; + } + + // --- Expressions --- + + function parseExpression(): Expr { + let expr = parseExpressionInner(); + + // where clause: expr where name = expr, name2 = expr2 + if (at(TokenType.Where)) { + const start = advance(); // 'where' + const bindings: { name: string; value: Expr }[] = []; + do { + const bname = expect(TokenType.Identifier).value; + expect(TokenType.Assign); + bindings.push({ name: bname, value: parseExpressionInner() }); + } while (at(TokenType.Comma) && advance()); + expr = { kind: 'WhereExpression', body: expr, bindings, location: loc(start) }; + } + + return expr; + } + + /** Parse an expression without consuming a trailing `where` clause. */ + function parseExpressionInner(): Expr { + if (at(TokenType.If)) return parseIfExpression(); + if (at(TokenType.Match)) return parseMatchExpression(); + return parseInfixExpression(0); + } + + function parseIfExpression(): Expr { + const start = advance(); // 'if' + const condition = parseExpressionInner(); + expectValue('then'); + const thenExpr = parseExpressionInner(); + const elseIfs: { condition: Expr; then: Expr }[] = []; + while (at(TokenType.Else) && peek(1).type === TokenType.If) { + advance(); // 'else' + advance(); // 'if' + const eiCond = parseExpressionInner(); + expectValue('then'); + const eiThen = parseExpressionInner(); + elseIfs.push({ condition: eiCond, then: eiThen }); + } + expect(TokenType.Else); + const elseExpr = parseExpressionInner(); + return { kind: 'IfExpression', condition, then: thenExpr, elseIfs, else: elseExpr, location: loc(start) }; + } + + function parseMatchExpression(): MatchExpr { + const start = advance(); // 'match' + + // match binding in iterable { ... } — iteration form + if (at(TokenType.Identifier) && peek(1).type === TokenType.In) { + const binding = advance().value; // binding name + advance(); // 'in' + const iterable = parseInfixExpression(0); + expect(TokenType.LBrace); + const arms: MatchArm[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + const pattern = parsePattern(); + expect(TokenType.Arrow); + const expression = parseExpression(); + arms.push({ pattern, expression }); + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + return { kind: 'MatchExpression', binding, iterable, arms, location: loc(start) }; + } + + // Standard match: subject or subjectless + let subject: Expr | undefined; + if (!at(TokenType.LBrace)) { + // Tuple subject: match (a, b, c) { ... } + if (at(TokenType.LParen)) { + const tupleStart = current(); + advance(); // '(' + const first = parseExpression(); + if (at(TokenType.Comma)) { + // It's a tuple + const elements: Expr[] = [first]; + while (at(TokenType.Comma)) { + advance(); + elements.push(parseExpression()); + } + expect(TokenType.RParen); + subject = { kind: 'ListLiteral', elements, location: loc(tupleStart) }; + } else { + // Single parenthesized expression + expect(TokenType.RParen); + subject = { kind: 'ParenExpression', expression: first, location: loc(tupleStart) }; + } + } else { + subject = parseInfixExpression(0); + } + } + expect(TokenType.LBrace); + const arms: MatchArm[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + const pattern = parsePattern(); + expect(TokenType.Arrow); + const expression = parseExpression(); + arms.push({ pattern, expression }); + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + return { kind: 'MatchExpression', subject, arms, location: loc(start) }; + } + + // --- Pratt parser for infix expressions --- + + const PRECEDENCE: Record = { + '||': 1, + '&&': 2, + '==': 3, '!=': 3, + '<': 4, '>': 4, '<=': 4, '>=': 4, + 'in': 4, 'not in': 4, + '+': 5, '-': 5, + '*': 6, '/': 6, '%': 6, + '**': 7, + }; + + function getInfixOp(): { op: string; prec: number } | null { + // 'not in' as two-token operator + if (at(TokenType.Not) && peek(1).type === TokenType.In) { + return { op: 'not in', prec: PRECEDENCE['not in'] }; + } + if (at(TokenType.In)) return { op: 'in', prec: PRECEDENCE['in'] }; + + const opMap: Partial> = { + [TokenType.Plus]: '+', [TokenType.Minus]: '-', + [TokenType.Star]: '*', [TokenType.Slash]: '/', + [TokenType.Percent]: '%', [TokenType.StarStar]: '**', + [TokenType.Eq]: '==', [TokenType.NotEq]: '!=', + [TokenType.Lt]: '<', [TokenType.Gt]: '>', + [TokenType.LtEq]: '<=', [TokenType.GtEq]: '>=', + [TokenType.And]: '&&', [TokenType.Or]: '||', + }; + const op = opMap[current().type]; + if (op) return { op, prec: PRECEDENCE[op] }; + return null; + } + + function parseInfixExpression(minPrec: number): Expr { + let left = parseUnaryExpression(); + + while (true) { + const inf = getInfixOp(); + if (!inf || inf.prec < minPrec) break; + + const start = current(); + if (inf.op === 'not in') { advance(); advance(); } + else advance(); + + // Right-associative for ** + const nextPrec = inf.op === '**' ? inf.prec : inf.prec + 1; + const right = parseInfixExpression(nextPrec); + left = { kind: 'InfixExpression', left, operator: inf.op, right, location: loc(start) }; + } + + return left; + } + + function parseUnaryExpression(): Expr { + if (at(TokenType.Not) && peek(1).type !== TokenType.In) { + const start = advance(); + const operand = parseUnaryExpression(); + return { kind: 'UnaryExpression', operator: 'not', operand, location: loc(start) }; + } + if (at(TokenType.Bang)) { + const start = advance(); + const operand = parseUnaryExpression(); + return { kind: 'UnaryExpression', operator: '!', operand, location: loc(start) }; + } + if (at(TokenType.Minus)) { + const start = advance(); + const operand = parseUnaryExpression(); + return { kind: 'UnaryExpression', operator: '-', operand, location: loc(start) }; + } + return parsePostfixExpression(); + } + + function parsePostfixExpression(): Expr { + let expr = parsePrimary(); + + while (true) { + if (at(TokenType.Dot)) { + advance(); + const prop = expect(TokenType.Identifier).value; + expr = { kind: 'MemberExpression', object: expr, property: prop, location: expr.location }; + } else if (at(TokenType.LBracket)) { + advance(); + const index = parseExpression(); + expect(TokenType.RBracket); + expr = { kind: 'IndexExpression', object: expr, index, location: expr.location }; + } else if (at(TokenType.As)) { + advance(); + const targetType = parseTypeAnnotation(); + expr = { kind: 'CoercionExpression', expression: expr, targetType, location: expr.location }; + } else { + break; + } + } + + return expr; + } + + function parsePrimary(): Expr { + const start = current(); + + // any PATTERN in EXPR + if (at(TokenType.Any)) { + advance(); + const pattern = parsePattern(true); + expectValue('in'); + const list = parseInfixExpression(0); + return { kind: 'AnyExpression', pattern, list, location: loc(start) }; + } + + // all PATTERN in EXPR + if (at(TokenType.All)) { + advance(); + const pattern = parsePattern(true); + expectValue('in'); + const list = parseInfixExpression(0); + return { kind: 'AllExpression', pattern, list, location: loc(start) }; + } + + // collect PATTERN in EXPR => BODY (pattern form — destructure/filter) + // collect BINDING in EXPR => BODY (map form — bind each element, transform all) + // collect BINDING in EXPR { ARMS } (binding form — filter + transform with arms) + if (at(TokenType.Collect)) { + advance(); + + // Detect binding/map form: collect identifier in expr ... + if (at(TokenType.Identifier) && peek(1).type === TokenType.In) { + const identTok = advance(); + advance(); // 'in' + const list = parseInfixExpression(0); + if (at(TokenType.LBrace)) { + // Binding form with arms: collect row in list { condition => body, ... } + expect(TokenType.LBrace); + const arms: { pattern: Pattern; body: Expr }[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + const armPattern = parsePattern(); + expect(TokenType.Arrow); + const armBody = parseExpression(); + arms.push({ pattern: armPattern, body: armBody }); + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + return { kind: 'CollectExpression', list, binding: identTok.value, arms, location: loc(start) }; + } + // Map form: collect ident in list => body (bind each element, transform all) + expect(TokenType.Arrow); + const body = parseExpression(); + const wildcardArm: { pattern: Pattern; body: Expr } = { + pattern: { kind: 'WildcardPattern', location: identTok.location }, + body, + }; + return { kind: 'CollectExpression', list, binding: identTok.value, arms: [wildcardArm], location: loc(start) }; + } + + const pattern = parsePattern(true); + expectValue('in'); + const list = parseInfixExpression(0); + expect(TokenType.Arrow); + const body = parseExpression(); + return { kind: 'CollectExpression', pattern, list, body, location: loc(start) }; + } + + // Nested if/match + if (at(TokenType.If)) return parseIfExpression(); + if (at(TokenType.Match)) return parseMatchExpression(); + + // Plugin literal (e.g., money: £100, GBP50.25) + if (at(TokenType.PluginLiteral)) { + const tok = advance(); + return { kind: 'PluginLiteral', tag: tok.tag!, value: tok.payload, displayValue: tok.value, location: tok.location }; + } + + // Number literal + if (at(TokenType.Number)) { + const tok = advance(); + return { kind: 'Literal', value: parseFloat(tok.value), raw: tok.value, location: tok.location }; + } + + // String literal + if (at(TokenType.String)) { + const tok = advance(); + return { kind: 'Literal', value: tok.value, raw: `"${tok.value}"`, location: tok.location }; + } + + // Bool literal + if (at(TokenType.Bool)) { + const tok = advance(); + return { kind: 'Literal', value: tok.value === 'true', raw: tok.value, location: tok.location }; + } + + // List literal + if (at(TokenType.LBracket)) { + advance(); + const elements: Expr[] = []; + while (!at(TokenType.RBracket) && !at(TokenType.EOF)) { + const before = pos; + elements.push(parseExpression()); + if (!at(TokenType.RBracket)) expect(TokenType.Comma); + if (!guardProgress(before)) break; + } + expect(TokenType.RBracket); + return { kind: 'ListLiteral', elements, location: loc(start) }; + } + + // Parenthesized expression or range pattern used as expression + if (at(TokenType.LParen)) { + advance(); + const inner = parseExpression(); + expect(TokenType.RParen); + return { kind: 'ParenExpression', expression: inner, location: loc(start) }; + } + + // Identifier, call, variant construction, aggregate collect, or dict + if (at(TokenType.Identifier)) { + // Look ahead for aggregate collect: IDENT collect ... + if (peek(1).type === TokenType.Collect) { + const aggName = advance().value; + advance(); // 'collect' + + // Detect binding form: agg collect identifier in expr { arms } + let binding: string | undefined; + if (at(TokenType.Identifier) && peek(1).type === TokenType.In) { + binding = advance().value; + } + + expectValue('in'); + const list = parseInfixExpression(0); + // Map form: agg collect ident in list => body + if (binding && at(TokenType.Arrow)) { + advance(); // '=>' + const mapBody = parseExpression(); + const wildcardArm: { pattern: Pattern; body: Expr } = { + pattern: { kind: 'WildcardPattern', location: loc(start) }, + body: mapBody, + }; + return { kind: 'AggregateCollectExpression', aggregator: aggName, list, arms: [wildcardArm], binding, location: loc(start) }; + } + // Arms form: agg collect ... in list { arms } + expect(TokenType.LBrace); + const arms: { pattern: Pattern; body: Expr }[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + const pattern = parsePattern(); + expect(TokenType.Arrow); + const body = parseExpression(); + arms.push({ pattern, body }); + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + return { kind: 'AggregateCollectExpression', aggregator: aggName, list, arms, binding, location: loc(start) }; + } + + // Resolve qualified name: a.b.c + // Only consume dots when this is clearly a qualified name for a call/variant/type, + // NOT member access like quote.field + let name = advance().value; + while (at(TokenType.Dot) && peek(1).type === TokenType.Identifier) { + const savedPos = pos; + advance(); // '.' + const next = advance().value; + const tentativeName = name + '.' + next; + + if (at(TokenType.Dot) && peek(1).type === TokenType.Identifier) { + // More dots coming — keep going, might be a.b.c.Tag + name = tentativeName; + continue; + } + if (at(TokenType.LParen)) { + // Qualified call: Name.Sub(...) + name = tentativeName; + break; + } + if (at(TokenType.LBrace)) { + // Could be variant construction: tag { ... } + // or member access followed by something else: expr.field { match body } + // Peek inside braces: variant/dict has IDENT ':' or is empty '{}' + if (looksLikeVariantOrDict()) { + name = tentativeName; + break; + } + // Not a variant — this is member access, roll back + pos = savedPos; + break; + } + // No call/variant follows — it's member access, roll back + pos = savedPos; + break; + } + + // Call expression: Name(...) + if (at(TokenType.LParen)) { + advance(); + const args: Expr[] = []; + const namedArgs: Record = {}; + const allArgs: Expr[] = []; + let spread = false; + // Look ahead for spread or named args — enables shorthand named args + let hasNamedOrSpread = false; + for (let j = pos; j < tokens.length; j++) { + if (tokens[j].type === TokenType.RParen || tokens[j].type === TokenType.EOF) break; + if (tokens[j].type === TokenType.Spread) { hasNamedOrSpread = true; break; } + if (tokens[j].type === TokenType.Identifier && j + 1 < tokens.length && tokens[j + 1].type === TokenType.Colon) { hasNamedOrSpread = true; break; } + } + while (!at(TokenType.RParen) && !at(TokenType.EOF)) { + const before = pos; + // Spread: ... fills remaining params from scope + if (at(TokenType.Spread)) { + advance(); + spread = true; + } + // Check for named arg: IDENT ':' + else if (at(TokenType.Identifier) && peek(1).type === TokenType.Colon) { + const argName = advance().value; + advance(); // ':' + const argExpr = parseExpression(); + namedArgs[argName] = argExpr; + allArgs.push(argExpr); + } + // Shorthand named arg: bare IDENT followed by , or ) — only with named args or spread + else if (hasNamedOrSpread && at(TokenType.Identifier) && (peek(1).type === TokenType.Comma || peek(1).type === TokenType.RParen)) { + const tok = advance(); + const argExpr: Expr = { kind: 'Identifier', name: tok.value, location: tok.location }; + namedArgs[tok.value] = argExpr; + allArgs.push(argExpr); + } + else { + const argExpr = parseExpression(); + args.push(argExpr); + allArgs.push(argExpr); + } + if (!at(TokenType.RParen)) expect(TokenType.Comma); + if (!guardProgress(before)) break; + } + expect(TokenType.RParen); + const node: any = { kind: 'CallExpression', callee: name, args, namedArgs, allArgs, location: loc(start) }; + if (spread) node.spread = true; + return node; + } + + // Variant construction or dict: Name { ... } + if (at(TokenType.LBrace) && looksLikeVariantOrDict()) { + advance(); // '{' + + // Empty braces = variant with empty payload + if (at(TokenType.RBrace)) { + advance(); + const typeParts = name.split('.'); + const tag = typeParts.pop()!; + const typeName = typeParts.length > 0 ? typeParts.join('.') : undefined; + return { kind: 'VariantConstruction', typeName, tag, entries: [], location: loc(start) }; + } + + // It's key:value — variant or dict + { + const entries: { key: string; value: Expr }[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + const key = expect(TokenType.Identifier).value; + // Shorthand: bare identifier without ':' means key: key + if (at(TokenType.Comma) || at(TokenType.RBrace)) { + entries.push({ key, value: { kind: 'Identifier', name: key, location: tokens[pos - 1].location } }); + } else { + expect(TokenType.Colon); + const value = parseExpression(); + entries.push({ key, value }); + } + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + + const typeParts = name.split('.'); + const tag = typeParts.pop()!; + const typeName = typeParts.length > 0 ? typeParts.join('.') : undefined; + return { kind: 'VariantConstruction', typeName, tag, entries, location: loc(start) }; + } + } + + // Plain identifier + return { kind: 'Identifier', name, location: start.location }; + } + + // Dict literal without a preceding identifier: { key: value, ... } + if (at(TokenType.LBrace)) { + advance(); + const entries: { key: string; value: Expr }[] = []; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + let key: string; + if (at(TokenType.String)) { + key = advance().value; + } else { + key = expect(TokenType.Identifier).value; + } + // Shorthand: bare identifier without ':' means key: key + if (!key.startsWith('"') && (at(TokenType.Comma) || at(TokenType.RBrace))) { + entries.push({ key, value: { kind: 'Identifier', name: key, location: tokens[pos - 1].location } }); + } else { + expect(TokenType.Colon); + const value = parseExpression(); + entries.push({ key, value }); + } + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + return { kind: 'DictLiteral', entries, location: loc(start) }; + } + + diagnostics.push(error('parse.unexpected_token', `Unexpected token '${current().value}'`, current().location)); + advance(); + return { kind: 'Literal', value: 0, raw: '0', location: start.location }; + } + + // --- Patterns --- + + function parsePattern(inCollectionForm: boolean = false): Pattern { + const start = current(); + const first = parseSinglePattern(inCollectionForm); + if (!at(TokenType.Pipe)) return first; + const patterns: Pattern[] = [first]; + while (at(TokenType.Pipe)) { + advance(); + patterns.push(parseSinglePattern(inCollectionForm)); + } + return { kind: 'AlternativePattern', patterns, location: loc(start) }; + } + + function parseSinglePattern(inCollectionForm: boolean = false): Pattern { + const start = current(); + + // Wildcard + if (at(TokenType.Underscore)) { + advance(); + return { kind: 'WildcardPattern', location: start.location }; + } + + // Range pattern: (lo..hi] or [lo..hi) etc. + if (at(TokenType.LParen) || at(TokenType.LBracket)) { + const mightBeRange = tryParseRangePattern(); + if (mightBeRange) return mightBeRange; + } + + // Tuple pattern: (pat1, pat2, ...) + if (at(TokenType.LParen)) { + const tupleStart = current(); + advance(); // '(' + const elements: Pattern[] = [parsePattern()]; + while (at(TokenType.Comma)) { + advance(); + elements.push(parsePattern()); + } + expect(TokenType.RParen); + if (elements.length >= 2) { + return { kind: 'TuplePattern', elements, location: loc(tupleStart) }; + } + // Single element — treat as the inner pattern + return elements[0]; + } + + // Variant pattern or literal pattern: IDENT { bindings } + if (at(TokenType.Identifier)) { + // Resolve qualified: a.b.Tag + let name = current().value; + const savedPos = pos; + advance(); + + while (at(TokenType.Dot) && peek(1).type === TokenType.Identifier) { + advance(); + name += '.' + advance().value; + } + + if (at(TokenType.LBrace)) { + // Variant pattern with field bindings + advance(); + const bindings: Record = {}; + while (!at(TokenType.RBrace) && !at(TokenType.EOF)) { + const before = pos; + const fieldName = expect(TokenType.Identifier).value; + if (at(TokenType.Colon)) { + advance(); + if (at(TokenType.Underscore)) { + advance(); + bindings[fieldName] = null; // wildcard binding + } else { + bindings[fieldName] = expect(TokenType.Identifier).value; + } + } else { + bindings[fieldName] = fieldName; // shorthand: name binds to name + } + if (at(TokenType.Comma)) advance(); + if (!guardProgress(before)) break; + } + expect(TokenType.RBrace); + + const parts = name.split('.'); + const tag = parts.pop()!; + const typeName = parts.length > 0 ? parts.join('.') : undefined; + return { kind: 'VariantPattern', typeName, tag, bindings, location: loc(start) }; + } + + // Bare tag pattern: `referred` without braces, in pattern-terminating context + // Only treat `in` as a terminator inside collection forms (any/all/collect), + // not in match arms where `x in [list]` is a valid expression pattern. + const inTerminates = inCollectionForm && at(TokenType.In); + if (inTerminates || at(TokenType.Arrow) || at(TokenType.Comma) || at(TokenType.RBrace)) { + const parts = name.split('.'); + const tag = parts.pop()!; + const typeName = parts.length > 0 ? parts.join('.') : undefined; + return { kind: 'VariantPattern', typeName, tag, bindings: {}, location: loc(start) }; + } + + // Not a variant pattern — might be an expression pattern + pos = savedPos; + } + + // Number literal pattern + if (at(TokenType.Number)) { + const tok = advance(); + return { kind: 'LiteralPattern', value: parseFloat(tok.value), raw: tok.value, location: tok.location }; + } + + // String literal pattern + if (at(TokenType.String)) { + const tok = advance(); + return { kind: 'LiteralPattern', value: tok.value, raw: `"${tok.value}"`, location: tok.location }; + } + + // Bool literal pattern + if (at(TokenType.Bool)) { + const tok = advance(); + return { kind: 'LiteralPattern', value: tok.value === 'true', raw: tok.value, location: tok.location }; + } + + // Expression pattern (fallback) + const expr = parseInfixExpression(0); + return { kind: 'ExpressionPattern', expression: expr, location: expr.location }; + } + + function tryParseRangePattern(): Pattern | null { + const saved = pos; + const start = current(); + const openLeft = at(TokenType.LParen); // ( = exclusive + advance(); // ( or [ + + let left: number | undefined; + let right: number | undefined; + + // Optional left number + if (at(TokenType.Number)) { + left = parseFloat(advance().value); + } + + // Expect .. + if (!at(TokenType.DotDot)) { pos = saved; return null; } + advance(); // .. + + // Optional right number + if (at(TokenType.Number)) { + right = parseFloat(advance().value); + } + + // At least one bound must be present + if (left === undefined && right === undefined) { pos = saved; return null; } + + const openRight = at(TokenType.RParen); // ) = exclusive + if (!at(TokenType.RBracket) && !at(TokenType.RParen)) { pos = saved; return null; } + advance(); // ] or ) + + return { + kind: 'RangePattern', + openLeft, + openRight, + left, + right, + location: loc(start), + }; + } + + const ast = parseProgram(); + return { ast, diagnostics }; +} diff --git a/playground/src/lang/plugin.ts b/playground/src/lang/plugin.ts new file mode 100644 index 0000000..30f7932 --- /dev/null +++ b/playground/src/lang/plugin.ts @@ -0,0 +1,43 @@ +import { TypeSig } from './types'; + +/** Plugin hook for custom token recognition in the lexer. */ +export interface LexerPlugin { + /** Try to recognize a custom token at position `pos` in source. + * Return null if this position isn't handled by this plugin. */ + tryTokenize(source: string, pos: number): { + tag: string; // e.g., 'money' + value: string; // display value, e.g., '£100.50' + payload: unknown; // structured data for AST/evaluator + length: number; // chars consumed from source + } | null; +} + +/** Plugin hook for type checking. */ +export interface CheckerPlugin { + /** Infer the type of a plugin literal. */ + inferLiteralType?(tag: string, payload: unknown): TypeSig | null; + /** Check a binary operator with the given operand types. + * Return the result type, { error: string } for a type error, or null to defer to defaults. */ + checkBinaryOp?(op: string, left: TypeSig, right: TypeSig): TypeSig | { error: string } | null; + /** Check an intrinsic/function call with the given arg types. + * Return the result type, or null to defer to default checking. */ + checkCall?(name: string, argTypes: TypeSig[]): TypeSig | null; +} + +/** Plugin hook for evaluation. */ +export interface EvaluatorPlugin { + /** Return true if this plugin handles the given binary operation. */ + supportsOp?(left: unknown, right: unknown, op: string): boolean; + /** Evaluate a binary operation. Only called if supportsOp returned true. */ + evaluateOp?(left: unknown, right: unknown, op: string): unknown; + /** Plugin-provided intrinsic overrides. Return undefined to fall through to built-in. */ + intrinsics?: Record unknown) | undefined>; +} + +/** An Axiom plugin bundles lexer, checker, and evaluator extensions. */ +export interface AxiomPlugin { + name: string; + lexer?: LexerPlugin; + checker?: CheckerPlugin; + evaluator?: EvaluatorPlugin; +} diff --git a/playground/src/lang/types.ts b/playground/src/lang/types.ts new file mode 100644 index 0000000..f8077f9 --- /dev/null +++ b/playground/src/lang/types.ts @@ -0,0 +1,132 @@ +export interface TypeSig { + name: string; + params: string[]; + shape?: Record; + variants?: Record>; // tag -> shape + isPluginProvided?: boolean; + elementType?: TypeSig; // Full element type for list(T), preserved through nesting +} + +export const TYPE_NUMBER: TypeSig = { name: 'number', params: [] }; +export const TYPE_STRING: TypeSig = { name: 'string', params: [] }; +export const TYPE_BOOL: TypeSig = { name: 'bool', params: [] }; +export const TYPE_MIXED: TypeSig = { name: 'mixed', params: [] }; + +export function typeList(element?: TypeSig): TypeSig { + return { name: 'list', params: element ? [element.name] : [], elementType: element }; +} + +export function typeDict(shape?: Record, valueType?: TypeSig): TypeSig { + return { name: 'dict', params: valueType ? [valueType.name] : [], shape, elementType: valueType }; +} + +export function typeVariant(name: string, variants: Record>): TypeSig { + return { name, params: [], variants }; +} + +export function typeMoney(currency: string): TypeSig { + // For the PoC, money is just a number alias with a label + return { name: 'money', params: [currency] }; +} + +export function isAssignable(source: TypeSig, target: TypeSig): boolean { + if (target.name === 'mixed') return true; + + if (source.name === target.name) { + // List: check element type compatibility + if (source.name === 'list') { + // Untyped list or mixed-element list is assignable to any list + if (source.params.length === 0 || target.params.length === 0) return true; + if (source.params[0] === 'mixed' || target.params[0] === 'mixed') return true; + // Use full elementType for deep comparison when available + if (source.elementType && target.elementType) { + return isAssignable(source.elementType, target.elementType); + } + // Element type names must match (nominal) + return source.params[0] === target.params[0]; + } + // Dict: check value type compatibility + if (source.name === 'dict') { + if (source.params.length === 0 || target.params.length === 0) return true; + if (source.params[0] === 'mixed' || target.params[0] === 'mixed') return true; + if (source.elementType && target.elementType) { + return isAssignable(source.elementType, target.elementType); + } + return source.params[0] === target.params[0]; + } + // Money: check currency + if (source.name === 'money') { + return source.params[0] === target.params[0] || target.params.length === 0; + } + // Same-named variant types are the same type (nominal) + return true; + } + + // number <-> money interop for PoC + if (source.name === 'number' && target.name === 'money') return true; + if (source.name === 'money' && target.name === 'number') return true; + + // A variant value inferred from construction (anonymous/structural) is + // assignable to a named variant if the source is the SAME named type, + // OR if the source was resolved to the target type by the checker. + // For nominal typing: different names = different types, even if + // structurally identical. + if (source.variants && target.variants) { + // Only assignable if they share the same type name + // This is the nominal check. + if (source.name === target.name) return true; + // Allow anonymous/ad-hoc variants (no declared type) to match if tags AND fields match + const sourceIsAnonymous = !source.name || source.name === Object.keys(source.variants)[0]; + if (sourceIsAnonymous) { + for (const tag of Object.keys(source.variants)) { + if (!(tag in target.variants)) return false; + const sourceShape = source.variants[tag]; + const targetShape = target.variants[tag]; + // All target fields must exist in source with compatible types + for (const [field, targetFieldType] of Object.entries(targetShape)) { + if (!(field in sourceShape)) return false; + if (!isAssignable(sourceShape[field], targetFieldType)) return false; + } + // No extra fields in source + for (const field of Object.keys(sourceShape)) { + if (!(field in targetShape)) return false; + } + } + return true; + } + return false; + } + + return false; +} + +export function typeToString(t: TypeSig): string { + if (t.name === 'list' && t.elementType) { + return `list(${typeToString(t.elementType)})`; + } + if (t.name === 'dict' && t.elementType) { + return `dict(${typeToString(t.elementType)})`; + } + if (t.params.length > 0) return `${t.name}(${t.params.join(', ')})`; + if (t.shape) { + const fields = Object.entries(t.shape).map(([k, v]) => `${k}: ${typeToString(v)}`); + return `{ ${fields.join(', ')} }`; + } + if (t.variants) { + // If the type has a meaningful name (not just the first tag), show it + const tags = Object.keys(t.variants); + const isNamed = t.name && t.name !== tags[0]; + if (isNamed) return t.name; + const alts = Object.entries(t.variants).map(([tag, shape]) => { + const fields = Object.entries(shape).map(([k, v]) => `${k}: ${typeToString(v)}`); + return `${tag} { ${fields.join(', ')} }`; + }); + return alts.join(' | '); + } + return t.name; +} + +export function propertyType(t: TypeSig, prop: string): TypeSig | null { + if (t.shape && t.shape[prop]) return t.shape[prop]; + return null; +} diff --git a/playground/src/main.ts b/playground/src/main.ts new file mode 100644 index 0000000..824726a --- /dev/null +++ b/playground/src/main.ts @@ -0,0 +1,269 @@ +import * as monaco from 'monaco-editor'; +import { registerAxiomLanguage } from './editor/language'; +import { registerAxiomTheme } from './editor/theme'; +import { tokenize } from './lang/lexer'; +import { parse } from './lang/parser'; +import { check } from './lang/checker'; +import { evaluate } from './lang/evaluator'; +import { INSURANCE_EXAMPLE, INSURANCE_INPUT } from './examples/insurance.axiom'; +import { HEALTHCARE_EXAMPLE, HEALTHCARE_INPUT } from './examples/healthcare.axiom'; +import { TRADESPEOPLE_EXAMPLE, TRADESPEOPLE_INPUT } from './examples/tradespeople.axiom'; +import { HOSPITALITY_EXAMPLE, HOSPITALITY_INPUT } from './examples/hospitality.axiom'; +import { LANDLORDS_EXAMPLE, LANDLORDS_INPUT } from './examples/landlords.axiom'; +import { MONEY_EXAMPLE, MONEY_INPUT } from './examples/money.axiom'; +import { ProgramNode, ExpressionDeclaration } from './lang/ast'; +import { AxiomPlugin } from './lang/plugin'; +import { moneyPlugin } from './plugins/money'; +import { isMoneyValue, formatMoney } from './plugins/money'; +import { Diagnostic } from './lang/diagnostics'; + +// Monaco worker setup +self.MonacoEnvironment = { + getWorkerUrl(_moduleId: string, label: string) { + if (label === 'json') { + return new URL('monaco-editor/esm/vs/language/json/json.worker.js', import.meta.url).href; + } + if (label === 'css' || label === 'scss' || label === 'less') { + return new URL('monaco-editor/esm/vs/language/css/css.worker.js', import.meta.url).href; + } + if (label === 'html' || label === 'handlebars' || label === 'razor') { + return new URL('monaco-editor/esm/vs/language/html/html.worker.js', import.meta.url).href; + } + if (label === 'typescript' || label === 'javascript') { + return new URL('monaco-editor/esm/vs/language/typescript/ts.worker.js', import.meta.url).href; + } + return new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url).href; + }, +}; + +// Register language and theme +registerAxiomLanguage(); +registerAxiomTheme(); + +// Create editor +const editorContainer = document.getElementById('editor-container')!; +const editor = monaco.editor.create(editorContainer, { + value: INSURANCE_EXAMPLE, + language: 'axiom', + theme: 'axiom-dark', + fontSize: 14, + fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace", + lineNumbers: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + padding: { top: 12 }, + renderLineHighlight: 'line', + bracketPairColorization: { enabled: true }, + autoIndent: 'full', + tabSize: 4, + insertSpaces: true, + wordWrap: 'on', + smoothScrolling: true, + cursorBlinking: 'smooth', + cursorSmoothCaretAnimation: 'on', +}); + +// Elements +const exampleSelect = document.getElementById('example-select') as HTMLSelectElement; +const exprSelect = document.getElementById('expr-select') as HTMLSelectElement; +const inputTextarea = document.getElementById('input-data') as HTMLTextAreaElement; +const outputPre = document.getElementById('output')!; +const diagnosticsPre = document.getElementById('diagnostics')!; + +const EXAMPLES: Record }> = { + insurance: { code: INSURANCE_EXAMPLE, input: INSURANCE_INPUT }, + healthcare: { code: HEALTHCARE_EXAMPLE, input: HEALTHCARE_INPUT }, + tradespeople: { code: TRADESPEOPLE_EXAMPLE, input: TRADESPEOPLE_INPUT }, + hospitality: { code: HOSPITALITY_EXAMPLE, input: HOSPITALITY_INPUT }, + landlords: { code: LANDLORDS_EXAMPLE, input: LANDLORDS_INPUT }, + money: { code: MONEY_EXAMPLE, input: MONEY_INPUT }, +}; + +const PLUGINS: AxiomPlugin[] = [moneyPlugin]; + +function loadExample(name: string) { + const example = EXAMPLES[name]; + if (!example) return; + editor.setValue(example.code); + inputTextarea.value = JSON.stringify(example.input, null, 2); +} + +// Set default input +inputTextarea.value = JSON.stringify(INSURANCE_INPUT, null, 2); + +// Resizer +const resizer = document.getElementById('resizer')!; +const outputPane = document.querySelector('.output-pane') as HTMLElement; +let isResizing = false; + +resizer.addEventListener('mousedown', () => { isResizing = true; }); +document.addEventListener('mousemove', (e) => { + if (!isResizing) return; + const containerWidth = document.querySelector('.main')!.getBoundingClientRect().width; + const newWidth = containerWidth - e.clientX; + outputPane.style.width = Math.max(250, Math.min(newWidth, containerWidth - 300)) + 'px'; + editor.layout(); +}); +document.addEventListener('mouseup', () => { isResizing = false; }); + +// Processing pipeline +let debounceTimer: number | undefined; + +function processCode() { + const source = editor.getValue(); + + // 1. Tokenize + const { tokens, diagnostics: lexDiags } = tokenize(source, PLUGINS); + + // 2. Parse + const { ast, diagnostics: parseDiags } = parse(tokens); + const allDiags = [...lexDiags, ...parseDiags]; + + // 3. Type check + const checkResult = check(ast, PLUGINS); + allDiags.push(...checkResult.diagnostics); + + // 4. Update expression selector + updateExpressionSelector(ast); + + // 5. Show diagnostics in Monaco + showDiagnostics(allDiags); + + // 6. Evaluate + const selectedExpr = exprSelect.value; + if (selectedExpr) { + tryEvaluate(ast, selectedExpr); + } else { + outputPre.textContent = 'Select an expression to evaluate'; + outputPre.style.color = '#6c7086'; + } +} + +function updateExpressionSelector(ast: ProgramNode) { + const previous = exprSelect.value; + const exprs = ast.body + .filter((d): d is ExpressionDeclaration => d.kind === 'ExpressionDeclaration') + .map(d => d.name); + + // Only update if the list changed + const currentOptions = Array.from(exprSelect.options).map(o => o.value); + if (JSON.stringify(exprs) !== JSON.stringify(currentOptions)) { + exprSelect.innerHTML = ''; + for (const name of exprs) { + const option = document.createElement('option'); + option.value = name; + option.textContent = name; + exprSelect.appendChild(option); + } + // Restore previous selection or default to last + if (exprs.includes(previous)) { + exprSelect.value = previous; + } else if (exprs.length > 0) { + exprSelect.value = exprs[exprs.length - 1]; + } + } +} + +function showDiagnostics(diags: Diagnostic[]) { + // Monaco markers + const model = editor.getModel()!; + const markers: monaco.editor.IMarkerData[] = diags + .filter(d => d.location) + .map(d => { + const startPos = model.getPositionAt(d.location!.offset); + const endPos = model.getPositionAt(d.location!.offset + Math.max(d.location!.length, 1)); + return { + severity: d.severity === 'error' + ? monaco.MarkerSeverity.Error + : d.severity === 'warning' + ? monaco.MarkerSeverity.Warning + : monaco.MarkerSeverity.Info, + startLineNumber: startPos.lineNumber, + startColumn: startPos.column, + endLineNumber: endPos.lineNumber, + endColumn: endPos.column, + message: d.message, + code: d.code, + }; + }); + monaco.editor.setModelMarkers(model, 'axiom', markers); + + // Diagnostics panel + const errors = diags.filter(d => d.severity === 'error'); + const warnings = diags.filter(d => d.severity === 'warning'); + + if (errors.length === 0 && warnings.length === 0) { + diagnosticsPre.textContent = 'No issues'; + diagnosticsPre.className = 'clean'; + } else { + const lines: string[] = []; + for (const d of [...errors, ...warnings]) { + const loc = d.location ? `[${d.location.line}:${d.location.column}]` : ''; + const prefix = d.severity === 'error' ? 'ERROR' : 'WARN'; + lines.push(`${prefix} ${loc} ${d.message}`); + } + diagnosticsPre.textContent = lines.join('\n'); + diagnosticsPre.className = errors.length > 0 ? '' : 'clean'; + if (errors.length > 0) { + diagnosticsPre.style.color = '#f38ba8'; + } else { + diagnosticsPre.style.color = '#f9e2af'; + } + } +} + +function tryEvaluate(ast: ProgramNode, exprName: string) { + let inputData: Record; + try { + inputData = JSON.parse(inputTextarea.value || '{}'); + } catch (e) { + outputPre.textContent = `Invalid JSON input: ${e instanceof Error ? e.message : String(e)}`; + outputPre.style.color = '#f38ba8'; + return; + } + + const { _sources, _tables, ...evalInput } = inputData as any; + const result = evaluate(ast, exprName, evalInput, _sources ?? undefined, _tables ?? undefined, PLUGINS); + + if (result.error) { + outputPre.textContent = `Error: ${result.error}`; + outputPre.style.color = '#f38ba8'; + } else { + outputPre.textContent = JSON.stringify(formatOutput(result.value), null, 2); + outputPre.style.color = '#a6e3a1'; + } +} + +/** Format output for display — converts money objects to readable strings. */ +function formatOutput(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (isMoneyValue(value)) return formatMoney(value); + if (Array.isArray(value)) return value.map(formatOutput); + if (typeof value === 'object') { + const result: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + result[k] = formatOutput(v); + } + return result; + } + return value; +} + +// Event listeners +editor.onDidChangeModelContent(() => { + clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(processCode, 300); +}); + +exprSelect.addEventListener('change', processCode); +exampleSelect.addEventListener('change', () => loadExample(exampleSelect.value)); +inputTextarea.addEventListener('input', () => { + clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(processCode, 300); +}); + +// Handle resize +window.addEventListener('resize', () => editor.layout()); + +// Initial processing +processCode(); diff --git a/playground/src/plugins/money.ts b/playground/src/plugins/money.ts new file mode 100644 index 0000000..803b20b --- /dev/null +++ b/playground/src/plugins/money.ts @@ -0,0 +1,233 @@ +import { AxiomPlugin } from '../lang/plugin'; +import { TypeSig, typeMoney, TYPE_NUMBER, TYPE_BOOL } from '../lang/types'; + +// Currency symbol → ISO code mapping +const SYMBOL_MAP: Record = { + '£': 'GBP', + '€': 'EUR', + '$': 'USD', + '¥': 'JPY', +}; + +// Reverse: ISO code → symbol (for display) +const SYMBOL_REVERSE: Record = { + GBP: '£', EUR: '€', USD: '$', JPY: '¥', +}; + +// Known ISO 4217 currency codes +const ISO_CODES = new Set([ + 'GBP', 'EUR', 'USD', 'JPY', 'AUD', 'CAD', 'CHF', 'CNY', + 'SEK', 'NOK', 'DKK', 'NZD', 'ZAR', 'SGD', 'HKD', 'INR', 'BRL', 'AED', +]); + +export interface MoneyValue { + _money: true; + amount: number; + currency: string; +} + +export function isMoneyValue(v: unknown): v is MoneyValue { + return v !== null && typeof v === 'object' && (v as MoneyValue)._money === true; +} + +/** Format a money value for display: £100.00 or USD 100.00 */ +export function formatMoney(v: MoneyValue): string { + const sym = SYMBOL_REVERSE[v.currency]; + const formatted = Number.isInteger(v.amount) ? v.amount.toFixed(0) : v.amount.toFixed(2); + return sym ? `${sym}${formatted}` : `${v.currency} ${formatted}`; +} + +/** Read the numeric portion of a money literal starting at `numStart`. */ +function readMoneyNumber(source: string, pos: number, prefixLen: number, currency: string) { + let i = pos + prefixLen; + let hasDecimal = false; + while (i < source.length) { + const ch = source[i]; + if (ch >= '0' && ch <= '9') { + i++; + } else if (ch === '.' && !hasDecimal && i + 1 < source.length && source[i + 1] >= '0' && source[i + 1] <= '9') { + hasDecimal = true; + i++; + } else { + break; + } + } + if (i === pos + prefixLen) return null; // no digits after prefix + + const amount = parseFloat(source.slice(pos + prefixLen, i)); + return { + tag: 'money', + value: source.slice(pos, i), + payload: { _money: true, amount, currency } as MoneyValue, + length: i - pos, + }; +} + +export const moneyPlugin: AxiomPlugin = { + name: 'money', + + lexer: { + tryTokenize(source: string, pos: number) { + const ch = source[pos]; + + // Symbol form: £123.45 + const currency = SYMBOL_MAP[ch]; + if (currency) { + return readMoneyNumber(source, pos, 1, currency); + } + + // ISO code form: GBP123.45 — 3 uppercase letters followed by a digit + if (ch >= 'A' && ch <= 'Z' && pos + 3 < source.length) { + const code = source.slice(pos, pos + 3); + if (ISO_CODES.has(code) && source[pos + 3] >= '0' && source[pos + 3] <= '9') { + return readMoneyNumber(source, pos, 3, code); + } + } + + return null; + }, + }, + + checker: { + inferLiteralType(tag: string, payload: unknown) { + if (tag === 'money') { + return typeMoney((payload as MoneyValue).currency); + } + return null; + }, + + checkBinaryOp(op: string, left: TypeSig, right: TypeSig) { + const leftIsMoney = left.name === 'money'; + const rightIsMoney = right.name === 'money'; + if (!leftIsMoney && !rightIsMoney) return null; // not our concern + + // money OP money + if (leftIsMoney && rightIsMoney) { + if (['+', '-'].includes(op)) { + if (left.params[0] !== right.params[0]) { + return { error: `Cannot ${op} money(${left.params[0]}) and money(${right.params[0]}) — currency mismatch` }; + } + return left; + } + if (op === '/') { + if (left.params[0] !== right.params[0]) { + return { error: `Cannot divide money(${left.params[0]}) by money(${right.params[0]}) — currency mismatch` }; + } + return TYPE_NUMBER; // ratio + } + if (['==', '!=', '<', '>', '<=', '>='].includes(op)) { + if (left.params[0] !== right.params[0]) { + return { error: `Cannot compare money(${left.params[0]}) and money(${right.params[0]}) — currency mismatch` }; + } + return TYPE_BOOL; + } + return { error: `Operator '${op}' is not supported between money values` }; + } + + // money OP number + if (leftIsMoney && right.name === 'number') { + if (op === '*' || op === '/') return left; + return { error: `Cannot '${op}' money(${left.params[0]}) and number — use * or / to scale money` }; + } + + // number OP money + if (left.name === 'number' && rightIsMoney) { + if (op === '*') return right; + return { error: `Cannot '${op}' number and money(${right.params[0]}) — use * to scale money` }; + } + + return null; + }, + + checkCall(name: string, argTypes: TypeSig[]) { + // round(money, number) → money + if (name === 'round' && argTypes.length === 2 && argTypes[0].name === 'money') { + return argTypes[0]; + } + // max/min(money, money) → money + if ((name === 'max' || name === 'min') && argTypes.length === 2 + && argTypes[0].name === 'money' && argTypes[1].name === 'money') { + return argTypes[0]; + } + // sum(list(money)) → money + if (name === 'sum' && argTypes.length === 1 + && argTypes[0].name === 'list' && argTypes[0].elementType?.name === 'money') { + return argTypes[0].elementType; + } + return null; + }, + }, + + evaluator: { + supportsOp(left: unknown, right: unknown, op: string) { + return isMoneyValue(left) || isMoneyValue(right); + }, + + evaluateOp(left: unknown, right: unknown, op: string): unknown { + if (isMoneyValue(left) && isMoneyValue(right)) { + if (left.currency !== right.currency) { + throw new Error(`Cannot ${op} ${formatMoney(left)} and ${formatMoney(right)} — currency mismatch`); + } + switch (op) { + case '+': return { _money: true, amount: left.amount + right.amount, currency: left.currency }; + case '-': return { _money: true, amount: left.amount - right.amount, currency: left.currency }; + case '/': return left.amount / right.amount; // ratio → number + case '==': return left.amount === right.amount; + case '!=': return left.amount !== right.amount; + case '<': return left.amount < right.amount; + case '>': return left.amount > right.amount; + case '<=': return left.amount <= right.amount; + case '>=': return left.amount >= right.amount; + default: throw new Error(`Unsupported operator '${op}' for money`); + } + } + + if (isMoneyValue(left) && typeof right === 'number') { + switch (op) { + case '*': return { _money: true, amount: left.amount * right, currency: left.currency }; + case '/': return right === 0 ? { _money: true, amount: 0, currency: left.currency } + : { _money: true, amount: left.amount / right, currency: left.currency }; + default: throw new Error(`Cannot '${op}' money and number`); + } + } + + if (typeof left === 'number' && isMoneyValue(right)) { + if (op === '*') return { _money: true, amount: left * right.amount, currency: right.currency }; + throw new Error(`Cannot '${op}' number and money`); + } + + throw new Error('Unsupported money operation'); + }, + + intrinsics: { + sum: (list: unknown) => { + if (!Array.isArray(list) || list.length === 0 || !isMoneyValue(list[0])) return undefined; + const currency = list[0].currency; + const total = list.reduce((acc: number, v: unknown) => acc + (isMoneyValue(v) ? v.amount : 0), 0); + return { _money: true, amount: total, currency } as MoneyValue; + }, + round: (n: unknown, decimals: unknown) => { + if (isMoneyValue(n)) { + const d = typeof decimals === 'number' ? decimals : 0; + const factor = Math.pow(10, d); + return { _money: true, amount: Math.round(n.amount * factor) / factor, currency: n.currency }; + } + return undefined; // fall through to built-in + }, + max: (...args: unknown[]) => { + if (args.length === 2 && isMoneyValue(args[0]) && isMoneyValue(args[1])) { + const a = args[0], b = args[1]; + return a.amount >= b.amount ? a : b; + } + return undefined; + }, + min: (...args: unknown[]) => { + if (args.length === 2 && isMoneyValue(args[0]) && isMoneyValue(args[1])) { + const a = args[0], b = args[1]; + return a.amount <= b.amount ? a : b; + } + return undefined; + }, + }, + }, +}; diff --git a/playground/src/utils/csv.ts b/playground/src/utils/csv.ts new file mode 100644 index 0000000..74efc04 --- /dev/null +++ b/playground/src/utils/csv.ts @@ -0,0 +1,61 @@ +/** + * Simple CSV parser for table artifacts. + * In production, the runtime (PHP) would handle this. + * This simulates artifact loading for the playground. + */ +export function parseCSV(text: string): Record[] { + const lines = text.trim().split('\n'); + if (lines.length < 2) return []; + + const headers = parseLine(lines[0]); + const rows: Record[] = []; + + for (let i = 1; i < lines.length; i++) { + const values = parseLine(lines[i]); + if (values.length === 0) continue; + const row: Record = {}; + for (let j = 0; j < headers.length; j++) { + const raw = values[j] ?? ''; + row[headers[j]] = coerce(raw); + } + rows.push(row); + } + + return rows; +} + +function parseLine(line: string): string[] { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"' && line[i + 1] === '"') { + current += '"'; + i++; + } else if (ch === '"') { + inQuotes = false; + } else { + current += ch; + } + } else if (ch === '"') { + inQuotes = true; + } else if (ch === ',') { + fields.push(current); + current = ''; + } else { + current += ch; + } + } + fields.push(current); + return fields; +} + +function coerce(value: string): string | number { + if (value === '') return ''; + const num = Number(value); + if (!isNaN(num) && value.trim() !== '') return num; + return value; +} diff --git a/playground/src/vite-env.d.ts b/playground/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/playground/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/playground/tsconfig.json b/playground/tsconfig.json new file mode 100644 index 0000000..6a83529 --- /dev/null +++ b/playground/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} diff --git a/playground/vite.config.ts b/playground/vite.config.ts new file mode 100644 index 0000000..c5257bb --- /dev/null +++ b/playground/vite.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + base: './', +}); diff --git a/src/Artifacts/ArtifactRepository.php b/src/Artifacts/ArtifactRepository.php new file mode 100644 index 0000000..94d25ff --- /dev/null +++ b/src/Artifacts/ArtifactRepository.php @@ -0,0 +1,12 @@ + $declarations + */ +final readonly class Program implements Node +{ + /** + * @param list $declarations + */ + public function __construct( + public array $declarations = [], + ) {} +} diff --git a/src/Conformance/ConformanceCase.php b/src/Conformance/ConformanceCase.php new file mode 100644 index 0000000..01530b1 --- /dev/null +++ b/src/Conformance/ConformanceCase.php @@ -0,0 +1,24 @@ + $artifacts + * @param array $input + */ +final readonly class ConformanceCase +{ + /** + * @param array $artifacts + * @param array $input + */ + public function __construct( + public string $name, + public string $source, + public string $expressionName, + public array $artifacts = [], + public array $input = [], + ) {} +} diff --git a/src/Diagnostics/Diagnostic.php b/src/Diagnostics/Diagnostic.php new file mode 100644 index 0000000..b130a81 --- /dev/null +++ b/src/Diagnostics/Diagnostic.php @@ -0,0 +1,14 @@ + $input + */ + public function evaluate(AnalyzedProgram $program, string $expressionName, array $input = []): Value; +} diff --git a/src/Extensions/Extension.php b/src/Extensions/Extension.php new file mode 100644 index 0000000..b848b8d --- /dev/null +++ b/src/Extensions/Extension.php @@ -0,0 +1,10 @@ + $input + * @return list + */ + public function validate(AnalyzedProgram $program, string $expressionName, array $input = []): array; +} diff --git a/src/Lexing/Lexer.php b/src/Lexing/Lexer.php new file mode 100644 index 0000000..40184df --- /dev/null +++ b/src/Lexing/Lexer.php @@ -0,0 +1,13 @@ + + */ + public function tokenize(string $file, string $source): array; +} diff --git a/src/Lexing/Token.php b/src/Lexing/Token.php new file mode 100644 index 0000000..9a16928 --- /dev/null +++ b/src/Lexing/Token.php @@ -0,0 +1,16 @@ + $tokens + */ + public function parse(array $tokens): ParsedProgram; +} diff --git a/src/Runtime/AnalyzedProgram.php b/src/Runtime/AnalyzedProgram.php new file mode 100644 index 0000000..978276b --- /dev/null +++ b/src/Runtime/AnalyzedProgram.php @@ -0,0 +1,22 @@ + $diagnostics + */ +final readonly class AnalyzedProgram +{ + /** + * @param list $diagnostics + */ + public function __construct( + public Program $program, + public array $diagnostics = [], + ) {} +} diff --git a/src/Runtime/Engine.php b/src/Runtime/Engine.php new file mode 100644 index 0000000..ed69e5c --- /dev/null +++ b/src/Runtime/Engine.php @@ -0,0 +1,16 @@ + $input + */ +final readonly class EvaluationRequest +{ + /** + * @param array $input + */ + public function __construct( + public string $expressionName, + public array $input = [], + ) {} +} diff --git a/src/Runtime/ParsedProgram.php b/src/Runtime/ParsedProgram.php new file mode 100644 index 0000000..4926481 --- /dev/null +++ b/src/Runtime/ParsedProgram.php @@ -0,0 +1,22 @@ + $diagnostics + */ +final readonly class ParsedProgram +{ + /** + * @param list $diagnostics + */ + public function __construct( + public ?Program $program, + public array $diagnostics = [], + ) {} +} diff --git a/src/Runtime/ProgramBundle.php b/src/Runtime/ProgramBundle.php new file mode 100644 index 0000000..330e963 --- /dev/null +++ b/src/Runtime/ProgramBundle.php @@ -0,0 +1,25 @@ + $sources + * @param list $extensions + */ +final readonly class ProgramBundle +{ + /** + * @param array $sources + * @param list $extensions + */ + public function __construct( + public array $sources, + public ArtifactRepository $artifacts, + public array $extensions = [], + ) {} +} diff --git a/src/Runtime/ResolvedProgram.php b/src/Runtime/ResolvedProgram.php new file mode 100644 index 0000000..ace49b6 --- /dev/null +++ b/src/Runtime/ResolvedProgram.php @@ -0,0 +1,22 @@ + $diagnostics + */ +final readonly class ResolvedProgram +{ + /** + * @param list $diagnostics + */ + public function __construct( + public Program $program, + public array $diagnostics = [], + ) {} +} diff --git a/src/Types/NumberType.php b/src/Types/NumberType.php index ba44496..87a009f 100644 --- a/src/Types/NumberType.php +++ b/src/Types/NumberType.php @@ -4,59 +4,10 @@ namespace Superscript\Axiom\Types; -use NumberFormatter; -use Superscript\Axiom\Exceptions\TransformValueException; -use Superscript\Monads\Option\Some; -use Superscript\Monads\Result\Err; -use Superscript\Monads\Result\Result; - -use function Psl\Str\before; -use function Psl\Type\num; -use function Psl\Type\numeric_string; -use function Psl\Type\string; -use function Superscript\Monads\Option\None; -use function Superscript\Monads\Option\Some; -use function Superscript\Monads\Result\Ok; - -/** - * @implements Type - */ -class NumberType implements Type +final readonly class NumberType implements Type { - public function assert(mixed $value): Result + public function describe(): string { - if (!num()->matches($value)) { - return new Err(new TransformValueException(type: 'numeric', value: $value)); - } - - return Ok(Some($value)); - } - - public function coerce(mixed $value): Result - { - if (is_string($value) && ($value === '' || $value === 'null')) { - return Ok(None()); - } - - return (match (true) { - numeric_string()->matches($value) || num()->matches($value) => Ok(num()->coerce($value)), - is_string($value) && numeric_string()->matches(before($value, '%')) => Ok(num()->coerce(before($value, '%')) / 100), - default => new Err(new TransformValueException(type: 'numeric', value: $value)), - })->map(fn(int|float $value) => Some($value)); - } - - /** - * @inheritDoc - */ - public function compare(mixed $a, mixed $b): bool - { - return $a === $b; - } - - public function format(mixed $value): string - { - $formatter = new NumberFormatter('en_GB', NumberFormatter::DECIMAL); - - return string()->assert($formatter->format($value)); + return 'number'; } } diff --git a/src/Types/Type.php b/src/Types/Type.php index dba6b11..4f30fff 100644 --- a/src/Types/Type.php +++ b/src/Types/Type.php @@ -4,39 +4,7 @@ namespace Superscript\Axiom\Types; -use Superscript\Monads\Option\Option; -use Superscript\Monads\Result\Result; -use Throwable; - -/** - * @template T = mixed - */ interface Type { - /** - * Assert that a value is of type T and return it wrapped in Option - * @param T $value - * @return Result, Throwable> - */ - public function assert(mixed $value): Result; - - /** - * Try to coerce a mixed value into type T - * @param mixed $value - * @return Result, Throwable> - */ - public function coerce(mixed $value): Result; - - /** - * @param T $a - * @param T $b - * @return bool - */ - public function compare(mixed $a, mixed $b): bool; - - /** - * @param T $value - * @return string - */ - public function format(mixed $value): string; + public function describe(): string; } diff --git a/src/Typing/TypeChecker.php b/src/Typing/TypeChecker.php new file mode 100644 index 0000000..432f513 --- /dev/null +++ b/src/Typing/TypeChecker.php @@ -0,0 +1,13 @@ +value; + } +} diff --git a/src/Values/Value.php b/src/Values/Value.php new file mode 100644 index 0000000..af585d6 --- /dev/null +++ b/src/Values/Value.php @@ -0,0 +1,10 @@ + 'Premium(): number { 42 }'], + artifacts: new class implements ArtifactRepository { + public function has(string $tableName): bool + { + return false; + } + + public function fetch(string $tableName): string + { + return ''; + } + }, + ); + + $request = new EvaluationRequest('Premium'); + $case = new ConformanceCase('smoke', 'Premium(): number { 42 }', 'Premium'); + $diagnostic = new Diagnostic( + DiagnosticSeverity::Info, + 'scaffold', + new SourceLocation('main.ax', 1, 1), + ); + $number = new NumberType(); + $value = new DecimalValue(BigDecimal::of('42')); + + self::assertArrayHasKey('main.ax', $bundle->sources); + self::assertSame('Premium', $request->expressionName); + self::assertSame('smoke', $case->name); + self::assertSame('scaffold', $diagnostic->message); + self::assertSame('number', $number->describe()); + self::assertTrue($value->unwrap()->isEqualTo(BigDecimal::of('42'))); + } +}