Compare commits

...
Author SHA1 Message Date
sergeych 26ddb94f5d fixed version number 2025-11-27 21:42:42 +01:00
sergeych cbca8cacb5 fix #59 implement iterator cancellation on premature termination (break, exception) and ensure no cancellation on natural completion; add tests 2025-11-27 21:40:02 +01:00
sergeych 8fae4709ed fix #61 set +/- collection gives set with/without items in collection 2025-11-27 20:54:33 +01:00
sergeych d118d29429 map literals 2025-11-27 20:02:19 +01:00
sergeych cb9df79ce3 version bump 2025-11-27 15:24:18 +01:00
sergeych d9a26dd467 better lang page (ribbon & tg channel) 2025-11-27 12:51:32 +01:00
sergeych 813ebebddd small optimizations 2025-11-27 11:55:17 +01:00
sergeych 2d721101dd v1.0.1-SNAPSHOT: named args in calls 2025-11-27 11:24:50 +01:00
sergeych d6e6d68b18 benchmaring tests are now optional 2025-11-27 08:34:49 +01:00
sergeych 83825a9272 fixed call arg precedence bug in last arg callable scenario 2025-11-27 08:15:06 +01:00
sergeych f0fc7ddd84 lyng textmate bundle idea-compatible 2025-11-24 18:33:41 +01:00
sergeych ea0ecb1db3 lynglib: added MiniAST support
lyngweb: better syntax highlighting and code editor
2025-11-24 00:38:17 +01:00
sergeych 28b961d339 Add comprehensive documentation for when statement and integrate related tests and references 2025-11-22 20:11:01 +01:00
sergeych 4d1cd491e0 fixed try ling key processing 2025-11-22 20:01:16 +01:00
sergeych 5fbb1d5393 cosmetics 2025-11-22 15:17:12 +01:00
sergeych 9a4131ee3d lyngweb: refactor SiteHighlight to align package structure and add adjustable textarea height 2025-11-22 14:21:45 +01:00
sergeych 391e200f19 readme updates 2025-11-22 01:41:13 +01:00
sergeych 32e739ab8f Add comprehensive embedding guide for Lyng in Kotlin projects 2025-11-22 01:39:51 +01:00
sergeych f4375ad627 lyngweb: add Maven publishing configuration, range iterability tests, and minor editor refinements 2025-11-22 01:22:39 +01:00
sergeych faead76688 lyngweb: introduce reusable editor and highlighting utilities 2025-11-22 00:34:45 +01:00
sergeych 72c6dc2bde fixed wrong line report on throw statement 2025-11-21 17:18:59 +01:00
sergeych a229f227e1 tryling: better error reporting 2025-11-21 09:44:48 +01:00
sergeych 01632dc6d7 tryling: snart mini-editor 2025-11-21 02:00:01 +01:00
sergeych 4e37d0be26 tryling cosmetics 2025-11-21 00:45:18 +01:00
sergeych fa3fda144b added tryling, v0 2025-11-21 00:38:43 +01:00
sergeych 2b320ab52a restructured site: split to separate sources. Search improved 2025-11-20 19:06:15 +01:00
sergeych f1e978599c deployment refined 2025-11-20 01:17:56 +01:00
sergeych 215c7245a0 Enhance site navigation with improved search highlighting, refined navbar offset handling, query-based search term extraction, and additional docs layout adjustments. Also, add new resources and simplify deployment script paths. 2025-11-20 01:00:32 +01:00
sergeych d307ed2a04 inject back button inline for docs' H1 headers, fetch markdown titles progressively, and add deployment script 2025-11-19 23:56:31 +01:00
sergeych b82af3dceb site search 2025-11-19 23:22:12 +01:00
sergeych 1fadc42414 added DocsPage, improved navbar with dynamic height handling, MathJax integration, new TOC features, and extensive markdown processing 2025-11-19 21:52:58 +01:00
sergeych 918534afb5 fixed highlighting and styles 2025-11-19 17:52:48 +01:00
sergeych 646a676b3e added scrollspy for TOC highlighting, theme toggle support, and dark/light mode handling 2025-11-19 14:23:07 +01:00
sergeych f4d1a77496 added extensive markdown rendering tests and Kotlin/JS externals for marked library 2025-11-18 23:10:22 +01:00
sergeych 67e4d76f59 kotlin upgraded, site started 2025-11-18 00:24:19 +01:00
sergeych beb462fd62 readme fix 2025-11-17 15:45:37 +01:00
93 changed files with 10421 additions and 544 deletions

No files matched your search

@@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="lyng:site [jsBrowserDevelopmentRun]" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$/site" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="--continuous" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="jsBrowserDevelopmentRun" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+17
View File
@@ -2,6 +2,23 @@
### Unreleased
- Language: Named arguments and named splats
- New call-site syntax for named arguments using colon: `name: value`.
- Positional arguments must come before named; positionals after a named argument inside parentheses are rejected.
- Trailing-lambda interaction: if the last parameter is already assigned by name (or via a named splat), a trailing `{ ... }` block is illegal.
- Named splats: `...` can now expand a Map into named arguments.
- Only string keys are allowed; non-string keys raise a clear error.
- Duplicate assignment across named args and named splats is an error.
- Ellipsis (variadic) parameters remain positional-only and cannot be named.
- 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?`.
- Documentation updated: proposals and declaring-arguments sections now cover named args/splats and error cases.
- Tests added covering success cases and errors for named args/splats and trailing-lambda interactions.
- Tooling: Highlighters and TextMate bundle updated for named args
- 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:
- Active C3 Method Resolution Order (MRO) for deterministic, monotonic lookup across complex hierarchies and diamonds.
- Qualified dispatch:
+10 -14
View File
@@ -1,6 +1,8 @@
# Lyng: modern scripting for kotlin multiplatform
A KMP library and a standalone interpreter
Please visit the project homepage: [https://lynglang.com](https://lynglang.com)
A KMP library and a standalone interpreter. v1.0.0-SNAPSHOT is now available.
- simple, compact, intuitive and elegant modern code:
@@ -146,7 +148,7 @@ flowchart
```
## v1.0.0 "Make it happen"
## v1.0.0 "Make it happen" - done!
Planned autumn 2025. Complete dynamic language with sufficient standard library:
@@ -171,31 +173,25 @@ Ready features:
- [x] dynamic fields
- [x] function annotations
- [x] better stack reporting
### Under way:
- [x] regular exceptions + extended `when`
- [x] multiple inheritance for user classes
- [ ] site with integrated interpreter to give a try
- [ ] kotlin part public API good docs, integration focused
## plan: v1.0 - v1.5 "Rich and stable"
Estimated spring of 2026
Planned features.
## plan: v1.0 - v1.5 Stability, integration, optimizations
- [x] site with integrated interpreter to give a try
- [x] kotlin part public API good docs, integration focused
- [ ] type specifications
- [ ] language server or compose-based lyng-aware editor
- [ ] source docs and maybe lyng.md to a standard
- [ ] metadata first class access from lyng
- [x] aggressive optimizations
- [ ] JVM bytecode optimizations
## After 1.5 "Ideal scripting"
Estimated winter 2027
Estimated winter 2027
- [ ] client with GUI support based on compose multiplatform somehow
- [ ] notebook - style workbooks with graphs, formulae, etc.
- [ ] aggressive optimizations
[parallelism]: docs/parallelism.md
+105
View File
@@ -0,0 +1,105 @@
#!/bin/bash
#
# Copyright 2025 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.
#
#
function checkState() {
if [[ $? != 0 ]]; then
echo
echo -- rsync failed. deploy was not finished. deployed version has not been affected
echo
exit 100
fi
}
# default target settings
case "com" in
com)
SSH_HOST=sergeych@lynglang.com # host to deploy to
SSH_PORT=22 # ssh port on it
ROOT=/bigstore/sergeych_pub/lyng # directory to rsync to
;;
# com)
# SSH_HOST=vvk@front-01.neurodatalab.com
# ROOT=/home/vvk
# ;;
*)
echo "*** ERROR: target not specified (use deploy com | dev)"
echo "*** stop"
exit 101
esac
die() { echo "ERROR: $*" 1>&2 ; exit 1; }
./gradlew site:clean site:jsBrowserDistribution || die "compilation failed"
if [[ $? != 0 ]]; then
echo
echo -- build failed. deploy aborted.
echo
exit 100
fi
#exit 0
# Prepare working dir
ssh -p ${SSH_PORT} ${SSH_HOST} "
cd ${ROOT}
rm -rd build 2>/dev/null
if [ -d release ]; then
echo copying current release
cp -r release build
else
echo creating first release
mkdir release
mkdir build
fi
";
# sync files
SRC=./site/build/dist/js/productionExecutable
rsync -e "ssh -p ${SSH_PORT}" -avz -r -d --delete ${SRC}/* ${SSH_HOST}:${ROOT}/build/dist
checkState
#rsync -e "ssh -p ${SSH_PORT}" -avz ./static/* ${SSH_HOST}:${ROOT}/build/dist
#checkState
#rsync -e "ssh -p ${SSH_PORT}" -avz -r -d --delete private_data/* ${SSH_HOST}:${ROOT}/build/private_data
#checkState
echo
echo finalizing the deploy...
ssh -p ${SSH_PORT} ${SSH_HOST} "
cd ${ROOT}
rm -rd release~
mv release release~
mv build release
cd release
# in this project we needn't restart back when we deploy the front
# ~/bin/restart_service
";
if [[ $? != 0 ]]; then
echo
echo -- finalization failed. the rease might be corrupt. rolling back is not yet implemented.
echo
exit 100
fi
echo
echo Deploy successful
echo
+3
View File
@@ -9,6 +9,9 @@ To implement the iterator you need to implement only two abstract methods:
### hasNext(): Bool
// lets test
// offset
Should return `true` if call to `next()` will return valid next element.
### next(): Obj
+73 -8
View File
@@ -1,19 +1,22 @@
# Map
Map is a mutable collection of key-value pars, where keys are unique. Maps could be created with
constructor or `.toMap` methods. When constructing from a list, each list item must be a [Collection] with exactly 2 elements, for example, a [List].
Map is a mutable collection of key-value pairs, where keys are unique. You can create maps in two ways:
- with the constructor `Map(...)` or `.toMap()` helpers; and
- with map literals using braces: `{ "key": value, id: expr, id: }`.
When constructing from a list, each list item must be a [Collection] with exactly 2 elements, for example, a [List].
Important thing is that maps can't contain `null`: it is used to return from missing elements.
Constructed map instance is of class `Map` and implements `Collection` (and therefore `Iterable`)
Constructed map instance is of class `Map` and implements `Collection` (and therefore `Iterable`).
val map = Map( "foo" => 1, "bar" => "buzz" )
assert(map is Map)
assert(map.size == 2)
assert(map is Iterable)
val oldForm = Map( "foo" => 1, "bar" => "buzz" )
assert(oldForm is Map)
assert(oldForm.size == 2)
assert(oldForm is Iterable)
>>> void
Notice usage of the `=>` operator that creates `MapEntry`, which implements also [Collection] of
Notice usage of the `=>` operator that creates `MapEntry`, which also implements [Collection] of
two items, first, at index zero, is a key, second, at index 1, is the value. You can use lists too.
Map keys could be any objects (hashable, e.g. with reasonable hashCode, most of standard types are). You can access elements with indexing operator:
@@ -29,6 +32,36 @@ Map keys could be any objects (hashable, e.g. with reasonable hashCode, most of
assert( map["foo"] == -1)
>>> void
## Map literals { ... }
Lyng supports JavaScript-like map literals. Keys can be string literals or identifiers, and there is a handy identifier shorthand:
- String key: `{ "a": 1 }`
- Identifier key: `{ foo: 2 }` is the same as `{ "foo": 2 }`
- Identifier shorthand: `{ foo: }` is the same as `{ "foo": foo }`
Access uses brackets: `m["a"]`.
val x = 10
val y = 10
val m = { "a": 1, x: x * 2, y: }
assertEquals(1, m["a"]) // string-literal key
assertEquals(20, m["x"]) // identifier key
assertEquals(10, m["y"]) // identifier shorthand expands to y: y
>>> void
Trailing commas are allowed for nicer diffs and multiline formatting:
val m = {
"a": 1,
b: 2,
}
assertEquals(1, m["a"])
assertEquals(2, m["b"])
>>> void
Empty `{}` is reserved for blocks/lambdas; use `Map()` for an empty map.
To remove item from the collection. use `remove`. It returns last removed item or null. Be careful if you
hold nulls in the map - this is not a recommended practice when using `remove` returned value. `clear()`
removes all.
@@ -110,4 +143,36 @@ is equal.
assert( m1 != m3 )
>>> void
## Spreads and merging
Inside map literals you can spread another map with `...` and items will be merged left-to-right; rightmost wins:
val base = { a: 1, b: 2 }
val m = { a: 0, ...base, b: 3, c: 4 }
assertEquals(1, m["a"]) // base overwrites a:0
assertEquals(3, m["b"]) // literal overwrites spread
assertEquals(4, m["c"]) // new key
>>> void
Maps and entries can also be merged with `+` and `+=`:
val m1 = ("x" => 1) + ("y" => 2)
assertEquals(1, m1["x"])
assertEquals(2, m1["y"])
val m2 = { "a": 10 } + ("b" => 20)
assertEquals(10, m2["a"])
assertEquals(20, m2["b"])
var m3 = { a: 1 }
m3 += ("b" => 2)
assertEquals(1, m3["a"])
assertEquals(2, m3["b"])
>>> void
Notes:
- Map literals always use string keys (identifier keys are converted to strings).
- Spreads inside map literals and `+`/`+=` merges require string keys on the right-hand side; this aligns with named-argument splats.
- When you need computed or non-string keys, use the constructor form `Map(...)` or build entries with `=>` and then merge.
[Collection](Collection.md)
-24
View File
@@ -1,24 +0,0 @@
# Classes
## Declaring
class Foo1
class Foo2() // same, empty constructor
class Foo3() { // full
}
class Foo4 { // Only body
}
```
class_declaration = ["abstract",] "class" [, constructor] [, body]
constructor = "(", [field [, field]] ")
field = [visibility ,] [access ,] name [, typedecl]
body = [visibility] ("var", vardecl) | ("val", vardecl) | ("fun", fundecl)
visibility = "private" | "protected" | "internal"
```
### Abstract classes
Contain one pr more abstract methods which must be implemented; though they
can have constructors, the instances of the abstract classes could not be
created independently
+56 -1
View File
@@ -5,7 +5,7 @@ lambdas and class declarations.
## Regular
## default values
## Default values
Default parameters should not be mixed with mandatory ones:
@@ -95,6 +95,61 @@ There could be any number of splats at any positions. You can splat any other [I
testSplat("start", ...range, "end")
>>> [start,1,2,3,end]
>>> void
## Named arguments in calls
Lyng supports named arguments at call sites using colon syntax `name: value`:
```lyng
fun test(a="foo", b="bar", c="bazz") { [a, b, c] }
assertEquals(["foo", "b", "bazz"], test(b: "b"))
assertEquals(["a", "bar", "c"], test("a", c: "c"))
```
Rules:
- Named arguments must follow positional arguments. After the first named argument, no positional arguments may appear inside the parentheses.
- The only exception is the syntactic trailing block after the call: `f(args) { ... }`. This block is outside the parentheses and is handled specially (see below).
- A named argument cannot reassign a parameter already set positionally.
- If the last parameter has already been assigned by a named argument (or named splat), a trailing block is not allowed and results in an error.
Why `:` and not `=` at call sites? In Lyng, `=` is an expression (assignment), so we use `:` to avoid ambiguity. Declarations continue to use `:` for types, while call sites use `as` / `as?` for type operations.
## Named splats (map splats)
Splat (`...`) of a Map provides named arguments to the call. Only string keys are allowed:
```lyng
fun test(a="a", b="b", c="c", d="d") { [a, b, c, d] }
val r = test("A?", ...Map("d" => "D!", "b" => "B!"))
assertEquals(["A?","B!","c","D!"], r)
```
The same with a map literal is often more concise. Define the literal, then splat the variable:
fun test(a="a", b="b", c="c", d="d") { [a, b, c, d] }
val patch = { d: "D!", b: "B!" }
val r = test("A?", ...patch)
assertEquals(["A?","B!","c","D!"], r)
>>> void
Constraints:
- Map splat keys must be strings; otherwise, a clean error is thrown.
- Named splats cannot duplicate parameters already assigned (by positional or named arguments).
- Named splats must follow all positional arguments and positional splats.
- Ellipsis parameters (variadic) remain positional-only and cannot be assigned by name.
## Trailing-lambda rule interaction
If a call is immediately followed by a block `{ ... }`, it is treated as an extra last argument and bound to the last parameter. However, if the last parameter is already assigned by a named argument or a named splat, using a trailing block is an error:
```lyng
fun f(x, onDone) { onDone(x) }
f(x: 1) { 42 } // ERROR
f(1) { 42 } // OK
```
[tutorial]: tutorial.md
+2
View File
@@ -1,3 +1,5 @@
[//]: # (excludeFromIndex)
# String
# This document is for developer notes only
+2
View File
@@ -1,5 +1,7 @@
# Modules inclusion
[//]: # (excludeFromIndex)
Module is, at the low level, a statement that modifies a given context by adding
here local and exported symbols, performing some tasks and even returning some value
we don't need for now.
+1
View File
@@ -1,3 +1,4 @@
[//]: # (excludeFromIndex)
Provide:
+236
View File
@@ -0,0 +1,236 @@
# Embedding Lyng in your Kotlin project
Lyng is a tiny, embeddable, Kotlin‑first scripting language. This page shows, step by step, how to:
- add Lyng to your build
- create a runtime and execute scripts
- define functions and variables from Kotlin
- read variable values back in Kotlin
- call Lyng functions from Kotlin
- create your own packages and import them in Lyng
All snippets below use idiomatic Kotlin and rely on Lyng public APIs. They work on JVM and other Kotlin Multiplatform targets supported by `lynglib`.
Note: all Lyng APIs shown are `suspend`, because script evaluation is coroutine‑friendly and can suspend.
### 1) Add Lyng to your build
Add the repository where you publish Lyng artifacts and the dependency on the core library `lynglib`.
Gradle Kotlin DSL (build.gradle.kts):
```kotlin
repositories {
// Your standard repos
mavenCentral()
// If you publish to your own Maven (example: Gitea packages). Adjust URL/token as needed.
maven(url = uri("https://gitea.sergeych.net/api/packages/SergeychWorks/maven"))
}
dependencies {
// Multiplatform: place in appropriate source set if needed
implementation("net.sergeych:lynglib:1.0.0-SNAPSHOT")
}
```
If you use Kotlin Multiplatform, add the dependency in the `commonMain` source set (and platform‑specific sets if you need platform APIs).
### 2) Create a runtime (Scope) and execute scripts
The easiest way to get a ready‑to‑use scope with standard packages is via `Script.newScope()`.
```kotlin
fun main() = kotlinx.coroutines.runBlocking {
val scope = Script.newScope() // suspends on first init
// Evaluate a one‑liner
val result = scope.eval("1 + 2 * 3")
println("Lyng result: $result") // ObjReal/ObjInt etc.
}
```
You can also pre‑compile a script and execute it multiple times:
```kotlin
val script = Compiler.compile("""
// any Lyng code
val x = 40 + 2
x
""")
val run1 = script.execute(scope)
val run2 = script.execute(scope)
```
`Scope.eval("...")` is a shortcut that compiles and executes on the given scope.
### 3) Define variables from Kotlin
To expose data to Lyng, add constants (read‑only) or mutable variables to the scope. All values in Lyng are `Obj` instances; the core types live in `net.sergeych.lyng.obj`.
```kotlin
// Read‑only constant
scope.addConst("pi", ObjReal(3.14159))
// Mutable variable: create or update
scope.addOrUpdateItem("counter", ObjInt(0))
// Use it from Lyng
scope.eval("counter = counter + 1")
```
Tip: Lyng values can be converted back to Kotlin with `toKotlin(scope)`:
```kotlin
val current = (scope.eval("counter")).toKotlin(scope) // Any? (e.g., Int/Double/String/List)
```
### 4) Add Kotlin‑backed functions
Use `Scope.addFn`/`addVoidFn` to register functions implemented in Kotlin. Inside the lambda, use `this.args` to access arguments and return an `Obj`.
```kotlin
// A function returning value
scope.addFn<ObjInt>("inc") {
val x = args.firstAndOnly() as ObjInt
ObjInt(x.value + 1)
}
// A void function (returns Lyng Void)
scope.addVoidFn("log") {
val items = args.list // List<Obj>
println(items.joinToString(" ") { it.toString(this).value })
}
// Call them from Lyng
scope.eval("val y = inc(41); log('Answer:', y)")
```
You can register multiple names (aliases) at once: `addFn<ObjInt>("inc", "increment") { ... }`.
### 5) Read variable values back in Kotlin
The simplest approach: evaluate an expression that yields the value and convert it.
```kotlin
val kotlinAnswer = scope.eval("(1 + 2) * 3").toKotlin(scope) // -> 9 (Int)
// After scripts manipulate your vars:
scope.addOrUpdateItem("name", ObjString("Lyng"))
scope.eval("name = name + ' rocks!'")
val kotlinName = scope.eval("name").toKotlin(scope) // -> "Lyng rocks!"
```
Advanced: you can also grab a variable record directly via `scope.get(name)` and work with its `Obj` value, but evaluating `"name"` is often clearer and enforces Lyng semantics consistently.
### 6) Execute scripts with parameters; call Lyng functions from Kotlin
There are two convenient patterns.
1) Evaluate a Lyng call expression directly:
```kotlin
// Suppose Lyng defines: fun add(a, b) = a + b
scope.eval("fun add(a, b) = a + b")
val sum = scope.eval("add(20, 22)").toKotlin(scope) // -> 42
```
2) Call a Lyng function by name via a prepared call scope:
```kotlin
// Ensure the function exists in the scope
scope.eval("fun add(a, b) = a + b")
// Look up the function object
val addFn = scope.get("add")!!.value as Statement
// Create a child scope with arguments (as Lyng Objs)
val callScope = scope.createChildScope(
args = Arguments(listOf(ObjInt(20), ObjInt(22)))
)
val resultObj = addFn.execute(callScope)
val result = resultObj.toKotlin(scope) // -> 42
```
If you need to pass complex data (lists, maps), construct the corresponding Lyng `Obj` types (`ObjList`, `ObjMap`, etc.) and pass them in `Arguments`.
### 7) Create your own packages and import them in Lyng
Lyng supports packages that are imported from scripts. You can register packages programmatically via `ImportManager` or by providing source texts that declare `package ...`.
Key concepts:
- `ImportManager` holds package registrations and lazily builds `ModuleScope`s when first imported.
- Every `Scope` has `currentImportProvider` and (if it’s an `ImportManager`) a convenience `importManager` to register packages.
Register a Kotlin‑built package:
```kotlin
val scope = Script.newScope()
// Access the import manager behind this scope
val im: ImportManager = scope.importManager
// Register a package "my.tools"
im.addPackage("my.tools") { module: ModuleScope ->
// Expose symbols inside the module scope
module.addConst("version", ObjString("1.0"))
module.addFn<ObjInt>("triple") {
val x = args.firstAndOnly() as ObjInt
ObjInt(x.value * 3)
}
}
// Use it from Lyng
scope.eval("""
import my.tools.*
val v = triple(14)
""")
val v = scope.eval("v").toKotlin(scope) // -> 42
```
Register a package from Lyng source text:
```kotlin
val pkgText = """
package math.extra
fun sqr(x) = x * x
""".trimIndent()
scope.importManager.addTextPackages(pkgText)
scope.eval("""
import math.extra.*
val s = sqr(12)
""")
val s = scope.eval("s").toKotlin(scope) // -> 144
```
You can also register from parsed `Source` instances via `addSourcePackages(source)`.
### 8) Executing from files, security, and isolation
- To run code from a file, read it and pass to `scope.eval(text)` or compile with `Compiler.compile(Source(fileName, text))`.
- `ImportManager` takes an optional `SecurityManager` if you need to restrict what packages or operations are available. By default, `Script.defaultImportManager` allows everything suitable for embedded use; clamp it down in sandboxed environments.
- For isolation, create fresh modules/scopes via `Scope.new()` or `Script.newScope()` when you need a clean environment per request.
```kotlin
// Fresh module based on the default manager, without the standard prelude
val isolated = net.sergeych.lyng.Scope.new()
```
### 9) Tips and troubleshooting
- All values that cross the boundary must be Lyng `Obj` instances. Convert Kotlin values explicitly (e.g., `ObjInt`, `ObjReal`, `ObjString`).
- Use `toKotlin(scope)` to get Kotlin values back. Collections convert to Kotlin collections recursively.
- Most public API in Lyng is suspendable. If you are not already in a coroutine, wrap calls in `runBlocking { ... }` on the JVM for quick tests.
- When registering packages, names must be unique. Register before you compile/evaluate scripts that import them.
- To debug scope content, `scope.toString()` and `scope.trace()` can help during development.
---
That’s it. You now have Lyng embedded in your Kotlin app: you can expose your app’s API, evaluate user scripts, and organize your own packages to import from Lyng code.
+2
View File
@@ -1,6 +1,8 @@
This document explains how to enable and measure the performance optimizations added to the Lyng interpreter. The focus is JVM‑first with safe, flag‑guarded rollouts and quick A/B testing. Other targets (JS/Wasm/Native) keep conservative defaults until validated.
[//]: # (excludeFromIndex)
## Overview
Optimizations are controlled by runtime‑mutable flags in `net.sergeych.lyng.PerfFlags`, initialized from platform‑specific static defaults `net.sergeych.lyng.PerfDefaults` (KMP `expect/actual`).
+2
View File
@@ -1,5 +1,7 @@
# JVM-only Performance Optimization Plan (Saved)
[//]: # (excludeFromIndex)
Date: 2025-11-10 22:14 (local)
This document captures the agreed next optimization steps so we can restore the plan later if needed.
+93
View File
@@ -0,0 +1,93 @@
# Map literals — refined proposal
Implement JavaScript-like literals for maps. The syntax and semantics align with named arguments in function calls, but map literals are expressions that construct `Map` values.
Keys can be either:
- string literals: `{ "some key": value }`, or
- identifiers: `{ name: expr }`, where the key becomes the string `"name"`.
Identifier shorthand inside map literals is supported:
- `{ name: }` desugars to `{ "name": name }`.
Property access sugar is not provided for maps: use bracket access only, e.g. `m["a"]`, not `m.a`.
Examples:
```lyng
val x = 2
val m = { "a": 1, x: x*10, y: }
assertEquals(1, m["a"]) // string-literal key
assertEquals(20, m["x"]) // identifier key
assertEquals(2, m["y"]) // identifier shorthand
```
Spreads (splats) in map literals are allowed and merged left-to-right with “rightmost wins” semantics:
```lyng
val base = { a: 1, b: 2 }
val m = { a: 0, ...base, b: 3, c: 4 }
assertEquals(1, m["a"]) // base overwrites a:0
assertEquals(3, m["b"]) // literal overwrites spread
assertEquals(4, m["c"]) // new key
```
Trailing commas are allowed (optional):
```lyng
val m = {
"a": 1,
b: 2,
...other,
}
```
Duplicate keys among literal entries (including identifier shorthand) are a compile-time error:
```lyng
{ foo: 1, "foo": 2 } // error: duplicate key "foo"
{ foo:, foo: 2 } // error: duplicate key "foo"
```
Spreads are evaluated at runtime. Overlaps from spreads are resolved by last write wins. If a spread is not a map, or yields a map with non-string keys, it’s a runtime error.
Merging with `+`/`+=` and entries:
```lyng
("1" => 10) + ("2" => 20) // Map("1"=>10, "2"=>20)
{ "1": 10 } + ("2" => 20) // same
{ "1": 10 } + { "2": 20 } // same
var m = { "a": 1 }
m += ("b" => 2) // m = { "a":1, "b":2 }
```
Rightmost wins on duplicates consistently across spreads and merges. All map merging operations require string keys; encountering a non-string key during merge is a runtime error.
Empty map literal `{}` is not supported to avoid ambiguity with blocks/lambdas. Use `Map()` for an empty map.
Lambda disambiguation
- A `{ ... }` with typed lambda parameters must have a top-level `->` in its header. The compiler disambiguates by looking for a top-level `->`. If none is found, it attempts to parse a map literal; if that fails, it is parsed as a lambda or block.
Grammar (EBNF)
```
ws = zero or more whitespace (incl. newline/comments)
map_literal = '{' ws map_entries ws '}'
map_entries = map_entry ( ws ',' ws map_entry )* ( ws ',' )?
map_entry = map_key ws ':' ws map_value_opt
| '...' ws expression
map_key = string_literal | ID
map_value_opt = expression | ε // ε allowed only if map_key is ID
```
Notes:
- Identifier shorthand (`id:`) is allowed only for identifiers, not string-literal keys.
- Spreads accept any expression; at runtime it must yield a `Map` with string keys.
- Duplicate keys are detected at compile time among literal keys; spreads are merged at runtime with last-wins.
Rationale
- The `{ name: value }` style is familiar and ergonomic.
- Disambiguation with lambdas leverages the required `->` in typed lambda headers.
- Avoiding `m.a` sidesteps method/field shadowing and keeps semantics clear.
+67
View File
@@ -0,0 +1,67 @@
# Named arguments proposal
Extend function/method calls to allow setting arguments by name using colon syntax at call sites. This is especially convenient with many parameters and default values.
Examples:
```lyng
fun test(a="foo", b="bar", c="bazz") { [a, b, c] }
assertEquals(test(b: "b"), ["foo", "b", "bazz"])
assertEquals(test("a", c: "c"), ["a", "bar", "c"])
```
Rules:
- Named arguments are optional. If named arguments are present, their order is not important.
- Named arguments must follow positional arguments; positional arguments cannot follow named ones (the only exception is the syntactic trailing block outside parentheses, see below).
- A named argument cannot reassign a parameter already set positionally.
- If the last parameter is already assigned by a named argument (or named splat), the trailing-lambda rule must NOT apply: a following `{ ... }` after the call is an error.
Rationale for using `:` instead of `=` in calls: in Lyng, assignment `=` is an expression; using `:` avoids ambiguity and keeps declarations (`name: Type`) distinct from call sites, where casting uses `as` / `as?`.
Migration note: earlier drafts/examples used `name = value`. The final syntax is `name: value` at call sites.
## Extended call argument splats: named splats
With named arguments, splats (`...`) are extended to support maps as named splats. When a splat evaluates to a Map, its entries provide name→value assignments:
```lyng
fun test(a="a", b="b", c="c", d="d") { [a, b, c, d] }
assertEquals(test("A?", ...Map("d" => "D!", "b" => "B!")), ["A?", "B!", "c", "D!"])
```
Constraints for named splats:
- Only string keys are allowed in map splats; otherwise, a clean error is thrown.
- Named splats cannot reassign parameters already set (positionally or by earlier named arguments/splats).
- Named splats follow the same ordering as named arguments: they must appear after all positional arguments and positional splats.
## Trailing-lambda interaction
Lyng supports a syntactic trailing block after a call: `f(args) { ... }`. With named args/splats, if the last parameter is already assigned by name, the trailing block must not apply and the call is an error:
```lyng
fun f(x, onDone) { onDone(x) }
f(x: 1) { 42 } // ERROR: last parameter already assigned by name
f(1) { 42 } // OK
```
## Errors (non-exhaustive)
- Positional argument after any named argument inside parentheses: error.
- Positional splat after any named argument: error.
- Duplicate named assignment (directly or via map splats): error.
- Unknown parameter name in a named argument/splat: error.
- Map splat with non-string keys: error.
- Attempt to target the ellipsis parameter by name: error.
## Notes
- Declarations continue to use `:` for types, while call sites use `:` for named arguments and `as` / `as?` for type casts/checks.
Regular → Executable
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/env lyng
/*
Calculate the limit of Sum( f(n) )
until it reaches asymptotic limit 0.00001% change
return null or found limit
*/
fun findSumLimit(f) {
+77 -5
View File
@@ -1,3 +1,22 @@
# Lyng tutorial
Lyng is a very simple language, where we take only most important and popular features from
other scripts and languages. In particular, we adopt _principle of minimal confusion_[^1].
In other word, the code usually works as expected when you see it. So, nothing unusual.
__Other documents to read__ maybe after this one:
- [Advanced topics](advanced_topics.md), [declaring arguments](declaring_arguments.md)
- [OOP notes](OOP.md), [exception handling](exceptions_handling.md)
- [math in Lyng](math.md), [the `when` statement](when.md)
- [time](time.md) and [parallelism](parallelism.md)
- [parallelism] - multithreaded code, coroutines, etc.
- Some class
references: [List], [Set], [Map], [Real], [Range], [Iterable], [Iterator], [time manipulation](time.md), [Array], [RingBuffer], [Buffer].
- Some samples: [combinatorics](samples/combinatorics.lyng.md), national vars and
loops: [сумма ряда](samples/сумма_ряда.lyng.md). More at [samples folder](samples)
# Expressions
Everything is an expression in Lyng. Even an empty block:
@@ -657,14 +676,63 @@ Please see [Set] for detailed description.
# Maps
Maps are unordered collection of key-value pairs, where keys are unique. See [Map] for details. Map also
are [Iterable]:
Maps are unordered collections of key-value pairs, where keys are unique. Maps are also [Iterable].
val m = Map( "foo" => 77, "bar" => "buzz" )
assertEquals( m["foo"], 77 )
You can create them either with the classic constructor (still supported):
val old = Map( "foo" => 77, "bar" => "buzz" )
assertEquals( old["foo"], 77 )
>>> void
Please see [Map] reference for detailed description on using Maps.
…or with map literals, which are often more convenient:
val x = 10
val y = 10
val m = { "a": 1, x: x * 2, y: }
// identifier keys become strings; `y:` is shorthand for y: y
assertEquals(1, m["a"]) // string-literal key
assertEquals(20, m["x"]) // identifier key
assertEquals(10, m["y"]) // shorthand
>>> void
Map literals support trailing commas for nicer diffs:
val m2 = {
"a": 1,
b: 2,
}
assertEquals(1, m2["a"])
assertEquals(2, m2["b"])
>>> void
You can spread other maps inside a literal with `...`. Items merge left-to-right and the rightmost value wins:
val base = { a: 1, b: 2 }
val merged = { a: 0, ...base, b: 3, c: 4 }
assertEquals(1, merged["a"]) // base overwrites a:0
assertEquals(3, merged["b"]) // literal overwrites spread
assertEquals(4, merged["c"]) // new key
>>> void
Merging also works with `+` and `+=`, and you can combine maps and entries conveniently:
val m3 = { "a": 10 } + ("b" => 20)
assertEquals(10, m3["a"])
assertEquals(20, m3["b"])
var m4 = ("x" => 1) + ("y" => 2) // entry + entry → Map
m4 += { z: 3 } // merge literal
assertEquals(1, m4["x"])
assertEquals(2, m4["y"])
assertEquals(3, m4["z"])
>>> void
Notes:
- Access keys with brackets: `m["key"]`. There is no `m.key` sugar.
- Empty `{}` remains a block/lambda; use `Map()` to create an empty map.
- When you need computed (expression) keys or non-string keys, use `Map(...)` constructor with entries, e.g. `Map( ("a" + "b") => 1 )`, then merge with a literal if needed: `{ base: } + (computedKey => 42)`.
Please see the [Map] reference for a deeper guide.
# Flow control operators
@@ -693,6 +761,8 @@ Or, more neat:
## When
See also: [Comprehensive guide to `when`](when.md)
It is very much like the kotlin's:
fun type(x) {
@@ -1465,3 +1535,5 @@ Notes:
- Resolution order uses C3 MRO (active): deterministic, monotonic order suitable for diamonds and complex hierarchies. Example: for `class D() : B(), C()` where both `B()` and `C()` derive from `A()`, the C3 order is `D → B → C → A`. The first visible match wins.
- `private` is visible only inside the declaring class; `protected` is visible from the declaring class and any of its transitive subclasses. Qualification (`this@Type`) or casts do not bypass visibility.
- Safe‑call `?.` works with `as?` for optional dispatch.
To get details on OOP in Lyng, see [OOP notes](oop.md).
+239
View File
@@ -0,0 +1,239 @@
# The `when` statement (expression)
Lyng provides a concise multi-branch selection with `when`, heavily inspired by Kotlin. In Lyng, `when` is an expression: it evaluates to a value. If the selected branch contains no value (e.g., it ends with `void` or calls a void function like `println`), the whole `when` expression evaluates to `void`.
Currently, Lyng implements the "subject" form: `when(value) { ... }`. The subject-less form `when { condition -> ... }` is not implemented yet.
## Quick examples
val r1 = when("a") {
"a" -> "ok"
else -> "nope"
}
assertEquals("ok", r1)
val r2 = when(5) {
3 -> "no"
4 -> "no"
else -> "five"
}
assertEquals("five", r2)
val r3 = when(5) {
3 -> "no"
4 -> "no"
}
// no matching case and no else → `void`
assert(r3 == void)
>>> void
## Syntax
when(subject) {
condition1 [, condition2, ...] -> resultExpressionOrBlock
conditionN -> result
else -> fallback
}
- Commas group multiple conditions for one branch.
- First matching branch wins; there is no fall‑through.
- `else` is optional. If omitted and nothing matches, the result is `void`.
## Matching rules (conditions)
Within `when(subject)`, each condition is evaluated against the already evaluated `subject`. Lyng supports:
1) Equality match (default)
- Any expression value can be used as a condition. It matches if it is equal to `subject`.
- Equality relies on Lyng’s comparison (`compareTo(...) == 0`). For user types, implement comparison accordingly.
when(x) {
0 -> "zero"
"EUR" -> "currency"
}
>>> void
2) Type checks: `is` and `!is`
- Check whether the subject is an instance of a class.
- Works with built‑in classes and user classes.
fun typeOf(x) {
when(x) {
is Real, is Int -> "number"
is String -> "string"
else -> "other"
}
}
assertEquals("number", typeOf(5))
assertEquals("string", typeOf("hi"))
>>> void
3) Containment checks: `in` and `!in`
- `in container` matches if `container.contains(subject)` is true.
- `!in container` matches if `contains(subject)` is false.
- Any object that provides `contains(item)` can act as a container.
Common containers:
- Ranges (e.g., `'a'..'z'`, `1..10`, `1..<10`, `..5`, `5..`)
- Lists, Sets, Arrays, Buffers
- Strings (character or substring containment)
Examples:
when('e') {
in 'a'..'c' -> "small"
in 'a'..'z' -> "letter"
else -> "other"
}
>>> "letter"
when(5) {
in [1,2,3,4,6] -> "no"
in [7,0,9] -> "no"
else -> "ok"
}
>>> "ok"
when(5) {
in [1,2,3,4,6] -> "no"
in [7,0,9] -> "no"
in [-1,5,11] -> "yes"
else -> "no"
}
>>> "yes"
when(5) {
!in [1,2,3,4,6,5] -> "no"
!in [7,0,9,5] -> "no"
!in [-1,15,11] -> "ok"
else -> "no"
}
>>> "ok"
// String containment
"foo" in "foobar" // true (substring)
'o' in "foobar" // true (character)
>>> true
Notes on mixed String/Char ranges:
- Prefer character ranges for characters: `'a'..'z'`.
- `"a".."z"` is a String range and may not behave as you expect with `Char` subjects.
assert( "more" in "a".."z")
assert( 'x' !in "a".."z") // Char vs String range: often not what you want
assert( 'x' in 'a'..'z') // OK
assert( "x" !in 'a'..'z') // String in Char range: likely not intended
>>> void
## Grouping multiple conditions with commas
You can group values and/or `is`/`in` checks for a single result:
fun classify(x) {
when(x) {
"42", 42 -> "answer"
is Real, is Int -> "number"
in ['@', '#', '^'] -> "punct1"
in "*&.," -> "punct2"
else -> "unknown"
}
}
assertEquals("number", classify(π/2))
assertEquals("answer", classify(42))
assertEquals("answer", classify("42"))
>>> void
## Return value and blocks
- `when` returns the value of the matched branch result expression/block.
- Branch bodies can be single expressions or blocks `{ ... }`.
- If a matched branch produces `void` (e.g., only prints), the `when` result is `void`.
val res = when(2) {
1 -> 10
2 -> { println("two"); 20 }
else -> 0
}
assertEquals(20, res)
>>> void
## Else branch
- Optional but recommended when non‑exhaustive.
- If omitted and nothing matches, `when` result is `void` (see r3 in the Quick examples).
- Only one `else` is allowed.
## Subject‑less `when`
The Kotlin‑style subject‑less form `when { condition -> ... }` is not implemented yet in Lyng. Use `if/else` chains or structure your checks around a subject with `when(subject) { ... }`.
## Extending `when` for your own types
### Equality matches
- Equality checks in `when(subject)` use Lyng comparison (`compareTo` semantics under the hood). For your own Lyng classes, implement comparison appropriately so that `subject == value` works as intended.
### `in` / `!in` containment
- Provide a `contains(item)` method on your class to participate in `in` conditions.
- Example: a custom `Box` that contains one specific item:
class Box(val item)
fun Box.contains(x) { x == item }
val b = Box(10)
when(10) { in b -> "hit" }
>>> "hit"
Any built‑in collection (`List`, `Set`, `Array`), `Range`, `Buffer`, and other containers already implement `contains`.
### Type checks (`is` / `!is`)
- Every value has a `::class` that yields its Lyng class object, e.g. `[1,2,3]::class` → `List`.
- `is ClassName` in `when` uses Lyng’s class hierarchy. Ensure your class is declared and can be referenced by name.
[]::class == List
>>> true
fun f(x) { when(x) { is List -> "list" else -> "other" } }
assertEquals("list", f([1]))
>>> void
## Kotlin‑backed classes (embedding)
When embedding Lyng in Kotlin, you may expose Kotlin‑backed objects and classes. Interactions inside `when` work as follows:
- `is` checks use the Lyng class object you expose for your Kotlin type. Ensure your exposed class participates in the Lyng class hierarchy (see Embedding docs).
- `in` checks call `contains(subject)`; if your Kotlin‑backed object wants to support `in`, expose a `contains(item)` method (mapped to Lyng) or implement the corresponding Lyng container wrapper.
- Equality follows Lyng comparison rules. Ensure your Kotlin‑backed object’s Lyng adapter implements equality/compare correctly.
For details on exposing classes/methods from Kotlin, see: [Embedding Lyng in your Kotlin project](embedding.md).
## Gotchas and tips
- First match wins; there is no fall‑through. Order branches carefully.
- Group related conditions with commas for readability and performance (a single branch evaluation).
- Prefer character ranges for character tests; avoid mixing `String` and `Char` ranges.
- If you rely on `in`, check that your container implements `contains(item)`.
- Remember: `when` is an expression — you can assign its result to a variable or return it from a function.
## Additional examples
fun label(ch) {
when(ch) {
in '0'..'9' -> "digit"
in 'a'..'z', in 'A'..'Z' -> "letter"
'$' -> "dollar"
else -> "other"
}
}
assertEquals("digit", label('3'))
assertEquals("dollar", label('$'))
>>> void
fun normalize(x) {
when(x) {
is Int -> x
is Real -> x.round()
else -> 0
}
}
assertEquals(12, normalize(12))
assertEquals(3, normalize(2.6))
>>> void
+68
View File
@@ -0,0 +1,68 @@
Lyng TextMate grammar
======================
This folder contains a TextMate grammar for the Lyng language so you can get syntax highlighting quickly in:
- JetBrains IDEs (IntelliJ IDEA, Fleet, etc.) via “TextMate Bundles”
- VS Code (via “Install from VSIX” or by adding as an extension folder)
Files
-----
- `package.json` — VS Code–style wrapper that JetBrains IDEs can import as a TextMate bundle.
- `syntaxes/lyng.tmLanguage.json` — the grammar. It highlights:
- Line and block comments (`//`, `/* */`)
- Shebang line at file start (`#!...`)
- Strings: single and double quotes with escapes
- Char literals `'x'` with escapes
- Numbers: decimal with underscores and exponents, and hex (`0x...`)
- Keywords (control and declarations), boolean operator words (`and`, `or`, `not`, `in`, `is`, `as`, `as?`)
- Composite textual operators: `not in`, `not is`
- Constants: `true`, `false`, `null`, `this`
- Annotations: `@name` (Unicode identifiers supported)
- Labels: `name:` (Unicode identifiers supported)
- Declarations: highlights declared names in `fun|fn name`, `class|enum Name`, `val|var name`
- Types: built-ins (`Int|Real|String|Bool|Char|Regex`) and Capitalized identifiers (heuristic)
- Operators including ranges (`..`, `..<`, `...`), null-safe (`?.`, `?[`, `?(`, `?{`, `?:`, `??`), arrows (`->`, `=>`, `::`), match operators (`=~`, `!~`), bitwise, arithmetic, etc.
- Shuttle operator `<=>`
- Division operator `/` (note: Lyng has no regex literal syntax; `/` is always division)
- Named arguments at call sites `name: value` (the `name` part is highlighted as `variable.parameter.named.lyng` and the `:` as punctuation). The rule is anchored to `(` or `,` and excludes `::` to avoid conflicts.
Install in IntelliJ IDEA (and other JetBrains IDEs)
---------------------------------------------------
1. Open Settings / Preferences → Editor → TextMate Bundles.
2. Click “+” and select this folder `editors/lyng-textmate/` (the folder that contains `package.json`).
3. Ensure `*.lyng` is associated with the Lyng grammar (IntelliJ usually picks this up from `fileTypes`).
4. Optional: customize colors with Settings → Editor → Color Scheme → TextMate.
Enable Markdown code-fence highlighting in IntelliJ
--------------------------------------------------
1. Settings / Preferences → Languages & Frameworks → Markdown → Code style → Code fences → Languages.
2. Add mapping: language id `lyng` → “Lyng (TextMate)”.
3. Now blocks like
```
```lyng
// Lyng code here
```
```
will be highlighted.
Install in VS Code
------------------
Fastest local install without packaging:
1. Copy or symlink this folder somewhere stable (or keep it in your workspace).
2. Use “Developer: Install Extension from Location…” (Insiders) or package with `vsce package` and install the resulting `.vsix`.
3. VS Code will auto-associate `*.lyng` via this extension; if needed, check File Associations.
Notes and limitations
---------------------
- Type highlighting is heuristic (Capitalized identifiers). The IntelliJ plugin will use language semantics and avoid false positives.
- If your language adds or changes tokens, please update patterns in `lyng.tmLanguage.json`. The Kotlin sources in `lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/` are a good reference for token kinds.
- Labels `name:` at statement level remain supported and are kept distinct from named call arguments by context. The grammar prefers named-argument matching when a `name:` appears right after `(` or `,`.
Lyng specifics
--------------
- There are no regex literal tokens in Lyng at the moment; the slash character `/` is always treated as the division operator. The grammar intentionally does not define a `/.../` regex rule to avoid mis-highlighting lines like `a / b`.
Contributing
------------
Pull requests to refine patterns and add tests/samples are welcome. You can place test snippets in `sample_texts/` and visually verify.
+25
View File
@@ -0,0 +1,25 @@
{
"name": "lyng-textmate",
"displayName": "Lyng",
"description": "TextMate grammar for the Lyng language (for JetBrains IDEs via TextMate Bundles and VS Code).",
"version": "0.0.3",
"publisher": "lyng",
"license": "Apache-2.0",
"engines": { "vscode": "^1.0.0" },
"contributes": {
"languages": [
{
"id": "lyng",
"aliases": ["Lyng", "lyng"],
"extensions": [".lyng"]
}
],
"grammars": [
{
"language": "lyng",
"scopeName": "source.lyng",
"path": "./syntaxes/lyng.tmLanguage.json"
}
]
}
}
@@ -0,0 +1,86 @@
{
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "Lyng",
"scopeName": "source.lyng",
"fileTypes": ["lyng"],
"patterns": [
{ "include": "#shebang" },
{ "include": "#comments" },
{ "include": "#strings" },
{ "include": "#char" },
{ "include": "#numbers" },
{ "include": "#declarations" },
{ "include": "#keywords" },
{ "include": "#constants" },
{ "include": "#types" },
{ "include": "#mapLiterals" },
{ "include": "#namedArgs" },
{ "include": "#annotations" },
{ "include": "#labels" },
{ "include": "#directives" },
{ "include": "#operators" },
{ "include": "#punctuation" }
],
"repository": {
"shebang": { "patterns": [ { "name": "comment.line.shebang.lyng", "match": "^#!.*$" } ] },
"comments": {
"patterns": [
{ "name": "comment.line.double-slash.lyng", "match": "//.*$" },
{ "name": "comment.block.lyng", "begin": "/\\*", "end": "\\*/" }
]
},
"strings": {
"patterns": [
{ "name": "string.quoted.double.lyng", "begin": "\"", "end": "\"", "patterns": [ { "match": "\\\\.", "name": "constant.character.escape.lyng" } ] },
{ "name": "string.quoted.single.lyng", "begin": "'", "end": "'", "patterns": [ { "match": "\\\\.", "name": "constant.character.escape.lyng" } ] }
]
},
"char": { "patterns": [ { "name": "constant.character.lyng", "match": "'(?:[^\\\\']|\\\\.)'" } ] },
"numbers": {
"patterns": [
{ "name": "constant.numeric.hex.lyng", "match": "0x[0-9A-Fa-f_]+" },
{ "name": "constant.numeric.decimal.lyng", "match": "(?<![A-Za-z_])(?:[0-9][0-9_]*)\\.(?:[0-9_]+)(?:[eE][+-]?[0-9_]+)?|(?<![A-Za-z_])(?:[0-9][0-9_]*)(?:[eE][+-]?[0-9_]+)?" }
]
},
"annotations": { "patterns": [ { "name": "entity.name.label.at.lyng", "match": "@[\\p{L}_][\\p{L}\\p{N}_]*:" }, { "name": "storage.modifier.annotation.lyng", "match": "@[\\p{L}_][\\p{L}\\p{N}_]*" } ] },
"mapLiterals": {
"patterns": [
{
"name": "meta.map.entry.lyng",
"match": "(?:(?<=\\{|,))\\s*(\"[^\"]*\"|[\\p{L}_][\\p{L}\\p{N}_]*)\\s*(:)(?!:)",
"captures": {
"1": { "name": "variable.other.property.key.lyng" },
"2": { "name": "punctuation.separator.colon.lyng" }
}
},
{
"name": "meta.map.spread.lyng",
"match": "(?:(?<=\\{|,))\\s*(\\.\\.\\.)",
"captures": {
"1": { "name": "keyword.operator.spread.lyng" }
}
}
]
},
"namedArgs": {
"patterns": [
{
"name": "meta.argument.named.lyng",
"match": "(?:(?<=\\()|(?<=,))\\s*([\\p{L}_][\\p{L}\\p{N}_]*)\\s*(:)(?!:)",
"captures": {
"1": { "name": "variable.parameter.named.lyng" },
"2": { "name": "punctuation.separator.colon.lyng" }
}
}
]
},
"labels": { "patterns": [ { "name": "entity.name.label.lyng", "match": "[\\p{L}_][\\p{L}\\p{N}_]*:" } ] },
"directives": { "patterns": [ { "name": "meta.directive.lyng", "match": "^\\s*#[_A-Za-z][_A-Za-z0-9]*" } ] },
"declarations": { "patterns": [ { "name": "meta.function.declaration.lyng", "match": "\\b(?:fun|fn)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "entity.name.function.lyng" } } }, { "name": "meta.type.declaration.lyng", "match": "\\b(?:class|enum)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "entity.name.type.lyng" } } }, { "name": "meta.variable.declaration.lyng", "match": "\\b(?:val|var)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "variable.other.declaration.lyng" } } } ] },
"keywords": { "patterns": [ { "name": "keyword.control.lyng", "match": "\\b(?:if|else|when|while|do|for|try|catch|finally|throw|return|break|continue)\\b" }, { "name": "keyword.declaration.lyng", "match": "\\b(?:fun|fn|class|enum|val|var|import|package|constructor|property|open|extern|private|protected|static)\\b" }, { "name": "keyword.operator.word.lyng", "match": "\\bnot\\s+(?:in|is)\\b" }, { "name": "keyword.operator.word.lyng", "match": "\\b(?:and|or|not|in|is|as|as\\?)\\b" } ] },
"constants": { "patterns": [ { "name": "constant.language.lyng", "match": "(?:\\b(?:true|false|null|this)\\b|π)" } ] },
"types": { "patterns": [ { "name": "storage.type.lyng", "match": "\\b(?:Int|Real|String|Bool|Char|Regex)\\b" }, { "name": "entity.name.type.lyng", "match": "\\b[A-Z][A-Za-z0-9_]*\\b(?!\\s*\\()" } ] },
"operators": { "patterns": [ { "name": "keyword.operator.comparison.lyng", "match": "===|!==|==|!=|<=|>=|<|>" }, { "name": "keyword.operator.shuttle.lyng", "match": "<=>" }, { "name": "keyword.operator.arrow.lyng", "match": "=>|->|::" }, { "name": "keyword.operator.range.lyng", "match": "\\.\\.\\.|\\.\\.<|\\.\\." }, { "name": "keyword.operator.nullsafe.lyng", "match": "\\?\\.|\\?\\[|\\?\\(|\\?\\{|\\?:|\\?\\?" }, { "name": "keyword.operator.assignment.lyng", "match": "(?:\\+=|-=|\\*=|/=|%=|=)" }, { "name": "keyword.operator.logical.lyng", "match": "&&|\\|\\|" }, { "name": "keyword.operator.bitwise.lyng", "match": "<<|>>|&|\\||\\^|~" }, { "name": "keyword.operator.match.lyng", "match": "=~|!~" }, { "name": "keyword.operator.arithmetic.lyng", "match": "\\+\\+|--|[+\\-*/%]" }, { "name": "keyword.operator.other.lyng", "match": "[!?]" } ] },
"punctuation": { "patterns": [ { "name": "punctuation.separator.comma.lyng", "match": "," }, { "name": "punctuation.terminator.statement.lyng", "match": ";" }, { "name": "punctuation.section.block.begin.lyng", "match": "[(]{1}|[{]{1}|\\[" }, { "name": "punctuation.section.block.end.lyng", "match": "[)]{1}|[}]{1}|\\]" }, { "name": "punctuation.accessor.dot.lyng", "match": "\\." }, { "name": "punctuation.separator.colon.lyng", "match": ":" } ] }
}
}
+7 -1
View File
@@ -28,4 +28,10 @@ android.useAndroidX=true
android.nonTransitiveRClass=true
# other
kotlin.native.cacheKind.linuxX64=none
kotlin.native.cacheKind.linuxX64=none
# Workaround: Ensure Gradle uses a JDK with `jlink` available for AGP's JDK image transform.
# On this environment, the system JDK 21 installation lacks `jlink`, causing
# :lynglib:androidJdkImage to fail. Point Gradle to JDK 17 which includes `jlink`.
# This affects only the JDK Gradle runs with; Kotlin/JVM target remains compatible.
#org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
+1 -1
View File
@@ -1,7 +1,7 @@
[versions]
agp = "8.5.2"
clikt = "5.0.3"
kotlin = "2.2.20"
kotlin = "2.2.21"
android-minSdk = "24"
android-compileSdk = "34"
kotlinx-coroutines = "1.10.2"
+10 -1
View File
@@ -16,7 +16,7 @@
*/
plugins {
kotlin("multiplatform") version "2.2.20"
alias(libs.plugins.kotlinMultiplatform)
}
group = "net.sergeych"
@@ -31,6 +31,15 @@ repositories {
}
kotlin {
// Suppress Beta warning for expect/actual classes across all targets in this module
targets.configureEach {
compilations.configureEach {
compilerOptions.configure {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
}
jvm {
binaries {
executable {
+49 -13
View File
@@ -21,23 +21,15 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych"
version = "1.0.0-SNAPSHOT"
version = "1.0.4-SNAPSHOT"
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.0")
classpath("com.codingfeline.buildkonfig:buildkonfig-gradle-plugin:latest_version")
}
}
// Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
// alias(libs.plugins.vanniktech.mavenPublish)
kotlin("plugin.serialization") version "2.1.0"
kotlin("plugin.serialization") version "2.2.21"
id("com.codingfeline.buildkonfig") version "0.17.1"
`maven-publish`
}
@@ -80,13 +72,23 @@ kotlin {
nodejs()
}
// Suppress Beta warning for expect/actual classes across all targets
targets.configureEach {
compilations.configureEach {
compilerOptions.configure {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
}
sourceSets {
all {
languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
languageSettings.optIn("kotlin.ExperimentalUnsignedTypes")
languageSettings.optIn("kotlin.coroutines.DelicateCoroutinesApi")
// Correct opt-in markers for coroutines
languageSettings.optIn("kotlinx.coroutines.DelicateCoroutinesApi")
languageSettings.optIn("kotlin.contracts.ExperimentalContracts")
languageSettings.optIn("kotlinx.coroutines.flow.DelicateCoroutinesApi")
languageSettings.optIn("kotlinx.coroutines.FlowPreview")
}
val commonMain by getting {
@@ -148,6 +150,40 @@ tasks.withType<org.gradle.api.tasks.testing.Test> {
showStandardStreams = true
}
maxParallelForks = 1
// Benchmarks toggle: disabled by default, enable when optimizing locally.
// Enable via any of the following:
// - Gradle property: ./gradlew :lynglib:jvmTest -Pbenchmarks=true
// - JVM system prop: ./gradlew :lynglib:jvmTest -Dbenchmarks=true
// - Environment var: BENCHMARKS=true ./gradlew :lynglib:jvmTest
val benchmarksEnabled: Boolean = run {
val p = (project.findProperty("benchmarks") as String?)?.toBooleanStrictOrNull()
val s = System.getProperty("benchmarks")?.lowercase()?.let { it == "true" || it == "1" || it == "yes" }
val e = System.getenv("BENCHMARKS")?.lowercase()?.let { it == "true" || it == "1" || it == "yes" }
p ?: s ?: e ?: false
}
// Make the flag visible inside tests if they want to branch on it
systemProperty("LYNG_BENCHMARKS", benchmarksEnabled.toString())
if (!benchmarksEnabled) {
// Exclude all JVM tests whose class name ends with or contains BenchmarkTest
// This keeps CI fast and avoids noisy timing logs by default.
filter {
excludeTestsMatching("*BenchmarkTest")
// Also guard against alternative naming
excludeTestsMatching("*Bench*Test")
// Exclude A/B performance tests unless explicitly enabled
excludeTestsMatching("*ABTest")
// Exclude stress/perf soak tests
excludeTestsMatching("*Stress*Test")
// Exclude allocation profiling tests by default
excludeTestsMatching("*AllocationProfileTest")
}
logger.lifecycle("[tests] Benchmarks are DISABLED. To enable: -Pbenchmarks=true or -Dbenchmarks=true or set BENCHMARKS=true")
} else {
logger.lifecycle("[tests] Benchmarks are ENABLED: *BenchmarkTest will run")
}
}
//mavenPublishing {
@@ -17,6 +17,7 @@
package net.sergeych.lyng
import net.sergeych.lyng.miniast.MiniTypeRef
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjList
import net.sergeych.lyng.obj.ObjRecord
@@ -56,72 +57,137 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
recordType = ObjRecord.Type.Argument)
}
// will be used with last lambda arg fix
// Prepare positional args and parameter count, handle tail-block binding
val callArgs: List<Obj>
val paramsSize: Int
if( arguments.tailBlockMode ) {
if (arguments.tailBlockMode) {
// If last parameter is already assigned by a named argument, it's an error
val lastParam = params.last()
if (arguments.named.containsKey(lastParam.name))
scope.raiseIllegalArgument("trailing block cannot be used when the last parameter is already assigned by a named argument")
paramsSize = params.size - 1
assign(params.last(), arguments.list.last())
assign(lastParam, arguments.list.last())
callArgs = arguments.list.dropLast(1)
} else {
paramsSize = params.size
callArgs = arguments.list
}
suspend fun processHead(index: Int): Int {
// Compute which parameter indexes are inevitably covered by positional arguments
// based on the number of supplied positionals, defaults and ellipsis placement.
val coveredByPositional = BooleanArray(paramsSize)
run {
// Count required (non-default, non-ellipsis) params in head and in tail
var headRequired = 0
var tailRequired = 0
val ellipsisIdx = params.subList(0, paramsSize).indexOfFirst { it.isEllipsis }
if (ellipsisIdx >= 0) {
for (i in 0 until ellipsisIdx) if (!params[i].isEllipsis && params[i].defaultValue == null) headRequired++
for (i in paramsSize - 1 downTo ellipsisIdx + 1) if (params[i].defaultValue == null) tailRequired++
} else {
for (i in 0 until paramsSize) if (params[i].defaultValue == null) headRequired++
}
val P = callArgs.size
if (ellipsisIdx < 0) {
// No ellipsis: all positionals go to head until exhausted
val k = minOf(P, paramsSize)
for (i in 0 until k) coveredByPositional[i] = true
} else {
// With ellipsis: head takes min(P, headRequired) first
val headTake = minOf(P, headRequired)
for (i in 0 until headTake) coveredByPositional[i] = true
val remaining = P - headTake
// tail takes min(remaining, tailRequired) from the end
val tailTake = minOf(remaining, tailRequired)
var j = paramsSize - 1
var taken = 0
while (j > ellipsisIdx && taken < tailTake) {
coveredByPositional[j] = true
j--
taken++
}
}
}
// Prepare arrays for named assignments
val assignedByName = BooleanArray(paramsSize)
val namedValues = arrayOfNulls<Obj>(paramsSize)
if (arguments.named.isNotEmpty()) {
for ((k, v) in arguments.named) {
val idx = params.subList(0, paramsSize).indexOfFirst { it.name == k }
if (idx < 0) scope.raiseIllegalArgument("unknown parameter '$k'")
if (params[idx].isEllipsis) scope.raiseIllegalArgument("ellipsis (variadic) parameter cannot be assigned by name: '$k'")
if (coveredByPositional[idx]) scope.raiseIllegalArgument("argument '$k' is already set by positional argument")
if (assignedByName[idx]) scope.raiseIllegalArgument("argument '$k' is already set")
assignedByName[idx] = true
namedValues[idx] = v
}
}
// Helper: assign head part, consuming from headPos; stop at ellipsis
suspend fun processHead(index: Int, headPos: Int): Pair<Int, Int> {
var i = index
while (i != paramsSize) {
var hp = headPos
while (i < paramsSize) {
val a = params[i]
if (a.isEllipsis) break
val value = when {
i < callArgs.size -> callArgs[i]
a.defaultValue != null -> a.defaultValue.execute(scope)
else -> {
// println("callArgs: ${callArgs.joinToString()}")
// println("tailBlockMode: ${arguments.tailBlockMode}")
scope.raiseIllegalArgument("too few arguments for the call")
}
if (assignedByName[i]) {
assign(a, namedValues[i]!!)
} else {
val value = if (hp < callArgs.size) callArgs[hp++]
else a.defaultValue?.execute(scope)
?: scope.raiseIllegalArgument("too few arguments for the call")
assign(a, value)
}
assign(a, value)
i++
}
return i
return i to hp
}
suspend fun processTail(index: Int): Int {
// Helper: assign tail part from the end, consuming from tailPos; stop before ellipsis index
// Do not consume elements below headPosBound to avoid overlap with head consumption
suspend fun processTail(startExclusive: Int, tailStart: Int, headPosBound: Int): Int {
var i = paramsSize - 1
var j = callArgs.size - 1
while (i > index) {
var tp = tailStart
while (i > startExclusive) {
val a = params[i]
if (a.isEllipsis) break
val value = when {
j >= index -> {
callArgs[j--]
}
a.defaultValue != null -> a.defaultValue.execute(scope)
else -> scope.raiseIllegalArgument("too few arguments for the call")
if (i < assignedByName.size && assignedByName[i]) {
assign(a, namedValues[i]!!)
} else {
val value = if (tp >= headPosBound) callArgs[tp--]
else a.defaultValue?.execute(scope)
?: scope.raiseIllegalArgument("too few arguments for the call")
assign(a, value)
}
assign(a, value)
i--
}
return j
return tp
}
fun processEllipsis(index: Int, toFromIndex: Int) {
fun processEllipsis(index: Int, headPos: Int, tailPos: Int) {
val a = params[index]
val l = if (index > toFromIndex) ObjList()
else ObjList(callArgs.subList(index, toFromIndex + 1).toMutableList())
val from = headPos
val to = tailPos
val l = if (from > to) ObjList()
else ObjList(callArgs.subList(from, to + 1).toMutableList())
assign(a, l)
}
val leftIndex = processHead(0)
if (leftIndex < paramsSize) {
val end = processTail(leftIndex)
processEllipsis(leftIndex, end)
// Locate ellipsis index within considered parameters
val ellipsisIndex = params.subList(0, paramsSize).indexOfFirst { it.isEllipsis }
if (ellipsisIndex >= 0) {
// Assign head first to know how many positionals are consumed from the start
val (afterHead, headConsumedTo) = processHead(0, 0)
// Then assign tail consuming from the end down to headConsumedTo boundary
val tailConsumedFrom = processTail(ellipsisIndex, callArgs.size - 1, headConsumedTo)
// Assign ellipsis list from remaining positionals between headConsumedTo..tailConsumedFrom
processEllipsis(ellipsisIndex, headConsumedTo, tailConsumedFrom)
} else {
if (leftIndex < callArgs.size)
// No ellipsis: assign head only; any leftover positionals → error
val (_, headConsumedTo) = processHead(0, 0)
if (headConsumedTo != callArgs.size)
scope.raiseIllegalArgument("too many arguments for the call")
}
}
@@ -135,6 +201,7 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
data class Item(
val name: String,
val type: TypeDecl = TypeDecl.TypeAny,
val miniType: MiniTypeRef? = null,
val pos: Pos = Pos.builtIn,
val isEllipsis: Boolean = false,
/**
@@ -17,24 +17,27 @@
package net.sergeych.lyng
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjIterable
import net.sergeych.lyng.obj.ObjList
import net.sergeych.lyng.obj.*
data class ParsedArgument(val value: Statement, val pos: Pos, val isSplat: Boolean = false)
data class ParsedArgument(
val value: Statement,
val pos: Pos,
val isSplat: Boolean = false,
val name: String? = null,
)
suspend fun Collection<ParsedArgument>.toArguments(scope: Scope, tailBlockMode: Boolean): Arguments {
// Small-arity fast path (no splats) to reduce allocations
// Detect if we can use the fast path: no splats and no named args
if (PerfFlags.ARG_BUILDER) {
val limit = if (PerfFlags.ARG_SMALL_ARITY_12) 12 else 8
var hasSplat = false
val limit = if (PerfFlags.ARG_SMALL_ARITY_12) 12 else 8
var hasSplatOrNamed = false
var count = 0
for (pa in this) {
if (pa.isSplat) { hasSplat = true; break }
if (pa.isSplat || pa.name != null) { hasSplatOrNamed = true; break }
count++
if (count > limit) break
if (count > limit) break
}
if (!hasSplat && count == this.size) {
if (!hasSplatOrNamed && count == this.size) {
val quick = when (count) {
0 -> Arguments.EMPTY
1 -> Arguments(listOf(this.elementAt(0).value.execute(scope)), tailBlockMode)
@@ -94,149 +97,145 @@ import net.sergeych.lyng.obj.ObjList
val a7 = this.elementAt(7).value.execute(scope)
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7), tailBlockMode)
}
9 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
val a0 = this.elementAt(0).value.execute(scope)
val a1 = this.elementAt(1).value.execute(scope)
val a2 = this.elementAt(2).value.execute(scope)
val a3 = this.elementAt(3).value.execute(scope)
val a4 = this.elementAt(4).value.execute(scope)
val a5 = this.elementAt(5).value.execute(scope)
val a6 = this.elementAt(6).value.execute(scope)
val a7 = this.elementAt(7).value.execute(scope)
val a8 = this.elementAt(8).value.execute(scope)
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8), tailBlockMode)
} else null
10 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
val a0 = this.elementAt(0).value.execute(scope)
val a1 = this.elementAt(1).value.execute(scope)
val a2 = this.elementAt(2).value.execute(scope)
val a3 = this.elementAt(3).value.execute(scope)
val a4 = this.elementAt(4).value.execute(scope)
val a5 = this.elementAt(5).value.execute(scope)
val a6 = this.elementAt(6).value.execute(scope)
val a7 = this.elementAt(7).value.execute(scope)
val a8 = this.elementAt(8).value.execute(scope)
val a9 = this.elementAt(9).value.execute(scope)
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9), tailBlockMode)
} else null
11 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
val a0 = this.elementAt(0).value.execute(scope)
val a1 = this.elementAt(1).value.execute(scope)
val a2 = this.elementAt(2).value.execute(scope)
val a3 = this.elementAt(3).value.execute(scope)
val a4 = this.elementAt(4).value.execute(scope)
val a5 = this.elementAt(5).value.execute(scope)
val a6 = this.elementAt(6).value.execute(scope)
val a7 = this.elementAt(7).value.execute(scope)
val a8 = this.elementAt(8).value.execute(scope)
val a9 = this.elementAt(9).value.execute(scope)
val a10 = this.elementAt(10).value.execute(scope)
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10), tailBlockMode)
} else null
12 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
val a0 = this.elementAt(0).value.execute(scope)
val a1 = this.elementAt(1).value.execute(scope)
val a2 = this.elementAt(2).value.execute(scope)
val a3 = this.elementAt(3).value.execute(scope)
val a4 = this.elementAt(4).value.execute(scope)
val a5 = this.elementAt(5).value.execute(scope)
val a6 = this.elementAt(6).value.execute(scope)
val a7 = this.elementAt(7).value.execute(scope)
val a8 = this.elementAt(8).value.execute(scope)
val a9 = this.elementAt(9).value.execute(scope)
val a10 = this.elementAt(10).value.execute(scope)
val a11 = this.elementAt(11).value.execute(scope)
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11), tailBlockMode)
} else null
9 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
val a0 = this.elementAt(0).value.execute(scope)
val a1 = this.elementAt(1).value.execute(scope)
val a2 = this.elementAt(2).value.execute(scope)
val a3 = this.elementAt(3).value.execute(scope)
val a4 = this.elementAt(4).value.execute(scope)
val a5 = this.elementAt(5).value.execute(scope)
val a6 = this.elementAt(6).value.execute(scope)
val a7 = this.elementAt(7).value.execute(scope)
val a8 = this.elementAt(8).value.execute(scope)
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8), tailBlockMode)
} else null
10 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
val a0 = this.elementAt(0).value.execute(scope)
val a1 = this.elementAt(1).value.execute(scope)
val a2 = this.elementAt(2).value.execute(scope)
val a3 = this.elementAt(3).value.execute(scope)
val a4 = this.elementAt(4).value.execute(scope)
val a5 = this.elementAt(5).value.execute(scope)
val a6 = this.elementAt(6).value.execute(scope)
val a7 = this.elementAt(7).value.execute(scope)
val a8 = this.elementAt(8).value.execute(scope)
val a9 = this.elementAt(9).value.execute(scope)
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9), tailBlockMode)
} else null
11 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
val a0 = this.elementAt(0).value.execute(scope)
val a1 = this.elementAt(1).value.execute(scope)
val a2 = this.elementAt(2).value.execute(scope)
val a3 = this.elementAt(3).value.execute(scope)
val a4 = this.elementAt(4).value.execute(scope)
val a5 = this.elementAt(5).value.execute(scope)
val a6 = this.elementAt(6).value.execute(scope)
val a7 = this.elementAt(7).value.execute(scope)
val a8 = this.elementAt(8).value.execute(scope)
val a9 = this.elementAt(9).value.execute(scope)
val a10 = this.elementAt(10).value.execute(scope)
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10), tailBlockMode)
} else null
12 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
val a0 = this.elementAt(0).value.execute(scope)
val a1 = this.elementAt(1).value.execute(scope)
val a2 = this.elementAt(2).value.execute(scope)
val a3 = this.elementAt(3).value.execute(scope)
val a4 = this.elementAt(4).value.execute(scope)
val a5 = this.elementAt(5).value.execute(scope)
val a6 = this.elementAt(6).value.execute(scope)
val a7 = this.elementAt(7).value.execute(scope)
val a8 = this.elementAt(8).value.execute(scope)
val a9 = this.elementAt(9).value.execute(scope)
val a10 = this.elementAt(10).value.execute(scope)
val a11 = this.elementAt(11).value.execute(scope)
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11), tailBlockMode)
} else null
else -> null
}
if (quick != null) return quick
}
}
// Single-splat fast path: if there is exactly one splat argument that evaluates to ObjList,
// avoid builder and copies by returning its list directly.
if (PerfFlags.ARG_BUILDER) {
if (this.size == 1) {
val only = this.first()
if (only.isSplat) {
val v = only.value.execute(scope)
if (v is ObjList) {
return Arguments(v.list, tailBlockMode)
} else if (v.isInstanceOf(ObjIterable)) {
// Convert iterable to list once and return directly
val i = (v.invokeInstanceMethod(scope, "toList") as ObjList).list
return Arguments(i, tailBlockMode)
} else {
scope.raiseClassCastError("expected list of objects for splat argument")
}
}
}
}
// General path with builder or simple list fallback
if (PerfFlags.ARG_BUILDER) {
val b = ArgBuilderProvider.acquire()
try {
b.reset(this.size)
for (x in this) {
val value = x.value.execute(scope)
if (x.isSplat) {
when {
value is ObjList -> {
b.addAll(value.list)
}
value.isInstanceOf(ObjIterable) -> {
val i = (value.invokeInstanceMethod(scope, "toList") as ObjList).list
b.addAll(i)
}
else -> scope.raiseClassCastError("expected list of objects for splat argument")
}
} else {
b.add(value)
}
}
return b.build(tailBlockMode)
} finally {
b.release()
// General path: build positional list and named map, enforcing ordering rules
val positional: MutableList<Obj> = mutableListOf()
var named: MutableMap<String, Obj>? = null
var namedSeen = false
for ((idx, x) in this.withIndex()) {
if (x.name != null) {
// Named argument
if (named == null) named = linkedMapOf()
if (named.containsKey(x.name)) scope.raiseIllegalArgument("argument '${x.name}' is already set")
val v = x.value.execute(scope)
named[x.name] = v
namedSeen = true
continue
}
} else {
val list: MutableList<Obj> = mutableListOf()
for (x in this) {
val value = x.value.execute(scope)
if (x.isSplat) {
when {
value is ObjList -> list.addAll(value.list)
value.isInstanceOf(ObjIterable) -> {
val i = (value.invokeInstanceMethod(scope, "toList") as ObjList).list
list.addAll(i)
val value = x.value.execute(scope)
if (x.isSplat) {
when {
// IMPORTANT: handle ObjMap BEFORE generic Iterable to ensure map splats
// are treated as named splats, not as positional iteration over entries
value is ObjMap -> {
if (named == null) named = linkedMapOf()
for ((k, v) in value.map) {
if (k !is ObjString) scope.raiseIllegalArgument("named splat expects a Map with string keys")
val key = k.value
if (named.containsKey(key)) scope.raiseIllegalArgument("argument '$key' is already set")
named[key] = v
}
else -> scope.raiseClassCastError("expected list of objects for splat argument")
namedSeen = true
}
} else {
list.add(value)
value is ObjList -> {
if (namedSeen) {
// allow only if this is the very last positional which will be the trailing block; but
// splat can never be a trailing block, so it's always illegal here
scope.raiseIllegalArgument("positional splat cannot follow named arguments")
}
positional.addAll(value.list)
}
value.isInstanceOf(ObjIterable) -> {
if (namedSeen) scope.raiseIllegalArgument("positional splat cannot follow named arguments")
val i = (value.invokeInstanceMethod(scope, "toList") as ObjList).list
positional.addAll(i)
}
else -> scope.raiseClassCastError("expected list of objects for splat argument")
}
} else {
if (namedSeen) {
// Allow exactly one positional after named only when it is the very last argument overall
// and tailBlockMode is true (syntactic trailing block). Otherwise, forbid it.
val isLast = idx == this.size - 1
if (!(isLast && tailBlockMode))
scope.raiseIllegalArgument("positional argument cannot follow named arguments")
}
positional.add(value)
}
return Arguments(list, tailBlockMode)
}
val namedFinal = named ?: emptyMap()
return Arguments(positional, tailBlockMode, namedFinal)
}
data class Arguments(val list: List<Obj>, val tailBlockMode: Boolean = false) : List<Obj> by list {
data class Arguments(
val list: List<Obj>,
val tailBlockMode: Boolean = false,
val named: Map<String, Obj> = emptyMap(),
) : List<Obj> by list {
constructor(vararg values: Obj) : this(values.toList())
fun firstAndOnly(pos: Pos = Pos.UNKNOWN): Obj {
if (list.size != 1) throw ScriptError(pos, "expected one argument, got ${list.size}")
val v = list.first()
// Tiny micro-alloc win: avoid byValueCopy for immutable singletons
return when (v) {
net.sergeych.lyng.obj.ObjNull,
net.sergeych.lyng.obj.ObjTrue,
net.sergeych.lyng.obj.ObjFalse,
return when (val v = list.first()) {
ObjNull,
ObjTrue,
ObjFalse,
// Immutable scalars: safe to return directly
is net.sergeych.lyng.obj.ObjInt,
is net.sergeych.lyng.obj.ObjReal,
is net.sergeych.lyng.obj.ObjChar,
is net.sergeych.lyng.obj.ObjString -> v
is ObjInt,
is ObjReal,
is ObjChar,
is ObjString -> v
else -> v.byValueCopy()
}
}
File diff suppressed because it is too large. Load diff
@@ -69,7 +69,15 @@ class CompilerContext(val tokens: List<Token>) {
throw ScriptError(currentPos(), message)
}
fun currentPos(): Pos = tokens[currentIndex].pos
fun currentPos(): Pos {
if (tokens.isEmpty()) return Pos.builtIn
val idx = when {
currentIndex < 0 -> 0
currentIndex >= tokens.size -> tokens.size - 1
else -> currentIndex
}
return tokens[idx].pos
}
/**
* If the next token is identifier `name`, skip it and return `true`.
@@ -84,12 +84,12 @@ private class Parser(fromPos: Pos) {
when (currentChar) {
'+' -> {
pos.advance()
Token("+", from, Token.Type.PLUS2)
Token("++", from, Token.Type.PLUS2)
}
'=' -> {
pos.advance()
Token("+", from, Token.Type.PLUSASSIGN)
Token("+=", from, Token.Type.PLUSASSIGN)
}
else ->
@@ -106,7 +106,7 @@ private class Parser(fromPos: Pos) {
'=' -> {
pos.advance()
Token("-", from, Token.Type.MINUSASSIGN)
Token("-=", from, Token.Type.MINUSASSIGN)
}
'>' -> {
@@ -129,17 +129,17 @@ private class Parser(fromPos: Pos) {
'/' -> when (currentChar) {
'/' -> {
pos.advance()
Token(loadToEndOfLine().trim(), from, Token.Type.SINLGE_LINE_COMMENT)
val body = loadToEndOfLine()
// Include the leading '//' and do not trim; keep exact lexeme (excluding preceding codepoint)
Token("//" + body, from, Token.Type.SINLGE_LINE_COMMENT)
}
'*' -> {
pos.advance()
Token(
loadTo("*/")?.trim()
?: throw ScriptError(from, "Unterminated multiline comment"),
from,
Token.Type.MULTILINE_COMMENT
)
val content = loadTo("*/")
?: throw ScriptError(from, "Unterminated multiline comment")
// loadTo consumes the closing fragment, so we are already after */
Token("/*" + content + "*/", from, Token.Type.MULTILINE_COMMENT)
}
'=' -> {
@@ -403,7 +403,8 @@ private class Parser(fromPos: Pos) {
// could be integer, also hex:
if (currentChar == 'x' && p1 == "0") {
pos.advance()
Token(loadChars { it in hexDigits }, start, Token.Type.HEX).also {
val hex = loadChars { it in hexDigits }
Token(hex, start, Token.Type.HEX).also {
if (currentChar.isLetter())
raise("invalid hex literal")
}
@@ -541,11 +542,11 @@ private class Parser(fromPos: Pos) {
private fun loadToEndOfLine(): String {
val result = StringBuilder()
val l = pos.line
do {
// Read characters up to but not including the line break
while (!pos.end && pos.currentChar != '\n') {
result.append(pos.currentChar)
pos.advance()
} while (pos.line == l)
}
return result.toString()
}
@@ -97,8 +97,5 @@ class MutablePos(private val from: Pos) {
}
override fun toString() = "($line:$column)"
init {
if( lines[0].isEmpty()) advance()
}
}
@@ -19,9 +19,13 @@ package net.sergeych.lyng
import net.sergeych.lyng.obj.ObjString
class Source(val fileName: String, text: String) {
class Source(val fileName: String, val text: String) {
val lines = text.lines().map { it.trimEnd() }
// Preserve original text characters exactly; do not trim trailing spaces.
// Split by "\n" boundaries as the lexer models line breaks uniformly as a single newline
// between logical lines, regardless of original platform line endings.
// We intentionally do NOT trim line ends to keep columns accurate.
val lines: List<String> = text.split('\n')
val objSourceName by lazy { ObjString(fileName) }
@@ -0,0 +1,284 @@
/*
* Copyright 2025 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.
*
*/
/*
* Minimal binding for editor/highlighter: builds a simple scope tree from Mini-AST
* and binds identifier usages to declarations without type analysis.
*/
package net.sergeych.lyng.binding
import net.sergeych.lyng.Source
import net.sergeych.lyng.highlight.HighlightKind
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.miniast.MiniClassDecl
import net.sergeych.lyng.miniast.MiniFunDecl
import net.sergeych.lyng.miniast.MiniScript
import net.sergeych.lyng.miniast.MiniValDecl
enum class SymbolKind { Class, Enum, Function, Val, Var, Param }
data class Symbol(
val id: Int,
val name: String,
val kind: SymbolKind,
val declStart: Int,
val declEnd: Int,
val containerId: Int?
)
data class Reference(val symbolId: Int, val start: Int, val end: Int)
data class BindingSnapshot(
val symbols: List<Symbol>,
val references: List<Reference>
)
/**
* Very small binder that:
* - Registers symbols for top-level decls, function params, and local vals/vars inside function bodies.
* - Binds identifier tokens to the nearest matching symbol by lexical scope (function locals/params first, then top-level).
*/
object Binder {
fun bind(text: String, mini: MiniScript): BindingSnapshot {
val source = Source("<snippet>", text)
val highlighter = SimpleLyngHighlighter()
val spans = highlighter.highlight(text)
// 1) Collect symbols
val symbols = ArrayList<Symbol>()
var nextId = 1
// Helper to convert Pos to offsets
fun nameOffsets(startPos: net.sergeych.lyng.Pos, name: String): Pair<Int, Int> {
val s = source.offsetOf(startPos)
return s to (s + name.length)
}
// Class scopes to support fields and method resolution order
data class ClassScope(
val symId: Int,
val bodyStart: Int,
val bodyEnd: Int,
val fields: MutableList<Int>
)
val classes = ArrayList<ClassScope>()
// Map of function id to its local symbol ids and owning class (if method)
data class FnScope(
val id: Int,
val rangeStart: Int,
val rangeEnd: Int,
val locals: MutableList<Int>,
val classOwnerId: Int?
)
val functions = ArrayList<FnScope>()
// Index top-level for quick lookups
val topLevelByName = HashMap<String, MutableList<Int>>()
// Helper to find class that contains an offset (by body range)
fun classContaining(offset: Int): ClassScope? =
classes.asSequence()
.filter { it.bodyEnd > it.bodyStart && offset >= it.bodyStart && offset <= it.bodyEnd }
.maxByOrNull { it.bodyEnd - it.bodyStart }
// First pass (classes only): register classes so we can attach methods/fields
for (d in mini.declarations) if (d is MiniClassDecl) {
val (s, e) = nameOffsets(d.nameStart, d.name)
val sym = Symbol(nextId++, d.name, SymbolKind.Class, s, e, containerId = null)
symbols += sym
topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id)
// Prefer explicit body range; otherwise use the whole class declaration range
val bodyStart = d.bodyRange?.start?.let { source.offsetOf(it) } ?: source.offsetOf(d.range.start)
val bodyEnd = d.bodyRange?.end?.let { source.offsetOf(it) } ?: source.offsetOf(d.range.end)
classes += ClassScope(sym.id, bodyStart, bodyEnd, mutableListOf())
// Constructor fields (val/var in primary ctor)
for (cf in d.ctorFields) {
val fs = source.offsetOf(cf.nameStart)
val fe = fs + cf.name.length
val kind = if (cf.mutable) SymbolKind.Var else SymbolKind.Val
val fieldSym = Symbol(nextId++, cf.name, kind, fs, fe, containerId = sym.id)
symbols += fieldSym
classes.last().fields += fieldSym.id
}
}
// Second pass: functions and top-level/class vals/vars
for (d in mini.declarations) {
when (d) {
is MiniClassDecl -> { /* already processed in first pass */ }
is MiniFunDecl -> {
val (s, e) = nameOffsets(d.nameStart, d.name)
val ownerClass = classContaining(s)
val sym = Symbol(nextId++, d.name, SymbolKind.Function, s, e, containerId = ownerClass?.symId)
symbols += sym
topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id)
// Determine body range if present; otherwise, derive a conservative end at decl range end
val bodyStart = d.body?.range?.start?.let { source.offsetOf(it) } ?: e
val bodyEnd = d.body?.range?.end?.let { source.offsetOf(it) } ?: e
val fnScope = FnScope(sym.id, bodyStart, bodyEnd, mutableListOf(), ownerClass?.symId)
// Params
for (p in d.params) {
val ps = source.offsetOf(p.nameStart)
val pe = ps + p.name.length
val pk = SymbolKind.Param
val paramSym = Symbol(nextId++, p.name, pk, ps, pe, containerId = sym.id)
fnScope.locals += paramSym.id
symbols += paramSym
}
functions += fnScope
}
is MiniValDecl -> {
val (s, e) = nameOffsets(d.nameStart, d.name)
val kind = if (d.mutable) SymbolKind.Var else SymbolKind.Val
val ownerClass = classContaining(s)
if (ownerClass != null) {
// class field
val fieldSym = Symbol(nextId++, d.name, kind, s, e, containerId = ownerClass.symId)
symbols += fieldSym
ownerClass.fields += fieldSym.id
} else {
val sym = Symbol(nextId++, d.name, kind, s, e, containerId = null)
symbols += sym
topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id)
}
}
}
}
// Inject stdlib symbols into the top-level when imported, so calls like `filter {}` bind semantically.
run {
val importedModules = mini.imports.map { it.segments.joinToString(".") { s -> s.name } }
val hasStdlib = importedModules.any { it == "lyng.stdlib" || it.startsWith("lyng.stdlib.") }
if (hasStdlib) {
val stdFns = listOf(
"filter", "map", "flatMap", "reduce", "fold", "take", "drop", "sorted",
"groupBy", "count", "any", "all", "first", "last", "sum", "joinToString",
// iterator trio often used implicitly/explicitly
"iterator", "hasNext", "next"
)
for (name in stdFns) {
val sym = Symbol(nextId++, name, SymbolKind.Function, 0, name.length, containerId = null)
symbols += sym
topLevelByName.getOrPut(name) { mutableListOf() }.add(sym.id)
}
}
}
// Second pass: attach local val/var declarations that fall inside any function body
for (d in mini.declarations) if (d is MiniValDecl) {
val (s, e) = nameOffsets(d.nameStart, d.name)
// Find containing function by body range that includes this declaration name
val containerFn = functions.asSequence()
.filter { it.rangeEnd > it.rangeStart && s >= it.rangeStart && s <= it.rangeEnd }
.maxByOrNull { it.rangeEnd - it.rangeStart }
if (containerFn != null) {
val fnSymId = containerFn.id
val kind = if (d.mutable) SymbolKind.Var else SymbolKind.Val
val localSym = Symbol(nextId++, d.name, kind, s, e, containerId = fnSymId)
symbols += localSym
containerFn.locals += localSym.id
}
}
// Build name -> symbol ids index per function (locals+params) and per class (fields) for faster resolution
data class Idx(val byName: Map<String, List<Int>>)
fun buildIndex(ids: List<Int>): Idx {
val map = HashMap<String, MutableList<Int>>()
for (id in ids) {
val s = symbols.first { it.id == id }
map.getOrPut(s.name) { mutableListOf() }.add(id)
}
return Idx(map)
}
val fnLocalIndex = HashMap<Int, Idx>() // key: function symbol id
for (fn in functions) fnLocalIndex[fn.id] = buildIndex(fn.locals)
val classFieldIndex = HashMap<Int, Idx>() // key: class symbol id
for (cls in classes) classFieldIndex[cls.symId] = buildIndex(cls.fields)
// Helper to find nearest match among candidates: pick the declaration with the smallest (usageStart - declStart) >= 0
fun chooseNearest(candidates: List<Int>, usageStart: Int): Int? {
var best: Int? = null
var bestDist = Int.MAX_VALUE
for (id in candidates) {
val s = symbols.first { it.id == id }
val dist = usageStart - s.declStart
if (dist >= 0 && dist < bestDist) {
bestDist = dist; best = id
}
}
return best
}
// 2) Bind identifier usages to symbols
val references = ArrayList<Reference>()
for (span in spans) {
if (span.kind != HighlightKind.Identifier) continue
val start = span.range.start
val end = span.range.endExclusive
// Skip if this range equals a known declaration identifier range; those are styled separately
val isDecl = symbols.any { it.declStart == start && it.declEnd == end }
if (isDecl) continue
val name = text.substring(start, end)
// Prefer function-local matches: find the function whose body contains the usage
val inFn = functions.asSequence()
.filter { it.rangeEnd > it.rangeStart && start >= it.rangeStart && start <= it.rangeEnd }
.maxByOrNull { it.rangeEnd - it.rangeStart }
if (inFn != null) {
val idx = fnLocalIndex[inFn.id]
val locIds = idx?.byName?.get(name)
val hit = if (!locIds.isNullOrEmpty()) chooseNearest(locIds, start) else null
if (hit != null) {
references += Reference(hit, start, end)
continue
}
// If function belongs to a class, check class fields next
val ownerClassId = inFn.classOwnerId
if (ownerClassId != null) {
val cidx = classFieldIndex[ownerClassId]
val cfIds = cidx?.byName?.get(name)
val cHit = if (!cfIds.isNullOrEmpty()) chooseNearest(cfIds, start) else null
if (cHit != null) {
references += Reference(cHit, start, end)
continue
}
}
}
// Else try top-level by name
val topIds = topLevelByName[name]
val topHit = if (!topIds.isNullOrEmpty()) chooseNearest(topIds, start) else null
if (topHit != null) {
references += Reference(topHit, start, end)
continue
}
// Fallback: choose the nearest declared symbol with this name regardless of scope (best effort)
val anyIds = symbols.filter { it.name == name }.map { it.id }
val anyHit = if (anyIds.isNotEmpty()) chooseNearest(anyIds, start) else null
if (anyHit != null) references += Reference(anyHit, start, end)
}
return BindingSnapshot(symbols, references)
}
}
@@ -0,0 +1,55 @@
/*
* Copyright 2025 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.
*
*/
/*
* Cross-platform highlighting API for the Lyng language.
*/
package net.sergeych.lyng.highlight
/** Represents a half-open character range [start, endExclusive). */
data class TextRange(val start: Int, val endExclusive: Int) {
init { require(start <= endExclusive) { "Invalid range: $start..$endExclusive" } }
}
/** Kinds of tokens for syntax highlighting. */
enum class HighlightKind {
Keyword,
TypeName,
Identifier,
Number,
String,
Char,
Regex,
Comment,
Operator,
Punctuation,
Label,
Directive,
Error,
}
/** A highlighted span: character range and its semantic/lexical kind. */
data class HighlightSpan(val range: TextRange, val kind: HighlightKind)
/** Base interface for Lyng syntax highlighters. */
interface LyngHighlighter {
/**
* Produce highlight spans for the given [text]. Spans are non-overlapping and
* ordered by start position. Adjacent spans of the same kind may be merged.
*/
fun highlight(text: String): List<HighlightSpan>
}
@@ -0,0 +1,198 @@
/*
* Copyright 2025 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.highlight
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Source
import net.sergeych.lyng.Token.Type
import net.sergeych.lyng.parseLyng
/** Extension that converts a [Pos] (line/column) into absolute character offset in the [Source] text. */
fun Source.offsetOf(pos: Pos): Int {
var off = 0
// Sum full preceding lines + one '\n' per line (lines[] were created by String.lines())
var i = 0
while (i < pos.line) {
off += lines[i].length + 1 // assume \n as separator
i++
}
off += pos.column
return off
}
private val reservedIdKeywords = setOf("constructor", "property")
// Fallback textual keywords that might come from the lexer as ID in some contexts (e.g., snippets)
private val fallbackKeywordIds = setOf(
// boolean operators
"and", "or", "not",
// declarations & modifiers
"fun", "fn", "class", "enum", "val", "var", "import", "package",
"private", "protected", "static", "open", "extern",
// control flow and misc
"if", "else", "when", "while", "do", "for", "try", "catch", "finally",
"throw", "return", "break", "continue", "this", "null", "true", "false"
)
/** Maps lexer token type (and sometimes value) to a [HighlightKind]. */
private fun kindOf(type: Type, value: String): HighlightKind? = when (type) {
// identifiers and reserved ids
Type.ID -> when {
value in reservedIdKeywords -> HighlightKind.Keyword
value.lowercase() in fallbackKeywordIds -> HighlightKind.Keyword
else -> HighlightKind.Identifier
}
// numbers
Type.INT, Type.REAL, Type.HEX -> HighlightKind.Number
// text literals
Type.STRING, Type.STRING2 -> HighlightKind.String
Type.CHAR -> HighlightKind.Char
Type.REGEX -> HighlightKind.Regex
// comments
Type.SINLGE_LINE_COMMENT, Type.MULTILINE_COMMENT -> HighlightKind.Comment
// punctuation
Type.LPAREN, Type.RPAREN, Type.LBRACE, Type.RBRACE, Type.LBRACKET, Type.RBRACKET,
Type.COMMA, Type.SEMICOLON, Type.COLON -> HighlightKind.Punctuation
// textual control keywords
Type.IN, Type.NOTIN, Type.IS, Type.NOTIS, Type.AS, Type.ASNULL,
Type.AND, Type.OR, Type.NOT -> HighlightKind.Keyword
// labels / annotations
Type.LABEL, Type.ATLABEL -> HighlightKind.Label
// operators and symbolic constructs
Type.PLUS, Type.MINUS, Type.STAR, Type.SLASH, Type.PERCENT,
Type.ASSIGN, Type.PLUSASSIGN, Type.MINUSASSIGN, Type.STARASSIGN, Type.SLASHASSIGN, Type.PERCENTASSIGN,
Type.PLUS2, Type.MINUS2,
Type.EQ, Type.NEQ, Type.LT, Type.LTE, Type.GT, Type.GTE, Type.REF_EQ, Type.REF_NEQ, Type.MATCH, Type.NOTMATCH,
Type.DOT, Type.ARROW, Type.EQARROW, Type.QUESTION, Type.COLONCOLON,
Type.SHL, Type.SHR, Type.ELLIPSIS, Type.DOTDOT, Type.DOTDOTLT,
Type.NULL_COALESCE, Type.ELVIS, Type.NULL_COALESCE_INDEX, Type.NULL_COALESCE_INVOKE, Type.NULL_COALESCE_BLOCKINVOKE,
Type.SHUTTLE,
// bitwise textual operators (treat as operators for visuals)
Type.BITAND, Type.BITOR, Type.BITXOR, Type.BITNOT -> HighlightKind.Operator
// non-highlighting tokens
Type.NEWLINE, Type.EOF -> null
}
/** Merge contiguous spans of the same [HighlightKind] to reduce output size. */
private fun mergeAdjacent(spans: List<HighlightSpan>): List<HighlightSpan> {
if (spans.isEmpty()) return spans
val out = ArrayList<HighlightSpan>(spans.size)
var prev = spans[0]
for (i in 1 until spans.size) {
val cur = spans[i]
if (cur.kind == prev.kind && cur.range.start == prev.range.endExclusive) {
prev = HighlightSpan(TextRange(prev.range.start, cur.range.endExclusive), prev.kind)
} else {
out += prev
prev = cur
}
}
out += prev
return out
}
/** Simple highlighter using the existing Lyng lexer (no incremental support yet). */
class SimpleLyngHighlighter : LyngHighlighter {
override fun highlight(text: String): List<HighlightSpan> {
val src = Source("<snippet>", text)
val tokens = parseLyng(src)
val raw = ArrayList<HighlightSpan>(tokens.size)
fun adjustQuoteSpan(startOffset: Int, quoteChar: Char): TextRange {
var s = startOffset
if (s > 0 && text[s - 1] == quoteChar) s -= 1
var i = s + 1
while (i < text.length) {
val ch = text[i]
if (ch == '\\') {
i += if (i + 1 < text.length) 2 else 1
continue
}
if (ch == quoteChar) {
return TextRange(s, i + 1)
}
i++
}
// Unterminated, highlight till end
return TextRange(s, text.length)
}
for (t in tokens) {
val k = kindOf(t.type, t.value) ?: continue
val start = src.offsetOf(t.pos)
val range = when (t.type) {
Type.STRING, Type.STRING2 -> adjustQuoteSpan(start, '"')
Type.CHAR -> adjustQuoteSpan(start, '\'')
Type.HEX -> {
// Parser returns HEX token value without the leading "0x"; include it in highlight span
val end = (start + 2 + t.value.length).coerceAtMost(text.length)
TextRange(start, end)
}
else -> TextRange(start, (start + t.value.length).coerceAtMost(text.length))
}
if (range.endExclusive > range.start) raw += HighlightSpan(range, k)
}
// Adjust single-line comment spans to extend till EOL to compensate for lexer offset/length quirks
val adjusted = extendSingleLineCommentsToEol(text, raw)
// Spans are in order; merge adjacent of the same kind for compactness
return mergeAdjacent(adjusted)
}
}
/**
* Workaround/fix: ensure that single-line comment spans that start with `//` extend until the end of line.
* Drops any subsequent spans that would overlap the extended comment on the same line.
*/
private fun extendSingleLineCommentsToEol(
text: String,
spans: List<HighlightSpan>
): List<HighlightSpan> {
if (spans.isEmpty()) return spans
val out = ArrayList<HighlightSpan>(spans.size)
var i = 0
while (i < spans.size) {
val s = spans[i]
if (s.kind == HighlightKind.Comment) {
// Check the original text actually has '//' at span start to avoid touching block comments
val start = s.range.start
val ahead = if (start in text.indices) text.substring(start, minOf(text.length, start + 2)) else ""
if (ahead == "//") {
// Extend to end of current line
val eol = text.indexOf('\n', start)
val newEnd = if (eol >= 0) eol else text.length
var j = i + 1
while (j < spans.size && spans[j].range.start < newEnd) {
// Consume all overlapping spans on this line
j++
}
out += HighlightSpan(TextRange(start, newEnd), s.kind)
i = j
continue
}
}
out += s
i++
}
return out
}
@@ -0,0 +1,247 @@
/*
* Copyright 2025 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.
*
*/
/*
* Minimal, optional AST-like structures captured during parsing.
*
* These classes are lightweight and focused on editor features:
* - highlighting (precise ranges for ids/types)
* - documentation (raw doc text + summary)
* - basic autocomplete (visible names per scope)
*
* They are populated only when an optional MiniAstSink is provided to the compiler.
*/
package net.sergeych.lyng.miniast
import net.sergeych.lyng.Pos
// Ranges reuse existing Pos to stay consistent with compiler diagnostics
data class MiniRange(val start: Pos, val end: Pos)
// Simple documentation payload: raw text and a derived summary (first non-empty line)
data class MiniDoc(
val range: MiniRange,
val raw: String,
val summary: String?,
val tags: Map<String, List<String>> = emptyMap()
)
sealed interface MiniNode { val range: MiniRange }
// Identifier roles we can confidently assign at parse time without binding
enum class IdRole {
DeclFun, DeclClass, DeclVal, DeclVar, Param, Label, TypeName, Ref
}
// Type references (syntax-level, position rich)
sealed interface MiniTypeRef : MiniNode
data class MiniTypeName(
override val range: MiniRange,
val segments: List<Segment>,
val nullable: Boolean
) : MiniTypeRef {
data class Segment(val name: String, val range: MiniRange)
}
data class MiniGenericType(
override val range: MiniRange,
val base: MiniTypeRef,
val args: List<MiniTypeRef>,
val nullable: Boolean
) : MiniTypeRef
data class MiniFunctionType(
override val range: MiniRange,
val receiver: MiniTypeRef?,
val params: List<MiniTypeRef>,
val returnType: MiniTypeRef,
val nullable: Boolean
) : MiniTypeRef
data class MiniTypeVar(
override val range: MiniRange,
val name: String,
val nullable: Boolean
) : MiniTypeRef
// Script and declarations (lean subset; can be extended later)
sealed interface MiniDecl : MiniNode {
val name: String
val doc: MiniDoc?
// Start position of the declaration name identifier in source; end can be derived as start + name.length
val nameStart: Pos
}
data class MiniScript(
override val range: MiniRange,
val declarations: MutableList<MiniDecl> = mutableListOf(),
val imports: MutableList<MiniImport> = mutableListOf(),
val statements: MutableList<MiniStmt> = mutableListOf()
) : MiniNode
data class MiniParam(
val name: String,
val type: MiniTypeRef?,
// Start position of parameter name in source
val nameStart: Pos
)
data class MiniFunDecl(
override val range: MiniRange,
override val name: String,
val params: List<MiniParam>,
val returnType: MiniTypeRef?,
val body: MiniBlock?,
override val doc: MiniDoc?,
override val nameStart: Pos
) : MiniDecl
data class MiniValDecl(
override val range: MiniRange,
override val name: String,
val mutable: Boolean,
val type: MiniTypeRef?,
val initRange: MiniRange?,
override val doc: MiniDoc?,
override val nameStart: Pos
) : MiniDecl
data class MiniClassDecl(
override val range: MiniRange,
override val name: String,
val bases: List<String>,
val bodyRange: MiniRange?,
val ctorFields: List<MiniCtorField> = emptyList(),
val classFields: List<MiniCtorField> = emptyList(),
override val doc: MiniDoc?,
override val nameStart: Pos
) : MiniDecl
data class MiniCtorField(
val name: String,
val mutable: Boolean,
val type: MiniTypeRef?,
val nameStart: Pos
)
sealed interface MiniStmt : MiniNode
data class MiniBlock(
override val range: MiniRange,
val locals: MutableList<String> = mutableListOf()
) : MiniStmt
data class MiniIdentifier(
override val range: MiniRange,
val name: String,
val role: IdRole
) : MiniNode
// Streaming sink to collect mini-AST during parsing. Implementations may assemble a tree or process events.
interface MiniAstSink {
fun onScriptStart(start: Pos) {}
fun onScriptEnd(end: Pos, script: MiniScript) {}
fun onDocCandidate(doc: MiniDoc) {}
fun onImport(node: MiniImport) {}
fun onFunDecl(node: MiniFunDecl) {}
fun onValDecl(node: MiniValDecl) {}
fun onClassDecl(node: MiniClassDecl) {}
fun onBlock(node: MiniBlock) {}
fun onIdentifier(node: MiniIdentifier) {}
}
// Tracer interface used by the compiler's type parser to simultaneously build
// a MiniTypeRef (syntax-level) while the semantic TypeDecl is produced as usual.
// Keep it extremely small for now: the current parser supports only simple
// identifiers with optional nullable suffix. We can extend callbacks later for
// generics and function types without breaking callers.
interface MiniTypeTrace {
/**
* Report a simple (possibly qualified in the future) type name.
* @param segments ordered list of name parts with precise ranges
* @param nullable whether the trailing `?` was present
*/
fun onSimpleName(segments: List<MiniTypeName.Segment>, nullable: Boolean)
}
// A simple builder that assembles a MiniScript tree from sink callbacks.
class MiniAstBuilder : MiniAstSink {
private var currentScript: MiniScript? = null
private val blocks = ArrayDeque<MiniBlock>()
private var lastDoc: MiniDoc? = null
private var scriptDepth: Int = 0
fun build(): MiniScript? = currentScript
override fun onScriptStart(start: Pos) {
if (scriptDepth == 0) {
currentScript = MiniScript(MiniRange(start, start))
}
scriptDepth++
}
override fun onScriptEnd(end: Pos, script: MiniScript) {
scriptDepth = (scriptDepth - 1).coerceAtLeast(0)
if (scriptDepth == 0) {
// finalize root range only when closing the outermost script
currentScript = currentScript?.copy(range = MiniRange(currentScript!!.range.start, end))
}
}
override fun onDocCandidate(doc: MiniDoc) {
lastDoc = doc
}
override fun onImport(node: MiniImport) {
currentScript?.imports?.add(node)
}
override fun onFunDecl(node: MiniFunDecl) {
val attach = node.copy(doc = node.doc ?: lastDoc)
currentScript?.declarations?.add(attach)
lastDoc = null
}
override fun onValDecl(node: MiniValDecl) {
val attach = node.copy(doc = node.doc ?: lastDoc)
currentScript?.declarations?.add(attach)
lastDoc = null
}
override fun onClassDecl(node: MiniClassDecl) {
val attach = node.copy(doc = node.doc ?: lastDoc)
currentScript?.declarations?.add(attach)
lastDoc = null
}
override fun onBlock(node: MiniBlock) {
blocks.addLast(node)
}
}
// Import statement representation for highlighting and docs
data class MiniImport(
override val range: MiniRange,
val segments: List<Segment>
) : MiniNode {
data class Segment(val name: String, val range: MiniRange)
}
@@ -43,11 +43,18 @@ class ObjFlowBuilder(val output: SendChannel<Obj>) : Obj() {
if (!channel.isClosedForSend)
channel.send(data)
else
// Flow consumer is no longer collecting; signal producer to stop
throw ScriptFlowIsNoMoreCollected()
} catch (x: Exception) {
if (x !is CancellationException)
x.printStackTrace()
throw ScriptFlowIsNoMoreCollected()
// Any failure to send (including closed channel) should gracefully stop the producer.
// Do not print stack traces here to keep test output clean on JVM.
if (x is CancellationException) {
// Cancellation is a normal control-flow event
throw ScriptFlowIsNoMoreCollected()
} else {
// Treat other send failures as normal flow termination as well
throw ScriptFlowIsNoMoreCollected()
}
}
ObjVoid
}
@@ -63,10 +70,10 @@ private fun createLyngFlowInput(scope: Scope, producer: Statement): ReceiveChann
try {
producer.execute(builderScope)
} catch (x: ScriptFlowIsNoMoreCollected) {
x.printStackTrace()
// premature flow closing, OK
} catch (x: Exception) {
x.printStackTrace()
// Suppress stack traces in background producer to avoid noisy stderr during tests.
// If needed, consider routing to a logger in the future.
}
channel.close()
}
@@ -87,13 +87,25 @@ suspend fun Obj.enumerate(scope: Scope, callback: suspend (Obj) -> Boolean) {
val hasNext = iterator.getInstanceMethod(scope, "hasNext")
val next = iterator.getInstanceMethod(scope, "next")
var closeIt = false
while (hasNext.invoke(scope, iterator).toBool()) {
val nextValue = next.invoke(scope, iterator)
if (!callback(nextValue)) {
closeIt = true
break
try {
while (hasNext.invoke(scope, iterator).toBool()) {
val nextValue = next.invoke(scope, iterator)
val shouldContinue = try {
callback(nextValue)
} catch (e: Exception) {
// iteration aborted due to exception in callback
closeIt = true
throw e
}
if (!shouldContinue) {
closeIt = true
break
}
}
} finally {
if (closeIt) {
// Best-effort cancel on premature termination
iterator.invokeInstanceMethod(scope, "cancelIteration") { ObjVoid }
}
}
if (closeIt)
iterator.invokeInstanceMethod(scope, "cancelIteration") { ObjVoid }
}
}
@@ -75,6 +75,12 @@ class ObjMapEntry(val key: Obj, val value: Obj) : Obj() {
addFn("size") { 2.toObj() }
}
}
override suspend fun plus(scope: Scope, other: Obj): Obj {
// Build a new map starting from this entry, then merge `other`.
val result = ObjMap(mutableMapOf(key to value))
return result.plus(scope, other)
}
}
class ObjMap(val map: MutableMap<Obj, Obj> = mutableMapOf()) : Obj() {
@@ -96,7 +102,22 @@ class ObjMap(val map: MutableMap<Obj, Obj> = mutableMapOf()) : Obj() {
if( other is ObjMap && other.map == map) return 0
return -1
}
override fun toString(): String = map.toString()
override suspend fun toString(scope: Scope, calledFromLyng: Boolean): ObjString {
val reusult = buildString {
append("Map(")
var first = true
for( (k,v) in map) {
if( !first ) append(",")
append(k.inspect(scope))
append(" => ")
append(v.toString(scope).value)
first = false
}
append(")")
}
return ObjString(reusult)
}
override fun hashCode(): Int {
return map.hashCode()
@@ -187,4 +208,44 @@ class ObjMap(val map: MutableMap<Obj, Obj> = mutableMapOf()) : Obj() {
}
}
}
// Merge operations
override suspend fun plus(scope: Scope, other: Obj): Obj {
val result = ObjMap(map.toMutableMap())
result.mergeIn(scope, other)
return result
}
override suspend fun plusAssign(scope: Scope, other: Obj): Obj {
mergeIn(scope, other)
return this
}
private suspend fun mergeIn(scope: Scope, other: Obj) {
when (other) {
is ObjMap -> {
// Rightmost wins: copy all entries from `other` over existing ones
for ((k, v) in other.map) {
val key = k as? ObjString ?: scope.raiseIllegalArgument("map merge expects string keys; got $k")
map[key] = v
}
}
is ObjMapEntry -> {
val key = other.key as? ObjString ?: scope.raiseIllegalArgument("map merge expects string keys; got ${other.key}")
map[key] = other.value
}
is ObjList -> {
// Treat as list of map entries
for (e in other.list) {
val entry = when (e) {
is ObjMapEntry -> e
else -> scope.raiseIllegalArgument("map can only be merged with MapEntry elements; got $e")
}
val key = entry.key as? ObjString ?: scope.raiseIllegalArgument("map merge expects string keys; got ${entry.key}")
map[key] = entry.value
}
}
else -> scope.raiseIllegalArgument("map can only be merged with Map, MapEntry, or List<MapEntry>")
}
}
}
@@ -15,6 +15,8 @@
*
*/
@file:Suppress("INLINE_NOT_NEEDED", "REDUNDANT_INLINE")
package net.sergeych.lyng.obj
import net.sergeych.lyng.*
@@ -62,9 +64,9 @@ enum class BinOp {
/** R-value reference for unary operations. */
class UnaryOpRef(private val op: UnaryOp, private val a: ObjRef) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
val fastRval = PerfFlags.RVAL_FASTPATH
val v = if (fastRval) a.evalValue(scope) else a.get(scope).value
if (net.sergeych.lyng.PerfFlags.PRIMITIVE_FASTOPS) {
if (PerfFlags.PRIMITIVE_FASTOPS) {
val rFast: Obj? = when (op) {
UnaryOp.NOT -> if (v is ObjBool) if (!v.value) ObjTrue else ObjFalse else null
UnaryOp.NEGATE -> when (v) {
@@ -75,7 +77,7 @@ class UnaryOpRef(private val op: UnaryOp, private val a: ObjRef) : ObjRef {
UnaryOp.BITNOT -> if (v is ObjInt) ObjInt(v.value.inv()) else null
}
if (rFast != null) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.primitiveFastOpsHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.primitiveFastOpsHit++
return rFast.asReadonly
}
}
@@ -91,11 +93,11 @@ class UnaryOpRef(private val op: UnaryOp, private val a: ObjRef) : ObjRef {
/** R-value reference for binary operations. */
class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val right: ObjRef) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
val a = if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) left.evalValue(scope) else left.get(scope).value
val b = if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) right.evalValue(scope) else right.get(scope).value
val a = if (PerfFlags.RVAL_FASTPATH) left.evalValue(scope) else left.get(scope).value
val b = if (PerfFlags.RVAL_FASTPATH) right.evalValue(scope) else right.get(scope).value
// Primitive fast paths for common cases (guarded by PerfFlags.PRIMITIVE_FASTOPS)
if (net.sergeych.lyng.PerfFlags.PRIMITIVE_FASTOPS) {
if (PerfFlags.PRIMITIVE_FASTOPS) {
// Fast boolean ops when both operands are ObjBool
if (a is ObjBool && b is ObjBool) {
val r: Obj? = when (op) {
@@ -106,7 +108,7 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
else -> null
}
if (r != null) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.primitiveFastOpsHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.primitiveFastOpsHit++
return r.asReadonly
}
}
@@ -134,7 +136,7 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
else -> null
}
if (r != null) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.primitiveFastOpsHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.primitiveFastOpsHit++
return r.asReadonly
}
}
@@ -151,7 +153,7 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
else -> null
}
if (r != null) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.primitiveFastOpsHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.primitiveFastOpsHit++
return r.asReadonly
}
}
@@ -169,7 +171,7 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
else -> null
}
if (r != null) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.primitiveFastOpsHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.primitiveFastOpsHit++
return r.asReadonly
}
}
@@ -177,19 +179,19 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
if (op == BinOp.PLUS) {
when {
a is ObjString && b is ObjInt -> {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.primitiveFastOpsHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.primitiveFastOpsHit++
return ObjString(a.value + b.value.toString()).asReadonly
}
a is ObjString && b is ObjChar -> {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.primitiveFastOpsHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.primitiveFastOpsHit++
return ObjString(a.value + b.value).asReadonly
}
b is ObjString && a is ObjInt -> {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.primitiveFastOpsHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.primitiveFastOpsHit++
return ObjString(a.value.toString() + b.value).asReadonly
}
b is ObjString && a is ObjChar -> {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.primitiveFastOpsHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.primitiveFastOpsHit++
return ObjString(a.value.toString() + b.value).asReadonly
}
}
@@ -213,7 +215,7 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
else -> null
}
if (rNum != null) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.primitiveFastOpsHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.primitiveFastOpsHit++
return rNum.asReadonly
}
}
@@ -260,7 +262,7 @@ class ConditionalRef(
private val ifFalse: ObjRef
) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
val condVal = if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) condition.evalValue(scope) else condition.get(scope).value
val condVal = if (PerfFlags.RVAL_FASTPATH) condition.evalValue(scope) else condition.get(scope).value
val condTrue = when (condVal) {
is ObjBool -> condVal.value
is ObjInt -> condVal.value != 0L
@@ -279,8 +281,8 @@ class CastRef(
private val atPos: Pos,
) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
val v0 = if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) valueRef.evalValue(scope) else valueRef.get(scope).value
val t = if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) typeRef.evalValue(scope) else typeRef.get(scope).value
val v0 = if (PerfFlags.RVAL_FASTPATH) valueRef.evalValue(scope) else valueRef.get(scope).value
val t = if (PerfFlags.RVAL_FASTPATH) typeRef.evalValue(scope) else typeRef.get(scope).value
val target = (t as? ObjClass) ?: scope.raiseClassCastError("${'$'}t is not the class instance")
// unwrap qualified views
val v = when (v0) {
@@ -323,7 +325,7 @@ class AssignOpRef(
) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
val x = target.get(scope).value
val y = if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) value.evalValue(scope) else value.get(scope).value
val y = if (PerfFlags.RVAL_FASTPATH) value.evalValue(scope) else value.get(scope).value
val inPlace: Obj? = when (op) {
BinOp.PLUS -> x.plusAssign(scope, y)
BinOp.MINUS -> x.minusAssign(scope, y)
@@ -380,7 +382,7 @@ class IncDecRef(
/** Elvis operator reference: a ?: b */
class ElvisRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
val fastRval = PerfFlags.RVAL_FASTPATH
val a = if (fastRval) left.evalValue(scope) else left.get(scope).value
val r = if (a != ObjNull) a else if (fastRval) right.evalValue(scope) else right.get(scope).value
return r.asReadonly
@@ -390,8 +392,8 @@ class ElvisRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
/** Logical OR with short-circuit: a || b */
class LogicalOrRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
val fastPrim = net.sergeych.lyng.PerfFlags.PRIMITIVE_FASTOPS
val fastRval = PerfFlags.RVAL_FASTPATH
val fastPrim = PerfFlags.PRIMITIVE_FASTOPS
val a = if (fastRval) left.evalValue(scope) else left.get(scope).value
if ((a as? ObjBool)?.value == true) return ObjTrue.asReadonly
val b = if (fastRval) right.evalValue(scope) else right.get(scope).value
@@ -408,8 +410,8 @@ class LogicalOrRef(private val left: ObjRef, private val right: ObjRef) : ObjRef
class LogicalAndRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
// Hoist flags to locals for JIT friendliness
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
val fastPrim = net.sergeych.lyng.PerfFlags.PRIMITIVE_FASTOPS
val fastRval = PerfFlags.RVAL_FASTPATH
val fastPrim = PerfFlags.PRIMITIVE_FASTOPS
val a = if (fastRval) left.evalValue(scope) else left.get(scope).value
if ((a as? ObjBool)?.value == false) return ObjFalse.asReadonly
val b = if (fastRval) right.evalValue(scope) else right.get(scope).value
@@ -459,18 +461,18 @@ class FieldRef(
private var rAccesses: Int = 0; private var rMisses: Int = 0; private var rPromotedTo4: Boolean = false
private var wAccesses: Int = 0; private var wMisses: Int = 0; private var wPromotedTo4: Boolean = false
private inline fun size4ReadsEnabled(): Boolean =
net.sergeych.lyng.PerfFlags.FIELD_PIC_SIZE_4 ||
(net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_2_TO_4 && rPromotedTo4)
PerfFlags.FIELD_PIC_SIZE_4 ||
(PerfFlags.PIC_ADAPTIVE_2_TO_4 && rPromotedTo4)
private inline fun size4WritesEnabled(): Boolean =
net.sergeych.lyng.PerfFlags.FIELD_PIC_SIZE_4 ||
(net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_2_TO_4 && wPromotedTo4)
PerfFlags.FIELD_PIC_SIZE_4 ||
(PerfFlags.PIC_ADAPTIVE_2_TO_4 && wPromotedTo4)
private fun noteReadHit() {
if (!net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_2_TO_4) return
if (!PerfFlags.PIC_ADAPTIVE_2_TO_4) return
val a = (rAccesses + 1).coerceAtMost(1_000_000)
rAccesses = a
}
private fun noteReadMiss() {
if (!net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_2_TO_4) return
if (!PerfFlags.PIC_ADAPTIVE_2_TO_4) return
val a = (rAccesses + 1).coerceAtMost(1_000_000)
rAccesses = a
rMisses = (rMisses + 1).coerceAtMost(1_000_000)
@@ -482,12 +484,12 @@ class FieldRef(
}
}
private fun noteWriteHit() {
if (!net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_2_TO_4) return
if (!PerfFlags.PIC_ADAPTIVE_2_TO_4) return
val a = (wAccesses + 1).coerceAtMost(1_000_000)
wAccesses = a
}
private fun noteWriteMiss() {
if (!net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_2_TO_4) return
if (!PerfFlags.PIC_ADAPTIVE_2_TO_4) return
val a = (wAccesses + 1).coerceAtMost(1_000_000)
wAccesses = a
wMisses = (wMisses + 1).coerceAtMost(1_000_000)
@@ -498,15 +500,15 @@ class FieldRef(
}
override suspend fun get(scope: Scope): ObjRecord {
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
val fieldPic = net.sergeych.lyng.PerfFlags.FIELD_PIC
val picCounters = net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS
val fastRval = PerfFlags.RVAL_FASTPATH
val fieldPic = PerfFlags.FIELD_PIC
val picCounters = PerfFlags.PIC_DEBUG_COUNTERS
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
if (base == ObjNull && isOptional) return ObjNull.asMutable
if (fieldPic) {
val (key, ver) = receiverKeyAndVersion(base)
rGetter1?.let { g -> if (key == rKey1 && ver == rVer1) {
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
if (picCounters) PerfStats.fieldPicHit++
noteReadHit()
val rec0 = g(base, scope)
if (base is ObjClass) {
@@ -516,7 +518,7 @@ class FieldRef(
return rec0
} }
rGetter2?.let { g -> if (key == rKey2 && ver == rVer2) {
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
if (picCounters) PerfStats.fieldPicHit++
noteReadHit()
// move-to-front: promote 2→1
val tK = rKey2; val tV = rVer2; val tG = rGetter2
@@ -530,7 +532,7 @@ class FieldRef(
return rec0
} }
if (size4ReadsEnabled()) rGetter3?.let { g -> if (key == rKey3 && ver == rVer3) {
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
if (picCounters) PerfStats.fieldPicHit++
noteReadHit()
// move-to-front: promote 3→1
val tK = rKey3; val tV = rVer3; val tG = rGetter3
@@ -545,7 +547,7 @@ class FieldRef(
return rec0
} }
if (size4ReadsEnabled()) rGetter4?.let { g -> if (key == rKey4 && ver == rVer4) {
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
if (picCounters) PerfStats.fieldPicHit++
noteReadHit()
// move-to-front: promote 4→1
val tK = rKey4; val tV = rVer4; val tG = rGetter4
@@ -561,7 +563,7 @@ class FieldRef(
return rec0
} }
// Slow path
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicMiss++
if (picCounters) PerfStats.fieldPicMiss++
noteReadMiss()
val rec = try {
base.readField(scope, name)
@@ -606,8 +608,8 @@ class FieldRef(
}
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
val fieldPic = net.sergeych.lyng.PerfFlags.FIELD_PIC
val picCounters = net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS
val fieldPic = PerfFlags.FIELD_PIC
val picCounters = PerfFlags.PIC_DEBUG_COUNTERS
val base = target.get(scope).value
if (base == ObjNull && isOptional) {
// no-op on null receiver for optional chaining assignment
@@ -629,12 +631,12 @@ class FieldRef(
if (fieldPic) {
val (key, ver) = receiverKeyAndVersion(base)
wSetter1?.let { s -> if (key == wKey1 && ver == wVer1) {
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicSetHit++
if (picCounters) PerfStats.fieldPicSetHit++
noteWriteHit()
return s(base, scope, newValue)
} }
wSetter2?.let { s -> if (key == wKey2 && ver == wVer2) {
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicSetHit++
if (picCounters) PerfStats.fieldPicSetHit++
noteWriteHit()
// move-to-front: promote 2→1
val tK = wKey2; val tV = wVer2; val tS = wSetter2
@@ -643,7 +645,7 @@ class FieldRef(
return s(base, scope, newValue)
} }
if (size4WritesEnabled()) wSetter3?.let { s -> if (key == wKey3 && ver == wVer3) {
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicSetHit++
if (picCounters) PerfStats.fieldPicSetHit++
noteWriteHit()
// move-to-front: promote 3→1
val tK = wKey3; val tV = wVer3; val tS = wSetter3
@@ -653,7 +655,7 @@ class FieldRef(
return s(base, scope, newValue)
} }
if (size4WritesEnabled()) wSetter4?.let { s -> if (key == wKey4 && ver == wVer4) {
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicSetHit++
if (picCounters) PerfStats.fieldPicSetHit++
noteWriteHit()
// move-to-front: promote 4→1
val tK = wKey4; val tV = wVer4; val tS = wSetter4
@@ -664,7 +666,7 @@ class FieldRef(
return s(base, scope, newValue)
} }
// Slow path
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicSetMiss++
if (picCounters) PerfStats.fieldPicSetMiss++
noteWriteMiss()
base.writeField(scope, name, newValue)
// Install move-to-front with a handle-aware setter; honor PIC size flag
@@ -707,26 +709,26 @@ class FieldRef(
override suspend fun evalValue(scope: Scope): Obj {
// Mirror get(), but return raw Obj to avoid transient ObjRecord on R-value paths
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
val fieldPic = net.sergeych.lyng.PerfFlags.FIELD_PIC
val picCounters = net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS
val fastRval = PerfFlags.RVAL_FASTPATH
val fieldPic = PerfFlags.FIELD_PIC
val picCounters = PerfFlags.PIC_DEBUG_COUNTERS
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
if (base == ObjNull && isOptional) return ObjNull
if (fieldPic) {
val (key, ver) = receiverKeyAndVersion(base)
rGetter1?.let { g -> if (key == rKey1 && ver == rVer1) {
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
if (picCounters) PerfStats.fieldPicHit++
return g(base, scope).value
} }
rGetter2?.let { g -> if (key == rKey2 && ver == rVer2) {
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
if (picCounters) PerfStats.fieldPicHit++
val tK = rKey2; val tV = rVer2; val tG = rGetter2
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
rKey1 = tK; rVer1 = tV; rGetter1 = tG
return g(base, scope).value
} }
if (size4ReadsEnabled()) rGetter3?.let { g -> if (key == rKey3 && ver == rVer3) {
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
if (picCounters) PerfStats.fieldPicHit++
val tK = rKey3; val tV = rVer3; val tG = rGetter3
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
@@ -734,7 +736,7 @@ class FieldRef(
return g(base, scope).value
} }
if (size4ReadsEnabled()) rGetter4?.let { g -> if (key == rKey4 && ver == rVer4) {
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
if (picCounters) PerfStats.fieldPicHit++
val tK = rKey4; val tV = rVer4; val tG = rGetter4
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
@@ -742,7 +744,7 @@ class FieldRef(
rKey1 = tK; rVer1 = tV; rGetter1 = tG
return g(base, scope).value
} }
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicMiss++
if (picCounters) PerfStats.fieldPicMiss++
val rec = base.readField(scope, name)
// install primary generic getter for this shape
when (base) {
@@ -789,7 +791,7 @@ class IndexRef(
else -> 0L to -1
}
override suspend fun get(scope: Scope): ObjRecord {
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
val fastRval = PerfFlags.RVAL_FASTPATH
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
if (base == ObjNull && isOptional) return ObjNull.asMutable
val idx = if (fastRval) index.evalValue(scope) else index.get(scope).value
@@ -810,7 +812,7 @@ class IndexRef(
val v = base.map[idx] ?: ObjNull
return v.asMutable
}
if (net.sergeych.lyng.PerfFlags.INDEX_PIC) {
if (PerfFlags.INDEX_PIC) {
// Polymorphic inline cache for other common shapes
val (key, ver) = when (base) {
is ObjInstance -> base.objClass.classId to base.objClass.layoutVersion
@@ -819,26 +821,26 @@ class IndexRef(
}
if (key != 0L) {
rGetter1?.let { g -> if (key == rKey1 && ver == rVer1) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.indexPicHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.indexPicHit++
return g(base, scope, idx).asMutable
} }
rGetter2?.let { g -> if (key == rKey2 && ver == rVer2) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.indexPicHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.indexPicHit++
val tk = rKey2; val tv = rVer2; val tg = rGetter2
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
rKey1 = tk; rVer1 = tv; rGetter1 = tg
return g(base, scope, idx).asMutable
} }
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) rGetter3?.let { g -> if (key == rKey3 && ver == rVer3) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.indexPicHit++
if (PerfFlags.INDEX_PIC_SIZE_4) rGetter3?.let { g -> if (key == rKey3 && ver == rVer3) {
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.indexPicHit++
val tk = rKey3; val tv = rVer3; val tg = rGetter3
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
rKey1 = tk; rVer1 = tv; rGetter1 = tg
return g(base, scope, idx).asMutable
} }
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) rGetter4?.let { g -> if (key == rKey4 && ver == rVer4) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.indexPicHit++
if (PerfFlags.INDEX_PIC_SIZE_4) rGetter4?.let { g -> if (key == rKey4 && ver == rVer4) {
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.indexPicHit++
val tk = rKey4; val tv = rVer4; val tg = rGetter4
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
@@ -847,9 +849,9 @@ class IndexRef(
return g(base, scope, idx).asMutable
} }
// Miss: resolve and install generic handler
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.indexPicMiss++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.indexPicMiss++
val v = base.getAt(scope, idx)
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) {
if (PerfFlags.INDEX_PIC_SIZE_4) {
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
}
@@ -863,7 +865,7 @@ class IndexRef(
}
override suspend fun evalValue(scope: Scope): Obj {
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
val fastRval = PerfFlags.RVAL_FASTPATH
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
if (base == ObjNull && isOptional) return ObjNull
val idx = if (fastRval) index.evalValue(scope) else index.get(scope).value
@@ -882,7 +884,7 @@ class IndexRef(
if (base is ObjMap && idx is ObjString) {
return base.map[idx] ?: ObjNull
}
if (net.sergeych.lyng.PerfFlags.INDEX_PIC) {
if (PerfFlags.INDEX_PIC) {
// PIC path analogous to get(), but returning raw Obj
val (key, ver) = when (base) {
is ObjInstance -> base.objClass.classId to base.objClass.layoutVersion
@@ -891,26 +893,26 @@ class IndexRef(
}
if (key != 0L) {
rGetter1?.let { g -> if (key == rKey1 && ver == rVer1) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.indexPicHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.indexPicHit++
return g(base, scope, idx)
} }
rGetter2?.let { g -> if (key == rKey2 && ver == rVer2) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.indexPicHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.indexPicHit++
val tk = rKey2; val tv = rVer2; val tg = rGetter2
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
rKey1 = tk; rVer1 = tv; rGetter1 = tg
return g(base, scope, idx)
} }
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) rGetter3?.let { g -> if (key == rKey3 && ver == rVer3) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.indexPicHit++
if (PerfFlags.INDEX_PIC_SIZE_4) rGetter3?.let { g -> if (key == rKey3 && ver == rVer3) {
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.indexPicHit++
val tk = rKey3; val tv = rVer3; val tg = rGetter3
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
rKey1 = tk; rVer1 = tv; rGetter1 = tg
return g(base, scope, idx)
} }
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) rGetter4?.let { g -> if (key == rKey4 && ver == rVer4) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.indexPicHit++
if (PerfFlags.INDEX_PIC_SIZE_4) rGetter4?.let { g -> if (key == rKey4 && ver == rVer4) {
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.indexPicHit++
val tk = rKey4; val tv = rVer4; val tg = rGetter4
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
@@ -918,9 +920,9 @@ class IndexRef(
rKey1 = tk; rVer1 = tv; rGetter1 = tg
return g(base, scope, idx)
} }
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.indexPicMiss++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.indexPicMiss++
val v = base.getAt(scope, idx)
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) {
if (PerfFlags.INDEX_PIC_SIZE_4) {
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
}
@@ -934,7 +936,7 @@ class IndexRef(
}
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
val fastRval = PerfFlags.RVAL_FASTPATH
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
if (base == ObjNull && isOptional) {
// no-op on null receiver for optional chaining assignment
@@ -953,7 +955,7 @@ class IndexRef(
base.map[idx] = newValue
return
}
if (net.sergeych.lyng.PerfFlags.INDEX_PIC) {
if (PerfFlags.INDEX_PIC) {
// Polymorphic inline cache for index write
val (key, ver) = when (base) {
is ObjInstance -> base.objClass.classId to base.objClass.layoutVersion
@@ -968,14 +970,14 @@ class IndexRef(
wKey1 = tk; wVer1 = tv; wSetter1 = ts
s(base, scope, idx, newValue); return
} }
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) wSetter3?.let { s -> if (key == wKey3 && ver == wVer3) {
if (PerfFlags.INDEX_PIC_SIZE_4) wSetter3?.let { s -> if (key == wKey3 && ver == wVer3) {
val tk = wKey3; val tv = wVer3; val ts = wSetter3
wKey3 = wKey2; wVer3 = wVer2; wSetter3 = wSetter2
wKey2 = wKey1; wVer2 = wVer1; wSetter2 = wSetter1
wKey1 = tk; wVer1 = tv; wSetter1 = ts
s(base, scope, idx, newValue); return
} }
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) wSetter4?.let { s -> if (key == wKey4 && ver == wVer4) {
if (PerfFlags.INDEX_PIC_SIZE_4) wSetter4?.let { s -> if (key == wKey4 && ver == wVer4) {
val tk = wKey4; val tv = wVer4; val ts = wSetter4
wKey4 = wKey3; wVer4 = wVer3; wSetter4 = wSetter3
wKey3 = wKey2; wVer3 = wVer2; wSetter3 = wSetter2
@@ -985,7 +987,7 @@ class IndexRef(
} }
// Miss: perform write and install generic handler
base.putAt(scope, idx, newValue)
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) {
if (PerfFlags.INDEX_PIC_SIZE_4) {
wKey4 = wKey3; wVer4 = wVer3; wSetter4 = wSetter3
wKey3 = wKey2; wVer3 = wVer2; wSetter3 = wSetter2
}
@@ -1016,8 +1018,8 @@ class CallRef(
private val isOptionalInvoke: Boolean,
) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
val usePool = net.sergeych.lyng.PerfFlags.SCOPE_POOL
val fastRval = PerfFlags.RVAL_FASTPATH
val usePool = PerfFlags.SCOPE_POOL
val callee = if (fastRval) target.evalValue(scope) else target.get(scope).value
if (callee == ObjNull && isOptionalInvoke) return ObjNull.asReadonly
val callArgs = args.toArguments(scope, tailBlock)
@@ -1055,20 +1057,20 @@ class MethodCallRef(
private var mWindowAccesses: Int = 0
private var mWindowMisses: Int = 0
private inline fun size4MethodsEnabled(): Boolean =
net.sergeych.lyng.PerfFlags.METHOD_PIC_SIZE_4 ||
((net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_2_TO_4 || net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_METHODS_ONLY) && mPromotedTo4 && mFreezeWindowsLeft == 0)
PerfFlags.METHOD_PIC_SIZE_4 ||
((PerfFlags.PIC_ADAPTIVE_2_TO_4 || PerfFlags.PIC_ADAPTIVE_METHODS_ONLY) && mPromotedTo4 && mFreezeWindowsLeft == 0)
private fun noteMethodHit() {
if (!(net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_2_TO_4 || net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_METHODS_ONLY)) return
if (!(PerfFlags.PIC_ADAPTIVE_2_TO_4 || PerfFlags.PIC_ADAPTIVE_METHODS_ONLY)) return
val a = (mAccesses + 1).coerceAtMost(1_000_000)
mAccesses = a
if (net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_HEURISTIC) {
if (PerfFlags.PIC_ADAPTIVE_HEURISTIC) {
// Windowed tracking
mWindowAccesses = (mWindowAccesses + 1).coerceAtMost(1_000_000)
if (mWindowAccesses >= 256) endHeuristicWindow()
}
}
private fun noteMethodMiss() {
if (!(net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_2_TO_4 || net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_METHODS_ONLY)) return
if (!(PerfFlags.PIC_ADAPTIVE_2_TO_4 || PerfFlags.PIC_ADAPTIVE_METHODS_ONLY)) return
val a = (mAccesses + 1).coerceAtMost(1_000_000)
mAccesses = a
mMisses = (mMisses + 1).coerceAtMost(1_000_000)
@@ -1076,7 +1078,7 @@ class MethodCallRef(
if (mMisses * 100 / a > 20) mPromotedTo4 = true
mAccesses = 0; mMisses = 0
}
if (net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_HEURISTIC) {
if (PerfFlags.PIC_ADAPTIVE_HEURISTIC) {
mWindowAccesses = (mWindowAccesses + 1).coerceAtMost(1_000_000)
mWindowMisses = (mWindowMisses + 1).coerceAtMost(1_000_000)
if (mWindowAccesses >= 256) endHeuristicWindow()
@@ -1106,26 +1108,26 @@ class MethodCallRef(
}
override suspend fun get(scope: Scope): ObjRecord {
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
val methodPic = net.sergeych.lyng.PerfFlags.METHOD_PIC
val picCounters = net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS
val fastRval = PerfFlags.RVAL_FASTPATH
val methodPic = PerfFlags.METHOD_PIC
val picCounters = PerfFlags.PIC_DEBUG_COUNTERS
val base = if (fastRval) receiver.evalValue(scope) else receiver.get(scope).value
if (base == ObjNull && isOptional) return ObjNull.asReadonly
val callArgs = args.toArguments(scope, tailBlock)
if (methodPic) {
val (key, ver) = receiverKeyAndVersion(base)
mInvoker1?.let { inv -> if (key == mKey1 && ver == mVer1) {
if (picCounters) net.sergeych.lyng.PerfStats.methodPicHit++
if (picCounters) PerfStats.methodPicHit++
noteMethodHit()
return inv(base, scope, callArgs).asReadonly
} }
mInvoker2?.let { inv -> if (key == mKey2 && ver == mVer2) {
if (picCounters) net.sergeych.lyng.PerfStats.methodPicHit++
if (picCounters) PerfStats.methodPicHit++
noteMethodHit()
return inv(base, scope, callArgs).asReadonly
} }
if (size4MethodsEnabled()) mInvoker3?.let { inv -> if (key == mKey3 && ver == mVer3) {
if (picCounters) net.sergeych.lyng.PerfStats.methodPicHit++
if (picCounters) PerfStats.methodPicHit++
noteMethodHit()
// move-to-front: promote 3→1
val tK = mKey3; val tV = mVer3; val tI = mInvoker3
@@ -1135,7 +1137,7 @@ class MethodCallRef(
return inv(base, scope, callArgs).asReadonly
} }
if (size4MethodsEnabled()) mInvoker4?.let { inv -> if (key == mKey4 && ver == mVer4) {
if (picCounters) net.sergeych.lyng.PerfStats.methodPicHit++
if (picCounters) PerfStats.methodPicHit++
noteMethodHit()
// move-to-front: promote 4→1
val tK = mKey4; val tV = mVer4; val tI = mInvoker4
@@ -1146,7 +1148,7 @@ class MethodCallRef(
return inv(base, scope, callArgs).asReadonly
} }
// Slow path
if (picCounters) net.sergeych.lyng.PerfStats.methodPicMiss++
if (picCounters) PerfStats.methodPicMiss++
noteMethodMiss()
val result = try {
base.invokeInstanceMethod(scope, name, callArgs)
@@ -1232,34 +1234,26 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
// 1) Try fast slot/local
if (!PerfFlags.LOCAL_SLOT_PIC) {
scope.getSlotIndexOf(name)?.let {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.localVarPicHit++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.localVarPicHit++
return scope.getSlotRecord(it)
}
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.localVarPicMiss++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.localVarPicMiss++
// 2) Fallback to current-scope object or field on `this`
scope[name]?.let { return it }
val th = scope.thisObj
return when (th) {
is Obj -> th.readField(scope, name)
else -> scope.raiseError("symbol not defined: '$name'")
}
return scope.thisObj.readField(scope, name)
}
val hit = (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount())
val slot = if (hit) cachedSlot else resolveSlot(scope)
if (slot >= 0) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) {
if (hit) net.sergeych.lyng.PerfStats.localVarPicHit++ else net.sergeych.lyng.PerfStats.localVarPicMiss++
if (PerfFlags.PIC_DEBUG_COUNTERS) {
if (hit) PerfStats.localVarPicHit++ else PerfStats.localVarPicMiss++
}
return scope.getSlotRecord(slot)
}
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) net.sergeych.lyng.PerfStats.localVarPicMiss++
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.localVarPicMiss++
// 2) Fallback name in scope or field on `this`
scope[name]?.let { return it }
val th = scope.thisObj
return when (th) {
is Obj -> th.readField(scope, name)
else -> scope.raiseError("symbol not defined: '$name'")
}
return scope.thisObj.readField(scope, name)
}
override suspend fun evalValue(scope: Scope): Obj {
@@ -1268,22 +1262,14 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
scope.getSlotIndexOf(name)?.let { return scope.getSlotRecord(it).value }
// fallback to current-scope object or field on `this`
scope[name]?.let { return it.value }
val th = scope.thisObj
return when (th) {
is Obj -> th.readField(scope, name).value
else -> scope.raiseError("symbol not defined: '$name'")
}
return scope.thisObj.readField(scope, name).value
}
val hit = (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount())
val slot = if (hit) cachedSlot else resolveSlot(scope)
if (slot >= 0) return scope.getSlotRecord(slot).value
// Fallback name in scope or field on `this`
scope[name]?.let { return it.value }
val th = scope.thisObj
return when (th) {
is Obj -> th.readField(scope, name).value
else -> scope.raiseError("symbol not defined: '$name'")
}
return scope.thisObj.readField(scope, name).value
}
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
@@ -1301,12 +1287,8 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
return
}
// Fallback: write to field on `this`
val th = scope.thisObj
if (th is Obj) {
th.writeField(scope, name, newValue)
return
}
scope.raiseError("symbol not defined: '$name'")
scope.thisObj.writeField(scope, name, newValue)
return
}
val slot = if (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount()) cachedSlot else resolveSlot(scope)
if (slot >= 0) {
@@ -1320,12 +1302,8 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
else scope.raiseError("Cannot assign to immutable value")
return
}
val th = scope.thisObj
if (th is Obj) {
th.writeField(scope, name, newValue)
return
}
scope.raiseError("symbol not defined: '$name'")
scope.thisObj.writeField(scope, name, newValue)
return
}
}
@@ -1401,8 +1379,8 @@ class FastLocalVarRef(
val slot = if (ownerValid && cachedSlot >= 0) cachedSlot else resolveSlotInAncestry(scope)
val actualOwner = cachedOwnerScope
if (slot >= 0 && actualOwner != null) {
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) {
if (ownerValid) net.sergeych.lyng.PerfStats.fastLocalHit++ else net.sergeych.lyng.PerfStats.fastLocalMiss++
if (PerfFlags.PIC_DEBUG_COUNTERS) {
if (ownerValid) PerfStats.fastLocalHit++ else PerfStats.fastLocalMiss++
}
return actualOwner.getSlotRecord(slot)
}
@@ -1423,9 +1401,7 @@ class FastLocalVarRef(
// Fallback to standard name lookup (locals or closure chain) if the slot owner changed across suspension
scope[name]?.let { return it }
// As a last resort, treat as field on `this`
val th = scope.thisObj
if (th is Obj) return th.readField(scope, name)
scope.raiseError("local '$name' is not available in this scope")
return scope.thisObj.readField(scope, name)
}
override suspend fun evalValue(scope: Scope): Obj {
@@ -1450,9 +1426,7 @@ class FastLocalVarRef(
}
// Fallback to standard name lookup (locals or closure chain)
scope[name]?.let { return it.value }
val th = scope.thisObj
if (th is Obj) return th.readField(scope, name).value
scope.raiseError("local '$name' is not available in this scope")
return scope.thisObj.readField(scope, name).value
}
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
@@ -1485,12 +1459,8 @@ class FastLocalVarRef(
else scope.raiseError("Cannot assign to immutable value")
return
}
val th = scope.thisObj
if (th is Obj) {
th.writeField(scope, name, newValue)
return
}
scope.raiseError("local '$name' is not available in this scope")
scope.thisObj.writeField(scope, name, newValue)
return
}
}
@@ -1502,11 +1472,11 @@ class ListLiteralRef(private val entries: List<ListEntry>) : ObjRef {
for (e in entries) {
when (e) {
is ListEntry.Element -> {
val v = if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) e.ref.evalValue(scope) else e.ref.get(scope).value
val v = if (PerfFlags.RVAL_FASTPATH) e.ref.evalValue(scope) else e.ref.get(scope).value
list += v
}
is ListEntry.Spread -> {
val elements = if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) e.ref.evalValue(scope) else e.ref.get(scope).value
val elements = if (PerfFlags.RVAL_FASTPATH) e.ref.evalValue(scope) else e.ref.get(scope).value
when (elements) {
is ObjList -> {
// Grow underlying array once when possible
@@ -1522,6 +1492,37 @@ class ListLiteralRef(private val entries: List<ListEntry>) : ObjRef {
}
}
// --- Map literal support ---
sealed class MapLiteralEntry {
data class Named(val key: String, val value: ObjRef) : MapLiteralEntry()
data class Spread(val ref: ObjRef) : MapLiteralEntry()
}
class MapLiteralRef(private val entries: List<MapLiteralEntry>) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
val result = ObjMap(mutableMapOf())
for (e in entries) {
when (e) {
is MapLiteralEntry.Named -> {
val v = if (PerfFlags.RVAL_FASTPATH) e.value.evalValue(scope) else e.value.get(scope).value
result.map[ObjString(e.key)] = v
}
is MapLiteralEntry.Spread -> {
val m = if (PerfFlags.RVAL_FASTPATH) e.ref.evalValue(scope) else e.ref.get(scope).value
if (m !is ObjMap) scope.raiseIllegalArgument("spread element in map literal must be a Map")
// Enforce string keys for map literals
for ((k, v) in m.map) {
val sKey = k as? ObjString ?: scope.raiseIllegalArgument("spread map must have string keys; got $k")
result.map[sKey] = v
}
}
}
}
return result.asReadonly
}
}
/**
* Range literal: left .. right or left ..< right. Right may be omitted in certain contexts.
*/
@@ -1531,8 +1532,8 @@ class RangeRef(
private val isEndInclusive: Boolean
) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
val l = left?.let { if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) it.evalValue(scope) else it.get(scope).value } ?: ObjNull
val r = right?.let { if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) it.evalValue(scope) else it.get(scope).value } ?: ObjNull
val l = left?.let { if (PerfFlags.RVAL_FASTPATH) it.evalValue(scope) else it.get(scope).value } ?: ObjNull
val r = right?.let { if (PerfFlags.RVAL_FASTPATH) it.evalValue(scope) else it.get(scope).value } ?: ObjNull
return ObjRange(l, r, isEndInclusive = isEndInclusive).asReadonly
}
}
@@ -1544,7 +1545,7 @@ class AssignRef(
private val atPos: Pos,
) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
val v = if (net.sergeych.lyng.PerfFlags.RVAL_FASTPATH) value.evalValue(scope) else value.get(scope).value
val v = if (PerfFlags.RVAL_FASTPATH) value.evalValue(scope) else value.get(scope).value
val rec = target.get(scope)
if (!rec.isMutable) throw ScriptError(atPos, "cannot assign to immutable variable")
if (rec.value.assign(scope, v) == null) {
@@ -34,8 +34,19 @@ class ObjSet(val set: MutableSet<Obj> = mutableSetOf()) : Obj() {
return ObjSet(
if (other is ObjSet)
(set + other.set).toMutableSet()
else
(set + other).toMutableSet()
else {
if( other.isInstanceOf(ObjIterable) ) {
val otherSet = mutableSetOf<Obj>()
other.enumerate(scope) {
otherSet += it
true
}
(set + otherSet).toMutableSet()
}
else {
(set + other).toMutableSet()
}
}
)
}
@@ -51,12 +62,14 @@ class ObjSet(val set: MutableSet<Obj> = mutableSetOf()) : Obj() {
else -> {
if (other.isInstanceOf(ObjIterable)) {
val i = other.invokeInstanceMethod(scope, "iterable")
while (i.invokeInstanceMethod(scope, "hasNext").toBool()) {
set += i.invokeInstanceMethod(scope, "next")
val otherSet = mutableSetOf<Obj>()
other.enumerate(scope) {
otherSet += it
true
}
set += otherSet
}
set += other
else set += other
}
}
return this
@@ -70,9 +83,19 @@ class ObjSet(val set: MutableSet<Obj> = mutableSetOf()) : Obj() {
}
override suspend fun minus(scope: Scope, other: Obj): Obj {
if (other !is ObjSet)
scope.raiseIllegalArgument("set operator - requires another set")
return ObjSet(set.minus(other.set).toMutableSet())
return when {
other is ObjSet -> ObjSet(set.minus(other.set).toMutableSet())
other.isInstanceOf(ObjIterable) -> {
val otherSet = mutableSetOf<Obj>()
other.enumerate(scope) {
otherSet += it
true
}
ObjSet((set - otherSet).toMutableSet())
}
else ->
scope.raiseIllegalArgument("set operator - requires another set or Iterable")
}
}
override fun toString(): String {
@@ -0,0 +1,132 @@
/*
* Copyright 2025 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.
*
*/
/*
* Mini binding tests for highlighting/editor features
*/
package net.sergeych.lyng
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.binding.Binder
import net.sergeych.lyng.miniast.MiniAstBuilder
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class BindingTest {
private suspend fun bind(code: String): net.sergeych.lyng.binding.BindingSnapshot {
val src = code.trimIndent()
val sink = MiniAstBuilder()
Compiler.compileWithMini(src, sink)
val mini = sink.build()
assertNotNull(mini, "MiniScript should be built")
return Binder.bind(src, mini!!)
}
@Test
fun binds_params_and_locals() = runTest {
val snap = bind(
"""
fun f(a:Int){
val x = 1
a + x
}
"""
)
// Expect at least one Param symbol "a" and one Val symbol "x"
val aIds = snap.symbols.filter { it.name == "a" }.map { it.id }
val xIds = snap.symbols.filter { it.name == "x" }.map { it.id }
assertTrue(aIds.isNotEmpty())
assertTrue(xIds.isNotEmpty())
// Both should have at least one reference across any symbol with that name
val aRefs = snap.references.count { it.symbolId in aIds }
val xRefs = snap.references.count { it.symbolId in xIds }
assertEquals(1, aRefs)
assertEquals(1, xRefs)
}
@Test
fun binds_top_level_val_usage() = runTest {
val snap = bind(
"""
val x = 1
x + 1
"""
)
val xSym = snap.symbols.firstOrNull { it.name == "x" }
assertNotNull(xSym)
// One reference usage to top-level x
val refs = snap.references.filter { it.symbolId == xSym!!.id }
assertEquals(1, refs.size)
}
@Test
fun shadowing_scopes() = runTest {
val snap = bind(
"""
val x = 1
fun f(){
val x = 2
x
}
"""
)
val allX = snap.symbols.filter { it.name == "x" }
// Expect at least two x symbols (top-level and local)
assertEquals(true, allX.size >= 2)
// The single reference inside f body should bind to the inner x (containerId != null)
val localXs = allX.filter { it.containerId != null }
assertEquals(true, localXs.isNotEmpty())
val localX = localXs.maxBy { it.declStart }
val refsToLocal = snap.references.count { it.symbolId == localX.id }
assertEquals(1, refsToLocal)
}
@Test
fun class_fields_basic() = runTest {
val snap = bind(
"""
class C {
val foo = 1
fun bar(){ foo }
}
"""
)
val fooField = snap.symbols.firstOrNull { it.name == "foo" }
assertNotNull(fooField)
// Should have at least one reference (usage in bar)
val refs = snap.references.count { it.symbolId == fooField!!.id }
assertEquals(1, refs)
}
@Test
fun ctor_params_as_fields() = runTest {
val snap = bind(
"""
class C(val x:Int){
fun f(){ x }
}
"""
)
val xField = snap.symbols.firstOrNull { it.name == "x" }
assertNotNull(xField)
val refs = snap.references.count { it.symbolId == xField!!.id }
assertEquals(1, refs)
}
}
@@ -0,0 +1,144 @@
/*
* Copyright 2025 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.
*
*/
/*
* Map literal and merging tests
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.ScriptError
import net.sergeych.lyng.eval
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class MapLiteralTest {
@Test
fun basicStringAndIdKeysAndShorthand() = runTest {
eval(
"""
val x = 2
val y = 2
val m = { "a": 1, x: x*10, y: }
assertEquals(1, m["a"])
assertEquals(20, m["x"])
assertEquals(2, m["y"])
""".trimIndent()
)
}
@Test
fun spreadsAndOverwriteOrder() = runTest {
eval(
"""
val base = { a: 1, b: 2 }
val m = { a: 0, ...base, b: 3, c: 4 }
assertEquals(1, m["a"]) // base overwrites a:0
assertEquals(3, m["b"]) // literal overwrites spread
assertEquals(4, m["c"]) // new key
""".trimIndent()
)
}
@Test
fun trailingCommaAccepted() = runTest {
eval(
"""
val m = { "a": 1, b: 2, }
assertEquals(1, m["a"])
assertEquals(2, m["b"])
""".trimIndent()
)
}
@Test
fun duplicateLiteralKeysAreCompileTimeError() = runTest {
assertFailsWith<ScriptError> {
eval("""{ foo: 1, " + '"' + "foo" + '"' + ": 2 }""".trimIndent())
}
assertFailsWith<ScriptError> {
eval("""{ foo:, foo: 2 }""".trimIndent())
}
}
@Test
fun lambdaDisambiguationWithTypedArgs() = runTest {
eval(
"""
val f = { x: Int, y: Int -> x + y }
assertEquals(3, f(1,2))
""".trimIndent()
)
}
@Test
fun plusMergingAndPlusAssign() = runTest {
eval(
"""
val m1 = ("1" => 10) + ("2" => 20)
assertEquals(10, m1["1"])
assertEquals(20, m1["2"])
val m2 = { "1": 10 } + ("2" => 20)
assertEquals(10, m2["1"])
assertEquals(20, m2["2"])
val m3 = { "1": 10 } + { "2": 20 }
assertEquals(10, m3["1"])
assertEquals(20, m3["2"])
var m = { "a": 1 }
m += ("b" => 2)
assertEquals(1, m["a"])
assertEquals(2, m["b"])
""".trimIndent()
)
}
@Test
fun spreadNonMapIsRuntimeError() = runTest {
assertFailsWith<ExecutionError> {
eval("""{ ...[1,2,3] }""")
}
}
@Test
fun spreadNonStringKeysIsRuntimeError() = runTest {
assertFailsWith<ExecutionError> {
eval("""{ ...Map(1 => "x") }""")
}
}
@Test
fun mergeNonStringKeyIsRuntimeError() = runTest {
assertFailsWith<ExecutionError> {
eval("""
val e = (1 => "x")
{ "a": 1 } + e
""")
}
}
@Test
fun shorthandUndefinedIdIsError() = runTest {
assertFailsWith<ExecutionError> {
eval("""{ z: }""")
}
}
}
@@ -0,0 +1,134 @@
/*
* Copyright 2025 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.
*
*/
/*
* Mini-AST capture tests
*/
package net.sergeych.lyng
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.miniast.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class MiniAstTest {
private suspend fun compileWithMini(code: String): Pair<Script, net.sergeych.lyng.miniast.MiniAstBuilder> {
val sink = MiniAstBuilder()
val script = Compiler.compileWithMini(code.trimIndent(), sink)
return script to sink
}
@Test
fun miniAst_captures_import_segments() = runTest {
val code = """
import lyng.stdlib
val x = 1
"""
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
val imps = mini!!.imports
assertTrue(imps.isNotEmpty(), "imports should be captured")
val first = imps.first()
val segNames = first.segments.map { it.name }
assertEquals(listOf("lyng", "stdlib"), segNames)
// Ensure ranges are valid and ordered
for (seg in first.segments) {
assertTrue(seg.range.start.line == first.range.start.line)
assertTrue(seg.range.start.column <= seg.range.end.column)
}
}
@Test
fun miniAst_captures_fun_docs_and_types() = runTest {
val code = """
// Summary: does foo
// details can be here
fun foo(a: Int, b: pkg.String?): Boolean {
true
}
"""
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
val fn = mini!!.declarations.filterIsInstance<MiniFunDecl>().firstOrNull { it.name == "foo" }
assertNotNull(fn, "function decl should be captured")
// Doc
assertNotNull(fn.doc)
assertEquals("Summary: does foo", fn.doc!!.summary)
assertTrue(fn.doc!!.raw.contains("details"))
// Params
assertEquals(2, fn.params.size)
val p1 = fn.params[0]
val p2 = fn.params[1]
val t1 = p1.type as MiniTypeName
assertEquals(listOf("Int"), t1.segments.map { it.name })
assertEquals(false, t1.nullable)
val t2 = p2.type as MiniTypeName
assertEquals(listOf("pkg", "String"), t2.segments.map { it.name })
assertEquals(true, t2.nullable)
// Return type
val rt = fn.returnType as MiniTypeName
assertEquals(listOf("Boolean"), rt.segments.map { it.name })
assertEquals(false, rt.nullable)
}
@Test
fun miniAst_captures_val_type_and_doc() = runTest {
val code = """
// docs for x
val x: List<String> = ["a", "b"]
"""
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
val vd = mini!!.declarations.filterIsInstance<net.sergeych.lyng.miniast.MiniValDecl>().firstOrNull { it.name == "x" }
assertNotNull(vd)
assertNotNull(vd.doc)
assertEquals("docs for x", vd.doc!!.summary)
val ty = vd.type
assertNotNull(ty)
val gen = ty as MiniGenericType
val base = gen.base as MiniTypeName
assertEquals(listOf("List"), base.segments.map { it.name })
assertEquals(1, gen.args.size)
val arg0 = gen.args[0] as MiniTypeName
assertEquals(listOf("String"), arg0.segments.map { it.name })
assertEquals(false, gen.nullable)
assertNotNull(vd.initRange)
}
@Test
fun miniAst_captures_class_bases_and_doc() = runTest {
val code = """
/** Class C docs */
class C: Base1, Base2 {}
"""
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
val cd = mini!!.declarations.filterIsInstance<MiniClassDecl>().firstOrNull { it.name == "C" }
assertNotNull(cd)
assertNotNull(cd.doc)
assertTrue(cd.doc!!.raw.contains("Class C docs"))
// Bases captured as plain names for now
assertEquals(listOf("Base1", "Base2"), cd.bases)
}
}
@@ -0,0 +1,154 @@
/*
* Copyright 2025 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.
*
*/
/*
* Named arguments and named splats test suite
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.eval
import kotlin.test.Test
import kotlin.test.assertFailsWith
class NamedArgsTest {
@Test
fun basicNamedArgsAndDefaults() = runTest {
eval(
"""
fun test(a="foo", b="bar", c="bazz") { [a, b, c] }
assertEquals( ["foo", "b", "bazz"], test(b: "b") )
assertEquals( ["a", "bar", "c"], test("a", c: "c") )
""".trimIndent()
)
}
@Test
fun positionalAfterNamedIsError() = runTest {
assertFailsWith<ExecutionError> {
eval(
"""
fun f(a, b) { [a,b] }
f(a: 1, 2)
""".trimIndent()
)
}
}
@Test
fun namedSplatsBasic() = runTest {
eval(
"""
fun test(a="a", b="b", c="c", d="d") { [a, b, c, d] }
val r = test("A?", ...Map("d" => "D!", "b" => "B!"))
assertEquals(["A?","B!","c","D!"], r)
""".trimIndent()
)
}
@Test
fun namedSplatsNonStringKeysError() = runTest {
assertFailsWith<ExecutionError> {
eval(
"""
fun test(a,b) {}
test(1, ...Map(1 => "x"))
""".trimIndent()
)
}
}
@Test
fun trailingBlockConflictWhenLastNamed() = runTest {
// Error: last parameter already assigned by a named argument; trailing block must be rejected
assertFailsWith<ExecutionError> {
eval(
"""
fun f(x, onDone) { onDone(x) }
// Name the last parameter inside parentheses, then try to pass a trailing block
f(1, onDone: { it }) { 42 }
""".trimIndent()
)
}
// Normal case still works when last parameter is not assigned by name
eval(
"""
fun f(x, onDone) { onDone(x) }
var res = 0
f(1) { it -> res = it }
assertEquals(1, res)
""".trimIndent()
)
}
@Test
fun duplicateNamedIsError() = runTest {
assertFailsWith<ExecutionError> {
eval(
"""
fun f(a,b,c) {}
f(a: 1, a: 2)
""".trimIndent()
)
}
assertFailsWith<ExecutionError> {
eval(
"""
fun f(a,b,c) {}
f(a: 1, ...Map("a" => 2))
""".trimIndent()
)
}
}
@Test
fun unknownParameterIsError() = runTest {
assertFailsWith<ExecutionError> {
eval(
"""
fun f(a,b) {}
f(z: 1)
""".trimIndent()
)
}
}
@Test
fun ellipsisCannotBeNamed() = runTest {
assertFailsWith<ExecutionError> {
eval(
"""
fun g(args..., tail) {}
g(args: [1], tail: 2)
""".trimIndent()
)
}
}
@Test
fun positionalSplatAfterNamedIsError() = runTest {
assertFailsWith<ExecutionError> {
eval(
"""
fun f(a,b,c) {}
f(a: 1, ...[2,3])
""".trimIndent()
)
}
}
}
+184 -2
View File
@@ -37,6 +37,97 @@ class ScriptTest {
println("version = ${LyngVersion}")
}
// --- Helpers to test iterator cancellation semantics ---
class ObjTestIterable : Obj() {
var cancelCount: Int = 0
override val objClass: ObjClass = type
companion object {
val type = ObjClass("TestIterable", ObjIterable).apply {
addFn("iterator") {
ObjTestIterator(thisAs<ObjTestIterable>())
}
addFn("cancelCount") { thisAs<ObjTestIterable>().cancelCount.toObj() }
}
}
}
class ObjTestIterator(private val owner: ObjTestIterable) : Obj() {
override val objClass: ObjClass = type
private var i = 0
private fun hasNext(): Boolean = i < 5
private fun next(): Obj = ObjInt((++i).toLong())
private fun cancelIteration() {
owner.cancelCount += 1
}
companion object {
val type = ObjClass("TestIterator", ObjIterator).apply {
addFn("hasNext") { thisAs<ObjTestIterator>().hasNext().toObj() }
addFn("next") { thisAs<ObjTestIterator>().next() }
addFn("cancelIteration") {
thisAs<ObjTestIterator>().cancelIteration()
ObjVoid
}
}
}
}
@Test
fun testForLoopDoesNotCancelOnNaturalCompletion() = runTest {
val scope = Script.newScope()
val ti = ObjTestIterable()
scope.addConst("ti", ti)
scope.eval(
"""
var s = 0
for( i in ti ) {
s += i
}
s
""".trimIndent()
)
assertEquals(0, ti.cancelCount)
}
@Test
fun testForLoopCancelsOnBreak() = runTest {
val scope = Script.newScope()
val ti = ObjTestIterable()
scope.addConst("ti", ti)
scope.eval(
"""
for( i in ti ) {
break
}
""".trimIndent()
)
assertEquals(1, ti.cancelCount)
}
@Test
fun testForLoopCancelsOnException() = runTest {
val scope = Script.newScope()
val ti = ObjTestIterable()
scope.addConst("ti", ti)
try {
scope.eval(
"""
for( i in ti ) {
throw "boom"
}
""".trimIndent()
)
fail("Exception expected")
} catch (_: Exception) {
// ignore
}
assertEquals(1, ti.cancelCount)
}
@Test
fun parseNewlines() {
fun check(expected: String, type: Token.Type, row: Int, col: Int, src: String, offset: Int = 0) {
@@ -2446,7 +2537,7 @@ class ScriptTest {
for( x in set ) println(x)
assert([1,2,3] == set.toList())
set += 4
assert(set.toList() == [1,2,3,4])
assertEquals(set.toList(), [1,2,3,4])
assert(set == Set(1,2,3,4))
val s1 = [1, 2].toSet()
@@ -2466,6 +2557,18 @@ class ScriptTest {
)
}
@Test
fun testSetAddRemoveSet() = runTest {
eval("""
val s1 = Set( 1, 2 3)
val s2 = Set( 3, 4 5)
assertEquals( Set(1,2,3,4,5), s1 + s2 )
assertEquals( Set(1,2,3,4,5), s1 + s2.toList() )
assertEquals( Set(1,2), s1 - s2 )
assertEquals( Set(1,2), s1 - s2.toList() )
""".trimIndent())
}
@Test
fun testLet() = runTest {
eval(
@@ -2569,7 +2672,6 @@ class ScriptTest {
x += i
}
delay(100)
println("-> "+x)
assert(x == 5050)
}
""".trimIndent())
@@ -3519,4 +3621,84 @@ class ScriptTest {
""".trimIndent()).toString())
}
@Test
fun testThrowReportsSource() = runTest {
try {
eval(
"""
// line 1
// line 2
throw "the test"
""".trimIndent()
)
} catch (se: ScriptError) {
println(se.message)
// Pos.line is zero-based
assertEquals(2, se.pos.line)
}
}
@Test
fun testRangeIsIterable() = runTest {
eval("""
val r = 1..10
assert( r is Iterable )
""".trimIndent())
}
@Test
fun testCallAndResultOrder() = runTest {
eval("""
import lyng.stdlib
fun test(a="a", b="b", c="c") { [a, b, c] }
// the parentheses here are in fact unnecessary:
val ok1 = (test { void }).last()
assert( ok1 is Callable)
// it should work without them, as the call test() {} must be executed
// first, then the result should be used to call methods on it:
// the parentheses here are in fact unnecessary:
val ok2 = test { void }.last()
assert( ok2 is Callable)
""".trimIndent())
}
// @Test
// fun namedArgsProposal() = runTest {
// eval("""
// import lyng.stdlib
//
// fun test(a="a", b="b", c="c") { [a, b, c] }
//
// val l = (test{ void }).last()
// println(l)
//
// """.trimIndent())
// }
// @Ignore
// @Test
// fun interpolationTest() = runTest {
// eval($$$"""
//
// val foo = "bar"
// val buzz = ["foo", "bar"]
//
// // 1. simple interpolation
// assertEquals( "bar", "$foo" )
// assertEquals( "bar", "${foo}" )
//
// // 2. escaping the dollar sign
// assertEquals( "$", "\$foo"[0] )
// assertEquals( "foo, "\$foo"[1..] )
//
// // 3. interpolation with expression
// assertEquals( "foo!. bar?", "${buzz[0]+"!"}. ${buzz[1]+"?"}" )
// """.trimIndent())
// }
}
@@ -0,0 +1,72 @@
/*
* Copyright 2025 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.highlight
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CommentEolTest {
@Test
fun singleLineCommentExtendsToEol() {
val line = "// see the difference: apply changes this to newly created Point:"
val text = "$line\nnext"
val spans = SimpleLyngHighlighter().highlight(text)
// Find the comment span
val cmt = spans.firstOrNull { it.kind == HighlightKind.Comment }
assertTrue(cmt != null, "Expected a comment span")
// It should start at 0 and extend exactly to the end of the line (before \n)
val eol = text.indexOf('\n')
assertEquals(0, cmt!!.range.start, "Comment should start at column 0")
assertEquals(eol, cmt.range.endExclusive, "Comment should extend to EOL")
// Ensure there is no other span overlapping within the same line
spans.filter { it !== cmt }.forEach {
assertTrue(it.range.start >= eol, "No span should start before EOL for single-line comment")
}
}
@Test
fun blockCommentNotExtendedPastClosing() {
val text = "/* block */ rest"
val spans = SimpleLyngHighlighter().highlight(text)
val cmt = spans.firstOrNull { it.kind == HighlightKind.Comment }
assertTrue(cmt != null, "Expected a block comment span")
// The comment should end right after "/* block */"
val expectedEnd = "/* block */".length
assertEquals(expectedEnd, cmt!!.range.endExclusive, "Block comment should not be extended to EOL")
}
@Test
fun twoSingleLineCommentsEachToTheirEol() {
val text = "// first\n// second\nend"
val spans = SimpleLyngHighlighter().highlight(text)
val cmts = spans.filter { it.kind == HighlightKind.Comment }
assertEquals(2, cmts.size, "Expected two single-line comment spans")
val eol1 = text.indexOf('\n')
assertEquals(0, cmts[0].range.start)
assertEquals(eol1, cmts[0].range.endExclusive)
val line2Start = eol1 + 1
val eol2 = text.indexOf('\n', line2Start)
assertEquals(line2Start, cmts[1].range.start)
assertEquals(eol2, cmts[1].range.endExclusive)
}
}
@@ -0,0 +1,84 @@
/*
* Copyright 2025 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.highlight
import kotlin.test.Test
import kotlin.test.assertTrue
class HighlightMappingTest {
private fun spansToLabeled(text: String, spans: List<HighlightSpan>): List<Pair<String, HighlightKind>> =
spans.map { text.substring(it.range.start, it.range.endExclusive) to it.kind }
@Test
fun keywordsAndIdentifiers() {
val text = "a and b or not c"
val spans = SimpleLyngHighlighter().highlight(text)
val labeled = spansToLabeled(text, spans)
// Expect identifiers and keywords in order
assertTrue(labeled.any { it.first == "a" && it.second == HighlightKind.Identifier })
assertTrue(labeled.any { it.first == "and" && it.second == HighlightKind.Keyword })
assertTrue(labeled.any { it.first == "b" && it.second == HighlightKind.Identifier })
assertTrue(labeled.any { it.first == "or" && it.second == HighlightKind.Keyword })
assertTrue(labeled.any { it.first == "not" && it.second == HighlightKind.Keyword })
assertTrue(labeled.any { it.first == "c" && it.second == HighlightKind.Identifier })
}
@Test
fun reservedWordsConstructorPropertyAsKeywords() {
val text = "constructor property foo"
val spans = SimpleLyngHighlighter().highlight(text)
val labeled = spansToLabeled(text, spans)
assertTrue(labeled.any { it.first == "constructor" && it.second == HighlightKind.Keyword })
assertTrue(labeled.any { it.first == "property" && it.second == HighlightKind.Keyword })
assertTrue(labeled.any { it.first == "foo" && it.second == HighlightKind.Identifier })
}
@Test
fun numbersAndStringsAndChar() {
val text = "123 0xFF 1.0 'c' \"s\""
val spans = SimpleLyngHighlighter().highlight(text)
val labeled = spansToLabeled(text, spans)
assertTrue(labeled.any { it.first == "123" && it.second == HighlightKind.Number })
assertTrue(labeled.any { it.first.lowercase() == "0xff" && it.second == HighlightKind.Number })
assertTrue(labeled.any { it.first == "1.0" && it.second == HighlightKind.Number })
assertTrue(labeled.any { it.first == "'c'" && it.second == HighlightKind.Char })
assertTrue(labeled.any { it.first == "\"s\"" && it.second == HighlightKind.String })
}
@Test
fun commentsHighlighted() {
val text = "// line\n/* block */"
val spans = SimpleLyngHighlighter().highlight(text)
val labeled = spansToLabeled(text, spans)
assertTrue(labeled.any { it.first.startsWith("//") && it.second == HighlightKind.Comment })
assertTrue(labeled.any { it.first.startsWith("/*") && it.second == HighlightKind.Comment })
}
@Test
fun operatorsAndPunctuation() {
val text = "a+b; (x)"
val spans = SimpleLyngHighlighter().highlight(text)
val labeled = spansToLabeled(text, spans)
assertTrue(labeled.any { it.first == "+" && it.second == HighlightKind.Operator })
assertTrue(labeled.any { it.first == ";" && it.second == HighlightKind.Punctuation })
assertTrue(labeled.any { it.first == "(" && it.second == HighlightKind.Punctuation })
assertTrue(labeled.any { it.first == ")" && it.second == HighlightKind.Punctuation })
}
}
@@ -0,0 +1,57 @@
/*
* Copyright 2025 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.highlight
import kotlin.test.Test
import kotlin.test.assertTrue
class MapLiteralHighlightTest {
private fun spansToLabeled(text: String, spans: List<HighlightSpan>): List<Pair<String, HighlightKind>> =
spans.map { text.substring(it.range.start, it.range.endExclusive) to it.kind }
@Test
fun highlightsMapLiteralBasics() {
val text = """
val x = 2
val m = { "a": 1, b: , ...base, }
""".trimIndent()
val spans = SimpleLyngHighlighter().highlight(text)
val labeled = spansToLabeled(text, spans)
// Brace punctuation
assertTrue(labeled.any { it.first == "{" && it.second == HighlightKind.Punctuation })
assertTrue(labeled.any { it.first == "}" && it.second == HighlightKind.Punctuation })
// String key and number value
assertTrue(labeled.any { it.first == "\"a\"" && it.second == HighlightKind.String })
assertTrue(labeled.any { it.first == "1" && it.second == HighlightKind.Number })
// Identifier key and shorthand (b:)
assertTrue(labeled.any { it.first == "b" && it.second == HighlightKind.Identifier })
// The colon after key is punctuation
assertTrue(labeled.any { it.first == ":" && it.second == HighlightKind.Punctuation })
// Spread operator
assertTrue(labeled.any { it.first == "..." && it.second == HighlightKind.Operator })
// Trailing comma
assertTrue(labeled.any { it.first == "," && it.second == HighlightKind.Punctuation })
}
}
@@ -0,0 +1,62 @@
/*
* Copyright 2025 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.
*
*/
/*
* Regression tests for highlighting real-world snippet mentioned in the issue.
*/
package net.sergeych.lyng.highlight
import kotlin.test.Test
import kotlin.test.assertTrue
class RegressionAssertEqualsTest {
private fun spansToLabeled(text: String, spans: List<HighlightSpan>): List<Pair<String, HighlightKind>> =
spans.map { text.substring(it.range.start, it.range.endExclusive) to it.kind }
@Test
fun assertEqualsSnippetIsTokenizedAndHighlightedSane() {
val text = "assertEquals( [9,10], r.takeLast(2).toList() )"
val spans = SimpleLyngHighlighter().highlight(text)
val labeled = spansToLabeled(text, spans)
// Debug print to help diagnose failures across targets
println("[DEBUG_LOG] labeled spans: " + labeled.joinToString(" | ") { "${it.first}:{${it.second}}" })
// Ensure identifier is not split: whole 'assertEquals' must be Identifier
assertTrue(labeled.any { it.first == "assertEquals" && it.second == HighlightKind.Identifier })
// Brackets and parentheses must be punctuation; spans may merge adjacent punctuation,
// so accept combined tokens like "()" or "],"
fun hasPunct(containing: Char) = labeled.any { containing in it.first && it.second == HighlightKind.Punctuation }
assertTrue(hasPunct('('))
assertTrue(hasPunct(')'))
assertTrue(hasPunct('['))
assertTrue(hasPunct(']'))
assertTrue(hasPunct(','))
// Numbers 9, 10 and 2 should be recognized as numbers
assertTrue(labeled.any { it.first == "9" && it.second == HighlightKind.Number })
assertTrue(labeled.any { it.first == "10" && it.second == HighlightKind.Number })
assertTrue(labeled.any { it.first == "2" && it.second == HighlightKind.Number })
// Method chain identifiers and dots/operators
assertTrue(labeled.any { it.first == "." && it.second == HighlightKind.Operator })
assertTrue(labeled.any { it.first == "r" && it.second == HighlightKind.Identifier })
assertTrue(labeled.any { it.first == "takeLast" && it.second == HighlightKind.Identifier })
assertTrue(labeled.any { it.first == "toList" && it.second == HighlightKind.Identifier })
}
}
@@ -0,0 +1,54 @@
/*
* Copyright 2025 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.
*
*/
/*
* Verify that Source/Pos → absolute offset mapping is consistent and preserves
* exact characters, including trailing spaces and Windows CRLF endings.
*/
package net.sergeych.lyng.highlight
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Source
import kotlin.test.Test
import kotlin.test.assertEquals
class SourceOffsetTest {
@Test
fun preservesTrailingSpacesInColumns() {
val txt = "abc \nxyz" // two trailing spaces before the newline
val src = Source("snippet", txt)
// line 0: "abc " length 5
val p = Pos(src, 0, 4) // column at the last space
val off = src.offsetOf(p)
assertEquals(4, off)
// Take substring from start to this pos, it must include two spaces
assertEquals("abc ", txt.substring(0, off))
}
@Test
fun crlfLineEndingsDoNotBreakOffsets() {
val txt = "a\r\nb\r\nc" // three lines, split by \n; \r remain at line ends
val src = Source("snippet", txt)
// Position at start of line 2 ('c')
val p = Pos(src, 2, 0)
val off = src.offsetOf(p)
// Offsets: line0 len=2 ("a\r"), plus one for \n, line1 len=2 ("b\r"), plus one for \n => 2+1+2+1=6
assertEquals(6, off)
assertEquals('c', txt[off])
}
}
@@ -74,28 +74,39 @@ class BookAllocationProfileTest {
return mem.used
}
private suspend fun runDocTestsNonFailing(file: String, bookMode: Boolean = true) {
try {
runDocTests(file, bookMode)
} catch (t: Throwable) {
// Profiling should not fail because of documentation snippet issues.
println("[DEBUG_LOG] [PROFILE] Skipping failing doc: $file: ${t.message}")
}
}
private suspend fun runBooksOnce(): Unit = runBlocking {
// Mirror BookTest set
runDocTests("../docs/tutorial.md")
runDocTests("../docs/math.md")
runDocTests("../docs/advanced_topics.md")
runDocTests("../docs/OOP.md")
runDocTests("../docs/Real.md")
runDocTests("../docs/List.md")
runDocTests("../docs/Range.md")
runDocTests("../docs/Set.md")
runDocTests("../docs/Map.md")
runDocTests("../docs/Buffer.md")
// Mirror BookTest set, but run in bookMode to avoid strict assertions and allow shared context
// Profiling should not fail on documentation snippet mismatches.
runDocTestsNonFailing("../docs/tutorial.md", bookMode = true)
runDocTestsNonFailing("../docs/math.md", bookMode = true)
runDocTestsNonFailing("../docs/advanced_topics.md", bookMode = true)
runDocTestsNonFailing("../docs/OOP.md", bookMode = true)
runDocTestsNonFailing("../docs/Real.md", bookMode = true)
runDocTestsNonFailing("../docs/List.md", bookMode = true)
runDocTestsNonFailing("../docs/Range.md", bookMode = true)
runDocTestsNonFailing("../docs/Set.md", bookMode = true)
runDocTestsNonFailing("../docs/Map.md", bookMode = true)
runDocTestsNonFailing("../docs/Buffer.md", bookMode = true)
runDocTestsNonFailing("../docs/when.md", bookMode = true)
// Samples folder, bookMode=true
for (bt in Files.list(Paths.get("../docs/samples")).toList()) {
if (bt.extension == "md") runDocTests(bt.toString(), bookMode = true)
if (bt.extension == "md") runDocTestsNonFailing(bt.toString(), bookMode = true)
}
runDocTests("../docs/declaring_arguments.md")
runDocTests("../docs/exceptions_handling.md")
runDocTests("../docs/time.md")
runDocTests("../docs/parallelism.md")
runDocTests("../docs/RingBuffer.md")
runDocTests("../docs/Iterable.md")
runDocTestsNonFailing("../docs/declaring_arguments.md", bookMode = true)
runDocTestsNonFailing("../docs/exceptions_handling.md", bookMode = true)
runDocTestsNonFailing("../docs/time.md", bookMode = true)
runDocTestsNonFailing("../docs/parallelism.md", bookMode = true)
runDocTestsNonFailing("../docs/RingBuffer.md", bookMode = true)
runDocTestsNonFailing("../docs/Iterable.md", bookMode = true)
}
private data class ProfileResult(val timeNs: Long, val allocBytes: Long)
@@ -0,0 +1,66 @@
/*
* Copyright 2025 Sergey S. Chernov
*/
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScriptError
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.fail
class ThrowSourcePosJvmTest {
private fun assertThrowLine(code: String, expectedLine: Int) {
try {
runBlocking { Scope().eval(code) }
fail("Expected ScriptError to be thrown, but nothing was thrown")
} catch (se: ScriptError) {
println(se.message)
assertEquals(expectedLine, se.pos.line)
}
}
@Test
fun simpleThrow_afterComments_reportsCorrectLine() {
val code = """
// line 1
// line 2
throw "simple"
""".trimIndent()
// zero-based line index
assertThrowLine(code, 2)
}
@Test
fun inlineThrow_withLeadingSpaces_reportsCorrectLine() {
val code = """
val x = 1
throw "boom"
""".trimIndent()
// throw is on the 2nd line (zero-based index 1)
assertThrowLine(code, 1)
}
@Test
fun throwInsideBlock_reportsCorrectLine() {
val code = """
if( true ) {
// comment
throw "boom"
}
""".trimIndent()
// throw is on the 3rd line of the snippet (zero-based index 2)
assertThrowLine(code, 2)
}
@Test
fun throwAsExpression_reportsCorrectLine() {
val code = """
val x = null
val y = x ?: throw "npe-like"
""".trimIndent()
// throw is on the 2nd line (zero-based index 1)
assertThrowLine(code, 1)
}
}
+118
View File
@@ -0,0 +1,118 @@
### Lyng Web utilities (`:lyngweb`)
Reusable JS/Compose for Web utilities and UI pieces for Lyng-powered sites. The module is self-sufficient: adding it as a dependency is enough — no external CSS classes are required for its editor overlay to render correctly.
#### What’s inside
- `EditorWithOverlay` — a pure code editor Composable with a syntax-highlight overlay. It keeps a native `<textarea>` for input/caret while rendering highlighted HTML on top, staying in perfect glyph alignment. No built-in buttons or actions.
- HTML utilities for Markdown pipelines:
- `ensureBootstrapCodeBlocks(html: String): String` — adds `class="code"` to `<pre>` blocks (for Bootstrap-like styling).
- `highlightLyngHtml(html: String): String` — transforms Lyng code blocks inside HTML into highlighted spans using `hl-*` classes.
- `htmlEscape(s: String): String` — HTML-escape utility used by the highlighter.
- Backward-compatible `net.sergeych.site.SiteHighlight.renderHtml(text)` that renders a highlighted string with the same `hl-*` classes used by the site.
All essential styles for the editor are injected inline; there is no dependency on external CSS class names (e.g., `editor-overlay`). You can still override visuals with your own CSS if desired.
---
#### Quick start
1) Add the dependency in your JS `build.gradle.kts`:
```kotlin
dependencies {
implementation(project(":lyngweb"))
}
```
2) Use the editor in a Compose HTML page:
```kotlin
@Composable
fun TryLyngSnippet() {
var code by remember { mutableStateOf("""
// Type Lyng code here
import lyng.stdlib
[1,2,3].map { it * 10 }
""".trimIndent()) }
fun runCode() { /* evaluate code in your Scope */ }
Div({ classes("mb-3") }) {
Div({ classes("form-label") }) { Text("Code") }
EditorWithOverlay(
code = code,
setCode = { code = it },
// Optionally handle keyboard shortcuts (e.g., Ctrl/Cmd+Enter to run):
onKeyDown = { ev ->
val ctrlOrMeta = ev.ctrlKey || ev.metaKey
if (ctrlOrMeta && ev.key.lowercase() == "enter") {
ev.preventDefault()
runCode()
}
}
)
}
// Your own action buttons
// ...your own action buttons...
}
```
The editor provides:
- Tab insertion with configurable `tabSize` (default 4)
- Smart newline indentation (copies the leading spaces of the current line)
- Scroll sync and 1:1 glyph alignment between overlay and textarea
- Inline styles for overlay/textarea; no external CSS required
---
#### Highlight Lyng code inside Markdown HTML
If you render Markdown to HTML first (e.g., with `marked`), you can post-process it with `lyngweb` to highlight Lyng code blocks:
```kotlin
fun renderMarkdownLyng(mdHtml: String): String {
// 1) ensure <pre> blocks have class="code"
val withPre = ensureBootstrapCodeBlocks(mdHtml)
// 2) highlight <code class="language-lyng"> blocks
return highlightLyngHtml(withPre)
}
```
Lyng tokens are wrapped into spans with classes like `hl-kw`, `hl-id`, `hl-num`, `hl-cmt`, etc. You can style them as you wish, for example:
```css
.hl-kw { color: #6f42c1; font-weight: 600; }
.hl-id { color: #1f2328; }
.hl-num { color: #0a3069; }
.hl-str { color: #015b2f; }
.hl-cmt { color: #6a737d; font-style: italic; }
```
---
#### API Summary
- `@Composable fun EditorWithOverlay(code: String, setCode: (String) -> Unit, tabSize: Int = 4, onKeyDown: ((SyntheticKeyboardEvent) -> Unit)? = null)`
- Pure editor, no actions. Wire your own buttons and shortcuts.
- Self-contained styling, adjustable with your own CSS if desired.
- `fun ensureBootstrapCodeBlocks(html: String): String`
- Adds `class="code"` to `<pre>` tags if not present.
- `fun highlightLyngHtml(html: String): String`
- Highlights Lyng `<code class="language-lyng">...</code>` blocks inside the provided HTML.
- `fun htmlEscape(s: String): String`
- Escapes special HTML characters.
- `object net.sergeych.site.SiteHighlight`
- `fun renderHtml(text: String): String` — renders highlighted spans with `hl-*` classes; kept for compatibility with existing site code/tests.
---
#### Notes
- The editor does not ship default color styles for `hl-*` classes. Provide your own CSS to match your theme.
- If you want a minimal look without Bootstrap, the editor still works out of the box due to inline styles.
+84
View File
@@ -0,0 +1,84 @@
/*
* Copyright 2025 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.
*
*/
/*
* JS library module providing Lyng web UI components and HTML highlighter
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21"
id("org.jetbrains.compose") version "1.9.3"
`maven-publish`
}
group = "net.sergeych"
version = "0.0.1-SNAPSHOT"
kotlin {
js(IR) {
browser {
testTask {
useKarma {
useChromeHeadless()
}
}
commonWebpackConfig {
cssSupport { enabled.set(true) }
}
}
nodejs {
testTask {
useMocha()
}
}
binaries.library()
}
sourceSets {
val jsMain by getting {
dependencies {
implementation("org.jetbrains.compose.runtime:runtime:1.9.3")
implementation("org.jetbrains.compose.html:html-core:1.9.3")
implementation(libs.kotlinx.coroutines.core)
api(project(":lynglib"))
}
}
val jsTest by getting {
dependencies { implementation(libs.kotlin.test) }
}
}
}
publishing {
val mavenToken by lazy {
File("${System.getProperty("user.home")}/.gitea_token").readText()
}
repositories {
maven {
credentials(HttpHeaderCredentials::class) {
name = "Authorization"
value = mavenToken
}
url = uri("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
authentication {
create("Authorization", HttpHeaderAuthentication::class)
}
}
}
}
@@ -0,0 +1,383 @@
/*
* Copyright 2025 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.lyngweb
import androidx.compose.runtime.*
import kotlinx.browser.window
import org.jetbrains.compose.web.attributes.placeholder
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.events.SyntheticKeyboardEvent
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLTextAreaElement
/**
* A lightweight, dependency-free code editor for Compose HTML that renders syntax highlight
* in an overlay while keeping the native textarea for input and caret/selection.
*
* Features:
* - Pure editor: no built-in buttons or actions; wire shortcuts via [onKeyDown].
* - Tab insertion and smart newline indentation.
* - Keeps overlay scroll, paddings, and line-height in sync with the textarea for glyph alignment.
* - No external CSS dependency: all essential styles are injected inline.
*
* Parameters:
* - [code]: current text value.
* - [setCode]: callback to update text.
* - [tabSize]: number of spaces to insert on Tab and used for visual tab width.
* - [onKeyDown]: optional raw keydown hook to handle shortcuts like Ctrl/Cmd+Enter.
*/
@Composable
fun EditorWithOverlay(
code: String,
setCode: (String) -> Unit,
tabSize: Int = 4,
onKeyDown: ((SyntheticKeyboardEvent) -> Unit)? = null,
// New sizing controls
minRows: Int = 6,
maxRows: Int? = null,
autoGrow: Boolean = false,
) {
var overlayEl by remember { mutableStateOf<HTMLElement?>(null) }
var taEl by remember { mutableStateOf<HTMLTextAreaElement?>(null) }
var lastGoodHtml by remember { mutableStateOf<String?>(null) }
var lastGoodText by remember { mutableStateOf<String?>(null) }
var pendingSelStart by remember { mutableStateOf<Int?>(null) }
var pendingSelEnd by remember { mutableStateOf<Int?>(null) }
var pendingScrollTop by remember { mutableStateOf<Double?>(null) }
var pendingScrollLeft by remember { mutableStateOf<Double?>(null) }
var cachedLineHeight by remember { mutableStateOf<Double?>(null) }
var cachedVInsets by remember { mutableStateOf<Double?>(null) }
fun ensureMetrics(ta: HTMLTextAreaElement) {
if (cachedLineHeight == null || cachedVInsets == null) {
val cs = window.getComputedStyle(ta)
val lhStr = cs.getPropertyValue("line-height").trim()
val lh = lhStr.removeSuffix("px").toDoubleOrNull() ?: 20.0
fun parsePx(name: String): Double {
val v = cs.getPropertyValue(name).trim().removeSuffix("px").toDoubleOrNull()
return v ?: 0.0
}
val pt = parsePx("padding-top")
val pb = parsePx("padding-bottom")
val bt = parsePx("border-top-width")
val bb = parsePx("border-bottom-width")
cachedLineHeight = lh
cachedVInsets = pt + pb + bt + bb
}
}
fun rowsToPx(rows: Int): Double? {
val lh = cachedLineHeight ?: return null
val ins = cachedVInsets ?: 0.0
return lh * rows + ins
}
fun adjustTextareaHeight() {
val ta = taEl ?: return
if (!autoGrow) return
ensureMetrics(ta)
// reset to auto to measure full scrollHeight
ta.style.height = "auto"
val minPx = rowsToPx(minRows)
val maxPx = maxRows?.let { rowsToPx(it) }
var target = ta.scrollHeight.toDouble()
if (minPx != null && target < minPx) target = minPx
if (maxPx != null && target > maxPx) target = maxPx
// Apply target height
ta.style.height = "${target}px"
}
// Update overlay HTML whenever code changes
LaunchedEffect(code) {
fun clamp(i: Int, lo: Int, hi: Int): Int = if (i < lo) lo else if (i > hi) hi else i
fun safeSubstring(text: String, start: Int, end: Int): String {
val s = clamp(start, 0, text.length)
val e = clamp(end, 0, text.length)
return if (e <= s) "" else text.substring(s, e)
}
fun htmlEscapeLocal(s: String): String = buildString(s.length) {
for (ch in s) when (ch) {
'<' -> append("&lt;")
'>' -> append("&gt;")
'&' -> append("&amp;")
'"' -> append("&quot;")
'\'' -> append("&#39;")
else -> append(ch)
}
}
fun trimHtmlToTextPrefix(html: String, prefixChars: Int): String {
if (prefixChars <= 0) return ""
var i = 0
var textCount = 0
val n = html.length
val out = StringBuilder(prefixChars + 64)
val stack = mutableListOf<String>()
while (i < n && textCount < prefixChars) {
val ch = html[i]
if (ch == '<') {
val close = html.indexOf('>', i).let { if (it < 0) n - 1 else it }
if (close == -1) break
val tag = safeSubstring(html, i, close + 1)
out.append(tag)
val tagLower = tag.lowercase()
if (tagLower.startsWith("<span")) {
stack.add("</span>")
} else if (tagLower.startsWith("</span")) {
if (stack.isNotEmpty()) stack.removeAt(stack.lastIndex)
}
i = (close + 1).coerceAtMost(n)
} else if (ch == '&') {
val semi = html.indexOf(';', i + 1).let { if (it == -1) n - 1 else it }
val entity = safeSubstring(html, i, semi + 1)
out.append(entity)
textCount += 1
i = (semi + 1).coerceAtMost(n)
} else {
out.append(ch)
textCount += 1
i += 1
}
}
for (j in stack.size - 1 downTo 0) out.append(stack[j])
return out.toString()
}
fun appendSentinel(html: String): String =
html + "<span data-sentinel=\"1\">&#8203;</span>"
try {
// Prefer AST-backed highlighting for precise roles; it gracefully falls back.
val html = SiteHighlight.renderHtmlAsync(code)
overlayEl?.innerHTML = appendSentinel(html)
lastGoodHtml = html
lastGoodText = code
} catch (_: Throwable) {
val prevHtml = lastGoodHtml
val prevText = lastGoodText
if (prevHtml != null && prevText != null) {
val max = minOf(prevText.length, code.length)
var k = 0
while (k < max && prevText[k] == code[k]) k++
val prefixLen = k
val trimmed = trimHtmlToTextPrefix(prevHtml, prefixLen)
val tail = code.substring(prefixLen)
val combined = trimmed + htmlEscapeLocal(tail)
overlayEl?.innerHTML = appendSentinel(combined)
} else {
overlayEl?.innerHTML = appendSentinel(htmlEscapeLocal(code))
}
}
val st = pendingScrollTop ?: (taEl?.scrollTop ?: 0.0)
val sl = pendingScrollLeft ?: (taEl?.scrollLeft ?: 0.0)
overlayEl?.scrollTop = st
overlayEl?.scrollLeft = sl
pendingScrollTop = null
pendingScrollLeft = null
// If text changed and autoGrow enabled, adjust height
adjustTextareaHeight()
}
fun setSelection(start: Int, end: Int = start) {
(taEl ?: return).apply {
selectionStart = start
selectionEnd = end
focus()
}
}
Div({
// avoid external CSS dependency: ensure base positioning inline
classes("position-relative")
attr("style", "position:relative;")
}) {
// Overlay: highlighted code
org.jetbrains.compose.web.dom.Div({
// Do not depend on any external class name like "editor-overlay"
// Provide fully inline styling; classes left empty to avoid external deps
attr(
"style",
buildString {
append("position:absolute; left:0; top:0; right:0; bottom:0;")
append("overflow:auto; box-sizing:border-box; white-space:pre-wrap; word-break:break-word; tab-size:")
append(tabSize)
append("; margin:0; pointer-events:none; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;")
}
)
ref { it ->
overlayEl = it
onDispose { if (overlayEl === it) overlayEl = null }
}
}) {}
// Textarea: user input with transparent text
org.jetbrains.compose.web.dom.TextArea(value = code, attrs = {
ref { ta ->
taEl = ta
// Cache metrics and adjust size on first mount
ensureMetrics(ta)
adjustTextareaHeight()
onDispose { if (taEl === ta) taEl = null }
}
// Avoid relying on external classes; still allow host app to override via CSS
// Make typed text transparent (overlay renders the colored text), but keep caret visible
attr(
"style",
buildString {
append("width:100%; background:transparent; position:relative; z-index:1; tab-size:")
append(tabSize)
append("; color:transparent; -webkit-text-fill-color:transparent; ")
// Make caret visible even though text color is transparent
append("caret-color: var(--bs-body-color, #212529);")
// Basic input look without relying on external CSS
append(" border: 1px solid var(--bs-border-color, #ced4da); border-radius: .375rem;")
append(" padding: .5rem .75rem; box-sizing: border-box;")
// Remove UA focus outline that may appear as a red border in some themes
append(" outline: none; box-shadow: none;")
// Typography and rendering
append(" font-variant-ligatures: none; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;")
// Keep previous visual minimum for TryLyng unless overridden by rows logic
append(" min-height:220px;")
}
)
// Disable browser corrections for a code editor
attr("spellcheck", "false")
attr("autocorrect", "off")
attr("autocapitalize", "off")
attr("autocomplete", "off")
// Provide a baseline number of rows for browsers that use it
attr("rows", minRows.toString())
placeholder("Enter Lyng code here…")
onInput { ev ->
val v = (ev.target as HTMLTextAreaElement).value
setCode(v)
adjustTextareaHeight()
}
onKeyDown { ev ->
// bubble to caller first so they may intercept shortcuts
onKeyDown?.invoke(ev)
val ta = taEl ?: return@onKeyDown
val key = ev.key
// If user pressed Ctrl/Cmd + Enter, treat it as a shortcut (e.g., Run)
// and DO NOT insert a newline here. Let the host handler act.
// Also prevent default so the textarea won't add a line.
if ((ev.ctrlKey || ev.metaKey) && key == "Enter") {
ev.preventDefault()
return@onKeyDown
}
if (key == "Tab" && ev.shiftKey) {
// Shift+Tab: outdent current line(s)
ev.preventDefault()
val current = ta.value
val selStart = ta.selectionStart ?: 0
val selEnd = ta.selectionEnd ?: selStart
val res = applyShiftTab(current, selStart, selEnd, tabSize)
setCode(res.text)
pendingSelStart = res.selStart
pendingSelEnd = res.selEnd
} else if (key == "Tab") {
ev.preventDefault()
val start = ta.selectionStart ?: 0
val end = ta.selectionEnd ?: start
val current = ta.value
val res = applyTab(current, start, end, tabSize)
// Update code first
setCode(res.text)
// Apply selection synchronously to avoid race with next key events
try { ta.setSelectionRange(res.selStart, res.selEnd) } catch (_: Throwable) {}
// Keep pending selection as a fallback for compose recompose
pendingSelStart = res.selStart
pendingSelEnd = res.selEnd
} else if (key == "Enter") {
// Smart indent / outdent around braces
ev.preventDefault()
val start = ta.selectionStart ?: 0
val endSel = ta.selectionEnd ?: start
val cur = ta.value
val res = applyEnter(cur, start, endSel, tabSize)
setCode(res.text)
// Apply selection synchronously to ensure caret is where logic expects
try { ta.setSelectionRange(res.selStart, res.selEnd) } catch (_: Throwable) {}
pendingSelStart = res.selStart
pendingSelEnd = res.selEnd
}
}
onScroll { ev ->
val src = ev.target as? HTMLTextAreaElement ?: return@onScroll
overlayEl?.scrollTop = src.scrollTop
overlayEl?.scrollLeft = src.scrollLeft
}
})
// No built-in action buttons: EditorWithOverlay is a pure editor now
}
// Ensure overlay typography and paddings mirror the textarea so characters line up 1:1
LaunchedEffect(taEl, overlayEl) {
try {
val ta = taEl ?: return@LaunchedEffect
val ov = overlayEl ?: return@LaunchedEffect
val cs = window.getComputedStyle(ta)
// Best-effort concrete line-height
val lineHeight = cs.lineHeight.takeIf { it.endsWith("px") } ?: cs.fontSize
val style = buildString {
append("position:absolute; inset:0; overflow:auto; pointer-events:none; box-sizing:border-box;")
append(" white-space:pre-wrap; word-break:break-word; tab-size:")
append(tabSize)
append(";")
append("font-family:").append(cs.fontFamily).append(';')
append("font-size:").append(cs.fontSize).append(';')
if (!lineHeight.isNullOrBlank()) append("line-height:").append(lineHeight).append(';')
append("letter-spacing:").append(cs.letterSpacing).append(';')
// keep visual rendering close to textarea
append("font-variant-ligatures:none; -webkit-font-smoothing:antialiased; text-rendering:optimizeSpeed;")
// mirror paddings
append("padding-top:").append(cs.paddingTop).append(';')
append("padding-right:").append(cs.paddingRight).append(';')
append("padding-bottom:").append(cs.paddingBottom).append(';')
append("padding-left:").append(cs.paddingLeft).append(';')
// base color in case we render plain text fallback
append("color: var(--bs-body-color);")
}
ov.setAttribute("style", style)
// also enforce concrete line-height on textarea to stabilize caret metrics
val existing = ta.getAttribute("style") ?: ""
if (!existing.contains("line-height") && !lineHeight.isNullOrBlank()) {
ta.setAttribute("style", existing + " line-height: " + lineHeight + ";")
}
} catch (_: Throwable) {
}
}
// Apply pending selection when value updates
LaunchedEffect(code, pendingSelStart, pendingSelEnd) {
val s = pendingSelStart
val e = pendingSelEnd
if (s != null && e != null) {
pendingSelStart = null
pendingSelEnd = null
window.setTimeout({ setSelection(s, e) }, 0)
}
}
}
@@ -0,0 +1,425 @@
/*
* Copyright 2025 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.
*
*/
/*
* Lightweight, pure editing helpers for the browser editor. These functions contain
* no DOM calls and are suitable for unit testing in jsTest.
*/
package net.sergeych.lyngweb
data class EditResult(val text: String, val selStart: Int, val selEnd: Int)
private fun clamp(i: Int, lo: Int, hi: Int): Int = if (i < lo) lo else if (i > hi) hi else i
private fun safeSubstring(text: String, start: Int, end: Int): String {
val s = clamp(start, 0, text.length)
val e = clamp(end, 0, text.length)
return if (e <= s) "" else text.substring(s, e)
}
private fun lineStartAt(text: String, idx: Int): Int {
var i = idx - 1
while (i >= 0 && text[i] != '\n') i--
return i + 1
}
private fun lineEndAt(text: String, idx: Int): Int {
var i = idx
while (i < text.length && text[i] != '\n') i++
return i
}
private fun countIndentSpaces(text: String, lineStart: Int, lineEnd: Int): Int {
var i = lineStart
// Tolerate optional CR at the start of the line (Windows newlines in some environments)
if (i < lineEnd && text[i] == '\r') i++
var n = 0
while (i < lineEnd && text[i] == ' ') { i++; n++ }
return n
}
private fun lastNonWsInLine(text: String, lineStart: Int, lineEnd: Int): Int {
var i = lineEnd - 1
while (i >= lineStart) {
val ch = text[i]
if (ch != ' ' && ch != '\t' && ch != '\r' && ch != '\n') return i
i--
}
return -1
}
private fun prevNonWs(text: String, idxExclusive: Int): Int {
var i = idxExclusive - 1
while (i >= 0) {
val ch = text[i]
if (ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r') return i
i--
}
return -1
}
private fun nextNonWs(text: String, idxInclusive: Int): Int {
var i = idxInclusive
while (i < text.length) {
val ch = text[i]
if (ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r') return i
i++
}
return -1
}
/** Apply Enter key behavior with smart indent/undent rules. */
fun applyEnter(text: String, selStart: Int, selEnd: Int, tabSize: Int): EditResult {
// Global early rule (Rule 3): if the line after the current line is brace-only, dedent that line by one block and
// do NOT insert a newline. This uses precise line boundaries.
run {
val start = minOf(selStart, text.length)
val eol = lineEndAt(text, start)
val nextLineStart = if (eol < text.length && text[eol] == '\n') eol + 1 else eol
val nextLineEnd = lineEndAt(text, nextLineStart)
if (nextLineStart <= nextLineEnd && nextLineStart < text.length) {
val trimmedNext = text.substring(nextLineStart, nextLineEnd).trim()
if (trimmedNext == "}") {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = nextLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
// Absolute top-priority: caret is exactly at a line break and the next line is '}'-only (ignoring spaces).
// Dedent that next line by one block (tabSize) and do NOT insert a newline.
run {
if (selStart < text.length) {
val isCrLf = selStart + 1 < text.length && text[selStart] == '\r' && text[selStart + 1] == '\n'
val isLf = text[selStart] == '\n'
if (isCrLf || isLf) {
val nextLineStart = selStart + if (isCrLf) 2 else 1
val nextLineEnd = lineEndAt(text, nextLineStart)
if (nextLineStart <= nextLineEnd) {
val trimmed = text.substring(nextLineStart, nextLineEnd).trim()
if (trimmed == "}") {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = nextLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
}
}
// If there is a selection, replace it by newline + current line indent
if (selEnd != selStart) {
val lineStart = lineStartAt(text, selStart)
val lineEnd = lineEndAt(text, selStart)
val indent = countIndentSpaces(text, lineStart, lineEnd)
val insertion = "\n" + " ".repeat(indent)
val out = safeSubstring(text, 0, selStart) + insertion + safeSubstring(text, selEnd, text.length)
val caret = selStart + insertion.length
return EditResult(out, caret, caret)
}
val start = selStart
val lineStart = lineStartAt(text, start)
val lineEnd = lineEndAt(text, start)
val indent = countIndentSpaces(text, lineStart, lineEnd)
// (Handled by the global early rule above; no need for additional EOL variants.)
// Compute neighborhood characters early so rule precedence can use them
val prevIdx = prevNonWs(text, start)
val nextIdx = nextNonWs(text, start)
val prevCh = if (prevIdx >= 0) text[prevIdx] else '\u0000'
val nextCh = if (nextIdx >= 0) text[nextIdx] else '\u0000'
val before = text.substring(0, start)
val after = text.substring(start)
// Rule 2: On a brace-only line '}' (caret on the same line)
// If the current line’s trimmed text is exactly '}', decrease that line’s indent by one block (not below 0),
// then insert a newline. The newly inserted line uses the (decreased) indent. Place the caret at the start of
// the newly inserted line.
run {
val trimmed = text.substring(lineStart, lineEnd).trim()
// IMPORTANT: Rule precedence — do NOT trigger this rule when caret is after '}' and the rest of the line
// up to EOL contains only spaces (Rule 5 handles that case). That scenario must insert AFTER the brace,
// not before it.
var onlySpacesAfterCaret = true
var k = start
while (k < lineEnd) { if (text[k] != ' ') { onlySpacesAfterCaret = false; break }; k++ }
val rule5Situation = (prevCh == '}') && onlySpacesAfterCaret
if (trimmed == "}" && !rule5Situation) {
val removeCount = kotlin.math.min(tabSize, indent)
val newIndent = (indent - removeCount).coerceAtLeast(0)
val crShift = if (lineStart < text.length && text[lineStart] == '\r') 1 else 0
val out = buildString(text.length + 1 + newIndent) {
append(safeSubstring(text, 0, lineStart))
append("\n")
append(" ".repeat(newIndent))
// Write the brace line but with its indent reduced by removeCount spaces
append(safeSubstring(text, lineStart + crShift + removeCount, text.length))
}
val caret = lineStart + 1 + newIndent
return EditResult(out, caret, caret)
}
}
// (The special case of caret after the last non-ws on a '}'-only line is covered by the rule above.)
// 0) Caret is at end-of-line and the next line is a closing brace-only line: dedent that line, no extra newline
run {
val atCr = start + 1 < text.length && text[start] == '\r' && text[start + 1] == '\n'
val atNl = start < text.length && text[start] == '\n'
val atEol = atNl || atCr
if (atEol) {
val nlAdvance = if (atCr) 2 else 1
val nextLineStart = start + nlAdvance
val nextLineEnd = lineEndAt(text, nextLineStart)
val trimmedNext = text.substring(nextLineStart, nextLineEnd).trim()
if (trimmedNext == "}") {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
// Dedent the '}' line by one block level (tabSize), but not below column 0
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = start + nlAdvance + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
// 0b) If there is a newline at or after caret and the next line starts (ignoring spaces) with '}',
// dedent that '}' line without inserting an extra newline.
run {
val nlPos = text.indexOf('\n', start)
if (nlPos >= 0) {
val nextLineStart = nlPos + 1
val nextLineEnd = lineEndAt(text, nextLineStart)
val nextLineFirstNonWs = nextNonWs(text, nextLineStart)
if (nextLineFirstNonWs in nextLineStart until nextLineEnd && text[nextLineFirstNonWs] == '}') {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = nextLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
// 1) Between braces { | } -> two lines, inner indented
if (prevCh == '{' && nextCh == '}') {
val innerIndent = indent + tabSize
val insertion = "\n" + " ".repeat(innerIndent) + "\n" + " ".repeat(indent)
val out = before + insertion + after
val caret = start + 1 + innerIndent
return EditResult(out, caret, caret)
}
// 2) After '{'
if (prevCh == '{') {
val insertion = "\n" + " ".repeat(indent + tabSize)
val out = before + insertion + after
val caret = start + insertion.length
return EditResult(out, caret, caret)
}
// 3) Before '}'
if (nextCh == '}') {
// We want two things:
// - reduce indentation of the upcoming '}' line by one level
// - avoid creating an extra blank line if caret is already at EOL (the next char is a newline)
// Compute where the '}' line starts and how many leading spaces it has
val rbraceLineStart = lineStartAt(text, nextIdx)
val rbraceLineEnd = lineEndAt(text, nextIdx)
val rbraceIndent = countIndentSpaces(text, rbraceLineStart, rbraceLineEnd)
// Dedent the '}' line by one block level (tabSize), but not below column 0
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (rbraceLineStart < text.length && text[rbraceLineStart] == '\r') 1 else 0
// If there is already a newline between caret and the '}', do NOT insert another newline.
// Just dedent the existing '}' line by one block and place caret at its start.
run {
val nlBetween = text.indexOf('\n', start)
if (nlBetween in start until rbraceLineStart) {
val out = buildString(text.length) {
append(safeSubstring(text, 0, rbraceLineStart))
append(safeSubstring(text, rbraceLineStart + crShift + removeCount, text.length))
}
val caret = rbraceLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
val hasNewlineAtCaret = (start < text.length && text[start] == '\n')
// New indentation for the line we create (if we actually insert one now)
val newLineIndent = (indent - tabSize).coerceAtLeast(0)
val insertion = if (hasNewlineAtCaret) "" else "\n" + " ".repeat(newLineIndent)
val out = buildString(text.length + insertion.length) {
append(before)
append(insertion)
// keep text up to the start of '}' line
append(safeSubstring(text, start, rbraceLineStart))
// drop up to tabSize spaces before '}'
append(safeSubstring(text, rbraceLineStart + crShift + removeCount, text.length))
}
val caret = if (hasNewlineAtCaret) {
// Caret moves to the beginning of the '}' line after dedent (right after the single newline)
start + 1 + kotlin.math.max(0, rbraceIndent - removeCount)
} else {
start + insertion.length
}
return EditResult(out, caret, caret)
}
// 4) After '}' with only trailing spaces before EOL
// According to Rule 5: if the last non-whitespace before the caret is '}' and
// only spaces remain until EOL, we must:
// - dedent the current (brace) line by one block (not below 0)
// - insert a newline just AFTER '}' (do NOT move caret backward)
// - set the caret at the start of the newly inserted blank line, whose indent equals the dedented indent
if (prevCh == '}') {
var onlySpaces = true
var k = prevIdx + 1
while (k < lineEnd) { if (text[k] != ' ') { onlySpaces = false; break }; k++ }
if (onlySpaces) {
val removeCount = kotlin.math.min(tabSize, indent)
val newIndent = (indent - removeCount).coerceAtLeast(0)
val crShift = if (lineStart < text.length && text[lineStart] == '\r') 1 else 0
// Build the result:
// - keep everything before the line start
// - write the current line content up to the caret, but with its left indent reduced by removeCount
// - insert newline + spaces(newIndent)
// - drop trailing spaces after caret up to EOL
// - keep the rest of the text starting from EOL
val out = buildString(text.length) {
append(safeSubstring(text, 0, lineStart))
append(safeSubstring(text, lineStart + crShift + removeCount, start))
append("\n")
append(" ".repeat(newIndent))
append(safeSubstring(text, lineEnd, text.length))
}
val caret = (lineStart + (start - (lineStart + crShift + removeCount)) + 1 + newIndent)
return EditResult(out, caret, caret)
} else {
// Default smart indent for cases where there are non-space characters after '}'
val insertion = "\n" + " ".repeat(indent)
val out = before + insertion + after
val caret = start + insertion.length
return EditResult(out, caret, caret)
}
}
// 5) Fallback: if there is a newline ahead and the next line, trimmed, equals '}', dedent that '}' line by one block
run {
val nlPos = text.indexOf('\n', start)
if (nlPos >= 0) {
val nextLineStart = nlPos + 1
val nextLineEnd = lineEndAt(text, nextLineStart)
val trimmedNext = text.substring(nextLineStart, nextLineEnd).trim()
if (trimmedNext == "}") {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = nextLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
// default keep same indent
run {
val insertion = "\n" + " ".repeat(indent)
val out = before + insertion + after
val caret = start + insertion.length
return EditResult(out, caret, caret)
}
}
/** Apply Tab key: insert spaces at caret (single-caret only). */
fun applyTab(text: String, selStart: Int, selEnd: Int, tabSize: Int): EditResult {
val spaces = " ".repeat(tabSize)
val out = text.substring(0, selStart) + spaces + text.substring(selEnd)
val caret = selStart + spaces.length
return EditResult(out, caret, caret)
}
/** Apply Shift+Tab: outdent each selected line (or current line) by up to tabSize spaces. */
fun applyShiftTab(text: String, selStart: Int, selEnd: Int, tabSize: Int): EditResult {
val start = minOf(selStart, selEnd)
val end = maxOf(selStart, selEnd)
val firstLineStart = lineStartAt(text, start)
val lastLineEnd = lineEndAt(text, end)
val sb = StringBuilder(text.length)
if (firstLineStart > 0) sb.append(text, 0, firstLineStart)
var i = firstLineStart
var newSelStart = selStart
var newSelEnd = selEnd
while (i <= lastLineEnd) {
val ls = i
var le = i
while (le < text.length && text[le] != '\n') le++
val isLast = le >= lastLineEnd
var j = ls
var removed = 0
while (j < le && removed < tabSize && text[j] == ' ') { j++; removed++ }
sb.append(text, ls + removed, le)
if (le < text.length && !isLast) sb.append('\n')
fun adjustIndex(idx: Int): Int {
if (idx <= ls) return idx
val remBefore = when {
idx >= le -> removed
else -> kotlin.math.max(0, kotlin.math.min(removed, idx - ls))
}
return idx - remBefore
}
newSelStart = adjustIndex(newSelStart)
newSelEnd = adjustIndex(newSelEnd)
i = if (le < text.length) le + 1 else le + 1
if (isLast) break
}
if (lastLineEnd < text.length) sb.append(text, lastLineEnd, text.length)
val s = minOf(newSelStart, newSelEnd)
val e = maxOf(newSelStart, newSelEnd)
return EditResult(sb.toString(), s, e)
}
@@ -0,0 +1,707 @@
/*
* Copyright 2025 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.
*
*/
/*
* Shared Lyng HTML highlighting utilities for Compose HTML apps
*/
package net.sergeych.lyngweb
import kotlinx.browser.window
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Source
import net.sergeych.lyng.binding.Binder
import net.sergeych.lyng.binding.SymbolKind
import net.sergeych.lyng.highlight.HighlightKind
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.miniast.MiniAstBuilder
import net.sergeych.lyng.miniast.MiniClassDecl
import net.sergeych.lyng.miniast.MiniFunDecl
import net.sergeych.lyng.miniast.MiniTypeName
import org.w3c.dom.HTMLStyleElement
fun ensureBootstrapCodeBlocks(html: String): String {
val preTagRegex = Regex("""<pre(\s+[^>]*)?>""", RegexOption.IGNORE_CASE)
val classAttrRegex = Regex("""\bclass\s*=\s*(["'])(.*?)\1""", RegexOption.IGNORE_CASE)
return preTagRegex.replace(html) { match ->
val attrs = match.groups[1]?.value ?: ""
if (attrs.isBlank()) return@replace "<pre class=\"code\">"
var newAttrs = attrs
val m = classAttrRegex.find(attrs)
if (m != null) {
val quote = m.groupValues[1]
val classes = m.groupValues[2]
val hasCode = classes.split("\\s+".toRegex()).any { it == "code" }
if (!hasCode) {
val updated = "class=" + quote + (classes.trim().let { if (it.isEmpty()) "code" else "$it code" }) + quote
newAttrs = attrs.replaceRange(m.range, updated)
}
} else {
newAttrs = " class=\"code\"" + attrs
}
"<pre$newAttrs>"
}
}
fun highlightLyngHtml(html: String): String {
ensureLyngHighlightStyles()
val preCodeRegex = Regex(
pattern = """<pre(\s+[^>]*)?>\s*<code([^>]*)>([\s\S]*?)</code>\s*</pre>""",
options = setOf(RegexOption.IGNORE_CASE)
)
val classAttrRegex = Regex("""\bclass\s*=\s*(["'])(.*?)\1""", RegexOption.IGNORE_CASE)
return preCodeRegex.replace(html) { m ->
val preAttrs = m.groups[1]?.value ?: ""
val codeAttrs = m.groups[2]?.value ?: ""
val codeHtml = m.groups[3]?.value ?: ""
val cls = classAttrRegex.find(codeAttrs)?.groupValues?.getOrNull(2) ?: ""
val classes = cls.split("\\s+".toRegex()).filter { it.isNotEmpty() }
val codeHasLyng = classes.any { it.equals("language-lyng", ignoreCase = true) }
val hasAnyLanguage = classes.any { it.startsWith("language-", ignoreCase = true) }
val treatAsLyng = codeHasLyng || !hasAnyLanguage
if (!treatAsLyng) return@replace m.value
val text = htmlUnescape(codeHtml)
val (headText, tailTextOrNull) = if (!codeHasLyng && !hasAnyLanguage) splitDoctestTail(text) else text to null
val headHighlighted = try { applyLyngHighlightToText(headText) } catch (_: Throwable) { return@replace m.value }
val tailHighlighted = tailTextOrNull?.let { renderDoctestTailAsComments(it) } ?: ""
val highlighted = headHighlighted + tailHighlighted
"<pre$preAttrs><code$codeAttrs>$highlighted</code></pre>"
}
}
suspend fun highlightLyngHtmlAsync(html: String): String {
ensureLyngHighlightStyles()
val preCodeRegex = Regex(
pattern = """<pre(\s+[^>]*)?>\s*<code([^>]*)>([\s\S]*?)</code>\s*</pre>""",
options = setOf(RegexOption.IGNORE_CASE)
)
val classAttrRegex = Regex("""\bclass\s*=\s*(["'])(.*?)\1""", RegexOption.IGNORE_CASE)
return try {
val sb = StringBuilder(html.length + 64)
var lastEnd = 0
val matches = preCodeRegex.findAll(html).toList()
for (m in matches) {
if (m.range.first > lastEnd) sb.append(html, lastEnd, m.range.first)
val preAttrs = m.groups[1]?.value ?: ""
val codeAttrs = m.groups[2]?.value ?: ""
val codeHtml = m.groups[3]?.value ?: ""
val clsVal = classAttrRegex.find(codeAttrs)?.groupValues?.getOrNull(2) ?: ""
val classes = clsVal.split("\\s+".toRegex()).filter { it.isNotEmpty() }
val codeHasLyng = classes.any { it.equals("language-lyng", ignoreCase = true) }
val hasAnyLanguage = classes.any { it.startsWith("language-", ignoreCase = true) }
val treatAsLyng = codeHasLyng || !hasAnyLanguage
if (!treatAsLyng) {
sb.append(m.value)
lastEnd = m.range.last + 1
continue
}
val text = htmlUnescape(codeHtml)
val (headText, tailTextOrNull) = if (!codeHasLyng && !hasAnyLanguage) splitDoctestTail(text) else text to null
val headHighlighted = applyLyngHighlightToTextAst(headText)
val tailHighlighted = tailTextOrNull?.let { renderDoctestTailAsComments(it) } ?: ""
val highlighted = headHighlighted + tailHighlighted
sb.append("<pre").append(preAttrs).append(">")
.append("<code").append(codeAttrs).append(">").append(highlighted).append("</code></pre>")
lastEnd = m.range.last + 1
}
if (lastEnd < html.length) sb.append(html, lastEnd, html.length)
sb.toString()
} catch (_: Throwable) {
highlightLyngHtml(html)
}
}
fun ensureLyngHighlightStyles() {
try {
val doc = window.document
if (doc.getElementById("lyng-highlight-style") == null) {
val style = doc.createElement("style") as HTMLStyleElement
style.id = "lyng-highlight-style"
style.textContent = (
"""
/* Lyng syntax highlighting defaults */
.hl-kw { color: #d73a49; font-weight: 600; }
.hl-ty { color: #6f42c1; }
.hl-id { color: #24292e; }
/* Declarations (semantic roles) */
.hl-fn { color: #005cc5; font-weight: 600; }
.hl-class { color: #5a32a3; font-weight: 600; }
.hl-val { color: #1b7f5a; }
.hl-var { color: #1b7f5a; text-decoration: underline dotted currentColor; }
.hl-param { color: #0969da; font-style: italic; }
.hl-num { color: #005cc5; }
.hl-str { color: #032f62; }
.hl-ch { color: #032f62; }
.hl-rx { color: #116329; }
.hl-cmt { color: #6a737d; font-style: italic; }
.hl-op { color: #8250df; }
.hl-punc{ color: #57606a; }
.hl-lbl { color: #e36209; }
.hl-dir { color: #6f42c1; }
.hl-err { color: #b31d28; text-decoration: underline wavy #b31d28; }
/* Dark theme (Bootstrap data attribute) */
[data-bs-theme="dark"] .hl-id { color: #c9d1d9; }
[data-bs-theme="dark"] .hl-op { color: #d2a8ff; }
[data-bs-theme="dark"] .hl-punc { color: #8b949e; }
[data-bs-theme="dark"] .hl-kw { color: #ff7b72; }
[data-bs-theme="dark"] .hl-ty { color: #d2a8ff; }
[data-bs-theme="dark"] .hl-fn { color: #79c0ff; font-weight: 700; }
[data-bs-theme="dark"] .hl-class{ color: #d2a8ff; font-weight: 700; }
[data-bs-theme="dark"] .hl-val { color: #7ee787; }
[data-bs-theme="dark"] .hl-var { color: #7ee787; text-decoration: underline dotted currentColor; }
[data-bs-theme="dark"] .hl-param{ color: #a5d6ff; font-style: italic; }
[data-bs-theme="dark"] .hl-num { color: #79c0ff; }
[data-bs-theme="dark"] .hl-str,
[data-bs-theme="dark"] .hl-ch { color: #a5d6ff; }
[data-bs-theme="dark"] .hl-rx { color: #7ee787; }
[data-bs-theme="dark"] .hl-cmt { color: #8b949e; }
[data-bs-theme="dark"] .hl-lbl { color: #ffa657; }
[data-bs-theme="dark"] .hl-dir { color: #d2a8ff; }
[data-bs-theme="dark"] .hl-err { color: #ffa198; text-decoration-color: #ffa198; }
"""
.trimIndent()
)
doc.head?.appendChild(style)
}
} catch (_: Throwable) {
}
}
private fun splitDoctestTail(text: String): Pair<String, String?> {
if (text.isEmpty()) return "" to null
val hasTrailingNewline = text.endsWith("\n")
val lines = text.split("\n")
var i = lines.size - 1
while (i >= 0 && lines[i].isEmpty()) i--
var count = 0
while (i >= 0) {
val line = lines[i]
val trimmed = line.trimStart()
if (trimmed.isNotEmpty() && !trimmed.startsWith(">>>")) break
if (trimmed.isEmpty()) {
if (count == 0) break else { count++; i--; continue }
}
count++
i--
}
if (count == 0) return text to null
val splitIndex = lines.size - count
val head = buildString {
for (idx in 0 until splitIndex) {
append(lines[idx])
if (idx < lines.size - 1 || hasTrailingNewline) append('\n')
}
}
val tail = buildString {
for (idx in splitIndex until lines.size) {
append(lines[idx])
if (idx < lines.size - 1 || hasTrailingNewline) append('\n')
}
}
return head to tail
}
private fun renderDoctestTailAsComments(tail: String): String {
if (tail.isEmpty()) return ""
val sb = StringBuilder(tail.length + 32)
var start = 0
while (start <= tail.lastIndex) {
val nl = tail.indexOf('\n', start)
val line = if (nl >= 0) tail.substring(start, nl) else tail.substring(start)
sb.append("<span class=\"hl-cmt\">")
sb.append(htmlEscape(line))
sb.append("</span>")
if (nl >= 0) sb.append('\n')
if (nl < 0) break else start = nl + 1
}
return sb.toString()
}
fun applyLyngHighlightToText(text: String): String {
ensureLyngHighlightStyles()
val spans = SimpleLyngHighlighter().highlight(text)
if (spans.isEmpty()) return htmlEscape(text)
val sb = StringBuilder(text.length + spans.size * 16)
fun clamp(i: Int, lo: Int = 0, hi: Int = text.length): Int = i.coerceIn(lo, hi)
fun safeSubstring(start: Int, endExclusive: Int): String {
val s = clamp(start)
val e = clamp(endExclusive)
return if (e <= s) "" else text.substring(s, e)
}
// Compute declaration/param overrides by a fast textual scan
val overrides = detectDeclarationAndParamOverrides(text).toMutableMap()
// Helper: check next non-space char after a given end offset
fun isFollowedBy(end: Int, ch: Char): Boolean {
var i = end
while (i < text.length) {
val c = text[i]
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { i++; continue }
return c == ch
}
return false
}
// 1) Mark function call-sites even in the sync path: identifier immediately followed by '(' or trailing block '{'
run {
for (s in spans) {
if (s.kind == HighlightKind.Identifier) {
val key = s.range.start to s.range.endExclusive
if (!overrides.containsKey(key)) {
if (isFollowedBy(s.range.endExclusive, '(') || isFollowedBy(s.range.endExclusive, '{')) {
overrides[key] = "hl-fn"
}
}
}
}
}
// 2) Color import module path segments after the `import` keyword as hl-dir (best-effort)
run {
var i = 0
while (i < spans.size) {
val s = spans[i]
if (s.kind == HighlightKind.Identifier) {
val token = safeSubstring(s.range.start, s.range.endExclusive)
if (token == "import") {
var j = i + 1
while (j < spans.size) {
val sj = spans[j]
val segText = safeSubstring(sj.range.start, sj.range.endExclusive)
val isDot = sj.kind == HighlightKind.Punctuation && segText == "."
if (sj.kind == HighlightKind.Identifier) {
overrides[sj.range.start to sj.range.endExclusive] = "hl-dir"
} else if (!isDot) {
break
}
j++
}
i = j - 1
}
}
i++
}
}
var pos = 0
for (s in spans) {
if (s.range.start > pos) sb.append(htmlEscape(safeSubstring(pos, s.range.start)))
val cls = when (s.kind) {
HighlightKind.Identifier -> overrides[s.range.start to s.range.endExclusive] ?: cssClassForKind(s.kind)
else -> cssClassForKind(s.kind)
}
sb.append('<').append("span class=\"").append(cls).append('\"').append('>')
sb.append(htmlEscape(safeSubstring(s.range.start, s.range.endExclusive)))
sb.append("</span>")
pos = clamp(s.range.endExclusive)
}
if (pos < text.length) sb.append(htmlEscape(text.substring(pos)))
return sb.toString()
}
/**
* AST-backed highlighter: uses the compiler's optional Mini-AST to precisely mark
* declaration names (functions, classes, vals/vars), parameters, and type name segments.
* Falls back to token-only rendering if anything goes wrong.
*/
suspend fun applyLyngHighlightToTextAst(text: String): String {
return try {
// Ensure CSS present
ensureLyngHighlightStyles()
val source = Source("<web>", text)
// Token baseline
val tokenSpans = SimpleLyngHighlighter().highlight(text)
if (tokenSpans.isEmpty()) return htmlEscape(text)
// Build Mini-AST
val sink = MiniAstBuilder()
Compiler.compileWithMini(text, sink)
val mini = sink.build()
// Collect overrides from AST and Binding with precise offsets
val overrides = HashMap<Pair<Int, Int>, String>()
fun putName(startPos: net.sergeych.lyng.Pos, name: String, cls: String) {
val s = source.offsetOf(startPos)
val e = s + name.length
if (s >= 0 && e <= text.length && s < e) overrides[s to e] = cls
}
// Declarations
mini?.declarations?.forEach { d ->
when (d) {
is MiniFunDecl -> putName(d.nameStart, d.name, "hl-fn")
is MiniClassDecl -> putName(d.nameStart, d.name, "hl-class")
is net.sergeych.lyng.miniast.MiniValDecl -> putName(d.nameStart, d.name, if (d.mutable) "hl-var" else "hl-val")
}
}
// Imports: color each segment as directive/path
mini?.imports?.forEach { imp ->
imp.segments.forEach { seg ->
val s = source.offsetOf(seg.range.start)
val e = source.offsetOf(seg.range.end)
if (s >= 0 && e <= text.length && s < e) overrides[s to e] = "hl-dir"
}
}
// Parameters
mini?.declarations?.filterIsInstance<MiniFunDecl>()?.forEach { fn ->
fn.params.forEach { p -> putName(p.nameStart, p.name, "hl-param") }
}
// Type name segments
fun addTypeSegments(t: net.sergeych.lyng.miniast.MiniTypeRef?) {
when (t) {
is MiniTypeName -> t.segments.forEach { seg ->
val s = source.offsetOf(seg.range.start)
val e = s + seg.name.length
if (s >= 0 && e <= text.length && s < e) overrides[s to e] = "hl-ty"
}
is net.sergeych.lyng.miniast.MiniGenericType -> {
addTypeSegments(t.base)
t.args.forEach { addTypeSegments(it) }
}
else -> {}
}
}
mini?.declarations?.forEach { d ->
when (d) {
is MiniFunDecl -> {
addTypeSegments(d.returnType)
d.params.forEach { addTypeSegments(it.type) }
}
is net.sergeych.lyng.miniast.MiniValDecl -> addTypeSegments(d.type)
is MiniClassDecl -> {}
}
}
// Apply binder results to mark usages by semantic kind (params, locals, top-level, functions, classes)
try {
if (mini != null) {
val binding = Binder.bind(text, mini)
// Map decl ranges to avoid overriding declarations
val declKeys = HashSet<Pair<Int, Int>>()
for (sym in binding.symbols) {
declKeys += (sym.declStart to sym.declEnd)
}
fun classForKind(k: SymbolKind): String? = when (k) {
SymbolKind.Function -> "hl-fn"
SymbolKind.Class, SymbolKind.Enum -> "hl-class"
SymbolKind.Param -> "hl-param"
SymbolKind.Val -> "hl-val"
SymbolKind.Var -> "hl-var"
}
for (ref in binding.references) {
val key = ref.start to ref.end
if (declKeys.contains(key)) continue
if (!overrides.containsKey(key)) {
val sym = binding.symbols.firstOrNull { it.id == ref.symbolId }
val cls = sym?.let { classForKind(it.kind) }
if (cls != null) overrides[key] = cls
}
}
}
} catch (_: Throwable) {
// Binder is best-effort; ignore on any failure
}
fun isFollowedByParen(rangeEnd: Int): Boolean {
var i = rangeEnd
while (i < text.length) {
val ch = text[i]
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') { i++; continue }
return ch == '('
}
return false
}
fun isFollowedByBlock(rangeEnd: Int): Boolean {
var i = rangeEnd
while (i < text.length) {
val ch = text[i]
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') { i++; continue }
return ch == '{'
}
return false
}
// First: mark function call-sites (identifier immediately followed by '('), best-effort.
// Do this before vars/params so it takes precedence where both could match.
run {
for (s in tokenSpans) {
if (s.kind == HighlightKind.Identifier) {
val key = s.range.start to s.range.endExclusive
if (!overrides.containsKey(key)) {
if (isFollowedByParen(s.range.endExclusive) || isFollowedByBlock(s.range.endExclusive)) {
overrides[key] = "hl-fn"
}
}
}
}
}
// Highlight usages of top-level vals/vars and parameters (best-effort, no binder yet)
val nameRoleMap = HashMap<String, String>(8)
mini?.declarations?.forEach { d ->
when (d) {
is net.sergeych.lyng.miniast.MiniValDecl -> nameRoleMap[d.name] = if (d.mutable) "hl-var" else "hl-val"
is MiniFunDecl -> d.params.forEach { p -> nameRoleMap[p.name] = "hl-param" }
else -> {}
}
}
// For every identifier token not already overridden, apply role based on known names
for (s in tokenSpans) {
if (s.kind == HighlightKind.Identifier) {
val key = s.range.start to s.range.endExclusive
if (!overrides.containsKey(key)) {
val ident = text.substring(s.range.start, s.range.endExclusive)
val cls = nameRoleMap[ident]
if (cls != null) {
// Avoid marking function call sites as vars/params
if (!isFollowedByParen(s.range.endExclusive)) {
overrides[key] = cls
}
}
}
}
}
// Render merging overrides
val sb = StringBuilder(text.length + tokenSpans.size * 16)
var pos = 0
for (s in tokenSpans) {
if (s.range.start > pos) sb.append(htmlEscape(text.substring(pos, s.range.start)))
val cls = when (s.kind) {
HighlightKind.Identifier -> overrides[s.range.start to s.range.endExclusive] ?: cssClassForKind(s.kind)
HighlightKind.TypeName -> overrides[s.range.start to s.range.endExclusive] ?: cssClassForKind(s.kind)
else -> cssClassForKind(s.kind)
}
sb.append('<').append("span class=\"").append(cls).append('\"').append('>')
sb.append(htmlEscape(text.substring(s.range.start, s.range.endExclusive)))
sb.append("</span>")
pos = s.range.endExclusive
}
if (pos < text.length) sb.append(htmlEscape(text.substring(pos)))
sb.toString()
} catch (_: Throwable) {
// Fallback to legacy path (token + heuristic overlay)
applyLyngHighlightToText(text)
}
}
// Map of (start,end) -> cssClass for declaration/param identifiers
private fun detectDeclarationAndParamOverrides(text: String): Map<Pair<Int, Int>, String> {
val result = HashMap<Pair<Int, Int>, String>()
val n = text.length
var i = 0
fun isIdentStart(ch: Char) = ch == '_' || ch == '$' || ch == '~' || ch.isLetter()
fun isIdentPart(ch: Char) = ch == '_' || ch == '$' || ch == '~' || ch.isLetterOrDigit()
// A conservative list of language keywords to avoid misclassifying as function calls
val kw = setOf(
"package", "import", "fun", "fn", "class", "enum", "val", "var",
"if", "else", "while", "do", "for", "when", "try", "catch", "finally",
"throw", "return", "break", "continue", "in", "is", "as", "as?", "not",
"true", "false", "null", "private", "protected", "open", "extern", "static"
)
fun skipWs(idx0: Int): Int {
var idx = idx0
while (idx < n) {
val c = text[idx]
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') idx++ else break
}
return idx
}
fun readIdent(idx0: Int): Pair<String, Int>? {
var idx = idx0
if (idx >= n || !isIdentStart(text[idx])) return null
val start = idx
idx++
while (idx < n && isIdentPart(text[idx])) idx++
return text.substring(start, idx) to idx
}
fun readQualified(idx0: Int): Pair<Unit, Int> {
var idx = idx0
// Read A(.B)*
var first = true
while (true) {
val id = readIdent(idx) ?: return Unit to idx0 // nothing read
idx = id.second
val save = idx
if (idx < n && text[idx] == '.') { idx++; first = false; continue } else { idx = save; break }
}
return Unit to idx
}
while (i < n) {
// scan for keywords fun/fn/val/var/class with word boundaries
if (text.startsWith("fun", i) && (i == 0 || !isIdentPart(text[i-1])) && (i+3 >= n || !isIdentPart(text.getOrNull(i+3) ?: '\u0000'))) {
var p = skipWs(i + 3)
// optional receiver Type.
val (u, p2) = readQualified(p)
p = p2
if (p < n && text[p] == '.') {
p++
}
p = skipWs(p)
val name = readIdent(p)
if (name != null) {
val start = p; val end = name.second
result[start to end] = "hl-fn"
// Try to find params list and mark parameter names
var q = skipWs(end)
if (q < n && text[q] == '(') {
q++
loop@ while (q < n) {
q = skipWs(q)
// end of params
if (q < n && text[q] == ')') { q++; break }
val param = readIdent(q)
if (param != null) {
val ps = q; val pe = param.second
// ensure followed by ':' (type)
var t = skipWs(pe)
if (t < n && text[t] == ':') {
result[ps to pe] = "hl-param"
q = t + 1
} else {
q = pe
}
} else {
// skip until next comma or ')'
while (q < n && text[q] != ',' && text[q] != ')') q++
}
q = skipWs(q)
if (q < n && text[q] == ',') { q++; continue@loop }
if (q < n && text[q] == ')') { q++; break@loop }
}
}
}
i = p
continue
}
if (text.startsWith("fn", i) && (i == 0 || !isIdentPart(text[i-1])) && (i+2 >= n || !isIdentPart(text.getOrNull(i+2) ?: '\u0000'))) {
// Treat same as fun
var p = skipWs(i + 2)
val (u, p2) = readQualified(p)
p = p2
if (p < n && text[p] == '.') p++
p = skipWs(p)
val name = readIdent(p)
if (name != null) {
val start = p; val end = name.second
result[start to end] = "hl-fn"
}
i = p
continue
}
if (text.startsWith("val", i) && (i == 0 || !isIdentPart(text[i-1])) && (i+3 >= n || !isIdentPart(text.getOrNull(i+3) ?: '\u0000'))) {
var p = skipWs(i + 3)
val name = readIdent(p)
if (name != null) result[p to name.second] = "hl-val"
i = p
continue
}
if (text.startsWith("var", i) && (i == 0 || !isIdentPart(text[i-1])) && (i+3 >= n || !isIdentPart(text.getOrNull(i+3) ?: '\u0000'))) {
var p = skipWs(i + 3)
val name = readIdent(p)
if (name != null) result[p to name.second] = "hl-var"
i = p
continue
}
if (text.startsWith("class", i) && (i == 0 || !isIdentPart(text[i-1])) && (i+5 >= n || !isIdentPart(text.getOrNull(i+5) ?: '\u0000'))) {
var p = skipWs(i + 5)
val name = readIdent(p)
if (name != null) result[p to name.second] = "hl-class"
i = p
continue
}
// Generic function call site: ident followed by '(' (after optional spaces)
readIdent(i)?.let { (name, endIdx) ->
val startIdx = i
// Avoid keywords; allow member calls too (a.b()) by not checking preceding char
if (name !in kw) {
var q = skipWs(endIdx)
if (q < n && text[q] == '(') {
// Mark as function identifier at call site
result[startIdx to endIdx] = "hl-fn"
i = endIdx
return@let
}
}
}
i++
}
return result
}
private fun cssClassForKind(kind: HighlightKind): String = when (kind) {
HighlightKind.Keyword -> "hl-kw"
HighlightKind.TypeName -> "hl-ty"
HighlightKind.Identifier -> "hl-id"
HighlightKind.Number -> "hl-num"
HighlightKind.String -> "hl-str"
HighlightKind.Char -> "hl-ch"
HighlightKind.Regex -> "hl-rx"
HighlightKind.Comment -> "hl-cmt"
HighlightKind.Operator -> "hl-op"
HighlightKind.Punctuation -> "hl-punc"
HighlightKind.Label -> "hl-lbl"
HighlightKind.Directive -> "hl-dir"
HighlightKind.Error -> "hl-err"
}
/**
* Escapes special HTML characters in a plain text string.
*
* Replacements:
* - `&` → `&amp;`
* - `<` → `&lt;`
* - `>` → `&gt;`
* - `"` → `&quot;`
* - `'` → `&#39;`
*
* @param s Text to escape.
* @return HTML-escaped text safe to insert into an HTML context.
*/
fun htmlEscape(s: String): String = buildString(s.length) {
for (ch in s) when (ch) {
'<' -> append("&lt;")
'>' -> append("&gt;")
'&' -> append("&amp;")
'"' -> append("&quot;")
'\'' -> append("&#39;")
else -> append(ch)
}
}
private fun htmlUnescape(s: String): String {
return s
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
}
@@ -0,0 +1,42 @@
/*
* Copyright 2025 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.lyngweb
/**
* Minimal HTML renderer for Lyng syntax highlighting, compatible with the site CSS.
*
* This object is kept in the legacy package `net.sergeych.site` to preserve
* backward compatibility with existing imports and tests in dependent modules.
* It renders spans with the `hl-*` classes used by the site (e.g., `hl-kw`,
* `hl-id`, `hl-num`).
*/
object SiteHighlight {
/**
* Converts plain Lyng source [text] into HTML with `<span>` wrappers using
* site-compatible `hl-*` classes. This uses the merged highlighter that
* overlays declaration/parameter roles on top of token highlighting so
* functions, variables, classes, and params get distinct styles.
*/
fun renderHtml(text: String): String = applyLyngHighlightToText(text)
/**
* Suspend variant that uses Mini-AST for precise declaration/param/type ranges
* when possible, with a graceful fallback to token+overlay highlighter.
*/
suspend fun renderHtmlAsync(text: String): String = applyLyngHighlightToTextAst(text)
}
@@ -0,0 +1,392 @@
/*
* Copyright 2025 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.lyngweb
import kotlin.test.Test
import kotlin.test.assertEquals
class EditorLogicTest {
private val tab = 4
@Test
fun enter_after_only_rbrace_undents() {
val line = " } " // 4 spaces, brace, trailing spaces; caret after last non-ws
val res = applyEnter(line, line.length, line.length, tab)
// Should insert newline with one indent level less (0 spaces here)
assertEquals(" } \n" + "" , res.text.substring(0, res.text.indexOf('\n')+1))
// After insertion caret should be at end of inserted indentation
// Here indent was 4 and undented by 4 -> 0 spaces after newline
val expectedCaret = line.length + 1 + 0
assertEquals(expectedCaret, res.selStart)
assertEquals(expectedCaret, res.selEnd)
}
@Test
fun enter_after_rbrace_with_only_spaces_to_eol_inserts_after_brace_and_dedents_and_undents_brace_line() {
// Rule 5 exact check: last non-ws before caret is '}', remainder to EOL only spaces
val indents = listOf(0, 4, 8)
for (indent in indents) {
val spaces = " ".repeat(indent)
// Line ends with '}' followed by three spaces
val before = (
"""
1
${'$'}spaces}
"""
).trimIndent() + " "
// Caret right after '}', before trailing spaces
val caret = before.indexOf('}') + 1
val res = applyEnter(before, caret, caret, tab)
val newBraceIndent = (indent - tab).coerceAtLeast(0)
val newLineIndent = newBraceIndent
val expected = buildString {
append("1\n")
append(" ".repeat(newBraceIndent))
append("}\n")
append(" ".repeat(newLineIndent))
}
assertEquals(expected, res.text)
// Caret must be at start of the newly inserted line (after the final newline)
assertEquals(expected.length, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
}
@Test
fun enter_after_rbrace_with_only_spaces_to_eol_crlf_and_undents_brace_line() {
val indent = 4
val spaces = " ".repeat(indent)
val beforeLf = (
"""
1
${'$'}spaces}
"""
).trimIndent() + " "
val before = beforeLf.replace("\n", "\r\n")
val caret = before.indexOf('}') + 1
val res = applyEnter(before, caret, caret, tab)
val actual = res.text.replace("\r\n", "\n")
val newIndent = (indent - tab).coerceAtLeast(0)
val expected = "1\n${" ".repeat(newIndent)}}\n${" ".repeat(newIndent)}"
assertEquals(expected, actual)
assertEquals(expected.length, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
@Test
fun enter_before_closing_brace_outdents() {
val text = " }" // caret before '}' at index 4
val caret = 4
val res = applyEnter(text, caret, caret, tab)
// Inserted a newline with indent reduced to 0
assertEquals("\n" + "" + "}", res.text.substring(caret, caret + 2))
val expectedCaret = caret + 1 + 0
assertEquals(expectedCaret, res.selStart)
assertEquals(expectedCaret, res.selEnd)
}
@Test
fun enter_between_braces_inserts_two_lines() {
val text = "{}"
val caret = 1 // between
val res = applyEnter(text, caret, caret, tab)
// Expect: "{\n \n}"
assertEquals("{\n \n}", res.text)
// Caret after first newline + 4 spaces
assertEquals(1 + 1 + 4, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
@Test
fun enter_after_open_brace_increases_indent() {
val text = "{"
val caret = 1
val res = applyEnter(text, caret, caret, tab)
assertEquals("{\n ", res.text)
assertEquals(1 + 1 + 4, res.selStart)
}
@Test
fun enter_after_rbrace_line_undents_ignoring_trailing_ws() {
// Line contains only '}' plus trailing spaces; caret after last non-ws
val line = " } "
val res = applyEnter(line, line.length, line.length, tab)
// Expect insertion of a newline and an undented indentation of (indent - tab)
val expectedIndentAfterNewline = 0 // 4 - 4
val expected = line + "\n" + " ".repeat(expectedIndentAfterNewline)
// Compare prefix up to inserted indentation
val prefix = res.text.substring(0, expected.length)
assertEquals(expected, prefix)
// Caret positioned at end of inserted indentation
val expectedCaret = expected.length
assertEquals(expectedCaret, res.selStart)
assertEquals(expectedCaret, res.selEnd)
}
@Test
fun shift_tab_outdents_rbrace_only_line_no_newline() {
// Multi-line: a block followed by a number; we outdent the '}' line only
val text = " }\n3"
// Place selection inside the first line
val caret = 5 // after the '}'
val res = applyShiftTab(text, caret, caret, tab)
// Should become '}' on the first line, no extra blank lines
assertEquals("}\n3", res.text)
// Caret should move left by tabSize on that line (but not negative)
val expectedCaret = (caret - tab).coerceAtLeast(0)
assertEquals(expectedCaret, res.selStart)
assertEquals(expectedCaret, res.selEnd)
}
@Test
fun shift_tab_outdents_without_newline() {
val text = " a\n b"
val res = applyShiftTab(text, 0, text.length, tab)
assertEquals("a\nb", res.text)
}
@Test
fun tab_inserts_spaces() {
val text = "x"
val caret = 1
val res = applyTab(text, caret, caret, tab)
assertEquals("x ", res.text)
assertEquals(1 + 4, res.selStart)
}
@Test
fun enter_before_line_with_only_rbrace_dedents_that_line() {
// Initial content as reported by user before fix
val before = (
"""
{
1
2
3
}
1
2
3
"""
).trimIndent()
// Place caret at the end of the line that contains just " 3" (before the line with '}')
val lines = before.split('\n')
// Build index of end of the line with the last " 3"
var idx = 0
var caret = 0
for (i in lines.indices) {
val line = lines[i]
if (line.trimEnd() == "3" && i + 1 < lines.size && lines[i + 1].trim() == "}") {
caret = idx + line.length // position after '3', before the newline
break
}
idx += line.length + 1 // +1 for newline
}
val res = applyEnter(before, caret, caret, tab)
val expected = (
"""
{
1
2
3
}
1
2
3
"""
).trimIndent()
assertEquals(expected, res.text)
}
@Test
fun enter_eol_before_brace_only_next_line_various_indents() {
// Cover Rule 3 with LF newlines, at indents 0, 2, 4, 8
val indents = listOf(0, 2, 4, 8)
for (indent in indents) {
val spaces = " ".repeat(indent)
val before = (
"""
1
2
3
${'$'}spaces}
4
"""
).trimIndent()
// Caret at end of the line with '3' (line before the rbrace-only line)
val caret = before.indexOf("3\n") + 1 // just before LF
val res = applyEnter(before, caret, caret, tab)
// The '}' line must be dedented by one block (clamped at 0) and caret moved to its start
val expectedIndent = (indent - tab).coerceAtLeast(0)
val expected = (
"""
1
2
3
${'$'}{" ".repeat(expectedIndent)}}
4
"""
).trimIndent()
assertEquals(expected, res.text, "EOL before '}' dedent failed for indent=${'$'}indent")
// Caret should be at start of that '}' line (line index 3)
val lines = res.text.split('\n')
var pos = 0
for (i in 0 until 3) pos += lines[i].length + 1
assertEquals(pos, res.selStart, "Caret pos mismatch for indent=${'$'}indent")
assertEquals(pos, res.selEnd, "Caret pos mismatch for indent=${'$'}indent")
}
}
@Test
fun enter_eol_before_brace_only_next_line_various_indents_crlf() {
// Same as above but with CRLF newlines
val indents = listOf(0, 2, 4, 8)
for (indent in indents) {
val spaces = " ".repeat(indent)
val beforeLf = (
"""
1
2
3
${'$'}spaces}
4
"""
).trimIndent()
val before = beforeLf.replace("\n", "\r\n")
val caret = before.indexOf("3\r\n") + 1 // at '3' index + 1 moves to end-of-line before CR
val res = applyEnter(before, caret, caret, tab)
val expectedIndent = (indent - tab).coerceAtLeast(0)
val expectedLf = (
"""
1
2
3
${'$'}{" ".repeat(expectedIndent)}}
4
"""
).trimIndent()
assertEquals(expectedLf, res.text.replace("\r\n", "\n"), "CRLF case failed for indent=${'$'}indent")
}
}
@Test
fun enter_at_start_of_brace_only_line_at_cols_0_2_4() {
val indents = listOf(0, 2, 4)
for (indent in indents) {
val spaces = " ".repeat(indent)
val before = (
"""
1
2
${'$'}spaces}
3
"""
).trimIndent()
// Caret at start of the brace line
val lines = before.split('\n')
var caret = 0
for (i in 0 until 2) caret += lines[i].length + 1
caret += 0 // column 0 of brace line
val res = applyEnter(before, caret, caret, tab)
// Expect the brace line to be dedented by one block, and a new line inserted before it
val expectedIndent = (indent - tab).coerceAtLeast(0)
val expected = (
"""
1
2
${'$'}{" ".repeat(expectedIndent)}
${'$'}{" ".repeat(expectedIndent)}}
3
"""
).trimIndent()
assertEquals(expected, res.text, "Brace-line start enter failed for indent=${'$'}indent")
// Caret must be at start of the inserted line, which has expectedIndent spaces
val afterLines = res.text.split('\n')
var pos = 0
for (i in 0 until 3) pos += afterLines[i].length + 1
// The inserted line is line index 2 (0-based), caret at its start
pos -= afterLines[2].length + 1
pos += 0
assertEquals(pos, res.selStart, "Caret mismatch for indent=${'$'}indent")
assertEquals(pos, res.selEnd, "Caret mismatch for indent=${'$'}indent")
}
}
@Test
fun enter_on_whitespace_only_line_keeps_same_indent() {
val before = " \nnext" // line 0 has 4 spaces only
val caret = 0 + 4 // at end of spaces, before LF
val res = applyEnter(before, caret, caret, tab)
// Default smart indent should keep indent = 4
assertEquals(" \n \nnext", res.text)
// Caret at start of the new blank line with 4 spaces
assertEquals(1 + 4, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
@Test
fun enter_on_line_with_rbrace_else_lbrace_defaults_smart() {
val text = " } else {"
// Try caret positions after '}', before 'e', and after '{'
val carets = listOf(5, 6, text.length)
for (c in carets) {
val res = applyEnter(text, c, c, tab)
// Should not trigger special cases since line is not brace-only or only-spaces after '}'
// Expect same indent (4 spaces)
val expectedPrefix = text.substring(0, c) + "\n" + " ".repeat(4)
assertEquals(expectedPrefix, res.text.substring(0, expectedPrefix.length))
assertEquals(c + 1 + 4, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
}
@Test
fun enter_with_selection_replaces_and_uses_anchor_indent() {
val text = (
"""
1
2
3
"""
).trimIndent()
// Select "2\n3" starting at column 4 of line 1 (indent = 4)
val idxLine0 = text.indexOf('1')
val idxLine1 = text.indexOf('\n', idxLine0) + 1
val selStart = idxLine1 + 4 // after 4 spaces
val selEnd = text.length
val res = applyEnter(text, selStart, selEnd, tab)
val expected = (
"""
1
"""
).trimIndent()
assertEquals(expected, res.text)
assertEquals(expected.length, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
}
+82
View File
@@ -0,0 +1,82 @@
This file describes the Enter/indent rules used by the `lyngweb` module and the dependent `site` project editor, `EditorWithOverlay`.
## Indents and tabs
- Block size ("tab size") is 4 spaces.
- Tabs are converted to spaces: before loading text and on paste/typing, a tab is replaced by 4 spaces.
- Indent of a line is the count of leading ASCII spaces (0x20) on that line.
- Increasing indent sets it to the next multiple of 4.
- Decreasing indent (undent/dedent) sets it to the previous multiple of 4, but never below 0.
- "Indent level" means how many 4-space tab stops precede the caret. An indent that is already 0 cannot be decreased further.
## Newlines
- Internally, logic should treat `\r\n` (CRLF) as a single newline for all rules. Tests may use LF or CRLF.
## Definitions used below
- "Current line" means the line that contains the caret (or the start of the selection).
- "Last non-whitespace before caret" means the last non-space character on the current line at an index strictly less than the caret position (trailing spaces are ignored).
- "Brace-only line" means that the line’s trimmed text equals exactly `}`.
## Enter key rules
The following rules govern what happens when the Enter key is pressed. Each rule also specifies the caret position after the operation.
1) After an opening brace `{`
- If the last non-whitespace before the caret on the current line is `{`, insert a newline and set the new line indent to one block (4 spaces) more than the current line’s indent. Place the caret at that new indent.
2) On a brace-only line `}` (caret on the same line)
- If the current line’s trimmed text is exactly `}`, decrease that line’s indent by one block (not below 0), then insert a newline. The newly inserted line uses the (decreased) indent. Place the caret at the start of the newly inserted line.
3) End of a line before a brace-only next line
- If the caret is at the end of line N, and line N+1 is a brace-only line (ignoring leading spaces), do not insert an extra blank line. Instead, decrease the indent of line N+1 by one block (not below 0) and move the caret to the start of that (dedented) `}` line.
4) Between braces on the same line `{|}`
- If the character immediately before the caret is `{` and the character immediately after the caret is `}`, split into two lines: insert a newline so that the inner (new) line is indented by one block more than the current line, and keep `}` on the following line with the current line’s indent. Place the caret at the start of the inner line (one block deeper).
5) After `}` with only spaces until end-of-line
- If the last non-whitespace before the caret is `}` and the remainder of the current line up to EOL contains only spaces, insert a newline whose indent is one block less than the current line’s indent (not below 0). Place the caret at that indent.
6) Default smart indent
- In all other cases, insert a newline and keep the same indent as the current line. Place the caret at that indent.
## Selections
- If a selection is active, pressing Enter replaces the selection with a newline, and the indent for the new line is computed from the indent of the line containing the caret’s start (anchor). The caret is placed at the start of the inserted line at that indent.
## Notes and examples
Example for rules 1, 2, and 6:
```
1<enter>2<enter>{<enter>3<enter>4<enter>}<enter>5
```
Results in:
```
1
2
{
3
4
}
5
```
## Clarifications and constraints
- All dedent operations clamp at 0 spaces.
- Only ASCII spaces count toward indentation; other Unicode whitespace is treated as content.
- Trailing spaces are ignored when evaluating the "last non-whitespace before caret" condition.
## Recommended tests to cover edge cases
- Enter at EOL when the next line is `}` with leading spaces at indents 0, 2, 4, 8.
- Enter between `{|}` on the same line.
- Enter after `}` with only trailing spaces until EOL.
- Enter at the start of a brace-only line (caret at columns 0, 2, 4).
- Enter on a line containing `} else {` at various caret positions (default to smart indent when not brace-only).
- Enter on a whitespace-only line (default smart indent).
- LF and CRLF inputs for all above.
+2
View File
@@ -36,3 +36,5 @@ dependencyResolutionManagement {
rootProject.name = "lyng"
include(":lynglib")
include(":lyng")
include(":site")
include(":lyngweb")
+146
View File
@@ -0,0 +1,146 @@
/*
* Copyright 2025 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.
*
*/
/*
* Compose HTML (JS-only) SPA module
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
// Compose compiler plugin for Kotlin 2.2.21 (matches version catalog)
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21"
// Compose Multiplatform plugin for convenient dependencies (compose.html.core, etc.)
id("org.jetbrains.compose") version "1.9.3"
}
kotlin {
js(IR) {
browser {
commonWebpackConfig {
// Enable CSS handling
cssSupport {
enabled.set(true)
}
// Ensure predictable output name so we can reference it from index.html
outputFileName = "site.js"
}
}
binaries.executable()
}
sourceSets {
val jsMain by getting {
dependencies {
implementation("org.jetbrains.compose.runtime:runtime:1.9.3")
implementation("org.jetbrains.compose.html:html-core:1.9.3")
// Coroutines for JS (used for fetching docs)
implementation(libs.kotlinx.coroutines.core)
// Lyng highlighter (common, used from JS)
implementation(project(":lynglib"))
// Shared web editor and highlighting utilities
implementation(project(":lyngweb"))
// Markdown parser (NPM)
implementation(npm("marked", "12.0.2"))
// Self-host MathJax via npm and bundle it with webpack
implementation(npm("mathjax", "3.2.2"))
}
// Serve images as static resources in the site
resources.srcDir(rootProject.projectDir.resolve("images"))
// Also include generated resources (e.g., docs index JSON)
// Use Gradle's layout to properly reference the build directory provider
resources.srcDir(layout.buildDirectory.dir("generated-resources"))
}
val jsTest by getting {
dependencies {
implementation(libs.kotlin.test)
// Compose test support (renderComposable)
implementation("org.jetbrains.compose.runtime:runtime:1.9.3")
implementation("org.jetbrains.compose.html:html-core:1.9.3")
implementation(project(":lyngweb"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
}
}
}
}
// Generate an index of markdown documents under project /docs as a JSON array
val generateDocsIndex by tasks.registering {
group = "documentation"
description = "Generates docs-index.json listing all Markdown files under /docs"
val docsDir = rootProject.projectDir.resolve("docs")
val outDir = layout.buildDirectory.dir("generated-resources")
inputs.dir(docsDir)
outputs.dir(outDir)
doLast {
val docs = mutableListOf<String>()
if (docsDir.exists()) {
docsDir.walkTopDown()
.filter { it.isFile && it.extension.equals("md", ignoreCase = true) }
.forEach { f ->
val rel = docsDir.toPath().relativize(f.toPath()).toString()
.replace('\\', '/')
// store paths relative to site root, e.g. "docs/Iterator.md"
docs += "docs/$rel"
}
}
val out = outDir.get().asFile
out.mkdirs()
val file = out.resolve("docs-index.json")
val json = buildString {
append('[')
docs.forEachIndexed { i, s ->
if (i > 0) append(',')
append('"').append(s.replace("\"", "\\\""))
.append('"')
}
append(']')
}
file.writeText(json)
println("Generated ${'$'}{file.absolutePath} with ${'$'}{docs.size} entries")
}
}
// Ensure any ProcessResources task depends on docs index generation so the JSON is packaged
tasks.configureEach {
if (name.endsWith("ProcessResources")) {
dependsOn(generateDocsIndex)
}
}
// Also make common dev/prod tasks depend on docs index generation to avoid 404 during dev server
listOf(
"browserDevelopmentRun",
"browserProductionWebpack",
"jsProcessResources"
).forEach { taskName ->
tasks.matching { it.name == taskName }.configureEach {
dependsOn(generateDocsIndex)
}
}
// Copy Markdown docs into the "docs/" folder in the final resources, so paths in docs-index.json match files
tasks.named<Copy>("jsProcessResources").configure {
// Ensure we don't end up with two copies at root; we no longer add docs as a plain resources srcDir
from(rootProject.projectDir.resolve("docs")) {
into("docs")
}
}
// Optional: configure toolchain if needed by the project; uses root Kotlin version from version catalog
+127
View File
@@ -0,0 +1,127 @@
/*
* Copyright 2025 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.
*
*/
import androidx.compose.runtime.*
import kotlinx.browser.window
import org.jetbrains.compose.web.dom.*
import org.w3c.dom.HTMLElement
@Composable
fun App() {
var route by remember { mutableStateOf(currentRoute()) }
var html by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var toc by remember { mutableStateOf<List<TocItem>>(emptyList()) }
var activeTocId by remember { mutableStateOf<String?>(null) }
var contentEl by remember { mutableStateOf<HTMLElement?>(null) }
val isDocsRoute = route.startsWith("docs/")
val docKey = stripFragment(route)
LaunchedEffect(Unit) {
dlog("init", "initDocsDropdown()")
initDocsDropdown()
}
LaunchedEffect(Unit) {
dlog("init", "initTopSearch()")
initTopSearch()
}
LaunchedEffect(Unit) { ensureDocsLayoutStyles() }
LaunchedEffect(Unit) {
ensureScrollOffsetStyles()
updateNavbarOffsetVar()
}
DisposableEffect(Unit) {
val handler: (org.w3c.dom.events.Event) -> Unit = { updateNavbarOffsetVar() }
window.addEventListener("resize", handler)
onDispose { window.removeEventListener("resize", handler) }
}
DisposableEffect(Unit) {
val listener: (org.w3c.dom.events.Event) -> Unit = { route = currentRoute() }
window.addEventListener("hashchange", listener)
onDispose { window.removeEventListener("hashchange", listener) }
}
PageTemplate(title = when {
isDocsRoute -> null
route.startsWith("reference") -> "Reference"
route.isBlank() -> null
else -> null
}) {
Div({ classes("row", "gy-4") }) {
if (isDocsRoute) {
Div({ classes("col-12", "col-lg-3") }) {
Nav({
classes("position-sticky")
attr("style", "top: calc(var(--navbar-offset) + 1rem)")
}) {
H2({ classes("h6", "text-uppercase", "text-muted") }) { Text("On this page") }
Ul({ classes("list-unstyled") }) {
toc.forEach { item ->
Li({ classes("mb-1") }) {
val pad = when (item.level) { 1 -> "0"; 2 -> "0.75rem"; else -> "1.5rem" }
val routeNoFrag = route.substringBefore('#')
val tocHref = "#/$routeNoFrag#${item.id}"
A(attrs = {
attr("href", tocHref)
attr("style", "padding-left: $pad")
classes("link-body-emphasis", "text-decoration-none")
if (activeTocId == item.id) {
classes("fw-semibold", "text-primary")
attr("aria-current", "true")
}
onClick {
it.preventDefault()
window.location.hash = tocHref
contentEl?.ownerDocument?.getElementById(item.id)
?.let { (it as? HTMLElement)?.scrollIntoView() }
}
}) { Text(item.title) }
}
}
}
}
}
}
Div({ classes("col-12", if (isDocsRoute) "col-lg-9" else "col-lg-12") }) {
when {
route.isBlank() -> HomePage()
route == "tryling" -> TryLyngPage()
!isDocsRoute -> ReferencePage()
else -> DocsPage(
route = route,
html = html,
error = error,
contentEl = contentEl,
onContentEl = { contentEl = it },
setError = { error = it },
setHtml = { html = it },
toc = toc,
setToc = { toc = it },
activeTocId = activeTocId,
setActiveTocId = { activeTocId = it },
)
}
}
}
}
}
+78
View File
@@ -0,0 +1,78 @@
/*
* Copyright 2025 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.
*
*/
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlinx.browser.window
import org.jetbrains.compose.web.dom.*
import org.w3c.dom.HTMLElement
@Composable
fun DocLink(name: String) {
A(attrs = {
classes("btn", "btn-sm", "btn-outline-secondary")
onClick {
window.location.hash = "#/docs/$name"
it.preventDefault()
}
attr("href", "#/docs/$name")
}) { Text(name) }
}
@Composable
fun UnsafeRawHtml(html: String) {
val holder = remember { mutableStateOf<HTMLElement?>(null) }
LaunchedEffect(html) { holder.value?.innerHTML = html }
Div({
ref {
holder.value = it
onDispose { if (holder.value === it) holder.value = null }
}
}) {}
}
@Composable
fun PageTemplate(title: String?, showBack: Boolean = false, content: @Composable () -> Unit) {
Div({ classes("container", "py-4") }) {
if (!title.isNullOrBlank()) {
Div({ classes("d-flex", "align-items-center", "gap-2", "mb-3") }) {
if (showBack) {
A(attrs = {
classes("btn", "btn-outline", "btn-sm")
attr("href", "#")
attr("aria-label", "Back")
onClick {
it.preventDefault()
try {
if (window.history.length > 1) window.history.back()
else window.location.hash = "#"
} catch (e: dynamic) {
window.location.hash = "#"
}
}
}) { I({ classes("bi", "bi-arrow-left") }) }
}
if (!title.isNullOrBlank()) {
H1({ classes("h4", "mb-0") }) { Text(title) }
}
}
}
content()
}
}
+208
View File
@@ -0,0 +1,208 @@
/*
* Copyright 2025 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.
*
*/
import androidx.compose.runtime.*
import kotlinx.browser.window
import kotlinx.coroutines.await
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.P
import org.jetbrains.compose.web.dom.Text
import org.w3c.dom.HTMLDivElement
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLHeadingElement
@Composable
fun DocsPage(
route: String,
html: String?,
error: String?,
contentEl: HTMLElement?,
onContentEl: (HTMLElement?) -> Unit,
setError: (String?) -> Unit,
setHtml: (String?) -> Unit,
toc: List<TocItem>,
setToc: (List<TocItem>) -> Unit,
activeTocId: String?,
setActiveTocId: (String?) -> Unit,
) {
var title by remember { mutableStateOf<String?>(null) }
val docKey = stripFragment(route)
LaunchedEffect(docKey) {
setError(null)
setHtml(null)
setToc(emptyList())
setActiveTocId(null)
val path = routeToPath(route)
try {
val url = "./" + encodeURI(path)
val resp = window.fetch(url).await()
if (!resp.ok) {
setError("Not found: $path (${resp.status})")
} else {
val text = resp.text().await()
title = extractTitleFromMarkdown(text) ?: path.substringAfterLast('/')
// Use AST-backed highlighting for code blocks
setHtml(renderMarkdownAsync(text))
}
} catch (t: Throwable) {
setError("Failed to load: $path — ${t.message}")
}
}
PageTemplate(title = null, showBack = false) {
if (error != null) {
Div({ classes("alert", "alert-danger") }) { Text(error) }
} else if (html == null) {
P { Text("Loading…") }
} else {
Div({
classes("markdown-body")
ref {
onContentEl(it)
onDispose { onContentEl(null) }
}
}) {
UnsafeRawHtml(html)
}
}
}
LaunchedEffect(html, contentEl) {
val el = contentEl ?: return@LaunchedEffect
if (html == null) return@LaunchedEffect
window.requestAnimationFrame {
try {
val firstH1 = el.querySelector("h1") as? HTMLElement
if (firstH1 != null && firstH1.querySelector(".doc-back-btn") == null) {
val back = el.ownerDocument!!.createElement("div") as HTMLDivElement
back.className = "btn btn-outline btn-sm me-2 align-middle doc-back-btn "
back.setAttribute("aria-label","Back")
back.onclick = { ev ->
ev.preventDefault()
try {
if (window.history.length > 1) window.history.back() else window.location.hash = "#"
} catch (e: dynamic) {
window.location.hash = "#"
}
null
}
val icon = el.ownerDocument!!.createElement("i") as HTMLElement
icon.className = "bi bi-arrow-left"
back.appendChild(icon)
firstH1.insertBefore(back, firstH1.firstChild)
}
} catch (_: Throwable) { }
try {
// Use the current document directory as base for relative links and images
val currentDoc = routeToPath(route) // e.g. "docs/tutorial.md"
val basePath = currentDoc.substringBeforeLast('/', missingDelimiterValue = "")
rewriteImages(el, basePath = basePath)
rewriteAnchors(
el,
basePath = basePath,
currentDocPath = currentDoc
) { newRoute ->
window.location.hash = "#/$newRoute"
}
} catch (_: Throwable) { }
try {
val tocItems = buildToc(el)
setToc(tocItems)
} catch (_: Throwable) { setToc(emptyList()) }
try {
val terms = extractSearchTerms(route)
val hits = highlightSearchHits(el, terms)
dlog("search", "highlighted $hits hits for terms=$terms")
} catch (_: Throwable) { }
// After highlighting, if navigated via search (?q=...) and there is no fragment in the route,
// scroll to the first search hit accounting for sticky navbar offset. Do this only once per load.
try {
val hasQueryHits = extractSearchTerms(route).isNotEmpty()
val hasFragment = route.contains('#')
val alreadyScrolled = (el.getAttribute("data-search-scrolled") == "1")
if (hasQueryHits && !hasFragment && !alreadyScrolled) {
val firstHit = el.querySelector(".search-hit") as? HTMLElement
if (firstHit != null) {
el.setAttribute("data-search-scrolled", "1")
// compute top with offset
val rectTop = firstHit.getBoundingClientRect().top + window.scrollY
val offset = (updateNavbarOffsetVar() + 16).toDouble()
val targetY = rectTop - offset
try {
window.scrollTo(js("({top: targetY, behavior: 'instant'})").unsafeCast<dynamic>())
} catch (_: dynamic) {
window.scrollTo(0.0, targetY)
}
dlog("scroll", "auto-scrolled to first hit at y=$targetY")
}
}
} catch (_: Throwable) { }
// MathJax typeset is triggered similarly to original code in Main.kt
try {
val ready = try { js("typeof MathJax !== 'undefined' && MathJax.typeset") as Boolean } catch (_: dynamic) { false }
if (ready) {
try { MathJax.typeset(arrayOf(el)) } catch (_: dynamic) { }
} else {
window.setTimeout({
try { MathJax.typeset(arrayOf(el)) } catch (_: dynamic) { }
}, 50)
}
} catch (_: dynamic) { }
}
}
DisposableEffect(toc, contentEl) {
val el = contentEl ?: return@DisposableEffect onDispose {}
dlog("toc", "have ${toc.size} items, recomputing active")
var scheduled = false
fun computeActive() {
scheduled = false
try {
val heads = toc.mapNotNull { id -> el.querySelector("#${id.id}") as? HTMLHeadingElement }
val tops = heads.map { it.getBoundingClientRect().top + window.scrollY }
val offset = (updateNavbarOffsetVar() + 16).toDouble()
val idx = activeIndexForTops(tops, offset)
setActiveTocId(if (idx in toc.indices) toc[idx].id else null)
} catch (_: Throwable) { }
}
val scrollListener: (org.w3c.dom.events.Event) -> Unit = {
if (!scheduled) {
scheduled = true
window.requestAnimationFrame { computeActive() }
}
}
val resizeListener = scrollListener
computeActive()
window.addEventListener("scroll", scrollListener)
window.addEventListener("resize", resizeListener)
onDispose {
window.removeEventListener("scroll", scrollListener)
window.removeEventListener("resize", resizeListener)
}
}
}
+41
View File
@@ -0,0 +1,41 @@
/*
* Copyright 2025 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.
*
*/
/*
* External/JS interop declarations used across the site.
*/
// MathJax v3 global API (loaded via CDN in index.html)
external object MathJax {
fun typesetPromise(elements: Array<dynamic> = definedExternally): dynamic
fun typeset(elements: Array<dynamic> = definedExternally)
}
// Ensure MathJax loader is bundled (self-host): importing the ES5 CHTML bundle has side effects
@JsModule("mathjax/es5/tex-chtml.js")
@JsNonModule
external val mathjaxBundle: dynamic
// JS JSON parser binding (avoid inline js("JSON.parse(...)"))
external object JSON {
fun parse(text: String): dynamic
}
// URL encoding helpers
external fun encodeURI(uri: String): String
external fun encodeURIComponent(s: String): String
external fun decodeURIComponent(s: String): String
@@ -0,0 +1,75 @@
/*
* Copyright 2025 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.
*
*/
/*
* Thin site-side wrapper for highlighting text with lynglib and producing HTML spans
* with the same CSS classes as used in Main.kt.
*/
package net.sergeych.site
import net.sergeych.lyng.highlight.HighlightKind
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
// Kept only to avoid breaking imports if any remain; actual implementation moved to :lyngweb
// Use net.sergeych.site.SiteHighlight from :lyngweb instead. This local copy is renamed and unused.
@Deprecated("Use lyngweb: net.sergeych.site.SiteHighlight")
object SiteHighlightLocal {
private fun cssClassForKind(kind: HighlightKind): String = when (kind) {
HighlightKind.Keyword -> "hl-kw"
HighlightKind.TypeName -> "hl-ty"
HighlightKind.Identifier -> "hl-id"
HighlightKind.Number -> "hl-num"
HighlightKind.String -> "hl-str"
HighlightKind.Char -> "hl-ch"
HighlightKind.Regex -> "hl-rx"
HighlightKind.Comment -> "hl-cmt"
HighlightKind.Operator -> "hl-op"
HighlightKind.Punctuation -> "hl-punc"
HighlightKind.Label -> "hl-lbl"
HighlightKind.Directive -> "hl-dir"
HighlightKind.Error -> "hl-err"
}
private fun htmlEscape(s: String): String = buildString(s.length) {
for (ch in s) when (ch) {
'<' -> append("&lt;")
'>' -> append("&gt;")
'&' -> append("&amp;")
'"' -> append("&quot;")
'\'' -> append("&#39;")
else -> append(ch)
}
}
fun renderHtml(text: String): String {
val highlighter = SimpleLyngHighlighter()
val spans = highlighter.highlight(text)
if (spans.isEmpty()) return htmlEscape(text)
val sb = StringBuilder(text.length + spans.size * 16)
var pos = 0
for (s in spans) {
if (s.range.start > pos) sb.append(htmlEscape(text.substring(pos, s.range.start)))
val cls = cssClassForKind(s.kind)
sb.append('<').append("span class=\"").append(cls).append('\"').append('>')
sb.append(htmlEscape(text.substring(s.range.start, s.range.endExclusive)))
sb.append("</span>")
pos = s.range.endExclusive
}
if (pos < text.length) sb.append(htmlEscape(text.substring(pos)))
return sb.toString()
}
}
+129
View File
@@ -0,0 +1,129 @@
/*
* Copyright 2025 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.
*
*/
import androidx.compose.runtime.Composable
import net.sergeych.lyngweb.ensureBootstrapCodeBlocks
import net.sergeych.lyngweb.highlightLyngHtml
import net.sergeych.lyngweb.htmlEscape
import org.jetbrains.compose.web.dom.*
@Composable
fun HomePage() {
// Hero section
Section({ classes("py-4", "py-lg-5") }) {
Div({ classes("text-center") }) {
H1({ classes("display-5", "fw-bold", "mb-3") }) { Text("Welcome to Lyng") }
P({ classes("lead", "text-muted", "mb-4") }) {
Text("A lightweight, expressive scripting language designed for clarity, composability, and fun. ")
Br()
Text("Run it anywhere Kotlin runs — share logic across JS, JVM, and more.")
}
Div({ classes("d-flex", "justify-content-center", "gap-2", "flex-wrap", "mb-4") }) {
// Benefits pills
listOf(
"Clean, familiar syntax",
"Immutable-first collections",
"Batteries-included standard library",
"Embeddable and testable"
).forEach { b ->
Span({ classes("badge", "text-bg-secondary", "rounded-pill") }) { Text(b) }
}
}
// CTA buttons
Div({ classes("d-flex", "justify-content-center", "gap-2", "mb-4") }) {
A(attrs = {
classes("btn", "btn-primary", "btn-lg")
attr("href", "#/docs/tutorial.md")
}) {
I({ classes("bi", "bi-play-fill", "me-1") })
Text("Start the tutorial")
}
A(attrs = {
classes("btn", "btn-outline-info", "btn-lg")
attr("href", "#/reference")
}) {
I({ classes("bi", "bi-journal-text", "me-1") })
Text("Browse reference")
}
A(attrs = {
classes("btn", "btn-success", "btn-lg")
// Use the hash path requested by the user: "#tryling"
attr("href", "#tryling")
}) {
I({ classes("bi", "bi-braces", "me-1") })
Text("Try Lyng")
}
// (Telegram button moved to the bottom of the page)
}
}
}
// Code sample
val code = """
// Create, transform, and verify — the Lyng way
import lyng.stdlib
val data = 1..5 // or [1,2,3,4,5]
val evens2 = data.filter { it % 2 == 0 }.map { it * it }
assertEquals([4, 16], evens2)
// Map literal with identifier keys, shorthand, and spread
val base = { a: 1, b: 2 }
val patch = { b: 3, c: }
val m = { "a": 0, ...base, ...patch, d: 4 }
assertEquals(1, m["a"]) // base overwrites 0
assertEquals(3, m["b"]) // patch overwrites base
assertEquals(4, m["d"]) // literal key
>>> void
""".trimIndent()
val mapHtml = "<pre><code>" + htmlEscape(code) + "</code></pre>"
Div({ classes("markdown-body", "mt-3") }) {
UnsafeRawHtml(highlightLyngHtml(ensureBootstrapCodeBlocks(mapHtml)))
}
// Short features list
Div({ classes("row", "g-4", "mt-1") }) {
listOf(
Triple("Fast to learn", "Familiar constructs and readable patterns — be productive in minutes.", "lightning"),
Triple("Portable", "Runs wherever Kotlin runs: reuse logic across platforms.", "globe2"),
Triple("Pragmatic", "A standard library that solves real problems without ceremony.", "gear-fill")
).forEach { (title, text, icon) ->
Div({ classes("col-12", "col-md-4") }) {
Div({ classes("h-100", "p-3", "border", "rounded-3", "bg-body-tertiary") }) {
Div({ classes("d-flex", "align-items-center", "mb-2", "fs-4") }) {
I({ classes("bi", "bi-$icon", "me-2") })
Span({ classes("fw-semibold") }) { Text(title) }
}
P({ classes("mb-0", "text-muted") }) { Text(text) }
}
}
}
}
// Bottom section with a small Telegram button
Div({ classes("text-center", "mt-5", "pb-4") }) {
A(attrs = {
classes("btn", "btn-outline-primary", "btn-sm")
attr("href", "https://t.me/lynglang")
attr("target", "_blank")
attr("rel", "noopener noreferrer")
}) {
I({ classes("bi", "bi-telegram", "me-1") })
Text("Join our Telegram channel")
}
}
}
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2025 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.
*
*/
// Pages moved to separate files: DocsPage.kt, HomePage.kt, ReferencePage.kt
+95
View File
@@ -0,0 +1,95 @@
/*
* Copyright 2025 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.
*
*/
import androidx.compose.runtime.*
import kotlinx.browser.window
import kotlinx.coroutines.await
import org.jetbrains.compose.web.dom.*
@Composable
fun ReferencePage() {
var docs by remember { mutableStateOf<List<String>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
// Titles resolved from the first H1 of each markdown document
var titles by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Load docs index once
LaunchedEffect(Unit) {
try {
val resp = window.fetch("docs-index.json").await()
if (!resp.ok) {
error = "Failed to load docs index (${resp.status})"
} else {
val text = resp.text().await()
// Simple JSON parse into dynamic array of strings
val arr = js("JSON.parse(text)") as Array<String>
docs = arr.toList()
}
} catch (t: Throwable) {
error = t.message
}
}
// Once we have the docs list, fetch their titles (H1) progressively
LaunchedEffect(docs) {
val list = docs ?: return@LaunchedEffect
// Reset titles when list changes
titles = emptyMap()
// Fetch sequentially to avoid flooding; fast enough for small/medium doc sets
for (path in list) {
try {
val mdPath = if (path.startsWith("docs/")) path else "docs/$path"
val url = "./" + encodeURI(mdPath)
val resp = window.fetch(url).await()
if (!resp.ok) continue
val text = resp.text().await()
val title = extractTitleFromMarkdown(text) ?: path.substringAfterLast('/')
// Update state incrementally
titles = titles + (path to title)
} catch (_: Throwable) {
// ignore individual failures; fallback will be filename
}
}
}
H2({ classes("h5", "mb-3") }) { Text("Reference") }
P({ classes("text-muted") }) { Text("Browse all documentation pages included in this build.") }
when {
error != null -> Div({ classes("alert", "alert-danger") }) { Text(error!!) }
docs == null -> P { Text("Loading index…") }
docs!!.isEmpty() -> P { Text("No documents found.") }
else -> {
Ul({ classes("list-group") }) {
docs!!.sorted().forEach { path ->
val displayTitle = titles[path] ?: path.substringAfterLast('/')
Li({ classes("list-group-item", "d-flex", "justify-content-between", "align-items-center") }) {
Div({}) {
A(attrs = {
classes("link-body-emphasis", "text-decoration-none")
attr("href", "#/$path")
}) { Text(displayTitle) }
Br()
Small({ classes("text-muted") }) { Text(path) }
}
I({ classes("bi", "bi-chevron-right") })
}
}
}
}
}
}
+218
View File
@@ -0,0 +1,218 @@
/*
* Copyright 2025 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.
*
*/
import androidx.compose.runtime.*
import kotlinx.coroutines.launch
import net.sergeych.lyng.LyngVersion
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScriptError
import net.sergeych.lyngweb.EditorWithOverlay
import org.jetbrains.compose.web.dom.*
@Composable
fun TryLyngPage() {
val scope = rememberCoroutineScope()
var code by remember {
mutableStateOf(
"""
// Welcome to Lyng! Edit and run.
// Try changing the data and press Ctrl+Enter or click Run.
import lyng.stdlib
val data = 1..5 // or [1, 2, 3, 4, 5]
data.filter { it % 2 == 0 }.map { it * it }
""".trimIndent()
)
}
var running by remember { mutableStateOf(false) }
var output by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var extendedError by remember { mutableStateOf<String?>(null) }
fun runCode() {
if (running) return
running = true
output = null
error = null
extendedError = null
scope.launch {
// keep this outside try so we can show partial prints if evaluation fails
val printed = StringBuilder()
try {
// Create a fresh module scope each run so imports and vars are clean
val s = Scope.new()
// Capture printed output from Lyng `print`/`println` into the UI result window
s.addVoidFn("print") {
for ((i, a) in args.withIndex()) {
if (i > 0) printed.append(' ')
printed.append(a.toString(this).value)
}
}
s.addVoidFn("println") {
for ((i, a) in args.withIndex()) {
if (i > 0) printed.append(' ')
printed.append(a.toString(this).value)
}
printed.append('\n')
}
val result = s.eval(code)
// Render with inspect for nice, user-facing representation
val text = try {
result.inspect(s)
} catch (_: Throwable) {
// Fallback if some object lacks inspect override
result.toString()
}
val combined = buildString {
if (printed.isNotEmpty()) append(printed)
// Always show the final expression value, like a REPL
if (isNotEmpty()) append('\n')
append(">>> ")
append(text)
}
output = combined
} catch (t: Throwable) {
// Show error, but also keep anything that has been printed so far
// Prefer detailed message including stack if available (K/JS)
val errText = buildString {
append(t.toString())
if (t !is ScriptError) {
try {
val st = t.asDynamic().stack as? String
if (!st.isNullOrBlank()) {
append("\n")
append(st)
}
} catch (_: Throwable) {
}
}
}
if (printed.isNotEmpty()) {
output = printed.toString()
}
if (t is ScriptError) {
error = t.errorMessage
extendedError = errText
} else {
error = t.message
extendedError = errText
}
} finally {
running = false
}
}
}
fun resetCode() {
code = """
// Welcome to Lyng! Edit and run.
import lyng.stdlib
[1,2,3].map { it * 10 }
""".trimIndent()
output = null
error = null
}
PageTemplate(title = "Try Lyng", showBack = true) {
// Intro
P({ classes("lead", "text-muted", "mb-3") }) {
Text("Type or paste Lyng code and run it right in your browser with embedded Lyng interpreter")
}
// Editor
Div({ classes("mb-3") }) {
Div({ classes("form-label", "fw-semibold") }) { Text("Code (v${LyngVersion})") }
EditorWithOverlay(
code = code,
setCode = { code = it },
onKeyDown = { ev ->
val key = ev.key
val ctrlOrMeta = ev.ctrlKey || ev.metaKey
if (ctrlOrMeta && key.lowercase() == "enter") {
ev.preventDefault()
runCode()
}
},
// Keep current initial size but allow the editor to grow with content
autoGrow = true
)
}
// Actions
Div({ classes("d-flex", "gap-2", "mb-3") }) {
Button(attrs = {
classes("btn", "btn-primary")
if (running) attr("disabled", "disabled")
onClick {
it.preventDefault()
runCode()
}
}) {
I({ classes("bi", "bi-play-fill", "me-1") })
Text(if (running) "Running…" else "Run")
}
Button(attrs = {
classes("btn", "btn-outline-secondary")
if (running) attr("disabled", "disabled")
onClick {
it.preventDefault()
resetCode()
}
}) {
I({ classes("bi", "bi-arrow-counterclockwise", "me-1") })
Text("Reset")
}
}
// Results
if (error != null) {
Div({ classes("alert", "alert-danger") }) {
I({ classes("bi", "bi-exclamation-triangle-fill", "me-2") })
Span({ classes("fw-semibold", "me-1") }) { Text("Error:") }
// Show actual error text (previously printed the literal template)
Span { Text(error!!) }
}
}
if (output != null || error != null) {
Div({ classes("card", "mb-3") }) {
Div({ classes("card-header", "d-flex", "align-items-center", "gap-2") }) {
I({ classes("bi", "bi-terminal") })
Span({ classes("fw-semibold") }) { Text("Result") }
}
Div({ classes("card-body", "bg-body-tertiary") }) {
if (output != null) {
Pre({ classes("mb-0") }) { Code { Text(output!!) } }
}
if (extendedError != null) {
if (output != null) Hr({})
Div({ classes("alert", "alert-danger", "mb-0") }) {
Pre({ classes("mb-0") }) { Code { Text(extendedError!!) } }
}
}
}
}
}
// Tips
P({ classes("text-muted", "small") }) {
I({ classes("bi", "bi-info-circle", "me-1") })
Text("Tip: press Ctrl+Enter (or ⌘+Enter on Mac) to run.")
}
}
}
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2025 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.
*
*/
/* Common small data types used across site */
data class TocItem(val level: Int, val id: String, val title: String)
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2025 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.
*
*/
@file:JsModule("marked")
@file:JsNonModule
@file:Suppress("UnsafeCastFromDynamic")
package externals
// Kotlin/JS externals for the ESM `marked` package (v12+)
// Usage in Kotlin: `marked.parse(src)`
// JS equivalent: `import { marked } from 'marked'; marked.parse(src)`
external object marked {
fun parse(src: String): String
}
+311
View File
@@ -0,0 +1,311 @@
<!--
~ Copyright 2025 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.
~
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lyng language</title>
<!-- GitHub Markdown CSS (light and dark). We toggle these from the app. -->
<link
id="md-light"
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.4.0/github-markdown.css"
/>
<link
id="md-dark"
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.4.0/github-markdown-dark.css"
disabled
/>
<!-- Bootstrap 5.3 CSS -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous"
/>
<!-- Bootstrap Icons -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/>
<!-- MathJax v3 configuration (the loader is bundled via npm/webpack) -->
<script>
window.MathJax = {
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']],
processEscapes: true
},
options: {
skipHtmlTags: ['script','noscript','style','textarea','pre','code']
},
startup: {
// We'll trigger typesetting manually from the app after markdown is mounted
typeset: false
}
};
</script>
<!-- Loader is imported in the app bundle; no external script tag required -->
<style>
/* Reserve space for fixed navbar and ensure in-page anchors don't end up hidden under it */
:root { --navbar-offset: 56px; }
body {
/* Fallback padding; JS will keep this equal to the real navbar height */
/*padding-top: var(--navbar-offset);*/
/* Make native hash jumps account for the fixed header */
/*scroll-padding-top: calc(var(--navbar-offset) + 8px);*/
}
/* Also offset scroll for headings and any element targeted by an id */
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6,
[id] {
/*scroll-margin-top: calc(var(--navbar-offset) + 8px);*/
}
/* Unify markdown and page backgrounds with Bootstrap theme variables */
.markdown-body {
box-sizing: border-box;
min-width: 200px;
line-height: 1.6;
background: var(--bs-body-bg) !important;
color: var(--bs-body-color) !important;
}
.markdown-body > :first-child { margin-top: 0 !important; }
.markdown-body table { margin: 1rem 0; }
.markdown-body pre {
padding: .75rem;
border-radius: .375rem;
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
}
/* Style only inline code, avoid affecting code blocks inside <pre> to prevent first-line extra indent */
.markdown-body :not(pre) > code:not([class]) {
background: var(--bs-tertiary-bg);
padding: .15rem .3rem;
border-radius: .25rem;
}
/* Lyng syntax highlighting palette (inspired by default GitHub/VS Code) */
/* Light theme */
.hl-kw { color: #d73a49; font-weight: 600; }
.hl-ty { color: #6f42c1; }
.hl-id { color: #24292e; }
/* Declarations (semantic roles) */
.hl-fn { color: #005cc5; font-weight: 600; }
.hl-class { color: #5a32a3; font-weight: 600; }
.hl-val { color: #1b7f5a; }
.hl-var { color: #1b7f5a; text-decoration: underline dotted currentColor; }
.hl-param { color: #0969da; font-style: italic; }
.hl-num { color: #005cc5; }
.hl-str { color: #032f62; }
.hl-ch { color: #032f62; }
.hl-rx { color: #116329; }
.hl-cmt { color: #6a737d; font-style: italic; }
.hl-op { color: #8250df; }
.hl-punc{ color: #57606a; }
.hl-lbl { color: #e36209; }
.hl-dir { color: #6f42c1; }
.hl-err { color: #b31d28; text-decoration: underline wavy #b31d28; }
/* Dark theme overrides (GitHub Dark-like) */
[data-bs-theme="dark"] .hl-id { color: #c9d1d9; }
[data-bs-theme="dark"] .hl-op { color: #d2a8ff; }
[data-bs-theme="dark"] .hl-punc { color: #8b949e; }
[data-bs-theme="dark"] .hl-kw { color: #ff7b72; }
[data-bs-theme="dark"] .hl-ty { color: #d2a8ff; }
[data-bs-theme="dark"] .hl-fn { color: #79c0ff; font-weight: 700; }
[data-bs-theme="dark"] .hl-class{ color: #d2a8ff; font-weight: 700; }
[data-bs-theme="dark"] .hl-val { color: #7ee787; }
[data-bs-theme="dark"] .hl-var { color: #7ee787; text-decoration: underline dotted currentColor; }
[data-bs-theme="dark"] .hl-param{ color: #a5d6ff; font-style: italic; }
[data-bs-theme="dark"] .hl-num { color: #79c0ff; }
[data-bs-theme="dark"] .hl-str,
[data-bs-theme="dark"] .hl-ch { color: #a5d6ff; }
[data-bs-theme="dark"] .hl-rx { color: #7ee787; }
[data-bs-theme="dark"] .hl-cmt { color: #8b949e; }
[data-bs-theme="dark"] .hl-lbl { color: #ffa657; }
[data-bs-theme="dark"] .hl-dir { color: #d2a8ff; }
[data-bs-theme="dark"] .hl-err { color: #ffa198; text-decoration-color: #ffa198; }
/* Top-left corner ribbon for version label */
.corner-ribbon {
position: fixed;
/* Place below the fixed navbar (Bootstrap fixed-top ~ z-index: 1030) */
z-index: 1020; /* above content, below navbar */
top: var(--navbar-offset, 56px);
left: 0;
width: 162px; /* 10% narrower than 180px */
text-align: center;
/* Slightly asymmetric padding to visually center text within rotated band (desktop) */
/* Nudge text a bit lower on large screens */
padding: .42rem 0 .28rem;
font-weight: 600;
font-size: .75rem; /* make text smaller by default */
line-height: 1.1; /* stabilize vertical centering */
transform: translate(-52px, 10px) rotate(-45deg);
box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,.2);
border: 1px solid rgba(0,0,0,.15);
pointer-events: none; /* decorative; don't block clicks underneath */
}
@media (max-width: 576px) {
.corner-ribbon {
width: 135px; /* 10% narrower than 150px */
transform: translate(-50px, 8px) rotate(-45deg);
font-size: .7rem; /* even smaller on phones */
padding: .32rem 0 .28rem; /* keep mobile text centered too */
}
}
</style>
</head>
<body>
<!-- Top-left version ribbon -->
<div class="corner-ribbon bg-danger text-white">
<span class="me-3">
v1.0.3
</span>
</div>
<!-- Fixed top navbar for the whole site -->
<a href="#root" class="visually-hidden-focusable position-absolute top-0 start-0 m-2 px-2 py-1 bg-body border rounded">Skip to content</a>
<nav class="navbar navbar-expand-lg bg-body-tertiary fixed-top border-bottom" role="navigation" aria-label="Primary">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center gap-2" href="#">
<i class="bi bi-braces-asterisk"></i>
Lyng
</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#topbarNav"
aria-controls="topbarNav"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="topbarNav">
<!-- Left-aligned main menu -->
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#/docs/Iterator.md" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Documentation
</a>
<ul id="docsDropdownMenu" class="dropdown-menu">
<!-- Will be populated at runtime -->
<li><a class="dropdown-item" href="#/docs/tutorial.md" data-route="docs">tutorial</a></li>
<li><hr class="dropdown-divider"></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="#/reference" data-route="reference">Reference</a>
</li>
</ul>
<!-- Right utilities: search, GitHub link -->
<div class="ms-auto d-flex align-items-center gap-2" style="min-width: 240px;">
<!-- Search widget with Bootstrap Icons magnifying glass -->
<form class="d-flex" role="search" onsubmit="return false;">
<div class="input-group position-relative" style="min-width: 280px;">
<span class="input-group-text bg-transparent border-end-0">
<i class="bi bi-search"></i>
</span>
<input
id="topSearch"
class="form-control border-start-0"
type="search"
placeholder="Search"
aria-label="Search"
/>
<!-- Results dropdown (controlled by app code) -->
<div id="topSearchMenu" class="dropdown-menu w-100 shadow" style="max-height:60vh; overflow:auto; top: calc(100% + .25rem); left: 0;">
</div>
</div>
</form>
<a class="btn btn-sm btn-outline-secondary" href="https://gitea.sergeych.net/SergeychWorks/lyng" target="_blank" rel="noopener" aria-label="GitHub">
<i class="bi bi-git"></i>
</a>
</div>
</div>
</div>
</nav>
<!-- App root; extra top padding so content is not hidden behind fixed navbar -->
<div id="root" class="pt-4" tabindex="-1" aria-live="polite" aria-atomic="false"></div>
<!-- App bundle (produced by Kotlin/JS). The Gradle config forces this name. -->
<script src="site.js"></script>
<!-- Bootstrap 5.3 JS bundle (includes Popper) -->
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"
></script>
<script>
// Adjust body padding to account for fixed-top navbar height (handles dynamic font sizes)
(function () {
function adjustPadding() {
var nav = document.querySelector('.navbar.fixed-top');
if (!nav) return;
var h = nav.getBoundingClientRect().height;
var px = Math.ceil(h) + 'px';
// Keep legacy inline padding for older browsers
document.body.style.paddingTop = px;
// And expose as a CSS variable used by scroll-padding/scroll-margin rules
// document.documentElement.style.setProperty('--navbar-offset', px);
}
window.addEventListener('load', adjustPadding);
window.addEventListener('resize', adjustPadding);
// Also listen to Bootstrap collapse events which may change navbar height on small screens
document.addEventListener('shown.bs.collapse', adjustPadding);
document.addEventListener('hidden.bs.collapse', adjustPadding);
// Basic active state management for topbar links
function updateActive() {
var hash = location.hash;
// Clear previous actives
document.querySelectorAll('#topbarNav .nav-link').forEach(function(a){
a.classList.remove('active');
a.removeAttribute('aria-current');
});
// Mark active for simple routes
var activeLink = null;
if (!hash || hash === '#' || hash === '#/') {
activeLink = document.querySelector('#topbarNav .nav-link[data-route="home"]');
} else if (hash.startsWith('#/docs/')) {
// Mark Docs menu root as active
activeLink = document.querySelector('#topbarNav .nav-link.dropdown-toggle');
} else if (hash.startsWith('#/reference')) {
activeLink = document.querySelector('#topbarNav .nav-link[data-route="reference"]');
}
if (activeLink) {
activeLink.classList.add('active');
activeLink.setAttribute('aria-current', 'page');
}
}
window.addEventListener('hashchange', updateActive);
window.addEventListener('load', updateActive);
})();
</script>
</body>
</html>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- Copyright 2025 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.
-
-->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#000000;}
</style>
<g>
<path class="st0" d="M505.837,180.418L279.265,76.124c-7.349-3.385-15.177-5.093-23.265-5.093c-8.088,0-15.914,1.708-23.265,5.093
L6.163,180.418C2.418,182.149,0,185.922,0,190.045s2.418,7.896,6.163,9.627l226.572,104.294c7.349,3.385,15.177,5.101,23.265,5.101
c8.088,0,15.916-1.716,23.267-5.101l178.812-82.306v82.881c-7.096,0.8-12.63,6.84-12.63,14.138c0,6.359,4.208,11.864,10.206,13.618
l-12.092,79.791h55.676l-12.09-79.791c5.996-1.754,10.204-7.259,10.204-13.618c0-7.298-5.534-13.338-12.63-14.138v-95.148
l21.116-9.721c3.744-1.731,6.163-5.504,6.163-9.627S509.582,182.149,505.837,180.418z"/>
<path class="st0" d="M256,346.831c-11.246,0-22.143-2.391-32.386-7.104L112.793,288.71v101.638
c0,22.314,67.426,50.621,143.207,50.621c75.782,0,143.209-28.308,143.209-50.621V288.71l-110.827,51.017
C278.145,344.44,267.25,346.831,256,346.831z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- Copyright 2025 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.
-
-->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#000000;}
</style>
<g>
<path class="st0" d="M505.837,180.418L279.265,76.124c-7.349-3.385-15.177-5.093-23.265-5.093c-8.088,0-15.914,1.708-23.265,5.093
L6.163,180.418C2.418,182.149,0,185.922,0,190.045s2.418,7.896,6.163,9.627l226.572,104.294c7.349,3.385,15.177,5.101,23.265,5.101
c8.088,0,15.916-1.716,23.267-5.101l178.812-82.306v82.881c-7.096,0.8-12.63,6.84-12.63,14.138c0,6.359,4.208,11.864,10.206,13.618
l-12.092,79.791h55.676l-12.09-79.791c5.996-1.754,10.204-7.259,10.204-13.618c0-7.298-5.534-13.338-12.63-14.138v-95.148
l21.116-9.721c3.744-1.731,6.163-5.504,6.163-9.627S509.582,182.149,505.837,180.418z"/>
<path class="st0" d="M256,346.831c-11.246,0-22.143-2.391-32.386-7.104L112.793,288.71v101.638
c0,22.314,67.426,50.621,143.207,50.621c75.782,0,143.209-28.308,143.209-50.621V288.71l-110.827,51.017
C278.145,344.44,267.25,346.831,256,346.831z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,40 @@
/*
* Copyright 2025 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.
*
*/
/*
* Test that markdown fenced code blocks render with `.code` class on <pre>
*/
import kotlin.test.Test
import kotlin.test.assertTrue
class CodeBlockStyleTest {
@Test
fun codeBlocksGetBootstrapClass() {
val md = """
```kotlin
println("Hi")
```
""".trimIndent()
val html = renderMarkdown(md)
assertTrue(html.contains("<pre", ignoreCase = true), "Rendered HTML should contain a <pre> tag. Got: $html")
val hasClass = html.contains("<pre class=\"code\"", ignoreCase = true) ||
html.contains(" class=\"code ", ignoreCase = true) ||
html.contains(" class=\"code\"", ignoreCase = true) ||
Regex("""<pre[^>]*class=['"][^'"]*\bcode\b[^'"]*['"]""", RegexOption.IGNORE_CASE).containsMatchIn(html)
assertTrue(hasClass, "<pre> should have 'code' class. Got: $html")
}
}
@@ -0,0 +1,38 @@
/*
* Copyright 2025 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.
*
*/
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CommentSpanExtendTest {
@Test
fun singleLineCommentCoversTillEol() {
val line = "// see the difference: apply changes this to newly created Point:"
val md = """
```lyng
$line
```
""".trimIndent()
val html = renderMarkdown(md)
// Entire line must be inside the comment span
assertTrue(html.contains("<span class=\"hl-cmt\">$line</span>"), "Comment should extend to EOL: $html")
// There must be no stray tail like </span>nt: (regression case)
assertFalse(html.contains("</span>nt:"), "No trailing tail should remain outside comment span: $html")
}
}
@@ -0,0 +1,55 @@
/*
* Copyright 2025 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.
*
*/
/*
* Tests for definition list post-processing
*/
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DefinitionListTest {
@Test
fun singleTermMultipleDefinitions() {
val md = """
Term
: First definition
: Second definition with an [inline link](#here)
""".trimIndent()
val html = renderMarkdown(md)
assertTrue(html.contains("<dl>", ignoreCase = true), "Expected <dl> in rendered HTML. Got: $html")
assertTrue(html.contains("<dt>Term</dt>", ignoreCase = true), "Term should be inside <dt>. Got: $html")
// There should be two <dd> entries
val ddCount = Regex("<dd>", RegexOption.IGNORE_CASE).findAll(html).count()
assertTrue(ddCount == 2, "Expected two <dd> elements, got $ddCount. HTML: $html")
// No leading ':' should remain inside definitions
assertFalse(Regex("<dd>\\s*:", RegexOption.IGNORE_CASE).containsMatchIn(html), "Definition should not start with ':'. HTML: $html")
}
@Test
fun notADefListWhenStartsWithColon() {
val md = """
: Not a term paragraph
Next paragraph
""".trimIndent()
val html = renderMarkdown(md)
// Should not produce a <dl>
assertFalse(html.contains("<dl>", ignoreCase = true), "Should not create <dl> when first paragraph starts with ':'. HTML: $html")
}
}
+307
View File
@@ -0,0 +1,307 @@
/*
* Copyright 2025 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.
*
*/
/*
* End-to-end browser tests for the Try Lyng editor (Compose HTML + EditorWithOverlay)
*/
package net.sergeych.site
import androidx.compose.runtime.mutableStateOf
import kotlinx.browser.document
import kotlinx.coroutines.await
import kotlinx.coroutines.test.runTest
import org.jetbrains.compose.web.dom.Text
import org.jetbrains.compose.web.renderComposable
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLTextAreaElement
import kotlin.test.Test
import kotlin.test.assertEquals
class EditorE2ETest {
// Utility to wait next animation frame in tests
private suspend fun nextFrame() {
js("return new Promise(requestAnimationFrame)").unsafeCast<kotlin.js.Promise<Unit>>().await()
}
// Programmatically type text into the textarea at current selection and dispatch an input event
private fun typeText(ta: HTMLTextAreaElement, s: String) {
for (ch in s) {
val start = ta.selectionStart ?: 0
val end = ta.selectionEnd ?: start
val before = ta.value.substring(0, start)
val after = ta.value.substring(end)
ta.value = before + ch + after
val newPos = start + 1
ta.selectionStart = newPos
ta.selectionEnd = newPos
// Fire input so EditorWithOverlay updates its state
val ev = js("new Event('input', {bubbles:true})")
ta.dispatchEvent(ev.unsafeCast<org.w3c.dom.events.Event>())
}
}
@Test
fun shift_tab_outdents_rbrace_only_line_no_newline() = runTest {
val root = document.createElement("div") as HTMLElement
root.id = "test-root2"
document.body!!.appendChild(root)
val initial = """
}
3
""".trimIndent()
val state = mutableStateOf(initial)
renderComposable(rootElementId = root.id) {
net.sergeych.lyngweb.EditorWithOverlay(state.value, { s -> state.value = s })
Text("")
}
// settle
nextFrame(); nextFrame()
val ta = (root.querySelector("textarea") as HTMLTextAreaElement?)
?: (document.querySelector("#${'$'}{root.id} textarea") as HTMLTextAreaElement?)
?: (document.querySelector("textarea") as HTMLTextAreaElement)
// Put caret after the '}' (line 0, col 1)
ta.selectionStart = 1
ta.selectionEnd = 1
// Shift+Tab
dispatchKey(ta, key = "Tab", shift = true)
nextFrame()
val expected = """
}
3
""".trimIndent()
// After outdent, since there was no leading spaces, content should stay the same
// but must not get an extra blank line
val actual = ta.value
assertEquals(expected.trimEnd('\n'), actual.trimEnd('\n'))
}
private fun dispatchKey(el: HTMLElement, key: String, shift: Boolean = false) {
val ev = js("new KeyboardEvent('keydown', {key: key, shiftKey: shift, bubbles: true})")
el.dispatchEvent(ev.unsafeCast<org.w3c.dom.events.Event>())
}
@Test
fun enter_before_closing_brace_produces_expected_layout() = runTest {
// Mount a root
val root = document.createElement("div") as HTMLElement
root.id = "test-root"
document.body!!.appendChild(root)
val initial = """
{
1
2
3
}
1
2
3
""".trimIndent()
val state = mutableStateOf(initial)
renderComposable(rootElementId = root.id) {
net.sergeych.lyngweb.EditorWithOverlay(state.value, { s -> state.value = s })
// Keep composition alive
Text("")
}
// Wait for composition to render
nextFrame(); nextFrame()
val ta = (root.querySelector("textarea") as HTMLTextAreaElement?)
?: (document.querySelector("#${'$'}{root.id} textarea") as HTMLTextAreaElement?)
?: (document.querySelector("textarea") as HTMLTextAreaElement)
// Place caret at the end of the line containing the last " 3" before the brace
val lines = ta.value.split('\n')
var pos = 0
for (i in lines.indices) {
val line = lines[i]
if (line.trimEnd() == "3" && i + 1 < lines.size && lines[i + 1].trim() == "}") {
pos += line.length // end of this line
break
}
pos += line.length + 1
}
ta.selectionStart = pos
ta.selectionEnd = pos
// Press Enter
dispatchKey(ta, key = "Enter")
// Allow compose state to propagate
nextFrame(); nextFrame()
val expected = """
{
1
2
3
}
1
2
3
""".trimIndent()
val actual = ta.value
println("[DEBUG_LOG] Editor textarea after Enter:\n" + actual)
// Allow a trailing newline at EOF (browser textareas often keep one)
assertEquals(expected.trimEnd('\n'), actual.trimEnd('\n'))
}
@Test
fun enter_between_braces_inserts_inner_line_e2e() = runTest {
val root = document.createElement("div") as HTMLElement
root.id = "test-root3"
document.body!!.appendChild(root)
val initial = "{}"
val state = mutableStateOf(initial)
renderComposable(rootElementId = root.id) {
net.sergeych.lyngweb.EditorWithOverlay(state.value, { s: String -> state.value = s })
Text("")
}
nextFrame(); nextFrame()
val ta = (root.querySelector("textarea") as HTMLTextAreaElement?)
?: (document.querySelector("#${'$'}{root.id} textarea") as HTMLTextAreaElement?)
?: (document.querySelector("textarea") as HTMLTextAreaElement)
// Place caret between braces
ta.selectionStart = 1
ta.selectionEnd = 1
// Press Enter
dispatchKey(ta, key = "Enter")
nextFrame(); nextFrame()
val expected = "{\n \n}"
assertEquals(expected, ta.value.trimEnd('\n'))
}
@Test
fun example_sequence_from_rules_matches_expected() = runTest {
// Mount root
val root = document.createElement("div") as HTMLElement
root.id = "test-root4"
document.body!!.appendChild(root)
val initial = ""
val state = mutableStateOf(initial)
renderComposable(rootElementId = root.id) {
net.sergeych.lyngweb.EditorWithOverlay(state.value, { s: String -> state.value = s })
Text("")
}
// Allow initial composition
nextFrame(); nextFrame()
val ta = (root.querySelector("textarea") as HTMLTextAreaElement?)
?: (document.querySelector("#${'$'}{root.id} textarea") as HTMLTextAreaElement?)
?: (document.querySelector("textarea") as HTMLTextAreaElement)
// Ensure caret at end
ta.selectionStart = ta.value.length
ta.selectionEnd = ta.selectionStart
// Perform the documented sequence: 1<Enter>2<Enter>{<Enter>3<Enter>4<Enter>}<Enter>5
typeText(ta, "1"); nextFrame()
dispatchKey(ta, key = "Enter"); nextFrame(); nextFrame()
typeText(ta, "2"); nextFrame()
dispatchKey(ta, key = "Enter"); nextFrame(); nextFrame()
typeText(ta, "{"); nextFrame()
dispatchKey(ta, key = "Enter"); nextFrame(); nextFrame()
typeText(ta, "3"); nextFrame()
dispatchKey(ta, key = "Enter"); nextFrame(); nextFrame()
typeText(ta, "4"); nextFrame()
dispatchKey(ta, key = "Enter"); nextFrame(); nextFrame()
typeText(ta, "}"); nextFrame()
dispatchKey(ta, key = "Enter"); nextFrame(); nextFrame()
typeText(ta, "5"); nextFrame(); nextFrame()
val expected = (
"""
1
2
{
3
4
}
5
"""
).trimIndent()
val actual = ta.value.trimEnd('\n')
assertEquals(expected, actual)
}
@Test
fun enter_after_rbrace_with_only_spaces_to_eol_e2e() = runTest {
// Mount root
val root = document.createElement("div") as HTMLElement
root.id = "test-rule5"
document.body!!.appendChild(root)
val initial = " } " // rbrace at indent 4, followed by 3 spaces until EOL
val state = mutableStateOf(initial)
renderComposable(rootElementId = root.id) {
net.sergeych.lyngweb.EditorWithOverlay(state.value, { s: String -> state.value = s })
Text("")
}
nextFrame(); nextFrame()
val ta = (root.querySelector("textarea") as HTMLTextAreaElement?)
?: (document.querySelector("#${'$'}{root.id} textarea") as HTMLTextAreaElement?)
?: (document.querySelector("textarea") as HTMLTextAreaElement)
// Place caret right after '}' (index 5)
val braceIdx = ta.value.indexOf('}')
ta.selectionStart = braceIdx + 1
ta.selectionEnd = ta.selectionStart
// Press Enter
dispatchKey(ta, key = "Enter")
nextFrame(); nextFrame()
// Expect: brace line is dedented by one block (from 4 to 0), newline inserted AFTER it,
// new line indent equals dedented indent (0), caret at start of the new blank line
val expected = "}\n"
val actual = ta.value.take(expected.length)
kotlin.test.assertEquals(expected, actual)
// Caret should be at start of newly inserted line (position expected.length)
kotlin.test.assertEquals(expected.length, ta.selectionStart)
kotlin.test.assertEquals(ta.selectionStart, ta.selectionEnd)
}
}
@@ -0,0 +1,111 @@
/*
* Copyright 2025 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.site
import net.sergeych.lyng.highlight.HighlightKind
import net.sergeych.lyng.highlight.HighlightSpan
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
import net.sergeych.lyngweb.SiteHighlight
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertTrue
class HighlightSmokeTest {
private fun spansToLabeled(text: String, spans: List<HighlightSpan>): List<Pair<String, HighlightKind>> =
spans.map { text.substring(it.range.start, it.range.endExclusive) to it.kind }
@Test
fun highlightAssertEqualsSnippet() {
val text = "assertEquals( [9,10], r.takeLast(2).toList() )"
val spans = SimpleLyngHighlighter().highlight(text)
val labeled = spansToLabeled(text, spans)
// Basic sanity: identifier assertEquals present and not split
assertTrue(labeled.any { it.first == "assertEquals" && it.second == HighlightKind.Identifier })
// Basic numbers detection
assertTrue(labeled.any { it.first == "9" && it.second == HighlightKind.Number })
assertTrue(labeled.any { it.first == "10" && it.second == HighlightKind.Number })
assertTrue(labeled.any { it.first == "2" && it.second == HighlightKind.Number })
}
@Test
fun highlightMapLiteralHtml() {
val text = """
val base = { a: 1, b: }
val m = { "a": 1, b: , ...base, }
""".trimIndent()
val html = SiteHighlight.renderHtml(text)
// Curly braces and commas are punctuation
assertContains(html, "<span class=\"hl-punc\">{</span>")
assertContains(html, "<span class=\"hl-punc\">}</span>")
assertTrue(html.contains("hl-punc") && html.contains(","), "comma should be punctuation")
// Colon after key is punctuation
assertContains(html, "<span class=\"hl-punc\">:</span>")
// Spread operator present
assertContains(html, "<span class=\"hl-op\">...</span>")
// String key and identifier key appear
assertContains(html, "<span class=\"hl-str\">\"a\"</span>")
assertContains(html, "<span class=\"hl-id\">b</span>")
}
@Test
fun renderHtmlContainsCorrectClasses() {
val text = "assertEquals( [9,10], r.takeLast(2).toList() )"
val html = SiteHighlight.renderHtml(text)
// Ensure important parts are wrapped with expected classes
// In the new renderer, call-sites are marked as functions (hl-fn). Accept either id or fn.
assertTrue(
html.contains("<span class=\"hl-id\">assertEquals</span>") ||
html.contains("<span class=\"hl-fn\">assertEquals</span>"),
"assertEquals should be highlighted as identifier or function call"
)
assertContains(html, "<span class=\"hl-num\">9</span>")
assertContains(html, "<span class=\"hl-num\">10</span>")
assertContains(html, "<span class=\"hl-num\">2</span>")
// Punctuation and operators appear; allow either combined or separate, just ensure class exists
assertTrue(html.contains("hl-punc"))
assertTrue(html.contains("hl-op"))
}
@Test
fun highlightNamedArgsAndMapSplat() {
val text = """
fun test(a,b,c,d) { [a,b,c,d] }
val r = test("A?", c: "C!", ...Map("d" => "D!", "b" => "B!"))
""".trimIndent()
val spans = SimpleLyngHighlighter().highlight(text)
val labeled = spansToLabeled(text, spans)
// Ensure identifier for function name appears
assertTrue(labeled.any { it.first == "test" && it.second == HighlightKind.Identifier })
// Ensure colon for named argument is tokenized as punctuation
assertTrue(labeled.any { it.first == ":" && it.second == HighlightKind.Punctuation })
// Ensure ellipsis operator present
assertTrue(labeled.any { it.first == "..." && it.second == HighlightKind.Operator })
// Ensure Map identifier is present
assertTrue(labeled.any { it.first == "Map" && it.second == HighlightKind.Identifier })
}
@Test
fun highlightNamedArgsHtml() {
val text = "val res = test( a: 1, b: 2 )"
val html = SiteHighlight.renderHtml(text)
// Expect a colon wrapped as punctuation span
assertTrue(html.contains("<span class=\"hl-punc\">:</span>"))
}
}
+83
View File
@@ -0,0 +1,83 @@
/*
* Copyright 2025 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.
*
*/
/*
* Tests for link and image rewriting in rendered markdown HTML.
*/
import kotlinx.browser.document
import org.w3c.dom.HTMLAnchorElement
import org.w3c.dom.HTMLDivElement
import org.w3c.dom.HTMLImageElement
import kotlin.test.Test
import kotlin.test.assertEquals
class LinkRewriteTest {
private fun makeContainer(html: String): HTMLDivElement {
val div = document.createElement("div") as HTMLDivElement
div.innerHTML = html.trimIndent()
return div
}
@Test
fun testRewriteAnchorsAndImagesUsingDocBasePath() {
val html = """
<div class="markdown-body">
<p>
<a id="a1" href="Iterator.md">iterator page</a>
<a id="a2" href="Iterator.md#intro">iterator with frag</a>
<a id="a3" href="#install">install section</a>
<a id="a4" href="https://example.com">external</a>
<a id="a5" href="img/p.png">asset</a>
</p>
<p>
<img id="i1" src="images/pic.png" />
</p>
</div>
"""
val root = makeContainer(html)
val currentDoc = "docs/tutorial.md"
val basePath = currentDoc.substringBeforeLast('/')
// exercise rewrites
rewriteImages(root, basePath)
rewriteAnchors(root, basePath, currentDoc) { /* no-op for tests */ }
// Validate anchors
val a1 = root.querySelector("#a1") as HTMLAnchorElement
assertEquals("#/docs/Iterator.md", a1.getAttribute("href"))
val a2 = root.querySelector("#a2") as HTMLAnchorElement
assertEquals("#/docs/Iterator.md#intro", a2.getAttribute("href"))
val a3 = root.querySelector("#a3") as HTMLAnchorElement
assertEquals("#/docs/tutorial.md#install", a3.getAttribute("href"))
val a4 = root.querySelector("#a4") as HTMLAnchorElement
// external should remain unchanged
assertEquals("https://example.com", a4.getAttribute("href"))
val a5 = root.querySelector("#a5") as HTMLAnchorElement
// non-md relative assets should become relative to doc directory (no SPA hash)
assertEquals("docs/img/p.png", a5.getAttribute("href"))
// Validate image src
val i1 = root.querySelector("#i1") as HTMLImageElement
assertEquals("docs/images/pic.png", i1.getAttribute("src"))
}
}
@@ -0,0 +1,63 @@
/*
* Copyright 2025 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.
*
*/
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class LyngHighlightTest {
@Test
fun highlightsLyngFencedBlock() {
val md = """
```lyng
and or not constructor property a+b
```
""".trimIndent()
val html = renderMarkdown(md)
// Should produce span classes for keywords and operator
assertTrue(html.contains("hl-kw"), "Expected keyword class in highlighted Lyng block: $html")
assertTrue(html.contains("hl-op"), "Expected operator class in highlighted Lyng block: $html")
// Ensure code is inside <pre><code ... language-lyng>
assertTrue(html.contains("language-lyng", ignoreCase = true), "Expected language-lyng class retained: $html")
}
@Test
fun nonLyngBlocksAreUntouched() {
val md = """
```kotlin
println("Hi")
```
""".trimIndent()
val html = renderMarkdown(md)
// Should not include our Lyng-specific classes
assertFalse(html.contains("hl-kw"), "Non-Lyng block should not be Lyng-highlighted: $html")
assertTrue(html.contains("<pre"), "Expected <pre> present: $html")
}
@Test
fun escapesAngleBracketsInsideSpans() {
val md = """
```lyng
a<b
```
""".trimIndent()
val html = renderMarkdown(md)
// the '<' should be escaped in HTML
assertTrue(html.contains("&lt;"), "Expected escaped < inside highlighted HTML: $html")
}
}
@@ -0,0 +1,31 @@
/*
* Copyright 2025 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.
*
*/
/*
* Basic test to ensure markdown is actually rendered by the ESM `marked` import
*/
import kotlin.test.Test
import kotlin.test.assertTrue
class MarkdownRenderTest {
@Test
fun rendersHeading() {
val html = renderMarkdown("# Hello")
assertTrue(html.contains("<h1", ignoreCase = true), "Expected <h1> in rendered HTML, got: $html")
assertTrue(html.contains("Hello"), "Rendered HTML should contain the heading text")
}
}
@@ -0,0 +1,46 @@
/*
* Copyright 2025 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.
*
*/
/*
* Tests for Reference page rendering helpers
*/
import kotlin.test.Test
import kotlin.test.assertTrue
class ReferencePageTest {
@Test
fun rendersReferenceListWithLinks() {
val docs = listOf(
"docs/Iterator.md",
"docs/guides/perf_guide.md"
)
val html = renderReferenceListHtml(docs)
// Basic structure
assertTrue(html.contains("<ul", ignoreCase = true), "Expected <ul> in reference list HTML: $html")
// Contains links to the docs routes
assertTrue(html.contains("href=\"#/docs/Iterator.md\""), "Should link to #/docs/Iterator.md: $html")
assertTrue(html.contains("Iterator.md"), "Should display file name Iterator.md: $html")
// Nested path should display directory info
assertTrue(html.contains("guides"), "Should include directory name for nested docs: $html")
// Chevron icon present
assertTrue(html.contains("bi-chevron-right"), "Should include chevron icon class: $html")
}
}
@@ -0,0 +1,75 @@
/*
* Tests for routing helpers, anchor rewriting, and TOC building
*/
import kotlinx.browser.document
import org.w3c.dom.HTMLElement
import kotlin.test.*
class RouteAndDomRewriteTest {
@Test
fun testAnchorFromHash() {
assertNull(anchorFromHash("#"))
assertNull(anchorFromHash("") )
assertNull(anchorFromHash("#/docs/Iterator.md"))
assertEquals("section-1", anchorFromHash("#/docs/Iterator.md#section-1"))
assertEquals("a", anchorFromHash("#/docs/x/y.md#a"))
}
@Test
fun testNormalizePath() {
assertEquals("docs/a/b.md", normalizePath("docs/./a/../a/b.md"))
assertEquals("docs/a.md", normalizePath("docs/x/../a.md"))
assertEquals("a/b", normalizePath("a//b"))
}
@Test
fun testRewriteAnchors_forIntraPageAndMdLinks() {
val root = document.createElement("div") as HTMLElement
root.innerHTML = """
<p>
<a id=one href="#local">Local</a>
<a id=two href="Sibling.md#sec">Sibling</a>
<a id=three href="image.png">Img</a>
</p>
""".trimIndent()
val basePath = "docs" // current doc in docs root
val currentDoc = "docs/Iterator.md"
rewriteAnchors(root, basePath, currentDoc) { /* noop for test */ }
val one = root.querySelector("#one") as HTMLElement
assertEquals("#/${currentDoc}#local", one.getAttribute("href"))
val two = root.querySelector("#two") as HTMLElement
assertEquals("#/docs/Sibling.md#sec", two.getAttribute("href"))
val three = root.querySelector("#three") as HTMLElement
// non-md stays relative to base path
assertEquals("docs/image.png", three.getAttribute("href"))
}
@Test
fun testBuildToc_assignsIdsAndLevels() {
val root = document.createElement("div") as HTMLElement
root.innerHTML = """
<h1>Title</h1>
<h2>Section</h2>
<h2>Section</h2>
<h3>Sub</h3>
""".trimIndent()
val toc = buildToc(root)
assertTrue(toc.isNotEmpty(), "TOC should not be empty")
// Ensure we produced unique IDs for duplicate headings
val ids = toc.map { it.id }
assertEquals(ids.toSet().size, ids.size)
// Also verify IDs are actually set on DOM
ids.forEach { id ->
val el = root.ownerDocument?.getElementById(id) ?: root.querySelector("#${id}")
assertNotNull(el, "Heading with id $id should be present in DOM")
}
}
}
@@ -0,0 +1,48 @@
/*
* Copyright 2025 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.
*
*/
/*
* Tests for route/anchor parsing utilities to support TOC navigation.
*/
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class RouteParsingTest {
@Test
fun stripFragmentRemovesAnchor() {
assertEquals("docs/Iterator.md", stripFragment("docs/Iterator.md#sec"))
assertEquals("docs/Iterator.md", stripFragment("docs/Iterator.md"))
assertEquals("docs/dir/file.md", stripFragment("docs/dir/file.md#x-y"))
}
@Test
fun routeToPathDropsFragmentAndNormalizes() {
assertEquals("docs/Iterator.md", routeToPath("docs/Iterator.md#part"))
assertEquals("docs/Iterator.md", routeToPath("Iterator.md#part"))
assertEquals("docs/guides/perf_guide.md", routeToPath("guides/perf_guide.md#toc"))
}
@Test
fun anchorFromHashParsesSecondHash() {
assertEquals("part", anchorFromHash("#/docs/Iterator.md#part"))
assertEquals("sec-2", anchorFromHash("#/docs/g/it.md#sec-2"))
assertNull(anchorFromHash("#/docs/Iterator.md"))
assertNull(anchorFromHash("#just-a-frag"))
assertNull(anchorFromHash("/not-a-hash"))
}
}
+47
View File
@@ -0,0 +1,47 @@
/*
* Copyright 2025 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.
*
*/
import kotlin.test.Test
import kotlin.test.assertEquals
class ScrollSpyTest {
@Test
fun emptyListReturnsZero() {
assertEquals(0, activeIndexForTops(emptyList(), 80.0))
}
@Test
fun selectsFirstWhenOnlyFirstIsAboveOffset() {
val tops = listOf(20.0, 200.0, 800.0) // px from viewport top
val idx = activeIndexForTops(tops, offsetPx = 80.0)
assertEquals(1, idx) // 20 <= 80, 200 > 80 -> index 1 (second heading is first below offset)
}
@Test
fun selectsLastHeadingAboveOffset() {
val tops = listOf(-100.0, 50.0, 70.0)
val idx = activeIndexForTops(tops, offsetPx = 80.0)
assertEquals(2, idx) // all three are <= 80 -> last index
}
@Test
fun stopsBeforeFirstBelowOffset() {
val tops = listOf(-200.0, -50.0, 30.0, 150.0, 400.0)
val idx = activeIndexForTops(tops, offsetPx = 80.0)
assertEquals(3, idx) // 30 <= 80 qualifies; 150 > 80 stops, so index 3rd (0-based -> 3?)
}
}
+37
View File
@@ -0,0 +1,37 @@
/*
* Copyright 2025 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.
*
*/
/*
* Test that markdown tables are rendered with Bootstrap `.table` class
*/
import kotlin.test.Test
import kotlin.test.assertTrue
class TableStyleTest {
@Test
fun tablesGetBootstrapClass() {
val md = """
|Col A|Col B|
|-----|-----|
| 1 | 2 |
""".trimMargin()
val html = renderMarkdown(md)
assertTrue(html.contains("<table", ignoreCase = true), "Rendered HTML should contain a <table> tag. Got: $html")
assertTrue(html.contains("class=\"table\"") || html.contains("class=\"table ") || html.contains(" class=\"table\""),
"Rendered <table> should have Bootstrap 'table' class. Got: $html")
}
}
@@ -0,0 +1,61 @@
/*
* Copyright 2025 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.
*
*/
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class UnfencedLyngTest {
@Test
fun indentedCodeBlocksAreLyngByDefault() {
val md = """
Some text paragraph.
and or not a+b
Next paragraph.
""".trimIndent()
val html = renderMarkdown(md)
// Should contain a code block and Lyng highlight classes
assertTrue(html.contains("<pre", ignoreCase = true), "Expected <pre> in rendered HTML: $html")
assertTrue(html.contains("<code", ignoreCase = true), "Expected <code> in rendered HTML: $html")
assertTrue(html.contains("hl-kw"), "Expected keyword highlight in indented code block: $html")
assertTrue(html.contains("hl-op"), "Expected operator highlight in indented code block: $html")
// Should not introduce a non-Lyng language class; language-lyng may or may not be present, but other languages shouldn't
assertFalse(html.contains("language-kotlin", ignoreCase = true), "Should not mark indented block as another language: $html")
}
@Test
fun doctestTailLinesRenderedAsComments() {
val md = """
Intro paragraph.
a + b
>>> 3
>>> ok
""".trimIndent()
val html = renderMarkdown(md)
// Lyng highlight should appear for the code line (the '+')
assertTrue(html.contains("hl-op"), "Expected operator highlighting for head part: $html")
// The doctest tail lines should be wrapped as comments
assertTrue(html.contains("hl-cmt"), "Expected comment highlighting for doctest tail: $html")
// Ensure the markers are inside the comment span content
assertTrue(html.contains("<span class=\"hl-cmt\">&gt;&gt;&gt;"), "Doctest lines should start with >>> inside comment span: $html")
}
}