Skip to content

Latest commit

 

History

History
138 lines (95 loc) · 16.5 KB

File metadata and controls

138 lines (95 loc) · 16.5 KB

Lucky Script v2 (current)

Uses ohm-js: lexer and parser are driven from parser/grammar.ohm (generated grammar.ohm-bundle). Pipeline: parse → compile → VM, orchestrated by runtime/ — see root AGENTS.md. For the legacy interpreter stack, see src/v1/AGENTS.md.

Layout (under src/v2/)

parser/
  grammar.ohm        # Authoritative surface grammar
  grammar.ohm-bundle.*
  parser.ts          # Thin wiring: createSemantics, mergeActions(...), parse/tryParse
  ast/               # AST types — one file per construct, mirrors `semantics/`.
                     #   `index.ts` assembles `Expr`/`Stmt`/`Program` unions.
                     #   `_shared.ts` holds `Span`, `Node<K>`, op string unions.
  semantics/         # Ohm action dicts — one file per construct.
                     #   Each exports an `ArithmeticActionDict<AstResult>`.
                     #   `parser.ts` `mergeActions(...)` combines them and throws
                     #   on duplicate rule keys. `_shared.ts` holds `spanOf`,
                     #   the `_iter` default, and shared `Params_*` handlers.
  testingUtils.ts    # `stripSpans`, `parseShape` for AST-shape tests.
compiler/            # AST → bytecode (`compile`, `bytecode`, `CompileOptions`)
vm/                  # Generic bytecode executor (`run`, stack, environment,
                     #  errors). Knows nothing about built-ins; accepts a
                     #  `globals` map of pre-seeded Values via RunOptions.
runtime/             # Lucky's surface runtime: orchestrates parse → compile →
                     #  VM, owns built-in names + handlers (`print`, `inspect`,
                     #  `str`) in `builtins.ts`, and the per-kind method
                     #  registry (`length`, `push`, `pop`, `to_number`, …) in
                     #  `methods.ts`. REPL (`repl.ts`, `ReplSession.ts`,
                     #  `replInput.ts`).
cli.ts               # CLI entry point. Dispatches `yarn lucky`: no args → REPL,
                     #  `*.ls` path → file execution with `args` global populated.
index.ts             # Thin re-export of the runtime's `run` / `RunOptions`.
examples/            # End-to-end tests through the full parse → compile → run
                     #  pipeline. feat-*.test.ts: one file per language construct.
                     #  demo-*.test.ts: one file per canonical algorithm.

Module dependency arrows

parser → compiler → vm
                 ↑
                 runtime → { parser, compiler, vm }

The compiler must not depend on the vm or the runtime: it's told what global names exist via CompileOptions.predefinedGlobalNames. The vm depends on the compiler only for types (FunctionProto, Bytecode). The runtime is the one place that wires the full Lucky calling convention together.

Architecture: Parse (Ohm) → Compile → VM

Parse: Match grammar, semantics in parser/parser.ts, AST types in parser/ast.ts.

Compile (compiler/compiler.ts): Produces Bytecode (compiler/bytecode.ts). Accepts a CompileOptions.predefinedGlobalNames list so callers can declare global names that exist at runtime without redefining them in source. Per-compile state lives in CompilerContext (a class in compiler/CompilerContext.ts). The loop-control trio (loopDepth, breakStatements, continueStatements) is bracketed per loop via ctx.withLoop(body) (caller returns { breakTarget, continueTarget }; the helper patches every registered jump and pops the trio) and swapped on function-body entry via ctx.withFreshLoopScope(body). Callers MUST go through these methods rather than mutating the fields directly. Future swap clusters (e.g. the constant pool from add-constant-pool) will add sibling with* methods following the same pattern.

Run (vm/run.ts): Operand stack and frames (vm/Environment, OperandStack), errors in vm/errors.ts, values in vm/value.ts (number / closure / native / bound_native / array / string / range / iterator / none / boolean). The VM is generic — pass globals: Record<string, Value> via RunOptions to seed the global Environment and methods: MethodRegistry to populate the per-kind property/method dispatch table that GET_PROPERTY consults.

