Compare commits

..
16 Commits
56 changed files with 4312 additions and 1136 deletions

No files matched your search

+8
View File
@@ -5,6 +5,14 @@
- For generics-heavy code generation, follow `docs/ai_language_reference.md` section `7.1 Generics Runtime Model and Bounds` and `7.2 Differences vs Java / Kotlin / Scala`. - For generics-heavy code generation, follow `docs/ai_language_reference.md` section `7.1 Generics Runtime Model and Bounds` and `7.2 Differences vs Java / Kotlin / Scala`.
- Use `docs/ai_stdlib_reference.md` for default runtime/module APIs and stdlib surface. - Use `docs/ai_stdlib_reference.md` for default runtime/module APIs and stdlib surface.
- Treat `LYNG_AI_SPEC.md` and older docs as secondary if they conflict with the two files above. - Treat `LYNG_AI_SPEC.md` and older docs as secondary if they conflict with the two files above.
- Prefer the shortest clear loop: use `for` for straightforward iteration/ranges; use `while` only when loop state/condition is irregular or changes in ways `for` cannot express cleanly.
- In Lyng code, slice strings with range indexing (`text[a..<b]`, `text[..<n]`, `text[n..]`) and avoid Java/Kotlin-style `substring(...)`.
## Lyng-First API Declarations
- Use `.lyng` declarations as the single source of truth for Lyng-facing API docs and types (especially module extern declarations).
- Prefer defining Lyng entities (enums/classes/type shapes) in `.lyng` files; only define them in Kotlin when there is Kotlin/platform-specific implementation detail that cannot be expressed in Lyng.
- Avoid hardcoding Lyng API documentation in Kotlin registrars when it can be declared in `.lyng`; Kotlin-side docs should be fallback/bridge only.
- For mixed pluggable modules (Lyng + Kotlin), embed module `.lyng` sources as generated Kotlin string literals, evaluate them into module scope during registration, then attach Kotlin implementations/bindings.
## Kotlin/Wasm generation guardrails ## Kotlin/Wasm generation guardrails
- Avoid creating suspend lambdas for compiler runtime statements. Prefer explicit `object : Statement()` with `override suspend fun execute(...)`. - Avoid creating suspend lambdas for compiler runtime statements. Prefer explicit `object : Statement()` with `override suspend fun execute(...)`.
+73 -141
View File
@@ -1,156 +1,88 @@
## 1.5.0-SNAPSHOT # Changelog
### Language Features This file tracks user-visible Lyng language/runtime/tooling changes.
- Added `return` statement with local and non-local exit support (`return@label`).
- Support for `abstract` classes, methods, and variables.
- Introduced `interface` as a synonym for `abstract class`.
- Multiple Inheritance (MI) completed and enabled by default (C3 MRO).
- Class properties with custom accessors (`get`, `set`).
- Restricted setter visibility (`private set`, `protected set`).
- Late-initialized `val` fields in classes with `Unset` protection.
- Named arguments (`name: value`) and named splats (`...Map`).
- Assign-if-null operator `?=`.
- Refined `protected` visibility rules and `closed` modifier.
- Transient attribute `@Transient` for serialization and equality.
- Unified Delegation model for `val`, `var`, and `fun`.
- Singleton objects (`object`) and object expressions.
### Standard Library History note:
- Added `with(self, block)` for scoped execution. - The project had periods where changelog maintenance lagged behind commits.
- Added `clamp()` function and extension. - Entries below are synchronized and curated for `1.5.x`.
- Improved `Exception` and `StackTraceEntry` reporting. - Earlier history may be incomplete and should be cross-checked with git tags/commits when needed.
### Tooling and IDE ## 1.5.1 (2026-03-25)
- **CLI**: Added `fmt` as a first-class subcommand for code formatting.
- **IDEA Plugin**: Lightweight autocompletion (experimental), improved docs, and Grazie integration.
- **Highlighters**: Updated TextMate bundle and website highlighters for new syntax.
### Detailed Changes: ### Language
- Added string interpolation:
- `"$name"` identifier interpolation.
- `"${expr}"` expression interpolation.
- Added literal-dollar forms in strings:
- `"\$"` -> `$`
- `"$$"` -> `$`
- `\\$x` is parsed as backslash + interpolation of `x`.
- Added per-file interpolation opt-out via leading directive comment:
- `// feature: interpolation: off`
- Language: Refined `protected` visibility rules ### Docs and AI references
- Ancestor classes can now access `protected` members of their descendants, provided the ancestor also defines or inherits a member with the same name (indicating an override of a member known to the ancestor). - Updated compiler-accurate AI language docs:
- This allows patterns where a base class calls a `protected` method that is implemented in a subclass. - interpolation syntax and escaping
- Fixed a regression where self-calls to unmangled members sometimes bypassed visibility checks incorrectly; these are now handled by the refined logic. - per-file feature switch behavior
- Refreshed tutorial examples and doctests to reflect new interpolation semantics.
- Added/reworked current proposal/reference materials for Lyng common-platform guidance.
- Language: Added `return` statement ### Compatibility notes
- `return [expression]` exits the innermost enclosing callable (function or lambda). - Interpolation is enabled by default for normal string literals.
- Supports non-local returns using `@label` syntax (e.g., `return@outer 42`). - Existing code that intentionally used `$name` as literal text should use `\$name`, `$$name`, or the file directive `// feature: interpolation: off`.
- Named functions automatically provide their name as a label for non-local returns.
- Labeled lambdas: lambdas can be explicitly labeled using `@label { ... }`.
- Restriction: `return` is forbidden in shorthand function definitions (e.g., `fun f(x) = return x` is a syntax error).
- Control Flow: `return` and `break` are now protected from being caught by user-defined `try-catch` blocks in Lyng.
- Documentation: New `docs/return_statement.md` and updated `tutorial.md`.
- Language: stdlib improvements ## 1.5.0 (2026-03-22)
- Added `with(self, block)` function to `root.lyng` which executes a block with `this` set to the provided object.
- Language: Abstract Classes and Interfaces
- Support for `abstract` modifier on classes, methods, and variables.
- Introduced `interface` as a synonym for `abstract class`, supporting full state (constructors, fields, `init` blocks) and implementation by parts via MI.
- New `closed` modifier (antonym to `open`) to prevent overriding class members.
- Refined `override` logic: mandatory keyword when re-declaring members that exist in the ancestor chain (MRO).
- MI Satisfaction: Abstract requirements are automatically satisfied by matching concrete members found later in the C3 MRO chain without requiring explicit proxy methods.
- Integration: Updated highlighters (lynglib, lyngweb, IDEA plugin), IDEA completion, and Grazie grammar checking.
- Documentation: Updated `docs/OOP.md` with sections on "Abstract Classes and Members", "Interfaces", and "Overriding and Virtual Dispatch".
- IDEA plugin: Improved natural language support and spellchecking
- Disabled the limited built-in English and Technical dictionaries.
- Enforced usage of the platform's standard Natural Languages (Grazie) and Spellchecker components.
- Integrated `SpellCheckerManager` for word suggestions and validation, respecting users' personal and project dictionaries.
- Added project-specific "learned words" support via `Lyng Formatter` settings and quick-fixes.
- Enhanced fallback spellchecker for technical terms and Lyng-specific vocabulary.
- Language: Class properties with accessors ### Major runtime/compiler direction
- Support for `val` (read-only) and `var` (read-write) properties in classes. - Completed migration to bytecode-first/bytecode-only execution paths.
- Syntax: `val name [ : Type ] get() { body }` or `var name [ : Type ] get() { body } set(value) { body }`. - Removed interpreter fallback behavior in core execution hot paths.
- Laconic Expression Shorthand: `val prop get() = expression` and `var prop get() = read set(v) = write`. - Continued frame-slot-first local/capture model improvements and related diagnostics.
- Properties are pure accessors and do **not** have automatic backing fields.
- Validation: `var` properties must have both accessors; `val` must have only a getter.
- Integration: Updated TextMate grammar and IntelliJ plugin (highlighting + keywords).
- Documentation: New "Properties" section in `docs/OOP.md`.
- Language: Restricted Setter Visibility ### Language features and semantics
- Support for `private set` and `protected set` modifiers on `var` fields and properties. - Added/finished `return` semantics including labeled non-local forms (`return@label`).
- Allows members to be publicly readable but only writable from within the declaring class or its subclasses. - Added abstract classes/members and `interface` support (as abstract-class-style construct).
- Enforcement at runtime: throws `AccessException` on unauthorized writes. - Completed and enabled multiple inheritance with C3 MRO by default.
- Supported only for declarations in class bodies (fields and properties). - Added class properties with accessors (`get`/`set`) and restricted setter visibility (`private set`, `protected set`).
- Documentation: New "Restricted Setter Visibility" section in `docs/OOP.md`. - Added late-initialized class `val` support with `Unset` protection rules.
- Added named arguments (`name: value`) and named splats (`...map`) with stricter validation.
- Added assign-if-null operator `?=`.
- Improved nullable/type-checking behavior (including `T is nullable` and related type checks).
- Added variadic function types (`...` in function type declarations) and tighter lambda type checks.
- Language: Late-initialized `val` fields in classes ### Type system and collections
- Support for declaring `val` without an immediate initializer in class bodies. - Added immutable collections hierarchy (`ImmutableList`, `ImmutableSet`, `ImmutableMap`).
- Compulsory initialization: every late-init `val` must be assigned at least once within the class body or an `init` block. - Improved generic runtime binding/checking for explicit type arguments and bounds.
- Write-once enforcement: assigning to a `val` is allowed only if its current value is `Unset`. - Added smarter type-aware collection ops (`+=`, `-=`) and stronger declared-member type checks.
- Access protection: reading a late-init `val` before it is assigned returns the `Unset` singleton; using `Unset` for most operations throws an `UnsetException`.
- Extension properties do not support late-init.
- Documentation: New "Late-initialized `val` fields" and "The `Unset` singleton" sections in `docs/OOP.md`.
- Docs: OOP improvements ### Extern/Kotlin bridge
- New page: `docs/scopes_and_closures.md` detailing `ClosureScope` resolution order, recursion‑safe helpers (`chainLookupIgnoreClosure`, `chainLookupWithMembers`, `baseGetIgnoreClosure`), cycle prevention, and capturing lexical environments for callbacks (`snapshotForClosure`). - Tightened extern declaration rules:
- Updated: `docs/advanced_topics.md` (link to the new page), `docs/parallelism.md` (closures in `launch`/`flow`), `docs/OOP.md` (visibility from closures with preserved `currentClassCtx`), `docs/exceptions_handling.md` (compatibility alias `SymbolNotFound`). - explicit extern members are required for extern class/object declarations.
- Tutorial: added quick link to Scopes and Closures. - Improved extern generic class behavior and diagnostics.
- Extended bridge APIs for binding global functions/variables and object/member interop scenarios.
- IDEA plugin: Lightweight autocompletion (experimental) ### Standard library and modules
- Global completion: local declarations, in‑scope parameters, imported modules, and stdlib symbols. - Added `lyng.observable` improvements (`ObservableList` hooks/events).
- Member completion: after a dot, suggests only members of the inferred receiver type (incl. chained calls like `Path(".." ).lines().` → `Iterator` methods). No global identifiers appear after a dot. - Added `Random` stdlib API used by updated samples.
- Inheritance-aware: direct class members first, then inherited (e.g., `List` includes `Collection`/`Iterable` methods). - Added/extended `lyngio.console` support and CLI integration for console interaction.
- Heuristics: handles literals (`"…"` → `String`, numbers → `Int/Real`, `[...]` → `List`, `{...}` → `Dict`) and static `Namespace.` members. - Migrated time APIs to `kotlin.time` (`Instant` migration and related docs/tests).
- Performance: capped results, early prefix filtering, per‑document MiniAst cache, cancellation checks.
- Toggle: Settings | Lyng Formatter → "Enable Lyng autocompletion (experimental)" (default ON).
- Stabilization: DEBUG completion/Quick Doc logs are OFF by default; behavior aligned between IDE and isolated engine tests.
- Language: Named arguments and named splats ### CLI, IDE, and docs/tooling
- New call-site syntax for named arguments using colon: `name: value`. - CLI:
- Positional arguments must come before named; positionals after a named argument inside parentheses are rejected. - added first-class `fmt` command
- Trailing-lambda interaction: if the last parameter is already assigned by name (or via a named splat), a trailing `{ ... }` block is illegal. - preserved direct script fast-path invocation
- Named splats: `...` can now expand a Map into named arguments. - improved command help/dispatch behavior
- Only string keys are allowed; non-string keys raise a clear error. - IntelliJ plugin:
- Duplicate assignment across named args and named splats is an error. - improved lightweight completion and documentation/inspection behavior
- Ellipsis (variadic) parameters remain positional-only and cannot be named. - continued highlighter and Grazie/spellchecking integration work
- Rationale: `=` is assignment and an expression in Lyng; `:` at call sites avoids ambiguity. Declarations keep `name: Type`; call-site casts continue to use `as` / `as?`. - Docs:
- Documentation updated: proposals and declaring-arguments sections now cover named args/splats and error cases. - substantial updates across tutorial/OOP/type/runtime references
- Tests added covering success cases and errors for named args/splats and trailing-lambda interactions. - expanded bytecode and advanced topics coverage
- Tooling: Highlighters and TextMate bundle updated for named args ## Migration checklist for 1.5.x
- Website/editor highlighter (lyngweb + site) works with `name: value` and `...Map("k" => v)`; added JS tests covering punctuation/operator spans for `:` and `...`.
- TextMate grammar updated to recognize named call arguments: `name: value` after `(` or `,` with `name` highlighted as `variable.parameter.named.lyng` and `:` as punctuation; excludes `::`.
- TextMate bundle version bumped to 0.0.3; README updated with details and guidance.
- Multiple Inheritance (MI) completed and enabled by default: - If you rely on literal `$...` strings:
- Active C3 Method Resolution Order (MRO) for deterministic, monotonic lookup across complex hierarchies and diamonds. - replace with `\$...` or `$$...`, or
- Qualified dispatch: - add `// feature: interpolation: off` at file top.
- `this@Type.member(...)` inside class bodies starts lookup at the specified ancestor. - Review any code relying on interpreter-era fallback behavior; 1.5.x assumes bytecode-first execution.
- Cast-based disambiguation: `(expr as Type).member(...)`, `(expr as? Type)?.member(...)` (works with existing safe-call `?.`). - For extern declarations, ensure members are explicitly declared where required.
- Field inheritance (`val`/`var`) under MI: - For named arguments/splats, verify call sites follow stricter ordering/duplication rules.
- Instance storage is disambiguated per declaring class; unqualified read/write resolves to the first match in MRO.
- Qualified read/write targets the chosen ancestor’s storage.
- Constructors and initialization:
- Direct bases are initialized left-to-right; each ancestor is initialized at most once (diamond-safe de-duplication).
- Header-specified constructor arguments are passed to direct bases.
- Visibility enforcement under MI:
- `private` visible only inside the declaring class body.
- `protected` visible inside the declaring class and its transitive subclasses. Additionally, ancestor classes can access protected members of their descendants if it's an override of a member known to the ancestor. Unrelated contexts cannot access it (qualification/casts do not bypass).
- Diagnostics improvements:
- Missing member/field messages include receiver class and linearization order; hints for `this@Type` or casts when helpful.
- Invalid `this@Type` reports that the qualifier is not an ancestor and shows the receiver lineage.
- `as`/`as?` cast errors include actual and target type names.
- Documentation updated (docs/OOP.md and tutorial quick-start) to reflect MI with active C3 MRO.
- CLI: Added `fmt` as a first-class Clikt subcommand.
- Default behavior: formats files to stdout (no in-place edits by default).
- Options:
- `--check`: check only; print files that would change; exit with code 2 if any changes are needed.
- `-i, --in-place`: write formatted result back to files.
- `--spacing`: apply spacing normalization.
- `--wrap`, `--wrapping`: enable line wrapping.
- Mutually exclusive: `--check` and `--in-place` together now produce an error and exit with code 1.
- Multi-file stdout prints headers `--- <path> ---` per file.
- `lyng --help` shows `fmt`; `lyng fmt --help` displays dedicated help.
- Fix: Property accessors (`get`, `set`, `private set`, `protected set`) are now correctly indented relative to the property declaration.
- Fix: Indentation now correctly carries over into blocks that start on extra‑indented lines (e.g., nested `if` statements or property accessor bodies).
- Fix: Formatting Markdown files no longer deletes content in `.lyng` code fences and works correctly with injected files (resolves clobbering, `StringIndexOutOfBoundsException`, and `nonempty text is not covered by block` errors).
- CLI: Preserved legacy script invocation fast-paths:
- `lyng script.lyng [args...]` executes the script directly.
- `lyng -- -file.lyng [args...]` executes a script whose name begins with `-`.
- CLI: Fixed a regression where the root help banner could print before subcommands.
- Root command no longer prints help when a subcommand (e.g., `fmt`) is invoked.
+250
View File
@@ -0,0 +1,250 @@
# Decimal (`lyng.decimal`)
`lyng.decimal` adds an arbitrary-precision decimal type to Lyng as a normal library module.
Import it when you need decimal arithmetic that should not inherit `Real`'s binary floating-point behavior:
```lyng
import lyng.decimal
```
## What `BigDecimal` Is For
Use `BigDecimal` when values are fundamentally decimal:
- money
- human-entered quantities
- exact decimal text
- predictable decimal rounding
- user-facing formatting and tests
Do not use it just because a number has a fractional part. `Real` is still the right type for ordinary double-precision numeric work.
## Creating Decimal Values
There are three supported conversions:
```lyng
import lyng.decimal
val a = 1.d
val b = 2.2.d
val c = "2.2".d
assertEquals("1", a.toStringExpanded())
assertEquals("2.2", b.toStringExpanded())
assertEquals("2.2", c.toStringExpanded())
```
The three forms mean different things:
- `1.d`: convert `Int -> BigDecimal`
- `2.2.d`: convert `Real -> BigDecimal`
- `"2.2".d`: parse exact decimal text
That distinction is intentional.
### `Real.d` vs `"..." .d`
`Real.d` preserves the current `Real` value. It does not pretend the source code was exact decimal text.
```lyng
import lyng.decimal
assertEquals("0.30000000000000004", (0.1 + 0.2).d.toStringExpanded())
assertEquals("0.3", "0.3".d.toStringExpanded())
```
This follows the "minimal confusion" rule:
- if you start from a `Real`, you get a decimal representation of that `Real`
- if you want exact decimal source text, use a `String`
## Factory Functions
The explicit factory methods are:
```lyng
import lyng.decimal
BigDecimal.fromInt(10)
BigDecimal.fromReal(2.5)
BigDecimal.fromString("12.34")
```
These are equivalent to the conversion-property forms, but sometimes clearer in APIs or generated code.
## Core Operations
`BigDecimal` supports:
- `+`
- `-`
- `*`
- `/`
- `%`
- unary `-`
- comparison operators
- equality operators
Examples:
```lyng
import lyng.decimal
assertEquals("3.75", ("1.5".d + "2.25".d).toStringExpanded())
assertEquals("1.25", ("2.5".d - "1.25".d).toStringExpanded())
assertEquals("3.0", ("1.5".d * 2.d).toStringExpanded())
assertEquals("0.5", (1.d / 2.d).toStringExpanded())
assert("2.0".d > "1.5".d)
assert("2.0".d == 2.d)
```
## Interoperability With `Int` and `Real`
The decimal module registers mixed-operand operator bridges so both sides read naturally:
```lyng
import lyng.decimal
assertEquals(3.d, 1 + 2.d)
assertEquals(3.d, 2.d + 1)
assertEquals(1.5.d, 1.d + 0.5)
assertEquals(1.5.d, 0.5 + 1.d)
assert(2 == 2.d)
assert(3 > 2.d)
```
Without this registration mechanism, only the cases directly implemented on the left-hand class would work. The bridge fills the gap for expressions such as `Int + BigDecimal` and `Real + BigDecimal`.
See [OperatorInterop.md](OperatorInterop.md) for the generic mechanism behind that.
## String Representation
Use `toStringExpanded()` when you want plain decimal output without scientific notation:
```lyng
import lyng.decimal
assertEquals("12.34", "12.34".d.toStringExpanded())
```
This is the recommended representation for:
- tests
- user-visible diagnostics
- decimal formatting checks
## Conversions Back To Built-ins
```lyng
import lyng.decimal
assertEquals(2, "2.9".d.toInt())
assertEquals(2.9, "2.9".d.toReal())
```
Use `toReal()` only when you are willing to return to binary floating-point semantics.
## Division Context
Division is the operation where precision and rounding matter most.
By default, decimal division uses:
- precision: `34` significant digits
- rounding: `HalfEven`
Example:
```lyng
import lyng.decimal
assertEquals("0.3333333333333333333333333333333333", (1.d / 3.d).toStringExpanded())
assertEquals("0.6666666666666666666666666666666667", ("2".d / 3.d).toStringExpanded())
```
## `withDecimalContext(...)`
Use `withDecimalContext(...)` to override decimal division rules inside a block:
```lyng
import lyng.decimal
assertEquals(
"0.3333333333",
withDecimalContext(10) { (1.d / 3.d).toStringExpanded() }
)
```
You can also pass an explicit context object:
```lyng
import lyng.decimal
val ctx = DecimalContext(6, DecimalRounding.HalfAwayFromZero)
assertEquals("0.666667", withDecimalContext(ctx) { ("2".d / 3.d).toStringExpanded() })
```
The context is dynamic and local to the block. After the block exits, the previous context is restored.
## Rounding Modes
Available rounding modes:
- `HalfEven`
- `HalfAwayFromZero`
- `HalfTowardsZero`
- `Ceiling`
- `Floor`
- `AwayFromZero`
- `TowardsZero`
Tie example at precision `2`:
```lyng
import lyng.decimal
assertEquals("0.12", withDecimalContext(2, DecimalRounding.HalfEven) { (1.d / 8.d).toStringExpanded() })
assertEquals("0.13", withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (1.d / 8.d).toStringExpanded() })
assertEquals("0.12", withDecimalContext(2, DecimalRounding.HalfTowardsZero) { (1.d / 8.d).toStringExpanded() })
assertEquals("0.13", withDecimalContext(2, DecimalRounding.Ceiling) { (1.d / 8.d).toStringExpanded() })
assertEquals("0.12", withDecimalContext(2, DecimalRounding.Floor) { (1.d / 8.d).toStringExpanded() })
```
Negative values follow the same named policy in the obvious direction:
```lyng
import lyng.decimal
assertEquals("-0.13", withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (-1.d / 8.d).toStringExpanded() })
assertEquals("-0.12", withDecimalContext(2, DecimalRounding.HalfTowardsZero) { (-1.d / 8.d).toStringExpanded() })
```
## Recommended Usage Rules
If you care about exact decimal source text:
```lyng
"12.34".d
```
If you intentionally convert an existing binary floating-point value:
```lyng
someReal.d
```
If you want local control over division:
```lyng
withDecimalContext(precision, rounding) { ... }
```
If you want custom mixed operators for your own type, follow the same pattern as Decimal and use the operator registry:
- define the operators on your own class
- choose a common class
- register mixed operand bridges
See [OperatorInterop.md](OperatorInterop.md).
+309
View File
@@ -0,0 +1,309 @@
# Operator Interop Registry
`lyng.operators` provides a runtime registry for mixed-class binary operators.
Import it when you want expressions such as:
- `1 + MyType(...)`
- `2 < MyType(...)`
- `3 == MyType(...)`
to work without modifying the built-in `Int` or `Real` classes.
```lyng
import lyng.operators
```
## Why This Exists
If your class defines:
```lyng
class Amount(val value: Int) {
fun plus(other: Amount) = Amount(value + other.value)
}
```
then:
```lyng
Amount(1) + Amount(2)
```
works, because the left operand already knows how to add another `Amount`.
But:
```lyng
1 + Amount(2)
```
does not naturally work, because `Int` has not been rewritten to know about `Amount`.
The operator interop registry solves exactly that problem.
## Mental Model
Registration describes a mixed pair:
- left class `L`
- right class `R`
- common class `C`
When Lyng sees `L op R`, it:
1. converts `L -> C`
2. converts `R -> C`
3. evaluates the operator as `C op C`
So the registry is a bridge, not a separate arithmetic engine.
## API
```lyng
OperatorInterop.register(
leftClass,
rightClass,
commonClass,
operators,
leftToCommon,
rightToCommon
)
```
Parameters:
- `leftClass`: original left operand class
- `rightClass`: original right operand class
- `commonClass`: class that will actually execute the operator methods
- `operators`: list of operators enabled for this pair
- `leftToCommon`: conversion from left operand to common class
- `rightToCommon`: conversion from right operand to common class
## Supported Operators
`BinaryOperator` values:
- `Plus`
- `Minus`
- `Mul`
- `Div`
- `Mod`
- `Compare`
- `Equals`
Meaning:
- `Compare` enables `<`, `<=`, `>`, `>=`, and `<=>`
- `Equals` enables `==` and `!=`
## Minimal Working Example
```lyng
package test.decimalbox
import lyng.operators
class DecimalBox(val value: Int) {
fun plus(other: DecimalBox) = DecimalBox(value + other.value)
fun minus(other: DecimalBox) = DecimalBox(value - other.value)
fun mul(other: DecimalBox) = DecimalBox(value * other.value)
fun div(other: DecimalBox) = DecimalBox(value / other.value)
fun mod(other: DecimalBox) = DecimalBox(value % other.value)
fun compareTo(other: DecimalBox) = value <=> other.value
}
OperatorInterop.register(
Int,
DecimalBox,
DecimalBox,
[
BinaryOperator.Plus,
BinaryOperator.Minus,
BinaryOperator.Mul,
BinaryOperator.Div,
BinaryOperator.Mod,
BinaryOperator.Compare,
BinaryOperator.Equals
],
{ x: Int -> DecimalBox(x) },
{ x: DecimalBox -> x }
)
```
Then:
```lyng
import test.decimalbox
assertEquals(DecimalBox(3), 1 + DecimalBox(2))
assertEquals(DecimalBox(1), 3 - DecimalBox(2))
assertEquals(DecimalBox(8), 4 * DecimalBox(2))
assertEquals(DecimalBox(4), 8 / DecimalBox(2))
assertEquals(DecimalBox(1), 7 % DecimalBox(2))
assert(1 < DecimalBox(2))
assert(2 <= DecimalBox(2))
assert(3 > DecimalBox(2))
assert(2 == DecimalBox(2))
assert(2 != DecimalBox(3))
```
## How Decimal Uses It
`lyng.decimal` uses this same mechanism so that:
```lyng
import lyng.decimal
1 + 2.d
0.5 + 1.d
2 == 2.d
3 > 2.d
```
work naturally even though `Int` and `Real` themselves were not edited to know `BigDecimal`.
The shape is:
- `leftClass = Int` or `Real`
- `rightClass = BigDecimal`
- `commonClass = BigDecimal`
- convert built-ins into `BigDecimal`
- leave `BigDecimal` values unchanged
## Step-By-Step Pattern For Your Own Type
### 1. Pick the common class
Choose one class that will be the actual arithmetic domain.
For numeric-like types, that is usually your own class:
```lyng
class Rational(...)
```
### 2. Implement operators on that class
The common class should define the operations you plan to register.
Example:
```lyng
class Rational(val num: Int, val den: Int) {
fun plus(other: Rational) = Rational(num * other.den + other.num * den, den * other.den)
fun minus(other: Rational) = Rational(num * other.den - other.num * den, den * other.den)
fun mul(other: Rational) = Rational(num * other.num, den * other.den)
fun div(other: Rational) = Rational(num * other.den, den * other.num)
fun compareTo(other: Rational) = (num * other.den) <=> (other.num * den)
static fun fromInt(value: Int) = Rational(value, 1)
}
```
### 3. Register the mixed pair
```lyng
import lyng.operators
OperatorInterop.register(
Int,
Rational,
Rational,
[
BinaryOperator.Plus,
BinaryOperator.Minus,
BinaryOperator.Mul,
BinaryOperator.Div,
BinaryOperator.Compare,
BinaryOperator.Equals
],
{ x: Int -> Rational.fromInt(x) },
{ x: Rational -> x }
)
```
### 4. Use it
```lyng
assertEquals(Rational(3, 2), 1 + Rational(1, 2))
assert(Rational(3, 2) == Rational(3, 2))
assert(2 > Rational(3, 2))
```
## Registering More Than One Built-in Type
If you want both `Int + MyType` and `Real + MyType`, register both pairs explicitly:
```lyng
OperatorInterop.register(
Int,
MyType,
MyType,
[BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals],
{ x: Int -> MyType.fromInt(x) },
{ x: MyType -> x }
)
OperatorInterop.register(
Real,
MyType,
MyType,
[BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals],
{ x: Real -> MyType.fromReal(x) },
{ x: MyType -> x }
)
```
Each mixed pair is independent.
## Pure Lyng Registration
This mechanism is intentionally useful from pure Lyng code, not only from Kotlin-backed modules.
That means you can:
- declare a class in Lyng
- define its operators in Lyng
- register mixed operand bridges in Lyng
without touching compiler internals.
## Where To Register
Register once during module initialization.
Top-level module code is a good place:
```lyng
package my.rational
import lyng.operators
class Rational(...)
OperatorInterop.register(...)
```
That keeps registration close to the type declaration and makes importing the module enough to activate the interop.
## What Registration Does Not Do
The registry does not:
- invent operators your common class does not implement
- change the original `Int`, `Real`, or other built-ins
- automatically cover every class pair
- replace normal method overload resolution when the left-hand class already knows what to do
It only teaches Lyng how to bridge a specific mixed pair into a common class for the listed operators.
## Recommended Design Rules
If you want interop to feel natural:
- choose one obvious common class
- make conversions explicit and unsurprising
- implement `compareTo` if you want ordering operators
- register `Equals` whenever mixed equality should work
- keep the registered operator list minimal and accurate
For decimal-like semantics, also read [Decimal.md](Decimal.md).
+8
View File
@@ -17,6 +17,14 @@ Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,T
- Supported escapes: `\n`, `\r`, `\t`, `\"`, `\\`, `\uXXXX` (4 hex digits). - Supported escapes: `\n`, `\r`, `\t`, `\"`, `\\`, `\uXXXX` (4 hex digits).
- Unicode escapes use exactly 4 hex digits (for example: `"\u0416"` -> `Ж`). - Unicode escapes use exactly 4 hex digits (for example: `"\u0416"` -> `Ж`).
- Unknown `\x` escapes in strings are preserved literally as two characters (`\` and `x`). - Unknown `\x` escapes in strings are preserved literally as two characters (`\` and `x`).
- String interpolation is supported:
- identifier form: `"$name"`
- expression form: `"${expr}"`
- escaped dollar: `"\$"` and `"$$"` both produce literal `$`.
- `\\$x` means backslash + interpolated `x`.
- Per-file opt-out is supported via leading comment directive:
- `// feature: interpolation: off`
- with this directive, `$...` stays literal text.
- Numbers: `Int` (`123`, `1_000`), `Real` (`1.2`, `1e3`), hex (`0xFF`). - Numbers: `Int` (`123`, `1_000`), `Real` (`1.2`, `1e3`), hex (`0xFF`).
- Char: `'a'`, escaped chars supported. - Char: `'a'`, escaped chars supported.
- Supported escapes: `\n`, `\r`, `\t`, `\'`, `\\`, `\uXXXX` (4 hex digits). - Supported escapes: `\n`, `\r`, `\t`, `\'`, `\\`, `\uXXXX` (4 hex digits).
+22
View File
@@ -72,6 +72,14 @@ For module-level APIs, the default workflow is:
1. declare globals in Lyng using `extern fun` / `extern val` / `extern var`; 1. declare globals in Lyng using `extern fun` / `extern val` / `extern var`;
2. bind Kotlin implementation via `ModuleScope.globalBinder()`. 2. bind Kotlin implementation via `ModuleScope.globalBinder()`.
This is also the recommended way to expose a Kotlin-backed value that should behave like a true
Lyng global variable/property. If you need `x` to read/write through Kotlin on every access, use
`extern var` / `extern val` plus `bindGlobalVar(...)`.
Do not use `addConst(...)` for this case: `addConst(...)` installs a value, not a Kotlin-backed
property accessor. It is appropriate for fixed values and objects, but not for a global that should
delegate reads/writes back into Kotlin state.
```kotlin ```kotlin
import net.sergeych.lyng.bridge.* import net.sergeych.lyng.bridge.*
import net.sergeych.lyng.obj.ObjInt import net.sergeych.lyng.obj.ObjInt
@@ -117,6 +125,12 @@ assertEquals("changed", globalProp)
assertEquals("1.0.0", globalVersion) assertEquals("1.0.0", globalVersion)
``` ```
Minimal rule of thumb:
- use `bindGlobalFun(...)` for global functions
- use `bindGlobalVar(...)` for Kotlin-backed global variables/properties
- use `addConst(...)` only for fixed values/objects that do not need getter/setter behavior
For custom argument handling and full runtime access: For custom argument handling and full runtime access:
```kotlin ```kotlin
@@ -462,14 +476,21 @@ im.addPackage("my.tools") { module: ModuleScope ->
module.eval( module.eval(
""" """
extern val version: String extern val version: String
extern var status: String
extern fun triple(x: Int): Int extern fun triple(x: Int): Int
""".trimIndent() """.trimIndent()
) )
val binder = module.globalBinder() val binder = module.globalBinder()
var status = "ready"
binder.bindGlobalVar( binder.bindGlobalVar(
name = "version", name = "version",
get = { "1.0" } get = { "1.0" }
) )
binder.bindGlobalVar(
name = "status",
get = { status },
set = { status = it }
)
binder.bindGlobalFun1<Int>("triple") { x -> binder.bindGlobalFun1<Int>("triple") { x ->
ObjInt.of((x * 3).toLong()) ObjInt.of((x * 3).toLong())
} }
@@ -479,6 +500,7 @@ im.addPackage("my.tools") { module: ModuleScope ->
scope.eval(""" scope.eval("""
import my.tools.* import my.tools.*
val v = triple(14) val v = triple(14)
status = "busy"
""") """)
val v = scope.eval("v").toKotlin(scope) // -> 42 val v = scope.eval("v").toKotlin(scope) // -> 42
``` ```
+15 -4
View File
@@ -40,7 +40,18 @@ Console.setCursorVisible(true)
Console.flush() Console.flush()
``` ```
Interactive sample script in this repo: #### Tetris sample
The repository includes a full interactive Tetris sample that demonstrates:
- alternate screen rendering
- raw keyboard input
- resize handling
- typed console events
![Lyng Tetris sample](/tetris.png)
Run it from the project root in a real TTY:
```bash ```bash
lyng examples/tetris_console.lyng lyng examples/tetris_console.lyng
@@ -50,7 +61,7 @@ lyng examples/tetris_console.lyng
- `Console.isSupported(): Bool` — whether console control is available on this platform/runtime. - `Console.isSupported(): Bool` — whether console control is available on this platform/runtime.
- `Console.isTty(): Bool` — whether output is attached to a TTY. - `Console.isTty(): Bool` — whether output is attached to a TTY.
- `Console.ansiLevel(): String` — `NONE`, `BASIC16`, `ANSI256`, `TRUECOLOR`. - `Console.ansiLevel(): ConsoleAnsiLevel` — `NONE`, `BASIC16`, `ANSI256`, `TRUECOLOR`.
- `Console.geometry(): ConsoleGeometry?` — `{columns, rows}` as typed object or `null`. - `Console.geometry(): ConsoleGeometry?` — `{columns, rows}` as typed object or `null`.
- `Console.details(): ConsoleDetails` — consolidated capability object. - `Console.details(): ConsoleDetails` — consolidated capability object.
- `Console.write(text: String)` — writes to console output. - `Console.write(text: String)` — writes to console output.
@@ -81,9 +92,9 @@ launch {
#### Event format #### Event format
`Console.events()` emits `ConsoleEvent` with at least: `Console.events()` emits `ConsoleEvent` with:
- `type: String` — `resize`, `keydown`, `keyup` - `type: ConsoleEventType` — `UNKNOWN`, `RESIZE`, `KEY_DOWN`, `KEY_UP`
Additional fields: Additional fields:
+46
View File
@@ -1573,6 +1573,52 @@ same as:
assert(s[1] == 'a') assert(s[1] == 'a')
>>> void >>> void
### String interpolation
Supported forms:
- `$name`
- `${expr}`
Literal dollar forms:
- `\$` -> `$`
- `$$` -> `$`
Example:
val name = "Lyng"
assertEquals("hello, Lyng!", "hello, $name!")
assertEquals("sum=3", "sum=${1+2}")
assertEquals("\$name", "\$name")
assertEquals("\$name", "$$name")
assertEquals("\\Lyng", "\\$name")
>>> void
Interpolation and `printf`-style formatting can be combined when needed:
val method = "transfer"
val argc = 2
val compact = "%s:%d"(method, argc)
assertEquals("call=transfer:2", "call=$compact")
assertEquals("[transfer:2] ok", "[${"%s:%d"(method, argc)}] ok")
>>> void
Interpolation also works well with regex patterns. To keep a literal `$` in the
regex, escape it in the resulting pattern:
val currency = "USD"
val amount = 15
val escapedDollar = "\\$"
val re = Regex("^${currency}${escapedDollar}${amount}$")
assert("USD$15" =~ re)
assert("USD15" !~ re)
>>> void
If you need old literal behavior in a file, add a leading directive comment:
// feature: interpolation: off
### Char literal escapes ### Char literal escapes
Are the same as in string literals with little difference: Are the same as in string literals with little difference:
+67
View File
@@ -5,6 +5,73 @@ For a programmer-focused migration summary, see `docs/whats_new_1_5.md`.
## Language Features ## Language Features
### Decimal Arithmetic Module (`lyng.decimal`)
Lyng now ships a first-class decimal module built as a regular extension library rather than a deep core special case.
It provides:
- `BigDecimal`
- convenient `.d` conversions from `Int`, `Real`, and `String`
- mixed arithmetic with `Int` and `Real`
- local division precision and rounding control via `withDecimalContext(...)`
```lyng
import lyng.decimal
assertEquals("3", (1 + 2.d).toStringExpanded())
assertEquals("0.30000000000000004", (0.1 + 0.2).d.toStringExpanded())
assertEquals("0.3", "0.3".d.toStringExpanded())
assertEquals(
"0.3333333333",
withDecimalContext(10) { (1.d / 3.d).toStringExpanded() }
)
```
The distinction between `Real -> Decimal` and exact decimal parsing is explicit by design:
- `2.2.d` converts the current `Real` value
- `"2.2".d` parses exact decimal text
See [Decimal](Decimal.md).
### Binary Operator Interop Registry
Lyng now provides a general mechanism for mixed binary operators through `lyng.operators`.
This solves cases like:
- `Int + MyType`
- `Real < MyType`
- `Int == MyType`
without requiring changes to built-in classes.
```lyng
import lyng.operators
class DecimalBox(val value: Int) {
fun plus(other: DecimalBox) = DecimalBox(value + other.value)
fun compareTo(other: DecimalBox) = value <=> other.value
}
OperatorInterop.register(
Int,
DecimalBox,
DecimalBox,
[BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals],
{ x: Int -> DecimalBox(x) },
{ x: DecimalBox -> x }
)
assertEquals(DecimalBox(3), 1 + DecimalBox(2))
assert(1 < DecimalBox(2))
assert(2 == DecimalBox(2))
```
`lyng.decimal` uses this same mechanism internally to interoperate with `Int` and `Real`.
See [Operator Interop Registry](OperatorInterop.md).
### Class Properties with Accessors ### Class Properties with Accessors
Classes now support properties with custom `get()` and `set()` accessors. Properties in Lyng do **not** have automatic backing fields; they are pure accessors. Classes now support properties with custom `get()` and `set()` accessors. Properties in Lyng do **not** have automatic backing fields; they are pure accessors.
Regular → Executable
+306 -151
View File
@@ -8,15 +8,25 @@
* - Up arrow or W: rotate * - Up arrow or W: rotate
* - Down arrow or S: soft drop * - Down arrow or S: soft drop
* - Space: hard drop * - Space: hard drop
* - Q or Escape: quit * - P or Escape: pause
* - Q: quit
Tsted to score:
sergeych@sergeych-XPS-17-9720:~$ ~/dev/lyng/examples/tetris_console.lyng
Bye.
Score: 435480
Lines: 271
Level: 28
Ssergeych@sergeych-XPS-17-9720:~$
*/ */
import lyng.io.console import lyng.io.console
import lyng.io.fs
val MIN_COLS = 56 val MIN_COLS = 56
val MIN_ROWS = 24 val MIN_ROWS = 24
val PANEL_WIDTH = 24 val PANEL_WIDTH = 24
val BOARD_MARGIN_ROWS = 8 val BOARD_MARGIN_ROWS = 5
val BOARD_MIN_W = 10 val BOARD_MIN_W = 10
val BOARD_MAX_W = 16 val BOARD_MAX_W = 16
val BOARD_MIN_H = 16 val BOARD_MIN_H = 16
@@ -26,9 +36,11 @@ val DROP_FRAMES_BASE = 15
val DROP_FRAMES_MIN = 3 val DROP_FRAMES_MIN = 3
val FRAME_DELAY_MS = 35 val FRAME_DELAY_MS = 35
val RESIZE_WAIT_MS = 250 val RESIZE_WAIT_MS = 250
val MAX_PENDING_INPUTS = 64
val ROTATION_KICKS = [0, -1, 1, -2, 2] val ROTATION_KICKS = [0, -1, 1, -2, 2]
val ANSI_ESC = "\u001b[" val ANSI_ESC = "\u001b["
val ANSI_RESET = ANSI_ESC + "0m" val ANSI_RESET = ANSI_ESC + "0m"
val ERROR_LOG_PATH = "/tmp/lyng_tetris_errors.log"
val UNICODE_BLOCK = "██" val UNICODE_BLOCK = "██"
val UNICODE_TOP_LEFT = "┌" val UNICODE_TOP_LEFT = "┌"
val UNICODE_TOP_RIGHT = "┐" val UNICODE_TOP_RIGHT = "┐"
@@ -64,6 +76,7 @@ class GameState(
var level = 1 var level = 1
var running = true var running = true
var gameOver = false var gameOver = false
var paused = false
} }
class LoopFrame(val resized: Bool, val originRow: Int, val originCol: Int) {} class LoopFrame(val resized: Bool, val originRow: Int, val originCol: Int) {}
@@ -72,44 +85,44 @@ fun clearAndHome() {
Console.home() Console.home()
} }
fun logError(message: String, err: Object?): Void {
try {
var details = ""
if (err != null) {
try {
details = ": " + err
} catch (_: Object) {
details = ": <error-format-failed>"
}
}
Path(ERROR_LOG_PATH).appendUtf8(message + details + "\n")
} catch (_: Object) {
// Never let logging errors affect gameplay.
}
}
fun repeatText(s: String, n: Int): String { fun repeatText(s: String, n: Int): String {
var out: String = "" var out: String = ""
var i = 0 for (i in 0..<n) {
while (i < n) {
out += s out += s
i = i + 1
} }
out out
} }
fun maxInt(a: Int, b: Int): Int { fun max<T>(a: T, b: T): T = if (a > b) a else b
if (a > b) a else b
}
fun minInt(a: Int, b: Int): Int {
if (a < b) a else b
}
fun clampInt(v: Int, lo: Int, hi: Int): Int {
minInt(hi, maxInt(lo, v))
}
fun emptyRow(width: Int): Row { fun emptyRow(width: Int): Row {
val r: Row = [] val r: Row = []
var x = 0 for (x in 0..<width) {
while (x < width) {
r.add(0) r.add(0)
x = x + 1
} }
r r
} }
fun createBoard(width: Int, height: Int): Board { fun createBoard(width: Int, height: Int): Board {
val b: Board = [] val b: Board = []
var y = 0 for (y in 0..<height) {
while (y < height) {
b.add(emptyRow(width)) b.add(emptyRow(width))
y = y + 1
} }
b b
} }
@@ -139,22 +152,30 @@ fun emptyCellText(useColor: Bool): String {
} }
fun canPlace(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int, px: Int, py: Int): Bool { fun canPlace(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int, px: Int, py: Int): Bool {
val piece: Piece = PIECES[pieceId - 1] try {
val cells = piece.rotations[rot] if (pieceId < 1 || pieceId > 7) return false
val piece: Piece = PIECES[pieceId - 1]
if (rot < 0 || rot >= piece.rotations.size) return false
val cells = piece.rotations[rot]
for (cell in cells) { for (cell in cells) {
val x = px + cell[0] val x = px + cell[0]
val y = py + cell[1] val y = py + cell[1]
if (x < 0 || x >= boardW) return false if (x < 0 || x >= boardW) return false
if (y >= boardH) return false if (y >= boardH) return false
if (y >= 0) { if (y >= 0) {
val row = board[y] if (y >= board.size) return false
if (row[x] != 0) return false val row = board[y]
if (row == null) return false
if (row[x] != 0) return false
}
} }
true
} catch (_: Object) {
false
} }
true
} }
fun lockPiece(board: Board, pieceId: Int, rot: Int, px: Int, py: Int): Void { fun lockPiece(board: Board, pieceId: Int, rot: Int, px: Int, py: Int): Void {
@@ -165,9 +186,11 @@ fun lockPiece(board: Board, pieceId: Int, rot: Int, px: Int, py: Int): Void {
val x = px + cell[0] val x = px + cell[0]
val y = py + cell[1] val y = py + cell[1]
if (y >= 0) { if (y >= 0 && y < board.size) {
val row = board[y] val row = board[y]
row[x] = pieceId if (row != null) {
row[x] = pieceId
}
} }
} }
} }
@@ -178,23 +201,31 @@ fun clearCompletedLines(board: Board, boardW: Int, boardH: Int): Int {
var cleared = 0 var cleared = 0
while (y >= 0) { while (y >= 0) {
if (y >= b.size) {
y--
continue
}
val row = b[y] val row = b[y]
if (row == null) {
b.removeAt(y)
b.insertAt(0, emptyRow(boardW))
cleared++
continue
}
var full = true var full = true
var x = 0 for (x in 0..<boardW) {
while (x < boardW) {
if (row[x] == 0) { if (row[x] == 0) {
full = false full = false
break break
} }
x = x + 1
} }
if (full) { if (full) {
b.removeAt(y) b.removeAt(y)
b.insertAt(0, emptyRow(boardW)) b.insertAt(0, emptyRow(boardW))
cleared = cleared + 1 cleared++
} else { } else {
y = y - 1 y--
} }
} }
@@ -220,7 +251,7 @@ fun tryRotateCw(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int,
val nr = (rot + 1) % rotations val nr = (rot + 1) % rotations
for (kx in ROTATION_KICKS) { for (kx in ROTATION_KICKS) {
val nx = px + kx val nx = px + kx
if (canPlace(board, boardW, boardH, pieceId, nr, nx, py)) { if (canPlace(board, boardW, boardH, pieceId, nr, nx, py) == true) {
return RotateResult(true, nr, nx) return RotateResult(true, nr, nx)
} }
} }
@@ -230,21 +261,18 @@ fun tryRotateCw(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int,
fun nextPreviewLines(pieceId: Int, useColor: Bool): List<String> { fun nextPreviewLines(pieceId: Int, useColor: Bool): List<String> {
val out: List<String> = [] val out: List<String> = []
if (pieceId <= 0) { if (pieceId <= 0) {
out.add(" ") for (i in 0..<4) {
out.add(" ") out.add(" ")
out.add(" ") }
out.add(" ")
return out return out
} }
val piece: Piece = PIECES[pieceId - 1] val piece: Piece = PIECES[pieceId - 1]
val cells = piece.rotations[0] val cells = piece.rotations[0]
var y = 0 for (y in 0..<4) {
while (y < 4) {
var line = "" var line = ""
var x = 0 for (x in 0..<4) {
while (x < 4) {
var filled = false var filled = false
for (cell in cells) { for (cell in cells) {
if (cell[0] == x && cell[1] == y) { if (cell[0] == x && cell[1] == y) {
@@ -253,10 +281,8 @@ fun nextPreviewLines(pieceId: Int, useColor: Bool): List<String> {
} }
} }
line += if (filled) blockText(pieceId, useColor) else " " line += if (filled) blockText(pieceId, useColor) else " "
x = x + 1
} }
out.add(line) out.add(line)
y = y + 1
} }
out out
} }
@@ -296,52 +322,101 @@ fun render(
panel.add("W/Up: rotate") panel.add("W/Up: rotate")
panel.add("S/Down: drop") panel.add("S/Down: drop")
panel.add("Space: hard drop") panel.add("Space: hard drop")
panel.add("Q/Esc: quit") panel.add("P/Esc: pause")
panel.add("Q: quit")
val frameLines: List<String> = [] val frameLines: List<String> = []
var y = 0 for (y in 0..<boardH) {
while (y < boardH) {
var line = UNICODE_VERTICAL var line = UNICODE_VERTICAL
var x = 0 for (x in 0..<boardW) {
while (x < boardW) {
val a = activeCellId(state.pieceId, state.rot, state.px, state.py, x, y) val a = activeCellId(state.pieceId, state.rot, state.px, state.py, x, y)
val row = board[y] val row = if (y < board.size) board[y] else null
val b = row[x] val b = if (row == null) 0 else row[x]
val id = if (a > 0) a else b val id = if (a > 0) a else b
line += if (id > 0) blockText(id, useColor) else emptyCellText(useColor) line += if (id > 0) blockText(id, useColor) else emptyCellText(useColor)
x = x + 1
} }
line += UNICODE_VERTICAL line += UNICODE_VERTICAL
val p = y if (y < panel.size) {
if (p < panel.size) { line += " " + panel[y]
line += " " + panel[p]
} }
frameLines.add(line) frameLines.add(line)
y = y + 1
} }
frameLines.add(bottomBorder) frameLines.add(bottomBorder)
val prev = prevFrameLines for (i in 0..<frameLines.size) {
var i = 0
while (i < frameLines.size) {
val line = frameLines[i] val line = frameLines[i]
val old = if (i < prev.size) prev[i] else null val old = if (i < prevFrameLines.size) prevFrameLines[i] else null
if (old != line) { if (old != line) {
Console.moveTo(originRow + i, originCol) Console.moveTo(originRow + i, originCol)
Console.clearLine() Console.clearLine()
Console.write(line) Console.write(line)
} }
i = i + 1
} }
frameLines frameLines
} }
fun fitLine(line: String, width: Int): String {
val maxLen = if (width > 0) width else 0
if (maxLen <= 0) return ""
if (line.size >= maxLen) return line[..<maxLen]
line + repeatText(" ", maxLen - line.size)
}
fun renderPauseOverlay(
originRow: Int,
originCol: Int,
boardW: Int,
boardH: Int,
): Void {
val contentWidth = boardW * 2 + 2 + 3 + PANEL_WIDTH
val contentHeight = boardH + 1
val lines: List<String> = []
lines.add("PAUSED")
lines.add("")
lines.add("Any key: continue game")
lines.add("Esc: exit game")
lines.add("")
lines.add("Move: A/D or arrows")
lines.add("Rotate: W or Up")
lines.add("Drop: S/Down, Space hard drop")
var innerWidth = 0
for (line in lines) {
if (line.size > innerWidth) innerWidth = line.size
}
innerWidth += 4
val maxInner = max(12, contentWidth - 2)
if (innerWidth > maxInner) innerWidth = maxInner
if (innerWidth % 2 != 0) innerWidth--
val boxWidth = innerWidth + 2
val boxHeight = lines.size + 2
val left = originCol + max(0, (contentWidth - boxWidth) / 2)
val top = originRow + max(0, (contentHeight - boxHeight) / 2)
val topBorder = UNICODE_TOP_LEFT + repeatText(UNICODE_HORIZONTAL, innerWidth / 2) + UNICODE_TOP_RIGHT
val bottomBorder = UNICODE_BOTTOM_LEFT + repeatText(UNICODE_HORIZONTAL, innerWidth / 2) + UNICODE_BOTTOM_RIGHT
Console.moveTo(top, left)
Console.write(topBorder)
for (i in 0..<lines.size) {
Console.moveTo(top + 1 + i, left)
Console.write(UNICODE_VERTICAL + fitLine(" " + lines[i], innerWidth) + UNICODE_VERTICAL)
}
Console.moveTo(top + boxHeight - 1, left)
Console.write(bottomBorder)
}
fun waitForMinimumSize(minCols: Int, minRows: Int): Object { fun waitForMinimumSize(minCols: Int, minRows: Int): Object {
while (true) { while (true) {
val g = Console.geometry() val g = Console.geometry()
@@ -351,9 +426,23 @@ fun waitForMinimumSize(minCols: Int, minRows: Int): Object {
if (cols >= minCols && rows >= minRows) return g if (cols >= minCols && rows >= minRows) return g
clearAndHome() clearAndHome()
println("Lyng Tetris needs at least %sx%s terminal size."(minCols, minRows)) val lines: List<String> = []
println("Current: %sx%s"(cols, rows)) lines.add("Lyng Tetris needs at least %sx%s terminal size."(minCols, minRows))
println("Resize the console window to continue...") lines.add("Current: %sx%s"(cols, rows))
lines.add("Resize the console window to continue.")
val visibleLines = if (rows < lines.size) rows else lines.size
if (visibleLines > 0) {
val startRow = max(1, ((rows - visibleLines) / 2) + 1)
for (i in 0..<visibleLines) {
val line = lines[i]
val startCol = max(1, ((cols - line.size) / 2) + 1)
Console.moveTo(startRow + i, startCol)
Console.clearLine()
Console.write(line)
}
Console.flush()
}
delay(RESIZE_WAIT_MS) delay(RESIZE_WAIT_MS)
} }
} }
@@ -434,8 +523,8 @@ if (!Console.isSupported()) {
val cols = g0?.columns ?: MIN_COLS val cols = g0?.columns ?: MIN_COLS
val rows = g0?.rows ?: MIN_ROWS val rows = g0?.rows ?: MIN_ROWS
val boardW = clampInt((cols - PANEL_WIDTH) / 2, BOARD_MIN_W, BOARD_MAX_W) val boardW = clamp((cols - PANEL_WIDTH) / 2, BOARD_MIN_W..BOARD_MAX_W)
val boardH = clampInt(rows - BOARD_MARGIN_ROWS, BOARD_MIN_H, BOARD_MAX_H) val boardH = clamp(rows - BOARD_MARGIN_ROWS, BOARD_MIN_H..BOARD_MAX_H)
val board: Board = createBoard(boardW, boardH) val board: Board = createBoard(boardW, boardH)
@@ -452,8 +541,9 @@ if (!Console.isSupported()) {
) )
var prevFrameLines: List<String> = [] var prevFrameLines: List<String> = []
val gameMutex = Mutex() val gameMutex: Mutex = Mutex()
var hasResizeEvent = false var forceRedraw = false
val pendingInputs: List<String> = []
val rawModeEnabled = Console.setRawMode(true) val rawModeEnabled = Console.setRawMode(true)
if (!rawModeEnabled) { if (!rawModeEnabled) {
@@ -461,39 +551,66 @@ if (!Console.isSupported()) {
println("Use jlyng in an interactive terminal with raw input support.") println("Use jlyng in an interactive terminal with raw input support.")
void void
} else { } else {
val useColor = Console.ansiLevel() != "NONE" val useColor = Console.ansiLevel() != ConsoleAnsiLevel.NONE
Console.enterAltScreen() Console.enterAltScreen()
Console.setCursorVisible(false) Console.setCursorVisible(false)
clearAndHome() clearAndHome()
fun resetActivePiece(s: GameState): Void {
s.pieceId = nextPieceId()
s.rot = 0
s.px = (boardW / 2) - 2
s.py = -1
}
fun applyKeyInput(s: GameState, key: String): Void { fun applyKeyInput(s: GameState, key: String): Void {
if (key == "__CTRL_C__" || key == "q" || key == "Q" || key == "Escape") { try {
s.running = false if (key == "__CTRL_C__") {
} s.running = false
else if (key == "ArrowLeft" || key == "a" || key == "A") {
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px - 1, s.py)) s.px = s.px - 1
}
else if (key == "ArrowRight" || key == "d" || key == "D") {
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px + 1, s.py)) s.px = s.px + 1
}
else if (key == "ArrowUp" || key == "w" || key == "W") {
val rr: RotateResult = tryRotateCw(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py)
if (rr.ok) {
s.rot = rr.rot
s.px = rr.px
} }
} else if (s.paused) {
else if (key == "ArrowDown" || key == "s" || key == "S") { if (key == "Escape") {
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1)) { s.running = false
s.py = s.py + 1 } else {
s.score = s.score + 1 s.paused = false
forceRedraw = true
}
} }
} else if (key == "p" || key == "P" || key == "Escape") {
else if (key == " ") { s.paused = true
while (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1)) { forceRedraw = true
s.py = s.py + 1
s.score = s.score + 2
} }
else if (key == "q" || key == "Q") {
s.running = false
}
else if (key == "ArrowLeft" || key == "a" || key == "A") {
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px - 1, s.py) == true) s.px--
}
else if (key == "ArrowRight" || key == "d" || key == "D") {
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px + 1, s.py) == true) s.px++
}
else if (key == "ArrowUp" || key == "w" || key == "W") {
val rr: RotateResult = tryRotateCw(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py)
if (rr.ok) {
s.rot = rr.rot
s.px = rr.px
}
}
else if (key == "ArrowDown" || key == "s" || key == "S") {
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1) == true) {
s.py++
s.score++
}
}
else if (key == " ") {
while (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1) == true) {
s.py++
s.score += 2
}
}
} catch (inputErr: Object) {
logError("applyKeyInput recovered after error", inputErr)
resetActivePiece(s)
} }
} }
@@ -503,26 +620,47 @@ if (!Console.isSupported()) {
try { try {
for (ev in Console.events()) { for (ev in Console.events()) {
if (!inputRunning) break if (!inputRunning) break
if (ev is ConsoleKeyEvent) {
val ke = ev as ConsoleKeyEvent // Isolate per-event failures so one bad event does not unwind the stream.
if (ke.type == "keydown") { try {
val key = ke.key if (ev is ConsoleKeyEvent) {
val ctrl = ke.ctrl val ke = ev as ConsoleKeyEvent
gameMutex.withLock { if (ke.type == ConsoleEventType.KEY_DOWN) {
var key = ""
var ctrl = false
try {
key = ke.key
} catch (keyErr: Object) {
logError("Input key read error", keyErr)
}
try {
ctrl = ke.ctrl == true
} catch (_: Object) {
ctrl = false
}
if (key == "") {
logError("Dropped key event with empty/null key", null)
continue
}
val mapped = if (ctrl && (key == "c" || key == "C")) "__CTRL_C__" else key val mapped = if (ctrl && (key == "c" || key == "C")) "__CTRL_C__" else key
applyKeyInput(state, mapped) val mm: Mutex = gameMutex
mm.withLock {
if (pendingInputs.size >= MAX_PENDING_INPUTS) {
pendingInputs.removeAt(0)
}
pendingInputs.add(mapped)
}
} }
} }
} else if (ev is ConsoleResizeEvent) { } catch (eventErr: Object) {
gameMutex.withLock { // Keep the input stream alive; report for diagnostics.
hasResizeEvent = true logError("Input event error", eventErr)
prevFrameLines = []
}
} }
} }
} catch (err: Exception) { } catch (err: Object) {
// Keep game alive: transient console-event failures should not force quit. // Recover stream-level failures by recreating event stream in next loop turn.
if (!inputRunning) break if (!inputRunning) break
logError("Input stream recovered after error", err)
Console.setRawMode(true) Console.setRawMode(true)
delay(50) delay(50)
} }
@@ -540,16 +678,10 @@ if (!Console.isSupported()) {
return null return null
} }
var resized = false
gameMutex.withLock {
resized = hasResizeEvent
hasResizeEvent = false
}
val contentCols = boardW * 2 + 2 + 3 + PANEL_WIDTH val contentCols = boardW * 2 + 2 + 3 + PANEL_WIDTH
val contentRows = boardH + 1 val contentRows = boardH + 1
val requiredCols = maxInt(MIN_COLS, contentCols) val requiredCols = max(MIN_COLS, contentCols)
val requiredRows = maxInt(MIN_ROWS, contentRows) val requiredRows = max(MIN_ROWS, contentRows)
if (c < requiredCols || r < requiredRows) { if (c < requiredCols || r < requiredRows) {
waitForMinimumSize(requiredCols, requiredRows) waitForMinimumSize(requiredCols, requiredRows)
clearAndHome() clearAndHome()
@@ -557,21 +689,21 @@ if (!Console.isSupported()) {
return null return null
} }
val originCol = maxInt(1, ((c - contentCols) / 2) + 1) val originCol = max(1, ((c - contentCols) / 2) + 1)
val originRow = maxInt(1, ((r - contentRows) / 2) + 1) val originRow = max(1, ((r - contentRows) / 2) + 1)
LoopFrame(resized, originRow, originCol) LoopFrame(false, originRow, originCol)
} }
fun advanceGravity(s: GameState, frame: Int): Int { fun advanceGravity(s: GameState, frame: Int): Int {
s.level = 1 + (s.totalLines / LEVEL_LINES_STEP) s.level = 1 + (s.totalLines / LEVEL_LINES_STEP)
val dropEvery = maxInt(DROP_FRAMES_MIN, DROP_FRAMES_BASE - s.level) val dropEvery = max(DROP_FRAMES_MIN, DROP_FRAMES_BASE - s.level)
var nextFrame = frame + 1 var nextFrame = frame + 1
if (nextFrame < dropEvery) return nextFrame if (nextFrame < dropEvery) return nextFrame
nextFrame = 0 nextFrame = 0
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1)) { if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1) == true) {
s.py = s.py + 1 s.py++
return nextFrame return nextFrame
} }
@@ -579,8 +711,8 @@ if (!Console.isSupported()) {
val cleared = clearCompletedLines(board, boardW, boardH) val cleared = clearCompletedLines(board, boardW, boardH)
if (cleared > 0) { if (cleared > 0) {
s.totalLines = s.totalLines + cleared s.totalLines += cleared
s.score = s.score + scoreForLines(cleared, s.level) s.score += scoreForLines(cleared, s.level)
} }
s.pieceId = s.nextId s.pieceId = s.nextId
@@ -605,37 +737,60 @@ if (!Console.isSupported()) {
var shouldStop = false var shouldStop = false
var prevOriginRow = -1 var prevOriginRow = -1
var prevOriginCol = -1 var prevOriginCol = -1
var prevPaused = false
while (!shouldStop) { while (!shouldStop) {
val frameData = pollLoopFrame() val frameData = pollLoopFrame()
if (frameData == null) { if (frameData == null) {
frame = 0 frame = 0
prevPaused = false
continue continue
} }
gameMutex.withLock { val mm: Mutex = gameMutex
if (!state.running || state.gameOver) { mm.withLock {
shouldStop = true if (pendingInputs.size > 0) {
} else { val toApply: List<String> = []
val movedOrigin = frameData.originRow != prevOriginRow || frameData.originCol != prevOriginCol while (pendingInputs.size > 0) {
if (frameData.resized || movedOrigin) { val k = pendingInputs[0]
clearAndHome() pendingInputs.removeAt(0)
prevFrameLines = [] toApply.add(k)
}
for (k in toApply) {
applyKeyInput(state, k)
if (!state.running || state.gameOver) break
} }
prevOriginRow = frameData.originRow
prevOriginCol = frameData.originCol
frame = advanceGravity(state, frame)
prevFrameLines = render(
state,
board,
boardW,
boardH,
prevFrameLines,
frameData.originRow,
frameData.originCol,
useColor
)
} }
} }
if (!state.running || state.gameOver) {
shouldStop = true
} else {
val localForceRedraw = forceRedraw
forceRedraw = false
val movedOrigin = frameData.originRow != prevOriginRow || frameData.originCol != prevOriginCol
if (frameData.resized || movedOrigin || localForceRedraw) {
clearAndHome()
prevFrameLines = []
}
prevOriginRow = frameData.originRow
prevOriginCol = frameData.originCol
if (!state.paused) {
frame = advanceGravity(state, frame)
}
prevFrameLines = render(
state,
board,
boardW,
boardH,
prevFrameLines,
frameData.originRow,
frameData.originCol,
useColor
)
if (state.paused && (!prevPaused || frameData.resized || movedOrigin)) {
renderPauseOverlay(frameData.originRow, frameData.originCol, boardW, boardH)
}
prevPaused = state.paused
}
Console.flush() Console.flush()
delay(FRAME_DELAY_MS) delay(FRAME_DELAY_MS)
} }
+2
View File
@@ -8,6 +8,7 @@ android-compileSdk = "34"
kotlinx-coroutines = "1.10.2" kotlinx-coroutines = "1.10.2"
kotlinx-datetime = "0.6.1" kotlinx-datetime = "0.6.1"
mp_bintools = "0.3.2" mp_bintools = "0.3.2"
ionspin-bignum = "0.3.10"
firebaseCrashlyticsBuildtools = "3.0.3" firebaseCrashlyticsBuildtools = "3.0.3"
okioVersion = "3.10.2" okioVersion = "3.10.2"
compiler = "3.2.0-alpha11" compiler = "3.2.0-alpha11"
@@ -22,6 +23,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
mp_bintools = { module = "net.sergeych:mp_bintools", version.ref = "mp_bintools" } mp_bintools = { module = "net.sergeych:mp_bintools", version.ref = "mp_bintools" }
ionspin-bignum = { module = "com.ionspin.kotlin:bignum", version.ref = "ionspin-bignum" }
firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" } firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" }
okio = { module = "com.squareup.okio:okio", version.ref = "okioVersion" } okio = { module = "com.squareup.okio:okio", version.ref = "okioVersion" }
okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okioVersion" } okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okioVersion" }
+58
View File
@@ -112,6 +112,64 @@ kotlin {
} }
} }
abstract class GenerateLyngioConsoleDecls : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val sourceFile: RegularFileProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val targetPkg = "net.sergeych.lyngio.stdlib_included"
val pkgPath = targetPkg.replace('.', '/')
val targetDir = outputDir.get().asFile.resolve(pkgPath)
targetDir.mkdirs()
val text = sourceFile.get().asFile.readText()
fun escapeForQuoted(s: String): String = buildString {
for (ch in s) when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> {}
'\t' -> append("\\t")
else -> append(ch)
}
}
val out = buildString {
append("package ").append(targetPkg).append("\n\n")
append("@Suppress(\"Unused\", \"MemberVisibilityCanBePrivate\")\n")
append("internal val consoleLyng = \"")
append(escapeForQuoted(text))
append("\"\n")
}
targetDir.resolve("console_types_lyng.generated.kt").writeText(out)
}
}
val lyngioConsoleDeclsFile = layout.projectDirectory.file("stdlib/lyng/io/console.lyng")
val generatedLyngioDeclsDir = layout.buildDirectory.dir("generated/source/lyngioDecls/commonMain/kotlin")
val generateLyngioConsoleDecls by tasks.registering(GenerateLyngioConsoleDecls::class) {
sourceFile.set(lyngioConsoleDeclsFile)
outputDir.set(generatedLyngioDeclsDir)
}
kotlin.sourceSets.named("commonMain") {
kotlin.srcDir(generateLyngioConsoleDecls)
}
kotlin.targets.configureEach {
compilations.configureEach {
compileTaskProvider.configure {
dependsOn(generateLyngioConsoleDecls)
}
}
}
android { android {
namespace = "net.sergeych.lyngio" namespace = "net.sergeych.lyngio"
compileSdk = libs.versions.android.compileSdk.get().toInt() compileSdk = libs.versions.android.compileSdk.get().toInt()
@@ -20,9 +20,11 @@ package net.sergeych.lyng.io.console
import net.sergeych.lyng.ModuleScope import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.miniast.* import net.sergeych.lyng.Source
import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjEnumClass
import net.sergeych.lyng.obj.ObjEnumEntry
import net.sergeych.lyng.obj.ObjIterable import net.sergeych.lyng.obj.ObjIterable
import net.sergeych.lyng.obj.ObjIterationFinishedException import net.sergeych.lyng.obj.ObjIterationFinishedException
import net.sergeych.lyng.obj.ObjIterator import net.sergeych.lyng.obj.ObjIterator
@@ -35,13 +37,13 @@ import net.sergeych.lyng.obj.toObj
import net.sergeych.lyng.pacman.ImportManager import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.raiseIllegalOperation import net.sergeych.lyng.raiseIllegalOperation
import net.sergeych.lyng.requireScope import net.sergeych.lyng.requireScope
import net.sergeych.lyngio.console.ConsoleEvent import net.sergeych.lyngio.console.*
import net.sergeych.lyngio.console.ConsoleEventSource
import net.sergeych.lyngio.console.LyngConsole
import net.sergeych.lyngio.console.getSystemConsole
import net.sergeych.lyngio.console.security.ConsoleAccessDeniedException import net.sergeych.lyngio.console.security.ConsoleAccessDeniedException
import net.sergeych.lyngio.console.security.ConsoleAccessPolicy import net.sergeych.lyngio.console.security.ConsoleAccessPolicy
import net.sergeych.lyngio.console.security.LyngConsoleSecured import net.sergeych.lyngio.console.security.LyngConsoleSecured
import net.sergeych.lyngio.stdlib_included.consoleLyng
private const val CONSOLE_MODULE_NAME = "lyng.io.console"
/** /**
* Install Lyng module `lyng.io.console` into the given scope's ImportManager. * Install Lyng module `lyng.io.console` into the given scope's ImportManager.
@@ -53,10 +55,9 @@ fun createConsole(policy: ConsoleAccessPolicy, scope: Scope): Boolean = createCo
/** Same as [createConsoleModule] but with explicit [ImportManager]. */ /** Same as [createConsoleModule] but with explicit [ImportManager]. */
fun createConsoleModule(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean { fun createConsoleModule(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean {
val name = "lyng.io.console" if (manager.packageNames.contains(CONSOLE_MODULE_NAME)) return false
if (manager.packageNames.contains(name)) return false
manager.addPackage(name) { module -> manager.addPackage(CONSOLE_MODULE_NAME) { module ->
buildConsoleModule(module, policy) buildConsoleModule(module, policy)
} }
return true return true
@@ -65,59 +66,37 @@ fun createConsoleModule(policy: ConsoleAccessPolicy, manager: ImportManager): Bo
fun createConsole(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean = createConsoleModule(policy, manager) fun createConsole(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean = createConsoleModule(policy, manager)
private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAccessPolicy) { private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAccessPolicy) {
// Load Lyng declarations for console enums/types first (module-local source of truth).
module.eval(Source(CONSOLE_MODULE_NAME, consoleLyng))
ConsoleEnums.initialize(module)
val console: LyngConsole = LyngConsoleSecured(getSystemConsole(), policy) val console: LyngConsole = LyngConsoleSecured(getSystemConsole(), policy)
val consoleType = object : net.sergeych.lyng.obj.ObjClass("Console") {} val consoleType = object : net.sergeych.lyng.obj.ObjClass("Console") {}
consoleType.apply { consoleType.apply {
addClassFnDoc( addClassFn("isSupported") {
name = "isSupported",
doc = "Whether console control API is supported on this platform.",
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
ObjBool(console.isSupported) ObjBool(console.isSupported)
} }
addClassFnDoc( addClassFn("isTty") {
name = "isTty",
doc = "Whether current stdout is attached to an interactive TTY.",
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
ObjBool(console.isTty()) ObjBool(console.isTty())
} }
} }
addClassFnDoc( addClassFn("ansiLevel") {
name = "ansiLevel",
doc = "Detected ANSI color capability: NONE, BASIC16, ANSI256, TRUECOLOR.",
returns = type("lyng.String"),
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
ObjString(console.ansiLevel().name) ConsoleEnums.ansiLevel(console.ansiLevel().name)
} }
} }
addClassFnDoc( addClassFn("geometry") {
name = "geometry",
doc = "Current terminal geometry or null.",
returns = type("ConsoleGeometry", nullable = true),
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
console.geometry()?.let { ObjConsoleGeometry(it.columns, it.rows) } ?: ObjNull console.geometry()?.let { ObjConsoleGeometry(it.columns, it.rows) } ?: ObjNull
} }
} }
addClassFnDoc( addClassFn("details") {
name = "details",
doc = "Get consolidated console details.",
returns = type("ConsoleDetails"),
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
val tty = console.isTty() val tty = console.isTty()
val ansi = console.ansiLevel() val ansi = console.ansiLevel()
@@ -125,18 +104,13 @@ private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAcces
ObjConsoleDetails( ObjConsoleDetails(
supported = console.isSupported, supported = console.isSupported,
isTty = tty, isTty = tty,
ansiLevel = ansi.name, ansiLevel = ConsoleEnums.ansiLevel(ansi.name),
geometry = geometry?.let { ObjConsoleGeometry(it.columns, it.rows) }, geometry = geometry?.let { ObjConsoleGeometry(it.columns, it.rows) },
) )
} }
} }
addClassFnDoc( addClassFn("write") {
name = "write",
doc = "Write text directly to console output.",
params = listOf(ParamDoc("text", type("lyng.String"))),
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
val text = requiredArg<ObjString>(0).value val text = requiredArg<ObjString>(0).value
console.write(text) console.write(text)
@@ -144,48 +118,28 @@ private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAcces
} }
} }
addClassFnDoc( addClassFn("flush") {
name = "flush",
doc = "Flush console output buffer.",
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
console.flush() console.flush()
ObjVoid ObjVoid
} }
} }
addClassFnDoc( addClassFn("home") {
name = "home",
doc = "Move cursor to home position (1,1).",
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
console.write("\u001B[H") console.write("\u001B[H")
ObjVoid ObjVoid
} }
} }
addClassFnDoc( addClassFn("clear") {
name = "clear",
doc = "Clear the visible screen buffer.",
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
console.write("\u001B[2J") console.write("\u001B[2J")
ObjVoid ObjVoid
} }
} }
addClassFnDoc( addClassFn("moveTo") {
name = "moveTo",
doc = "Move cursor to 1-based row and column.",
params = listOf(
ParamDoc("row", type("lyng.Int")),
ParamDoc("column", type("lyng.Int")),
),
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
val row = requiredArg<net.sergeych.lyng.obj.ObjInt>(0).value val row = requiredArg<net.sergeych.lyng.obj.ObjInt>(0).value
val col = requiredArg<net.sergeych.lyng.obj.ObjInt>(1).value val col = requiredArg<net.sergeych.lyng.obj.ObjInt>(1).value
@@ -194,45 +148,28 @@ private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAcces
} }
} }
addClassFnDoc( addClassFn("clearLine") {
name = "clearLine",
doc = "Clear the current line.",
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
console.write("\u001B[2K") console.write("\u001B[2K")
ObjVoid ObjVoid
} }
} }
addClassFnDoc( addClassFn("enterAltScreen") {
name = "enterAltScreen",
doc = "Switch to terminal alternate screen buffer.",
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
console.write("\u001B[?1049h") console.write("\u001B[?1049h")
ObjVoid ObjVoid
} }
} }
addClassFnDoc( addClassFn("leaveAltScreen") {
name = "leaveAltScreen",
doc = "Return from alternate screen buffer to normal screen.",
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
console.write("\u001B[?1049l") console.write("\u001B[?1049l")
ObjVoid ObjVoid
} }
} }
addClassFnDoc( addClassFn("setCursorVisible") {
name = "setCursorVisible",
doc = "Show or hide the terminal cursor.",
params = listOf(ParamDoc("visible", type("lyng.Bool"))),
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
val visible = requiredArg<ObjBool>(0).value val visible = requiredArg<ObjBool>(0).value
console.write(if (visible) "\u001B[?25h" else "\u001B[?25l") console.write(if (visible) "\u001B[?25h" else "\u001B[?25l")
@@ -240,24 +177,13 @@ private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAcces
} }
} }
addClassFnDoc( addClassFn("events") {
name = "events",
doc = "Endless iterable console event source (resize, keydown, keyup). Use in a loop, often inside launch.",
returns = type("ConsoleEventStream"),
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
console.events().toConsoleEventStream() console.events().toConsoleEventStream()
} }
} }
addClassFnDoc( addClassFn("setRawMode") {
name = "setRawMode",
doc = "Enable or disable raw keyboard mode. Returns true if mode changed.",
params = listOf(ParamDoc("enabled", type("lyng.Bool"))),
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
consoleGuard { consoleGuard {
val enabled = requiredArg<ObjBool>(0).value val enabled = requiredArg<ObjBool>(0).value
ObjBool(console.setRawMode(enabled)) ObjBool(console.setRawMode(enabled))
@@ -265,55 +191,13 @@ private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAcces
} }
} }
module.addConstDoc( module.addConst("Console", consoleType)
name = "Console", module.addConst("ConsoleGeometry", ObjConsoleGeometry.type)
value = consoleType, module.addConst("ConsoleDetails", ObjConsoleDetails.type)
doc = "Console runtime API.", module.addConst("ConsoleEvent", ObjConsoleEvent.type)
type = type("Console"), module.addConst("ConsoleResizeEvent", ObjConsoleResizeEvent.type)
moduleName = module.packageName module.addConst("ConsoleKeyEvent", ObjConsoleKeyEvent.typeObj)
) module.addConst("ConsoleEventStream", ObjConsoleEventStream.type)
module.addConstDoc(
name = "ConsoleGeometry",
value = ObjConsoleGeometry.type,
doc = "Terminal geometry.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleDetails",
value = ObjConsoleDetails.type,
doc = "Consolidated console capability details.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleEvent",
value = ObjConsoleEvent.type,
doc = "Base class for console events.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleResizeEvent",
value = ObjConsoleResizeEvent.type,
doc = "Terminal resize event.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleKeyEvent",
value = ObjConsoleKeyEvent.typeObj,
doc = "Keyboard event.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleEventStream",
value = ObjConsoleEventStream.type,
doc = "Endless iterable stream of console events.",
type = type("lyng.Class"),
moduleName = module.packageName
)
} }
private suspend inline fun ScopeFacade.consoleGuard(crossinline block: suspend () -> Obj): Obj { private suspend inline fun ScopeFacade.consoleGuard(crossinline block: suspend () -> Obj): Obj {
@@ -338,12 +222,7 @@ private class ObjConsoleEventStream(
companion object { companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventStream", ObjIterable).apply { val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventStream", ObjIterable).apply {
addFnDoc( addFn("iterator") {
name = "iterator",
doc = "Create an iterator over incoming console events.",
returns = type("lyng.Iterator"),
moduleName = "lyng.io.console",
) {
val stream = thisAs<ObjConsoleEventStream>() val stream = thisAs<ObjConsoleEventStream>()
ObjConsoleEventIterator(stream.source) ObjConsoleEventIterator(stream.source)
} }
@@ -363,19 +242,36 @@ private class ObjConsoleEventIterator(
private suspend fun ensureCached(): Boolean { private suspend fun ensureCached(): Boolean {
if (closed) return false if (closed) return false
if (cached != null) return true if (cached != null) return true
val event = source.nextEvent() while (!closed && cached == null) {
if (event == null) { val event = try {
closeSource() source.nextEvent()
return false } catch (e: Throwable) {
// Consumer loops must survive source/read failures: report and keep polling.
consoleFlowDebug("console-bridge: nextEvent failed; dropping failure and continuing", e)
continue
}
if (event == null) {
closeSource()
return false
}
cached = try {
event.toObjEvent()
} catch (e: Throwable) {
// Malformed/native event payload must not terminate consumer iteration.
consoleFlowDebug("console-bridge: malformed event dropped: $event", e)
null
}
} }
cached = event.toObjEvent() return cached != null
return true
} }
private suspend fun closeSource() { private suspend fun closeSource() {
if (closed) return if (closed) return
closed = true closed = true
source.close() // Do not close the underlying console source from VM iterator cancellation.
// CmdFrame.cancelIterators() may call cancelIteration() while user code is still
// expected to keep processing input (e.g. recover from app-level exceptions).
// The source lifecycle is managed by the console runtime.
} }
suspend fun hasNext(): Boolean = ensureCached() suspend fun hasNext(): Boolean = ensureCached()
@@ -391,28 +287,13 @@ private class ObjConsoleEventIterator(
companion object { companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventIterator", ObjIterator).apply { val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventIterator", ObjIterator).apply {
addFnDoc( addFn("hasNext") {
name = "hasNext",
doc = "Whether another console event is available.",
returns = type("lyng.Bool"),
moduleName = "lyng.io.console",
) {
thisAs<ObjConsoleEventIterator>().hasNext().toObj() thisAs<ObjConsoleEventIterator>().hasNext().toObj()
} }
addFnDoc( addFn("next") {
name = "next",
doc = "Return the next console event.",
returns = type("ConsoleEvent"),
moduleName = "lyng.io.console",
) {
thisAs<ObjConsoleEventIterator>().next(requireScope()) thisAs<ObjConsoleEventIterator>().next(requireScope())
} }
addFnDoc( addFn("cancelIteration") {
name = "cancelIteration",
doc = "Stop reading console events and release resources.",
returns = type("lyng.Void"),
moduleName = "lyng.io.console",
) {
thisAs<ObjConsoleEventIterator>().closeSource() thisAs<ObjConsoleEventIterator>().closeSource()
ObjVoid ObjVoid
} }
@@ -422,27 +303,119 @@ private class ObjConsoleEventIterator(
private fun ConsoleEvent.toObjEvent(): Obj = when (this) { private fun ConsoleEvent.toObjEvent(): Obj = when (this) {
is ConsoleEvent.Resize -> ObjConsoleResizeEvent(columns, rows) is ConsoleEvent.Resize -> ObjConsoleResizeEvent(columns, rows)
is ConsoleEvent.KeyDown -> ObjConsoleKeyEvent(type = "keydown", key = key, code = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta) is ConsoleEvent.KeyDown -> ObjConsoleKeyEvent(type = ConsoleEnums.KEY_DOWN, key = sanitizedKeyOrFallback(key), codeName = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta)
is ConsoleEvent.KeyUp -> ObjConsoleKeyEvent(type = "keyup", key = key, code = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta) is ConsoleEvent.KeyUp -> ObjConsoleKeyEvent(type = ConsoleEnums.KEY_UP, key = sanitizedKeyOrFallback(key), codeName = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta)
}
private fun sanitizedKeyOrFallback(key: String): String {
if (key.isNotEmpty()) return key
consoleFlowDebug("console-bridge: empty key value received; using fallback key name")
return "Unknown"
}
private object ConsoleEnums {
lateinit var eventTypeClass: ObjEnumClass
private set
lateinit var keyCodeClass: ObjEnumClass
private set
lateinit var ansiLevelClass: ObjEnumClass
private set
private lateinit var eventEntries: Map<String, ObjEnumEntry>
private lateinit var keyCodeEntries: Map<String, ObjEnumEntry>
private lateinit var ansiLevelEntries: Map<String, ObjEnumEntry>
val UNKNOWN: ObjEnumEntry get() = event("UNKNOWN")
val RESIZE: ObjEnumEntry get() = event("RESIZE")
val KEY_DOWN: ObjEnumEntry get() = event("KEY_DOWN")
val KEY_UP: ObjEnumEntry get() = event("KEY_UP")
val CODE_UNKNOWN: ObjEnumEntry get() = code("UNKNOWN")
val CHARACTER: ObjEnumEntry get() = code("CHARACTER")
fun initialize(module: ModuleScope) {
eventTypeClass = resolveEnum(module, "ConsoleEventType")
keyCodeClass = resolveEnum(module, "ConsoleKeyCode")
ansiLevelClass = resolveEnum(module, "ConsoleAnsiLevel")
eventEntries = resolveEntries(
eventTypeClass,
listOf("UNKNOWN", "RESIZE", "KEY_DOWN", "KEY_UP")
)
keyCodeEntries = resolveEntries(
keyCodeClass,
listOf(
"UNKNOWN", "CHARACTER", "ARROW_UP", "ARROW_DOWN", "ARROW_LEFT", "ARROW_RIGHT",
"HOME", "END", "INSERT", "DELETE", "PAGE_UP", "PAGE_DOWN",
"ESCAPE", "ENTER", "TAB", "BACKSPACE", "SPACE"
)
)
ansiLevelEntries = resolveEntries(
ansiLevelClass,
listOf("NONE", "BASIC16", "ANSI256", "TRUECOLOR")
)
}
private fun resolveEnum(module: ModuleScope, enumName: String): ObjEnumClass {
val local = module.get(enumName)?.value as? ObjEnumClass
if (local != null) return local
val root = module.importProvider.rootScope.get(enumName)?.value as? ObjEnumClass
return root ?: error("lyng.io.console declaration enum is missing: $enumName")
}
private fun resolveEntries(enumClass: ObjEnumClass, names: List<String>): Map<String, ObjEnumEntry> {
return names.associateWith { name ->
(enumClass.byName[ObjString(name)] as? ObjEnumEntry)
?: error("lyng.io.console enum entry is missing: ${enumClass.className}.$name")
}
}
fun event(name: String): ObjEnumEntry = eventEntries[name]
?: error("lyng.io.console enum entry is missing: ${eventTypeClass.className}.$name")
fun code(name: String): ObjEnumEntry = keyCodeEntries[name]
?: error("lyng.io.console enum entry is missing: ${keyCodeClass.className}.$name")
fun ansiLevel(name: String): ObjEnumEntry = ansiLevelEntries[name]
?: error("lyng.io.console enum entry is missing: ${ansiLevelClass.className}.$name")
}
private val KEY_CODE_BY_KEY_NAME = mapOf(
"ArrowUp" to "ARROW_UP",
"ArrowDown" to "ARROW_DOWN",
"ArrowLeft" to "ARROW_LEFT",
"ArrowRight" to "ARROW_RIGHT",
"Home" to "HOME",
"End" to "END",
"Insert" to "INSERT",
"Delete" to "DELETE",
"PageUp" to "PAGE_UP",
"PageDown" to "PAGE_DOWN",
"Escape" to "ESCAPE",
"Enter" to "ENTER",
"Tab" to "TAB",
"Backspace" to "BACKSPACE",
" " to "SPACE",
)
private fun codeFrom(key: String, codeName: String?): ObjEnumEntry {
val resolved = KEY_CODE_BY_KEY_NAME[codeName ?: key]
return when {
resolved != null -> ConsoleEnums.code(resolved)
key.length == 1 -> ConsoleEnums.CHARACTER
else -> ConsoleEnums.CODE_UNKNOWN
}
} }
private abstract class ObjConsoleEventBase( private abstract class ObjConsoleEventBase(
private val eventType: String, private val type: ObjEnumEntry,
final override val objClass: net.sergeych.lyng.obj.ObjClass, final override val objClass: net.sergeych.lyng.obj.ObjClass,
) : Obj() { ) : Obj() {
fun eventTypeName(): String = eventType fun type(): ObjEnumEntry = type
} }
private class ObjConsoleEvent : ObjConsoleEventBase("event", type) { private class ObjConsoleEvent : ObjConsoleEventBase(ConsoleEnums.UNKNOWN, type) {
companion object { companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEvent").apply { val type = net.sergeych.lyng.obj.ObjClass("ConsoleEvent").apply {
addPropertyDoc( addProperty(name = "type", getter = { (this.thisObj as ObjConsoleEventBase).type() })
name = "type",
doc = "Event type string: resize, keydown, keyup.",
type = type("lyng.String"),
moduleName = "lyng.io.console",
getter = { ObjString((this.thisObj as ObjConsoleEventBase).eventTypeName()) }
)
} }
} }
} }
@@ -450,83 +423,40 @@ private class ObjConsoleEvent : ObjConsoleEventBase("event", type) {
private class ObjConsoleResizeEvent( private class ObjConsoleResizeEvent(
val columns: Int, val columns: Int,
val rows: Int, val rows: Int,
) : ObjConsoleEventBase("resize", type) { ) : ObjConsoleEventBase(ConsoleEnums.RESIZE, type) {
companion object { companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleResizeEvent", ObjConsoleEvent.type).apply { val type = net.sergeych.lyng.obj.ObjClass("ConsoleResizeEvent", ObjConsoleEvent.type).apply {
addPropertyDoc( addProperty(name = "columns", getter = { (this.thisObj as ObjConsoleResizeEvent).columns.toObj() })
name = "columns", addProperty(name = "rows", getter = { (this.thisObj as ObjConsoleResizeEvent).rows.toObj() })
doc = "Terminal width in character cells.",
type = type("lyng.Int"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleResizeEvent).columns.toObj() }
)
addPropertyDoc(
name = "rows",
doc = "Terminal height in character cells.",
type = type("lyng.Int"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleResizeEvent).rows.toObj() }
)
} }
} }
} }
private class ObjConsoleKeyEvent( private class ObjConsoleKeyEvent(
type: String, type: ObjEnumEntry,
val key: String, val key: String,
val code: String?, val codeName: String?,
val ctrl: Boolean, val ctrl: Boolean,
val alt: Boolean, val alt: Boolean,
val shift: Boolean, val shift: Boolean,
val meta: Boolean, val meta: Boolean,
) : ObjConsoleEventBase(type, typeObj) { ) : ObjConsoleEventBase(type, typeObj) {
init {
require(key.isNotEmpty()) { "ConsoleKeyEvent.key must never be empty" }
}
companion object { companion object {
val typeObj = net.sergeych.lyng.obj.ObjClass("ConsoleKeyEvent", ObjConsoleEvent.type).apply { val typeObj = net.sergeych.lyng.obj.ObjClass("ConsoleKeyEvent", ObjConsoleEvent.type).apply {
addPropertyDoc( addProperty(name = "key", getter = { ObjString((this.thisObj as ObjConsoleKeyEvent).key) })
name = "key", addProperty(name = "code", getter = { codeFrom((this.thisObj as ObjConsoleKeyEvent).key, (this.thisObj as ObjConsoleKeyEvent).codeName) })
doc = "Logical key name (e.g. ArrowLeft, a, Escape).", addProperty(name = "codeName", getter = {
type = type("lyng.String"), val code = (this.thisObj as ObjConsoleKeyEvent).codeName
moduleName = "lyng.io.console", code?.let(::ObjString) ?: ObjNull
getter = { ObjString((this.thisObj as ObjConsoleKeyEvent).key) } })
) addProperty(name = "ctrl", getter = { (this.thisObj as ObjConsoleKeyEvent).ctrl.toObj() })
addPropertyDoc( addProperty(name = "alt", getter = { (this.thisObj as ObjConsoleKeyEvent).alt.toObj() })
name = "code", addProperty(name = "shift", getter = { (this.thisObj as ObjConsoleKeyEvent).shift.toObj() })
doc = "Optional hardware/code identifier.", addProperty(name = "meta", getter = { (this.thisObj as ObjConsoleKeyEvent).meta.toObj() })
type = type("lyng.String", nullable = true),
moduleName = "lyng.io.console",
getter = {
val code = (this.thisObj as ObjConsoleKeyEvent).code
code?.let(::ObjString) ?: ObjNull
}
)
addPropertyDoc(
name = "ctrl",
doc = "Whether Ctrl modifier is pressed.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleKeyEvent).ctrl.toObj() }
)
addPropertyDoc(
name = "alt",
doc = "Whether Alt modifier is pressed.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleKeyEvent).alt.toObj() }
)
addPropertyDoc(
name = "shift",
doc = "Whether Shift modifier is pressed.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleKeyEvent).shift.toObj() }
)
addPropertyDoc(
name = "meta",
doc = "Whether Meta/Super modifier is pressed.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleKeyEvent).meta.toObj() }
)
} }
} }
} }
@@ -539,20 +469,8 @@ private class ObjConsoleGeometry(
companion object { companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleGeometry").apply { val type = net.sergeych.lyng.obj.ObjClass("ConsoleGeometry").apply {
addPropertyDoc( addProperty(name = "columns", getter = { (this.thisObj as ObjConsoleGeometry).columns.toObj() })
name = "columns", addProperty(name = "rows", getter = { (this.thisObj as ObjConsoleGeometry).rows.toObj() })
doc = "Terminal width in character cells.",
type = type("lyng.Int"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleGeometry).columns.toObj() }
)
addPropertyDoc(
name = "rows",
doc = "Terminal height in character cells.",
type = type("lyng.Int"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleGeometry).rows.toObj() }
)
} }
} }
} }
@@ -560,41 +478,17 @@ private class ObjConsoleGeometry(
private class ObjConsoleDetails( private class ObjConsoleDetails(
val supported: Boolean, val supported: Boolean,
val isTty: Boolean, val isTty: Boolean,
val ansiLevel: String, val ansiLevel: ObjEnumEntry,
val geometry: ObjConsoleGeometry?, val geometry: ObjConsoleGeometry?,
) : Obj() { ) : Obj() {
override val objClass: net.sergeych.lyng.obj.ObjClass get() = type override val objClass: net.sergeych.lyng.obj.ObjClass get() = type
companion object { companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleDetails").apply { val type = net.sergeych.lyng.obj.ObjClass("ConsoleDetails").apply {
addPropertyDoc( addProperty(name = "supported", getter = { (this.thisObj as ObjConsoleDetails).supported.toObj() })
name = "supported", addProperty(name = "isTty", getter = { (this.thisObj as ObjConsoleDetails).isTty.toObj() })
doc = "Whether console API is supported.", addProperty(name = "ansiLevel", getter = { (this.thisObj as ObjConsoleDetails).ansiLevel })
type = type("lyng.Bool"), addProperty(name = "geometry", getter = { (this.thisObj as ObjConsoleDetails).geometry ?: ObjNull })
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleDetails).supported.toObj() }
)
addPropertyDoc(
name = "isTty",
doc = "Whether output is connected to a TTY.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleDetails).isTty.toObj() }
)
addPropertyDoc(
name = "ansiLevel",
doc = "Detected ANSI color capability level.",
type = type("lyng.String"),
moduleName = "lyng.io.console",
getter = { ObjString((this.thisObj as ObjConsoleDetails).ansiLevel) }
)
addPropertyDoc(
name = "geometry",
doc = "Current terminal geometry or null.",
type = type("ConsoleGeometry", nullable = true),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleDetails).geometry ?: ObjNull }
)
} }
} }
} }
@@ -100,8 +100,9 @@ object MordantLyngConsole : LyngConsole {
var running = true var running = true
globalLaunch { globalLaunch {
var lastWidth = t.updateSize().width val initialSize = runCatching { t.updateSize() }.getOrNull()
var lastHeight = t.updateSize().height var lastWidth = initialSize?.width ?: 0
var lastHeight = initialSize?.height ?: 0
val startMark = TimeSource.Monotonic.markNow() val startMark = TimeSource.Monotonic.markNow()
var lastHeartbeatMark = startMark var lastHeartbeatMark = startMark
var loops = 0L var loops = 0L
@@ -113,6 +114,18 @@ object MordantLyngConsole : LyngConsole {
var lastKeyMark = startMark var lastKeyMark = startMark
var lastRawRecoveryMark = startMark var lastRawRecoveryMark = startMark
fun tryEmitResize(width: Int, height: Int) {
if (width < 1 || height < 1) {
consoleFlowDebug("events: ignored invalid resize width=$width height=$height")
return
}
if (width == lastWidth && height == lastHeight) return
out.trySend(ConsoleEvent.Resize(width, height))
lastWidth = width
lastHeight = height
resizeEvents += 1
}
consoleFlowDebug("events: collector started") consoleFlowDebug("events: collector started")
try { try {
while (currentCoroutineContext().isActive && sourceState.withLock { running }) { while (currentCoroutineContext().isActive && sourceState.withLock { running }) {
@@ -122,12 +135,7 @@ object MordantLyngConsole : LyngConsole {
delay(150) delay(150)
continue continue
} }
if (currentSize.width != lastWidth || currentSize.height != lastHeight) { tryEmitResize(currentSize.width, currentSize.height)
out.trySend(ConsoleEvent.Resize(currentSize.width, currentSize.height))
lastWidth = currentSize.width
lastHeight = currentSize.height
resizeEvents += 1
}
val raw = stateMutex.withLock { val raw = stateMutex.withLock {
if (!rawModeRequested) { if (!rawModeRequested) {
@@ -173,10 +181,8 @@ object MordantLyngConsole : LyngConsole {
val ev = readResult.getOrNull() val ev = readResult.getOrNull()
val resized = runCatching { t.updateSize() }.getOrNull() val resized = runCatching { t.updateSize() }.getOrNull()
if (resized != null && (resized.width != lastWidth || resized.height != lastHeight)) { if (resized != null) {
out.trySend(ConsoleEvent.Resize(resized.width, resized.height)) tryEmitResize(resized.width, resized.height)
lastWidth = resized.width
lastHeight = resized.height
} }
when (ev) { when (ev) {
@@ -17,248 +17,12 @@
package net.sergeych.lyngio.docs package net.sergeych.lyngio.docs
import net.sergeych.lyng.miniast.BuiltinDocRegistry /**
import net.sergeych.lyng.miniast.ParamDoc * Console docs are declared in `lyngio/stdlib/lyng/io/console.lyng`.
import net.sergeych.lyng.miniast.type * Keep this shim for compatibility with reflective loaders.
*/
object ConsoleBuiltinDocs { object ConsoleBuiltinDocs {
private var registered = false
fun ensure() { fun ensure() {
if (registered) return // No Kotlin-side doc registration: console.lyng is the source of truth.
BuiltinDocRegistry.module("lyng.io.console") {
classDoc(
name = "Console",
doc = "Console runtime API."
) {
method(
name = "isSupported",
doc = "Whether console control API is supported on this platform.",
returns = type("lyng.Bool"),
isStatic = true
)
method(
name = "isTty",
doc = "Whether stdout is attached to an interactive TTY.",
returns = type("lyng.Bool"),
isStatic = true
)
method(
name = "ansiLevel",
doc = "Detected ANSI color capability: NONE, BASIC16, ANSI256, TRUECOLOR.",
returns = type("lyng.String"),
isStatic = true
)
method(
name = "geometry",
doc = "Current terminal geometry or null.",
returns = type("ConsoleGeometry", nullable = true),
isStatic = true
)
method(
name = "details",
doc = "Get consolidated console details.",
returns = type("ConsoleDetails"),
isStatic = true
)
method(
name = "write",
doc = "Write text directly to console output.",
params = listOf(ParamDoc("text", type("lyng.String"))),
isStatic = true
)
method(
name = "flush",
doc = "Flush console output buffer.",
isStatic = true
)
method(
name = "home",
doc = "Move cursor to home position (1,1).",
isStatic = true
)
method(
name = "clear",
doc = "Clear the visible screen buffer.",
isStatic = true
)
method(
name = "moveTo",
doc = "Move cursor to 1-based row and column.",
params = listOf(
ParamDoc("row", type("lyng.Int")),
ParamDoc("column", type("lyng.Int")),
),
isStatic = true
)
method(
name = "clearLine",
doc = "Clear the current line.",
isStatic = true
)
method(
name = "enterAltScreen",
doc = "Switch to terminal alternate screen buffer.",
isStatic = true
)
method(
name = "leaveAltScreen",
doc = "Return from alternate screen buffer to normal screen.",
isStatic = true
)
method(
name = "setCursorVisible",
doc = "Show or hide the terminal cursor.",
params = listOf(ParamDoc("visible", type("lyng.Bool"))),
isStatic = true
)
method(
name = "events",
doc = "Endless iterable console event source (resize, keydown, keyup). Use in a loop, often inside launch.",
returns = type("ConsoleEventStream"),
isStatic = true
)
method(
name = "setRawMode",
doc = "Enable or disable raw keyboard mode. Returns true if mode changed.",
params = listOf(ParamDoc("enabled", type("lyng.Bool"))),
returns = type("lyng.Bool"),
isStatic = true
)
}
classDoc(
name = "ConsoleEventStream",
doc = "Endless iterable stream of console events."
) {
method(
name = "iterator",
doc = "Create an iterator over incoming console events.",
returns = type("lyng.Iterator")
)
}
classDoc(
name = "ConsoleGeometry",
doc = "Terminal geometry."
) {
field(
name = "columns",
doc = "Terminal width in character cells.",
type = type("lyng.Int")
)
field(
name = "rows",
doc = "Terminal height in character cells.",
type = type("lyng.Int")
)
}
classDoc(
name = "ConsoleDetails",
doc = "Consolidated console capability details."
) {
field(
name = "supported",
doc = "Whether console API is supported.",
type = type("lyng.Bool")
)
field(
name = "isTty",
doc = "Whether output is attached to a TTY.",
type = type("lyng.Bool")
)
field(
name = "ansiLevel",
doc = "Detected ANSI color capability.",
type = type("lyng.String")
)
field(
name = "geometry",
doc = "Current geometry or null.",
type = type("ConsoleGeometry", nullable = true)
)
}
classDoc(
name = "ConsoleEvent",
doc = "Base class for console events."
) {
field(
name = "type",
doc = "Event kind string.",
type = type("lyng.String")
)
}
classDoc(
name = "ConsoleResizeEvent",
doc = "Resize event."
) {
field(
name = "type",
doc = "Event kind string: resize.",
type = type("lyng.String")
)
field(
name = "columns",
doc = "Terminal width in character cells.",
type = type("lyng.Int")
)
field(
name = "rows",
doc = "Terminal height in character cells.",
type = type("lyng.Int")
)
}
classDoc(
name = "ConsoleKeyEvent",
doc = "Keyboard event."
) {
field(
name = "type",
doc = "Event kind string: keydown or keyup.",
type = type("lyng.String")
)
field(
name = "key",
doc = "Logical key name.",
type = type("lyng.String")
)
field(
name = "code",
doc = "Optional hardware code.",
type = type("lyng.String", nullable = true)
)
field(
name = "ctrl",
doc = "Ctrl modifier state.",
type = type("lyng.Bool")
)
field(
name = "alt",
doc = "Alt modifier state.",
type = type("lyng.Bool")
)
field(
name = "shift",
doc = "Shift modifier state.",
type = type("lyng.Bool")
)
field(
name = "meta",
doc = "Meta modifier state.",
type = type("lyng.Bool")
)
}
valDoc(
name = "Console",
doc = "Console runtime API.",
type = type("Console")
)
valDoc(name = "ConsoleGeometry", doc = "Terminal geometry class.", type = type("lyng.Class"))
valDoc(name = "ConsoleDetails", doc = "Console details class.", type = type("lyng.Class"))
valDoc(name = "ConsoleEvent", doc = "Base console event class.", type = type("lyng.Class"))
valDoc(name = "ConsoleResizeEvent", doc = "Resize event class.", type = type("lyng.Class"))
valDoc(name = "ConsoleKeyEvent", doc = "Keyboard event class.", type = type("lyng.Class"))
valDoc(name = "ConsoleEventStream", doc = "Iterable console event stream class.", type = type("lyng.Class"))
}
registered = true
} }
} }
@@ -26,6 +26,7 @@ import org.jline.terminal.Terminal
import org.jline.terminal.TerminalBuilder import org.jline.terminal.TerminalBuilder
import org.jline.utils.NonBlockingReader import org.jline.utils.NonBlockingReader
import java.io.EOFException import java.io.EOFException
import java.io.InterruptedIOException
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
@@ -41,7 +42,7 @@ import kotlin.time.TimeSource
* to avoid dual-terminal contention. * to avoid dual-terminal contention.
*/ */
object JvmLyngConsole : LyngConsole { object JvmLyngConsole : LyngConsole {
private const val DEBUG_REVISION = "jline-r26-force-rebuild-on-noop-recovery-2026-03-19" private const val DEBUG_REVISION = "jline-r27-no-close-on-vm-iterator-cancel-2026-03-19"
private val codeSourceLocation: String by lazy { private val codeSourceLocation: String by lazy {
runCatching { runCatching {
JvmLyngConsole::class.java.protectionDomain?.codeSource?.location?.toString() JvmLyngConsole::class.java.protectionDomain?.codeSource?.location?.toString()
@@ -50,6 +51,44 @@ object JvmLyngConsole : LyngConsole {
private val terminalRef = AtomicReference<Terminal?>(null) private val terminalRef = AtomicReference<Terminal?>(null)
private val terminalInitLock = Any() private val terminalInitLock = Any()
private val shutdownHook = Thread(
{
restoreTerminalStateOnShutdown()
},
"lyng-console-shutdown"
).apply { isDaemon = true }
init {
runCatching { Runtime.getRuntime().addShutdownHook(shutdownHook) }
.onFailure { consoleFlowDebug("jline-events: shutdown hook install failed", it) }
}
private fun restoreTerminalStateOnShutdown() {
val term = terminalRef.get() ?: return
runCatching {
term.writer().print("\u001B[?25h")
term.writer().print("\u001B[?1049l")
term.writer().flush()
}.onFailure {
consoleFlowDebug("jline-events: shutdown visual restore failed", it)
}
val saved = if (runCatching { stateMutex.tryLock() }.getOrNull() == true) {
try {
rawModeRequested = false
val s = rawSavedAttributes
rawSavedAttributes = null
s
} finally {
stateMutex.unlock()
}
} else {
null
}
if (saved != null) {
runCatching { term.setAttributes(saved) }
.onFailure { consoleFlowDebug("jline-events: shutdown raw attrs restore failed", it) }
}
}
private fun currentTerminal(): Terminal? { private fun currentTerminal(): Terminal? {
val existing = terminalRef.get() val existing = terminalRef.get()
@@ -176,10 +215,29 @@ object JvmLyngConsole : LyngConsole {
var reader = activeTerm.reader() var reader = activeTerm.reader()
var keyThread: Thread? = null var keyThread: Thread? = null
var heartbeatThread: Thread? = null var heartbeatThread: Thread? = null
val resizeEmitMutex = Any()
var lastResizeCols = Int.MIN_VALUE
var lastResizeRows = Int.MIN_VALUE
fun emitResize() { fun emitResize() {
val size = runCatching { activeTerm.size }.getOrNull() ?: return val size = runCatching { activeTerm.size }.getOrNull() ?: return
out.trySend(ConsoleEvent.Resize(size.columns, size.rows)) val cols = size.columns
val rows = size.rows
if (cols < 1 || rows < 1) {
consoleFlowDebug("jline-events: ignored invalid resize columns=$cols rows=$rows")
return
}
val shouldEmit = synchronized(resizeEmitMutex) {
if (cols == lastResizeCols && rows == lastResizeRows) {
false
} else {
lastResizeCols = cols
lastResizeRows = rows
true
}
}
if (!shouldEmit) return
out.trySend(ConsoleEvent.Resize(cols, rows))
} }
fun cleanup() { fun cleanup() {
@@ -270,13 +328,13 @@ object JvmLyngConsole : LyngConsole {
.onFailure { consoleFlowDebug("jline-events: reader shutdown failed during recovery", it) } .onFailure { consoleFlowDebug("jline-events: reader shutdown failed during recovery", it) }
reader = activeTerm.reader() reader = activeTerm.reader()
if (reader.hashCode() == prevReader.hashCode()) { if (reader === prevReader) {
consoleFlowDebug("jline-events: reader recovery no-op oldReader=${prevReader.hashCode()} newReader=${reader.hashCode()} -> forcing terminal rebuild") consoleFlowDebug("jline-events: reader recovery no-op oldReader=${System.identityHashCode(prevReader)} newReader=${System.identityHashCode(reader)} -> forcing terminal rebuild")
if (!tryRebuildTerminal()) { if (!tryRebuildTerminal()) {
consoleFlowDebug("jline-events: forced terminal rebuild did not produce a new reader") consoleFlowDebug("jline-events: forced terminal rebuild did not produce a new reader")
} }
} else { } else {
consoleFlowDebug("jline-events: reader recovered oldReader=${prevReader.hashCode()} newReader=${reader.hashCode()}") consoleFlowDebug("jline-events: reader recovered oldReader=${System.identityHashCode(prevReader)} newReader=${System.identityHashCode(reader)}")
} }
readerRecoveries.incrementAndGet() readerRecoveries.incrementAndGet()
@@ -305,18 +363,56 @@ object JvmLyngConsole : LyngConsole {
} else { } else {
keySendFailures.incrementAndGet() keySendFailures.incrementAndGet()
} }
} catch (_: InterruptedException) { } catch (e: InterruptedException) {
break // Keep input alive if this is a transient interrupt while still running.
if (!running.get() || !keyLoopRunning.get()) break
recoveryRequested.set(true)
consoleFlowDebug("jline-events: key-reader interrupted; scheduling reader recovery", e)
Thread.interrupted()
continue
} catch (e: InterruptedIOException) {
// Common during reader shutdown/rebind. Recover silently and keep input flowing.
if (!running.get() || !keyLoopRunning.get()) break
recoveryRequested.set(true)
consoleFlowDebug("jline-events: read interrupted; scheduling reader recovery", e)
try {
Thread.sleep(10)
} catch (ie: InterruptedException) {
if (!running.get() || !keyLoopRunning.get()) break
recoveryRequested.set(true)
consoleFlowDebug("jline-events: interrupted during recovery backoff; continuing", ie)
Thread.interrupted()
}
} catch (e: EOFException) {
// EOF from reader should trigger rebind/rebuild rather than ending input stream.
if (!running.get() || !keyLoopRunning.get()) break
recoveryRequested.set(true)
consoleFlowDebug("jline-events: reader EOF; scheduling reader recovery", e)
try {
Thread.sleep(20)
} catch (ie: InterruptedException) {
if (!running.get() || !keyLoopRunning.get()) break
recoveryRequested.set(true)
consoleFlowDebug("jline-events: interrupted during EOF backoff; continuing", ie)
Thread.interrupted()
}
} catch (e: Throwable) { } catch (e: Throwable) {
readFailures.incrementAndGet() readFailures.incrementAndGet()
recoveryRequested.set(true)
consoleFlowDebug("jline-events: blocking read failed", e) consoleFlowDebug("jline-events: blocking read failed", e)
try { try {
Thread.sleep(50) Thread.sleep(50)
} catch (_: InterruptedException) { } catch (ie: InterruptedException) {
break if (!running.get() || !keyLoopRunning.get()) break
recoveryRequested.set(true)
consoleFlowDebug("jline-events: interrupted during error backoff; continuing", ie)
Thread.interrupted()
} }
} }
} }
consoleFlowDebug(
"jline-events: key-reader thread stopped running=${running.get()} keyLoopRunning=${keyLoopRunning.get()} loops=${keyLoopCount.get()} keys=${keyEvents.get()} readFailures=${readFailures.get()}"
)
} }
heartbeatThread = thread(start = true, isDaemon = true, name = "lyng-jline-heartbeat") { heartbeatThread = thread(start = true, isDaemon = true, name = "lyng-jline-heartbeat") {
@@ -334,11 +430,17 @@ object JvmLyngConsole : LyngConsole {
val readBlockedMs = if (readStartNs > 0L && readEndNs < readStartNs) { val readBlockedMs = if (readStartNs > 0L && readEndNs < readStartNs) {
(System.nanoTime() - readStartNs) / 1_000_000L (System.nanoTime() - readStartNs) / 1_000_000L
} else 0L } else 0L
if (requested && keyCodesRead.get() > 0L && idleMs >= 1400L) { val streamIdle = requested && keyCodesRead.get() > 0L && idleMs >= 1400L
val readStalled = requested && readBlockedMs >= 1600L
if (streamIdle || readStalled) {
val sinceRecoveryMs = (System.nanoTime() - lastRecoveryNs.get()) / 1_000_000L val sinceRecoveryMs = (System.nanoTime() - lastRecoveryNs.get()) / 1_000_000L
if (sinceRecoveryMs >= 1200L) { if (sinceRecoveryMs >= 1200L) {
recoveryRequested.set(true) recoveryRequested.set(true)
consoleFlowDebug("jline-events: key stream idle ${idleMs}ms; scheduling reader recovery") if (readStalled) {
consoleFlowDebug("jline-events: key read blocked ${readBlockedMs}ms; scheduling reader recovery")
} else {
consoleFlowDebug("jline-events: key stream idle ${idleMs}ms; scheduling reader recovery")
}
} }
} }
consoleFlowDebug( consoleFlowDebug(
@@ -366,6 +468,7 @@ object JvmLyngConsole : LyngConsole {
} }
override suspend fun close() { override suspend fun close() {
consoleFlowDebug("jline-events: collector close requested", Throwable("collector close caller"))
cleanup() cleanup()
consoleFlowDebug( consoleFlowDebug(
"jline-events: collector ended keys=${keyEvents.get()} readFailures=${readFailures.get()}" "jline-events: collector ended keys=${keyEvents.get()} readFailures=${readFailures.get()}"
@@ -485,12 +588,15 @@ object JvmLyngConsole : LyngConsole {
ctrl: Boolean = false, ctrl: Boolean = false,
alt: Boolean = false, alt: Boolean = false,
shift: Boolean = false, shift: Boolean = false,
): ConsoleEvent.KeyDown = ConsoleEvent.KeyDown( ): ConsoleEvent.KeyDown {
key = value, require(value.isNotEmpty()) { "ConsoleEvent.KeyDown.key must never be empty" }
code = null, return ConsoleEvent.KeyDown(
ctrl = ctrl, key = value,
alt = alt, code = null,
shift = shift, ctrl = ctrl,
meta = false alt = alt,
) shift = shift,
meta = false
)
}
} }
@@ -56,7 +56,7 @@ class LyngConsoleModuleTest {
val d = Console.details() val d = Console.details()
assert(d.supported is Bool) assert(d.supported is Bool)
assert(d.isTty is Bool) assert(d.isTty is Bool)
assert(d.ansiLevel is String) assert(d.ansiLevel is ConsoleAnsiLevel)
val g = Console.geometry() val g = Console.geometry()
if (g != null) { if (g != null) {
+157
View File
@@ -0,0 +1,157 @@
package lyng.io.console
/* Console event kinds used by `ConsoleEvent.type`. */
enum ConsoleEventType {
UNKNOWN,
RESIZE,
KEY_DOWN,
KEY_UP
}
/* Normalized key codes used by `ConsoleKeyEvent.code`. */
enum ConsoleKeyCode {
UNKNOWN,
CHARACTER,
ARROW_UP,
ARROW_DOWN,
ARROW_LEFT,
ARROW_RIGHT,
HOME,
END,
INSERT,
DELETE,
PAGE_UP,
PAGE_DOWN,
ESCAPE,
ENTER,
TAB,
BACKSPACE,
SPACE
}
/* Detected ANSI terminal capability level. */
enum ConsoleAnsiLevel {
NONE,
BASIC16,
ANSI256,
TRUECOLOR
}
/* Base class for console events. */
extern class ConsoleEvent {
/* Event kind for stable matching/switching. */
val type: ConsoleEventType
}
/* Terminal resize event. */
extern class ConsoleResizeEvent : ConsoleEvent {
/* Current terminal width in character cells. */
val columns: Int
/* Current terminal height in character cells. */
val rows: Int
}
/* Keyboard event. */
extern class ConsoleKeyEvent : ConsoleEvent {
/*
Logical key name normalized for app-level handling, for example:
"a", "A", "ArrowLeft", "Escape", "Enter".
*/
val key: String
/* Normalized key code enum for robust matching independent of backend specifics. */
val code: ConsoleKeyCode
/*
Optional backend-specific raw identifier (if available).
Not guaranteed to be present or stable across platforms.
*/
val codeName: String?
/* True when Ctrl was pressed during the key event. */
val ctrl: Bool
/* True when Alt/Option was pressed during the key event. */
val alt: Bool
/* True when Shift was pressed during the key event. */
val shift: Bool
/* True when Meta/Super/Command was pressed during the key event. */
val meta: Bool
}
/* Pull iterator over console events. */
extern class ConsoleEventIterator : Iterator<ConsoleEvent> {
/* Whether another event is currently available from the stream. */
override fun hasNext(): Bool
/* Returns next event or throws iteration-finished when exhausted/cancelled. */
override fun next(): ConsoleEvent
/* Stops this iterator. The underlying console service remains managed by runtime. */
override fun cancelIteration(): void
}
/* Endless iterable console event stream. */
extern class ConsoleEventStream : Iterable<ConsoleEvent> {
/* Creates a fresh event iterator bound to the current console input stream. */
override fun iterator(): ConsoleEventIterator
}
/* Terminal geometry in character cells. */
extern class ConsoleGeometry {
val columns: Int
val rows: Int
}
/* Snapshot of console support/capabilities. */
extern class ConsoleDetails {
/* True when current runtime has console control implementation. */
val supported: Bool
/* True when output/input are attached to an interactive terminal. */
val isTty: Bool
/* Detected terminal color capability level. */
val ansiLevel: ConsoleAnsiLevel
/* Current terminal size if available, otherwise null. */
val geometry: ConsoleGeometry?
}
/* Console API singleton object. */
extern object Console {
/* Returns true when console control API is implemented in this runtime. */
fun isSupported(): Bool
/* Returns true when process is attached to interactive TTY. */
fun isTty(): Bool
/* Returns detected color capability level. */
fun ansiLevel(): ConsoleAnsiLevel
/* Returns current terminal geometry, or null when unavailable. */
fun geometry(): ConsoleGeometry?
/* Returns combined capability snapshot in one call. */
fun details(): ConsoleDetails
/* Writes raw text to console output buffer (no implicit newline). */
fun write(text: String): void
/* Flushes pending console output. Call after batched writes. */
fun flush(): void
/* Moves cursor to home position (row 1, column 1). */
fun home(): void
/* Clears visible screen buffer. Cursor position is backend-dependent after clear. */
fun clear(): void
/* Moves cursor to 1-based row/column. Values outside viewport are backend-defined. */
fun moveTo(row: Int, column: Int): void
/* Clears current line content. Cursor stays on the same line. */
fun clearLine(): void
/* Switches terminal into alternate screen buffer (useful for TUIs). */
fun enterAltScreen(): void
/* Returns from alternate screen buffer to the normal terminal screen. */
fun leaveAltScreen(): void
/* Shows or hides the cursor. Prefer restoring visibility in finally blocks. */
fun setCursorVisible(visible: Bool): void
/*
Returns endless event stream (resize + key events).
Typical usage is consuming in a launched loop.
*/
fun events(): ConsoleEventStream
/*
Enables/disables raw keyboard mode.
Returns true when state was actually changed.
*/
fun setRawMode(enabled: Bool): Bool
}
+2 -1
View File
@@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych" group = "net.sergeych"
version = "1.5.0-RC" version = "1.5.1"
// Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below // Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below
@@ -101,6 +101,7 @@ kotlin {
//put your multiplatform dependencies here //put your multiplatform dependencies here
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
api(libs.mp.bintools) api(libs.mp.bintools)
implementation(libs.ionspin.bignum)
} }
} }
val commonTest by getting { val commonTest by getting {
@@ -184,6 +184,7 @@ class Compiler(
private val encodedPayloadTypeByName: MutableMap<String, ObjClass> = mutableMapOf() private val encodedPayloadTypeByName: MutableMap<String, ObjClass> = mutableMapOf()
private val objectDeclNames: MutableSet<String> = mutableSetOf() private val objectDeclNames: MutableSet<String> = mutableSetOf()
private val externCallableNames: MutableSet<String> = mutableSetOf() private val externCallableNames: MutableSet<String> = mutableSetOf()
private val externBindingNames: MutableSet<String> = mutableSetOf()
private val moduleDeclaredNames: MutableSet<String> = mutableSetOf() private val moduleDeclaredNames: MutableSet<String> = mutableSetOf()
private var seedingSlotPlan: Boolean = false private var seedingSlotPlan: Boolean = false
@@ -192,6 +193,7 @@ class Compiler(
if (plan.slots.isEmpty()) return emptyMap() if (plan.slots.isEmpty()) return emptyMap()
val result = LinkedHashMap<String, ForcedLocalSlotInfo>(plan.slots.size) val result = LinkedHashMap<String, ForcedLocalSlotInfo>(plan.slots.size)
for ((name, entry) in plan.slots) { for ((name, entry) in plan.slots) {
if (externBindingNames.contains(name)) continue
result[name] = ForcedLocalSlotInfo( result[name] = ForcedLocalSlotInfo(
index = entry.index, index = entry.index,
isMutable = entry.isMutable, isMutable = entry.isMutable,
@@ -581,7 +583,7 @@ class Compiler(
val baseNames = cls.directParents.map { it.className } val baseNames = cls.directParents.map { it.className }
val nextFieldId = (fieldIds.values.maxOrNull() ?: -1) + 1 val nextFieldId = (fieldIds.values.maxOrNull() ?: -1) + 1
val nextMethodId = (methodIds.values.maxOrNull() ?: -1) + 1 val nextMethodId = (methodIds.values.maxOrNull() ?: -1) + 1
return CompileClassInfo(name, fieldIds, methodIds, nextFieldId, nextMethodId, baseNames) return CompileClassInfo(name, cls.logicalPackageName, fieldIds, methodIds, nextFieldId, nextMethodId, baseNames)
} }
private data class BaseMemberIds( private data class BaseMemberIds(
@@ -885,6 +887,7 @@ class Compiler(
name = declaredName, name = declaredName,
isMutable = false, isMutable = false,
visibility = Visibility.Public, visibility = Visibility.Public,
actualExtern = false,
initializer = initStmt, initializer = initStmt,
isTransient = false, isTransient = false,
typeDecl = null, typeDecl = null,
@@ -1550,6 +1553,7 @@ class Compiler(
private data class CompileClassInfo( private data class CompileClassInfo(
val name: String, val name: String,
val packageName: String?,
val fieldIds: Map<String, Int>, val fieldIds: Map<String, Int>,
val methodIds: Map<String, Int>, val methodIds: Map<String, Int>,
val nextFieldId: Int, val nextFieldId: Int,
@@ -1751,6 +1755,7 @@ class Compiler(
enumEntriesByName = enumEntriesByName, enumEntriesByName = enumEntriesByName,
callableReturnTypeByScopeId = callableReturnTypeByScopeId, callableReturnTypeByScopeId = callableReturnTypeByScopeId,
callableReturnTypeByName = callableReturnTypeByName, callableReturnTypeByName = callableReturnTypeByName,
externBindingNames = externBindingNames,
lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef
) as BytecodeStatement ) as BytecodeStatement
unwrapped to bytecodeStmt.bytecodeFunction() unwrapped to bytecodeStmt.bytecodeFunction()
@@ -1957,6 +1962,9 @@ class Compiler(
val scopeIndex = slotPlanStack.indexOfLast { it.id == slotLoc.scopeId } val scopeIndex = slotPlanStack.indexOfLast { it.id == slotLoc.scopeId }
if (functionIndex >= 0 && scopeIndex >= functionIndex) return null if (functionIndex >= 0 && scopeIndex >= functionIndex) return null
val modulePlan = moduleSlotPlan() val modulePlan = moduleSlotPlan()
if (modulePlan != null && slotLoc.scopeId == modulePlan.id && externBindingNames.contains(name)) {
return null
}
if (useScopeSlots && modulePlan != null && slotLoc.scopeId == modulePlan.id) { if (useScopeSlots && modulePlan != null && slotLoc.scopeId == modulePlan.id) {
return null return null
} }
@@ -2075,6 +2083,7 @@ class Compiler(
callableReturnTypeByScopeId = callableReturnTypeByScopeId, callableReturnTypeByScopeId = callableReturnTypeByScopeId,
callableReturnTypeByName = callableReturnTypeByName, callableReturnTypeByName = callableReturnTypeByName,
externCallableNames = externCallableNames, externCallableNames = externCallableNames,
externBindingNames = externBindingNames,
lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef
) )
} }
@@ -2105,6 +2114,7 @@ class Compiler(
callableReturnTypeByScopeId = callableReturnTypeByScopeId, callableReturnTypeByScopeId = callableReturnTypeByScopeId,
callableReturnTypeByName = callableReturnTypeByName, callableReturnTypeByName = callableReturnTypeByName,
externCallableNames = externCallableNames, externCallableNames = externCallableNames,
externBindingNames = externBindingNames,
lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef
) )
} }
@@ -2160,6 +2170,7 @@ class Compiler(
callableReturnTypeByScopeId = callableReturnTypeByScopeId, callableReturnTypeByScopeId = callableReturnTypeByScopeId,
callableReturnTypeByName = callableReturnTypeByName, callableReturnTypeByName = callableReturnTypeByName,
externCallableNames = externCallableNames, externCallableNames = externCallableNames,
externBindingNames = externBindingNames,
lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef
) )
} }
@@ -2343,6 +2354,7 @@ class Compiler(
stmt.name, stmt.name,
stmt.isMutable, stmt.isMutable,
stmt.visibility, stmt.visibility,
stmt.actualExtern,
init, init,
stmt.isTransient, stmt.isTransient,
stmt.typeDecl, stmt.typeDecl,
@@ -4655,6 +4667,7 @@ class Compiler(
} }
is MethodCallRef -> methodReturnTypeDeclByRef[ref] is MethodCallRef -> methodReturnTypeDeclByRef[ref]
is CallRef -> callReturnTypeDeclByRef[ref] is CallRef -> callReturnTypeDeclByRef[ref]
is BinaryOpRef -> inferBinaryOpReturnTypeDecl(ref)
is StatementRef -> (ref.statement as? ExpressionStatement)?.let { resolveReceiverTypeDecl(it.ref) } is StatementRef -> (ref.statement as? ExpressionStatement)?.let { resolveReceiverTypeDecl(it.ref) }
else -> null else -> null
} }
@@ -4718,6 +4731,7 @@ class Compiler(
is ThisMethodSlotCallRef -> inferMethodCallReturnClass(ref.methodName()) is ThisMethodSlotCallRef -> inferMethodCallReturnClass(ref.methodName())
is QualifiedThisMethodSlotCallRef -> inferMethodCallReturnClass(ref.methodName()) is QualifiedThisMethodSlotCallRef -> inferMethodCallReturnClass(ref.methodName())
is CallRef -> inferCallReturnClass(ref) is CallRef -> inferCallReturnClass(ref)
is BinaryOpRef -> inferBinaryOpReturnClass(ref)
is FieldRef -> { is FieldRef -> {
val targetClass = resolveReceiverClassForMember(ref.target) val targetClass = resolveReceiverClassForMember(ref.target)
inferFieldReturnClass(targetClass, ref.name) inferFieldReturnClass(targetClass, ref.name)
@@ -4732,10 +4746,105 @@ class Compiler(
} }
} }
private fun typeDeclOfClass(objClass: ObjClass): TypeDecl = TypeDecl.Simple(objClass.className, false)
private fun binaryOpMethodName(op: BinOp): String? = when (op) {
BinOp.PLUS -> "plus"
BinOp.MINUS -> "minus"
BinOp.STAR -> "mul"
BinOp.SLASH -> "div"
BinOp.PERCENT -> "mod"
else -> null
}
private fun interopOperatorFor(op: BinOp): InteropOperator? = when (op) {
BinOp.PLUS -> InteropOperator.Plus
BinOp.MINUS -> InteropOperator.Minus
BinOp.STAR -> InteropOperator.Mul
BinOp.SLASH -> InteropOperator.Div
BinOp.PERCENT -> InteropOperator.Mod
BinOp.LT, BinOp.LTE, BinOp.GT, BinOp.GTE -> InteropOperator.Compare
BinOp.EQ, BinOp.NEQ -> InteropOperator.Equals
else -> null
}
private fun sameClassArithmeticFallback(leftClass: ObjClass, rightClass: ObjClass, op: BinOp): TypeDecl? {
if (leftClass !== rightClass) return null
val methodName = binaryOpMethodName(op) ?: return null
if (leftClass.getInstanceMemberOrNull(methodName, includeAbstract = true) == null) return null
return typeDeclOfClass(leftClass)
}
private fun inferBinaryOpReturnTypeDecl(ref: BinaryOpRef): TypeDecl? {
val leftClass = resolveReceiverClassForMember(ref.left) ?: inferObjClassFromRef(ref.left)
val rightClass = resolveReceiverClassForMember(ref.right) ?: inferObjClassFromRef(ref.right)
val boolType = typeDeclOfClass(ObjBool.type)
val intType = typeDeclOfClass(ObjInt.type)
val realType = typeDeclOfClass(ObjReal.type)
val stringType = typeDeclOfClass(ObjString.type)
when (ref.op) {
BinOp.OR, BinOp.AND,
BinOp.EQARROW, BinOp.EQ, BinOp.NEQ, BinOp.REF_EQ, BinOp.REF_NEQ, BinOp.MATCH, BinOp.NOTMATCH,
BinOp.LTE, BinOp.LT, BinOp.GTE, BinOp.GT,
BinOp.IN, BinOp.NOTIN,
BinOp.IS, BinOp.NOTIS -> return boolType
else -> {}
}
if (leftClass == null || rightClass == null) return null
val leftIsInt = leftClass == ObjInt.type
val rightIsInt = rightClass == ObjInt.type
val leftIsReal = leftClass == ObjReal.type
val rightIsReal = rightClass == ObjReal.type
val leftIsNumeric = leftIsInt || leftIsReal
val rightIsNumeric = rightIsInt || rightIsReal
return when (ref.op) {
BinOp.PLUS -> when {
leftIsInt && rightIsInt -> intType
leftIsNumeric && rightIsNumeric -> realType
leftClass == ObjString.type -> stringType
interopOperatorFor(ref.op)?.let {
OperatorInteropRegistry.commonClassFor(leftClass, rightClass, it)
} != null -> typeDeclOfClass(
OperatorInteropRegistry.commonClassFor(leftClass, rightClass, interopOperatorFor(ref.op)!!)!!
)
else -> binaryOpMethodName(ref.op)?.let { classMethodReturnTypeDecl(leftClass, it) }
?: sameClassArithmeticFallback(leftClass, rightClass, ref.op)
}
BinOp.MINUS, BinOp.STAR, BinOp.SLASH, BinOp.PERCENT -> when {
leftIsInt && rightIsInt -> intType
leftIsNumeric && rightIsNumeric -> realType
ref.op == BinOp.STAR && leftClass == ObjString.type && rightIsNumeric -> stringType
interopOperatorFor(ref.op)?.let {
OperatorInteropRegistry.commonClassFor(leftClass, rightClass, it)
} != null -> typeDeclOfClass(
OperatorInteropRegistry.commonClassFor(leftClass, rightClass, interopOperatorFor(ref.op)!!)!!
)
else -> binaryOpMethodName(ref.op)?.let { classMethodReturnTypeDecl(leftClass, it) }
?: sameClassArithmeticFallback(leftClass, rightClass, ref.op)
}
BinOp.BAND, BinOp.BOR, BinOp.BXOR, BinOp.SHL, BinOp.SHR ->
if (leftIsInt && rightIsInt) intType else null
else -> null
}
}
private fun inferBinaryOpReturnClass(ref: BinaryOpRef): ObjClass? { private fun inferBinaryOpReturnClass(ref: BinaryOpRef): ObjClass? {
inferBinaryOpReturnTypeDecl(ref)?.let { declared ->
resolveTypeDeclObjClass(declared)?.let { return it }
}
val leftClass = resolveReceiverClassForMember(ref.left) ?: inferObjClassFromRef(ref.left) val leftClass = resolveReceiverClassForMember(ref.left) ?: inferObjClassFromRef(ref.left)
val rightClass = resolveReceiverClassForMember(ref.right) ?: inferObjClassFromRef(ref.right) val rightClass = resolveReceiverClassForMember(ref.right) ?: inferObjClassFromRef(ref.right)
if (leftClass == null || rightClass == null) return null if (leftClass == null || rightClass == null) return null
interopOperatorFor(ref.op)?.let { op ->
OperatorInteropRegistry.commonClassFor(leftClass, rightClass, op)?.let { return it }
}
sameClassArithmeticFallback(leftClass, rightClass, ref.op)?.let { declared ->
resolveTypeDeclObjClass(declared)?.let { return it }
}
return when (ref.op) { return when (ref.op) {
BinOp.PLUS, BinOp.MINUS -> when { BinOp.PLUS, BinOp.MINUS -> when {
leftClass == ObjInstant.type && rightClass == ObjInstant.type && ref.op == BinOp.MINUS -> ObjDuration.type leftClass == ObjInstant.type && rightClass == ObjInstant.type && ref.op == BinOp.MINUS -> ObjDuration.type
@@ -6446,7 +6555,7 @@ class Compiler(
WhenStatement(value, cases, elseCase, whenPos) WhenStatement(value, cases, elseCase, whenPos)
} else { } else {
// when { cond -> ... } // when { cond -> ... }
TODO("when without object is not yet implemented") throw ScriptError(t.pos, "when without subject is not implemented")
} }
return wrapBytecode(stmt) return wrapBytecode(stmt)
} }
@@ -6699,6 +6808,7 @@ class Compiler(
) )
compileClassInfos[qualifiedName] = CompileClassInfo( compileClassInfos[qualifiedName] = CompileClassInfo(
name = qualifiedName, name = qualifiedName,
packageName = packageName,
fieldIds = fieldIds, fieldIds = fieldIds,
methodIds = methodIds, methodIds = methodIds,
nextFieldId = fieldIds.size, nextFieldId = fieldIds.size,
@@ -6821,6 +6931,7 @@ class Compiler(
classCtx?.let { ctx -> classCtx?.let { ctx ->
compileClassInfos[className] = CompileClassInfo( compileClassInfos[className] = CompileClassInfo(
name = className, name = className,
packageName = packageName,
fieldIds = ctx.memberFieldIds.toMap(), fieldIds = ctx.memberFieldIds.toMap(),
methodIds = ctx.memberMethodIds.toMap(), methodIds = ctx.memberMethodIds.toMap(),
nextFieldId = ctx.nextFieldId, nextFieldId = ctx.nextFieldId,
@@ -6862,6 +6973,7 @@ class Compiler(
val baseIds = collectBaseMemberIds(baseSpecs.map { it.name }) val baseIds = collectBaseMemberIds(baseSpecs.map { it.name })
compileClassInfos[className] = CompileClassInfo( compileClassInfos[className] = CompileClassInfo(
name = className, name = className,
packageName = packageName,
fieldIds = baseIds.fieldIds, fieldIds = baseIds.fieldIds,
methodIds = baseIds.methodIds, methodIds = baseIds.methodIds,
nextFieldId = baseIds.nextFieldId, nextFieldId = baseIds.nextFieldId,
@@ -7110,6 +7222,7 @@ class Compiler(
} }
compileClassInfos[qualifiedName] = CompileClassInfo( compileClassInfos[qualifiedName] = CompileClassInfo(
name = qualifiedName, name = qualifiedName,
packageName = packageName,
fieldIds = ctx.memberFieldIds.toMap(), fieldIds = ctx.memberFieldIds.toMap(),
methodIds = ctx.memberMethodIds.toMap(), methodIds = ctx.memberMethodIds.toMap(),
nextFieldId = ctx.nextFieldId, nextFieldId = ctx.nextFieldId,
@@ -7125,6 +7238,7 @@ class Compiler(
classCtx?.let { ctx -> classCtx?.let { ctx ->
compileClassInfos[qualifiedName] = CompileClassInfo( compileClassInfos[qualifiedName] = CompileClassInfo(
name = qualifiedName, name = qualifiedName,
packageName = packageName,
fieldIds = ctx.memberFieldIds.toMap(), fieldIds = ctx.memberFieldIds.toMap(),
methodIds = ctx.memberMethodIds.toMap(), methodIds = ctx.memberMethodIds.toMap(),
nextFieldId = ctx.nextFieldId, nextFieldId = ctx.nextFieldId,
@@ -7211,6 +7325,7 @@ class Compiler(
} }
compileClassInfos[qualifiedName] = CompileClassInfo( compileClassInfos[qualifiedName] = CompileClassInfo(
name = qualifiedName, name = qualifiedName,
packageName = packageName,
fieldIds = ctx.memberFieldIds.toMap(), fieldIds = ctx.memberFieldIds.toMap(),
methodIds = ctx.memberMethodIds.toMap(), methodIds = ctx.memberMethodIds.toMap(),
nextFieldId = ctx.nextFieldId, nextFieldId = ctx.nextFieldId,
@@ -8390,7 +8505,7 @@ class Compiler(
is ImplicitThisMethodCallRef -> { is ImplicitThisMethodCallRef -> {
when (directRef.methodName()) { when (directRef.methodName()) {
"iterator" -> ObjIterator "iterator" -> ObjIterator
"lazy" -> ObjLazyDelegate.type "lazy" -> resolveClassByName("lazy") ?: inferMethodCallReturnClass(directRef.methodName())
else -> inferMethodCallReturnClass(directRef.methodName()) else -> inferMethodCallReturnClass(directRef.methodName())
} }
} }
@@ -8409,7 +8524,7 @@ class Compiler(
when { when {
target is LocalSlotRef -> { target is LocalSlotRef -> {
when (target.name) { when (target.name) {
"lazy" -> ObjLazyDelegate.type "lazy" -> resolveClassByName("lazy")
"iterator" -> ObjIterator "iterator" -> ObjIterator
"flow" -> ObjFlow.type "flow" -> ObjFlow.type
"launch" -> ObjDeferred.type "launch" -> ObjDeferred.type
@@ -8420,7 +8535,7 @@ class Compiler(
} }
target is LocalVarRef -> { target is LocalVarRef -> {
when (target.name) { when (target.name) {
"lazy" -> ObjLazyDelegate.type "lazy" -> resolveClassByName("lazy")
"iterator" -> ObjIterator "iterator" -> ObjIterator
"flow" -> ObjFlow.type "flow" -> ObjFlow.type
"launch" -> ObjDeferred.type "launch" -> ObjDeferred.type
@@ -8459,7 +8574,9 @@ class Compiler(
?: unwrapDirectRef(initializer)?.let { inferObjClassFromRef(it) } ?: unwrapDirectRef(initializer)?.let { inferObjClassFromRef(it) }
?: throw ScriptError(initializer.pos, "Delegate type must be known at compile time") ?: throw ScriptError(initializer.pos, "Delegate type must be known at compile time")
if (initClass !== delegateClass && if (initClass !== delegateClass &&
initClass.logicalName != delegateClass.logicalName &&
!initClass.allParentsSet.contains(delegateClass) && !initClass.allParentsSet.contains(delegateClass) &&
!initClass.allParentsSet.any { it.logicalName == delegateClass.logicalName } &&
!initClass.allImplementingNames.contains(delegateClass.className) !initClass.allImplementingNames.contains(delegateClass.className)
) { ) {
throw ScriptError( throw ScriptError(
@@ -8549,7 +8666,9 @@ class Compiler(
if (closedParent != null) { if (closedParent != null) {
throw ScriptError(Pos.builtIn, "can't inherit from closed class ${closedParent.className}") throw ScriptError(Pos.builtIn, "can't inherit from closed class ${closedParent.className}")
} }
ObjInstanceClass(info.name, *parents.toTypedArray()) ObjInstanceClass(info.name, *parents.toTypedArray()).apply {
logicalPackageNameOverride = info.packageName
}
} }
if (stub is ObjInstanceClass) { if (stub is ObjInstanceClass) {
for ((fieldName, fieldId) in info.fieldIds) { for ((fieldName, fieldId) in info.fieldIds) {
@@ -8893,7 +9012,7 @@ class Compiler(
val effectiveEqToken = if (isProperty) null else eqToken val effectiveEqToken = if (isProperty) null else eqToken
// Register the local name at compile time so that subsequent identifiers can be emitted as fast locals // Register the local name at compile time so that subsequent identifiers can be emitted as fast locals
if (!isStatic && declaringClassNameCaptured == null) declareLocalName(name, isMutable) if (!isStatic && declaringClassNameCaptured == null && !actualExtern) declareLocalName(name, isMutable)
val declKind = if (codeContexts.lastOrNull() is CodeContext.ClassBody) { val declKind = if (codeContexts.lastOrNull() is CodeContext.ClassBody) {
SymbolKind.MEMBER SymbolKind.MEMBER
} else { } else {
@@ -8902,6 +9021,8 @@ class Compiler(
resolutionSink?.declareSymbol(name, declKind, isMutable, nameStartPos, isOverride = isOverride) resolutionSink?.declareSymbol(name, declKind, isMutable, nameStartPos, isOverride = isOverride)
if (declKind == SymbolKind.MEMBER && extTypeName == null) { if (declKind == SymbolKind.MEMBER && extTypeName == null) {
(codeContexts.lastOrNull() as? CodeContext.ClassBody)?.declaredMembers?.add(name) (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.declaredMembers?.add(name)
} else if (actualExtern) {
externBindingNames.add(name)
} }
val isDelegate = if (isAbstract || actualExtern) { val isDelegate = if (isAbstract || actualExtern) {
@@ -8967,7 +9088,11 @@ class Compiler(
if (isDelegate && initialExpression != null) { if (isDelegate && initialExpression != null) {
ensureDelegateType(initialExpression) ensureDelegateType(initialExpression)
if (isMutable && resolveInitializerObjClass(initialExpression) == ObjLazyDelegate.type) { val lazyClass = resolveClassByName("lazy")
if (isMutable &&
lazyClass != null &&
resolveInitializerObjClass(initialExpression)?.logicalName == lazyClass.logicalName
) {
throw ScriptError(initialExpression.pos, "lazy delegate is read-only") throw ScriptError(initialExpression.pos, "lazy delegate is read-only")
} }
} }
@@ -9059,6 +9184,7 @@ class Compiler(
name, name,
isMutable, isMutable,
visibility, visibility,
actualExtern,
initialExpression, initialExpression,
isTransient, isTransient,
declaredType, declaredType,
@@ -0,0 +1,109 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjClass
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjVoid
internal enum class InteropOperator(val memberName: String?) {
Plus("plus"),
Minus("minus"),
Mul("mul"),
Div("div"),
Mod("mod"),
Compare("compareTo"),
Equals("equals");
companion object {
fun fromName(name: String): InteropOperator =
entries.firstOrNull { it.name == name }
?: throw IllegalArgumentException("unknown interop operator: $name")
}
}
private data class InteropRule(
val commonClass: ObjClass,
val operators: Set<InteropOperator>,
val leftToCommon: Obj,
val rightToCommon: Obj
)
internal data class PromotedOperands(val left: Obj, val right: Obj)
internal object OperatorInteropRegistry {
private val rules = mutableMapOf<Pair<ObjClass, ObjClass>, InteropRule>()
fun register(
leftClass: ObjClass,
rightClass: ObjClass,
commonClass: ObjClass,
operatorNames: List<String>,
leftToCommon: Obj,
rightToCommon: Obj
) {
val operators = operatorNames.mapTo(linkedSetOf(), InteropOperator::fromName)
rules[leftClass to rightClass] = InteropRule(commonClass, operators, leftToCommon, rightToCommon)
if (leftClass !== rightClass) {
rules[rightClass to leftClass] = InteropRule(commonClass, operators, rightToCommon, leftToCommon)
}
}
suspend fun promote(scope: Scope, left: Obj, right: Obj, operator: InteropOperator): PromotedOperands? {
val leftValue = unwrap(left)
val rightValue = unwrap(right)
val rule = rules[leftValue.objClass to rightValue.objClass] ?: return null
if (operator !in rule.operators) return null
val promotedLeft = rule.leftToCommon.invoke(scope, ObjVoid, Arguments(leftValue))
val promotedRight = rule.rightToCommon.invoke(scope, ObjVoid, Arguments(rightValue))
if (promotedLeft.objClass !== rule.commonClass || promotedRight.objClass !== rule.commonClass) {
scope.raiseIllegalState(
"Operator interop promotion must return ${rule.commonClass.className}, " +
"got ${promotedLeft.objClass.className} and ${promotedRight.objClass.className}"
)
}
return PromotedOperands(promotedLeft, promotedRight)
}
suspend fun invokeBinary(scope: Scope, left: Obj, right: Obj, operator: InteropOperator): Obj? {
val promoted = promote(scope, left, right, operator) ?: return null
val memberName = operator.memberName ?: return null
return promoted.left.invokeInstanceMethod(scope, memberName, Arguments(promoted.right))
}
suspend fun invokeCompare(scope: Scope, left: Obj, right: Obj): Int? {
val promoted = promote(scope, left, right, InteropOperator.Compare) ?: return null
return promoted.left.invokeInstanceMethod(scope, "compareTo", Arguments(promoted.right))
.cast<ObjInt>(scope)
.value
.toInt()
}
fun commonClassFor(leftClass: ObjClass, rightClass: ObjClass, operator: InteropOperator): ObjClass? {
val rule = rules[leftClass to rightClass] ?: return null
if (operator !in rule.operators) return null
return rule.commonClass
}
private suspend fun unwrap(obj: Obj): Obj = when (obj) {
is FrameSlotRef -> obj.read()
is RecordSlotRef -> obj.read()
else -> obj
}
}
@@ -26,7 +26,10 @@ val idNextChars = { d: Char -> d.isLetter() || d == '_' || d.isDigit() || d == '
val idFirstChars = { d: Char -> d.isLetter() || d == '_' || d == '$' } val idFirstChars = { d: Char -> d.isLetter() || d == '_' || d == '$' }
fun parseLyng(source: Source): List<Token> { fun parseLyng(source: Source): List<Token> {
val p = Parser(fromPos = source.startPos) val p = Parser(
fromPos = source.startPos,
interpolationEnabled = detectInterpolationEnabled(source.text)
)
val tokens = mutableListOf<Token>() val tokens = mutableListOf<Token>()
do { do {
val t = p.nextToken() val t = p.nextToken()
@@ -35,9 +38,70 @@ fun parseLyng(source: Source): List<Token> {
return tokens return tokens
} }
private class Parser(fromPos: Pos) { private fun parseLyng(source: Source, interpolationEnabled: Boolean): List<Token> {
val p = Parser(
fromPos = source.startPos,
interpolationEnabled = interpolationEnabled
)
val tokens = mutableListOf<Token>()
do {
val t = p.nextToken()
tokens += t
} while (t.type != Token.Type.EOF)
return tokens
}
private fun detectInterpolationEnabled(text: String): Boolean {
// Per-file feature switch in leading comments:
// // feature: interpolation: off
// // feature: interpolation: on
var enabled = true
val lines = text.split('\n')
var i = 0
while (i < lines.size) {
val line = lines[i]
val trimmed = line.trim()
if (trimmed.isBlank()) {
i++
continue
}
if (i == 0 && trimmed.startsWith("#!")) {
i++
continue
}
if (trimmed.startsWith("//")) {
val m = Regex("^//\\s*feature\\s*:\\s*interpolation\\s*:\\s*(on|off)\\s*$", RegexOption.IGNORE_CASE)
.matchEntire(trimmed)
if (m != null) {
enabled = m.groupValues[1].equals("on", ignoreCase = true)
}
i++
continue
}
if (trimmed.startsWith("/*")) {
// Skip leading block comment(s); directives are intentionally line-comment based.
var j = i
var closed = false
while (j < lines.size) {
if (lines[j].contains("*/")) {
closed = true
break
}
j++
}
if (!closed) break
i = j + 1
continue
}
break
}
return enabled
}
private class Parser(fromPos: Pos, private val interpolationEnabled: Boolean = true) {
private val pos = MutablePos(fromPos) private val pos = MutablePos(fromPos)
private val bufferedTokens = ArrayDeque<Token>()
/** /**
* Immutable copy of current position * Immutable copy of current position
@@ -47,6 +111,7 @@ private class Parser(fromPos: Pos) {
private fun raise(msg: String): Nothing = throw ScriptError(currentPos, msg) private fun raise(msg: String): Nothing = throw ScriptError(currentPos, msg)
fun nextToken(): Token { fun nextToken(): Token {
if (bufferedTokens.isNotEmpty()) return bufferedTokens.removeFirst()
skipws() skipws()
if (pos.end) return Token("", currentPos, Token.Type.EOF) if (pos.end) return Token("", currentPos, Token.Type.EOF)
val from = currentPos val from = currentPos
@@ -296,7 +361,7 @@ private class Parser(fromPos: Pos) {
Token(":", from, Token.Type.COLON) Token(":", from, Token.Type.COLON)
} }
'"' -> loadStringToken() '"' -> loadStringTokens(from)
in digitsSet -> { in digitsSet -> {
pos.back() pos.back()
@@ -485,11 +550,6 @@ private class Parser(fromPos: Pos) {
private fun loadStringToken(): Token { private fun loadStringToken(): Token {
val start = currentPos val start = currentPos
// if (currentChar == '"') pos.advance()
// else start = start.back()
// start = start.back()
val sb = StringBuilder() val sb = StringBuilder()
var newlineDetected = false var newlineDetected = false
while (currentChar != '"') { while (currentChar != '"') {
@@ -548,6 +608,262 @@ private class Parser(fromPos: Pos) {
return Token(result, start, Token.Type.STRING) return Token(result, start, Token.Type.STRING)
} }
private sealed interface StringChunk {
data class Literal(val text: String, val pos: Pos) : StringChunk
data class Expr(val tokens: List<Token>, val pos: Pos) : StringChunk
}
private fun loadStringTokens(startQuotePos: Pos): Token {
if (!interpolationEnabled) return loadStringToken()
val tokenPos = currentPos
val chunks = mutableListOf<StringChunk>()
val literal = StringBuilder()
var newlineDetected = false
var hasInterpolation = false
fun flushLiteralChunk() {
if (literal.isNotEmpty()) {
chunks += StringChunk.Literal(literal.toString(), tokenPos)
literal.clear()
}
}
while (currentChar != '"') {
if (pos.end) throw ScriptError(startQuotePos, "unterminated string started there")
when (currentChar) {
'\\' -> {
pos.advance() ?: raise("unterminated string")
when (currentChar) {
'n' -> {
literal.append('\n'); pos.advance()
}
'r' -> {
literal.append('\r'); pos.advance()
}
't' -> {
literal.append('\t'); pos.advance()
}
'"' -> {
literal.append('"'); pos.advance()
}
'\\' -> {
literal.append('\\'); pos.advance()
}
'u' -> {
literal.append(loadUnicodeEscape(tokenPos))
}
'$' -> {
// Backslash-escaped dollar is always literal.
literal.append('$')
pos.advance()
}
else -> {
literal.append('\\').append(currentChar)
pos.advance()
}
}
}
'$' -> {
pos.advance()
when {
currentChar == '$' -> {
// $$ -> literal '$'
literal.append('$')
pos.advance()
}
currentChar == '{' -> {
hasInterpolation = true
flushLiteralChunk()
val exprStart = pos.toPos()
pos.advance() // consume '{'
val exprText = readInterpolationExprText(startQuotePos)
val exprTokens = parseEmbeddedExpressionTokens(exprText, exprStart)
chunks += StringChunk.Expr(exprTokens, exprStart)
}
idFirstChars(currentChar) -> {
hasInterpolation = true
flushLiteralChunk()
val idPos = pos.toPos()
val id = loadChars(idNextChars)
val idToken = Token(id, idPos, Token.Type.ID)
chunks += StringChunk.Expr(listOf(idToken), idPos)
}
else -> {
// Bare '$' before non-interpolation text stays literal.
literal.append('$')
}
}
}
'\n', '\r' -> {
newlineDetected = true
literal.append(currentChar)
pos.advance()
}
else -> {
literal.append(currentChar)
pos.advance()
}
}
}
pos.advance() // closing quote
if (!hasInterpolation) {
val result = literal.toString().let { if (newlineDetected) fixMultilineStringLiteral(it) else it }
return Token(result, tokenPos, Token.Type.STRING)
}
flushLiteralChunk()
if (chunks.isEmpty()) {
return Token("", tokenPos, Token.Type.STRING)
}
val expanded = mutableListOf<Token>()
expanded += Token("(", tokenPos, Token.Type.LPAREN)
var emittedPieces = 0
for (chunk in chunks) {
val pieceTokens = when (chunk) {
is StringChunk.Literal -> {
if (chunk.text.isEmpty()) emptyList()
else listOf(Token(chunk.text, chunk.pos, Token.Type.STRING))
}
is StringChunk.Expr -> {
if (chunk.tokens.isEmpty()) throw ScriptError(chunk.pos, "empty interpolation expression")
if (chunk.tokens.size == 1) {
chunk.tokens
} else {
listOf(Token("(", chunk.pos, Token.Type.LPAREN)) +
chunk.tokens +
listOf(Token(")", chunk.pos, Token.Type.RPAREN))
}
}
}
if (pieceTokens.isEmpty()) continue
if (emittedPieces > 0) expanded += Token("+", tokenPos, Token.Type.PLUS)
expanded += pieceTokens
emittedPieces++
}
if (emittedPieces == 0) {
expanded += Token("", tokenPos, Token.Type.STRING)
}
expanded += Token(")", tokenPos, Token.Type.RPAREN)
val first = expanded.first()
for (i in 1 until expanded.size) bufferedTokens.addLast(expanded[i])
return first
}
private fun parseEmbeddedExpressionTokens(text: String, exprPos: Pos): List<Token> {
val tokens = parseLyng(Source(exprPos.source.fileName, text), interpolationEnabled)
if (tokens.isEmpty()) return emptyList()
val withoutEof = if (tokens.last().type == Token.Type.EOF) tokens.dropLast(1) else tokens
return withoutEof
}
private fun readInterpolationExprText(start: Pos): String {
val out = StringBuilder()
var depth = 1
while (!pos.end) {
val ch = currentChar
if (ch == '"') {
appendQuoted(out, '"')
continue
}
if (ch == '\'') {
appendQuoted(out, '\'')
continue
}
if (ch == '/' && peekChar() == '/') {
out.append('/').append('/')
pos.advance()
pos.advance()
while (!pos.end && currentChar != '\n') {
out.append(currentChar)
pos.advance()
}
continue
}
if (ch == '/' && peekChar() == '*') {
out.append('/').append('*')
pos.advance()
pos.advance()
var closed = false
while (!pos.end) {
val c = currentChar
if (c == '*' && peekChar() == '/') {
out.append('*').append('/')
pos.advance()
pos.advance()
closed = true
break
}
out.append(c)
pos.advance()
}
if (!closed) throw ScriptError(start, "unterminated block comment in interpolation")
continue
}
if (ch == '{') {
depth++
out.append(ch)
pos.advance()
continue
}
if (ch == '}') {
depth--
if (depth == 0) {
pos.advance() // consume closing '}'
return out.toString()
}
out.append(ch)
pos.advance()
continue
}
out.append(ch)
pos.advance()
}
throw ScriptError(start, "unterminated interpolation expression")
}
private fun appendQuoted(out: StringBuilder, quote: Char) {
out.append(quote)
pos.advance()
while (!pos.end) {
val c = currentChar
out.append(c)
pos.advance()
if (c == '\\') {
if (!pos.end) {
out.append(currentChar)
pos.advance()
}
continue
}
if (c == quote) return
}
}
private fun peekChar(): Char {
if (pos.end) return 0.toChar()
val mark = pos.toPos()
pos.advance()
val result = currentChar
pos.resetTo(mark)
return result
}
private fun loadUnicodeEscape(start: Pos): Char { private fun loadUnicodeEscape(start: Pos): Char {
// Called when currentChar points to 'u' right after a backslash. // Called when currentChar points to 'u' right after a backslash.
if (currentChar != 'u') throw ScriptError(currentPos, "expected unicode escape marker: u") if (currentChar != 'u') throw ScriptError(currentPos, "expected unicode escape marker: u")
@@ -27,7 +27,9 @@ import net.sergeych.lyng.bytecode.CmdVm
import net.sergeych.lyng.miniast.* import net.sergeych.lyng.miniast.*
import net.sergeych.lyng.obj.* import net.sergeych.lyng.obj.*
import net.sergeych.lyng.pacman.ImportManager import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.stdlib_included.decimalLyng
import net.sergeych.lyng.stdlib_included.observableLyng import net.sergeych.lyng.stdlib_included.observableLyng
import net.sergeych.lyng.stdlib_included.operatorsLyng
import net.sergeych.lyng.stdlib_included.rootLyng import net.sergeych.lyng.stdlib_included.rootLyng
import net.sergeych.lynon.ObjLynonClass import net.sergeych.lynon.ObjLynonClass
import net.sergeych.mp_tools.globalDefer import net.sergeych.mp_tools.globalDefer
@@ -46,6 +48,28 @@ class Script(
// private val catchReturn: Boolean = false, // private val catchReturn: Boolean = false,
) : Statement() { ) : Statement() {
fun statements(): List<Statement> = statements fun statements(): List<Statement> = statements
/**
* Explicitly apply this script's import/module bindings to [scope] without executing the script.
* This is intended for embedding scenarios where the host owns scope lifecycle and wants
* script-specific imports to be a separate, opt-in preparation step.
*/
suspend fun importInto(scope: Scope, seedScope: Scope = scope): Scope {
prepareScriptScope(scope, seedScope)
return scope
}
/**
* Create a fresh raw module scope and apply this script's import/module bindings to it.
* If [seedScope] is provided, its import provider is reused and seed-bound imports resolve from it.
*/
suspend fun instantiateModule(seedScope: Scope? = null, pos: Pos = this.pos): ModuleScope {
val provider = seedScope?.currentImportProvider ?: defaultImportManager
val module = provider.newModuleAt(pos)
prepareScriptScope(module, seedScope ?: module)
return module
}
override suspend fun execute(scope: Scope): Obj { override suspend fun execute(scope: Scope): Obj {
scope.pos = pos scope.pos = pos
val execScope = resolveModuleScope(scope) ?: scope val execScope = resolveModuleScope(scope) ?: scope
@@ -75,6 +99,19 @@ class Script(
return ObjVoid return ObjVoid
} }
private suspend fun prepareScriptScope(scope: Scope, seedScope: Scope) {
if (importBindings.isNotEmpty() || importedModules.isNotEmpty()) {
seedImportBindings(scope, seedScope)
}
if (moduleSlotPlan.isNotEmpty()) {
scope.applySlotPlan(moduleSlotPlan)
for (name in moduleSlotPlan.keys) {
val record = scope.objects[name] ?: scope.localBindings[name] ?: continue
scope.updateSlotFor(name, record)
}
}
}
private suspend fun seedModuleSlots(scope: Scope, seedScope: Scope) { private suspend fun seedModuleSlots(scope: Scope, seedScope: Scope) {
if (importBindings.isEmpty() && importedModules.isEmpty()) return if (importBindings.isEmpty() && importedModules.isEmpty()) return
seedImportBindings(scope, seedScope) seedImportBindings(scope, seedScope)
@@ -505,10 +542,6 @@ class Script(
cachedValue cachedValue
} }
} }
addFn("lazy") {
val builder = requireOnlyArg<Obj>()
ObjLazyDelegate(builder, requireScope())
}
addVoidFn("delay") { addVoidFn("delay") {
val a = args.firstAndOnly() val a = args.firstAndOnly()
when (a) { when (a) {
@@ -696,6 +729,36 @@ class Script(
module.addConst("ChangeRejectionException", ObjChangeRejectionExceptionClass) module.addConst("ChangeRejectionException", ObjChangeRejectionExceptionClass)
module.eval(Source("lyng.observable", observableLyng)) module.eval(Source("lyng.observable", observableLyng))
} }
addPackage("lyng.operators") { module ->
module.eval(Source("lyng.operators", operatorsLyng))
module.bindObject("OperatorInterop") {
addFun("register") {
val leftClass = requiredArg<ObjClass>(0)
val rightClass = requiredArg<ObjClass>(1)
val commonClass = requiredArg<ObjClass>(2)
val operators = requiredArg<ObjList>(3).list.map { value ->
val entry = value as? ObjEnumEntry
?: requireScope().raiseIllegalArgument(
"OperatorInterop.register expects BinaryOperator enum entries"
)
entry.name.value
}
OperatorInteropRegistry.register(
leftClass = leftClass,
rightClass = rightClass,
commonClass = commonClass,
operatorNames = operators,
leftToCommon = args[4],
rightToCommon = args[5]
)
ObjVoid
}
}
}
addPackage("lyng.decimal") { module ->
module.eval(Source("lyng.decimal", decimalLyng))
ObjBigDecimalSupport.bindTo(module)
}
addPackage("lyng.buffer") { addPackage("lyng.buffer") {
it.addConstDoc( it.addConstDoc(
name = "Buffer", name = "Buffer",
@@ -24,6 +24,7 @@ class VarDeclStatement(
val name: String, val name: String,
val isMutable: Boolean, val isMutable: Boolean,
val visibility: Visibility, val visibility: Visibility,
val actualExtern: Boolean,
val initializer: Statement?, val initializer: Statement?,
val isTransient: Boolean, val isTransient: Boolean,
val typeDecl: TypeDecl?, val typeDecl: TypeDecl?,
@@ -42,6 +42,7 @@ class BytecodeCompiler(
private val callableReturnTypeByScopeId: Map<Int, Map<Int, ObjClass>> = emptyMap(), private val callableReturnTypeByScopeId: Map<Int, Map<Int, ObjClass>> = emptyMap(),
private val callableReturnTypeByName: Map<String, ObjClass> = emptyMap(), private val callableReturnTypeByName: Map<String, ObjClass> = emptyMap(),
private val externCallableNames: Set<String> = emptySet(), private val externCallableNames: Set<String> = emptySet(),
private val externBindingNames: Set<String> = emptySet(),
private val lambdaCaptureEntriesByRef: Map<ValueFnRef, List<LambdaCaptureEntry>> = emptyMap(), private val lambdaCaptureEntriesByRef: Map<ValueFnRef, List<LambdaCaptureEntry>> = emptyMap(),
) { ) {
private val useScopeSlots: Boolean = allowedScopeNames != null || scopeSlotNameSet != null private val useScopeSlots: Boolean = allowedScopeNames != null || scopeSlotNameSet != null
@@ -1088,7 +1089,6 @@ class BytecodeCompiler(
private fun isDelegateClass(receiverClass: ObjClass): Boolean = private fun isDelegateClass(receiverClass: ObjClass): Boolean =
receiverClass.className == "Delegate" || receiverClass.className == "Delegate" ||
receiverClass.className == "LazyDelegate" ||
receiverClass.implementingNames.contains("Delegate") receiverClass.implementingNames.contains("Delegate")
private fun operatorMemberName(op: BinOp): String? = when (op) { private fun operatorMemberName(op: BinOp): String? = when (op) {
@@ -4508,7 +4508,7 @@ class BytecodeCompiler(
val resolved = slotTypes[slot] ?: SlotType.UNKNOWN val resolved = slotTypes[slot] ?: SlotType.UNKNOWN
return CompiledValue(slot, resolved) return CompiledValue(slot, resolved)
} }
if (useScopeSlots && allowedScopeNames?.contains(name) == true) { if (useScopeSlots && isPreparedScopeName(name)) {
scopeSlotIndexByName[name]?.let { slot -> scopeSlotIndexByName[name]?.let { slot ->
val resolved = slotTypes[slot] ?: SlotType.UNKNOWN val resolved = slotTypes[slot] ?: SlotType.UNKNOWN
return CompiledValue(slot, resolved) return CompiledValue(slot, resolved)
@@ -8073,7 +8073,7 @@ class BytecodeCompiler(
slotInitClassByKey[ScopeSlotKey(scopeId, slotIndex)] = cls slotInitClassByKey[ScopeSlotKey(scopeId, slotIndex)] = cls
} }
} }
if (allowLocalSlots && slotIndex != null && !shouldUseScopeSlotFor(scopeId)) { if (allowLocalSlots && slotIndex != null && (stmt.actualExtern || !shouldUseScopeSlotFor(scopeId, stmt.name, isDelegated = false))) {
val key = ScopeSlotKey(scopeId, slotIndex) val key = ScopeSlotKey(scopeId, slotIndex)
declaredLocalKeys.add(key) declaredLocalKeys.add(key)
if (!localSlotInfoMap.containsKey(key)) { if (!localSlotInfoMap.containsKey(key)) {
@@ -8100,7 +8100,7 @@ class BytecodeCompiler(
val scopeId = stmt.spec.scopeId ?: 0 val scopeId = stmt.spec.scopeId ?: 0
if (slotIndex != null) { if (slotIndex != null) {
val key = ScopeSlotKey(scopeId, slotIndex) val key = ScopeSlotKey(scopeId, slotIndex)
if (allowLocalSlots && !shouldUseScopeSlotFor(scopeId)) { if (allowLocalSlots && !shouldUseScopeSlotFor(scopeId, stmt.spec.name, isDelegated = false)) {
if (!localSlotInfoMap.containsKey(key)) { if (!localSlotInfoMap.containsKey(key)) {
localSlotInfoMap[key] = LocalSlotInfo(stmt.spec.name, isMutable = false, isDelegated = false) localSlotInfoMap[key] = LocalSlotInfo(stmt.spec.name, isMutable = false, isDelegated = false)
} }
@@ -8117,7 +8117,7 @@ class BytecodeCompiler(
is DelegatedVarDeclStatement -> { is DelegatedVarDeclStatement -> {
val slotIndex = stmt.slotIndex val slotIndex = stmt.slotIndex
val scopeId = stmt.scopeId ?: 0 val scopeId = stmt.scopeId ?: 0
if (allowLocalSlots && slotIndex != null && !shouldUseScopeSlotFor(scopeId)) { if (allowLocalSlots && slotIndex != null && !shouldUseScopeSlotFor(scopeId, stmt.name, isDelegated = true)) {
val key = ScopeSlotKey(scopeId, slotIndex) val key = ScopeSlotKey(scopeId, slotIndex)
declaredLocalKeys.add(key) declaredLocalKeys.add(key)
if (!localSlotInfoMap.containsKey(key)) { if (!localSlotInfoMap.containsKey(key)) {
@@ -8283,15 +8283,26 @@ class BytecodeCompiler(
private fun isModuleSlot(scopeId: Int, name: String?): Boolean { private fun isModuleSlot(scopeId: Int, name: String?): Boolean {
if (moduleScopeId != null && scopeId != moduleScopeId) return false if (moduleScopeId != null && scopeId != moduleScopeId) return false
val scopeNames = allowedScopeNames ?: scopeSlotNameSet return isPreparedScopeName(name)
if (scopeNames == null || name == null) return false
return scopeNames.contains(name)
} }
private fun shouldUseScopeSlotFor(scopeId: Int): Boolean { private fun shouldUseScopeSlotFor(scopeId: Int): Boolean {
return useScopeSlots && moduleScopeId != null && scopeId == moduleScopeId return useScopeSlots && moduleScopeId != null && scopeId == moduleScopeId
} }
private fun shouldUseScopeSlotFor(scopeId: Int, name: String, isDelegated: Boolean): Boolean {
if (moduleScopeId == null || scopeId != moduleScopeId) return false
if (isDelegated) return false
if (externBindingNames.contains(name)) return true
return useScopeSlots && isPreparedScopeName(name)
}
private fun isPreparedScopeName(name: String?): Boolean {
if (name == null) return false
if (scopeSlotNameSet?.contains(name) == true) return true
return allowedScopeNames?.contains(name) == true
}
private fun collectLoopVarNames(stmt: Statement) { private fun collectLoopVarNames(stmt: Statement) {
if (stmt is BytecodeStatement) { if (stmt is BytecodeStatement) {
collectLoopVarNames(stmt.original) collectLoopVarNames(stmt.original)
@@ -8400,8 +8411,9 @@ class BytecodeCompiler(
captureSlotKeys.add(key) captureSlotKeys.add(key)
return return
} }
val forceScopeSlot = shouldUseScopeSlotFor(scopeId, ref.name, ref.isDelegated)
val isModuleSlot = if (ref.isDelegated) false else isModuleSlot(scopeId, ref.name) val isModuleSlot = if (ref.isDelegated) false else isModuleSlot(scopeId, ref.name)
if (allowLocalSlots && !isModuleSlot) { if (allowLocalSlots && !isModuleSlot && !forceScopeSlot) {
if (!localSlotInfoMap.containsKey(key)) { if (!localSlotInfoMap.containsKey(key)) {
localSlotInfoMap[key] = LocalSlotInfo(ref.name, ref.isMutable, ref.isDelegated) localSlotInfoMap[key] = LocalSlotInfo(ref.name, ref.isMutable, ref.isDelegated)
} }
@@ -8448,8 +8460,9 @@ class BytecodeCompiler(
} }
captureSlotKeys.add(key) captureSlotKeys.add(key)
} else { } else {
val forceScopeSlot = shouldUseScopeSlotFor(scopeId, target.name, target.isDelegated)
val isModuleSlot = if (target.isDelegated) false else isModuleSlot(scopeId, target.name) val isModuleSlot = if (target.isDelegated) false else isModuleSlot(scopeId, target.name)
if (allowLocalSlots && !isModuleSlot) { if (allowLocalSlots && !isModuleSlot && !forceScopeSlot) {
if (!localSlotInfoMap.containsKey(key)) { if (!localSlotInfoMap.containsKey(key)) {
localSlotInfoMap[key] = LocalSlotInfo(target.name, target.isMutable, target.isDelegated) localSlotInfoMap[key] = LocalSlotInfo(target.name, target.isMutable, target.isDelegated)
} }
@@ -87,6 +87,7 @@ class BytecodeStatement private constructor(
callableReturnTypeByScopeId: Map<Int, Map<Int, ObjClass>> = emptyMap(), callableReturnTypeByScopeId: Map<Int, Map<Int, ObjClass>> = emptyMap(),
callableReturnTypeByName: Map<String, ObjClass> = emptyMap(), callableReturnTypeByName: Map<String, ObjClass> = emptyMap(),
externCallableNames: Set<String> = emptySet(), externCallableNames: Set<String> = emptySet(),
externBindingNames: Set<String> = emptySet(),
lambdaCaptureEntriesByRef: Map<ValueFnRef, List<LambdaCaptureEntry>> = emptyMap(), lambdaCaptureEntriesByRef: Map<ValueFnRef, List<LambdaCaptureEntry>> = emptyMap(),
slotTypeDeclByScopeId: Map<Int, Map<Int, TypeDecl>> = emptyMap(), slotTypeDeclByScopeId: Map<Int, Map<Int, TypeDecl>> = emptyMap(),
): Statement { ): Statement {
@@ -122,6 +123,7 @@ class BytecodeStatement private constructor(
callableReturnTypeByScopeId = callableReturnTypeByScopeId, callableReturnTypeByScopeId = callableReturnTypeByScopeId,
callableReturnTypeByName = callableReturnTypeByName, callableReturnTypeByName = callableReturnTypeByName,
externCallableNames = externCallableNames, externCallableNames = externCallableNames,
externBindingNames = externBindingNames,
lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef lambdaCaptureEntriesByRef = lambdaCaptureEntriesByRef
) )
val compiled = compiler.compileStatement(nameHint, statement) val compiled = compiler.compileStatement(nameHint, statement)
@@ -236,6 +238,7 @@ class BytecodeStatement private constructor(
stmt.name, stmt.name,
stmt.isMutable, stmt.isMutable,
stmt.visibility, stmt.visibility,
stmt.actualExtern,
stmt.initializer?.let { unwrapDeep(it) }, stmt.initializer?.let { unwrapDeep(it) },
stmt.isTransient, stmt.isTransient,
stmt.typeDecl, stmt.typeDecl,
@@ -2392,8 +2392,13 @@ class CmdDeclLocal(internal val constId: Int, internal val slot: Int) : Cmd() {
?: error("DECL_LOCAL expects LocalDecl at $constId") ?: error("DECL_LOCAL expects LocalDecl at $constId")
if (slot < frame.fn.scopeSlotCount) { if (slot < frame.fn.scopeSlotCount) {
val target = frame.scopeTarget(slot) val target = frame.scopeTarget(slot)
frame.ensureScopeSlot(target, slot) val index = frame.ensureScopeSlot(target, slot)
val value = frame.slotToObj(slot).byValueCopy() val raw = target.getSlotRecord(index).value
val value = when (raw) {
is FrameSlotRef -> raw.read()
is RecordSlotRef -> raw.read()
else -> raw
}.byValueCopy()
target.updateSlotFor( target.updateSlotFor(
decl.name, decl.name,
ObjRecord( ObjRecord(
@@ -3930,7 +3935,7 @@ class CmdFrame(
} else { } else {
val raw = frame.getRawObj(localIndex) val raw = frame.getRawObj(localIndex)
if (raw == null && name != null) { if (raw == null && name != null) {
val record = scope.get(name) val record = findNamedExistingRecord(scope, name)
if (record != null) { if (record != null) {
val value = record.value val value = record.value
return@mapIndexed when (value) { return@mapIndexed when (value) {
@@ -3939,6 +3944,12 @@ class CmdFrame(
else -> ObjRecord(value, isMutable) else -> ObjRecord(value, isMutable)
} }
} }
if (hasNamedScopeBinding(scope, name)) {
throw ScriptError(
ensureScope().pos,
"captured binding '$name' is not available in the execution scope; prepare the script imports/module bindings explicitly"
)
}
} }
when (raw) { when (raw) {
is FrameSlotRef -> ObjRecord(raw, isMutable) is FrameSlotRef -> ObjRecord(raw, isMutable)
@@ -3952,22 +3963,27 @@ class CmdFrame(
val target = moduleScope val target = moduleScope
val name = captureNames?.getOrNull(index) val name = captureNames?.getOrNull(index)
if (name != null) { if (name != null) {
target.tryGetLocalRecord(target, name, target.currentClassCtx)?.let { return@mapIndexed it } findNamedExistingRecord(target, name)?.let { return@mapIndexed it }
target.getSlotIndexOf(name)?.let { return@mapIndexed target.getSlotRecord(it) }
target.get(name)?.let { return@mapIndexed it }
// Fallback to current scope in case the module scope isn't in the parent chain // Fallback to current scope in case the module scope isn't in the parent chain
// or doesn't carry the imported symbol yet. // or doesn't carry the imported symbol yet.
scope.tryGetLocalRecord(scope, name, scope.currentClassCtx)?.let { return@mapIndexed it } findNamedExistingRecord(scope, name)?.let { return@mapIndexed it }
scope.get(name)?.let { return@mapIndexed it }
} }
if (slotId < target.slotCount) { if (slotId < target.slotCount) {
return@mapIndexed target.getSlotRecord(slotId) val existing = target.getSlotRecord(slotId)
if (name == null || existing.value !== ObjUnset || hasResolvedNamedScopeBinding(target, name)) {
return@mapIndexed existing
}
} }
if (name != null) { if (name != null) {
target.applySlotPlan(mapOf(name to slotId)) throw ScriptError(
return@mapIndexed target.getSlotRecord(slotId) ensureScope().pos,
"module capture '$name' is not available in the execution scope; prepare the script imports/module bindings explicitly"
)
} }
error("Missing module capture slot $slotId") throw ScriptError(
ensureScope().pos,
"missing module capture slot $slotId"
)
} }
} }
} }
@@ -4546,7 +4562,7 @@ class CmdFrame(
return getScopeSlotValueAtAddr(addrSlot) return getScopeSlotValueAtAddr(addrSlot)
} }
fun setAddrObj(addrSlot: Int, value: Obj) { suspend fun setAddrObj(addrSlot: Int, value: Obj) {
setScopeSlotValueAtAddr(addrSlot, value) setScopeSlotValueAtAddr(addrSlot, value)
} }
@@ -4554,7 +4570,7 @@ class CmdFrame(
return getScopeSlotValueAtAddr(addrSlot).toLong() return getScopeSlotValueAtAddr(addrSlot).toLong()
} }
fun setAddrInt(addrSlot: Int, value: Long) { suspend fun setAddrInt(addrSlot: Int, value: Long) {
setScopeSlotValueAtAddr(addrSlot, ObjInt.of(value)) setScopeSlotValueAtAddr(addrSlot, ObjInt.of(value))
} }
@@ -4562,7 +4578,7 @@ class CmdFrame(
return getScopeSlotValueAtAddr(addrSlot).toDouble() return getScopeSlotValueAtAddr(addrSlot).toDouble()
} }
fun setAddrReal(addrSlot: Int, value: Double) { suspend fun setAddrReal(addrSlot: Int, value: Double) {
setScopeSlotValueAtAddr(addrSlot, ObjReal.of(value)) setScopeSlotValueAtAddr(addrSlot, ObjReal.of(value))
} }
@@ -4570,7 +4586,7 @@ class CmdFrame(
return getScopeSlotValueAtAddr(addrSlot).toBool() return getScopeSlotValueAtAddr(addrSlot).toBool()
} }
fun setAddrBool(addrSlot: Int, value: Boolean) { suspend fun setAddrBool(addrSlot: Int, value: Boolean) {
setScopeSlotValueAtAddr(addrSlot, if (value) ObjTrue else ObjFalse) setScopeSlotValueAtAddr(addrSlot, if (value) ObjTrue else ObjFalse)
} }
@@ -4776,12 +4792,13 @@ class CmdFrame(
private suspend fun getScopeSlotValue(slot: Int): Obj { private suspend fun getScopeSlotValue(slot: Int): Obj {
val target = scopeTarget(slot) val target = scopeTarget(slot)
val name = fn.scopeSlotNames[slot]
val hadNamedBinding = name != null && hasResolvedNamedScopeBinding(target, name)
val index = ensureScopeSlot(target, slot) val index = ensureScopeSlot(target, slot)
val record = target.getSlotRecord(index) val record = target.getSlotRecord(index)
val direct = record.value val direct = record.value
if (direct is FrameSlotRef) return direct.read() if (direct is FrameSlotRef) return direct.read()
if (direct is RecordSlotRef) return direct.read() if (direct is RecordSlotRef) return direct.read()
val name = fn.scopeSlotNames[slot]
if (name != null && record.memberName != null && record.memberName != name) { if (name != null && record.memberName != null && record.memberName != name) {
val resolved = target.get(name) val resolved = target.get(name)
if (resolved != null) { if (resolved != null) {
@@ -4802,9 +4819,15 @@ class CmdFrame(
return direct return direct
} }
if (name == null) return record.value if (name == null) return record.value
val resolved = target.get(name) ?: return record.value val resolved = target.get(name)
if (resolved == null) {
failMissingPreparedModuleBinding(slot, name, hadNamedBinding, record)
return record.value
}
if (resolved.value !== ObjUnset) { if (resolved.value !== ObjUnset) {
target.updateSlotFor(name, resolved) target.updateSlotFor(name, resolved)
} else {
failMissingPreparedModuleBinding(slot, name, hadNamedBinding, resolved)
} }
return resolved.value return resolved.value
} }
@@ -4812,12 +4835,13 @@ class CmdFrame(
private suspend fun getScopeSlotValueAtAddr(addrSlot: Int): Obj { private suspend fun getScopeSlotValueAtAddr(addrSlot: Int): Obj {
val target = addrScopes[addrSlot] ?: error("Address slot $addrSlot is not resolved") val target = addrScopes[addrSlot] ?: error("Address slot $addrSlot is not resolved")
val index = addrIndices[addrSlot] val index = addrIndices[addrSlot]
val slotId = addrScopeSlots[addrSlot]
val name = fn.scopeSlotNames.getOrNull(slotId)
val hadNamedBinding = name != null && hasResolvedNamedScopeBinding(target, name)
val record = target.getSlotRecord(index) val record = target.getSlotRecord(index)
val direct = record.value val direct = record.value
if (direct is FrameSlotRef) return direct.read() if (direct is FrameSlotRef) return direct.read()
if (direct is RecordSlotRef) return direct.read() if (direct is RecordSlotRef) return direct.read()
val slotId = addrScopeSlots[addrSlot]
val name = fn.scopeSlotNames.getOrNull(slotId)
if (name != null && record.memberName != null && record.memberName != name) { if (name != null && record.memberName != null && record.memberName != name) {
val resolved = target.get(name) val resolved = target.get(name)
if (resolved != null) { if (resolved != null) {
@@ -4838,16 +4862,29 @@ class CmdFrame(
return direct return direct
} }
if (name == null) return record.value if (name == null) return record.value
val resolved = target.get(name) ?: return record.value val resolved = target.get(name)
if (resolved == null) {
failMissingPreparedModuleBinding(slotId, name, hadNamedBinding, record)
return record.value
}
if (resolved.value !== ObjUnset) { if (resolved.value !== ObjUnset) {
target.updateSlotFor(name, resolved) target.updateSlotFor(name, resolved)
} else {
failMissingPreparedModuleBinding(slotId, name, hadNamedBinding, resolved)
} }
return resolved.value return resolved.value
} }
private fun setScopeSlotValueAtAddr(addrSlot: Int, value: Obj) { private suspend fun setScopeSlotValueAtAddr(addrSlot: Int, value: Obj) {
val target = addrScopes[addrSlot] ?: error("Address slot $addrSlot is not resolved") val target = addrScopes[addrSlot] ?: error("Address slot $addrSlot is not resolved")
val index = addrIndices[addrSlot] val index = addrIndices[addrSlot]
val record = target.getSlotRecord(index)
val slotId = addrScopeSlots[addrSlot]
val name = fn.scopeSlotNames.getOrNull(slotId)
if (name != null && (record.type == ObjRecord.Type.Delegated || record.type == ObjRecord.Type.Property || record.value is ObjProperty)) {
target.assign(record, name, value)
return
}
target.setSlotValue(index, value) target.setSlotValue(index, value)
} }
@@ -4875,6 +4912,41 @@ class CmdFrame(
return index return index
} }
private fun hasNamedScopeBinding(target: Scope, name: String): Boolean {
if (target.tryGetLocalRecord(target, name, target.currentClassCtx) != null) return true
if (target.getSlotIndexOf(name) != null) return true
if (target.get(name) != null) return true
return false
}
private fun hasResolvedNamedScopeBinding(target: Scope, name: String): Boolean =
findNamedExistingRecord(target, name) != null
private fun findNamedExistingRecord(target: Scope, name: String): ObjRecord? {
target.tryGetLocalRecord(target, name, target.currentClassCtx)?.let { return it }
target.get(name)?.let { record ->
if (record.value !== ObjUnset || record.type == ObjRecord.Type.Delegated || record.type == ObjRecord.Type.Property) {
return record
}
}
return null
}
private fun failMissingPreparedModuleBinding(
slot: Int,
name: String,
hadNamedBinding: Boolean,
record: ObjRecord
) {
if (hadNamedBinding) return
if (record.value !== ObjUnset) return
if (fn.scopeSlotIsModule.getOrNull(slot) != true) return
throw ScriptError(
ensureScope().pos,
"module binding '$name' is not available in the execution scope; prepare the script imports/module bindings explicitly"
)
}
private fun ensureLocalMutable(localIndex: Int) { private fun ensureLocalMutable(localIndex: Int) {
val name = fn.localSlotNames.getOrNull(localIndex) ?: return val name = fn.localSlotNames.getOrNull(localIndex) ?: return
val isMutable = fn.localSlotMutables.getOrNull(localIndex) ?: true val isMutable = fn.localSlotMutables.getOrNull(localIndex) ?: true
@@ -36,7 +36,7 @@ internal suspend fun seedFrameLocalsFromScope(frame: CmdFrame, scope: Scope) {
} else { } else {
record.value record.value
} }
if (value is net.sergeych.lyng.FrameSlotRef && value.refersTo(frame.frame, base + i)) { if (value is net.sergeych.lyng.FrameSlotRef && value.refersTo(frame.frame, i)) {
continue continue
} }
frame.setObjUnchecked(base + i, value) frame.setObjUnchecked(base + i, value)
@@ -25,6 +25,8 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import net.sergeych.lyng.* import net.sergeych.lyng.*
import net.sergeych.lyng.InteropOperator
import net.sergeych.lyng.OperatorInteropRegistry
import net.sergeych.lyng.miniast.ParamDoc import net.sergeych.lyng.miniast.ParamDoc
import net.sergeych.lyng.miniast.addFnDoc import net.sergeych.lyng.miniast.addFnDoc
import net.sergeych.lyng.miniast.type import net.sergeych.lyng.miniast.type
@@ -175,7 +177,8 @@ open class Obj {
if (other === this) return 0 if (other === this) return 0
if (other === ObjNull || other === ObjUnset || other === ObjVoid) return 2 if (other === ObjNull || other === ObjUnset || other === ObjVoid) return 2
return invokeInstanceMethod(scope, "compareTo", Arguments(other)) { return invokeInstanceMethod(scope, "compareTo", Arguments(other)) {
scope.raiseNotImplemented("compareTo for ${objClass.className}") OperatorInteropRegistry.invokeCompare(scope, this, other)?.let { ObjInt.of(it.toLong()) }
?: scope.raiseNotImplemented("compareTo for ${objClass.className}")
}.cast<ObjInt>(scope).toInt() }.cast<ObjInt>(scope).toInt()
} }
@@ -284,7 +287,8 @@ open class Obj {
} }
if (self !== this) return self.plus(scope, otherValue) if (self !== this) return self.plus(scope, otherValue)
return invokeInstanceMethod(scope, "plus", Arguments(otherValue)) { return invokeInstanceMethod(scope, "plus", Arguments(otherValue)) {
scope.raiseNotImplemented("plus for ${objClass.className}") OperatorInteropRegistry.invokeBinary(scope, self, otherValue, InteropOperator.Plus)
?: scope.raiseNotImplemented("plus for ${objClass.className}")
} }
} }
@@ -301,7 +305,8 @@ open class Obj {
} }
if (self !== this) return self.minus(scope, otherValue) if (self !== this) return self.minus(scope, otherValue)
return invokeInstanceMethod(scope, "minus", Arguments(otherValue)) { return invokeInstanceMethod(scope, "minus", Arguments(otherValue)) {
scope.raiseNotImplemented("minus for ${objClass.className}") OperatorInteropRegistry.invokeBinary(scope, self, otherValue, InteropOperator.Minus)
?: scope.raiseNotImplemented("minus for ${objClass.className}")
} }
} }
@@ -324,7 +329,8 @@ open class Obj {
} }
if (self !== this) return self.mul(scope, otherValue) if (self !== this) return self.mul(scope, otherValue)
return invokeInstanceMethod(scope, "mul", Arguments(otherValue)) { return invokeInstanceMethod(scope, "mul", Arguments(otherValue)) {
scope.raiseNotImplemented("mul for ${objClass.className}") OperatorInteropRegistry.invokeBinary(scope, self, otherValue, InteropOperator.Mul)
?: scope.raiseNotImplemented("mul for ${objClass.className}")
} }
} }
@@ -341,7 +347,8 @@ open class Obj {
} }
if (self !== this) return self.div(scope, otherValue) if (self !== this) return self.div(scope, otherValue)
return invokeInstanceMethod(scope, "div", Arguments(otherValue)) { return invokeInstanceMethod(scope, "div", Arguments(otherValue)) {
scope.raiseNotImplemented("div for ${objClass.className}") OperatorInteropRegistry.invokeBinary(scope, self, otherValue, InteropOperator.Div)
?: scope.raiseNotImplemented("div for ${objClass.className}")
} }
} }
@@ -358,7 +365,8 @@ open class Obj {
} }
if (self !== this) return self.mod(scope, otherValue) if (self !== this) return self.mod(scope, otherValue)
return invokeInstanceMethod(scope, "mod", Arguments(otherValue)) { return invokeInstanceMethod(scope, "mod", Arguments(otherValue)) {
scope.raiseNotImplemented("mod for ${objClass.className}") OperatorInteropRegistry.invokeBinary(scope, self, otherValue, InteropOperator.Mod)
?: scope.raiseNotImplemented("mod for ${objClass.className}")
} }
} }
@@ -0,0 +1,313 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.obj
import com.ionspin.kotlin.bignum.decimal.BigDecimal as IonBigDecimal
import com.ionspin.kotlin.bignum.decimal.DecimalMode
import com.ionspin.kotlin.bignum.decimal.RoundingMode
import com.ionspin.kotlin.bignum.integer.BigInteger
import net.sergeych.lyng.Arguments
import net.sergeych.lyng.FrameSlotRef
import net.sergeych.lyng.InteropOperator
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.OperatorInteropRegistry
import net.sergeych.lyng.RecordSlotRef
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.TypeDecl
import net.sergeych.lyng.miniast.addPropertyDoc
import net.sergeych.lyng.miniast.type
import net.sergeych.lyng.requiredArg
object ObjBigDecimalSupport {
private const val decimalContextVar = "__lyng_decimal_context__"
// For Real -> BigDecimal, preserve the actual IEEE-754 Double value using a
// round-trip-safe precision. This intentionally does not try to recover source text.
private val realConversionMode = DecimalMode(17L, RoundingMode.ROUND_HALF_TO_EVEN)
// Division needs an explicit stopping rule for non-terminating results. Use a
// decimal128-like default context until Lyng exposes per-operation contexts.
private val defaultDivisionMode = DecimalMode(34L, RoundingMode.ROUND_HALF_TO_EVEN)
private val zero: IonBigDecimal = IonBigDecimal.ZERO
private val decimalTypeDecl = TypeDecl.Simple("lyng.decimal.BigDecimal", false)
private object BoundMarker
private data class DecimalRuntimeContext(
val precision: Long,
val rounding: RoundingMode
) : Obj()
suspend fun bindTo(module: ModuleScope) {
val decimalClass = module.requireClass("BigDecimal")
if (decimalClass.kotlinClassData === BoundMarker) return
decimalClass.kotlinClassData = BoundMarker
decimalClass.isAbstract = false
val hooks = decimalClass.bridgeInitHooks ?: mutableListOf<suspend (ScopeFacade, ObjInstance) -> Unit>().also {
decimalClass.bridgeInitHooks = it
}
hooks += { _, instance ->
instance.kotlinInstanceData = zero
}
decimalClass.addFn("plus") {
newInstance(decimalClass, valueOf(thisObj).plus(coerceArg(requireScope(), args.firstAndOnly())))
}
decimalClass.addFn("minus") {
newInstance(decimalClass, valueOf(thisObj).minus(coerceArg(requireScope(), args.firstAndOnly())))
}
decimalClass.addFn("mul") {
newInstance(decimalClass, valueOf(thisObj).times(coerceArg(requireScope(), args.firstAndOnly())))
}
decimalClass.addFn("div") {
newInstance(decimalClass, divideWithContext(valueOf(thisObj), coerceArg(requireScope(), args.firstAndOnly()), currentDivisionMode(requireScope())))
}
decimalClass.addFn("mod") {
newInstance(decimalClass, valueOf(thisObj).rem(coerceArg(requireScope(), args.firstAndOnly())))
}
decimalClass.addFn("compareTo") {
ObjInt.of(valueOf(thisObj).compareTo(coerceArg(requireScope(), args.firstAndOnly())).toLong())
}
decimalClass.addFn("negate") {
newInstance(decimalClass, valueOf(thisObj).unaryMinus())
}
decimalClass.addFn("toInt") {
ObjInt.of(valueOf(thisObj).longValue(false))
}
decimalClass.addFn("toReal") {
ObjReal.of(valueOf(thisObj).doubleValue(false))
}
decimalClass.addFn("toStringExpanded") {
ObjString(valueOf(thisObj).toStringExpanded())
}
decimalClass.addClassFn("fromInt") {
val value = requiredArg<ObjInt>(0).value
newInstance(decimalClass, IonBigDecimal.fromLongAsSignificand(value))
}
decimalClass.addClassFn("fromReal") {
val value = requiredArg<ObjReal>(0).value
newInstance(decimalClass, IonBigDecimal.fromDouble(value, realConversionMode))
}
decimalClass.addClassFn("fromString") {
val value = requiredArg<ObjString>(0).value
try {
newInstance(decimalClass, IonBigDecimal.parseStringWithMode(value))
} catch (e: Throwable) {
requireScope().raiseIllegalArgument("invalid BigDecimal string: $value")
}
}
module.addFn("withDecimalContext") {
val (context, block) = when (args.list.size) {
2 -> {
val first = args[0]
val block = args[1]
if (first is ObjInt) {
DecimalRuntimeContext(first.value, RoundingMode.ROUND_HALF_TO_EVEN) to block
} else {
normalizeContext(requireScope(), first) to block
}
}
3 -> {
val precision = requiredArg<ObjInt>(0).value
val rounding = roundingModeFromObj(requireScope(), args[1])
DecimalRuntimeContext(precision, rounding) to args[2]
}
else -> requireScope().raiseIllegalArgument("withDecimalContext expects (context, block), (precision, block), or (precision, rounding, block)")
}
val child = requireScope().createChildScope()
child.addConst(decimalContextVar, context)
block.callOn(child)
}
registerBuiltinConversions(decimalClass)
registerInterop(decimalClass)
}
private fun valueOf(obj: Obj): IonBigDecimal {
val instance = obj as? ObjInstance ?: error("BigDecimal receiver must be an object instance")
return instance.kotlinInstanceData as? IonBigDecimal ?: zero
}
private suspend fun currentDivisionMode(scope: Scope): DecimalMode {
val context = findContextObject(scope) ?: return defaultDivisionMode
return DecimalMode(context.precision, context.rounding)
}
private fun divideWithContext(left: IonBigDecimal, right: IonBigDecimal, mode: DecimalMode): IonBigDecimal {
if (mode.decimalPrecision <= 0L) {
return stripMode(left.divide(right, mode))
}
val exactLeft = stripMode(left)
val exactRight = stripMode(right)
val guardMode = DecimalMode(mode.decimalPrecision + 2, RoundingMode.TOWARDS_ZERO)
var guarded = stripMode(exactLeft.divide(exactRight, guardMode))
val hasMoreTail = !stripMode(exactLeft - stripMode(guarded * exactRight)).isZero()
if (hasMoreTail && isHalfRounding(mode.roundingMode) && looksLikeExactHalf(guarded, mode.decimalPrecision)) {
guarded = nudgeLastDigitAwayFromZero(guarded)
}
return stripMode(guarded.roundSignificand(mode))
}
private suspend fun ScopeFacade.newInstance(decimalClass: ObjClass, value: IonBigDecimal): ObjInstance {
val instance = call(decimalClass) as? ObjInstance
?: raiseIllegalState("BigDecimal() did not return an object instance")
instance.kotlinInstanceData = value
return instance
}
private fun coerceArg(scope: Scope, value: Obj): IonBigDecimal = when (value) {
is ObjInt -> IonBigDecimal.fromLongAsSignificand(value.value)
is ObjReal -> IonBigDecimal.fromDouble(value.value, realConversionMode)
is ObjInstance -> {
if (value.objClass.className != "BigDecimal") {
scope.raiseIllegalArgument("expected BigDecimal-compatible value, got ${value.objClass.className}")
}
value.kotlinInstanceData as? IonBigDecimal ?: zero
}
else -> scope.raiseIllegalArgument("expected BigDecimal-compatible value, got ${value.objClass.className}")
}
private suspend fun normalizeContext(scope: Scope, value: Obj): DecimalRuntimeContext {
val instance = value as? ObjInstance
?: scope.raiseClassCastError("withDecimalContext expects DecimalContext as the first argument")
if (instance.objClass.className != "DecimalContext") {
scope.raiseClassCastError("withDecimalContext expects DecimalContext as the first argument")
}
return decimalRuntimeContextFromInstance(scope, instance)
}
private fun findContextObject(scope: Scope): DecimalRuntimeContext? {
var current: Scope? = scope
while (current != null) {
val record = current.objects[decimalContextVar] ?: current.localBindings[decimalContextVar]
val value = when (val raw = record?.value) {
is FrameSlotRef -> raw.peekValue()
is RecordSlotRef -> raw.peekValue()
else -> raw
}
when (value) {
is DecimalRuntimeContext -> return value
}
current = current.parent
}
return null
}
private suspend fun decimalRuntimeContextFromInstance(scope: Scope, context: ObjInstance): DecimalRuntimeContext {
val precision = context.readField(scope, "precision").value as? ObjInt
?: scope.raiseClassCastError("DecimalContext.precision must be Int")
if (precision.value <= 0L) {
scope.raiseIllegalArgument("DecimalContext precision must be positive")
}
val rounding = roundingModeFromObj(scope, context.readField(scope, "rounding").value)
return DecimalRuntimeContext(precision.value, rounding)
}
private fun stripMode(value: IonBigDecimal): IonBigDecimal =
IonBigDecimal.fromBigIntegerWithExponent(value.significand, value.exponent)
private fun isHalfRounding(mode: RoundingMode): Boolean = when (mode) {
RoundingMode.ROUND_HALF_TO_EVEN,
RoundingMode.ROUND_HALF_AWAY_FROM_ZERO,
RoundingMode.ROUND_HALF_TOWARDS_ZERO,
RoundingMode.ROUND_HALF_CEILING,
RoundingMode.ROUND_HALF_FLOOR,
RoundingMode.ROUND_HALF_TO_ODD -> true
else -> false
}
private fun looksLikeExactHalf(value: IonBigDecimal, targetPrecision: Long): Boolean {
val digits = value.significand.abs().toString(10)
if (digits.length <= targetPrecision) return false
val discarded = digits.substring(targetPrecision.toInt())
return discarded[0] == '5' && discarded.drop(1).all { it == '0' }
}
private fun nudgeLastDigitAwayFromZero(value: IonBigDecimal): IonBigDecimal {
val ulpExponent = value.exponent - value.precision + 1
val ulp = IonBigDecimal.fromBigIntegerWithExponent(BigInteger.ONE, ulpExponent)
return if (value.significand.signum() < 0) value - ulp else value + ulp
}
private fun roundingModeFromObj(scope: Scope, value: Obj): RoundingMode {
val entry = value as? ObjEnumEntry ?: scope.raiseClassCastError("DecimalContext.rounding must be DecimalRounding")
return when (entry.name.value) {
"HalfEven" -> RoundingMode.ROUND_HALF_TO_EVEN
"HalfAwayFromZero" -> RoundingMode.ROUND_HALF_AWAY_FROM_ZERO
"HalfTowardsZero" -> RoundingMode.ROUND_HALF_TOWARDS_ZERO
"Ceiling" -> RoundingMode.CEILING
"Floor" -> RoundingMode.FLOOR
"AwayFromZero" -> RoundingMode.AWAY_FROM_ZERO
"TowardsZero" -> RoundingMode.TOWARDS_ZERO
else -> scope.raiseIllegalArgument("unsupported DecimalRounding: ${entry.name.value}")
}
}
private fun registerBuiltinConversions(decimalClass: ObjClass) {
ObjInt.type.addPropertyDoc(
name = "d",
doc = "Convert this integer to a BigDecimal.",
type = type("lyng.decimal.BigDecimal"),
moduleName = "lyng.decimal",
getter = { newInstance(decimalClass, IonBigDecimal.fromLongAsSignificand(thisAs<ObjInt>().value)) }
)
ObjInt.type.members["d"] = ObjInt.type.members.getValue("d").copy(typeDecl = decimalTypeDecl)
ObjReal.type.addPropertyDoc(
name = "d",
doc = "Convert this real number to a BigDecimal by preserving the current IEEE-754 value with 17 significant digits and half-even rounding.",
type = type("lyng.decimal.BigDecimal"),
moduleName = "lyng.decimal",
getter = { newInstance(decimalClass, IonBigDecimal.fromDouble(thisAs<ObjReal>().value, realConversionMode)) }
)
ObjReal.type.members["d"] = ObjReal.type.members.getValue("d").copy(typeDecl = decimalTypeDecl)
ObjString.type.addPropertyDoc(
name = "d",
doc = "Parse this string as a BigDecimal.",
type = type("lyng.decimal.BigDecimal"),
moduleName = "lyng.decimal",
getter = {
val value = thisAs<ObjString>().value
try {
newInstance(decimalClass, IonBigDecimal.parseStringWithMode(value))
} catch (e: Throwable) {
requireScope().raiseIllegalArgument("invalid BigDecimal string: $value")
}
}
)
ObjString.type.members["d"] = ObjString.type.members.getValue("d").copy(typeDecl = decimalTypeDecl)
}
private fun registerInterop(decimalClass: ObjClass) {
OperatorInteropRegistry.register(
leftClass = ObjInt.type,
rightClass = decimalClass,
commonClass = decimalClass,
operatorNames = listOf(
InteropOperator.Plus.name,
InteropOperator.Minus.name,
InteropOperator.Mul.name,
InteropOperator.Div.name,
InteropOperator.Mod.name,
InteropOperator.Compare.name,
InteropOperator.Equals.name
),
leftToCommon = ObjExternCallable.fromBridge {
val value = requiredArg<ObjInt>(0).value
newInstance(decimalClass, IonBigDecimal.fromLongAsSignificand(value))
},
rightToCommon = ObjExternCallable.fromBridge {
requiredArg<Obj>(0)
}
)
}
}
@@ -106,10 +106,20 @@ open class ObjClass(
vararg parents: ObjClass, vararg parents: ObjClass,
) : Obj() { ) : Obj() {
private fun declaringModulePackageName(scope: Scope?): String? {
var current = scope
while (current != null) {
if (current is ModuleScope) return current.packageName
current = current.parent
}
return null
}
var isAnonymous: Boolean = false var isAnonymous: Boolean = false
var isAbstract: Boolean = false var isAbstract: Boolean = false
var isClosed: Boolean = false var isClosed: Boolean = false
var logicalPackageNameOverride: String? = null
// Stable identity and simple structural version for PICs // Stable identity and simple structural version for PICs
val classId: Long = ClassIdGen.nextId() val classId: Long = ClassIdGen.nextId()
@@ -169,6 +179,16 @@ open class ObjClass(
*/ */
var classScope: Scope? = null var classScope: Scope? = null
/**
* Stable logical identity for class matching across separately instantiated modules.
* Uses declaring module package plus class name when available.
*/
val logicalPackageName: String?
get() = logicalPackageNameOverride ?: declaringModulePackageName(classScope)
val logicalName: String
get() = logicalPackageName?.let { "$it::$className" } ?: className
/** Direct parents in declaration order (kept deterministic). */ /** Direct parents in declaration order (kept deterministic). */
val directParents: List<ObjClass> = parents.toList() val directParents: List<ObjClass> = parents.toList()
@@ -129,6 +129,7 @@ open class ObjDynamic(var readCallback: Obj? = null, var writeCallback: Obj? = n
} }
val type = object : ObjClass("Delegate") {}.apply { val type = object : ObjClass("Delegate") {}.apply {
logicalPackageNameOverride = "lyng.stdlib"
addFn("getValue") { raiseError("Delegate.getValue is not implemented") } addFn("getValue") { raiseError("Delegate.getValue is not implemented") }
addFn("setValue") { raiseError("Delegate.setValue is not implemented") } addFn("setValue") { raiseError("Delegate.setValue is not implemented") }
addFn("invoke") { raiseError("Delegate.invoke is not implemented") } addFn("invoke") { raiseError("Delegate.invoke is not implemented") }
@@ -364,6 +364,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
onNotFoundResult: (suspend () -> Obj?)? onNotFoundResult: (suspend () -> Obj?)?
): Obj { ): Obj {
val caller = scope.currentClassCtx val caller = scope.currentClassCtx
val methodScope = scope.applyClosure(instanceScope)
// Fast path for public members when outside any class context // Fast path for public members when outside any class context
if (caller == null) { if (caller == null) {
@@ -373,7 +374,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
if (rec.type == ObjRecord.Type.Property) { if (rec.type == ObjRecord.Type.Property) {
if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, decl) if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, decl)
} else if (rec.type == ObjRecord.Type.Fun) { } else if (rec.type == ObjRecord.Type.Fun) {
return rec.value.invoke(instanceScope, this, args, decl) return rec.value.invoke(methodScope, this, args, decl)
} }
} }
} }
@@ -384,7 +385,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
if (rec.type == ObjRecord.Type.Property) { if (rec.type == ObjRecord.Type.Property) {
if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, decl) if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, decl)
} else if (rec.type == ObjRecord.Type.Fun) { } else if (rec.type == ObjRecord.Type.Fun) {
return rec.value.invoke(instanceScope, this, args, decl) return rec.value.invoke(methodScope, this, args, decl)
} }
} }
} }
@@ -394,7 +395,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
if (rec.type == ObjRecord.Type.Property) { if (rec.type == ObjRecord.Type.Property) {
if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, decl) if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, decl)
} else if (rec.type == ObjRecord.Type.Fun) { } else if (rec.type == ObjRecord.Type.Fun) {
return rec.value.invoke(instanceScope, this, args, decl) return rec.value.invoke(methodScope, this, args, decl)
} }
} }
} }
@@ -409,7 +410,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
if (rec.type == ObjRecord.Type.Property) { if (rec.type == ObjRecord.Type.Property) {
if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, c) if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, c)
} else if (rec.type == ObjRecord.Type.Fun) { } else if (rec.type == ObjRecord.Type.Fun) {
return rec.value.invoke(instanceScope, this, args, c) return rec.value.invoke(methodScope, this, args, c)
} }
} }
} }
@@ -418,7 +419,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
if (rec.type == ObjRecord.Type.Property) { if (rec.type == ObjRecord.Type.Property) {
if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, c) if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, c)
} else if (rec.type == ObjRecord.Type.Fun) { } else if (rec.type == ObjRecord.Type.Fun) {
return rec.value.invoke(instanceScope, this, args, c) return rec.value.invoke(methodScope, this, args, c)
} }
} }
} }
@@ -427,7 +428,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
if (rec.type == ObjRecord.Type.Property) { if (rec.type == ObjRecord.Type.Property) {
if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, c) if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, c)
} else if (rec.type == ObjRecord.Type.Fun) { } else if (rec.type == ObjRecord.Type.Fun) {
return rec.value.invoke(instanceScope, this, args, c) return rec.value.invoke(methodScope, this, args, c)
} }
} }
} }
@@ -439,7 +440,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name) ?: objClass val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name) ?: objClass
val effectiveCaller = caller ?: if (scope.thisObj === this) objClass else null val effectiveCaller = caller ?: if (scope.thisObj === this) objClass else null
if (canAccessMember(rec.visibility, decl, effectiveCaller, name)) { if (canAccessMember(rec.visibility, decl, effectiveCaller, name)) {
return rec.value.invoke(instanceScope, this, args, decl) return rec.value.invoke(methodScope, this, args, decl)
} }
} }
} }
@@ -474,7 +475,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, decl) if (args.isEmpty()) return (rec.value as ObjProperty).callGetter(scope, this, decl)
} else if (rec.type == ObjRecord.Type.Fun) { } else if (rec.type == ObjRecord.Type.Fun) {
return rec.value.invoke( return rec.value.invoke(
instanceScope, methodScope,
this, this,
args, args,
decl decl
@@ -586,8 +587,10 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
} }
override suspend fun compareTo(scope: Scope, other: Obj): Int { override suspend fun compareTo(scope: Scope, other: Obj): Int {
if (other !is ObjInstance) return -1 if (other !is ObjInstance || other.objClass != objClass) {
if (other.objClass != objClass) return -1 OperatorInteropRegistry.invokeCompare(scope, this, other)?.let { return it }
return -1
}
for (f in comparableVars) { for (f in comparableVars) {
val a = f.value.value val a = f.value.value
val b = other.instanceScope.objects[f.key]?.value ?: scope.raiseError("Internal error: field ${f.key} not found in other instance") val b = other.instanceScope.objects[f.key]?.value ?: scope.raiseError("Internal error: field ${f.key} not found in other instance")
@@ -19,6 +19,8 @@ package net.sergeych.lyng.obj
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import net.sergeych.lyng.InteropOperator
import net.sergeych.lyng.OperatorInteropRegistry
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
import net.sergeych.lyng.miniast.addFnDoc import net.sergeych.lyng.miniast.addFnDoc
import net.sergeych.lynon.LynonDecoder import net.sergeych.lynon.LynonDecoder
@@ -54,10 +56,11 @@ class ObjInt(val value: Long, override val isConst: Boolean = false) : Obj(), Nu
} }
override suspend fun compareTo(scope: Scope, other: Obj): Int { override suspend fun compareTo(scope: Scope, other: Obj): Int {
if (other !is Numeric) return -2
return if (other is ObjInt) { return if (other is ObjInt) {
value.compareTo(other.value) value.compareTo(other.value)
} else { } else {
OperatorInteropRegistry.invokeCompare(scope, this, other)?.let { return it }
if (other !is Numeric) return -2
doubleValue.compareTo(other.doubleValue) doubleValue.compareTo(other.doubleValue)
} }
} }
@@ -70,28 +73,39 @@ class ObjInt(val value: Long, override val isConst: Boolean = false) : Obj(), Nu
if (other is ObjInt) if (other is ObjInt)
of(this.value + other.value) of(this.value + other.value)
else else
OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Plus)
?:
ObjReal.of(this.doubleValue + other.toDouble()) ObjReal.of(this.doubleValue + other.toDouble())
override suspend fun minus(scope: Scope, other: Obj): Obj = override suspend fun minus(scope: Scope, other: Obj): Obj =
if (other is ObjInt) if (other is ObjInt)
of(this.value - other.value) of(this.value - other.value)
else else
OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Minus)
?:
ObjReal.of(this.doubleValue - other.toDouble()) ObjReal.of(this.doubleValue - other.toDouble())
override suspend fun mul(scope: Scope, other: Obj): Obj = override suspend fun mul(scope: Scope, other: Obj): Obj =
if (other is ObjInt) { if (other is ObjInt) {
of(this.value * other.value) of(this.value * other.value)
} else ObjReal.of(this.value * other.toDouble()) } else {
OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Mul)
?: ObjReal.of(this.value * other.toDouble())
}
override suspend fun div(scope: Scope, other: Obj): Obj = override suspend fun div(scope: Scope, other: Obj): Obj =
if (other is ObjInt) if (other is ObjInt)
of(this.value / other.value) of(this.value / other.value)
else ObjReal.of(this.value / other.toDouble()) else
OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Div)
?: ObjReal.of(this.value / other.toDouble())
override suspend fun mod(scope: Scope, other: Obj): Obj = override suspend fun mod(scope: Scope, other: Obj): Obj =
if (other is ObjInt) if (other is ObjInt)
of(this.value % other.value) of(this.value % other.value)
else ObjReal.of(this.value.toDouble() % other.toDouble()) else
OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Mod)
?: ObjReal.of(this.value.toDouble() % other.toDouble())
/** /**
* Numbers are now immutable, so we can't do in-place assignment. * Numbers are now immutable, so we can't do in-place assignment.
@@ -1,86 +0,0 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.obj
import net.sergeych.lyng.Arguments
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Statement
import net.sergeych.lyng.Visibility
import net.sergeych.lyng.executeBytecodeWithSeed
/**
* Lazy delegate used by `val x by lazy { ... }`.
*/
class ObjLazyDelegate(
private val builder: Obj,
private val capturedScope: Scope,
) : Obj() {
override val objClass: ObjClass = type
private var calculated = false
private var cachedValue: Obj = ObjVoid
override suspend fun invokeInstanceMethod(
scope: Scope,
name: String,
args: Arguments,
onNotFoundResult: (suspend () -> Obj?)?,
): Obj {
return when (name) {
"getValue" -> {
if (!calculated) {
val callScope = capturedScope.createChildScope(capturedScope.pos, args = Arguments.EMPTY)
cachedValue = if (builder is Statement) {
executeBytecodeWithSeed(callScope, builder, "lazy delegate")
} else {
builder.callOn(callScope)
}
calculated = true
}
cachedValue
}
"setValue" -> scope.raiseIllegalAssignment("lazy delegate is read-only")
else -> super.invokeInstanceMethod(scope, name, args, onNotFoundResult)
}
}
companion object {
val type = ObjClass("LazyDelegate").apply {
implementingNames.add("Delegate")
createField(
"getValue",
ObjNull,
isMutable = false,
visibility = Visibility.Public,
pos = Pos.builtIn,
declaringClass = this,
type = ObjRecord.Type.Fun
)
createField(
"setValue",
ObjNull,
isMutable = false,
visibility = Visibility.Public,
pos = Pos.builtIn,
declaringClass = this,
type = ObjRecord.Type.Fun
)
}
}
}
@@ -19,6 +19,8 @@ package net.sergeych.lyng.obj
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import net.sergeych.lyng.InteropOperator
import net.sergeych.lyng.OperatorInteropRegistry
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
import net.sergeych.lyng.miniast.addConstDoc import net.sergeych.lyng.miniast.addConstDoc
import net.sergeych.lyng.miniast.addFnDoc import net.sergeych.lyng.miniast.addFnDoc
@@ -40,6 +42,8 @@ data class ObjReal(val value: Double) : Obj(), Numeric {
override fun byValueCopy(): Obj = this override fun byValueCopy(): Obj = this
override suspend fun compareTo(scope: Scope, other: Obj): Int { override suspend fun compareTo(scope: Scope, other: Obj): Int {
if (other is ObjReal) return value.compareTo(other.value)
OperatorInteropRegistry.invokeCompare(scope, this, other)?.let { return it }
if (other !is Numeric) return -2 if (other !is Numeric) return -2
return value.compareTo(other.doubleValue) return value.compareTo(other.doubleValue)
} }
@@ -63,19 +67,24 @@ data class ObjReal(val value: Double) : Obj(), Numeric {
} }
override suspend fun plus(scope: Scope, other: Obj): Obj = override suspend fun plus(scope: Scope, other: Obj): Obj =
of(this.value + other.toDouble()) OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Plus)
?: of(this.value + other.toDouble())
override suspend fun minus(scope: Scope, other: Obj): Obj = override suspend fun minus(scope: Scope, other: Obj): Obj =
of(this.value - other.toDouble()) OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Minus)
?: of(this.value - other.toDouble())
override suspend fun mul(scope: Scope, other: Obj): Obj = override suspend fun mul(scope: Scope, other: Obj): Obj =
of(this.value * other.toDouble()) OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Mul)
?: of(this.value * other.toDouble())
override suspend fun div(scope: Scope, other: Obj): Obj = override suspend fun div(scope: Scope, other: Obj): Obj =
of(this.value / other.toDouble()) OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Div)
?: of(this.value / other.toDouble())
override suspend fun mod(scope: Scope, other: Obj): Obj = override suspend fun mod(scope: Scope, other: Obj): Obj =
of(this.value % other.toDouble()) OperatorInteropRegistry.invokeBinary(scope, this, other, InteropOperator.Mod)
?: of(this.value % other.toDouble())
/** /**
* Returns unboxed Double value * Returns unboxed Double value
@@ -0,0 +1,222 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.stdlib_included
@Suppress("Unused", "MemberVisibilityCanBePrivate")
internal val decimalLyng = """
package lyng.decimal
/**
* Rounding policies used by [DecimalContext] and `withDecimalContext(...)`.
*
* These modes currently affect decimal division. They are designed to be explicit and readable in Lyng code.
*
* Common examples at precision `2`:
* - `HalfEven`: `1.d / 8.d -> 0.12`
* - `HalfAwayFromZero`: `1.d / 8.d -> 0.13`, `-1.d / 8.d -> -0.13`
* - `HalfTowardsZero`: `1.d / 8.d -> 0.12`, `-1.d / 8.d -> -0.12`
* - `Ceiling`: rounds toward positive infinity
* - `Floor`: rounds toward negative infinity
* - `AwayFromZero`: always increases magnitude when rounding is needed
* - `TowardsZero`: always truncates toward zero
*/
enum DecimalRounding {
HalfEven,
HalfAwayFromZero,
HalfTowardsZero,
Ceiling,
Floor,
AwayFromZero,
TowardsZero
}
/**
* Dynamic decimal arithmetic settings.
*
* A decimal context is not attached permanently to a `BigDecimal` value. Instead, it is applied dynamically
* inside `withDecimalContext(...)`, which makes the rule local to a block of code.
*
* Default context:
* - precision: `34` significant digits
* - rounding: `DecimalRounding.HalfEven`
*
* Example:
*
* import lyng.decimal
*
* (1.d / 3.d).toStringExpanded()
* >>> "0.3333333333333333333333333333333333"
*
* withDecimalContext(10) { (1.d / 3.d).toStringExpanded() }
* >>> "0.3333333333"
*
* withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (1.d / 8.d).toStringExpanded() }
* >>> "0.13"
*/
class DecimalContext(
val precision: Int = 34,
val rounding: DecimalRounding = DecimalRounding.HalfEven
)
/**
* Arbitrary-precision decimal value.
*
* `BigDecimal` is intended for decimal arithmetic where binary floating-point (`Real`) is the wrong tool:
* - money
* - human-entered decimal values
* - ratios that should round in decimal, not in binary
* - reproducible decimal formatting
*
* Creating values:
*
* - `1.d` converts `Int -> BigDecimal`
* - `2.2.d` converts `Real -> BigDecimal` by preserving the current IEEE-754 value
* - `"2.2".d` parses exact decimal text
* - `BigDecimal.fromInt(...)`, `fromReal(...)`, `fromString(...)` are explicit factory forms
*
* Important distinction:
*
* - `2.2.d` means "take the current `Real` value and convert it"
* - `"2.2".d` means "parse this exact decimal literal text"
*
* Therefore:
*
* import lyng.decimal
*
* 2.2.d.toStringExpanded()
* >>> "2.2"
*
* (0.1 + 0.2).d.toStringExpanded()
* >>> "0.30000000000000004"
*
* "0.3".d.toStringExpanded()
* >>> "0.3"
*
* Mixed arithmetic:
*
* `BigDecimal` defines its own operators against decimal-compatible values, and the decimal module also registers
* interop bridges so built-in left-hand operands work naturally:
*
* import lyng.decimal
*
* 1.d + 2
* >>> 3.d
*
* 1 + 2.d
* >>> 3.d
*
* 0.5 + 1.d
* >>> 1.5.d
*
* Precision and rounding:
*
* - division uses the default decimal context unless overridden
* - use `withDecimalContext(...)` to apply a local precision/rounding policy
*
* Exact decimal literal style:
*
* If you want the source text itself to be the decimal value, use a string:
*
* "2.2".d
*
* That is the precise form. `2.2.d` remains a `Real -> BigDecimal` conversion by design.
*/
extern class BigDecimal() {
/** Add another decimal-compatible value. */
extern fun plus(other: Object): BigDecimal
/** Subtract another decimal-compatible value. */
extern fun minus(other: Object): BigDecimal
/** Multiply by another decimal-compatible value. */
extern fun mul(other: Object): BigDecimal
/**
* Divide by another decimal-compatible value.
*
* Division uses the current decimal context:
* - by default: `34` significant digits, `HalfEven`
* - inside `withDecimalContext(...)`: the context active for the current block
*/
extern fun div(other: Object): BigDecimal
/** Remainder with another decimal-compatible value. */
extern fun mod(other: Object): BigDecimal
/** Compare with another decimal-compatible value. */
extern fun compareTo(other: Object): Int
/** Unary minus. */
extern fun negate(): BigDecimal
/** Convert to `Int` by dropping the fractional part according to backend conversion rules. */
extern fun toInt(): Int
/** Convert to `Real`. */
extern fun toReal(): Real
/**
* Convert to a plain decimal string without scientific notation.
*
* This is the preferred representation for user-facing decimal tests and diagnostics.
*/
extern fun toStringExpanded(): String
/** Create a decimal from an `Int`. */
static extern fun fromInt(value: Int): BigDecimal
/**
* Create a decimal from a `Real`.
*
* This preserves the current IEEE-754 value using a round-trip-safe decimal conversion.
* It does not try to recover the original source text.
*/
static extern fun fromReal(value: Real): BigDecimal
/** Parse exact decimal text. */
static extern fun fromString(value: String): BigDecimal
}
/**
* Run [block] with the provided decimal context.
*
* This is the main way to control decimal division precision and rounding locally without changing global behavior.
*
* Example:
*
* import lyng.decimal
*
* withDecimalContext(10) {
* (1.d / 3.d).toStringExpanded()
* }
* >>> "0.3333333333"
*
* Contexts are dynamic and block-local. After the block finishes, the previous context is restored.
*/
extern fun withDecimalContext<T>(context: DecimalContext, block: ()->T): T
/**
* Convenience overload for changing only precision.
*
* Equivalent to `withDecimalContext(DecimalContext(precision, DecimalRounding.HalfEven), block)`.
*/
extern fun withDecimalContext<T>(precision: Int, block: ()->T): T
/**
* Convenience overload for changing precision and rounding explicitly.
*
* Example:
*
* import lyng.decimal
*
* withDecimalContext(2, DecimalRounding.HalfAwayFromZero) {
* (1.d / 8.d).toStringExpanded()
* }
* >>> "0.13"
*/
extern fun withDecimalContext<T>(precision: Int, rounding: DecimalRounding, block: ()->T): T
""".trimIndent()
@@ -0,0 +1,191 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.stdlib_included
@Suppress("Unused", "MemberVisibilityCanBePrivate")
internal val operatorsLyng = """
package lyng.operators
/**
* Binary operators that can be bridged between two different operand classes.
*
* Registering a pair means:
* - the runtime can evaluate `left op right` when `left` has class `L` and `right` has class `R`
* - both operands are first converted to a shared "common" class `C`
* - the actual operator implementation is then looked up on `C`
*
* This is primarily useful when:
* - you add a new numeric-like type in a library or in pure Lyng code
* - your type already implements operators against itself
* - you also want existing left-hand types such as `Int` or `Real` to work when your type is on the right
*
* Example:
*
* import lyng.operators
*
* class DecimalBox(val value: Int) {
* fun plus(other: DecimalBox) = DecimalBox(value + other.value)
* fun compareTo(other: DecimalBox) = value <=> other.value
* }
*
* OperatorInterop.register(
* Int,
* DecimalBox,
* DecimalBox,
* [BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals],
* { x: Int -> DecimalBox(x) },
* { x: DecimalBox -> x }
* )
*
* After registration:
* - `1 + DecimalBox(2)` works
* - `1 < DecimalBox(2)` works
* - `2 == DecimalBox(2)` works
*
* But this registration does not replace methods on the original classes. It only teaches
* Lyng how to bridge a mixed pair into a common class for the listed operators.
*/
enum BinaryOperator {
/** `a + b` */
Plus,
/** `a - b` */
Minus,
/** `a * b` */
Mul,
/** `a / b` */
Div,
/** `a % b` */
Mod,
/**
* Ordering comparisons.
*
* Registering `Compare` enables `<`, `<=`, `>`, `>=`, and the shuttle operator `<=>`
* for the mixed operand pair.
*/
Compare,
/**
* Equality comparisons.
*
* Registering `Equals` enables `==` and `!=` for the mixed operand pair.
*/
Equals
}
/**
* Runtime registry for mixed-class binary operators.
*
* `register(L, R, C, ...)` defines how Lyng should evaluate expressions where:
* - the left operand has class `L`
* - the right operand has class `R`
* - the actual operation should be executed as if both were values of class `C`
*
* The registry is symmetric for the converted values, but not for the original syntax.
* Its job is specifically to fill the gap where your custom type appears on the right:
*
* - `myDecimal + 1` usually already works if `BigDecimal.plus(Int)` exists
* - `1 + myDecimal` needs registration because `Int` itself is not rewritten
*
* Typical pattern for a custom type:
*
* import lyng.operators
*
* class Rational(val num: Int, val den: Int) {
* fun plus(other: Rational) = Rational(num * other.den + other.num * den, den * other.den)
* fun minus(other: Rational) = Rational(num * other.den - other.num * den, den * other.den)
* fun mul(other: Rational) = Rational(num * other.num, den * other.den)
* fun div(other: Rational) = Rational(num * other.den, den * other.num)
* fun compareTo(other: Rational) = (num * other.den) <=> (other.num * den)
*
* static fun fromInt(value: Int) = Rational(value, 1)
* }
*
* OperatorInterop.register(
* Int,
* Rational,
* Rational,
* [
* BinaryOperator.Plus,
* BinaryOperator.Minus,
* BinaryOperator.Mul,
* BinaryOperator.Div,
* BinaryOperator.Compare,
* BinaryOperator.Equals
* ],
* { x: Int -> Rational.fromInt(x) },
* { x: Rational -> x }
* )
*
* Then:
* - `1 + Rational(1, 2)` works
* - `3 > Rational(5, 2)` works
* - `2 == Rational(2, 1)` works
*
* Decimal uses the same mechanism internally to make `Int + BigDecimal` and `Real + BigDecimal`
* work without changing the built-in `Int` or `Real` classes.
*/
extern object OperatorInterop {
/**
* Register a mixed-operand operator bridge.
*
* @param leftClass class of the original left operand
* @param rightClass class of the original right operand
* @param commonClass class that will actually execute the operator methods
* @param operators operators supported for this mixed pair
* @param leftToCommon conversion from `L` to `C`
* @param rightToCommon conversion from `R` to `C`
*
* Requirements for `commonClass`:
* - if you register `Plus`, `C` should implement `fun plus(other: C): C` or equivalent accepted result type
* - if you register `Minus`, `C` should implement `fun minus(other: C): ...`
* - if you register `Mul`, `C` should implement `fun mul(other: C): ...`
* - if you register `Div`, `C` should implement `fun div(other: C): ...`
* - if you register `Mod`, `C` should implement `fun mod(other: C): ...`
* - if you register `Compare`, `C` should implement `fun compareTo(other: C): Int`
*
* `Equals` reuses comparison/equality semantics of the promoted values.
*
* Registration is usually done once at module initialization time:
*
* package my.rational
* import lyng.operators
*
* class Rational(val num: Int, val den: Int) {
* fun plus(other: Rational) = Rational(num * other.den + other.num * den, den * other.den)
* fun compareTo(other: Rational) = (num * other.den) <=> (other.num * den)
* static fun fromInt(value: Int) = Rational(value, 1)
* }
*
* OperatorInterop.register(
* Int,
* Rational,
* Rational,
* [BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals],
* { x: Int -> Rational.fromInt(x) },
* { x: Rational -> x }
* )
*/
extern fun register<L, R, C>(
leftClass: Class<L>,
rightClass: Class<R>,
commonClass: Class<C>,
operators: List<BinaryOperator>,
leftToCommon: (L)->C,
rightToCommon: (R)->C
): Void
}
""".trimIndent()
@@ -0,0 +1,88 @@
/*
* Copyright 2026 Sergey S. Chernov
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Script
import net.sergeych.lyng.ScriptError
import net.sergeych.lyng.Source
import net.sergeych.lyng.asFacade
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.toInt
import net.sergeych.lyng.pacman.ImportManager
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class CompilerVmReviewRegressionTest {
@Test
fun missingModuleCaptureFailsFastInsteadOfBecomingUnset() = runTest {
val manager = ImportManager()
manager.addTextPackages(
"""
package foo
val answer = 42
""".trimIndent()
)
val script = Compiler.compile(
Source(
"<missing-module-capture>",
"""
import foo
fun make() = { answer }
make()
""".trimIndent()
),
manager
)
val prepared = manager.newModule()
script.importInto(prepared)
val preparedLambda = script.execute(prepared)
assertEquals(42, prepared.asFacade().call(preparedLambda).toInt())
val rawModule = manager.newModule()
val hostScope = Scope(parent = rawModule, thisObj = ObjString("receiver"))
val lambda = script.execute(hostScope)
val ex = assertFailsWith<ScriptError> {
hostScope.asFacade().call(lambda)
}
assertContains(ex.errorMessage, "module binding 'answer'")
}
@Test
fun subjectlessWhenReportsScriptError() = runTest {
val ex = assertFailsWith<ScriptError> {
Compiler.compile(
Source(
"<when-without-subject>",
"""
when {
true -> 1
}
""".trimIndent()
),
Script.defaultImportManager
)
}
assertContains(ex.errorMessage, "when without subject")
}
}
@@ -0,0 +1,52 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.bridge.bindGlobalVar
import net.sergeych.lyng.bridge.globalBinder
import kotlin.test.Test
import kotlin.test.assertEquals
class GlobalPropertyCaptureRegressionTest {
@Test
fun externGlobalVarAssignmentInsideFunctionShouldCallBoundSetter() = runTest {
val scope = Script.newScope()
var x = 1.0
scope.eval(
"""
extern var X: Real
fun main() {
X = X + 1.0
}
""".trimIndent()
)
scope.globalBinder().bindGlobalVar(
name = "X",
get = { x },
set = { x = it }
)
scope.eval("main()")
assertEquals(2.0, x, "bound extern var should stay live inside function bodies")
}
}
@@ -114,7 +114,7 @@ class ObjectExpressionTest {
eval(""" eval("""
val x = object { } val x = object { }
val name = ((x::class as Class).className as String) val name = ((x::class as Class).className as String)
assert(name.startsWith("${'$'}Anon_")) assert(name.startsWith("\${'$'}Anon_"))
""".trimIndent()) """.trimIndent())
} }
} }
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Sergey S. Chernov
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Script
import net.sergeych.lyng.Source
import net.sergeych.lyng.obj.toInt
import net.sergeych.lyng.pacman.ImportManager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class ScriptImportPreparationTest {
@Test
fun scriptImportIntoExplicitlyPreparesExistingScope() = runTest {
val manager = ImportManager()
manager.addTextPackages(
"""
package foo
val answer = 42
""".trimIndent()
)
val script = Compiler.compile(
Source(
"<prepare-scope>",
"""
import foo
answer
""".trimIndent()
),
manager
)
val scope = manager.newModule()
assertNull(scope["answer"])
script.importInto(scope)
val record = assertNotNull(scope["answer"])
assertEquals(42, scope.resolve(record, "answer").toInt())
}
@Test
fun scriptInstantiateModuleUsesSeedScopeImportProvider() = runTest {
val manager = ImportManager()
manager.addTextPackages(
"""
package foo
val answer = 42
""".trimIndent()
)
val script = Compiler.compile(
Source(
"<instantiate-module>",
"""
import foo
answer
""".trimIndent()
),
manager
)
val seedScope = manager.newModule()
val module = script.instantiateModule(seedScope)
val record = assertNotNull(module["answer"])
assertEquals(42, module.resolve(record, "answer").toInt())
}
}
+37 -20
View File
@@ -4194,26 +4194,43 @@ class ScriptTest {
// } // }
// @Ignore @Test
// @Test fun interpolationTest() = runTest {
// fun interpolationTest() = runTest { val d = '$'
// eval($$$""" eval(
// """
// val foo = "bar" val foo = "bar"
// val buzz = ["foo", "bar"] val buzz = ["foo", "bar"]
//
// // 1. simple interpolation // 1. simple interpolation
// assertEquals( "bar", "$foo" ) assertEquals("bar", "${d}foo")
// assertEquals( "bar", "${foo}" ) assertEquals("bar", "${d}{foo}")
//
// // 2. escaping the dollar sign // 2. escaping / literal dollar forms
// assertEquals( "$", "\$foo"[0] ) assertEquals("${d}${d}foo", "\${d}foo")
// assertEquals( "foo, "\$foo"[1..] ) assertEquals("${d}${d}foo", "${d}${d}foo")
// assertEquals("\\bar", "\\${d}foo")
// // 3. interpolation with expression
// assertEquals( "foo!. bar?", "${buzz[0]+"!"}. ${buzz[1]+"?"}" ) // 3. interpolation with expression
// """.trimIndent()) assertEquals("foo!. bar?", "${d}{buzz[0] + "!"}. ${d}{buzz[1] + "?"}")
// } """.trimIndent()
)
}
@Test
fun interpolationCanBeDisabledByFeatureComment() = runTest {
val d = '$'
eval(
"""
// feature: interpolation: off
val name = "lyng"
assertEquals("hello, ${d}name!", "hello, ${d}name!")
assertEquals("${d}{name}", "${d}{name}")
assertEquals("${d}${d}name", "${d}${d}name")
assertEquals("\\${d}name", "\\${d}name")
""".trimIndent()
)
}
@Test @Test
fun testInlineArrayLiteral() = runTest { fun testInlineArrayLiteral() = runTest {
@@ -0,0 +1,60 @@
/*
* Copyright 2026 Sergey S. Chernov
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.FrameSlotRef
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
import net.sergeych.lyng.bytecode.BytecodeConst
import net.sergeych.lyng.bytecode.CmdFrame
import net.sergeych.lyng.bytecode.CmdFunction
import net.sergeych.lyng.bytecode.CmdVm
import net.sergeych.lyng.bytecode.seedFrameLocalsFromScope
import kotlin.test.Test
import kotlin.test.assertNull
class SeedLocalsRegressionTest {
@Test
fun seedFrameLocalsSkipsSelfReferentialFrameSlotRef() = runTest {
val fn = CmdFunction(
name = "seed-self-ref",
localCount = 1,
addrCount = 0,
returnLabels = emptySet(),
scopeSlotCount = 1,
scopeSlotIndices = intArrayOf(0),
scopeSlotNames = arrayOf(null),
scopeSlotIsModule = booleanArrayOf(false),
localSlotNames = arrayOf("x"),
localSlotMutables = booleanArrayOf(true),
localSlotDelegated = booleanArrayOf(false),
localSlotCaptures = booleanArrayOf(false),
constants = listOf(BytecodeConst.IntVal(0)),
cmds = emptyArray(),
posByIp = emptyArray()
)
val scope = Scope(null, pos = Pos.builtIn)
val record = scope.addConst("x", net.sergeych.lyng.obj.ObjInt.of(1))
val frame = CmdFrame(CmdVm(), fn, scope, emptyList())
record.value = FrameSlotRef(frame.frame, 0)
scope.updateSlotFor("x", record)
seedFrameLocalsFromScope(frame, scope)
assertNull(frame.frame.getRawObj(0))
}
}
@@ -0,0 +1,138 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
class BigDecimalModuleTest {
@Test
fun testDecimalModuleFactoriesAndConversions() = runTest {
val scope = Script.newScope()
scope.eval(
"""
import lyng.decimal
assertEquals("12.34", BigDecimal.fromString("12.34").toStringExpanded())
assertEquals("1", BigDecimal.fromInt(1).toStringExpanded())
assertEquals("2.5", "2.5".d.toStringExpanded())
assertEquals("1", 1.d.toStringExpanded())
assertEquals("2.2", 2.2.d.toStringExpanded())
assertEquals("3", (1 + 2).d.toStringExpanded())
assertEquals("1.5", (1 + 0.5).d.toStringExpanded())
assertEquals("3", (1 + 2.d).toStringExpanded())
assertEquals("3", (2.d + 1).toStringExpanded())
assertEquals(2.5, "2.5".d.toReal())
assertEquals(2, "2.5".d.toInt())
assertEquals(2.2, 2.2.d.toReal())
assertEquals("0.30000000000000004", (0.1 + 0.2).d.toStringExpanded())
""".trimIndent()
)
}
@Test
fun testDecimalModuleMixedIntOperators() = runTest {
val scope = Script.newScope()
scope.eval(
"""
import lyng.decimal
assertEquals(3.d, 1 + 2.d)
assertEquals(3.d, 2.d + 1)
assertEquals(1.d, 3 - 2.d)
assertEquals(1.d, 3.d - 2)
assertEquals(8.d, 4 * 2.d)
assertEquals(8.d, 4.d * 2)
assertEquals(4.d, 8 / 2.d)
assertEquals(4.d, 8.d / 2)
assertEquals(1.d, 7 % 2.d)
assertEquals(1.d, 7.d % 2)
assert(1 < 2.d)
assert(2 <= 2.d)
assert(3 > 2.d)
assert(3.d > 2)
assert(2 == 2.d)
assert(2.d == 2)
""".trimIndent()
)
}
@Test
fun testDecimalDivisionUsesDefaultContext() = runTest {
val scope = Script.newScope()
scope.eval(
"""
import lyng.decimal
assertEquals("0.125", (1.d / 8.d).toStringExpanded())
assertEquals("0.3333333333333333333333333333333333", (1.d / 3.d).toStringExpanded())
assertEquals("0.6666666666666666666666666666666667", ("2".d / 3.d).toStringExpanded())
""".trimIndent()
)
}
@Test
fun testWithDecimalContextOverridesDivisionContext() = runTest {
val scope = Script.newScope()
scope.eval(
"""
import lyng.decimal
assertEquals("0.3333333333333333333333333333333333", (1.d / 3.d).toStringExpanded())
assertEquals("0.3333333333", withDecimalContext(10) { (1.d / 3.d).toStringExpanded() })
assertEquals("0.666667", withDecimalContext(6) { ("2".d / 3.d).toStringExpanded() })
assertEquals("0.666667", withDecimalContext(DecimalContext(6)) { ("2".d / 3.d).toStringExpanded() })
assertEquals("0.12", withDecimalContext(2) { (1.d / 8.d).toStringExpanded() })
assertEquals("0.13", withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (1.d / 8.d).toStringExpanded() })
assertEquals("0.13", withDecimalContext(DecimalContext(2, DecimalRounding.HalfAwayFromZero)) { (1.d / 8.d).toStringExpanded() })
assertEquals("0.3333333333333333333333333333333333", (1.d / 3.d).toStringExpanded())
""".trimIndent()
)
}
@Test
fun testDecimalDivisionRoundingMatrix() = runTest {
val scope = Script.newScope()
scope.eval(
"""
import lyng.decimal
assertEquals("0.12", withDecimalContext(2, DecimalRounding.HalfEven) { (1.d / 8.d).toStringExpanded() })
assertEquals("-0.12", withDecimalContext(2, DecimalRounding.HalfEven) { (-1.d / 8.d).toStringExpanded() })
assertEquals("0.13", withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (1.d / 8.d).toStringExpanded() })
assertEquals("-0.13", withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (-1.d / 8.d).toStringExpanded() })
assertEquals("0.12", withDecimalContext(2, DecimalRounding.HalfTowardsZero) { (1.d / 8.d).toStringExpanded() })
assertEquals("-0.12", withDecimalContext(2, DecimalRounding.HalfTowardsZero) { (-1.d / 8.d).toStringExpanded() })
assertEquals("0.13", withDecimalContext(2, DecimalRounding.Ceiling) { (1.d / 8.d).toStringExpanded() })
assertEquals("-0.12", withDecimalContext(2, DecimalRounding.Ceiling) { (-1.d / 8.d).toStringExpanded() })
assertEquals("0.12", withDecimalContext(2, DecimalRounding.Floor) { (1.d / 8.d).toStringExpanded() })
assertEquals("-0.13", withDecimalContext(2, DecimalRounding.Floor) { (-1.d / 8.d).toStringExpanded() })
assertEquals("0.13", withDecimalContext(2, DecimalRounding.AwayFromZero) { (1.d / 8.d).toStringExpanded() })
assertEquals("-0.13", withDecimalContext(2, DecimalRounding.AwayFromZero) { (-1.d / 8.d).toStringExpanded() })
assertEquals("0.12", withDecimalContext(2, DecimalRounding.TowardsZero) { (1.d / 8.d).toStringExpanded() })
assertEquals("-0.12", withDecimalContext(2, DecimalRounding.TowardsZero) { (-1.d / 8.d).toStringExpanded() })
""".trimIndent()
)
}
}
@@ -212,6 +212,96 @@ class DelegationTest {
assertTrue(badThrown) assertTrue(badThrown)
} }
@Test
fun testPureLyngLazyPreservesReceiverAndClosure() = runTest {
val scope = Script.newScope()
scope.eval(
"""
val GLOBAL_NUMBERS = [1,2,3]
class PureLazy<T,ThisRefType=Object>(creatorParam: ThisRefType.()->T) : Delegate<T,ThisRefType> {
private val creator: ThisRefType.()->T = creatorParam
private var value = Unset
override fun bind(name: String, access, thisRef: ThisRefType): Object = this
override fun getValue(thisRef: ThisRefType, name: String): T {
if (value == Unset)
value = creator(thisRef)
value as T
}
}
fun pureLazy<T,ThisRefType=Object>(creator: ThisRefType.()->T): Delegate<T,ThisRefType> = PureLazy(creator)
class A {
val numbers = [1,2,3]
val fromThis: List by pureLazy { this.numbers }
val fromScope: List by pureLazy { GLOBAL_NUMBERS }
}
class B {
val a: A by pureLazy { A() }
val test: List by pureLazy { (a as A).fromThis + [4] }
}
assertEquals([1,2,3], A().fromThis)
assertEquals([1,2,3], A().fromScope)
assertEquals([1,2,3,4], B().test)
""".trimIndent()
)
}
@Test
fun testImportedPureLyngLazyPreservesReceiverAndClosure() = runTest {
val scope = Script.newScope()
scope.importManager.addTextPackages(
"""
package repro.lazy
import lyng.stdlib
class PureLazy<T,ThisRefType=Object>(creatorParam: ThisRefType.()->T) : Delegate<T,ThisRefType> {
private val creator: ThisRefType.()->T = creatorParam
private var value = Unset
override fun bind(name: String, access: DelegateAccess, thisRef: ThisRefType): Object {
if (access != DelegateAccess.Val) throw "lazy delegate can only be used with 'val'"
this
}
override fun getValue(thisRef: ThisRefType, name: String): T {
if (value == Unset)
value = with(thisRef, creator)
value as T
}
}
""".trimIndent()
)
scope.eval(
"""
import repro.lazy
val GLOBAL_NUMBERS = [1,2,3]
class A {
val numbers = [1,2,3]
val fromThis: List by PureLazy { this.numbers }
val fromScope: List by PureLazy { GLOBAL_NUMBERS }
}
class B {
val a: A by PureLazy { A() }
val test: List by PureLazy { (a as A).fromThis + [4] }
}
assertEquals([1,2,3], A().fromThis)
assertEquals([1,2,3], A().fromScope)
assertEquals([1,2,3,4], B().test)
""".trimIndent()
)
}
@Test @Test
fun testLazyIsDelegate() = runTest { fun testLazyIsDelegate() = runTest {
eval(""" eval("""
@@ -0,0 +1,111 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
class OperatorInteropTest {
@Test
fun testPureLyngOperatorInteropRegistration() = runTest {
val im = Script.defaultImportManager.copy()
im.addPackage("test.decimalbox") { scope ->
scope.eval(
"""
package test.decimalbox
import lyng.operators
class DecimalBox(val value: Int) {
fun plus(other: DecimalBox) = DecimalBox(value + other.value)
fun minus(other: DecimalBox) = DecimalBox(value - other.value)
fun mul(other: DecimalBox) = DecimalBox(value * other.value)
fun div(other: DecimalBox) = DecimalBox(value / other.value)
fun mod(other: DecimalBox) = DecimalBox(value % other.value)
fun compareTo(other: DecimalBox) = value <=> other.value
}
OperatorInterop.register(
Int,
DecimalBox,
DecimalBox,
[BinaryOperator.Plus, BinaryOperator.Minus, BinaryOperator.Mul, BinaryOperator.Div, BinaryOperator.Mod, BinaryOperator.Compare, BinaryOperator.Equals],
{ x: Int -> DecimalBox(x) },
{ x: DecimalBox -> x }
)
""".trimIndent()
)
}
val scope = im.newStdScope()
scope.eval(
"""
import test.decimalbox
assertEquals(DecimalBox(3), 1 + DecimalBox(2))
assertEquals(DecimalBox(1), 3 - DecimalBox(2))
assertEquals(DecimalBox(8), 4 * DecimalBox(2))
assertEquals(DecimalBox(4), 8 / DecimalBox(2))
assertEquals(DecimalBox(1), 7 % DecimalBox(2))
assert(1 < DecimalBox(2))
assert(2 <= DecimalBox(2))
assert(3 > DecimalBox(2))
assert(2 >= DecimalBox(2))
assert(2 == DecimalBox(2))
assert(2 != DecimalBox(3))
""".trimIndent()
)
}
@Test
fun testRealInteropRegistrationUsesTopLevelModuleCode() = runTest {
val im = Script.defaultImportManager.copy()
im.addPackage("test.realbox") { scope ->
scope.eval(
"""
package test.realbox
import lyng.operators
class RealBox(val value: Real) {
fun plus(other: RealBox) = RealBox(value + other.value)
fun compareTo(other: RealBox) = value <=> other.value
}
OperatorInterop.register(
Real,
RealBox,
RealBox,
[BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals],
{ x: Real -> RealBox(x) },
{ x: RealBox -> x }
)
""".trimIndent()
)
}
val scope = im.newStdScope()
scope.eval(
"""
import test.realbox
assertEquals(RealBox(1.75), 0.5 + RealBox(1.25))
assert(1.5 < RealBox(2.0))
assert(2.0 == RealBox(2.0))
""".trimIndent()
)
}
}
+2 -2
View File
@@ -81,6 +81,7 @@ extern fun abs(x: Object): Real
extern fun ln(x: Object): Real extern fun ln(x: Object): Real
extern fun pow(x: Object, y: Object): Real extern fun pow(x: Object, y: Object): Real
extern fun sqrt(x: Object): Real extern fun sqrt(x: Object): Real
extern fun clamp<T>(value: T, range: Range<T>): T
class SeededRandom { class SeededRandom {
extern fun nextInt(): Int extern fun nextInt(): Int
@@ -420,7 +421,6 @@ fun with<T,R>(self: T, block: T.()->R): R {
Can only be used with 'val' properties. Can only be used with 'val' properties.
*/ */
class lazy<T,ThisRefType=Object>(creatorParam: ThisRefType.()->T) : Delegate<T,ThisRefType> { class lazy<T,ThisRefType=Object>(creatorParam: ThisRefType.()->T) : Delegate<T,ThisRefType> {
private val creator: ThisRefType.()->T = creatorParam
private var value = Unset private var value = Unset
override fun bind(name: String, access: DelegateAccess, thisRef: ThisRefType): Object { override fun bind(name: String, access: DelegateAccess, thisRef: ThisRefType): Object {
@@ -430,7 +430,7 @@ class lazy<T,ThisRefType=Object>(creatorParam: ThisRefType.()->T) : Delegate<T,T
override fun getValue(thisRef: ThisRefType, name: String): T { override fun getValue(thisRef: ThisRefType, name: String): T {
if (value == Unset) if (value == Unset)
value = with(thisRef,creator) value = with(thisRef, creatorParam)
value as T value as T
} }
} }
@@ -0,0 +1,69 @@
# :lynglib Compiler and VM Review (2026-03-26)
## Scope
- Reviewed `:lynglib` compiler and bytecode VM paths, focusing on compile/execution correctness.
- Read core files around `Compiler`, `Script`, `BytecodeCompiler`, `CmdRuntime`, `Scope`, `ModuleScope`, and capture/slot plumbing.
- Ran `./gradlew :lynglib:jvmTest` on 2026-03-26: PASS.
## Findings
### 1. High: local seeding uses the wrong slot index when checking for self-referential `FrameSlotRef`
- Status: fixed in worktree on 2026-03-26; covered by `SeedLocalsRegressionTest`.
- File: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/SeedLocals.kt:39`
- The self-reference guard compares `FrameSlotRef` against `base + i`, but `FrameSlotRef` stores the underlying `BytecodeFrame` local slot index, not the VM absolute slot id.
- The same slot is then written using `setObjUnchecked(base + i, value)` at `SeedLocals.kt:42`, which means a self-reference is not filtered out before being reinserted.
- Impact: this can seed a local with a reference to itself, creating recursive `FrameSlotRef` chains. Reads then recurse through `BytecodeFrame.getObj()` / `FrameSlotRef.read()` instead of resolving to a value, which is a realistic path to stack overflow or non-terminating execution in bytecode-only helper execution (`executeBytecodeWithSeed`).
- Why this looks real: the equivalent check in `CmdFrame.applyCaptureRecords()` uses the local index directly (`CmdRuntime.kt:3867`), and `Script.seedModuleLocals()` also compares with the local index, not `scopeSlotCount + i` (`Script.kt:109`).
- Suggested fix: compare with `i`, not `base + i`, and add a regression test around `executeBytecodeWithSeed` seeding a scope that already contains a `FrameSlotRef` to the same local.
### 2. Design note: script-specific import/module preparation should stay explicit, not hidden in `execute(scope)`
- Status: resolved by API addition, without changing `Script.execute(scope)` semantics.
- File: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt:51-57`
- `shouldSeedModule` is only true when `execScope` is a `ModuleScope` or `thisObj === ObjVoid`.
- If a compiled script is executed against a non-module scope with a real receiver object, `seedModuleSlots()` is skipped entirely, so script imports and explicit module bindings are never installed into `moduleTarget`.
- Original concern: top-level bytecode that expects imported names or module globals can see different behavior depending on whether the host prepared the target scope explicitly.
- Resolution taken: do **not** change `Script.execute(scope)`, because in this codebase it is expected to run on exactly the provided scope and many embedding flows already rely on explicit scope setup via `Script.newScope()`, `Scope.eval(...)`, `addFn(...)`, `addConst(...)`, and direct import-manager configuration.
- New explicit APIs added instead:
- `Script.importInto(scope, seedScope = scope)`
- `Script.instantiateModule(seedScope = null, pos = script.pos)`
- This keeps existing execution behavior stable while giving hosts an opt-in way to apply a script's import/module bindings when they actually want that preparation step.
- Coverage added: `ScriptImportPreparationTest`.
### 3. Medium: missing module captures are silently converted into fresh `Unset` slots
- Status: fixed in worktree on 2026-03-27; covered by `CompilerVmReviewRegressionTest`.
- File: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt:3950-3970`
- In `CmdFrame.buildCaptureRecords()`, the module-capture path first tries several lookups. If the requested `slotId` is missing but the capture has a `name`, it calls `target.applySlotPlan(mapOf(name to slotId))` and immediately returns `target.getSlotRecord(slotId)`.
- That record is a newly created placeholder from `Scope.applySlotPlan()` (`Scope.kt:460-471`) and defaults to `ObjUnset`.
- Impact: a compiler/runtime disagreement in capture resolution is masked as a normal capture of `Unset`, so the failure moves far away from closure creation and becomes data corruption or an unrelated later exception. This will be difficult to debug when it happens.
- Resolution taken: the VM now refuses to treat synthetic placeholder slots as real module/captured bindings. Reads and capture construction fail with a source-positioned `ScriptError` when the execution scope was not prepared with the script's required module/import bindings.
### 4. Medium: subject-less `when { ... }` still crashes through a raw Kotlin `TODO`
- Status: fixed in worktree on 2026-03-27; covered by `CompilerVmReviewRegressionTest`.
- File: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt:6447-6449`
- The unsupported branch uses `TODO("when without object is not yet implemented")`.
- Current docs explicitly say subject-less `when` is not implemented, so the language limitation itself is documented. The problem is the failure mode: the compiler throws a raw Kotlin `NotImplementedError` instead of a normal `ScriptError` or a feature diagnostic.
- Impact: IDE/embedding callers get an implementation exception rather than a source-positioned language error, which is especially bad across non-JVM targets and for editor tooling.
- Resolution taken: the parser now throws a normal `ScriptError` at the `when` position with an explicit "when without subject is not implemented" message.
## Risks Worth Checking Next
### 5. Medium risk: module frame growth only retargets records stored in `objects`/`localBindings`
- File: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/ModuleScope.kt:39-67`
- When `ensureModuleFrame()` replaces the old `BytecodeFrame` with a larger one, it retargets `FrameSlotRef`s only in `objects` and `localBindings`.
- Existing closures/capture records that already hold `FrameSlotRef(oldFrame, slot)` are not retargeted here.
- Impact: if a module scope survives across recompilation/re-execution with a larger local frame, previously exported closures can keep reading stale values from the old frame instance.
- Confidence is lower because this depends on module reuse across differing compiled shapes, but the reference-retargeting logic is clearly incomplete for that scenario.
- Suggested check: add a regression covering module re-execution with increased local count and an exported closure captured before the resize.
## Test Status
- `./gradlew :lynglib:jvmTest` passed during this review.
- `./gradlew :lynglib:jvmTest --tests ScriptImportPreparationTest --tests SeedLocalsRegressionTest` passed after the fixes/API additions.
- `./gradlew :lynglib:jvmTest --tests CompilerVmReviewRegressionTest --tests ScriptImportPreparationTest --tests SeedLocalsRegressionTest` passed after fixing findings 3 and 4.
- Finding 1 is covered directly; finding 2 is covered by explicit preparation API tests.
## Suggested Fix Order
1. Fix finding 1 first: it is a concrete slot-index bug with likely recursive failure modes. Done.
2. Keep `Script.execute(scope)` semantics stable and use explicit preparation APIs where script-owned import/module setup is needed. Done.
3. Tighten finding 3 next: fail fast on capture mismatches. Done.
4. Replace the raw `TODO` in finding 4 so unsupported syntax produces normal diagnostics. Done.
5. Decide whether finding 5 matters for current module-reload workflows; add a regression before changing behavior.
+46
View File
@@ -0,0 +1,46 @@
WASM использует стековую модель памяти. Lyng стеки не использует. В чем разница?
Стек это очень древняя структура, была придумана в 1955 (магазинная память Бауэра и Самельсона), и приняла современную форму в 1960, благодаря Барбаре Лисокв, вполседствии лауреата премии Тьюринга, которого, в свою очередь, за выдающийся вклад в развитие IT кастрировали, формально, за гомосексуализм, но в британии, где на трех джентльменов приходилось тогда, и приходится и теперь, четыре гомосексуала, все понимают, что это был просто предлог.
Стек был прекрасен в 60е годы, когда IBM планировала продавать по компьютеру в год в лучшем случае, а за идеи о микропроцессорах или ядрах можно было устроиться только пациентом психушки. Стек это заранее жестко выделенная область памяти, которая обычно вообще не используется, и по ней в одну сторону растет используемая область, которая хранит локальные переменные, адреса возврата и иногда состояние процессора.
Она крайне плохо подходит для многозадачности. Представь себе что у тебя есть wasm процесс, со своим стеком, и ему надо переключиться, остановив текущий поток исполнения (например, он ждет ответа от сети). Тогда тебе придется создать _новый стек_ — пока еще пустой, переключиться на него и исполнять там другую задачу. Сколько потоков - столько и стеков. И все они по большей части не используются — там просто запас памяти "на вырост", если исполнение потребует. А оценить заранее адекватно почти невозможно, дают с запасом.
Далее, васм использует фиксированную память выделенную на процесс. Ее тоже приходится брать "с запасом", так как если по ходу не хватит, процесс вылетит. И она тоже в изрядной части пустая. Ну и назасладочку, васм был задуман однозадачным, и все попытки туда хотя бы треды засунуть, получаются очень кривыми.
Линг использует фреймы и сопрограммы. Линг вообще треды не использует. Основная команда исполнения программы на линге - сопрограмма (coroutine), ей не нужен стейк, она им не пользуется. Вместо этого каждый вызов в линге, грубо говоря, это вызов сопрограммы. Для него формируется фрейм (не на стеке, а в динамической памяти!), которого гарантировано достаточно для исполнения собственно кода. Он заменяет собой "стековый фрейм", но его размер известен заранее, и его можно распределять и освобождать проще, как обычную динамическую память.
Далее, линг использует ту же динамическую память, что и его родительская платформа. Если он работает на Java, то использует память со сборкой мусора JVM, на Javascript - память машины JS, в нативных машинах - специальнй менеджер памяти со сборкой мусора, от Kotlin Native (довольно хороший и очень быстро развивающийся). В результате, запустить одновременно десять, или сто тысяч программ на лигне, которые будут исполняться конкурентно, вполне реально. Просто попытка запустить 10к васм-машин на v8 или другом движке скорее всего убьет систему, да и 10 тысяч тредов редко какой сервер приложению даст. Огромный перерасход ресурсов и тормоза.
Далее. Васм СТЕКОВАЯ машина со стековыми командами, он без стека вообще не может. Посмотрим как он считает примитивное выражение:
```wasm
(module
;; Экспортируем функцию, чтобы её можно было вызвать из JS
(func (export "calc") (param $a i32) (param $b i32) (param $c i32) (param $d i32) (result i32)
;; --- вычисление (a + b) / c * d ---
local.get $a ;; помещаем a на стек
local.get $b ;; помещаем b на стек
i32.add ;; складываем: стек содержит (a+b)
local.get $c ;; помещаем c
i32.div_s ;; знаковое деление: стек содержит ((a+b) / c)
local.get $d ;; помещаем d
i32.mul ;; умножение: стек содержит (((a+b)/c) * d)
;; результат остаётся на стеке — это возвращаемое значение функции
)
)
```
На ВМ Линга нет стека, он трехадресный универсальный ассемблер, если можно так сказать:
```
add_int s1, s2 -> s5 // a + b -> s5
div_int s5, s3 -> s5 // s5 -> s5 / c
mul_int s5, s4 -> s5 // s5 -> s5 * d
return s5
```
Этот код крайне эффективно реализуется на любом реальном процессоре, и хорошо оптимизируется. Исполняется он без всякого стека.
+21
View File
@@ -0,0 +1,21 @@
Почему не WASM?
- wasm очень громоздкий и неудобный для разработки, он проектировался очень давно и с другими целями, так что сейчас его безуспешно пытаются "перезаточить" в современные области, но он поддается с трудом. Компилировать в wasm была бы задача по сложности превосходящая весь проект, а результат был бы уныл: мы и так имеем все достоинсва wasm, полезные для Lyng, но не имеем многих его недостатков.
Виртуалка lyng легкая, и доступна на всех платформах уже сейчас, а вот прикрутить скажем wasm машину к котлину на сервере, чтобы она работала как сопрограммы первого класса вместе с котлинскими, задача сейчас неразрешимая.
То есть, преимущества wasm что он доступен на многих платформах, для нас неважно - мы и так на тех же платформах имеем полную поддержку.
- Lyng VM изначально заточена на исполнение именно сопрограмм со сборкой мусора. Эти два ключевых механизма линга практически отсутствуют в wasm, их мучительно прикручивают как расширения, и они довольно слабо поддерживаются.
- LyngVM заточен на исполнение программ с гибридной ООП-ФП моделью, она хорошо поддерживает множественное наследование, делегацию, работу со сложными списками аргументов (ФП фичи, подстановки, деструктурирование), на васме это очень непросто написать, будет медленно и громоздко.
- одна из целей Lyng была получить быстрое безопасное и очень мощное скриптовое решение для вклучения в проекты на Java/Kotlin multiplatform. На wasm это в принципе невозможно - его включение это кошмар, он громоздкий. К тому же требует компиляции, а Lyng работает с JIT (исполняет прямо исходник, компилируя его на лету, как JS), и может использоваться как скрипт (wasm не может)
- типы данных которые мы используем в Lyng в WASM отсутстсвтуют, их бы пришлось добавлять
В результате если бы мы компилировали Lyng->WASM мы бы получили огромный и медленный код. Для которого потребовалась бы огромная wasm VM. Практического смысла в этом нет.
Если же ты рассматриваешь идею в единой платформе использовать разные языки, а wasm как промежуточный язых совместимости, то это собственно противоречит идее использовать общую кодовую базу и интерфейсы. Разные языки имеют несовпадающие, несовместимые интерфейсы вызовов, так что приходится писать руками биндинги, которые не добавляют скорости и добавляют ошибки, к тому же, разные языки предоставляют часто вообще несовместимые модели программирования (например прототипы в JS, множественное наследование С++ или Линга и кошмар на улице вязов от руста, в владением ссылками).
Платформ на васме полно, и там и ловить нечего, и неинтересно. Я же предлагаю вылезти в другой класс. Навеяно реальным опоытом переносов контрактов из старого золота в мольтпей, которое получается на удивление хорошо
+91 -77
View File
@@ -27,74 +27,85 @@ import org.jetbrains.compose.web.dom.*
fun HomePage() { fun HomePage() {
val samples = remember { val samples = remember {
listOf( listOf(
"""
// Decimal arithmetic with explicit local precision
import lyng.decimal
val exact = "0.3".d
val fromReal = (0.1 + 0.2).d
println(exact.toStringExpanded())
println(fromReal.toStringExpanded())
println(withDecimalContext(10) { (1.d / 3.d).toStringExpanded() })
""".trimIndent(),
"""
// Mixed operators for your own type
import lyng.operators
class Coins(val amount: Int) {
fun plus(other: Coins) = Coins(amount + other.amount)
fun compareTo(other: Coins) = amount <=> other.amount
}
OperatorInterop.register(
Int,
Coins,
Coins,
[BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals],
{ x: Int -> Coins(x) },
{ x: Coins -> x }
)
println(1 + Coins(2))
println(3 > Coins(2))
""".trimIndent(),
"""
// Non-local returns from closures
fun findFirst<T>(list: Iterable<T>, predicate: (T)->Bool): T? {
list.forEach {
if (predicate(it)) return@findFirst it
}
null
}
val found: Int? = findFirst([1, 5, 8, 12]) { it > 10 }
println("Found: " + found)
""".trimIndent(),
"""
// Implicit coroutines: parallelism without ceremony
import lyng.time
val d1 = launch {
delay(100.milliseconds)
"Task A finished"
}
val d2 = launch {
delay(50.milliseconds)
"Task B finished"
}
println(d1.await())
println(d2.await())
""".trimIndent(),
""" """
// Everything is an expression // Everything is an expression
val x: Int = 10 val x: Int = 10
val status: String = if (x > 0) "Positive" else "Zero or Negative" val status: String = if (x > 0) "Positive" else "Zero or Negative"
// Even loops return values!
val result = for (i in 1..5) { val result = for (i in 1..5) {
if (i == 3) break "Found 3!" if (i == 3) break "Found 3!"
} else "Not found" } else "Not found"
println("Result: " + result) println(status)
println(result)
""".trimIndent(), """.trimIndent(),
""" """
// Functional power with generics and collections // Functional collections with strict static types
val squares: List<Int> = (1..10) val squares: List<Int> = (1..10)
.filter { it % 2 == 0 } .filter { it % 2 == 0 }
.map { it * it } .map { it * it }
println("Even squares: " + squares) println("Even squares: " + squares)
// Output: [4, 16, 36, 64, 100]
""".trimIndent(),
"""
// Generics and type aliases
type Num = Int | Real
class Box<out T: Num>(val value: T) {
fun get(): T = value
}
val intBox = Box(42)
val realBox = Box(3.14)
println("Boxes: " + intBox.get() + ", " + realBox.get())
""".trimIndent(),
"""
// Strict compile-time types and symbol resolution
fun greet(name: String, count: Int) {
for (i in 1..count) {
println("Hello, " + name + "!")
}
}
greet("Lyng", 3)
// greet(10, "error") // This would be a compile-time error!
""".trimIndent(),
"""
// Flexible map literals and shorthands
val id = 101
val name = "Lyng"
val base = { id:, name: } // Shorthand for id: id, name: name
val full = { ...base, version: "1.5.0-SNAPSHOT", status: "active" }
println(full)
""".trimIndent(),
"""
// Modern null safety
var config: Map<String, Int>? = null
config ?= { timeout: 30 } // Assign only if null
val timeout = config?["timeout"] ?: 60
println("Timeout is: " + timeout)
""".trimIndent(),
"""
// Destructuring with splat operator
val [first, middle..., last] = [1, 2, 3, 4, 5, 6]
println("First: " + first)
println("Middle: " + middle)
println("Last: " + last)
""".trimIndent(), """.trimIndent(),
""" """
// Diamond-safe Multiple Inheritance (C3 MRO) // Diamond-safe Multiple Inheritance (C3 MRO)
@@ -123,18 +134,6 @@ fun HomePage() {
println([10, 20, 30].second) println([10, 20, 30].second)
""".trimIndent(), """.trimIndent(),
""" """
// Non-local returns from closures
fun findFirst<T>(list: Iterable<T>, predicate: (T)->Bool): T? {
list.forEach {
if (predicate(it)) return@findFirst it
}
null
}
val found: Int? = findFirst([1, 5, 8, 12]) { it > 10 }
println("Found: " + found)
""".trimIndent(),
"""
// Easy operator overloading // Easy operator overloading
class Vector(val x: Real, val y: Real) { class Vector(val x: Real, val y: Real) {
fun plus(other: Vector): Vector = Vector(x + other.x, y + other.y) fun plus(other: Vector): Vector = Vector(x + other.x, y + other.y)
@@ -156,20 +155,13 @@ fun HomePage() {
println("User name: " + u.name) println("User name: " + u.name)
""".trimIndent(), """.trimIndent(),
""" """
// Implicit coroutines: parallelism without ceremony // Flexible map literals and shorthands
import lyng.time val id = 101
val name = "Lyng"
val base = { id:, name: }
val full = { ...base, status: "active", tags: ["typed", "portable"] }
val d1 = launch { println(full)
delay(100.milliseconds)
"Task A finished"
}
val d2 = launch {
delay(50.milliseconds)
"Task B finished"
}
println(d1.await())
println(d2.await())
""".trimIndent() """.trimIndent()
) )
} }
@@ -195,6 +187,8 @@ fun HomePage() {
Div({ classes("d-flex", "justify-content-center", "gap-2", "flex-wrap", "mb-4") }) { Div({ classes("d-flex", "justify-content-center", "gap-2", "flex-wrap", "mb-4") }) {
// Benefits pills // Benefits pills
listOf( listOf(
"Decimal arithmetic",
"Operator interop",
"Strict static typing", "Strict static typing",
"Generics & Type Aliases", "Generics & Type Aliases",
"Implicit coroutines", "Implicit coroutines",
@@ -233,6 +227,26 @@ fun HomePage() {
} }
} }
Div({ classes("row", "g-3", "mb-4") }) {
listOf(
Triple("New: Decimal", "Exact decimal values, local precision control, and mixed operators with Int and Real.", "#/docs/Decimal.md"),
Triple("New: Operator Interop", "Teach Lyng how your custom types interact with built-ins on the left-hand side.", "#/docs/OperatorInterop.md"),
Triple("What Changed", "Recent language, stdlib, and tooling improvements in one place.", "#/docs/whats_new.md")
).forEach { (title, text, href) ->
Div({ classes("col-12", "col-lg-4") }) {
A(attrs = {
classes("text-decoration-none")
attr("href", href)
}) {
Div({ classes("h-100", "p-3", "border", "rounded-3", "bg-body-tertiary") }) {
H3({ classes("h5", "mb-2", "text-body") }) { Text(title) }
P({ classes("mb-0", "text-muted") }) { Text(text) }
}
}
}
}
}
// Code sample slideshow // Code sample slideshow
Div({ Div({
classes("markdown-body", "mt-0", "slide-container", "position-relative") classes("markdown-body", "mt-0", "slide-container", "position-relative")
+1 -1
View File
@@ -339,7 +339,7 @@
<!-- Top-left version ribbon --> <!-- Top-left version ribbon -->
<div class="corner-ribbon bg-danger text-white"> <div class="corner-ribbon bg-danger text-white">
<span style="margin-left: -5em"> <span style="margin-left: -5em">
v1.5.0-RC v1.5.1
</span> </span>
</div> </div>
<!-- Fixed top navbar for the whole site --> <!-- Fixed top navbar for the whole site -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+189
View File
@@ -0,0 +1,189 @@
# Lyng as a Common Platform for Smart Contracts and Distributed Execution
## Executive Summary
This proposal describes Lyng as a shared execution layer for:
- smart contracts across different blockchain networks,
- distributed compute workflows,
- decentralized application logic.
The goal is simple: write business logic once in Lyng, then run it consistently across chains, trusted execution nodes, and off-chain workers.
## Why a Common Lyng Layer
A unified language and runtime reduces fragmentation between contract code, off-chain services, and automation scripts.
Key benefits:
- One language across environments: the same Lyng code can be reused in contracts, validators, and distributed tasks.
- Capability-safe by default: no filesystem, process, or host access unless explicitly exposed by the host platform.
- Async-first execution model: lightweight concurrent tasks are natural, and host runtimes can schedule them efficiently.
- Strong typing with inference: concise source code without sacrificing type safety.
- Deterministic execution options: instruction/gas counting can be enabled for metered environments.
- Broad platform support: JVM, JS/Wasm, and native targets enable consistent tooling and deployment.
## Where It Fits
Lyng is a strong fit for:
- contract logic that must also run in simulation or off-chain verification,
- distributed orchestration and rule-based automations,
- cross-network shared libraries (validation, accounting rules, policy checks),
- deterministic business logic that should be portable between operators.
## Proposed Architecture
1. **Primary runtime**: Lyng VM/JIT for high-throughput trusted execution.
2. **Metered runtime**: Lyng VM mode with instruction counting and gas limits for blockchain execution.
3. **Standard API surface**:
- core Lyng standard library,
- storage contract (`key/value/tags`) shared across network adapters.
4. **Developer sandbox**:
- local deterministic sandbox for test/debug,
- optional feature-rich sandbox profile for integration testing.
## Strong Parts of the Lyng Approach
- **Code portability**: contract logic and off-chain logic remain aligned.
- **Lower maintenance**: fewer language/runtime rewrites across components.
- **Faster onboarding**: modern, readable syntax and concise constructs.
- **Safer host integration**: explicit capability injection limits accidental exposure.
- **Operational flexibility**: same package can run in metered or non-metered mode.
## Example: Deterministic Ledger Transfer
```lyng
class Ledger(storage) {
fun transfer(from: Int, to: Int, amount: Int) {
require(amount > 0)
val fromKey = "bal:$from"
val toKey = "bal:$to"
val fromBal = storage[fromKey] ?: 0
require(fromBal >= amount)
storage[fromKey] = fromBal - amount
storage[toKey] = (storage[toKey] ?: 0) + amount
}
}
```
The same logic can run:
- in a gas-metered contract runtime,
- in off-chain simulation,
- in distributed batch reconciliation.
## Example: Parallel Verification Workflow
```lyng
fun verifyBatch(items, verifier) {
val jobs = []
for (item in items) {
jobs += launch { verifier(item) }
}
val results = []
for (job in jobs) {
results += await(job)
}
results
}
```
This model is useful for distributed validation, scoring, and pre-consensus checks.
## Feature Showcases
### 1. Runtime generics with bounds (non-erased behavior)
```lyng
fun square<T: Int | Real>(x: T) = x * x
fun describeValue<T: Int | Real>(x: T): String = when (T) {
Int -> "Int value=$x"
Real -> "Real value=$x"
else -> "Numeric value=$x"
}
fun sameType<T>(x: T, y: Object): Bool = y is T
```
This enables practical runtime type-aware logic in generic code without JVM-style erasure limitations.
### 2. Multiple inheritance with lifted enums
```lyng
class Payable(val accountId: Int) {
fun channel() = "onchain"
}
class Audited {
fun mark() = "audited"
}
class Payment(accountId: Int) : Payable(accountId), Audited() {
enum Status* { Pending, Settled }
}
val p = Payment(42)
assertEquals("onchain", p.channel())
assertEquals("audited", p.mark())
assertEquals(Payment.Pending, Payment.Status.Pending)
```
This combines reusable behavior composition with concise domain modeling.
### 3. Unified delegation model (`val`, `var`, `fun`)
```lyng
val state = { "owner": "alice", "limit": 10 }
val owner by state
var limit by state
object RpcDelegate {
fun invoke(thisRef, name, args...) = "$name:${args.size}"
}
fun remoteCall by RpcDelegate
limit = 25
assertEquals(25, state["limit"])
assertEquals("remoteCall:2", remoteCall("alice", 10))
```
Delegation helps build compact, extensible models with minimal boilerplate.
## Rollout Strategy
1. Define the minimal portable storage API (`get/put/find by tags`) as a stable contract.
2. Publish a compatibility profile for metered execution (determinism + gas accounting).
3. Provide chain adapters that map Lyng storage/API calls to each target network.
4. Ship a reference sandbox and conformance tests for runtime parity.
5. Build shared Lyng libraries for common contract and compute patterns.
6. Launch a Lyng package manager and registry (NPM/Maven-style) with first-class support for unified scripts:
- package metadata for runtime profile (metered/off-chain), storage API version, and capability requirements,
- deterministic dependency resolution and lockfiles,
- signed package publishing and verification for supply-chain integrity.
## Existing Resources
- Lyng project repository: https://github.com/sergeych/lyng
- Lyng docs home: https://lynglang.com
- Lyng tutorial: https://lynglang.com/#/docs/tutorial.md
- Embedding Lyng: https://lynglang.com/#/docs/embedding.md
## Conclusion
Lyng can serve as a practical common platform for blockchain contracts and distributed execution by combining:
- portable language semantics,
- safe-by-default host interaction,
- metered deterministic execution when required,
- and reusable code across on-chain and off-chain environments.
This reduces duplication, improves consistency, and shortens delivery cycles for decentralized systems.