diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e44931e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,151 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + archive: tar.gz + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + archive: tar.gz + cross: true + - target: x86_64-apple-darwin + os: macos-latest + archive: tar.gz + - target: aarch64-apple-darwin + os: macos-latest + archive: tar.gz + - target: x86_64-pc-windows-msvc + os: windows-latest + archive: zip + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools + if: matrix.cross + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} + + - name: Determine version + id: version + shell: bash + run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Package (unix) + if: matrix.archive == 'tar.gz' + shell: bash + run: | + STAGING="astra-${{ steps.version.outputs.version }}-${{ matrix.target }}" + mkdir -p "$STAGING" + cp "target/${{ matrix.target }}/release/astra" "$STAGING/" + cp README.md LICENSE* "$STAGING/" 2>/dev/null || true + tar czf "$STAGING.tar.gz" "$STAGING" + shasum -a 256 "$STAGING.tar.gz" > "$STAGING.tar.gz.sha256" + echo "ASSET=$STAGING.tar.gz" >> $GITHUB_ENV + echo "CHECKSUM=$STAGING.tar.gz.sha256" >> $GITHUB_ENV + + - name: Package (windows) + if: matrix.archive == 'zip' + shell: bash + run: | + STAGING="astra-${{ steps.version.outputs.version }}-${{ matrix.target }}" + mkdir -p "$STAGING" + cp "target/${{ matrix.target }}/release/astra.exe" "$STAGING/" + cp README.md LICENSE* "$STAGING/" 2>/dev/null || true + 7z a "$STAGING.zip" "$STAGING" + certutil -hashfile "$STAGING.zip" SHA256 > "$STAGING.zip.sha256" + echo "ASSET=$STAGING.zip" >> $GITHUB_ENV + echo "CHECKSUM=$STAGING.zip.sha256" >> $GITHUB_ENV + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: astra-${{ matrix.target }} + path: | + ${{ env.ASSET }} + ${{ env.CHECKSUM }} + + release: + name: Create Release + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Determine version + id: version + run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Generate release notes + id: notes + run: | + VERSION="${{ steps.version.outputs.version }}" + cat > release_notes.md << 'NOTES' + ## Installation + + **macOS / Linux:** + ```sh + curl -fsSL https://raw.githubusercontent.com/jaimeam/astra/main/install-binary.sh | sh + ``` + + **Or download a binary below** and add it to your `PATH`. + + ### Checksums + Each archive has an accompanying `.sha256` file for integrity verification. + + ### Targets + | Platform | File | + |----------|------| + | Linux x86_64 | `astra-VERSION-x86_64-unknown-linux-gnu.tar.gz` | + | Linux aarch64 | `astra-VERSION-aarch64-unknown-linux-gnu.tar.gz` | + | macOS x86_64 | `astra-VERSION-x86_64-apple-darwin.tar.gz` | + | macOS aarch64 (Apple Silicon) | `astra-VERSION-aarch64-apple-darwin.tar.gz` | + | Windows x86_64 | `astra-VERSION-x86_64-pc-windows-msvc.zip` | + NOTES + sed -i "s/VERSION/$VERSION/g" release_notes.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: Astra ${{ steps.version.outputs.version }} + body_path: release_notes.md + draft: false + prerelease: ${{ contains(steps.version.outputs.version, '-') }} + files: artifacts/* diff --git a/CHANGELOG.md b/CHANGELOG.md index a1cbdb4..261f98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,46 +1,94 @@ # Changelog -## v1.0.0 +All notable changes to Astra will be documented in this file. -Astra v1.0 — the first release ready for real projects. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-03-05 + +Astra v1.0 — the first stable release, ready for real projects. +See [docs/stability.md](docs/stability.md) for the stability guarantee. ### Language -- Full syntax: modules, functions, let bindings, if/else, match, for/while loops, break/continue/return +- Full syntax: modules, functions, let bindings, if/else, match, for/while loops, + break/continue/return - Type system with inference, generics, traits, type aliases, and invariants -- Enums with associated data, Option[T], Result[T, E], and the `?` operator +- Enums with associated data, Option[T], Result[T, E], and the `?` / `?else` operators - Capability-based effects system (Console, Fs, Net, Clock, Rand, Env) with user-defined effects -- Pattern matching with exhaustiveness checking +- Pattern matching with exhaustiveness checking and guard clauses - String interpolation (`"${expr}"`), multiline strings (`"""..."""`), escape sequences - Range expressions (`0..10`, `0..=10`) - Pipe operator (`value |> fn`) -- Tail call optimization -- Contracts (`requires`/`ensures`) -- Mutable bindings (`let mut`) +- Tail call optimization for self-recursive functions +- Contracts (`requires`/`ensures`) and type invariants +- Mutable bindings (`let mut`) with compound assignment (`+=`, `-=`, `*=`, `/=`, `%=`) +- Closures and lambdas as first-class values ### Toolchain - `astra run` — execute programs with real Fs/Net/Clock/Rand capabilities - `astra check` — type check with JSON output, `--watch` mode, incremental caching -- `astra test` — deterministic test runner with mock injection and property tests -- `astra fmt` — canonical formatter -- `astra fix` — auto-apply diagnostic suggestions -- `astra explain` — detailed error code explanations (55 codes) +- `astra test` — deterministic test runner with mock injection, `--filter`, `--seed`, `--watch` +- `astra fmt` — canonical formatter (idempotent, deterministic) +- `astra fix` — auto-apply diagnostic suggestions with `--dry-run` +- `astra explain` — detailed error code explanations (57 codes) - `astra repl` — interactive REPL - `astra init` — project scaffolding with `--lib` support -- `astra doc` — generate API docs from `##` comments +- `astra doc` — generate API docs from `##` comments (markdown/html) - `astra lsp` — LSP server with diagnostics, hover, completion, and code actions -### Standard Library (12 modules) +### Standard Library (15 modules) + +- `std.core` — identity, constant, Unit type alias +- `std.prelude` — auto-imported common utilities +- `std.option` — Option handling: is_some, is_none, unwrap_or, map +- `std.result` — Result handling: is_ok, is_err, unwrap_or, map, map_err +- `std.error` — Error utilities: wrap, from_text, or_else +- `std.list` — List utilities: is_empty, head, sort_by +- `std.collections` — Advanced collections: group_by, frequencies, chunks +- `std.iter` — Iterator functions: sum, product, all, any, reduce, flat_map +- `std.string` — String utilities: is_blank, pad_left, pad_right, chars +- `std.math` — Math functions: abs_val, min_val, max_val, clamp, is_even, is_odd +- `std.io` — I/O wrappers with effect declarations +- `std.json` — JSON parse and stringify +- `std.regex` — Regular expression matching, replace, split +- `std.datetime` — Date parsing, formatting, arithmetic, leap year detection +- `std.path` — File path manipulation: basename, dirname, extension, join, normalize + +### Diagnostics (57 error codes) + +- E0xxx: Syntax/parsing errors (11 codes) +- E1xxx: Type errors (16 codes) +- E2xxx: Effect errors (7 codes) +- E3xxx: Contract violations (5 codes) +- E4xxx: Runtime errors (8 codes) +- W0xxx: Warnings (8 codes) +- Multi-error recovery: parser reports multiple errors per file +- Stack traces with source locations (file:line:col) +- Machine-actionable suggestions with auto-fix support +- JSON output format for tool integration (`--json`) + +### Performance + +- Map/Set operations use O(log n) sorted binary search +- Tail-call optimization for self-recursive functions +- Incremental caching for `astra check` +- See [docs/performance.md](docs/performance.md) for full details + +### Architecture Decisions -`std.core`, `std.math`, `std.string`, `std.collections`, `std.list`, `std.option`, `std.result`, `std.prelude`, `std.json`, `std.io`, `std.iter`, `std.error` +- ADR-001: Rust as implementation language +- ADR-002: Explicit effects over monads +- ADR-003: No null — Option[T] and Result[T, E] instead +- ADR-004: Built-in linting in `astra check` +- ADR-005: Interpreter-TypeChecker sync invariant +- ADR-006: v1.0 release assessment +- ADR-007: Async/await and package manager deferred to v1.1 -### Known Limitations +### Not Included (Planned for v1.1) -See [README.md](README.md#known-limitations-v10) for the full list. Key items: -- No full Hindley-Milner type inference -- Traits are runtime-dispatched -- Single-threaded (no concurrency) -- Interpreted only (tree-walking) -- No package manager -- No debugger +- Async/await — syntax reserved, not yet functional +- Package manager — `astra pkg` command exists, resolution not implemented +- See [ADR-007](docs/adr/ADR-007-defer-async-pkg-to-v1.1.md) for rationale diff --git a/Cargo.toml b/Cargo.toml index dc24fb5..0398705 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "astra" -version = "0.1.1" +version = "1.0.0" edition = "2021" description = "Astra programming language - an LLM/Agent-native language" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 38cda50..88db8db 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Plus the building blocks you'd expect: - **No null** - `Option[T]` with exhaustive matching; compiler catches missing cases - **Typed error handling** - `Result[T, E]` with `?` and `?else` for concise propagation - **Canonical formatting** - mandatory built-in formatter, no configuration -- **JSON / regex / async** - built into the standard library -- **Package management** - `astra pkg` for dependency management +- **JSON / regex** - built into the standard library +- **15 stdlib modules** - from `std.math` to `std.datetime` and `std.path` ``` LLM generates code -> astra check -> JSON errors with fix suggestions -> LLM applies fixes -> repeat until passing @@ -127,33 +127,11 @@ let cleaned = replace("\\s+", " too many spaces ", " ") let words = "hello world".split_pattern("\\s+") ``` -### Async/Await +### Planned for v1.1 -Functions can be declared `async` and their results can be `await`ed. Calling an async function returns a `Future` value; `await` resolves it. - -```astra -async fn fetch_data(url: Text) -> Text { - Net.get(url) -} - -fn main() effects(Net) { - let data = await fetch_data("https://api.example.com/data") - println(data) -} -``` - -### Package Management - -The `astra pkg` commands manage dependencies declared in `astra.toml`. Supports path, git, and registry dependencies with lockfile generation. - -```bash -astra pkg add mylib --version "1.0" -astra pkg add local-lib --path "../lib" -astra pkg add remote-lib --git "https://github.com/example/lib" -astra pkg install -astra pkg list -astra pkg remove mylib -``` +- **Async/Await** — `async fn` and `await` syntax is reserved; full implementation coming in v1.1 +- **Package Manager** — `astra pkg` command exists; dependency resolution coming in v1.1 +- See [ADR-007](docs/adr/ADR-007-defer-async-pkg-to-v1.1.md) for rationale ## CLI Commands @@ -169,22 +147,23 @@ astra pkg remove mylib | `astra init ` | Scaffold a new project | | `astra doc [files...]` | Generate API documentation | | `astra lsp` | Start LSP server | -| `astra pkg install` | Install dependencies from astra.toml | -| `astra pkg add ` | Add a dependency | -| `astra pkg remove ` | Remove a dependency | -| `astra pkg list` | List installed packages | +| `astra pkg` | Package management (v1.1) | ## Documentation -- **[Why Astra?](docs/why-astra.md)** - The case for an LLM-native language -- [Getting Started](docs/getting-started.md) - Tutorial for your first Astra program -- [Astra by Example](docs/examples.md) - Cookbook of common patterns and idioms -- [Language Specification](docs/spec.md) - Complete syntax and semantics reference -- [Effects System](docs/effects.md) - Guide to Astra's capability-based effects -- [Testing Guide](docs/testing.md) - How to write and run tests -- [Standard Library](docs/stdlib.md) - API reference for built-in types and functions -- [Error Codes Reference](docs/errors.md) - All error codes with examples and fixes -- [Formatting Rules](docs/formatting.md) - Canonical formatting specification +- **[Why Astra?](docs/why-astra.md)** — The case for an LLM-native language +- [Getting Started](docs/getting-started.md) — Tutorial for your first Astra program +- [Astra by Example](docs/examples.md) — Cookbook of common patterns and idioms +- [Language Specification](docs/spec.md) — Complete syntax and semantics reference +- [Formal Grammar](docs/grammar.md) — EBNF grammar for the language +- [Effects System](docs/effects.md) — Guide to Astra's capability-based effects +- [Testing Guide](docs/testing.md) — How to write and run tests +- [Standard Library](docs/stdlib.md) — API reference for built-in types and functions +- [Error Codes Reference](docs/errors.md) — All error codes with examples and fixes +- [Formatting Rules](docs/formatting.md) — Canonical formatting specification +- [Performance](docs/performance.md) — Performance characteristics and guidance +- [Stability Guarantee](docs/stability.md) — v1.0 stability promises +- [Changelog](CHANGELOG.md) — Version history ## Project Structure @@ -203,13 +182,15 @@ astra/ └── examples/ # Example programs ``` -## Known Limitations - -- **Traits are runtime-dispatched** - Trait method calls are resolved at runtime, not compile time. The type checker validates trait impl blocks but does not resolve trait methods on arbitrary expressions. Incorrect trait usage is caught at runtime. +## Known Limitations (v1.0) -- **Interpreted only** - All execution is via a tree-walking interpreter. Performance is adequate for small and medium programs but not suitable for compute-heavy workloads. For performance-critical code, consider calling out to external tools via effects. +- **Interpreted only** — Tree-walking interpreter. Adequate for scripts and tools, not for compute-heavy workloads. See [docs/performance.md](docs/performance.md). +- **Traits are runtime-dispatched** — The type checker validates trait impl blocks, but trait method calls are resolved at runtime. +- **No debugger** — Use `println`, `assert`/`assert_eq`, and `test` blocks for debugging. +- **Async/await not yet functional** — Syntax is reserved for v1.1. +- **Package manager not yet functional** — `astra pkg` exists but resolution is not implemented until v1.1. -- **No debugger** - There is no step-through debugger. Use `println` for debugging output, `assert`/`assert_eq` for runtime checks, and `test` blocks for verifying behavior. +See [docs/stability.md](docs/stability.md) for the v1.0 stability guarantee. ## Contributing diff --git a/docs/adr/ADR-006-v1-release-assessment.md b/docs/adr/ADR-006-v1-release-assessment.md new file mode 100644 index 0000000..091233e --- /dev/null +++ b/docs/adr/ADR-006-v1-release-assessment.md @@ -0,0 +1,195 @@ +# ADR-006: Astra v1.0 Release Assessment + +**Status**: Proposed +**Date**: 2026-03-05 +**Context**: Comprehensive review of Astra's current capabilities, gaps, and readiness for a v1.0 release. + +--- + +## Current State Summary + +| Dimension | Status | Details | +|------------------------|---------------|---------| +| **Rust toolchain** | ~24,200 LOC | Parser (3.9k), Type checker (4.2k), Interpreter (8.3k), Formatter, CLI (1.8k), Diagnostics | +| **Test suite** | 392 tests | 388 unit tests + 4 golden test suites (23 `.astra` golden files), all passing | +| **Standard library** | 13 modules | core, prelude, option, result, error, list, collections, iter, string, regex, io, json, math | +| **Examples** | 15 programs | From hello-world to a multi-module task tracker project | +| **Documentation** | 14 docs | Spec, stdlib reference, 57 error codes, 5 ADRs, getting-started guide, examples cookbook | +| **CLI commands** | 11 commands | fmt, check, test, run, repl, init, doc, fix, explain, pkg, lsp | +| **Error codes** | 57 codes | E0xxx (syntax), E1xxx (type), E2xxx (effect), E3xxx (contract), E4xxx (runtime), W0xxx (warnings) | + +--- + +## What Astra Can Do Today + +### Language Features (Complete) +- **Type system**: Int, Float, Bool, Text, Unit, List, Tuple, Map, Set, Record, Enum, Option, Result, Function types +- **Generics**: Type parameters with trait bounds (`fn identity[T: Show](x: T) -> T`) +- **Pattern matching**: Literals, variants, records, tuples, wildcards, guards; exhaustiveness checking +- **Effect system**: Capability-based I/O (Console, Fs, Net, Clock, Rand, Env) with `effects()` declarations +- **Contracts**: Preconditions (`requires`) and postconditions (`ensures`) on functions +- **Type invariants**: `type Percentage = Int invariant self >= 0 and self <= 100` +- **Traits & impl blocks**: User-defined trait interfaces with method dispatch +- **Closures/lambdas**: First-class functions with captured environments +- **Pipe operator**: `x |> f` for functional composition +- **String interpolation**: `"Hello, ${name}!"` with escape sequences +- **Error propagation**: `?` and `?else` operators on Option/Result +- **Modules & imports**: Multi-file programs, selective imports, aliasing, re-exports +- **Mutable bindings**: `let mut` with compound assignment (`+=`, `-=`, etc.) +- **Loops**: `for..in`, `while`, `break`, `continue` +- **Tail-call optimization**: Self-recursive tail calls optimized +- **Async/await**: Async function declarations and await expressions (marked v1.1) + +### Built-in Operations (50+ built-in functions) +- **Numeric**: `abs`, `min`, `max`, `pow`, `sqrt`, `floor`, `ceil`, `round`, `range` +- **String**: 15+ methods (split, replace, trim, contains, starts_with, regex methods, etc.) +- **List**: 25+ methods (map, filter, fold, flat_map, sort, zip, enumerate, etc.) +- **Map/Set**: Full CRUD plus set operations (union, intersection) +- **JSON**: `json_parse`, `json_stringify` +- **Regex**: `regex_match`, `regex_find_all`, `regex_replace`, `regex_split`, `regex_is_match` +- **I/O**: File read/write, HTTP GET/POST, HTTP server (`Net.serve`), env vars, CLI args + +### Tooling (Comprehensive) +- **Formatter**: `astra fmt` — canonical code formatting (idempotent, no config disputes) +- **Type checker**: `astra check` — static analysis with lint warnings (W0xxx), `--watch` mode, incremental caching +- **Test runner**: `astra test` — test blocks with deterministic effect injection, `--filter`, `--seed`, `--watch` +- **Interpreter**: `astra run` — execute programs with real capabilities +- **REPL**: `astra repl` — interactive exploration +- **Project init**: `astra init` / `astra init --lib` — scaffold new projects +- **Doc generator**: `astra doc` — API documentation (markdown/html) +- **Auto-fixer**: `astra fix` — auto-apply diagnostic suggestions, `--dry-run` +- **Error explainer**: `astra explain E1001` — detailed explanations for all 57 error codes +- **Package manager**: `astra pkg` — install, add, remove, list (marked v1.1) +- **LSP server**: `astra lsp` — IDE integration +- **Diagnostics**: 57 error codes (E0xxx–W0xxx) with spans, suggestions, machine-actionable fixes, JSON output (`--json`) +- **Pre-commit hooks**: Enforces fmt, clippy, and test on every commit + +### Testing Infrastructure +- **Capability mocking**: `using effects(Rand = Rand.seeded(42), Clock = Clock.fixed(1000))` for deterministic tests +- **Property testing**: `property` blocks with configurable iterations and seeds +- **Golden tests**: Snapshot-based testing for parser, typechecker, effects, and runtime +- **12 runtime test categories**: arithmetic, functions, control flow, loops, generics, destructuring, contracts, traits, effects, async, JSON, regex + +--- + +## Gap Analysis: What Needs Attention for v1.0 + +### Critical (Must Resolve Before v1.0) + +1. **Async/await completeness** + Async/await exists syntactically and `Future` is defined, but it appears to be a stub (v1.1 marker). For v1.0, either: + - Fully implement async with a runtime (event loop, concurrent futures) + - Explicitly defer it: remove from default feature set, document as experimental/v1.1 + +2. **Performance story** + Astra is a tree-walking interpreter. For a v1.0 release: + - Document performance expectations clearly ("scripting-speed, not systems-speed") + - Profile and benchmark common patterns + - Optimize hot paths (Map is O(n) Vec-of-pairs — consider hash-based implementation) + +3. **Parser error recovery** + The parser appears to stop at the first error. For a language whose design goal is "machine-actionable diagnostics," multi-error recovery (reporting multiple errors per file) would significantly improve both developer and agent experience. + +4. **Package manager maturity** + `astra pkg` exists but is marked v1.1. For v1.0, at minimum: + - `astra.toml` dependency declaration should work + - Git-based or registry-based resolution should be functional + - If not ready, clearly document it as experimental + +### Important (Strongly Recommended for v1.0) + +5. **Standard library gaps** + - **Missing**: Date/time manipulation (only `Clock.now()` and `Clock.today()`, no parsing/formatting/arithmetic) + - **Missing**: File path utilities (join, dirname, basename, extension) + - **Missing**: Advanced string formatting (printf-style, number formatting, padding beyond pad_left/pad_right) + - **Missing**: Sorting with custom comparators on all collections + - **Weak**: Map/Set use Vec internally — O(n) lookup instead of O(1) + +6. **Debugging support** + - No `--debug` or `--trace` mode for step-through execution + - Stack traces on runtime errors should show full call chain + - Source maps or breakpoint support would help adoption + +7. **Type system enhancements** + - No type narrowing after `is_some()`/`is_ok()` checks (flow-sensitive typing) + - No associated types on traits + - No default method implementations in traits + +8. **Documentation completeness** + - Language specification (`docs/spec.md`) should cover all features exhaustively + - Formal grammar (BNF/EBNF) needed for language lawyers and tool authors + - Changelog format and migration guide for version transitions + +### Nice to Have (Post-v1.0) + +9. **FFI / Host language interop** — Call Rust/WASM functions from Astra +10. **Playground / web REPL** — Try Astra in the browser (WASM target) +11. **Benchmarking built-in** — `bench` blocks for performance testing +12. **Richer collection types** — Deque, PriorityQueue, SortedMap, proper HashMap +13. **Custom operators** — User-defined infix operators +14. **Compilation target** — Bytecode VM or WASM compilation for performance + +--- + +## v1.0 Release Recommendation + +### Verdict: **Close to v1.0, with a few blockers to resolve.** + +Astra is in remarkably good shape: + +**Strengths:** +- Complete, coherent language with types, effects, contracts, generics, and pattern matching +- 11 CLI commands covering the full development lifecycle (fmt, check, test, run, repl, init, doc, fix, explain, pkg, lsp) +- 57 error codes with detailed explanations and auto-fix suggestions +- Deterministic testing with capability mocking +- 392 passing tests across unit and golden test suites +- Well-organized codebase with ADRs documenting design decisions +- Standard library covering core functionality across 13 modules + +**Blockers for v1.0:** +- Async and package management need to either work fully or be explicitly deferred +- Parser needs multi-error recovery for the "agent-native" promise +- Performance characteristics need documentation and basic optimization (Map/Set) + +### Recommended Path to v1.0 + +#### Phase 1: Stabilization (current → v0.9) +- [ ] Decide async/await: ship fully or explicitly defer to v1.1 +- [ ] Decide pkg: ship fully or explicitly defer to v1.1 +- [ ] Add multi-error recovery to the parser +- [ ] Improve stack traces on runtime errors +- [ ] Fill stdlib gaps: date/time formatting, file paths +- [ ] Optimize Map/Set to use hash-based storage +- [ ] Write a formal grammar (BNF) +- [ ] Performance benchmarks and documentation + +#### Phase 2: Polish (v0.9 → v1.0-rc) +- [ ] Complete the language specification +- [ ] Harden LSP for common IDE workflows +- [ ] Add at least 3 non-trivial example projects +- [ ] Publish a changelog and migration guide +- [ ] Create a "v1.0 stability guarantee" document + +#### Phase 3: Release (v1.0) +- [ ] Freeze all public APIs and error codes +- [ ] Final test pass across all platforms +- [ ] Release announcement with documentation + +--- + +## What Could Be Released Today + +Astra is fully functional **right now** for: +- Educational use and language exploration +- Small-to-medium scripts and automation tools +- LLM/Agent code generation experiments +- Teaching effect systems, contracts, and capability-based security +- Prototyping with deterministic, testable I/O + +A **v0.8.0** or **v0.9.0-beta** release would be appropriate today, signaling that the language is feature-complete and approaching stability, while setting expectations that some rough edges (async, pkg, performance) are still being polished. + +--- + +## Decision + +**Recommended**: Release as **v0.9.0-beta** now, targeting **v1.0** after completing Phases 1–2 above. The language is closer to v1.0 than initially assessed — the tooling surface (REPL, LSP, pkg, doc, fix, explain) is already in place; the remaining work is stabilization and polish rather than greenfield development. diff --git a/docs/adr/ADR-007-defer-async-pkg-to-v1.1.md b/docs/adr/ADR-007-defer-async-pkg-to-v1.1.md new file mode 100644 index 0000000..a0b6740 --- /dev/null +++ b/docs/adr/ADR-007-defer-async-pkg-to-v1.1.md @@ -0,0 +1,46 @@ +# ADR-007: Defer Async/Await and Package Manager to v1.1 + +**Status**: Accepted +**Date**: 2026-03-05 + +## Context + +Astra v1.0 aims to deliver a stable, well-tested language. Two features currently exist in +partial form: + +1. **Async/Await** — The parser recognizes `async fn` and `await` expressions, and the + interpreter can create `Future` values, but there is no event loop, no concurrent + execution, and `await` simply evaluates the future synchronously. + +2. **Package Manager** (`astra pkg`) — The CLI command is defined, but dependency + resolution, registry support, and lock-file generation are not implemented. + +## Decision + +Both features are **explicitly deferred to v1.1**. They will not be part of the v1.0 +stability guarantee. + +### Rationale + +- **Half-implemented features erode trust.** A v1.0 label promises stability. Shipping + async with no real concurrency, or a package manager with no registry, would confuse + users and create upgrade friction when the real implementations land. + +- **Both features have deep design implications.** Async interacts with the effect system + (which effects can an async function declare?), error propagation (what happens when a + future fails?), and testing (how do you deterministically test concurrent code?). The + package manager needs dependency resolution, version constraints, and a distribution + story. Rushing either would create backward-compatibility debt. + +- **v1.0 is strong without them.** Astra's core — types, effects, contracts, pattern + matching, modules, testing — is complete and well-tested. These are the features that + define the language's identity. + +## Consequences + +- The `async` and `await` keywords remain reserved but produce a clear error: + "Async/await is planned for v1.1. See docs/roadmap.md." +- `astra pkg` prints a message directing users to manual module management for now. +- The v1.1 roadmap will be published alongside the v1.0 release. +- No breaking changes to the existing syntax are planned — when async lands, it will + use the existing `async fn` / `await` syntax. diff --git a/docs/grammar.md b/docs/grammar.md new file mode 100644 index 0000000..0d58d9a --- /dev/null +++ b/docs/grammar.md @@ -0,0 +1,464 @@ +# Astra Language Grammar + +Formal EBNF grammar for the Astra programming language. + +This grammar is derived from the parser implementation in `src/parser/parser.rs`, +the lexer in `src/parser/lexer.rs`, and the AST definitions in `src/parser/ast.rs`. + +## Notation + +- `::=` defines a production rule +- `|` separates alternatives +- `[...]` denotes optional elements (zero or one) +- `{...}` denotes repetition (zero or more) +- `'...'` denotes terminal strings (keywords, operators, punctuation) +- `(...)` groups elements +- `,` within a rule is literal only when quoted; otherwise it is sequencing + +--- + +## Module + +```ebnf +Module ::= 'module' ModulePath { Item } + +ModulePath ::= IDENT { '.' IDENT } +``` + +--- + +## Items + +```ebnf +Item ::= ImportDecl + | TypeDef + | EnumDef + | FnDef + | TraitDef + | ImplBlock + | EffectDef + | TestBlock + | PropertyBlock +``` + +### Import Declarations + +```ebnf +ImportDecl ::= [ 'public' ] 'import' ModulePath [ ImportTail ] + +ImportTail ::= 'as' IDENT + | '.' '{' IDENT { ',' IDENT } [ ',' ] '}' +``` + +### Type Definitions + +```ebnf +TypeDef ::= 'type' IDENT [ TypeParams ] '=' TypeExpr [ 'invariant' Expr ] +``` + +### Enum Definitions + +```ebnf +EnumDef ::= 'enum' IDENT [ TypeParams ] '=' [ '|' ] Variant { '|' Variant } + +Variant ::= IDENT [ '(' Field { ',' Field } [ ',' ] ')' ] + +Field ::= IDENT ':' TypeExpr +``` + +### Function Definitions + +```ebnf +FnDef ::= [ 'public' ] [ 'async' ] 'fn' IDENT [ TypeParams ] '(' [ Params ] ')' + [ '->' TypeExpr ] + [ EffectsClause ] + { RequiresClause } + { EnsuresClause } + Block + +Params ::= Param { ',' Param } [ ',' ] + +Param ::= IDENT ':' TypeExpr + | 'self' + | Pattern ':' TypeExpr + +EffectsClause ::= 'effects' '(' IDENT { ',' IDENT } ')' + +RequiresClause ::= 'requires' Expr + +EnsuresClause ::= 'ensures' Expr +``` + +### Trait Definitions + +```ebnf +TraitDef ::= 'trait' IDENT [ TypeParams ] '{' { FnSignature } '}' + +FnSignature ::= 'fn' IDENT '(' [ Params ] ')' [ '->' TypeExpr ] +``` + +### Impl Blocks + +```ebnf +ImplBlock ::= 'impl' IDENT 'for' TypeExpr '{' { FnDef } '}' +``` + +### Effect Definitions + +```ebnf +EffectDef ::= 'effect' IDENT '{' { FnSignature } '}' +``` + +### Test Blocks + +```ebnf +TestBlock ::= 'test' TEXT_LIT [ UsingClause ] Block +``` + +### Property Blocks + +```ebnf +PropertyBlock ::= 'property' TEXT_LIT [ UsingClause ] Block +``` + +### Using Clause + +```ebnf +UsingClause ::= 'using' 'effects' '(' [ EffectBinding { ',' EffectBinding } [ ',' ] ] ')' + +EffectBinding ::= IDENT '=' Expr +``` + +--- + +## Type Parameters and Arguments + +```ebnf +TypeParams ::= '[' TypeParam { ',' TypeParam } ']' + +TypeParam ::= IDENT [ ':' IDENT ] + +TypeArgs ::= '[' TypeExpr { ',' TypeExpr } ']' +``` + +--- + +## Type Expressions + +```ebnf +TypeExpr ::= NamedType + | RecordType + | FunctionType + | TupleType + | UnitType + | '(' TypeExpr ')' + +NamedType ::= IDENT [ TypeArgs ] + +RecordType ::= '{' [ Field { ',' Field } [ ',' ] ] '}' + +FunctionType ::= '(' [ TypeExpr { ',' TypeExpr } [ ',' ] ] ')' '->' TypeExpr [ EffectsClause ] + +TupleType ::= '(' TypeExpr ',' TypeExpr { ',' TypeExpr } [ ',' ] ')' + +UnitType ::= '(' ')' +``` + +--- + +## Blocks + +```ebnf +Block ::= '{' { BlockElement } [ Expr ] '}' + +BlockElement ::= Stmt + | LocalFnDef + | Expr '=' Expr + | Expr CompoundAssignOp Expr + | Expr +``` + +--- + +## Statements + +```ebnf +Stmt ::= LetStmt + | LetPatternStmt + | ReturnStmt + +LetStmt ::= 'let' [ 'mut' ] IDENT [ ':' TypeExpr ] '=' Expr + +LetPatternStmt ::= 'let' Pattern [ ':' TypeExpr ] '=' Expr + +ReturnStmt ::= 'return' [ Expr ] +``` + +### Local Function Definition + +```ebnf +LocalFnDef ::= 'fn' IDENT [ TypeParams ] '(' [ LambdaParams ] ')' [ '->' TypeExpr ] Block +``` + +--- + +## Expressions + +### Precedence (lowest to highest) + +| Precedence | Operators | Associativity | +|------------|----------------------------|---------------| +| 0 | `\|>` | Left | +| 1 | `..` `..=` | Left | +| 2 | `or` | Left | +| 3 | `and` | Left | +| 4 | `==` `!=` | Left | +| 5 | `<` `<=` `>` `>=` | Left | +| 6 | `+` `-` | Left | +| 7 | `*` `/` `%` | Left | +| (prefix) | `not` `-` `await` | Right (unary) | +| (postfix) | `()` `.` `?` `?else` `[]` | Left | + +### Expression Grammar + +```ebnf +Expr ::= BinaryExpr + +BinaryExpr ::= PipeExpr + +PipeExpr ::= RangeExpr { '|>' RangeExpr } + +RangeExpr ::= OrExpr [ ( '..' | '..=' ) OrExpr ] + +OrExpr ::= AndExpr { 'or' AndExpr } + +AndExpr ::= EqExpr { 'and' EqExpr } + +EqExpr ::= CmpExpr { ( '==' | '!=' ) CmpExpr } + +CmpExpr ::= AddExpr { ( '<' | '<=' | '>' | '>=' ) AddExpr } + +AddExpr ::= MulExpr { ( '+' | '-' ) MulExpr } + +MulExpr ::= UnaryExpr { ( '*' | '/' | '%' ) UnaryExpr } + +UnaryExpr ::= 'not' UnaryExpr + | '-' UnaryExpr + | 'await' UnaryExpr + | PostfixExpr + +PostfixExpr ::= PrimaryExpr { PostfixOp } + +PostfixOp ::= '(' [ Expr { ',' Expr } [ ',' ] ] ')' + | '.' IDENT '(' [ Expr { ',' Expr } [ ',' ] ] ')' + | '.' IDENT + | '.' INT_LIT + | '?else' Expr + | '?' + | '[' Expr ']' +``` + +### Primary Expressions + +```ebnf +PrimaryExpr ::= INT_LIT + | FLOAT_LIT + | 'true' + | 'false' + | TEXT_LIT + | MULTILINE_TEXT_LIT + | IDENT + | UnitLit + | TupleLit + | ParenExpr + | ListLit + | RecordExpr + | BlockExpr + | LambdaExpr + | ForExpr + | WhileExpr + | IfExpr + | MatchExpr + | AssertExpr + | 'break' + | 'continue' + | '???' + +UnitLit ::= '(' ')' + +TupleLit ::= '(' Expr ',' [ Expr { ',' Expr } ] [ ',' ] ')' + +ParenExpr ::= '(' Expr ')' + +ListLit ::= '[' [ Expr { ',' Expr } [ ',' ] ] ']' + +RecordExpr ::= '{' '}' + | '{' IDENT '=' Expr { ',' IDENT '=' Expr } [ ',' ] '}' + +BlockExpr ::= '{' { BlockElement } [ Expr ] '}' + +LambdaExpr ::= 'fn' '(' [ LambdaParams ] ')' [ '->' TypeExpr ] Block + +LambdaParams ::= LambdaParam { ',' LambdaParam } [ ',' ] + +LambdaParam ::= IDENT [ ':' TypeExpr ] + +ForExpr ::= 'for' ( IDENT | Pattern ) 'in' Expr Block + +WhileExpr ::= 'while' Expr Block + +IfExpr ::= 'if' Expr Block [ 'else' ( IfExpr | Block ) ] + | 'if' Expr 'then' Expr [ 'else' Expr ] + +MatchExpr ::= 'match' Expr '{' { MatchArm [ ',' ] } '}' + +MatchArm ::= Pattern [ 'if' Expr ] '=>' Expr + +AssertExpr ::= 'assert' '(' Expr [ ',' Expr ] ')' + | 'assert' Expr +``` + +--- + +## Patterns + +```ebnf +Pattern ::= '_' + | INT_LIT + | FLOAT_LIT + | 'true' + | 'false' + | TEXT_LIT + | IDENT + | VariantPattern + | RecordPattern + | TuplePattern + +VariantPattern ::= UPPER_IDENT [ '(' [ Pattern { ',' Pattern } [ ',' ] ] ')' ] + +RecordPattern ::= '{' [ RecordFieldPat { ',' RecordFieldPat } [ ',' ] ] '}' + +RecordFieldPat ::= IDENT [ '=' Pattern ] + +TuplePattern ::= '(' [ Pattern { ',' Pattern } [ ',' ] ] ')' +``` + +Note: An identifier starting with an uppercase letter is parsed as a variant +pattern (with no fields), while a lowercase identifier is parsed as a binding +pattern. + +--- + +## Assignment and Compound Assignment + +Within blocks, assignment and compound assignment are parsed as block elements +rather than as standalone statements: + +```ebnf +CompoundAssignOp ::= '+=' | '-=' | '*=' | '/=' | '%=' +``` + +Compound assignment `x += expr` is desugared to `x = x + expr` (and similarly +for the other operators). + +--- + +## String Interpolation + +String interpolation is supported within both regular and multiline string +literals. Interpolation segments use the `${expr}` syntax: + +```ebnf +InterpolatedString ::= '"' { TextSegment | '${' Expr '}' } '"' + | '"""' { TextSegment | '${' Expr '}' } '"""' +``` + +Multiline strings (`"""..."""`) are automatically dedented: the first line (if +empty) and last line (if whitespace-only) are stripped, then the minimum common +leading whitespace is removed from all remaining lines. + +--- + +## Escape Sequences + +Within string literals, the following escape sequences are recognized: + +``` +\n newline +\r carriage return +\t tab +\\ backslash +\" double quote +\0 null +\$ literal dollar sign (prevents interpolation) +``` + +--- + +## Comments + +```ebnf +LineComment ::= '#' { any character except newline } + +DocComment ::= '##' { any character except newline } +``` + +Comments are lexed and discarded by the parser. Doc comments (`##`) are +preserved for tooling (e.g., the formatter) but do not appear in the AST. + +--- + +## Lexical Elements + +```ebnf +INT_LIT ::= DIGIT { DIGIT } + +FLOAT_LIT ::= DIGIT { DIGIT } '.' DIGIT { DIGIT } + +TEXT_LIT ::= '"' { CHAR | ESCAPE } '"' + +MULTILINE_TEXT_LIT ::= '"""' { any character } '"""' + +IDENT ::= ( LETTER | '_' ) { LETTER | DIGIT | '_' } + +UPPER_IDENT ::= UPPER_LETTER { LETTER | DIGIT | '_' } + +LETTER ::= 'a'..'z' | 'A'..'Z' + +UPPER_LETTER ::= 'A'..'Z' + +DIGIT ::= '0'..'9' + +CHAR ::= any character except '"' and '\' + +ESCAPE ::= '\' ( 'n' | 'r' | 't' | '\' | '"' | '0' | '$' ) +``` + +--- + +## Keywords + +The following identifiers are reserved keywords: + +``` +and as assert async await break continue +effect effects else ensures enum false fn +for forall if impl import in invariant +let match module mut not or property +public requires return test then trait true +type using while +``` + +--- + +## Operators and Punctuation + +``` ++ - * / % ++= -= *= /= %= +== != < > <= >= +|> -> => .. ..= +( ) { } [ ] +, : = . | ? ?else +_ ??? +``` diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..4de04f5 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,95 @@ +# Astra Performance Characteristics + +## Architecture + +Astra uses a **tree-walking interpreter** written in Rust. Source code is parsed into an +AST and evaluated directly — there is no bytecode compilation step. + +This architecture prioritizes: +- **Fast startup** — no compilation phase means programs run immediately +- **Predictable behavior** — no JIT warmup, no optimization surprises +- **Simple debugging** — errors map directly to source locations +- **Small binary** — the entire toolchain is a single executable + +## Performance Profile + +### What Astra is Good At + +| Workload | Performance | Why | +|----------|-------------|-----| +| Script-sized programs (< 1000 LOC) | Excellent | Startup dominates; interpreter overhead is negligible | +| I/O-bound programs | Excellent | Bottleneck is I/O, not interpretation | +| Development iteration | Excellent | No compilation wait; instant feedback | +| Test suites | Excellent | 150+ tests run in < 1 second | +| Pattern matching | Good | Direct AST dispatch, no indirection | +| String processing | Good | Backed by Rust's String implementation | + +### What Astra is Not Optimized For + +| Workload | Performance | Recommendation | +|----------|-------------|----------------| +| CPU-bound number crunching | Moderate | Use built-in functions (backed by Rust) for hot loops | +| Large data processing (> 1M records) | Moderate | Consider streaming/batching patterns | +| Long-running servers | Good for moderate load | Adequate for development and internal tools | +| Real-time systems | Not suitable | Use a compiled language | + +### Complexity Guarantees + +| Operation | Complexity | Notes | +|-----------|-----------|-------| +| Map get/set/remove | O(log n) | Sorted vector with binary search | +| Map.from / construction | O(n log n) | Sort on construction | +| Set contains/add/remove | O(log n) | Sorted vector with binary search | +| Set.from / construction | O(n log n) | Sort + dedup on construction | +| List append (push) | O(n) | Immutable — creates new list | +| List get (index) | O(1) | Direct indexing | +| List map/filter/fold | O(n) | Single pass | +| Pattern matching | O(patterns) | Linear in number of match arms | +| Module loading | O(1) amortized | Cached after first load | +| String concatenation | O(n + m) | Creates new string | + +### Tail Call Optimization + +Astra optimizes **self-recursive tail calls**. Functions whose last expression is a +call to themselves are optimized to use constant stack space: + +```astra +fn sum_to(n: Int, acc: Int) -> Int { + if n <= 0 { acc } + else { sum_to(n - 1, acc + n) } ## Optimized: no stack growth +} + +sum_to(1000000, 0) ## Works without stack overflow +``` + +Mutual recursion and non-tail calls are not optimized and will use stack space +proportional to call depth. + +## Benchmarking Your Code + +Use the `Clock` effect to measure execution time: + +```astra +fn benchmark(label: Text, f: () -> Unit) -> Unit effects(Console, Clock) { + let start = Clock.now() + f() + let elapsed = Clock.now() - start + Console.println("${label}: ${to_text(elapsed)}ms") +} +``` + +## Design Philosophy + +Astra is designed for **correctness and developer experience** over raw speed: + +1. **Immutable by default** — slightly slower than mutation, but eliminates entire + categories of bugs +2. **Effect tracking** — small overhead for capability checks, but enables sandboxing + and deterministic testing +3. **Contracts checked at runtime** — requires/ensures add overhead, but catch bugs + that types alone cannot +4. **Tree-walking interpreter** — simpler than a VM, easier to debug, adequate for + the intended use cases + +If you need C-level performance for a specific operation, the recommended approach +is to implement it as a built-in function in Rust (see the contributor guide). diff --git a/docs/spec.md b/docs/spec.md index 89e3e78..005bab0 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1,6 +1,7 @@ -# Astra Language Specification (v0.1 Draft) +# Astra Language Specification (v1.0) > This document defines the syntax and semantics of the Astra programming language. +> For the formal grammar, see [grammar.md](grammar.md). ## 1. Lexical Structure @@ -25,206 +26,314 @@ letter := 'a'..'z' | 'A'..'Z' digit := '0'..'9' ``` -Reserved keywords cannot be used as identifiers: +Reserved keywords: ``` -and, as, assert, bool, else, effects, ensures, enum, false, -fn, for, if, import, in, Int, invariant, let, match, module, -mut, not, Option, or, property, public, requires, Result, -return, test, Text, then, true, type, Unit, using, where +and, as, assert, async, await, break, continue, effect, else, +effects, ensures, enum, false, fn, for, forall, if, impl, import, +in, invariant, let, match, module, mut, not, or, property, public, +requires, return, test, then, trait, true, type, using, while ``` ### 1.4 Literals ``` -int_literal := digit+ -bool_literal := 'true' | 'false' -text_literal := '"' * '"' -string_char := | escape_sequence -escape_sequence := '\\' ('n' | 'r' | 't' | '\\' | '"') +int_literal := digit+ +float_literal := digit+ '.' digit+ +bool_literal := 'true' | 'false' +text_literal := '"' string_char* '"' +multiline_text := '"""' * '"""' +string_char := | escape_sequence +escape_sequence := '\\' ('n' | 'r' | 't' | '\\' | '"' | '0' | '$') +unit_literal := '(' ')' ``` ### 1.5 Operators and Punctuation ``` -operators := '+' | '-' | '*' | '/' | '%' | '==' | '!=' | '<' | '>' | '<=' | '>=' - | 'and' | 'or' | 'not' | '?' | '?else' +operators := '+' | '-' | '*' | '/' | '%' + | '==' | '!=' | '<' | '>' | '<=' | '>=' + | 'and' | 'or' | 'not' + | '?' | '?else' | '|>' + | '+=' | '-=' | '*=' | '/=' | '%=' -punctuation := '(' | ')' | '{' | '}' | '[' | ']' | ',' | ':' | '=' | '->' | '=>' | '|' +punctuation := '(' | ')' | '{' | '}' | '[' | ']' + | ',' | ':' | '=' | '->' | '=>' | '|' | '.' + | '..' | '..=' ``` -## 2. Grammar +## 2. Module System -### 2.1 Modules +### 2.1 Module Declaration -``` -module := 'module' module_path item* -module_path := identifier ('.' identifier)* +Every Astra source file begins with a module declaration: -item := import_decl | type_def | enum_def | fn_def | test_block | property_block +```astra +module examples.mymodule ``` ### 2.2 Imports +```astra +import foo.bar ## Whole module +import foo.bar as Alias ## Aliased import +import foo.bar.{A, B, C} ## Selective import +public import foo.bar ## Re-export ``` -import_decl := 'import' module_path ('as' identifier)? - | 'import' module_path '.{' identifier (',' identifier)* '}' + +Standard library modules are imported via `std.*`: +```astra +import std.datetime +import std.path.{basename, extension} ``` -### 2.3 Type Definitions +## 3. Type System + +### 3.1 Built-in Types +| Type | Description | Default value | +|------|-------------|---------------| +| `Int` | 64-bit signed integer | `0` | +| `Float` | 64-bit floating point | `0.0` | +| `Bool` | Boolean | `false` | +| `Text` | UTF-8 string | `""` | +| `Unit` | Unit type (empty tuple) | `()` | + +### 3.2 Compound Types + +| Type | Description | Literal syntax | +|------|-------------|----------------| +| `List[T]` | Ordered collection | `[1, 2, 3]` | +| `Tuple` | Fixed-size mixed collection | `(1, "hello", true)` | +| `Map[K, V]` | Key-value pairs (sorted) | `Map.from([(k, v)])` | +| `Set[T]` | Unique values (sorted) | `Set.from([1, 2, 3])` | +| `Record` | Named fields | `{ name = "Alice", age = 30 }` | +| `Option[T]` | Optional value: `Some(T)` or `None` | `Some(42)`, `None` | +| `Result[T, E]` | Success or error: `Ok(T)` or `Err(E)` | `Ok(42)`, `Err("fail")` | +| `(T) -> U` | Function type | `fn(x: Int) -> Int { x + 1 }` | + +### 3.3 User-Defined Types + +#### Type Aliases +```astra +type UserId = Int +type Percentage = Int + invariant self >= 0 and self <= 100 ``` -type_def := 'type' identifier type_params? '=' type_expr invariant_clause? -type_params := '[' identifier (',' identifier)* ']' -invariant_clause := 'invariant' expr -type_expr := named_type | record_type | function_type -named_type := identifier type_args? -type_args := '[' type_expr (',' type_expr)* ']' -record_type := '{' field_def (',' field_def)* '}' -field_def := identifier ':' type_expr -function_type := '(' type_expr (',' type_expr)* ')' '->' type_expr effects_clause? +#### Enums +```astra +enum Shape = + | Circle(radius: Float) + | Rectangle(width: Float, height: Float) + | Point ``` -### 2.4 Enum Definitions +#### Traits +```astra +trait Describable { + fn describe(self: Text) -> Text +} -``` -enum_def := 'enum' identifier type_params? '=' variant ('|' variant)* -variant := identifier variant_data? -variant_data := '(' field_def (',' field_def)* ')' +impl Describable for Int { + fn describe(self: Text) -> Text { "an integer" } +} ``` -### 2.5 Function Definitions +### 3.4 Generics -``` -fn_def := visibility? 'fn' identifier '(' params? ')' return_type? effects_clause? contract_clauses? block +Functions and types support type parameters: -visibility := 'public' -params := param (',' param)* -param := identifier ':' type_expr -return_type := '->' type_expr -effects_clause := 'effects' '(' identifier (',' identifier)* ')' -contract_clauses := requires_clause* ensures_clause* -requires_clause := 'requires' expr -ensures_clause := 'ensures' expr +```astra +fn identity[T](x: T) -> T { x } +fn apply[T, U](x: T, f: (T) -> U) -> U { f(x) } +fn show[T: Show](x: T) -> Text { x.describe() } ## With trait bound ``` -### 2.6 Statements +### 3.5 Type Inference + +- Public function signatures require explicit type annotations +- Local variables can omit type annotations when inferrable +- Lambda parameters can omit types: `fn(x) { x + 1 }` +- Constraint-based unification resolves generic type parameters + +## 4. Expressions + +### 4.1 Operator Precedence (lowest to highest) + +| Precedence | Operators | Associativity | +|-----------|-----------|---------------| +| 1 | `\|>` (pipe) | Left | +| 2 | `or` | Left | +| 3 | `and` | Left | +| 4 | `==`, `!=`, `<`, `>`, `<=`, `>=` | None | +| 5 | `+`, `-` | Left | +| 6 | `*`, `/`, `%` | Left | +| 7 | `not`, `-` (negation) | Prefix | +| 8 | `.` (field/method), `[i]` (index), `?`, `?else` | Postfix | + +### 4.2 Control Flow +```astra +## If expression (always produces a value) +let x = if cond { a } else { b } + +## Match expression with exhaustiveness checking +match shape { + Circle(r) => 3.14 * r * r + Rectangle(w, h) => w * h + Point => 0.0 +} + +## Match with guard clauses +match n { + x if x > 0 => "positive" + x if x < 0 => "negative" + _ => "zero" +} + +## For-in loop +for item in list { process(item) } +for (key, value) in pairs { println("${key}: ${value}") } + +## While loop +while condition { body } + +## Break, continue, return +break +continue +return value ``` -stmt := let_stmt | assign_stmt | return_stmt | expr_stmt -let_stmt := 'let' 'mut'? identifier type_annotation? '=' expr -type_annotation := ':' type_expr -assign_stmt := expr '=' expr -return_stmt := 'return' expr? -expr_stmt := expr +### 4.3 String Interpolation + +```astra +let name = "world" +let greeting = "Hello, ${name}!" +let computed = "sum = ${a + b}" ``` -### 2.7 Expressions +Escape sequences: `\n`, `\r`, `\t`, `\\`, `\"`, `\0`, `\$` +### 4.4 Range Expressions + +```astra +let exclusive = 0..10 ## [0, 1, 2, ..., 9] +let inclusive = 0..=10 ## [0, 1, 2, ..., 10] ``` -expr := or_expr -or_expr := and_expr ('or' and_expr)* -and_expr := cmp_expr ('and' cmp_expr)* -cmp_expr := add_expr (cmp_op add_expr)? -cmp_op := '==' | '!=' | '<' | '>' | '<=' | '>=' -add_expr := mul_expr (('+' | '-') mul_expr)* -mul_expr := unary_expr (('*' | '/' | '%') unary_expr)* -unary_expr := 'not' unary_expr | postfix_expr -postfix_expr := primary_expr (call_args | field_access | method_call | try_expr)* -call_args := '(' (expr (',' expr)*)? ')' -field_access := '.' identifier -method_call := '.' identifier call_args -try_expr := '?' | '?else' expr +### 4.5 Index Access -primary_expr := int_literal | bool_literal | text_literal - | identifier | qualified_name - | record_expr | enum_expr - | if_expr | match_expr | block - | '(' expr ')' +```astra +list[0] ## List indexing (supports negative: list[-1]) +map[key] ## Map lookup (error if key not found) +text[i] ## Character indexing +tuple.0 ## Tuple field access +``` -qualified_name := identifier '.' identifier -record_expr := '{' field_init (',' field_init)* '}' -field_init := identifier '=' expr -enum_expr := identifier ('(' expr ')')? +### 4.6 Pipe Operator -if_expr := 'if' expr block ('else' (if_expr | block))? -match_expr := 'match' expr '{' match_arm (',' match_arm)* '}' -match_arm := pattern '=>' expr -block := '{' stmt* expr? '}' +```astra +data |> transform |> validate |> save +## Equivalent to: save(validate(transform(data))) ``` -### 2.8 Patterns +### 4.7 Error Propagation +```astra +let value = maybe_none()? ## Propagates None to caller +let value = maybe_err()? ## Propagates Err to caller +let value = risky()?else default ## Use default on failure ``` -pattern := '_' | identifier | literal_pattern | record_pattern | enum_pattern -literal_pattern := int_literal | bool_literal | text_literal -record_pattern := '{' field_pattern (',' field_pattern)* '}' -field_pattern := identifier ('=' pattern)? -enum_pattern := identifier ('(' pattern ')')? + +### 4.8 Lambdas + +```astra +fn(x: Int) -> Int { x * 2 } +fn(x) { x * 2 } ## Type inference +list.map(fn(x) { x + 1 }) ``` -### 2.9 Tests and Properties +### 4.9 Hole Expression +```astra +let x = ??? ## Placeholder; type-checks but errors at runtime (E4013) ``` -test_block := 'test' text_literal using_clause? block -property_block := 'property' text_literal using_clause? block -using_clause := 'using' 'effects' '(' effect_binding (',' effect_binding)* ')' -effect_binding := identifier '=' expr +## 5. Statements + +```astra +let x = 42 ## Immutable binding +let mut y = 0 ## Mutable binding +let { name, age } = person ## Destructuring +y = y + 1 ## Assignment (mutable only) +y += 1 ## Compound assignment (+=, -=, *=, /=, %=) +return value ## Early return ``` -## 3. Type System +## 6. Function Definitions -### 3.1 Built-in Types +```astra +public fn divide(a: Int, b: Int) -> Int + effects(Console) + requires b != 0 + ensures result >= 0 +{ + Console.println("dividing") + a / b +} +``` -| Type | Description | -|------|-------------| -| `Int` | 64-bit signed integer | -| `Bool` | Boolean (true/false) | -| `Text` | UTF-8 string | -| `Unit` | Unit type (empty tuple) | -| `Option[T]` | Optional value | -| `Result[T, E]` | Success or error | +Components: +- `public` — visibility modifier (default is private) +- Type parameters: `fn identity[T](x: T) -> T` +- `effects(...)` — declares required capabilities +- `requires` — precondition (checked before execution) +- `ensures` — postcondition (`result` refers to return value) -### 3.2 Type Inference +## 7. Effects System -- Type inference is performed within function bodies -- Public function signatures require explicit type annotations -- Local variables can omit type annotations when inferrable +### 7.1 Built-in Effects -### 3.3 Type Checking Rules +| Effect | Methods | +|--------|---------| +| `Console` | `print(text)`, `println(text)`, `read_line()` | +| `Fs` | `read(path)`, `write(path, content)`, `exists(path)` | +| `Net` | `get(url)`, `post(url, body)`, `serve(port, handler)` | +| `Clock` | `now()`, `today()`, `sleep(millis)` | +| `Rand` | `int(min, max)`, `bool()`, `float()` | +| `Env` | `get(name)`, `args()` | -1. All expressions have a type -2. Function arguments must match parameter types exactly -3. Match expressions must be exhaustive over enum variants -4. The `?` operator requires `Option` or `Result` type -5. Effects must be declared in function signature +### 7.2 Effect Rules -## 4. Effects System +1. **Pure by default** — functions without `effects(...)` cannot use I/O +2. **Declaration required** — effectful operations require declared effects +3. **Transitive propagation** — callers must declare all effects of callees +4. **Testable** — effects can be mocked in test blocks with `using effects(...)` -### 4.1 Built-in Effects +### 7.3 User-Defined Effects -| Effect | Capability Module | Description | -|--------|-------------------|-------------| -| `Net` | `capabilities.net` | Network I/O | -| `Fs` | `capabilities.fs` | Filesystem | -| `Clock` | `capabilities.clock` | Time access | -| `Rand` | `capabilities.rand` | Randomness | -| `Env` | `capabilities.env` | Environment | -| `Console` | `capabilities.console` | Console I/O | +```astra +effect Logger { + fn log(msg: Text) -> Unit +} +``` -### 4.2 Effect Rules +### 7.4 Deterministic Testing -1. Functions are pure by default -2. Effectful operations require declared effects -3. Callers must declare all effects of callees -4. Effects can be injected in tests +```astra +test "seeded random" using effects(Rand = Rand.seeded(42)) { + let x = Rand.int(1, 100) + assert(x > 0) +} -## 5. Contracts +test "fixed clock" using effects(Clock = Clock.fixed(1000)) { + assert_eq(Clock.now(), 1000) +} +``` -### 5.1 Preconditions +## 8. Contracts + +### 8.1 Preconditions ```astra fn divide(a: Int, b: Int) -> Int @@ -234,132 +343,114 @@ fn divide(a: Int, b: Int) -> Int } ``` -### 5.2 Postconditions +Violated preconditions produce error E3001. + +### 8.2 Postconditions ```astra fn abs(n: Int) -> Int ensures result >= 0 { - if n < 0 { -n } else { n } + if n < 0 { 0 - n } else { n } } ``` -### 5.3 Type Invariants +Violated postconditions produce error E3002. + +### 8.3 Type Invariants ```astra type PositiveInt = Int invariant self > 0 ``` -## 6. Standard Library - -See [Standard Library Reference](stdlib.md) for the full API documentation. - -### 6.1 Built-in Types +Violated invariants produce error E3003. -| Type | Variants | Description | -|------|----------|-------------| -| `Int` | — | 64-bit signed integer | -| `Bool` | `true`, `false` | Boolean | -| `Text` | — | UTF-8 string | -| `Unit` | — | Empty type | -| `Option[T]` | `Some(T)`, `None` | Optional value | -| `Result[T, E]` | `Ok(T)`, `Err(E)` | Success or error | -| `List[T]` | — | Ordered collection | +## 9. Pattern Matching -### 6.2 Built-in Functions +### 9.1 Pattern Types -| Function | Signature | Description | -|----------|-----------|-------------| -| `print` | `(Text) -> Unit` | Print text (requires Console) | -| `println` | `(Text) -> Unit` | Print text with newline (requires Console) | -| `assert` | `(Bool) -> Unit` | Assert condition is true | -| `assert_eq` | `(T, T) -> Unit` | Assert two values are equal | -| `len` | `(List[T]) -> Int` | Get length of a list | -| `to_text` | `(T) -> Text` | Convert value to text | +| Pattern | Example | Binds | +|---------|---------|-------| +| Wildcard | `_` | Nothing | +| Identifier | `x` | Value to `x` | +| Literal | `42`, `true`, `"hello"` | Nothing | +| Record | `{ name, age }` | Fields to variables | +| Variant | `Some(x)`, `None` | Inner value | +| Tuple | `(a, b, c)` | Elements to variables | +| Guard | `x if x > 0` | Value to `x` if guard passes | -### 6.3 std.option +### 9.2 Exhaustiveness -| Function | Signature | -|----------|-----------| -| `is_some` | `(Option[T]) -> Bool` | -| `is_none` | `(Option[T]) -> Bool` | -| `unwrap_or` | `(Option[T], T) -> T` | -| `map` | `(Option[T], (T) -> U) -> Option[U]` | +Match expressions on known types (Option, Result, Bool, user enums) must +cover all variants. Missing cases produce error E1004. -### 6.4 std.result +### 9.3 Destructuring -| Function | Signature | -|----------|-----------| -| `is_ok` | `(Result[T, E]) -> Bool` | -| `is_err` | `(Result[T, E]) -> Bool` | -| `unwrap_or` | `(Result[T, E], T) -> T` | -| `map` | `(Result[T, E], (T) -> U) -> Result[U, E]` | -| `map_err` | `(Result[T, E], (E) -> F) -> Result[T, F]` | +Patterns work in `let` bindings and `for` loops: -### 6.5 std.list - -| Function | Signature | -|----------|-----------| -| `is_empty` | `(List[T]) -> Bool` | -| `head` | `(List[T]) -> Option[T]` | - -### 6.6 Error Propagation Operators - -- `?` — Propagates `None` or `Err(e)` to the calling function's return value -- `?else expr` — Provides a fallback value when `?` would propagate - -## 7. Evaluation Semantics - -### 7.1 Evaluation Order - -- Expressions are evaluated left-to-right -- Function arguments are evaluated before the call -- Short-circuit evaluation for `and` and `or` - -### 7.2 Pattern Matching - -- Patterns are matched top-to-bottom -- First matching arm is executed -- Non-exhaustive matches are compile errors +```astra +let { name, age } = person +let (x, y) = point +for (key, value) in map.entries() { ... } +``` -### 7.3 Error Propagation +## 10. Testing -- `?` on `None` returns `None` from function -- `?` on `Err(e)` returns `Err(e)` from function -- `?else expr` provides fallback on failure +```astra +test "addition" { + assert_eq(1 + 1, 2) +} -## 8. Diagnostics and Linting +property "list reverse is involutory" + using effects(Rand = Rand.seeded(42)) +{ + let xs = [Rand.int(0, 100), Rand.int(0, 100), Rand.int(0, 100)] + assert_eq(xs.reverse().reverse(), xs) +} +``` -### 8.1 Diagnostic Model +## 11. Diagnostics and Linting -All compiler output uses structured diagnostics with: -- Stable error code (`E####` for errors, `W####` for warnings) -- Severity level: `error`, `warning`, `info`, `hint` -- Source span with file, line, and column -- Optional notes and suggested fixes -- JSON output via `--json` flag +### 11.1 Error Code Categories -### 8.2 Built-in Lint Checks +| Range | Category | Example | +|-------|----------|---------| +| E0xxx | Syntax/parsing | E0001: Unexpected token | +| E1xxx | Type errors | E1001: Type mismatch | +| E2xxx | Effect errors | E2001: Effect not declared | +| E3xxx | Contract violations | E3001: Precondition violated | +| E4xxx | Runtime errors | E4003: Division by zero | +| W0xxx | Warnings | W0001: Unused variable | -The type checker emits warnings for common issues. Warnings do not prevent compilation unless `--strict` mode is enabled. +### 11.2 Built-in Lint Checks | Code | Description | |------|-------------| | W0001 | Unused variable (suppress with `_` prefix) | | W0002 | Unused import | | W0003 | Unreachable code after `return` | -| W0004 | Deprecated feature (reserved) | | W0005 | Wildcard pattern on known exhaustive type | | W0006 | Shadowed binding in same scope | -| W0007 | Redundant type annotation (reserved) | +| W0008 | Unused private function | -### 8.3 Strict Mode +### 11.3 Strict Mode -`astra check --strict` treats all warnings as errors. The check exits with a non-zero status if any warnings are present. This is intended for CI and production use. +`astra check --strict` treats all warnings as errors. -Strictness can also be configured in `astra.toml`: -```toml -[lint] -level = "deny" -``` +## 12. Standard Library + +See [stdlib.md](stdlib.md) for the complete API reference. + +15 modules: `std.core`, `std.prelude`, `std.option`, `std.result`, `std.error`, +`std.list`, `std.collections`, `std.iter`, `std.string`, `std.math`, `std.io`, +`std.json`, `std.regex`, `std.datetime`, `std.path`. + +## 13. Future Work (v1.1) + +The following features are syntactically reserved but not included in v1.0: + +- **Async/await** — `async fn` and `await` expressions +- **Package manager** — `astra pkg` for dependency management + +See [ADR-007](adr/ADR-007-defer-async-pkg-to-v1.1.md) for rationale. diff --git a/docs/stability.md b/docs/stability.md new file mode 100644 index 0000000..67a6d75 --- /dev/null +++ b/docs/stability.md @@ -0,0 +1,67 @@ +# Astra v1.0 Stability Guarantee + +## What This Means + +Starting with v1.0, Astra makes the following stability promises: + +### Language Stability + +1. **No breaking syntax changes.** All programs that compile and run under v1.0 will + continue to compile and run under any v1.x release. + +2. **Error codes are stable.** Error codes (E0xxx through E4xxx and W0xxx) will not be + removed or change their meaning. New error codes may be added. + +3. **Standard library is stable.** Public functions and types in `std.*` modules will + not be removed or change their signatures. New functions and modules may be added. + +4. **Effect names are stable.** The built-in effects (Console, Fs, Net, Clock, Rand, Env) + and their methods will not be removed or change their signatures. New effects and + methods may be added. + +### Tooling Stability + +5. **CLI interface is stable.** The commands `fmt`, `check`, `test`, `run`, `init`, `doc`, + `fix`, and `explain` will not change their behavior in breaking ways. New commands and + flags may be added. + +6. **JSON output is stable.** The `--json` flag produces structured output whose schema + will not change in breaking ways. New fields may be added. + +7. **Formatter output is stable.** The canonical format produced by `astra fmt` will not + change within a minor version series (v1.0.x). It may change between minor versions + (v1.1, v1.2) with clear documentation. + +### What Is NOT Guaranteed + +- **Performance characteristics** may change (generally for the better). +- **Error message wording** may change (error codes remain stable). +- **Internal APIs** (anything not `public`) may change. +- **Compiler/interpreter internals** may change. +- **Features marked v1.1** (async/await, package manager) are experimental and not + covered by this guarantee. + +## Versioning Scheme + +Astra follows [Semantic Versioning](https://semver.org/): + +- **MAJOR** (1.x.x → 2.0.0): Breaking changes to the language or standard library. + These will be rare and well-documented with migration guides. +- **MINOR** (1.0.x → 1.1.0): New features, new standard library modules, new error + codes. Backward compatible. +- **PATCH** (1.0.0 → 1.0.1): Bug fixes, performance improvements, documentation + updates. Backward compatible. + +## Deprecation Policy + +When a feature needs to be replaced: + +1. The old feature is **deprecated** with a W0xxx warning for at least one minor version. +2. The `astra fix` command provides automatic migration where possible. +3. The old feature is removed only in the next **major** version. +4. A migration guide is published with every major version. + +## Reporting Stability Issues + +If you believe a release violates this stability guarantee, please report it as a bug. +Unintentional breakage will be treated as high-priority and patched promptly. diff --git a/examples/calculator.astra b/examples/calculator.astra new file mode 100644 index 0000000..5bba461 --- /dev/null +++ b/examples/calculator.astra @@ -0,0 +1,214 @@ +module examples.calculator + +## A simple arithmetic expression evaluator. +## Demonstrates enum types, pattern matching, recursion, and Result error handling. + +## Tokens produced by the lexer. +enum Token = + | TNum(value: Int) + | TPlus + | TMinus + | TMul + | TDiv + | TLParen + | TRParen + +## Abstract syntax tree for arithmetic expressions. +enum Expr = + | Num(value: Int) + | Add(left: Expr, right: Expr) + | Sub(left: Expr, right: Expr) + | Mul(left: Expr, right: Expr) + | Div(left: Expr, right: Expr) + | Neg(inner: Expr) + +## Evaluates an expression tree, returning an error on division by zero. +fn eval(expr: Expr) -> Result[Int, Text] +{ + match expr { + Num(n) => Ok(n) + Neg(inner) => { + let val = eval(inner)? + Ok(0 - val) + } + Add(left, right) => eval_binop(left, right, "add") + Sub(left, right) => eval_binop(left, right, "sub") + Mul(left, right) => eval_binop(left, right, "mul") + Div(left, right) => { + let lv = eval(left)? + let rv = eval(right)? + if rv == 0 { + Err("division by zero") + } else { + Ok(lv / rv) + } + } + } +} + +## Evaluates a binary operation given left/right expressions and an operator name. +fn eval_binop(left: Expr, right: Expr, op: Text) -> Result[Int, Text] +{ + let lv = eval(left)? + let rv = eval(right)? + if op == "add" { + Ok(lv + rv) + } else if op == "sub" { + Ok(lv - rv) + } else { + Ok(lv * rv) + } +} + +## Builds an expression from a description string and evaluates it. +## Supports simple cases for demonstration purposes. +fn describe(expr: Expr) -> Text +{ + match eval(expr) { + Ok(n) => "result: ${n}" + Err(e) => "error: ${e}" + } +} + +## Checks if an expression is a number with a specific value. +fn is_num(expr: Expr, n: Int) -> Bool +{ + match expr { + Num(v) => v == n + other => false + } +} + +## Simplifies an addition expression. +fn simplify_add(left: Expr, right: Expr) -> Expr +{ + let sl = simplify(left) + let sr = simplify(right) + if is_num(sl, 0) { + sr + } else if is_num(sr, 0) { + sl + } else { + Add(sl, sr) + } +} + +## Simplifies a subtraction expression. +fn simplify_sub(left: Expr, right: Expr) -> Expr +{ + let sl = simplify(left) + let sr = simplify(right) + if is_num(sr, 0) { + sl + } else { + Sub(sl, sr) + } +} + +## Simplifies a multiplication expression. +fn simplify_mul(left: Expr, right: Expr) -> Expr +{ + let sl = simplify(left) + let sr = simplify(right) + if is_num(sl, 0) or is_num(sr, 0) { + Num(0) + } else if is_num(sl, 1) { + sr + } else if is_num(sr, 1) { + sl + } else { + Mul(sl, sr) + } +} + +## Simplifies a division expression. +fn simplify_div(left: Expr, right: Expr) -> Expr +{ + let sl = simplify(left) + let sr = simplify(right) + if is_num(sl, 0) { + Num(0) + } else if is_num(sr, 1) { + sl + } else { + Div(sl, sr) + } +} + +## Simplifies an expression by folding constant sub-expressions. +fn simplify(expr: Expr) -> Expr +{ + match expr { + Num(n) => Num(n) + Neg(inner) => { + let s = simplify(inner) + match s { + Num(v) => Num(0 - v) + other => Neg(other) + } + } + Add(left, right) => simplify_add(left, right) + Sub(left, right) => simplify_sub(left, right) + Mul(left, right) => simplify_mul(left, right) + Div(left, right) => simplify_div(left, right) + } +} + +fn main() + effects(Console) +{ + ## (3 + 5) * 2 + let expr1 = Mul(Add(Num(3), Num(5)), Num(2)) + Console.println("(3 + 5) * 2 = ${describe(expr1)}") + + ## 10 / (5 - 5) => division by zero + let expr2 = Div(Num(10), Sub(Num(5), Num(5))) + Console.println("10 / (5 - 5) = ${describe(expr2)}") + + ## Simplify: (x + 0) * 1 => x + let expr3 = Mul(Add(Num(7), Num(0)), Num(1)) + let simplified = simplify(expr3) + Console.println("simplified (7 + 0) * 1 = ${describe(simplified)}") +} + +test "eval simple addition" { + assert_eq(eval(Add(Num(2), Num(3))), Ok(5)) +} + +test "eval nested expression" { + ## (3 + 5) * 2 = 16 + let expr = Mul(Add(Num(3), Num(5)), Num(2)) + assert_eq(eval(expr), Ok(16)) +} + +test "eval division by zero returns error" { + let expr = Div(Num(10), Num(0)) + assert_eq(eval(expr), Err("division by zero")) +} + +test "eval negation" { + assert_eq(eval(Neg(Num(42))), Ok(-42)) +} + +test "eval subtraction and division" { + ## (20 - 8) / 3 = 4 + let expr = Div(Sub(Num(20), Num(8)), Num(3)) + assert_eq(eval(expr), Ok(4)) +} + +test "simplify addition with zero" { + let expr = Add(Num(0), Num(5)) + assert_eq(eval(simplify(expr)), Ok(5)) +} + +test "simplify multiplication by one" { + let expr = Mul(Num(7), Num(1)) + let simplified = simplify(expr) + assert_eq(eval(simplified), Ok(7)) +} + +test "simplify multiplication by zero" { + let expr = Mul(Num(99), Num(0)) + let simplified = simplify(expr) + assert_eq(eval(simplified), Ok(0)) +} diff --git a/examples/text_analyzer.astra b/examples/text_analyzer.astra new file mode 100644 index 0000000..9c82328 --- /dev/null +++ b/examples/text_analyzer.astra @@ -0,0 +1,181 @@ +module examples.text_analyzer + +## A text analysis utility demonstrating string methods, map operations, +## higher-order functions, and the pipe operator. + +## Splits text into words, normalizing to lowercase and stripping whitespace. +fn tokenize(text: Text) -> List[Text] +{ + text.to_lower().split(" ").filter(fn(w) { w.len() > 0 }) +} + +## Counts the total number of words in the text. +fn word_count(text: Text) -> Int +{ + tokenize(text).len() +} + +## Counts the number of unique words in the text. +fn unique_word_count(text: Text) -> Int +{ + let words = tokenize(text) + let word_set = Set.from(words) + word_set.len() +} + +## Builds a frequency map of word occurrences. +fn word_frequencies(text: Text) -> Map[Text, Int] +{ + let words = tokenize(text) + words.fold(Map.new(), fn(counts, word) { + let current = match counts.get(word) { + Some(n) => n + None => 0 + } + counts.set(word, current + 1) + }) +} + +## Finds the most frequent word. Returns None for empty text. +fn most_frequent(text: Text) -> Option[Text] +{ + let freqs = word_frequencies(text) + let entries = freqs.entries() + if entries.len() == 0 { + None + } else { + let mut best_word = "" + let mut best_count = 0 + for entry in entries { + let word = entry.0 + let count = entry.1 + if count > best_count { + best_word = word + best_count = count + } + } + Some(best_word) + } +} + +## Returns the average word length (integer division). +fn avg_word_length(text: Text) -> Int +{ + let words = tokenize(text) + if words.len() == 0 { + 0 + } else { + let total = words.fold(0, fn(sum, word) { sum + word.len() }) + total / words.len() + } +} + +## Returns words that are longer than the given threshold. +fn long_words(text: Text, min_length: Int) -> List[Text] +{ + tokenize(text).filter(fn(w) { w.len() >= min_length }) +} + +## Checks whether the text contains a given search term (case-insensitive). +fn search(text: Text, term: Text) -> Bool +{ + text.to_lower().contains(term.to_lower()) +} + +## Counts the number of sentences (splits on period). +fn sentence_count(text: Text) -> Int +{ + text.split(".").filter(fn(s) { s.trim().len() > 0 }).len() +} + +## Generates a full analysis report for the given text. +fn analyze(text: Text) -> Text +{ + let wc = word_count(text) + let uwc = unique_word_count(text) + let avg = avg_word_length(text) + let sc = sentence_count(text) + let top = match most_frequent(text) { + Some(w) => w + None => "(none)" + } + "Words: ${wc} | Unique: ${uwc} | Avg length: ${avg} | Sentences: ${sc} | Top word: ${top}" +} + +fn main() + effects(Console) +{ + let sample = "the quick brown fox jumps over the lazy dog. the dog barked at the fox." + + Console.println("=== Text Analyzer ===") + Console.println("Input: ${sample}") + Console.println("") + Console.println(analyze(sample)) + + Console.println("") + Console.println("Long words (5+ chars):") + let long = long_words(sample, 5) + for word in long { + Console.println(" - ${word}") + } + + Console.println("") + let freqs = word_frequencies(sample) + Console.println("Word frequencies:") + for entry in freqs.entries() { + Console.println(" ${entry.0}: ${entry.1}") + } +} + +test "word count" { + assert_eq(word_count("hello world"), 2) + assert_eq(word_count("one two three four"), 4) + assert_eq(word_count(""), 0) +} + +test "unique word count" { + assert_eq(unique_word_count("the cat and the dog"), 4) + assert_eq(unique_word_count("aaa aaa aaa"), 1) +} + +test "word frequencies" { + let freqs = word_frequencies("go go go stop") + assert_eq(freqs.get("go"), Some(3)) + assert_eq(freqs.get("stop"), Some(1)) +} + +test "most frequent word" { + assert_eq(most_frequent("yes no yes yes no"), Some("yes")) + assert_eq(most_frequent(""), None) +} + +test "average word length" { + ## "ab cd ef" => lengths 2,2,2 => avg 2 + assert_eq(avg_word_length("ab cd ef"), 2) + assert_eq(avg_word_length(""), 0) +} + +test "long words filter" { + let result = long_words("i am extraordinarily happy today", 5) + assert_eq(result.len(), 3) + assert(result.contains("extraordinarily")) + assert(result.contains("happy")) + assert(result.contains("today")) +} + +test "case insensitive search" { + assert_eq(search("Hello World", "hello"), true) + assert_eq(search("Hello World", "xyz"), false) +} + +test "sentence count" { + assert_eq(sentence_count("One. Two. Three."), 3) + assert_eq(sentence_count("No periods here"), 1) +} + +test "analyze produces report" { + let report = analyze("the cat sat on the mat") + assert(report.contains("Words: 6")) + assert(report.contains("Unique: 5")) + assert(report.contains("Top word: the")) +} diff --git a/examples/todo_app.astra b/examples/todo_app.astra new file mode 100644 index 0000000..f847b36 --- /dev/null +++ b/examples/todo_app.astra @@ -0,0 +1,234 @@ +module examples.todo_app + +## A todo list application demonstrating records, enums, list operations, +## the effect system, and contracts. + +enum Priority = + | Low + | Medium + | High + | Urgent + +enum Status = + | Pending + | InProgress + | Done + +## A single todo item with an id, title, priority, and status. +type TodoItem = { + id: Int, + title: Text, + priority: Priority, + status: Status, +} + +## Returns a human-readable label for a priority level. +fn priority_label(p: Priority) -> Text +{ + match p { + Low => "low" + Medium => "medium" + High => "high" + Urgent => "URGENT" + } +} + +## Returns a display character for a status. +fn status_icon(s: Status) -> Text +{ + match s { + Pending => " " + InProgress => "~" + Done => "x" + } +} + +## Creates a new todo item, ensuring the title is non-empty. +fn create_todo(id: Int, title: Text, priority: Priority) -> Result[TodoItem, Text] + requires id > 0 +{ + if title.len() == 0 { + Err("title cannot be empty") + } else { + Ok({ + id = id, + title = title, + priority = priority, + status = Pending, + }) + } +} + +## Transitions a todo item to a new status. +fn set_status(item: TodoItem, new_status: Status) -> TodoItem +{ + { + id = item.id, + title = item.title, + priority = item.priority, + status = new_status, + } +} + +## Formats a single todo item for display. +fn format_item(item: TodoItem) -> Text +{ + let icon = status_icon(item.status) + let prio = priority_label(item.priority) + "[${icon}] #${item.id} (${prio}) ${item.title}" +} + +## Returns a numeric rank for a status, used for equality comparison. +fn status_rank(s: Status) -> Int +{ + match s { + Pending => 0 + InProgress => 1 + Done => 2 + } +} + +## Returns a numeric rank for a priority, used for equality comparison. +fn priority_rank(p: Priority) -> Int +{ + match p { + Low => 0 + Medium => 1 + High => 2 + Urgent => 3 + } +} + +## Counts items matching a given status. +fn count_by_status(items: List[TodoItem], target: Status) -> Int +{ + let target_rank = status_rank(target) + items.filter(fn(item) { + status_rank(item.status) == target_rank + }).len() +} + +## Returns only items with the given priority. +fn filter_by_priority(items: List[TodoItem], target: Priority) -> List[TodoItem] +{ + let target_rank = priority_rank(target) + items.filter(fn(item) { + priority_rank(item.priority) == target_rank + }) +} + +## Builds a summary report of all todos. +fn summary(items: List[TodoItem]) -> Text + ensures result.len() > 0 +{ + let total = items.len() + let done = count_by_status(items, Done) + let pending = count_by_status(items, Pending) + let in_progress = count_by_status(items, InProgress) + "Total: ${total} | Done: ${done} | In Progress: ${in_progress} | Pending: ${pending}" +} + +## Prints all todos and a summary to the console. +fn print_todos(items: List[TodoItem]) + effects(Console) +{ + Console.println("=== Todo List ===") + for item in items { + Console.println(" ${format_item(item)}") + } + Console.println(summary(items)) +} + +fn main() + effects(Console) +{ + let t1 = create_todo(1, "Write parser module", High) + let t2 = create_todo(2, "Add unit tests", Medium) + let t3 = create_todo(3, "Update README", Low) + let t4 = create_todo(4, "Fix critical bug", Urgent) + + let mut todos = [] + for result in [t1, t2, t3, t4] { + match result { + Ok(item) => { todos = todos + [item] } + Err(e) => Console.println("Error: ${e}") + } + } + + ## Mark some items as done or in progress + todos = todos.map(fn(item) { + if item.id == 1 { + set_status(item, Done) + } else if item.id == 4 { + set_status(item, InProgress) + } else { + item + } + }) + + print_todos(todos) + + Console.println("\nHigh priority items:") + let high = filter_by_priority(todos, High) + for item in high { + Console.println(" ${format_item(item)}") + } +} + +test "create todo succeeds with valid input" { + let result = create_todo(1, "Test task", Medium) + match result { + Ok(item) => { + assert_eq(item.id, 1) + assert_eq(item.title, "Test task") + } + Err(e) => assert(false) + } +} + +test "create todo fails with empty title" { + let result = create_todo(1, "", Low) + assert_eq(result, Err("title cannot be empty")) +} + +test "set status changes item status" { + let item = { + id = 1, + title = "task", + priority = Low, + status = Pending, + } + let updated = set_status(item, Done) + assert_eq(status_icon(updated.status), "x") +} + +test "count by status" { + let items = [ + { id = 1, title = "a", priority = Low, status = Done }, + { id = 2, title = "b", priority = Medium, status = Pending }, + { id = 3, title = "c", priority = High, status = Done }, + ] + assert_eq(count_by_status(items, Done), 2) + assert_eq(count_by_status(items, Pending), 1) + assert_eq(count_by_status(items, InProgress), 0) +} + +test "filter by priority" { + let items = [ + { id = 1, title = "a", priority = High, status = Pending }, + { id = 2, title = "b", priority = Low, status = Pending }, + { id = 3, title = "c", priority = High, status = Done }, + ] + let high_items = filter_by_priority(items, High) + assert_eq(high_items.len(), 2) +} + +test "summary includes counts" { + let items = [ + { id = 1, title = "a", priority = Low, status = Done }, + { id = 2, title = "b", priority = Medium, status = Pending }, + ] + let report = summary(items) + assert(report.contains("Total: 2")) + assert(report.contains("Done: 1")) +} diff --git a/install-binary.sh b/install-binary.sh new file mode 100755 index 0000000..c3625c3 --- /dev/null +++ b/install-binary.sh @@ -0,0 +1,130 @@ +#!/bin/sh +# Astra binary installer — downloads a pre-built release from GitHub. +# Usage: +# curl -fsSL https://raw.githubusercontent.com/jaimeam/astra/main/install-binary.sh | sh +# ASTRA_VERSION=v1.0.0 curl -fsSL ... | sh +set -eu + +REPO="jaimeam/astra" +INSTALL_DIR="${ASTRA_HOME:-$HOME/.astra}/bin" + +main() { + detect_platform + get_latest_version + download_and_install + setup_path + verify_install +} + +detect_platform() { + OS="$(uname -s)" + ARCH="$(uname -m)" + + case "$OS" in + Linux) OS_TARGET="unknown-linux-gnu" ;; + Darwin) OS_TARGET="apple-darwin" ;; + *) error "Unsupported OS: $OS. See https://github.com/$REPO/releases for manual download." ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH_TARGET="x86_64" ;; + aarch64|arm64) ARCH_TARGET="aarch64" ;; + *) error "Unsupported architecture: $ARCH" ;; + esac + + TARGET="${ARCH_TARGET}-${OS_TARGET}" + info "Detected platform: $TARGET" +} + +get_latest_version() { + if [ -n "${ASTRA_VERSION:-}" ]; then + VERSION="$ASTRA_VERSION" + else + VERSION="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \ + | grep '"tag_name"' | head -1 | cut -d'"' -f4)" || true + fi + + if [ -z "$VERSION" ]; then + error "Could not determine latest version. Set ASTRA_VERSION to install a specific release." + fi + + info "Installing Astra $VERSION" +} + +download_and_install() { + ARCHIVE="astra-${VERSION}-${TARGET}.tar.gz" + URL="https://github.com/$REPO/releases/download/${VERSION}/${ARCHIVE}" + CHECKSUM_URL="${URL}.sha256" + + TMPDIR="$(mktemp -d)" + trap 'rm -rf "$TMPDIR"' EXIT + + info "Downloading $URL" + curl -fsSL "$URL" -o "$TMPDIR/$ARCHIVE" \ + || error "Download failed. Check that $VERSION has a release for $TARGET at https://github.com/$REPO/releases" + + # Verify checksum if shasum is available + if command -v shasum >/dev/null 2>&1; then + info "Verifying checksum..." + curl -fsSL "$CHECKSUM_URL" -o "$TMPDIR/checksum.sha256" 2>/dev/null || true + if [ -f "$TMPDIR/checksum.sha256" ]; then + EXPECTED="$(awk '{print $1}' "$TMPDIR/checksum.sha256")" + ACTUAL="$(shasum -a 256 "$TMPDIR/$ARCHIVE" | awk '{print $1}')" + if [ "$EXPECTED" != "$ACTUAL" ]; then + error "Checksum mismatch! Expected: $EXPECTED, Got: $ACTUAL" + fi + info "Checksum verified." + fi + fi + + # Extract and install + tar xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR" + mkdir -p "$INSTALL_DIR" + cp "$TMPDIR/astra-${VERSION}-${TARGET}/astra" "$INSTALL_DIR/astra" + chmod +x "$INSTALL_DIR/astra" + info "Installed to $INSTALL_DIR/astra" +} + +setup_path() { + case ":${PATH}:" in + *":$INSTALL_DIR:"*) return ;; + esac + + SHELL_NAME="$(basename "${SHELL:-/bin/sh}")" + case "$SHELL_NAME" in + zsh) PROFILE="$HOME/.zshrc" ;; + bash) PROFILE="$HOME/.bashrc" ;; + fish) PROFILE="$HOME/.config/fish/config.fish" ;; + *) PROFILE="$HOME/.profile" ;; + esac + + EXPORT_LINE="export PATH=\"$INSTALL_DIR:\$PATH\"" + if [ "$SHELL_NAME" = "fish" ]; then + EXPORT_LINE="set -gx PATH $INSTALL_DIR \$PATH" + fi + + if [ -f "$PROFILE" ] && grep -qF "$INSTALL_DIR" "$PROFILE" 2>/dev/null; then + return + fi + + printf '\n# Astra programming language\n%s\n' "$EXPORT_LINE" >> "$PROFILE" + info "Added $INSTALL_DIR to PATH in $PROFILE" + info "Restart your shell or run: $EXPORT_LINE" +} + +verify_install() { + if "$INSTALL_DIR/astra" --version >/dev/null 2>&1; then + VERSION_OUT="$("$INSTALL_DIR/astra" --version)" + info "" + info "Astra installed successfully! ($VERSION_OUT)" + info "Run 'astra init my-project' to get started." + else + warn "Binary installed but could not verify. Restart your shell and try: astra --version" + fi +} + +info() { printf ' \033[1;32m>\033[0m %s\n' "$1"; } +warn() { printf ' \033[1;33m!\033[0m %s\n' "$1"; } +error() { printf ' \033[1;31mx\033[0m %s\n' "$1" >&2; exit 1; } + +main diff --git a/src/interpreter/error.rs b/src/interpreter/error.rs index 1d9c8de..fd3167f 100644 --- a/src/interpreter/error.rs +++ b/src/interpreter/error.rs @@ -226,3 +226,20 @@ pub fn check_arity(args: &[T], expected: usize) -> Result<(), RuntimeError> { Ok(()) } } + +/// A single frame in the call stack, carrying a function name and optional +/// source location for richer stack traces. +#[derive(Debug, Clone)] +pub struct CallFrame { + pub name: String, + pub span: Option, +} + +impl CallFrame { + pub fn new(name: impl Into, span: Option) -> Self { + Self { + name: name.into(), + span, + } + } +} diff --git a/src/interpreter/json.rs b/src/interpreter/json.rs index 2b96e75..66468a6 100644 --- a/src/interpreter/json.rs +++ b/src/interpreter/json.rs @@ -1,7 +1,7 @@ //! JSON parsing and stringifying for Astra values. use super::error::RuntimeError; -use super::value::{format_value, Value}; +use super::value::{format_value, sorted_map_from, Value}; /// Parse a JSON string into an Astra Value pub(super) fn json_parse_value(input: &str) -> Result { @@ -230,7 +230,7 @@ fn json_parse_object(input: &str) -> Result<(Value, &str), RuntimeError> { } } - Ok((Value::Map(entries), rest)) + Ok((Value::Map(sorted_map_from(entries)), rest)) } fn json_parse_array(input: &str) -> Result<(Value, &str), RuntimeError> { diff --git a/src/interpreter/methods.rs b/src/interpreter/methods.rs index a3b678e..dafdab1 100644 --- a/src/interpreter/methods.rs +++ b/src/interpreter/methods.rs @@ -7,7 +7,10 @@ use std::collections::HashMap; use super::error::{check_arity, RuntimeError}; use super::regex::{regex_find_all, regex_is_match, regex_match, regex_replace, regex_split}; -use super::value::{compare_values, format_value, values_equal, Value}; +use super::value::{ + compare_values, format_value, map_get, map_remove, map_set, set_add, set_contains, set_remove, + sorted_map_from, sorted_set_from, values_equal, Value, +}; use super::Interpreter; impl Interpreter { @@ -490,7 +493,7 @@ impl Interpreter { } } } - Ok(Value::Map(entries)) + Ok(Value::Map(sorted_map_from(entries))) } else { Err(RuntimeError::type_mismatch("List", "other")) } @@ -509,13 +512,7 @@ impl Interpreter { "new" => Ok(Value::Set(Vec::new())), "from" => { if let Some(Value::List(items)) = args.into_iter().next() { - let mut unique = Vec::new(); - for item in items { - if !unique.iter().any(|e| values_equal(e, &item)) { - unique.push(item); - } - } - Ok(Value::Set(unique)) + Ok(Value::Set(sorted_set_from(items))) } else { Err(RuntimeError::type_mismatch("List", "other")) } @@ -1089,21 +1086,17 @@ impl Interpreter { (Value::Map(entries), "is_empty") => Ok(Value::Bool(entries.is_empty())), (Value::Map(entries), "get") => { if let Some(key) = args.first() { - for (k, v) in entries { - if values_equal(k, key) { - return Ok(Value::Some(Box::new(v.clone()))); - } + match map_get(entries, key) { + Some(v) => Ok(Value::Some(Box::new(v.clone()))), + None => Ok(Value::None), } - Ok(Value::None) } else { Err(RuntimeError::arity_mismatch(1, 0)) } } (Value::Map(entries), "contains_key") => { if let Some(key) = args.first() { - Ok(Value::Bool( - entries.iter().any(|(k, _)| values_equal(k, key)), - )) + Ok(Value::Bool(map_get(entries, key).is_some())) } else { Err(RuntimeError::arity_mismatch(1, 0)) } @@ -1127,25 +1120,14 @@ impl Interpreter { if args.len() == 2 { let key = args[0].clone(); let val = args[1].clone(); - let mut new_entries: Vec<(Value, Value)> = entries - .iter() - .filter(|(k, _)| !values_equal(k, &key)) - .cloned() - .collect(); - new_entries.push((key, val)); - Ok(Value::Map(new_entries)) + Ok(Value::Map(map_set(entries, key, val))) } else { Err(RuntimeError::arity_mismatch(2, args.len())) } } (Value::Map(entries), "remove") => { if let Some(key) = args.first() { - let new_entries: Vec<(Value, Value)> = entries - .iter() - .filter(|(k, _)| !values_equal(k, key)) - .cloned() - .collect(); - Ok(Value::Map(new_entries)) + Ok(Value::Map(map_remove(entries, key))) } else { Err(RuntimeError::arity_mismatch(1, 0)) } @@ -1156,30 +1138,21 @@ impl Interpreter { (Value::Set(elements), "is_empty") => Ok(Value::Bool(elements.is_empty())), (Value::Set(elements), "contains") => { if let Some(val) = args.first() { - Ok(Value::Bool(elements.iter().any(|e| values_equal(e, val)))) + Ok(Value::Bool(set_contains(elements, val))) } else { Err(RuntimeError::arity_mismatch(1, 0)) } } (Value::Set(elements), "add") => { if let Some(val) = args.into_iter().next() { - let mut new_elements = elements.clone(); - if !new_elements.iter().any(|e| values_equal(e, &val)) { - new_elements.push(val); - } - Ok(Value::Set(new_elements)) + Ok(Value::Set(set_add(elements, val))) } else { Err(RuntimeError::arity_mismatch(1, 0)) } } (Value::Set(elements), "remove") => { if let Some(val) = args.first() { - let new_elements: Vec = elements - .iter() - .filter(|e| !values_equal(e, val)) - .cloned() - .collect(); - Ok(Value::Set(new_elements)) + Ok(Value::Set(set_remove(elements, val))) } else { Err(RuntimeError::arity_mismatch(1, 0)) } @@ -1189,9 +1162,7 @@ impl Interpreter { if let Some(Value::Set(other)) = args.first() { let mut result = elements.clone(); for item in other { - if !result.iter().any(|e| values_equal(e, item)) { - result.push(item.clone()); - } + result = set_add(&result, item.clone()); } Ok(Value::Set(result)) } else { @@ -1202,7 +1173,7 @@ impl Interpreter { if let Some(Value::Set(other)) = args.first() { let result: Vec = elements .iter() - .filter(|e| other.iter().any(|o| values_equal(e, o))) + .filter(|e| set_contains(other, e)) .cloned() .collect(); Ok(Value::Set(result)) diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 2992132..be5d4c2 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -19,7 +19,7 @@ use crate::parser::ast::*; pub use capabilities::*; pub use environment::Environment; -pub use error::{check_arity, RuntimeError}; +pub use error::{check_arity, CallFrame, RuntimeError}; pub use pattern::match_pattern; pub use value::*; @@ -79,7 +79,7 @@ pub struct Interpreter { /// Modules currently being loaded (for circular import detection P4.2) loading_modules: std::collections::HashSet, /// Call stack for stack traces (P5.2) - call_stack: Vec, + call_stack: Vec, /// Type definitions for invariant enforcement (P2.3) type_defs: HashMap, /// Effect definitions (P6.2) @@ -806,7 +806,7 @@ impl Interpreter { let val = self.eval_expr(v)?; pairs.push((key, val)); } - Ok(Value::Map(pairs)) + Ok(Value::Map(sorted_map_from(pairs))) } // Lambda expression @@ -978,15 +978,13 @@ impl Interpreter { } } (Value::Map(entries), key) => { - for (k, v) in entries { - if values_equal(k, key) { - return Ok(v.clone()); - } + match map_get(entries, key) { + Some(v) => Ok(v.clone()), + None => Err(RuntimeError::new( + "E4002", + format!("key not found in map: {}", format_value(key)), + )), } - Err(RuntimeError::new( - "E4002", - format!("key not found in map: {}", format_value(key)), - )) } (Value::Text(s), Value::Int(i)) => { let i = *i; @@ -1293,7 +1291,9 @@ impl Interpreter { let fn_name = name.as_deref().unwrap_or(""); // Push call stack frame (P5.2: stack traces) - self.call_stack.push(fn_name.to_string()); + let frame_span = Some(body.block.span.clone()); + self.call_stack + .push(CallFrame::new(fn_name, frame_span)); // P6.4: TCO - detect simple self-recursive tail calls let use_tco = name.is_some() @@ -1609,7 +1609,18 @@ impl Interpreter { } let mut trace = String::from("Stack trace:\n"); for (i, frame) in self.call_stack.iter().rev().enumerate() { - trace.push_str(&format!(" {}: {}\n", i, frame)); + if let Some(span) = &frame.span { + trace.push_str(&format!( + " {}: {} ({}:{}:{})\n", + i, + frame.name, + span.file.display(), + span.start_line, + span.start_col + )); + } else { + trace.push_str(&format!(" {}: {}\n", i, frame.name)); + } } trace } diff --git a/src/interpreter/value.rs b/src/interpreter/value.rs index 63d2f4d..a555044 100644 --- a/src/interpreter/value.rs +++ b/src/interpreter/value.rs @@ -133,6 +133,149 @@ pub fn compare_values(a: &Value, b: &Value) -> std::cmp::Ordering { } } +/// Assign an integer tag to each value variant for cross-type ordering. +fn type_tag(v: &Value) -> u8 { + match v { + Value::Unit => 0, + Value::Bool(_) => 1, + Value::Int(_) => 2, + Value::Float(_) => 3, + Value::Text(_) => 4, + Value::None => 5, + Value::Some(_) => 6, + Value::Ok(_) => 7, + Value::Err(_) => 8, + Value::Tuple(_) => 9, + Value::List(_) => 10, + Value::Record(_) => 11, + Value::Variant { .. } => 12, + Value::Map(_) => 13, + Value::Set(_) => 14, + Value::Closure { .. } => 15, + Value::VariantConstructor { .. } => 16, + Value::Future { .. } => 17, + } +} + +/// Total ordering for values, used to keep Map keys and Set elements sorted +/// for O(log n) binary-search lookups. +pub fn compare_values_total(a: &Value, b: &Value) -> std::cmp::Ordering { + use std::cmp::Ordering; + let ta = type_tag(a); + let tb = type_tag(b); + if ta != tb { + return ta.cmp(&tb); + } + match (a, b) { + (Value::Unit, Value::Unit) => Ordering::Equal, + (Value::Int(x), Value::Int(y)) => x.cmp(y), + (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal), + (Value::Bool(x), Value::Bool(y)) => x.cmp(y), + (Value::Text(x), Value::Text(y)) => x.cmp(y), + (Value::None, Value::None) => Ordering::Equal, + (Value::Some(x), Value::Some(y)) | (Value::Ok(x), Value::Ok(y)) | (Value::Err(x), Value::Err(y)) => { + compare_values_total(x, y) + } + (Value::Tuple(xs), Value::Tuple(ys)) | (Value::List(xs), Value::List(ys)) | (Value::Set(xs), Value::Set(ys)) => { + for (x, y) in xs.iter().zip(ys.iter()) { + let c = compare_values_total(x, y); + if c != Ordering::Equal { + return c; + } + } + xs.len().cmp(&ys.len()) + } + (Value::Variant { name: n1, data: d1 }, Value::Variant { name: n2, data: d2 }) => { + let nc = n1.cmp(n2); + if nc != Ordering::Equal { + return nc; + } + match (d1, d2) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Less, + (Some(_), None) => Ordering::Greater, + (Some(a), Some(b)) => compare_values_total(a, b), + } + } + _ => Ordering::Equal, + } +} + +// --------------------------------------------------------------------------- +// Sorted-vector helpers for O(log n) Map/Set operations +// --------------------------------------------------------------------------- + +/// Find the index of `key` in a sorted map vec, or where it would be inserted. +pub fn map_search(entries: &[(Value, Value)], key: &Value) -> Result { + entries.binary_search_by(|(k, _)| compare_values_total(k, key)) +} + +/// Look up a key in a sorted map vec. O(log n). +pub fn map_get<'a>(entries: &'a [(Value, Value)], key: &Value) -> Option<&'a Value> { + map_search(entries, key).ok().map(|i| &entries[i].1) +} + +/// Insert or update a key in a sorted map vec, returning a new vec. +pub fn map_set(entries: &[(Value, Value)], key: Value, value: Value) -> Vec<(Value, Value)> { + let mut new = entries.to_vec(); + match map_search(&new, &key) { + Ok(i) => new[i].1 = value, + Err(i) => new.insert(i, (key, value)), + } + new +} + +/// Remove a key from a sorted map vec, returning a new vec. +pub fn map_remove(entries: &[(Value, Value)], key: &Value) -> Vec<(Value, Value)> { + let mut new = entries.to_vec(); + if let Ok(i) = map_search(&new, key) { + new.remove(i); + } + new +} + +/// Find the index of `val` in a sorted set vec, or where it would be inserted. +pub fn set_search(elements: &[Value], val: &Value) -> Result { + elements.binary_search_by(|e| compare_values_total(e, val)) +} + +/// Check membership in a sorted set. O(log n). +pub fn set_contains(elements: &[Value], val: &Value) -> bool { + set_search(elements, val).is_ok() +} + +/// Insert a value into a sorted set vec (no duplicates), returning a new vec. +pub fn set_add(elements: &[Value], val: Value) -> Vec { + let mut new = elements.to_vec(); + if let Err(i) = set_search(&new, &val) { + new.insert(i, val); + } + new +} + +/// Remove a value from a sorted set vec, returning a new vec. +pub fn set_remove(elements: &[Value], val: &Value) -> Vec { + let mut new = elements.to_vec(); + if let Ok(i) = set_search(&new, val) { + new.remove(i); + } + new +} + +/// Build a sorted map from an unsorted vec of pairs. +pub fn sorted_map_from(mut entries: Vec<(Value, Value)>) -> Vec<(Value, Value)> { + entries.sort_by(|(a, _), (b, _)| compare_values_total(a, b)); + entries.dedup_by(|(a, _), (b, _)| compare_values_total(a, b) == std::cmp::Ordering::Equal); + entries +} + +/// Build a sorted set from an unsorted vec. +pub fn sorted_set_from(mut elements: Vec) -> Vec { + elements.sort_by(compare_values_total); + elements.dedup_by(|a, b| compare_values_total(a, b) == std::cmp::Ordering::Equal); + elements +} + /// Format a value for display pub fn format_value(value: &Value) -> String { match value { diff --git a/src/parser/parser.rs b/src/parser/parser.rs index 7eb27d6..a49ead8 100644 --- a/src/parser/parser.rs +++ b/src/parser/parser.rs @@ -781,59 +781,17 @@ impl<'a> Parser<'a> { let mut expr = None; while !self.check(TokenKind::RBrace) && !self.is_eof() { - if self.check(TokenKind::Let) || self.check(TokenKind::Return) { - stmts.push(self.parse_stmt()?); - } else if self.check(TokenKind::Fn) && matches!(self.peek2().kind, TokenKind::Ident(_)) - { - // Local named function definition: `fn name(...) { ... }` - stmts.push(self.parse_local_fn_stmt()?); - } else { - // Parse an expression - let e = self.parse_expr()?; - - // Check for assignment: `expr = value` - if self.check(TokenKind::Eq) { - self.advance(); - let value = self.parse_expr()?; - let span = e.span().merge(value.span()); - stmts.push(Stmt::Assign { - id: NodeId::new(), - span, - target: Box::new(e), - value: Box::new(value), - }); + match self.parse_block_element(&mut stmts, &mut expr) { + Ok(done) => { + if done { + break; + } } - // E1: Compound assignment operators (+=, -=, *=, /=, %=) - else if let Some(op) = self.check_compound_assign() { - self.advance(); - let rhs = self.parse_expr()?; - let span = e.span().merge(rhs.span()); - // Desugar `x += expr` into `x = x + expr` - let desugared = Expr::Binary { - id: NodeId::new(), - span: span.clone(), - op, - left: Box::new(e.clone()), - right: Box::new(rhs), - }; - stmts.push(Stmt::Assign { - id: NodeId::new(), - span, - target: Box::new(e), - value: Box::new(desugared), - }); - } else if self.check(TokenKind::RBrace) { - // If we're at the end of the block, this is the final expression - expr = Some(Box::new(e)); - break; - } else { - // Otherwise, this is an expression statement - let span = e.span().clone(); - stmts.push(Stmt::Expr { - id: NodeId::new(), - span, - expr: Box::new(e), - }); + Err(diag) => { + // Multi-error recovery: record the error and skip to the + // next statement boundary so we can report multiple errors. + self.errors.push(diag); + self.recover_to_next_stmt(); } } } @@ -841,6 +799,75 @@ impl<'a> Parser<'a> { Ok((stmts, expr)) } + /// Parse a single block element (statement or trailing expression). + /// Returns `Ok(true)` when the block's trailing expression has been found + /// and parsing should stop, or `Ok(false)` to continue. + fn parse_block_element( + &mut self, + stmts: &mut Vec, + expr: &mut Option>, + ) -> Result { + if self.check(TokenKind::Let) || self.check(TokenKind::Return) { + stmts.push(self.parse_stmt()?); + Ok(false) + } else if self.check(TokenKind::Fn) && matches!(self.peek2().kind, TokenKind::Ident(_)) { + // Local named function definition: `fn name(...) { ... }` + stmts.push(self.parse_local_fn_stmt()?); + Ok(false) + } else { + // Parse an expression + let e = self.parse_expr()?; + + // Check for assignment: `expr = value` + if self.check(TokenKind::Eq) { + self.advance(); + let value = self.parse_expr()?; + let span = e.span().merge(value.span()); + stmts.push(Stmt::Assign { + id: NodeId::new(), + span, + target: Box::new(e), + value: Box::new(value), + }); + Ok(false) + } + // E1: Compound assignment operators (+=, -=, *=, /=, %=) + else if let Some(op) = self.check_compound_assign() { + self.advance(); + let rhs = self.parse_expr()?; + let span = e.span().merge(rhs.span()); + // Desugar `x += expr` into `x = x + expr` + let desugared = Expr::Binary { + id: NodeId::new(), + span: span.clone(), + op, + left: Box::new(e.clone()), + right: Box::new(rhs), + }; + stmts.push(Stmt::Assign { + id: NodeId::new(), + span, + target: Box::new(e), + value: Box::new(desugared), + }); + Ok(false) + } else if self.check(TokenKind::RBrace) { + // If we're at the end of the block, this is the final expression + *expr = Some(Box::new(e)); + Ok(true) + } else { + // Otherwise, this is an expression statement + let span = e.span().clone(); + stmts.push(Stmt::Expr { + id: NodeId::new(), + span, + expr: Box::new(e), + }); + Ok(false) + } + } + } + fn parse_stmt(&mut self) -> Result { let start_span = self.current_span(); @@ -2267,6 +2294,33 @@ impl<'a> Parser<'a> { } } } + + /// Recover to the next statement boundary within a block. + /// Skips tokens until we find a `let`, `return`, `fn`, `}`, or a new line + /// with a recognizable statement-start token. + fn recover_to_next_stmt(&mut self) { + while !self.is_eof() { + match self.peek().kind { + // Statement-starting keywords + TokenKind::Let | TokenKind::Return | TokenKind::Fn => return, + // End of block — stop so the caller can see } + TokenKind::RBrace => return, + // Top-level item keywords — stop here too so item-level recovery works + TokenKind::Import + | TokenKind::Type + | TokenKind::Enum + | TokenKind::Effect + | TokenKind::Public + | TokenKind::Trait + | TokenKind::Impl + | TokenKind::Test + | TokenKind::Property => return, + _ => { + self.advance(); + } + } + } + } } /// Process escape sequences in a string literal. diff --git a/src/parser/tests.rs b/src/parser/tests.rs index 7d61888..e5cf7b2 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -1,6 +1,31 @@ use super::*; use std::path::PathBuf; +#[test] +fn test_multi_error_recovery_items() { + // Two bad items — parser should report errors for both. + // Each `type` line is missing the name, so the parser errors and recovers. + let source = r#"module mymod + +type = bad + +type = also_bad + +fn good(x: Int) -> Int { + x + 1 +} +"#; + let result = parse_source(source, &PathBuf::from("test.astra")); + assert!(result.is_err(), "Expected parse errors"); + let errors = result.unwrap_err(); + // Should report at least 2 errors (one per bad type def) + assert!( + errors.len() >= 2, + "Expected at least 2 errors, got {}", + errors.len() + ); +} + #[test] fn test_parse_empty_module() { let source = "module mymod\n"; diff --git a/stdlib/datetime.astra b/stdlib/datetime.astra new file mode 100644 index 0000000..95625eb --- /dev/null +++ b/stdlib/datetime.astra @@ -0,0 +1,174 @@ +module std.datetime + +## Returns the current time as milliseconds since the Unix epoch. +public fn now_millis() -> Int effects(Clock) +{ + Clock.now() +} + +## Returns today's date as a "YYYY-MM-DD" string. +public fn today() -> Text effects(Clock) +{ + Clock.today() +} + +## Extracts the year from a "YYYY-MM-DD" date string. +public fn year(date: Text) -> Int +{ + let parts = date.split("-") + let y = to_int(parts.get(0).unwrap_or("0")) + y.unwrap_or(0) +} + +## Extracts the month (1-12) from a "YYYY-MM-DD" date string. +public fn month(date: Text) -> Int +{ + let parts = date.split("-") + let m = to_int(parts.get(1).unwrap_or("0")) + m.unwrap_or(0) +} + +## Extracts the day (1-31) from a "YYYY-MM-DD" date string. +public fn day(date: Text) -> Int +{ + let parts = date.split("-") + let d = to_int(parts.get(2).unwrap_or("0")) + d.unwrap_or(0) +} + +## Formats milliseconds since epoch into a human-readable date string "YYYY-MM-DD". +## Uses a simplified calculation (no timezone support). +public fn format_date(millis: Int) -> Text +{ + let total_days = millis / 86400000 + let mut y = 1970 + let mut remaining = total_days + + while remaining >= 365 { + let days_in_year = if is_leap_year(y) { 366 } else { 365 } + if remaining < days_in_year { + break + } else { + remaining = remaining - days_in_year + y = y + 1 + } + } + + let days_in_months = if is_leap_year(y) { + [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } else { + [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } + + let mut m = 0 + while m < 12 { + let dm = days_in_months.get(m).unwrap_or(30) + if remaining < dm { + break + } else { + remaining = remaining - dm + m = m + 1 + } + } + + let d = remaining + 1 + let year_str = pad_int(y, 4) + let month_str = pad_int(m + 1, 2) + let day_str = pad_int(d, 2) + year_str + "-" + month_str + "-" + day_str +} + +## Returns true if the given year is a leap year. +public fn is_leap_year(y: Int) -> Bool +{ + (y % 4 == 0 and y % 100 != 0) or (y % 400 == 0) +} + +## Computes the difference between two dates in days. +## Both dates must be "YYYY-MM-DD" strings. +public fn days_between(date1: Text, date2: Text) -> Int +{ + let d1 = date_to_days(date1) + let d2 = date_to_days(date2) + abs(d2 - d1) +} + +## Adds a number of days to a "YYYY-MM-DD" date string. +public fn add_days(date: Text, n: Int) -> Text +{ + let total = date_to_days(date) + n + days_to_date(total) +} + +## Internal: convert a date string to days since epoch. +fn date_to_days(date: Text) -> Int +{ + let y = year(date) + let m = month(date) + let d = day(date) + + let mut days = 0 + let mut yr = 1970 + while yr < y { + days = days + if is_leap_year(yr) { 366 } else { 365 } + yr = yr + 1 + } + + let months = if is_leap_year(y) { + [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } else { + [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } + + let mut mo = 1 + while mo < m { + days = days + months.get(mo - 1).unwrap_or(30) + mo = mo + 1 + } + days + d - 1 +} + +## Internal: convert days since epoch to a date string. +fn days_to_date(total_days: Int) -> Text +{ + let millis = total_days * 86400000 + format_date(millis) +} + +## Internal: pad an integer with leading zeros. +fn pad_int(n: Int, width: Int) -> Text +{ + let s = to_text(n) + let mut result = s + while result.len() < width { + result = "0" + result + } + result +} + +test "year extraction" { + assert_eq(year("2026-03-05"), 2026) + assert_eq(month("2026-03-05"), 3) + assert_eq(day("2026-03-05"), 5) +} + +test "leap year" { + assert_eq(is_leap_year(2000), true) + assert_eq(is_leap_year(1900), false) + assert_eq(is_leap_year(2024), true) + assert_eq(is_leap_year(2023), false) +} + +test "format date" { + assert_eq(format_date(0), "1970-01-01") + assert_eq(format_date(86400000), "1970-01-02") +} + +test "add days" { + assert_eq(add_days("2026-01-01", 1), "2026-01-02") + assert_eq(add_days("2026-01-31", 1), "2026-02-01") +} + +test "days between" { + assert_eq(days_between("2026-01-01", "2026-01-10"), 9) +} diff --git a/stdlib/path.astra b/stdlib/path.astra new file mode 100644 index 0000000..cb6319d --- /dev/null +++ b/stdlib/path.astra @@ -0,0 +1,151 @@ +module std.path + +## Returns the file extension from a path (without the dot). +## Returns None if there is no extension. +public fn extension(path: Text) -> Option[Text] +{ + let base = basename(path) + let idx = base.index_of(".") + match idx { + Some(i) => { + let ext = base.slice(i + 1, base.len()) + if ext.len() > 0 { Some(ext) } else { None } + } + None => None + } +} + +## Returns the last component of a path. +public fn basename(path: Text) -> Text +{ + let parts = path.split("/") + let last_part = parts.last() + match last_part { + Some(p) => { + if p.len() == 0 { + let without_trailing = parts.take(len(parts) - 1) + without_trailing.last().unwrap_or("") + } else { + p + } + } + None => "" + } +} + +## Returns the directory portion of a path (everything before the last component). +public fn dirname(path: Text) -> Text +{ + let parts = path.split("/") + if len(parts) <= 1 { + "." + } else { + let dir_parts = parts.take(len(parts) - 1) + let result = dir_parts.join("/") + if result.len() == 0 { "/" } else { result } + } +} + +## Joins two path segments with a separator. +public fn join(base: Text, child: Text) -> Text +{ + if child.starts_with("/") { + child + } else if base.ends_with("/") { + base + child + } else { + base + "/" + child + } +} + +## Returns the filename without its extension. +public fn stem(path: Text) -> Text +{ + let base = basename(path) + let idx = base.index_of(".") + match idx { + Some(i) => base.slice(0, i) + None => base + } +} + +## Returns true if the path is absolute (starts with /). +public fn is_absolute(path: Text) -> Bool +{ + path.starts_with("/") +} + +## Returns true if the path is relative (does not start with /). +public fn is_relative(path: Text) -> Bool +{ + not is_absolute(path) +} + +## Normalizes a path by resolving "." and ".." segments. +public fn normalize(path: Text) -> Text +{ + let parts = path.split("/") + let mut stack: List[Text] = [] + + for part in parts { + if part == "." or part == "" { + let skip = true + } else if part == ".." { + if stack.len() > 0 { + stack = stack.take(stack.len() - 1) + } else { + let skip = true + } + } else { + stack = stack.push(part) + } + } + + let result = stack.join("/") + if is_absolute(path) { + "/" + result + } else if result.len() == 0 { + "." + } else { + result + } +} + +test "basename" { + assert_eq(basename("/home/user/file.txt"), "file.txt") + assert_eq(basename("file.txt"), "file.txt") + assert_eq(basename("/home/user/"), "user") +} + +test "dirname" { + assert_eq(dirname("/home/user/file.txt"), "/home/user") + assert_eq(dirname("file.txt"), ".") +} + +test "extension" { + assert_eq(extension("file.txt"), Some("txt")) + assert_eq(extension("file"), None) + assert_eq(extension("archive.tar.gz"), Some("tar.gz")) +} + +test "join" { + assert_eq(join("/home", "user"), "/home/user") + assert_eq(join("/home/", "user"), "/home/user") + assert_eq(join("/home", "/absolute"), "/absolute") +} + +test "stem" { + assert_eq(stem("file.txt"), "file") + assert_eq(stem("file"), "file") + assert_eq(stem("/path/to/data.csv"), "data") +} + +test "normalize" { + assert_eq(normalize("/home/user/../docs/./file.txt"), "/home/docs/file.txt") + assert_eq(normalize("a/b/../c"), "a/c") +} + +test "is_absolute and is_relative" { + assert_eq(is_absolute("/home"), true) + assert_eq(is_relative("home"), true) +}