Runtime (runtime/run.ts): Public entry point. Parses, compiles with the built-in name list, constructs the built-in bindings (runtime/builtins.ts), seeds them into the VM, and unwraps the final number. Override print / inspect here for test injection.

Tests

Tests live colocated with the layer they exercise. Each file has a distinct responsibility — do not duplicate positive-path program behavior across layers, since doing so just couples the suite to internal artifacts (token stream, AST shape, bytecode calling convention) without adding coverage.

File Responsibility
parser/parser.test.ts, parser/parser.spans.test.ts Grammar shape, AST construction, source-span reporting, parse errors. Asserts on AST nodes, not on program results.
compiler/compiler.test.ts Bytecode emitted for representative AST shapes, compile-time errors (e.g. unknown name, return outside of a function). Asserts on instruction lists, not on runtime values.
vm/Environment.test.ts, vm/OperandStack.test.ts Unit-level invariants of the runtime data structures (chain walking, push/pop bounds, etc.).
vm/run.test.ts VM-only edge cases — runtime errors (StackUnderflow, NotCallable, ArityMismatch, UndefinedVariable, …), resource limits (StackOverflow, FrameStackOverflow), opcode semantics the surface language cannot conveniently express (e.g. DIV by zero, NaN comparisons, MAKE_CLOSURE capture-by-reference, cross-kind EQ), and the globals injection primitive (native dispatch). Built from hand-crafted Bytecode literals. Not for happy-path programs.
runtime/run.test.ts RunOptions plumbing — how builtins handlers thread into the VM (print / inspect overrides), default console.log fallback routing. Asserts on host-side wiring, not on Lucky-language semantics.
examples/feat-*.test.ts Feature-focused source-string tests — one file per language construct (feat-arithmetic, feat-variables, feat-comparisons, feat-logical, feat-if, feat-while, feat-functions, feat-closures, feat-builtins, feat-errors). All tests feed a Lucky source string to run() through the full pipeline; use unwrap(run(…)) from runtime/testingUtils.ts for numeric toBe assertions.
examples/demo-*.test.ts Canonical algorithm programs — one file per algorithm (demo-factorial, demo-fibonacci, demo-gcd, demo-counter). Cross-feature composition with a clear algorithmic purpose.

Layered-imports rule (parser → compiler → vm → runtime → examples): a test file at layer X may import from layer X and any layer to the right in the chain. Imports in the opposite direction are forbidden. For example, compiler/compiler.test.ts may import parse from ../parser (legal — compiler depends on parser); parser/parser.test.ts must not import from ../compiler (forbidden — would invert the chain). See design.md in the refactor-v2-test-organization change for full details and scenarios.

Rule of thumb: if a test could be written by feeding a string source to run(), it belongs in examples/feat-*.test.ts or examples/demo-*.test.ts. Bytecode-level tests in vm/run.test.ts should only exist for behavior the compiler does not (or cannot) emit, or for failure modes that are awkward to provoke from source. Kitchen-sink tests (single scripts that mix many unrelated features) are not permitted — use focused feat-* files and algorithm-anchored demo-* files instead.

Language principles

Cross-cutting rules that every new feature in Lucky Script inherits. Check these before adding a new Value kind, opcode, or built-in.

Truthiness

Only none and false are falsy — everything else is truthy. This includes 0, -0, NaN, [], and any closure. Implemented via isFalsy(v) in vm/value.ts; the single canonical location. JMP_IF_FALSY uses it; if/while compile to JMP_IF_FALSY.

Value equality

Cross-kind == / != is always well-defined (returns false/true) and never a runtime error. Numbers compare by value; everything else (closures, arrays, none, booleans) compares by reference identity. The singleton objects NONE_VALUE, TRUE_VALUE, FALSE_VALUE are frozen and unique — none == none is always true because it's literally NONE_VALUE === NONE_VALUE.

Singleton identity

none, true, and false are exported singletons from vm/value.ts. There is exactly one NoneValue object in the process. Code that needs to test for none should use v === NONE_VALUE (or v.kind === "none"), never v.value === null or similar.

Type checking belongs in the VM, not the compiler

The compiler emits instructions based on syntax; it does not track types. ADD will throw TypeMismatch at runtime if operands are non-numeric; the compiler does not guard this. This keeps the compiler simple and is consistent with how clox and Monkey Book both approach dynamic typing. Ref: Ball Writing a Compiler in Go ch.9.

Missing access fails fast

A lookup that has no answer — out-of-bounds index, unknown property, (future) missing record key — throws an error appropriate to the access shape: IndexError for OOB indexing, NoSuchProperty for unknown properties, KeyError for the future record / hash-map kind. none is reserved for values the program produced on purpose (the none literal, void-shaped builtins like print, explicit returns), never for "not found". Enforced today at three points: INDEX_GET (arrays, strings), GET_PROPERTY (every receiver kind via the runtime method registry), and the records spec when it lands. Reversing this principle for any new accessor would split the language into "silent miss" and "loud miss" shapes with no rule to predict which is which.

Iterables and ranges

for-in is polymorphic over any Value kind whose case is registered in makeIter (vm/iter.ts). The dispatch table today has two arms: array → new ArrayIter(v) and range → new RangeIter(v). A RangeValue is a lazy four-field record {start, end, step, inclusive} built by the MAKE_RANGE opcode from the .. (inclusive) / ... (exclusive) operators with an optional step <expr> clause. Direction-mismatched ranges (0..10 step -1, 10..0) iterate zero times — they are valid Values, not errors. step 0 is rejected at construction time with range step must be non-zero. r.length returns the element count in O(1) (registered as a getter under range in runtime/methods.ts); range equality is structural over all four fields. New iterables plug in by adding a case to makeItercompileForInStmt does not change.

Contextual keywords

A keyword is contextual (or soft) when the grammar recognizes it only at a specific syntactic position and leaves it available as a normal identifier everywhere else. Today the sole instance is step: it appears inline as kw<"step"> inside RangeExp (parser/grammar.ohm), is NOT in the top-level keyword alternation, and so let step = 5 / fun fold(acc, x, step) parse without complaint. The kw<…> matcher still enforces the whole-word boundary so stepup is not mis-tokenized. A keyword is a safe candidate for contextual treatment iff (1) it appears in exactly one syntactic position AND (2) the position is lexically unambiguous — the immediately preceding token is a unique marker (here: .. / ...). in deliberately stays reserved because its slot in ForStmt is preceded by ident, which would produce legal-but-cursed sources like for in in 0..10 … end; criterion (1) alone is not enough. When adding a future one-position keyword (yield, where, …), apply the same two-criterion test before deciding whether to liberate it.

Opcode count philosophy

Prefer fewer opcodes when the instruction can be expressed as a short sequence of existing ones. For example, a != b compiles to EQ; NOT — no NEQ opcode. Dedicated opcodes are justified when the hot path would otherwise execute two instructions on every iteration of a tight loop, or when the semantics would otherwise require two stack pops to achieve what one opcode does in one pass. Ref: CI ch.18.4.2. Current dedicated opcodes with no single-opcode alternative: PUSH_NONE, PUSH_TRUE, PUSH_FALSE (emitting a constant in one instruction), JMP_IF_FALSY (pop-and-branch in one instruction).

Development workflow (preferred for language work)

Prefer v2 whenever behavior or syntax should evolve.

1 — Grammar (syntax changes)

  1. Edit parser/grammar.ohm; extend parser/parser.test.ts and parser/parser.spans.test.ts when behavior or source spans change.
  2. Bundles and types are regenerated automatically via a Claude Code PostToolUse hook whenever a *.ohm file is saved.
    To regenerate manually: yarn ohm
  3. Run yarn test v2/parser (or full yarn test).

2 — Implementation (TDD)

Typical layer order:

  1. Parser / ASTparser/*.test.ts, parser.ts, ast.ts.
  2. Compilercompiler/compiler.test.ts, compiler.ts, bytecode.ts.
  3. VMvm/*.test.ts, opcodes / execution in run.ts and related modules.
  4. Runtimeruntime/run.ts, runtime/builtins.ts for new built-ins or anything that ties parse / compile / VM together (REPL state, file loaders).
  5. Examples — add to the appropriate examples/feat-*.test.ts for the construct, or create examples/demo-*.test.ts for a new canonical algorithm.

3 — Quality

yarn lint && yarn typecheck && yarn test (same as root AGENTS.md).