Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26ddb94f5d | ||
|
|
cbca8cacb5 | ||
|
|
8fae4709ed | ||
|
|
d118d29429 | ||
|
|
cb9df79ce3 | ||
|
|
d9a26dd467 | ||
|
|
813ebebddd | ||
|
|
2d721101dd | ||
|
|
d6e6d68b18 | ||
|
|
83825a9272 | ||
|
|
f0fc7ddd84 | ||
|
|
ea0ecb1db3 | ||
|
|
28b961d339 | ||
|
|
4d1cd491e0 | ||
|
|
5fbb1d5393 | ||
|
|
9a4131ee3d | ||
|
|
391e200f19 | ||
|
|
32e739ab8f | ||
|
|
f4375ad627 | ||
|
|
faead76688 | ||
|
|
72c6dc2bde | ||
|
|
a229f227e1 | ||
|
|
01632dc6d7 | ||
|
|
4e37d0be26 | ||
|
|
fa3fda144b | ||
|
|
2b320ab52a | ||
|
|
f1e978599c | ||
|
|
215c7245a0 | ||
|
|
d307ed2a04 | ||
|
|
b82af3dceb | ||
|
|
1fadc42414 | ||
|
|
918534afb5 | ||
|
|
646a676b3e | ||
|
|
f4d1a77496 | ||
|
|
67e4d76f59 | ||
|
|
beb462fd62 |
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>
|
||||||
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
### Unreleased
|
### 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:
|
- Multiple Inheritance (MI) completed and enabled by default:
|
||||||
- Active C3 Method Resolution Order (MRO) for deterministic, monotonic lookup across complex hierarchies and diamonds.
|
- Active C3 Method Resolution Order (MRO) for deterministic, monotonic lookup across complex hierarchies and diamonds.
|
||||||
- Qualified dispatch:
|
- Qualified dispatch:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Lyng: modern scripting for kotlin multiplatform
|
# 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:
|
- 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:
|
Planned autumn 2025. Complete dynamic language with sufficient standard library:
|
||||||
|
|
||||||
@@ -171,31 +173,25 @@ Ready features:
|
|||||||
- [x] dynamic fields
|
- [x] dynamic fields
|
||||||
- [x] function annotations
|
- [x] function annotations
|
||||||
- [x] better stack reporting
|
- [x] better stack reporting
|
||||||
|
|
||||||
### Under way:
|
|
||||||
|
|
||||||
- [x] regular exceptions + extended `when`
|
- [x] regular exceptions + extended `when`
|
||||||
- [x] multiple inheritance for user classes
|
- [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"
|
## plan: v1.0 - v1.5 Stability, integration, optimizations
|
||||||
|
|
||||||
Estimated spring of 2026
|
|
||||||
|
|
||||||
Planned features.
|
|
||||||
|
|
||||||
|
- [x] site with integrated interpreter to give a try
|
||||||
|
- [x] kotlin part public API good docs, integration focused
|
||||||
- [ ] type specifications
|
- [ ] type specifications
|
||||||
- [ ] language server or compose-based lyng-aware editor
|
- [ ] language server or compose-based lyng-aware editor
|
||||||
- [ ] source docs and maybe lyng.md to a standard
|
- [ ] source docs and maybe lyng.md to a standard
|
||||||
- [ ] metadata first class access from lyng
|
- [ ] metadata first class access from lyng
|
||||||
|
- [x] aggressive optimizations
|
||||||
|
- [ ] JVM bytecode optimizations
|
||||||
|
|
||||||
## After 1.5 "Ideal scripting"
|
## After 1.5 "Ideal scripting"
|
||||||
|
|
||||||
Estimated winter 2027
|
Estimated winter 2027
|
||||||
|
|
||||||
- [ ] client with GUI support based on compose multiplatform somehow
|
- [ ] client with GUI support based on compose multiplatform somehow
|
||||||
- [ ] notebook - style workbooks with graphs, formulae, etc.
|
- [ ] notebook - style workbooks with graphs, formulae, etc.
|
||||||
- [ ] aggressive optimizations
|
|
||||||
|
|
||||||
[parallelism]: docs/parallelism.md
|
[parallelism]: docs/parallelism.md
|
||||||
Executable
+105
@@ -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
|
||||||
|
|
||||||
@@ -9,6 +9,9 @@ To implement the iterator you need to implement only two abstract methods:
|
|||||||
|
|
||||||
### hasNext(): Bool
|
### hasNext(): Bool
|
||||||
|
|
||||||
|
// lets test
|
||||||
|
// offset
|
||||||
|
|
||||||
Should return `true` if call to `next()` will return valid next element.
|
Should return `true` if call to `next()` will return valid next element.
|
||||||
|
|
||||||
### next(): Obj
|
### next(): Obj
|
||||||
|
|||||||
+73
-8
@@ -1,19 +1,22 @@
|
|||||||
# Map
|
# Map
|
||||||
|
|
||||||
Map is a mutable collection of key-value pars, where keys are unique. Maps could be created with
|
Map is a mutable collection of key-value pairs, where keys are unique. You can create maps in two ways:
|
||||||
constructor or `.toMap` methods. When constructing from a list, each list item must be a [Collection] with exactly 2 elements, for example, a [List].
|
- 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.
|
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" )
|
val oldForm = Map( "foo" => 1, "bar" => "buzz" )
|
||||||
assert(map is Map)
|
assert(oldForm is Map)
|
||||||
assert(map.size == 2)
|
assert(oldForm.size == 2)
|
||||||
assert(map is Iterable)
|
assert(oldForm is Iterable)
|
||||||
>>> void
|
>>> 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.
|
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:
|
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)
|
assert( map["foo"] == -1)
|
||||||
>>> void
|
>>> 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
|
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()`
|
hold nulls in the map - this is not a recommended practice when using `remove` returned value. `clear()`
|
||||||
removes all.
|
removes all.
|
||||||
@@ -110,4 +143,36 @@ is equal.
|
|||||||
assert( m1 != m3 )
|
assert( m1 != m3 )
|
||||||
>>> void
|
>>> 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)
|
[Collection](Collection.md)
|
||||||
@@ -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
|
|
||||||
@@ -5,7 +5,7 @@ lambdas and class declarations.
|
|||||||
|
|
||||||
## Regular
|
## Regular
|
||||||
|
|
||||||
## default values
|
## Default values
|
||||||
|
|
||||||
Default parameters should not be mixed with mandatory ones:
|
Default parameters should not be mixed with mandatory ones:
|
||||||
|
|
||||||
@@ -96,5 +96,60 @@ There could be any number of splats at any positions. You can splat any other [I
|
|||||||
>>> [start,1,2,3,end]
|
>>> [start,1,2,3,end]
|
||||||
>>> void
|
>>> 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
|
[tutorial]: tutorial.md
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
[//]: # (excludeFromIndex)
|
||||||
|
|
||||||
# String
|
# String
|
||||||
|
|
||||||
# This document is for developer notes only
|
# This document is for developer notes only
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Modules inclusion
|
# Modules inclusion
|
||||||
|
|
||||||
|
[//]: # (excludeFromIndex)
|
||||||
|
|
||||||
Module is, at the low level, a statement that modifies a given context by adding
|
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
|
here local and exported symbols, performing some tasks and even returning some value
|
||||||
we don't need for now.
|
we don't need for now.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
[//]: # (excludeFromIndex)
|
||||||
|
|
||||||
|
|
||||||
Provide:
|
Provide:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
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
|
## 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`).
|
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`).
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# JVM-only Performance Optimization Plan (Saved)
|
# JVM-only Performance Optimization Plan (Saved)
|
||||||
|
|
||||||
|
[//]: # (excludeFromIndex)
|
||||||
|
|
||||||
Date: 2025-11-10 22:14 (local)
|
Date: 2025-11-10 22:14 (local)
|
||||||
|
|
||||||
This document captures the agreed next optimization steps so we can restore the plan later if needed.
|
This document captures the agreed next optimization steps so we can restore the plan later if needed.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
|
#!/bin/env lyng
|
||||||
/*
|
/*
|
||||||
Calculate the limit of Sum( f(n) )
|
Calculate the limit of Sum( f(n) )
|
||||||
until it reaches asymptotic limit 0.00001% change
|
until it reaches asymptotic limit 0.00001% change
|
||||||
|
|
||||||
return null or found limit
|
return null or found limit
|
||||||
*/
|
*/
|
||||||
fun findSumLimit(f) {
|
fun findSumLimit(f) {
|
||||||
|
|||||||
+77
-5
@@ -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:
|
Everything is an expression in Lyng. Even an empty block:
|
||||||
|
|
||||||
@@ -657,14 +676,63 @@ Please see [Set] for detailed description.
|
|||||||
|
|
||||||
# Maps
|
# Maps
|
||||||
|
|
||||||
Maps are unordered collection of key-value pairs, where keys are unique. See [Map] for details. Map also
|
Maps are unordered collections of key-value pairs, where keys are unique. Maps are also [Iterable].
|
||||||
are [Iterable]:
|
|
||||||
|
|
||||||
val m = Map( "foo" => 77, "bar" => "buzz" )
|
You can create them either with the classic constructor (still supported):
|
||||||
assertEquals( m["foo"], 77 )
|
|
||||||
|
val old = Map( "foo" => 77, "bar" => "buzz" )
|
||||||
|
assertEquals( old["foo"], 77 )
|
||||||
>>> void
|
>>> 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
|
# Flow control operators
|
||||||
|
|
||||||
@@ -693,6 +761,8 @@ Or, more neat:
|
|||||||
|
|
||||||
## When
|
## When
|
||||||
|
|
||||||
|
See also: [Comprehensive guide to `when`](when.md)
|
||||||
|
|
||||||
It is very much like the kotlin's:
|
It is very much like the kotlin's:
|
||||||
|
|
||||||
fun type(x) {
|
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.
|
- 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.
|
- `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.
|
- Safe‑call `?.` works with `as?` for optional dispatch.
|
||||||
|
|
||||||
|
To get details on OOP in Lyng, see [OOP notes](oop.md).
|
||||||
+239
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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": ":" } ] }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,3 +29,9 @@ android.nonTransitiveRClass=true
|
|||||||
|
|
||||||
# other
|
# 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,7 +1,7 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.5.2"
|
agp = "8.5.2"
|
||||||
clikt = "5.0.3"
|
clikt = "5.0.3"
|
||||||
kotlin = "2.2.20"
|
kotlin = "2.2.21"
|
||||||
android-minSdk = "24"
|
android-minSdk = "24"
|
||||||
android-compileSdk = "34"
|
android-compileSdk = "34"
|
||||||
kotlinx-coroutines = "1.10.2"
|
kotlinx-coroutines = "1.10.2"
|
||||||
|
|||||||
+10
-1
@@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
kotlin("multiplatform") version "2.2.20"
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "net.sergeych"
|
group = "net.sergeych"
|
||||||
@@ -31,6 +31,15 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
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 {
|
jvm {
|
||||||
binaries {
|
binaries {
|
||||||
executable {
|
executable {
|
||||||
|
|||||||
+49
-13
@@ -21,23 +21,15 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
group = "net.sergeych"
|
group = "net.sergeych"
|
||||||
version = "1.0.0-SNAPSHOT"
|
version = "1.0.4-SNAPSHOT"
|
||||||
|
|
||||||
buildscript {
|
// Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below
|
||||||
repositories {
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
dependencies {
|
|
||||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.0")
|
|
||||||
classpath("com.codingfeline.buildkonfig:buildkonfig-gradle-plugin:latest_version")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.androidLibrary)
|
alias(libs.plugins.androidLibrary)
|
||||||
// alias(libs.plugins.vanniktech.mavenPublish)
|
// 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"
|
id("com.codingfeline.buildkonfig") version "0.17.1"
|
||||||
`maven-publish`
|
`maven-publish`
|
||||||
}
|
}
|
||||||
@@ -80,13 +72,23 @@ kotlin {
|
|||||||
nodejs()
|
nodejs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suppress Beta warning for expect/actual classes across all targets
|
||||||
|
targets.configureEach {
|
||||||
|
compilations.configureEach {
|
||||||
|
compilerOptions.configure {
|
||||||
|
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
all {
|
all {
|
||||||
languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
|
languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
|
||||||
languageSettings.optIn("kotlin.ExperimentalUnsignedTypes")
|
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("kotlin.contracts.ExperimentalContracts")
|
||||||
languageSettings.optIn("kotlinx.coroutines.flow.DelicateCoroutinesApi")
|
languageSettings.optIn("kotlinx.coroutines.FlowPreview")
|
||||||
}
|
}
|
||||||
|
|
||||||
val commonMain by getting {
|
val commonMain by getting {
|
||||||
@@ -148,6 +150,40 @@ tasks.withType<org.gradle.api.tasks.testing.Test> {
|
|||||||
showStandardStreams = true
|
showStandardStreams = true
|
||||||
}
|
}
|
||||||
maxParallelForks = 1
|
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 {
|
//mavenPublishing {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
package net.sergeych.lyng
|
package net.sergeych.lyng
|
||||||
|
|
||||||
|
import net.sergeych.lyng.miniast.MiniTypeRef
|
||||||
import net.sergeych.lyng.obj.Obj
|
import net.sergeych.lyng.obj.Obj
|
||||||
import net.sergeych.lyng.obj.ObjList
|
import net.sergeych.lyng.obj.ObjList
|
||||||
import net.sergeych.lyng.obj.ObjRecord
|
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)
|
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 callArgs: List<Obj>
|
||||||
val paramsSize: Int
|
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
|
paramsSize = params.size - 1
|
||||||
assign(params.last(), arguments.list.last())
|
assign(lastParam, arguments.list.last())
|
||||||
callArgs = arguments.list.dropLast(1)
|
callArgs = arguments.list.dropLast(1)
|
||||||
} else {
|
} else {
|
||||||
paramsSize = params.size
|
paramsSize = params.size
|
||||||
callArgs = arguments.list
|
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
|
var i = index
|
||||||
while (i != paramsSize) {
|
var hp = headPos
|
||||||
|
while (i < paramsSize) {
|
||||||
val a = params[i]
|
val a = params[i]
|
||||||
if (a.isEllipsis) break
|
if (a.isEllipsis) break
|
||||||
val value = when {
|
if (assignedByName[i]) {
|
||||||
i < callArgs.size -> callArgs[i]
|
assign(a, namedValues[i]!!)
|
||||||
a.defaultValue != null -> a.defaultValue.execute(scope)
|
} else {
|
||||||
else -> {
|
val value = if (hp < callArgs.size) callArgs[hp++]
|
||||||
// println("callArgs: ${callArgs.joinToString()}")
|
else a.defaultValue?.execute(scope)
|
||||||
// println("tailBlockMode: ${arguments.tailBlockMode}")
|
?: scope.raiseIllegalArgument("too few arguments for the call")
|
||||||
scope.raiseIllegalArgument("too few arguments for the call")
|
assign(a, value)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
assign(a, value)
|
|
||||||
i++
|
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 i = paramsSize - 1
|
||||||
var j = callArgs.size - 1
|
var tp = tailStart
|
||||||
while (i > index) {
|
while (i > startExclusive) {
|
||||||
val a = params[i]
|
val a = params[i]
|
||||||
if (a.isEllipsis) break
|
if (a.isEllipsis) break
|
||||||
val value = when {
|
if (i < assignedByName.size && assignedByName[i]) {
|
||||||
j >= index -> {
|
assign(a, namedValues[i]!!)
|
||||||
callArgs[j--]
|
} else {
|
||||||
}
|
val value = if (tp >= headPosBound) callArgs[tp--]
|
||||||
|
else a.defaultValue?.execute(scope)
|
||||||
a.defaultValue != null -> a.defaultValue.execute(scope)
|
?: scope.raiseIllegalArgument("too few arguments for the call")
|
||||||
else -> scope.raiseIllegalArgument("too few arguments for the call")
|
assign(a, value)
|
||||||
}
|
}
|
||||||
assign(a, value)
|
|
||||||
i--
|
i--
|
||||||
}
|
}
|
||||||
return j
|
return tp
|
||||||
}
|
}
|
||||||
|
|
||||||
fun processEllipsis(index: Int, toFromIndex: Int) {
|
fun processEllipsis(index: Int, headPos: Int, tailPos: Int) {
|
||||||
val a = params[index]
|
val a = params[index]
|
||||||
val l = if (index > toFromIndex) ObjList()
|
val from = headPos
|
||||||
else ObjList(callArgs.subList(index, toFromIndex + 1).toMutableList())
|
val to = tailPos
|
||||||
|
val l = if (from > to) ObjList()
|
||||||
|
else ObjList(callArgs.subList(from, to + 1).toMutableList())
|
||||||
assign(a, l)
|
assign(a, l)
|
||||||
}
|
}
|
||||||
|
|
||||||
val leftIndex = processHead(0)
|
// Locate ellipsis index within considered parameters
|
||||||
if (leftIndex < paramsSize) {
|
val ellipsisIndex = params.subList(0, paramsSize).indexOfFirst { it.isEllipsis }
|
||||||
val end = processTail(leftIndex)
|
|
||||||
processEllipsis(leftIndex, end)
|
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 {
|
} 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")
|
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(
|
data class Item(
|
||||||
val name: String,
|
val name: String,
|
||||||
val type: TypeDecl = TypeDecl.TypeAny,
|
val type: TypeDecl = TypeDecl.TypeAny,
|
||||||
|
val miniType: MiniTypeRef? = null,
|
||||||
val pos: Pos = Pos.builtIn,
|
val pos: Pos = Pos.builtIn,
|
||||||
val isEllipsis: Boolean = false,
|
val isEllipsis: Boolean = false,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,24 +17,27 @@
|
|||||||
|
|
||||||
package net.sergeych.lyng
|
package net.sergeych.lyng
|
||||||
|
|
||||||
import net.sergeych.lyng.obj.Obj
|
import net.sergeych.lyng.obj.*
|
||||||
import net.sergeych.lyng.obj.ObjIterable
|
|
||||||
import net.sergeych.lyng.obj.ObjList
|
|
||||||
|
|
||||||
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 {
|
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) {
|
if (PerfFlags.ARG_BUILDER) {
|
||||||
val limit = if (PerfFlags.ARG_SMALL_ARITY_12) 12 else 8
|
val limit = if (PerfFlags.ARG_SMALL_ARITY_12) 12 else 8
|
||||||
var hasSplat = false
|
var hasSplatOrNamed = false
|
||||||
var count = 0
|
var count = 0
|
||||||
for (pa in this) {
|
for (pa in this) {
|
||||||
if (pa.isSplat) { hasSplat = true; break }
|
if (pa.isSplat || pa.name != null) { hasSplatOrNamed = true; break }
|
||||||
count++
|
count++
|
||||||
if (count > limit) break
|
if (count > limit) break
|
||||||
}
|
}
|
||||||
if (!hasSplat && count == this.size) {
|
if (!hasSplatOrNamed && count == this.size) {
|
||||||
val quick = when (count) {
|
val quick = when (count) {
|
||||||
0 -> Arguments.EMPTY
|
0 -> Arguments.EMPTY
|
||||||
1 -> Arguments(listOf(this.elementAt(0).value.execute(scope)), tailBlockMode)
|
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)
|
val a7 = this.elementAt(7).value.execute(scope)
|
||||||
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7), tailBlockMode)
|
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7), tailBlockMode)
|
||||||
}
|
}
|
||||||
9 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
|
9 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
|
||||||
val a0 = this.elementAt(0).value.execute(scope)
|
val a0 = this.elementAt(0).value.execute(scope)
|
||||||
val a1 = this.elementAt(1).value.execute(scope)
|
val a1 = this.elementAt(1).value.execute(scope)
|
||||||
val a2 = this.elementAt(2).value.execute(scope)
|
val a2 = this.elementAt(2).value.execute(scope)
|
||||||
val a3 = this.elementAt(3).value.execute(scope)
|
val a3 = this.elementAt(3).value.execute(scope)
|
||||||
val a4 = this.elementAt(4).value.execute(scope)
|
val a4 = this.elementAt(4).value.execute(scope)
|
||||||
val a5 = this.elementAt(5).value.execute(scope)
|
val a5 = this.elementAt(5).value.execute(scope)
|
||||||
val a6 = this.elementAt(6).value.execute(scope)
|
val a6 = this.elementAt(6).value.execute(scope)
|
||||||
val a7 = this.elementAt(7).value.execute(scope)
|
val a7 = this.elementAt(7).value.execute(scope)
|
||||||
val a8 = this.elementAt(8).value.execute(scope)
|
val a8 = this.elementAt(8).value.execute(scope)
|
||||||
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8), tailBlockMode)
|
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8), tailBlockMode)
|
||||||
} else null
|
} else null
|
||||||
10 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
|
10 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
|
||||||
val a0 = this.elementAt(0).value.execute(scope)
|
val a0 = this.elementAt(0).value.execute(scope)
|
||||||
val a1 = this.elementAt(1).value.execute(scope)
|
val a1 = this.elementAt(1).value.execute(scope)
|
||||||
val a2 = this.elementAt(2).value.execute(scope)
|
val a2 = this.elementAt(2).value.execute(scope)
|
||||||
val a3 = this.elementAt(3).value.execute(scope)
|
val a3 = this.elementAt(3).value.execute(scope)
|
||||||
val a4 = this.elementAt(4).value.execute(scope)
|
val a4 = this.elementAt(4).value.execute(scope)
|
||||||
val a5 = this.elementAt(5).value.execute(scope)
|
val a5 = this.elementAt(5).value.execute(scope)
|
||||||
val a6 = this.elementAt(6).value.execute(scope)
|
val a6 = this.elementAt(6).value.execute(scope)
|
||||||
val a7 = this.elementAt(7).value.execute(scope)
|
val a7 = this.elementAt(7).value.execute(scope)
|
||||||
val a8 = this.elementAt(8).value.execute(scope)
|
val a8 = this.elementAt(8).value.execute(scope)
|
||||||
val a9 = this.elementAt(9).value.execute(scope)
|
val a9 = this.elementAt(9).value.execute(scope)
|
||||||
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9), tailBlockMode)
|
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9), tailBlockMode)
|
||||||
} else null
|
} else null
|
||||||
11 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
|
11 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
|
||||||
val a0 = this.elementAt(0).value.execute(scope)
|
val a0 = this.elementAt(0).value.execute(scope)
|
||||||
val a1 = this.elementAt(1).value.execute(scope)
|
val a1 = this.elementAt(1).value.execute(scope)
|
||||||
val a2 = this.elementAt(2).value.execute(scope)
|
val a2 = this.elementAt(2).value.execute(scope)
|
||||||
val a3 = this.elementAt(3).value.execute(scope)
|
val a3 = this.elementAt(3).value.execute(scope)
|
||||||
val a4 = this.elementAt(4).value.execute(scope)
|
val a4 = this.elementAt(4).value.execute(scope)
|
||||||
val a5 = this.elementAt(5).value.execute(scope)
|
val a5 = this.elementAt(5).value.execute(scope)
|
||||||
val a6 = this.elementAt(6).value.execute(scope)
|
val a6 = this.elementAt(6).value.execute(scope)
|
||||||
val a7 = this.elementAt(7).value.execute(scope)
|
val a7 = this.elementAt(7).value.execute(scope)
|
||||||
val a8 = this.elementAt(8).value.execute(scope)
|
val a8 = this.elementAt(8).value.execute(scope)
|
||||||
val a9 = this.elementAt(9).value.execute(scope)
|
val a9 = this.elementAt(9).value.execute(scope)
|
||||||
val a10 = this.elementAt(10).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)
|
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10), tailBlockMode)
|
||||||
} else null
|
} else null
|
||||||
12 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
|
12 -> if (PerfFlags.ARG_SMALL_ARITY_12) {
|
||||||
val a0 = this.elementAt(0).value.execute(scope)
|
val a0 = this.elementAt(0).value.execute(scope)
|
||||||
val a1 = this.elementAt(1).value.execute(scope)
|
val a1 = this.elementAt(1).value.execute(scope)
|
||||||
val a2 = this.elementAt(2).value.execute(scope)
|
val a2 = this.elementAt(2).value.execute(scope)
|
||||||
val a3 = this.elementAt(3).value.execute(scope)
|
val a3 = this.elementAt(3).value.execute(scope)
|
||||||
val a4 = this.elementAt(4).value.execute(scope)
|
val a4 = this.elementAt(4).value.execute(scope)
|
||||||
val a5 = this.elementAt(5).value.execute(scope)
|
val a5 = this.elementAt(5).value.execute(scope)
|
||||||
val a6 = this.elementAt(6).value.execute(scope)
|
val a6 = this.elementAt(6).value.execute(scope)
|
||||||
val a7 = this.elementAt(7).value.execute(scope)
|
val a7 = this.elementAt(7).value.execute(scope)
|
||||||
val a8 = this.elementAt(8).value.execute(scope)
|
val a8 = this.elementAt(8).value.execute(scope)
|
||||||
val a9 = this.elementAt(9).value.execute(scope)
|
val a9 = this.elementAt(9).value.execute(scope)
|
||||||
val a10 = this.elementAt(10).value.execute(scope)
|
val a10 = this.elementAt(10).value.execute(scope)
|
||||||
val a11 = this.elementAt(11).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)
|
Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11), tailBlockMode)
|
||||||
} else null
|
} else null
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (quick != null) return quick
|
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
|
// General path: build positional list and named map, enforcing ordering rules
|
||||||
if (PerfFlags.ARG_BUILDER) {
|
val positional: MutableList<Obj> = mutableListOf()
|
||||||
val b = ArgBuilderProvider.acquire()
|
var named: MutableMap<String, Obj>? = null
|
||||||
try {
|
var namedSeen = false
|
||||||
b.reset(this.size)
|
for ((idx, x) in this.withIndex()) {
|
||||||
for (x in this) {
|
if (x.name != null) {
|
||||||
val value = x.value.execute(scope)
|
// Named argument
|
||||||
if (x.isSplat) {
|
if (named == null) named = linkedMapOf()
|
||||||
when {
|
if (named.containsKey(x.name)) scope.raiseIllegalArgument("argument '${x.name}' is already set")
|
||||||
value is ObjList -> {
|
val v = x.value.execute(scope)
|
||||||
b.addAll(value.list)
|
named[x.name] = v
|
||||||
}
|
namedSeen = true
|
||||||
value.isInstanceOf(ObjIterable) -> {
|
continue
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
} else {
|
val value = x.value.execute(scope)
|
||||||
val list: MutableList<Obj> = mutableListOf()
|
if (x.isSplat) {
|
||||||
for (x in this) {
|
when {
|
||||||
val value = x.value.execute(scope)
|
// IMPORTANT: handle ObjMap BEFORE generic Iterable to ensure map splats
|
||||||
if (x.isSplat) {
|
// are treated as named splats, not as positional iteration over entries
|
||||||
when {
|
value is ObjMap -> {
|
||||||
value is ObjList -> list.addAll(value.list)
|
if (named == null) named = linkedMapOf()
|
||||||
value.isInstanceOf(ObjIterable) -> {
|
for ((k, v) in value.map) {
|
||||||
val i = (value.invokeInstanceMethod(scope, "toList") as ObjList).list
|
if (k !is ObjString) scope.raiseIllegalArgument("named splat expects a Map with string keys")
|
||||||
list.addAll(i)
|
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 {
|
value is ObjList -> {
|
||||||
list.add(value)
|
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())
|
constructor(vararg values: Obj) : this(values.toList())
|
||||||
|
|
||||||
fun firstAndOnly(pos: Pos = Pos.UNKNOWN): Obj {
|
fun firstAndOnly(pos: Pos = Pos.UNKNOWN): Obj {
|
||||||
if (list.size != 1) throw ScriptError(pos, "expected one argument, got ${list.size}")
|
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
|
// Tiny micro-alloc win: avoid byValueCopy for immutable singletons
|
||||||
return when (v) {
|
return when (val v = list.first()) {
|
||||||
net.sergeych.lyng.obj.ObjNull,
|
ObjNull,
|
||||||
net.sergeych.lyng.obj.ObjTrue,
|
ObjTrue,
|
||||||
net.sergeych.lyng.obj.ObjFalse,
|
ObjFalse,
|
||||||
// Immutable scalars: safe to return directly
|
// Immutable scalars: safe to return directly
|
||||||
is net.sergeych.lyng.obj.ObjInt,
|
is ObjInt,
|
||||||
is net.sergeych.lyng.obj.ObjReal,
|
is ObjReal,
|
||||||
is net.sergeych.lyng.obj.ObjChar,
|
is ObjChar,
|
||||||
is net.sergeych.lyng.obj.ObjString -> v
|
is ObjString -> v
|
||||||
else -> v.byValueCopy()
|
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)
|
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`.
|
* If the next token is identifier `name`, skip it and return `true`.
|
||||||
|
|||||||
@@ -84,12 +84,12 @@ private class Parser(fromPos: Pos) {
|
|||||||
when (currentChar) {
|
when (currentChar) {
|
||||||
'+' -> {
|
'+' -> {
|
||||||
pos.advance()
|
pos.advance()
|
||||||
Token("+", from, Token.Type.PLUS2)
|
Token("++", from, Token.Type.PLUS2)
|
||||||
}
|
}
|
||||||
|
|
||||||
'=' -> {
|
'=' -> {
|
||||||
pos.advance()
|
pos.advance()
|
||||||
Token("+", from, Token.Type.PLUSASSIGN)
|
Token("+=", from, Token.Type.PLUSASSIGN)
|
||||||
}
|
}
|
||||||
|
|
||||||
else ->
|
else ->
|
||||||
@@ -106,7 +106,7 @@ private class Parser(fromPos: Pos) {
|
|||||||
|
|
||||||
'=' -> {
|
'=' -> {
|
||||||
pos.advance()
|
pos.advance()
|
||||||
Token("-", from, Token.Type.MINUSASSIGN)
|
Token("-=", from, Token.Type.MINUSASSIGN)
|
||||||
}
|
}
|
||||||
|
|
||||||
'>' -> {
|
'>' -> {
|
||||||
@@ -129,17 +129,17 @@ private class Parser(fromPos: Pos) {
|
|||||||
'/' -> when (currentChar) {
|
'/' -> when (currentChar) {
|
||||||
'/' -> {
|
'/' -> {
|
||||||
pos.advance()
|
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()
|
pos.advance()
|
||||||
Token(
|
val content = loadTo("*/")
|
||||||
loadTo("*/")?.trim()
|
?: throw ScriptError(from, "Unterminated multiline comment")
|
||||||
?: throw ScriptError(from, "Unterminated multiline comment"),
|
// loadTo consumes the closing fragment, so we are already after */
|
||||||
from,
|
Token("/*" + content + "*/", from, Token.Type.MULTILINE_COMMENT)
|
||||||
Token.Type.MULTILINE_COMMENT
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
'=' -> {
|
'=' -> {
|
||||||
@@ -403,7 +403,8 @@ private class Parser(fromPos: Pos) {
|
|||||||
// could be integer, also hex:
|
// could be integer, also hex:
|
||||||
if (currentChar == 'x' && p1 == "0") {
|
if (currentChar == 'x' && p1 == "0") {
|
||||||
pos.advance()
|
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())
|
if (currentChar.isLetter())
|
||||||
raise("invalid hex literal")
|
raise("invalid hex literal")
|
||||||
}
|
}
|
||||||
@@ -541,11 +542,11 @@ private class Parser(fromPos: Pos) {
|
|||||||
|
|
||||||
private fun loadToEndOfLine(): String {
|
private fun loadToEndOfLine(): String {
|
||||||
val result = StringBuilder()
|
val result = StringBuilder()
|
||||||
val l = pos.line
|
// Read characters up to but not including the line break
|
||||||
do {
|
while (!pos.end && pos.currentChar != '\n') {
|
||||||
result.append(pos.currentChar)
|
result.append(pos.currentChar)
|
||||||
pos.advance()
|
pos.advance()
|
||||||
} while (pos.line == l)
|
}
|
||||||
return result.toString()
|
return result.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,4 @@ class MutablePos(private val from: Pos) {
|
|||||||
|
|
||||||
override fun toString() = "($line:$column)"
|
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
|
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) }
|
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)
|
if (!channel.isClosedForSend)
|
||||||
channel.send(data)
|
channel.send(data)
|
||||||
else
|
else
|
||||||
|
// Flow consumer is no longer collecting; signal producer to stop
|
||||||
throw ScriptFlowIsNoMoreCollected()
|
throw ScriptFlowIsNoMoreCollected()
|
||||||
} catch (x: Exception) {
|
} catch (x: Exception) {
|
||||||
if (x !is CancellationException)
|
// Any failure to send (including closed channel) should gracefully stop the producer.
|
||||||
x.printStackTrace()
|
// Do not print stack traces here to keep test output clean on JVM.
|
||||||
throw ScriptFlowIsNoMoreCollected()
|
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
|
ObjVoid
|
||||||
}
|
}
|
||||||
@@ -63,10 +70,10 @@ private fun createLyngFlowInput(scope: Scope, producer: Statement): ReceiveChann
|
|||||||
try {
|
try {
|
||||||
producer.execute(builderScope)
|
producer.execute(builderScope)
|
||||||
} catch (x: ScriptFlowIsNoMoreCollected) {
|
} catch (x: ScriptFlowIsNoMoreCollected) {
|
||||||
x.printStackTrace()
|
|
||||||
// premature flow closing, OK
|
// premature flow closing, OK
|
||||||
} catch (x: Exception) {
|
} 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()
|
channel.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,13 +87,25 @@ suspend fun Obj.enumerate(scope: Scope, callback: suspend (Obj) -> Boolean) {
|
|||||||
val hasNext = iterator.getInstanceMethod(scope, "hasNext")
|
val hasNext = iterator.getInstanceMethod(scope, "hasNext")
|
||||||
val next = iterator.getInstanceMethod(scope, "next")
|
val next = iterator.getInstanceMethod(scope, "next")
|
||||||
var closeIt = false
|
var closeIt = false
|
||||||
while (hasNext.invoke(scope, iterator).toBool()) {
|
try {
|
||||||
val nextValue = next.invoke(scope, iterator)
|
while (hasNext.invoke(scope, iterator).toBool()) {
|
||||||
if (!callback(nextValue)) {
|
val nextValue = next.invoke(scope, iterator)
|
||||||
closeIt = true
|
val shouldContinue = try {
|
||||||
break
|
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() }
|
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() {
|
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
|
if( other is ObjMap && other.map == map) return 0
|
||||||
return -1
|
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 {
|
override fun hashCode(): Int {
|
||||||
return map.hashCode()
|
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
|
package net.sergeych.lyng.obj
|
||||||
|
|
||||||
import net.sergeych.lyng.*
|
import net.sergeych.lyng.*
|
||||||
@@ -62,9 +64,9 @@ enum class BinOp {
|
|||||||
/** R-value reference for unary operations. */
|
/** R-value reference for unary operations. */
|
||||||
class UnaryOpRef(private val op: UnaryOp, private val a: ObjRef) : ObjRef {
|
class UnaryOpRef(private val op: UnaryOp, private val a: ObjRef) : ObjRef {
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
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
|
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) {
|
val rFast: Obj? = when (op) {
|
||||||
UnaryOp.NOT -> if (v is ObjBool) if (!v.value) ObjTrue else ObjFalse else null
|
UnaryOp.NOT -> if (v is ObjBool) if (!v.value) ObjTrue else ObjFalse else null
|
||||||
UnaryOp.NEGATE -> when (v) {
|
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
|
UnaryOp.BITNOT -> if (v is ObjInt) ObjInt(v.value.inv()) else null
|
||||||
}
|
}
|
||||||
if (rFast != 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
|
return rFast.asReadonly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,11 +93,11 @@ class UnaryOpRef(private val op: UnaryOp, private val a: ObjRef) : ObjRef {
|
|||||||
/** R-value reference for binary operations. */
|
/** R-value reference for binary operations. */
|
||||||
class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val right: ObjRef) : ObjRef {
|
class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val right: ObjRef) : ObjRef {
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
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 a = if (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 b = if (PerfFlags.RVAL_FASTPATH) right.evalValue(scope) else right.get(scope).value
|
||||||
|
|
||||||
// Primitive fast paths for common cases (guarded by PerfFlags.PRIMITIVE_FASTOPS)
|
// 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
|
// Fast boolean ops when both operands are ObjBool
|
||||||
if (a is ObjBool && b is ObjBool) {
|
if (a is ObjBool && b is ObjBool) {
|
||||||
val r: Obj? = when (op) {
|
val r: Obj? = when (op) {
|
||||||
@@ -106,7 +108,7 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (r != 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
|
return r.asReadonly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +136,7 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (r != 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
|
return r.asReadonly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +153,7 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (r != 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
|
return r.asReadonly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +171,7 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (r != 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
|
return r.asReadonly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,19 +179,19 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
|
|||||||
if (op == BinOp.PLUS) {
|
if (op == BinOp.PLUS) {
|
||||||
when {
|
when {
|
||||||
a is ObjString && b is ObjInt -> {
|
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
|
return ObjString(a.value + b.value.toString()).asReadonly
|
||||||
}
|
}
|
||||||
a is ObjString && b is ObjChar -> {
|
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
|
return ObjString(a.value + b.value).asReadonly
|
||||||
}
|
}
|
||||||
b is ObjString && a is ObjInt -> {
|
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
|
return ObjString(a.value.toString() + b.value).asReadonly
|
||||||
}
|
}
|
||||||
b is ObjString && a is ObjChar -> {
|
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
|
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
|
else -> null
|
||||||
}
|
}
|
||||||
if (rNum != 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
|
return rNum.asReadonly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,7 +262,7 @@ class ConditionalRef(
|
|||||||
private val ifFalse: ObjRef
|
private val ifFalse: ObjRef
|
||||||
) : ObjRef {
|
) : ObjRef {
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
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) {
|
val condTrue = when (condVal) {
|
||||||
is ObjBool -> condVal.value
|
is ObjBool -> condVal.value
|
||||||
is ObjInt -> condVal.value != 0L
|
is ObjInt -> condVal.value != 0L
|
||||||
@@ -279,8 +281,8 @@ class CastRef(
|
|||||||
private val atPos: Pos,
|
private val atPos: Pos,
|
||||||
) : ObjRef {
|
) : ObjRef {
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
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 v0 = if (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 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")
|
val target = (t as? ObjClass) ?: scope.raiseClassCastError("${'$'}t is not the class instance")
|
||||||
// unwrap qualified views
|
// unwrap qualified views
|
||||||
val v = when (v0) {
|
val v = when (v0) {
|
||||||
@@ -323,7 +325,7 @@ class AssignOpRef(
|
|||||||
) : ObjRef {
|
) : ObjRef {
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
override suspend fun get(scope: Scope): ObjRecord {
|
||||||
val x = target.get(scope).value
|
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) {
|
val inPlace: Obj? = when (op) {
|
||||||
BinOp.PLUS -> x.plusAssign(scope, y)
|
BinOp.PLUS -> x.plusAssign(scope, y)
|
||||||
BinOp.MINUS -> x.minusAssign(scope, y)
|
BinOp.MINUS -> x.minusAssign(scope, y)
|
||||||
@@ -380,7 +382,7 @@ class IncDecRef(
|
|||||||
/** Elvis operator reference: a ?: b */
|
/** Elvis operator reference: a ?: b */
|
||||||
class ElvisRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
|
class ElvisRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
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 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
|
val r = if (a != ObjNull) a else if (fastRval) right.evalValue(scope) else right.get(scope).value
|
||||||
return r.asReadonly
|
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 */
|
/** Logical OR with short-circuit: a || b */
|
||||||
class LogicalOrRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
|
class LogicalOrRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
override suspend fun get(scope: Scope): ObjRecord {
|
||||||
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
|
val fastRval = PerfFlags.RVAL_FASTPATH
|
||||||
val fastPrim = net.sergeych.lyng.PerfFlags.PRIMITIVE_FASTOPS
|
val fastPrim = PerfFlags.PRIMITIVE_FASTOPS
|
||||||
val a = if (fastRval) left.evalValue(scope) else left.get(scope).value
|
val a = if (fastRval) left.evalValue(scope) else left.get(scope).value
|
||||||
if ((a as? ObjBool)?.value == true) return ObjTrue.asReadonly
|
if ((a as? ObjBool)?.value == true) return ObjTrue.asReadonly
|
||||||
val b = if (fastRval) right.evalValue(scope) else right.get(scope).value
|
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 {
|
class LogicalAndRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
override suspend fun get(scope: Scope): ObjRecord {
|
||||||
// Hoist flags to locals for JIT friendliness
|
// Hoist flags to locals for JIT friendliness
|
||||||
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
|
val fastRval = PerfFlags.RVAL_FASTPATH
|
||||||
val fastPrim = net.sergeych.lyng.PerfFlags.PRIMITIVE_FASTOPS
|
val fastPrim = PerfFlags.PRIMITIVE_FASTOPS
|
||||||
val a = if (fastRval) left.evalValue(scope) else left.get(scope).value
|
val a = if (fastRval) left.evalValue(scope) else left.get(scope).value
|
||||||
if ((a as? ObjBool)?.value == false) return ObjFalse.asReadonly
|
if ((a as? ObjBool)?.value == false) return ObjFalse.asReadonly
|
||||||
val b = if (fastRval) right.evalValue(scope) else right.get(scope).value
|
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 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 var wAccesses: Int = 0; private var wMisses: Int = 0; private var wPromotedTo4: Boolean = false
|
||||||
private inline fun size4ReadsEnabled(): Boolean =
|
private inline fun size4ReadsEnabled(): Boolean =
|
||||||
net.sergeych.lyng.PerfFlags.FIELD_PIC_SIZE_4 ||
|
PerfFlags.FIELD_PIC_SIZE_4 ||
|
||||||
(net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_2_TO_4 && rPromotedTo4)
|
(PerfFlags.PIC_ADAPTIVE_2_TO_4 && rPromotedTo4)
|
||||||
private inline fun size4WritesEnabled(): Boolean =
|
private inline fun size4WritesEnabled(): Boolean =
|
||||||
net.sergeych.lyng.PerfFlags.FIELD_PIC_SIZE_4 ||
|
PerfFlags.FIELD_PIC_SIZE_4 ||
|
||||||
(net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_2_TO_4 && wPromotedTo4)
|
(PerfFlags.PIC_ADAPTIVE_2_TO_4 && wPromotedTo4)
|
||||||
private fun noteReadHit() {
|
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)
|
val a = (rAccesses + 1).coerceAtMost(1_000_000)
|
||||||
rAccesses = a
|
rAccesses = a
|
||||||
}
|
}
|
||||||
private fun noteReadMiss() {
|
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)
|
val a = (rAccesses + 1).coerceAtMost(1_000_000)
|
||||||
rAccesses = a
|
rAccesses = a
|
||||||
rMisses = (rMisses + 1).coerceAtMost(1_000_000)
|
rMisses = (rMisses + 1).coerceAtMost(1_000_000)
|
||||||
@@ -482,12 +484,12 @@ class FieldRef(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun noteWriteHit() {
|
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)
|
val a = (wAccesses + 1).coerceAtMost(1_000_000)
|
||||||
wAccesses = a
|
wAccesses = a
|
||||||
}
|
}
|
||||||
private fun noteWriteMiss() {
|
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)
|
val a = (wAccesses + 1).coerceAtMost(1_000_000)
|
||||||
wAccesses = a
|
wAccesses = a
|
||||||
wMisses = (wMisses + 1).coerceAtMost(1_000_000)
|
wMisses = (wMisses + 1).coerceAtMost(1_000_000)
|
||||||
@@ -498,15 +500,15 @@ class FieldRef(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
override suspend fun get(scope: Scope): ObjRecord {
|
||||||
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
|
val fastRval = PerfFlags.RVAL_FASTPATH
|
||||||
val fieldPic = net.sergeych.lyng.PerfFlags.FIELD_PIC
|
val fieldPic = PerfFlags.FIELD_PIC
|
||||||
val picCounters = net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS
|
val picCounters = PerfFlags.PIC_DEBUG_COUNTERS
|
||||||
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
|
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
|
||||||
if (base == ObjNull && isOptional) return ObjNull.asMutable
|
if (base == ObjNull && isOptional) return ObjNull.asMutable
|
||||||
if (fieldPic) {
|
if (fieldPic) {
|
||||||
val (key, ver) = receiverKeyAndVersion(base)
|
val (key, ver) = receiverKeyAndVersion(base)
|
||||||
rGetter1?.let { g -> if (key == rKey1 && ver == rVer1) {
|
rGetter1?.let { g -> if (key == rKey1 && ver == rVer1) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
|
if (picCounters) PerfStats.fieldPicHit++
|
||||||
noteReadHit()
|
noteReadHit()
|
||||||
val rec0 = g(base, scope)
|
val rec0 = g(base, scope)
|
||||||
if (base is ObjClass) {
|
if (base is ObjClass) {
|
||||||
@@ -516,7 +518,7 @@ class FieldRef(
|
|||||||
return rec0
|
return rec0
|
||||||
} }
|
} }
|
||||||
rGetter2?.let { g -> if (key == rKey2 && ver == rVer2) {
|
rGetter2?.let { g -> if (key == rKey2 && ver == rVer2) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
|
if (picCounters) PerfStats.fieldPicHit++
|
||||||
noteReadHit()
|
noteReadHit()
|
||||||
// move-to-front: promote 2→1
|
// move-to-front: promote 2→1
|
||||||
val tK = rKey2; val tV = rVer2; val tG = rGetter2
|
val tK = rKey2; val tV = rVer2; val tG = rGetter2
|
||||||
@@ -530,7 +532,7 @@ class FieldRef(
|
|||||||
return rec0
|
return rec0
|
||||||
} }
|
} }
|
||||||
if (size4ReadsEnabled()) rGetter3?.let { g -> if (key == rKey3 && ver == rVer3) {
|
if (size4ReadsEnabled()) rGetter3?.let { g -> if (key == rKey3 && ver == rVer3) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
|
if (picCounters) PerfStats.fieldPicHit++
|
||||||
noteReadHit()
|
noteReadHit()
|
||||||
// move-to-front: promote 3→1
|
// move-to-front: promote 3→1
|
||||||
val tK = rKey3; val tV = rVer3; val tG = rGetter3
|
val tK = rKey3; val tV = rVer3; val tG = rGetter3
|
||||||
@@ -545,7 +547,7 @@ class FieldRef(
|
|||||||
return rec0
|
return rec0
|
||||||
} }
|
} }
|
||||||
if (size4ReadsEnabled()) rGetter4?.let { g -> if (key == rKey4 && ver == rVer4) {
|
if (size4ReadsEnabled()) rGetter4?.let { g -> if (key == rKey4 && ver == rVer4) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
|
if (picCounters) PerfStats.fieldPicHit++
|
||||||
noteReadHit()
|
noteReadHit()
|
||||||
// move-to-front: promote 4→1
|
// move-to-front: promote 4→1
|
||||||
val tK = rKey4; val tV = rVer4; val tG = rGetter4
|
val tK = rKey4; val tV = rVer4; val tG = rGetter4
|
||||||
@@ -561,7 +563,7 @@ class FieldRef(
|
|||||||
return rec0
|
return rec0
|
||||||
} }
|
} }
|
||||||
// Slow path
|
// Slow path
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicMiss++
|
if (picCounters) PerfStats.fieldPicMiss++
|
||||||
noteReadMiss()
|
noteReadMiss()
|
||||||
val rec = try {
|
val rec = try {
|
||||||
base.readField(scope, name)
|
base.readField(scope, name)
|
||||||
@@ -606,8 +608,8 @@ class FieldRef(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
|
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
|
||||||
val fieldPic = net.sergeych.lyng.PerfFlags.FIELD_PIC
|
val fieldPic = PerfFlags.FIELD_PIC
|
||||||
val picCounters = net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS
|
val picCounters = PerfFlags.PIC_DEBUG_COUNTERS
|
||||||
val base = target.get(scope).value
|
val base = target.get(scope).value
|
||||||
if (base == ObjNull && isOptional) {
|
if (base == ObjNull && isOptional) {
|
||||||
// no-op on null receiver for optional chaining assignment
|
// no-op on null receiver for optional chaining assignment
|
||||||
@@ -629,12 +631,12 @@ class FieldRef(
|
|||||||
if (fieldPic) {
|
if (fieldPic) {
|
||||||
val (key, ver) = receiverKeyAndVersion(base)
|
val (key, ver) = receiverKeyAndVersion(base)
|
||||||
wSetter1?.let { s -> if (key == wKey1 && ver == wVer1) {
|
wSetter1?.let { s -> if (key == wKey1 && ver == wVer1) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicSetHit++
|
if (picCounters) PerfStats.fieldPicSetHit++
|
||||||
noteWriteHit()
|
noteWriteHit()
|
||||||
return s(base, scope, newValue)
|
return s(base, scope, newValue)
|
||||||
} }
|
} }
|
||||||
wSetter2?.let { s -> if (key == wKey2 && ver == wVer2) {
|
wSetter2?.let { s -> if (key == wKey2 && ver == wVer2) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicSetHit++
|
if (picCounters) PerfStats.fieldPicSetHit++
|
||||||
noteWriteHit()
|
noteWriteHit()
|
||||||
// move-to-front: promote 2→1
|
// move-to-front: promote 2→1
|
||||||
val tK = wKey2; val tV = wVer2; val tS = wSetter2
|
val tK = wKey2; val tV = wVer2; val tS = wSetter2
|
||||||
@@ -643,7 +645,7 @@ class FieldRef(
|
|||||||
return s(base, scope, newValue)
|
return s(base, scope, newValue)
|
||||||
} }
|
} }
|
||||||
if (size4WritesEnabled()) wSetter3?.let { s -> if (key == wKey3 && ver == wVer3) {
|
if (size4WritesEnabled()) wSetter3?.let { s -> if (key == wKey3 && ver == wVer3) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicSetHit++
|
if (picCounters) PerfStats.fieldPicSetHit++
|
||||||
noteWriteHit()
|
noteWriteHit()
|
||||||
// move-to-front: promote 3→1
|
// move-to-front: promote 3→1
|
||||||
val tK = wKey3; val tV = wVer3; val tS = wSetter3
|
val tK = wKey3; val tV = wVer3; val tS = wSetter3
|
||||||
@@ -653,7 +655,7 @@ class FieldRef(
|
|||||||
return s(base, scope, newValue)
|
return s(base, scope, newValue)
|
||||||
} }
|
} }
|
||||||
if (size4WritesEnabled()) wSetter4?.let { s -> if (key == wKey4 && ver == wVer4) {
|
if (size4WritesEnabled()) wSetter4?.let { s -> if (key == wKey4 && ver == wVer4) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicSetHit++
|
if (picCounters) PerfStats.fieldPicSetHit++
|
||||||
noteWriteHit()
|
noteWriteHit()
|
||||||
// move-to-front: promote 4→1
|
// move-to-front: promote 4→1
|
||||||
val tK = wKey4; val tV = wVer4; val tS = wSetter4
|
val tK = wKey4; val tV = wVer4; val tS = wSetter4
|
||||||
@@ -664,7 +666,7 @@ class FieldRef(
|
|||||||
return s(base, scope, newValue)
|
return s(base, scope, newValue)
|
||||||
} }
|
} }
|
||||||
// Slow path
|
// Slow path
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicSetMiss++
|
if (picCounters) PerfStats.fieldPicSetMiss++
|
||||||
noteWriteMiss()
|
noteWriteMiss()
|
||||||
base.writeField(scope, name, newValue)
|
base.writeField(scope, name, newValue)
|
||||||
// Install move-to-front with a handle-aware setter; honor PIC size flag
|
// 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 {
|
override suspend fun evalValue(scope: Scope): Obj {
|
||||||
// Mirror get(), but return raw Obj to avoid transient ObjRecord on R-value paths
|
// Mirror get(), but return raw Obj to avoid transient ObjRecord on R-value paths
|
||||||
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
|
val fastRval = PerfFlags.RVAL_FASTPATH
|
||||||
val fieldPic = net.sergeych.lyng.PerfFlags.FIELD_PIC
|
val fieldPic = PerfFlags.FIELD_PIC
|
||||||
val picCounters = net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS
|
val picCounters = PerfFlags.PIC_DEBUG_COUNTERS
|
||||||
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
|
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
|
||||||
if (base == ObjNull && isOptional) return ObjNull
|
if (base == ObjNull && isOptional) return ObjNull
|
||||||
if (fieldPic) {
|
if (fieldPic) {
|
||||||
val (key, ver) = receiverKeyAndVersion(base)
|
val (key, ver) = receiverKeyAndVersion(base)
|
||||||
rGetter1?.let { g -> if (key == rKey1 && ver == rVer1) {
|
rGetter1?.let { g -> if (key == rKey1 && ver == rVer1) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicHit++
|
if (picCounters) PerfStats.fieldPicHit++
|
||||||
return g(base, scope).value
|
return g(base, scope).value
|
||||||
} }
|
} }
|
||||||
rGetter2?.let { g -> if (key == rKey2 && ver == rVer2) {
|
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
|
val tK = rKey2; val tV = rVer2; val tG = rGetter2
|
||||||
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
|
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
|
||||||
rKey1 = tK; rVer1 = tV; rGetter1 = tG
|
rKey1 = tK; rVer1 = tV; rGetter1 = tG
|
||||||
return g(base, scope).value
|
return g(base, scope).value
|
||||||
} }
|
} }
|
||||||
if (size4ReadsEnabled()) rGetter3?.let { g -> if (key == rKey3 && ver == rVer3) {
|
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
|
val tK = rKey3; val tV = rVer3; val tG = rGetter3
|
||||||
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
||||||
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
|
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
|
||||||
@@ -734,7 +736,7 @@ class FieldRef(
|
|||||||
return g(base, scope).value
|
return g(base, scope).value
|
||||||
} }
|
} }
|
||||||
if (size4ReadsEnabled()) rGetter4?.let { g -> if (key == rKey4 && ver == rVer4) {
|
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
|
val tK = rKey4; val tV = rVer4; val tG = rGetter4
|
||||||
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
|
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
|
||||||
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
||||||
@@ -742,7 +744,7 @@ class FieldRef(
|
|||||||
rKey1 = tK; rVer1 = tV; rGetter1 = tG
|
rKey1 = tK; rVer1 = tV; rGetter1 = tG
|
||||||
return g(base, scope).value
|
return g(base, scope).value
|
||||||
} }
|
} }
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.fieldPicMiss++
|
if (picCounters) PerfStats.fieldPicMiss++
|
||||||
val rec = base.readField(scope, name)
|
val rec = base.readField(scope, name)
|
||||||
// install primary generic getter for this shape
|
// install primary generic getter for this shape
|
||||||
when (base) {
|
when (base) {
|
||||||
@@ -789,7 +791,7 @@ class IndexRef(
|
|||||||
else -> 0L to -1
|
else -> 0L to -1
|
||||||
}
|
}
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
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
|
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
|
||||||
if (base == ObjNull && isOptional) return ObjNull.asMutable
|
if (base == ObjNull && isOptional) return ObjNull.asMutable
|
||||||
val idx = if (fastRval) index.evalValue(scope) else index.get(scope).value
|
val idx = if (fastRval) index.evalValue(scope) else index.get(scope).value
|
||||||
@@ -810,7 +812,7 @@ class IndexRef(
|
|||||||
val v = base.map[idx] ?: ObjNull
|
val v = base.map[idx] ?: ObjNull
|
||||||
return v.asMutable
|
return v.asMutable
|
||||||
}
|
}
|
||||||
if (net.sergeych.lyng.PerfFlags.INDEX_PIC) {
|
if (PerfFlags.INDEX_PIC) {
|
||||||
// Polymorphic inline cache for other common shapes
|
// Polymorphic inline cache for other common shapes
|
||||||
val (key, ver) = when (base) {
|
val (key, ver) = when (base) {
|
||||||
is ObjInstance -> base.objClass.classId to base.objClass.layoutVersion
|
is ObjInstance -> base.objClass.classId to base.objClass.layoutVersion
|
||||||
@@ -819,26 +821,26 @@ class IndexRef(
|
|||||||
}
|
}
|
||||||
if (key != 0L) {
|
if (key != 0L) {
|
||||||
rGetter1?.let { g -> if (key == rKey1 && ver == rVer1) {
|
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
|
return g(base, scope, idx).asMutable
|
||||||
} }
|
} }
|
||||||
rGetter2?.let { g -> if (key == rKey2 && ver == rVer2) {
|
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
|
val tk = rKey2; val tv = rVer2; val tg = rGetter2
|
||||||
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
|
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
|
||||||
rKey1 = tk; rVer1 = tv; rGetter1 = tg
|
rKey1 = tk; rVer1 = tv; rGetter1 = tg
|
||||||
return g(base, scope, idx).asMutable
|
return g(base, scope, idx).asMutable
|
||||||
} }
|
} }
|
||||||
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) rGetter3?.let { g -> if (key == rKey3 && ver == rVer3) {
|
if (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.PIC_DEBUG_COUNTERS) PerfStats.indexPicHit++
|
||||||
val tk = rKey3; val tv = rVer3; val tg = rGetter3
|
val tk = rKey3; val tv = rVer3; val tg = rGetter3
|
||||||
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
||||||
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
|
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
|
||||||
rKey1 = tk; rVer1 = tv; rGetter1 = tg
|
rKey1 = tk; rVer1 = tv; rGetter1 = tg
|
||||||
return g(base, scope, idx).asMutable
|
return g(base, scope, idx).asMutable
|
||||||
} }
|
} }
|
||||||
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) rGetter4?.let { g -> if (key == rKey4 && ver == rVer4) {
|
if (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.PIC_DEBUG_COUNTERS) PerfStats.indexPicHit++
|
||||||
val tk = rKey4; val tv = rVer4; val tg = rGetter4
|
val tk = rKey4; val tv = rVer4; val tg = rGetter4
|
||||||
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
|
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
|
||||||
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
||||||
@@ -847,9 +849,9 @@ class IndexRef(
|
|||||||
return g(base, scope, idx).asMutable
|
return g(base, scope, idx).asMutable
|
||||||
} }
|
} }
|
||||||
// Miss: resolve and install generic handler
|
// 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)
|
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
|
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
|
||||||
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
||||||
}
|
}
|
||||||
@@ -863,7 +865,7 @@ class IndexRef(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun evalValue(scope: Scope): Obj {
|
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
|
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
|
||||||
if (base == ObjNull && isOptional) return ObjNull
|
if (base == ObjNull && isOptional) return ObjNull
|
||||||
val idx = if (fastRval) index.evalValue(scope) else index.get(scope).value
|
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) {
|
if (base is ObjMap && idx is ObjString) {
|
||||||
return base.map[idx] ?: ObjNull
|
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
|
// PIC path analogous to get(), but returning raw Obj
|
||||||
val (key, ver) = when (base) {
|
val (key, ver) = when (base) {
|
||||||
is ObjInstance -> base.objClass.classId to base.objClass.layoutVersion
|
is ObjInstance -> base.objClass.classId to base.objClass.layoutVersion
|
||||||
@@ -891,26 +893,26 @@ class IndexRef(
|
|||||||
}
|
}
|
||||||
if (key != 0L) {
|
if (key != 0L) {
|
||||||
rGetter1?.let { g -> if (key == rKey1 && ver == rVer1) {
|
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)
|
return g(base, scope, idx)
|
||||||
} }
|
} }
|
||||||
rGetter2?.let { g -> if (key == rKey2 && ver == rVer2) {
|
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
|
val tk = rKey2; val tv = rVer2; val tg = rGetter2
|
||||||
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
|
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
|
||||||
rKey1 = tk; rVer1 = tv; rGetter1 = tg
|
rKey1 = tk; rVer1 = tv; rGetter1 = tg
|
||||||
return g(base, scope, idx)
|
return g(base, scope, idx)
|
||||||
} }
|
} }
|
||||||
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) rGetter3?.let { g -> if (key == rKey3 && ver == rVer3) {
|
if (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.PIC_DEBUG_COUNTERS) PerfStats.indexPicHit++
|
||||||
val tk = rKey3; val tv = rVer3; val tg = rGetter3
|
val tk = rKey3; val tv = rVer3; val tg = rGetter3
|
||||||
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
||||||
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
|
rKey2 = rKey1; rVer2 = rVer1; rGetter2 = rGetter1
|
||||||
rKey1 = tk; rVer1 = tv; rGetter1 = tg
|
rKey1 = tk; rVer1 = tv; rGetter1 = tg
|
||||||
return g(base, scope, idx)
|
return g(base, scope, idx)
|
||||||
} }
|
} }
|
||||||
if (net.sergeych.lyng.PerfFlags.INDEX_PIC_SIZE_4) rGetter4?.let { g -> if (key == rKey4 && ver == rVer4) {
|
if (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.PIC_DEBUG_COUNTERS) PerfStats.indexPicHit++
|
||||||
val tk = rKey4; val tv = rVer4; val tg = rGetter4
|
val tk = rKey4; val tv = rVer4; val tg = rGetter4
|
||||||
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
|
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
|
||||||
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
||||||
@@ -918,9 +920,9 @@ class IndexRef(
|
|||||||
rKey1 = tk; rVer1 = tv; rGetter1 = tg
|
rKey1 = tk; rVer1 = tv; rGetter1 = tg
|
||||||
return g(base, scope, idx)
|
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)
|
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
|
rKey4 = rKey3; rVer4 = rVer3; rGetter4 = rGetter3
|
||||||
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
rKey3 = rKey2; rVer3 = rVer2; rGetter3 = rGetter2
|
||||||
}
|
}
|
||||||
@@ -934,7 +936,7 @@ class IndexRef(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
|
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
|
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
|
||||||
if (base == ObjNull && isOptional) {
|
if (base == ObjNull && isOptional) {
|
||||||
// no-op on null receiver for optional chaining assignment
|
// no-op on null receiver for optional chaining assignment
|
||||||
@@ -953,7 +955,7 @@ class IndexRef(
|
|||||||
base.map[idx] = newValue
|
base.map[idx] = newValue
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (net.sergeych.lyng.PerfFlags.INDEX_PIC) {
|
if (PerfFlags.INDEX_PIC) {
|
||||||
// Polymorphic inline cache for index write
|
// Polymorphic inline cache for index write
|
||||||
val (key, ver) = when (base) {
|
val (key, ver) = when (base) {
|
||||||
is ObjInstance -> base.objClass.classId to base.objClass.layoutVersion
|
is ObjInstance -> base.objClass.classId to base.objClass.layoutVersion
|
||||||
@@ -968,14 +970,14 @@ class IndexRef(
|
|||||||
wKey1 = tk; wVer1 = tv; wSetter1 = ts
|
wKey1 = tk; wVer1 = tv; wSetter1 = ts
|
||||||
s(base, scope, idx, newValue); return
|
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
|
val tk = wKey3; val tv = wVer3; val ts = wSetter3
|
||||||
wKey3 = wKey2; wVer3 = wVer2; wSetter3 = wSetter2
|
wKey3 = wKey2; wVer3 = wVer2; wSetter3 = wSetter2
|
||||||
wKey2 = wKey1; wVer2 = wVer1; wSetter2 = wSetter1
|
wKey2 = wKey1; wVer2 = wVer1; wSetter2 = wSetter1
|
||||||
wKey1 = tk; wVer1 = tv; wSetter1 = ts
|
wKey1 = tk; wVer1 = tv; wSetter1 = ts
|
||||||
s(base, scope, idx, newValue); return
|
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
|
val tk = wKey4; val tv = wVer4; val ts = wSetter4
|
||||||
wKey4 = wKey3; wVer4 = wVer3; wSetter4 = wSetter3
|
wKey4 = wKey3; wVer4 = wVer3; wSetter4 = wSetter3
|
||||||
wKey3 = wKey2; wVer3 = wVer2; wSetter3 = wSetter2
|
wKey3 = wKey2; wVer3 = wVer2; wSetter3 = wSetter2
|
||||||
@@ -985,7 +987,7 @@ class IndexRef(
|
|||||||
} }
|
} }
|
||||||
// Miss: perform write and install generic handler
|
// Miss: perform write and install generic handler
|
||||||
base.putAt(scope, idx, newValue)
|
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
|
wKey4 = wKey3; wVer4 = wVer3; wSetter4 = wSetter3
|
||||||
wKey3 = wKey2; wVer3 = wVer2; wSetter3 = wSetter2
|
wKey3 = wKey2; wVer3 = wVer2; wSetter3 = wSetter2
|
||||||
}
|
}
|
||||||
@@ -1016,8 +1018,8 @@ class CallRef(
|
|||||||
private val isOptionalInvoke: Boolean,
|
private val isOptionalInvoke: Boolean,
|
||||||
) : ObjRef {
|
) : ObjRef {
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
override suspend fun get(scope: Scope): ObjRecord {
|
||||||
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
|
val fastRval = PerfFlags.RVAL_FASTPATH
|
||||||
val usePool = net.sergeych.lyng.PerfFlags.SCOPE_POOL
|
val usePool = PerfFlags.SCOPE_POOL
|
||||||
val callee = if (fastRval) target.evalValue(scope) else target.get(scope).value
|
val callee = if (fastRval) target.evalValue(scope) else target.get(scope).value
|
||||||
if (callee == ObjNull && isOptionalInvoke) return ObjNull.asReadonly
|
if (callee == ObjNull && isOptionalInvoke) return ObjNull.asReadonly
|
||||||
val callArgs = args.toArguments(scope, tailBlock)
|
val callArgs = args.toArguments(scope, tailBlock)
|
||||||
@@ -1055,20 +1057,20 @@ class MethodCallRef(
|
|||||||
private var mWindowAccesses: Int = 0
|
private var mWindowAccesses: Int = 0
|
||||||
private var mWindowMisses: Int = 0
|
private var mWindowMisses: Int = 0
|
||||||
private inline fun size4MethodsEnabled(): Boolean =
|
private inline fun size4MethodsEnabled(): Boolean =
|
||||||
net.sergeych.lyng.PerfFlags.METHOD_PIC_SIZE_4 ||
|
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.PIC_ADAPTIVE_2_TO_4 || PerfFlags.PIC_ADAPTIVE_METHODS_ONLY) && mPromotedTo4 && mFreezeWindowsLeft == 0)
|
||||||
private fun noteMethodHit() {
|
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)
|
val a = (mAccesses + 1).coerceAtMost(1_000_000)
|
||||||
mAccesses = a
|
mAccesses = a
|
||||||
if (net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_HEURISTIC) {
|
if (PerfFlags.PIC_ADAPTIVE_HEURISTIC) {
|
||||||
// Windowed tracking
|
// Windowed tracking
|
||||||
mWindowAccesses = (mWindowAccesses + 1).coerceAtMost(1_000_000)
|
mWindowAccesses = (mWindowAccesses + 1).coerceAtMost(1_000_000)
|
||||||
if (mWindowAccesses >= 256) endHeuristicWindow()
|
if (mWindowAccesses >= 256) endHeuristicWindow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun noteMethodMiss() {
|
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)
|
val a = (mAccesses + 1).coerceAtMost(1_000_000)
|
||||||
mAccesses = a
|
mAccesses = a
|
||||||
mMisses = (mMisses + 1).coerceAtMost(1_000_000)
|
mMisses = (mMisses + 1).coerceAtMost(1_000_000)
|
||||||
@@ -1076,7 +1078,7 @@ class MethodCallRef(
|
|||||||
if (mMisses * 100 / a > 20) mPromotedTo4 = true
|
if (mMisses * 100 / a > 20) mPromotedTo4 = true
|
||||||
mAccesses = 0; mMisses = 0
|
mAccesses = 0; mMisses = 0
|
||||||
}
|
}
|
||||||
if (net.sergeych.lyng.PerfFlags.PIC_ADAPTIVE_HEURISTIC) {
|
if (PerfFlags.PIC_ADAPTIVE_HEURISTIC) {
|
||||||
mWindowAccesses = (mWindowAccesses + 1).coerceAtMost(1_000_000)
|
mWindowAccesses = (mWindowAccesses + 1).coerceAtMost(1_000_000)
|
||||||
mWindowMisses = (mWindowMisses + 1).coerceAtMost(1_000_000)
|
mWindowMisses = (mWindowMisses + 1).coerceAtMost(1_000_000)
|
||||||
if (mWindowAccesses >= 256) endHeuristicWindow()
|
if (mWindowAccesses >= 256) endHeuristicWindow()
|
||||||
@@ -1106,26 +1108,26 @@ class MethodCallRef(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
override suspend fun get(scope: Scope): ObjRecord {
|
||||||
val fastRval = net.sergeych.lyng.PerfFlags.RVAL_FASTPATH
|
val fastRval = PerfFlags.RVAL_FASTPATH
|
||||||
val methodPic = net.sergeych.lyng.PerfFlags.METHOD_PIC
|
val methodPic = PerfFlags.METHOD_PIC
|
||||||
val picCounters = net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS
|
val picCounters = PerfFlags.PIC_DEBUG_COUNTERS
|
||||||
val base = if (fastRval) receiver.evalValue(scope) else receiver.get(scope).value
|
val base = if (fastRval) receiver.evalValue(scope) else receiver.get(scope).value
|
||||||
if (base == ObjNull && isOptional) return ObjNull.asReadonly
|
if (base == ObjNull && isOptional) return ObjNull.asReadonly
|
||||||
val callArgs = args.toArguments(scope, tailBlock)
|
val callArgs = args.toArguments(scope, tailBlock)
|
||||||
if (methodPic) {
|
if (methodPic) {
|
||||||
val (key, ver) = receiverKeyAndVersion(base)
|
val (key, ver) = receiverKeyAndVersion(base)
|
||||||
mInvoker1?.let { inv -> if (key == mKey1 && ver == mVer1) {
|
mInvoker1?.let { inv -> if (key == mKey1 && ver == mVer1) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.methodPicHit++
|
if (picCounters) PerfStats.methodPicHit++
|
||||||
noteMethodHit()
|
noteMethodHit()
|
||||||
return inv(base, scope, callArgs).asReadonly
|
return inv(base, scope, callArgs).asReadonly
|
||||||
} }
|
} }
|
||||||
mInvoker2?.let { inv -> if (key == mKey2 && ver == mVer2) {
|
mInvoker2?.let { inv -> if (key == mKey2 && ver == mVer2) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.methodPicHit++
|
if (picCounters) PerfStats.methodPicHit++
|
||||||
noteMethodHit()
|
noteMethodHit()
|
||||||
return inv(base, scope, callArgs).asReadonly
|
return inv(base, scope, callArgs).asReadonly
|
||||||
} }
|
} }
|
||||||
if (size4MethodsEnabled()) mInvoker3?.let { inv -> if (key == mKey3 && ver == mVer3) {
|
if (size4MethodsEnabled()) mInvoker3?.let { inv -> if (key == mKey3 && ver == mVer3) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.methodPicHit++
|
if (picCounters) PerfStats.methodPicHit++
|
||||||
noteMethodHit()
|
noteMethodHit()
|
||||||
// move-to-front: promote 3→1
|
// move-to-front: promote 3→1
|
||||||
val tK = mKey3; val tV = mVer3; val tI = mInvoker3
|
val tK = mKey3; val tV = mVer3; val tI = mInvoker3
|
||||||
@@ -1135,7 +1137,7 @@ class MethodCallRef(
|
|||||||
return inv(base, scope, callArgs).asReadonly
|
return inv(base, scope, callArgs).asReadonly
|
||||||
} }
|
} }
|
||||||
if (size4MethodsEnabled()) mInvoker4?.let { inv -> if (key == mKey4 && ver == mVer4) {
|
if (size4MethodsEnabled()) mInvoker4?.let { inv -> if (key == mKey4 && ver == mVer4) {
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.methodPicHit++
|
if (picCounters) PerfStats.methodPicHit++
|
||||||
noteMethodHit()
|
noteMethodHit()
|
||||||
// move-to-front: promote 4→1
|
// move-to-front: promote 4→1
|
||||||
val tK = mKey4; val tV = mVer4; val tI = mInvoker4
|
val tK = mKey4; val tV = mVer4; val tI = mInvoker4
|
||||||
@@ -1146,7 +1148,7 @@ class MethodCallRef(
|
|||||||
return inv(base, scope, callArgs).asReadonly
|
return inv(base, scope, callArgs).asReadonly
|
||||||
} }
|
} }
|
||||||
// Slow path
|
// Slow path
|
||||||
if (picCounters) net.sergeych.lyng.PerfStats.methodPicMiss++
|
if (picCounters) PerfStats.methodPicMiss++
|
||||||
noteMethodMiss()
|
noteMethodMiss()
|
||||||
val result = try {
|
val result = try {
|
||||||
base.invokeInstanceMethod(scope, name, callArgs)
|
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
|
// 1) Try fast slot/local
|
||||||
if (!PerfFlags.LOCAL_SLOT_PIC) {
|
if (!PerfFlags.LOCAL_SLOT_PIC) {
|
||||||
scope.getSlotIndexOf(name)?.let {
|
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)
|
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`
|
// 2) Fallback to current-scope object or field on `this`
|
||||||
scope[name]?.let { return it }
|
scope[name]?.let { return it }
|
||||||
val th = scope.thisObj
|
return scope.thisObj.readField(scope, name)
|
||||||
return when (th) {
|
|
||||||
is Obj -> th.readField(scope, name)
|
|
||||||
else -> scope.raiseError("symbol not defined: '$name'")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val hit = (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount())
|
val hit = (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount())
|
||||||
val slot = if (hit) cachedSlot else resolveSlot(scope)
|
val slot = if (hit) cachedSlot else resolveSlot(scope)
|
||||||
if (slot >= 0) {
|
if (slot >= 0) {
|
||||||
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) {
|
if (PerfFlags.PIC_DEBUG_COUNTERS) {
|
||||||
if (hit) net.sergeych.lyng.PerfStats.localVarPicHit++ else net.sergeych.lyng.PerfStats.localVarPicMiss++
|
if (hit) PerfStats.localVarPicHit++ else PerfStats.localVarPicMiss++
|
||||||
}
|
}
|
||||||
return scope.getSlotRecord(slot)
|
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`
|
// 2) Fallback name in scope or field on `this`
|
||||||
scope[name]?.let { return it }
|
scope[name]?.let { return it }
|
||||||
val th = scope.thisObj
|
return scope.thisObj.readField(scope, name)
|
||||||
return when (th) {
|
|
||||||
is Obj -> th.readField(scope, name)
|
|
||||||
else -> scope.raiseError("symbol not defined: '$name'")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun evalValue(scope: Scope): Obj {
|
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 }
|
scope.getSlotIndexOf(name)?.let { return scope.getSlotRecord(it).value }
|
||||||
// fallback to current-scope object or field on `this`
|
// fallback to current-scope object or field on `this`
|
||||||
scope[name]?.let { return it.value }
|
scope[name]?.let { return it.value }
|
||||||
val th = scope.thisObj
|
return scope.thisObj.readField(scope, name).value
|
||||||
return when (th) {
|
|
||||||
is Obj -> th.readField(scope, name).value
|
|
||||||
else -> scope.raiseError("symbol not defined: '$name'")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val hit = (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount())
|
val hit = (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount())
|
||||||
val slot = if (hit) cachedSlot else resolveSlot(scope)
|
val slot = if (hit) cachedSlot else resolveSlot(scope)
|
||||||
if (slot >= 0) return scope.getSlotRecord(slot).value
|
if (slot >= 0) return scope.getSlotRecord(slot).value
|
||||||
// Fallback name in scope or field on `this`
|
// Fallback name in scope or field on `this`
|
||||||
scope[name]?.let { return it.value }
|
scope[name]?.let { return it.value }
|
||||||
val th = scope.thisObj
|
return scope.thisObj.readField(scope, name).value
|
||||||
return when (th) {
|
|
||||||
is Obj -> th.readField(scope, name).value
|
|
||||||
else -> scope.raiseError("symbol not defined: '$name'")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
|
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
|
return
|
||||||
}
|
}
|
||||||
// Fallback: write to field on `this`
|
// Fallback: write to field on `this`
|
||||||
val th = scope.thisObj
|
scope.thisObj.writeField(scope, name, newValue)
|
||||||
if (th is Obj) {
|
return
|
||||||
th.writeField(scope, name, newValue)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scope.raiseError("symbol not defined: '$name'")
|
|
||||||
}
|
}
|
||||||
val slot = if (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount()) cachedSlot else resolveSlot(scope)
|
val slot = if (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount()) cachedSlot else resolveSlot(scope)
|
||||||
if (slot >= 0) {
|
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")
|
else scope.raiseError("Cannot assign to immutable value")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val th = scope.thisObj
|
scope.thisObj.writeField(scope, name, newValue)
|
||||||
if (th is Obj) {
|
return
|
||||||
th.writeField(scope, name, newValue)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scope.raiseError("symbol not defined: '$name'")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1401,8 +1379,8 @@ class FastLocalVarRef(
|
|||||||
val slot = if (ownerValid && cachedSlot >= 0) cachedSlot else resolveSlotInAncestry(scope)
|
val slot = if (ownerValid && cachedSlot >= 0) cachedSlot else resolveSlotInAncestry(scope)
|
||||||
val actualOwner = cachedOwnerScope
|
val actualOwner = cachedOwnerScope
|
||||||
if (slot >= 0 && actualOwner != null) {
|
if (slot >= 0 && actualOwner != null) {
|
||||||
if (net.sergeych.lyng.PerfFlags.PIC_DEBUG_COUNTERS) {
|
if (PerfFlags.PIC_DEBUG_COUNTERS) {
|
||||||
if (ownerValid) net.sergeych.lyng.PerfStats.fastLocalHit++ else net.sergeych.lyng.PerfStats.fastLocalMiss++
|
if (ownerValid) PerfStats.fastLocalHit++ else PerfStats.fastLocalMiss++
|
||||||
}
|
}
|
||||||
return actualOwner.getSlotRecord(slot)
|
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
|
// Fallback to standard name lookup (locals or closure chain) if the slot owner changed across suspension
|
||||||
scope[name]?.let { return it }
|
scope[name]?.let { return it }
|
||||||
// As a last resort, treat as field on `this`
|
// As a last resort, treat as field on `this`
|
||||||
val th = scope.thisObj
|
return scope.thisObj.readField(scope, name)
|
||||||
if (th is Obj) return th.readField(scope, name)
|
|
||||||
scope.raiseError("local '$name' is not available in this scope")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun evalValue(scope: Scope): Obj {
|
override suspend fun evalValue(scope: Scope): Obj {
|
||||||
@@ -1450,9 +1426,7 @@ class FastLocalVarRef(
|
|||||||
}
|
}
|
||||||
// Fallback to standard name lookup (locals or closure chain)
|
// Fallback to standard name lookup (locals or closure chain)
|
||||||
scope[name]?.let { return it.value }
|
scope[name]?.let { return it.value }
|
||||||
val th = scope.thisObj
|
return scope.thisObj.readField(scope, name).value
|
||||||
if (th is Obj) return th.readField(scope, name).value
|
|
||||||
scope.raiseError("local '$name' is not available in this scope")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
|
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
|
||||||
@@ -1485,12 +1459,8 @@ class FastLocalVarRef(
|
|||||||
else scope.raiseError("Cannot assign to immutable value")
|
else scope.raiseError("Cannot assign to immutable value")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val th = scope.thisObj
|
scope.thisObj.writeField(scope, name, newValue)
|
||||||
if (th is Obj) {
|
return
|
||||||
th.writeField(scope, name, newValue)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scope.raiseError("local '$name' is not available in this scope")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1502,11 +1472,11 @@ class ListLiteralRef(private val entries: List<ListEntry>) : ObjRef {
|
|||||||
for (e in entries) {
|
for (e in entries) {
|
||||||
when (e) {
|
when (e) {
|
||||||
is ListEntry.Element -> {
|
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
|
list += v
|
||||||
}
|
}
|
||||||
is ListEntry.Spread -> {
|
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) {
|
when (elements) {
|
||||||
is ObjList -> {
|
is ObjList -> {
|
||||||
// Grow underlying array once when possible
|
// 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.
|
* Range literal: left .. right or left ..< right. Right may be omitted in certain contexts.
|
||||||
*/
|
*/
|
||||||
@@ -1531,8 +1532,8 @@ class RangeRef(
|
|||||||
private val isEndInclusive: Boolean
|
private val isEndInclusive: Boolean
|
||||||
) : ObjRef {
|
) : ObjRef {
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
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 l = left?.let { if (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 r = right?.let { if (PerfFlags.RVAL_FASTPATH) it.evalValue(scope) else it.get(scope).value } ?: ObjNull
|
||||||
return ObjRange(l, r, isEndInclusive = isEndInclusive).asReadonly
|
return ObjRange(l, r, isEndInclusive = isEndInclusive).asReadonly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1544,7 +1545,7 @@ class AssignRef(
|
|||||||
private val atPos: Pos,
|
private val atPos: Pos,
|
||||||
) : ObjRef {
|
) : ObjRef {
|
||||||
override suspend fun get(scope: Scope): ObjRecord {
|
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)
|
val rec = target.get(scope)
|
||||||
if (!rec.isMutable) throw ScriptError(atPos, "cannot assign to immutable variable")
|
if (!rec.isMutable) throw ScriptError(atPos, "cannot assign to immutable variable")
|
||||||
if (rec.value.assign(scope, v) == null) {
|
if (rec.value.assign(scope, v) == null) {
|
||||||
|
|||||||
@@ -34,8 +34,19 @@ class ObjSet(val set: MutableSet<Obj> = mutableSetOf()) : Obj() {
|
|||||||
return ObjSet(
|
return ObjSet(
|
||||||
if (other is ObjSet)
|
if (other is ObjSet)
|
||||||
(set + other.set).toMutableSet()
|
(set + other.set).toMutableSet()
|
||||||
else
|
else {
|
||||||
(set + other).toMutableSet()
|
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 -> {
|
else -> {
|
||||||
if (other.isInstanceOf(ObjIterable)) {
|
if (other.isInstanceOf(ObjIterable)) {
|
||||||
val i = other.invokeInstanceMethod(scope, "iterable")
|
val otherSet = mutableSetOf<Obj>()
|
||||||
while (i.invokeInstanceMethod(scope, "hasNext").toBool()) {
|
other.enumerate(scope) {
|
||||||
set += i.invokeInstanceMethod(scope, "next")
|
otherSet += it
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
set += otherSet
|
||||||
}
|
}
|
||||||
set += other
|
else set += other
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
@@ -70,9 +83,19 @@ class ObjSet(val set: MutableSet<Obj> = mutableSetOf()) : Obj() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun minus(scope: Scope, other: Obj): Obj {
|
override suspend fun minus(scope: Scope, other: Obj): Obj {
|
||||||
if (other !is ObjSet)
|
return when {
|
||||||
scope.raiseIllegalArgument("set operator - requires another set")
|
other is ObjSet -> ObjSet(set.minus(other.set).toMutableSet())
|
||||||
return 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 {
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,97 @@ class ScriptTest {
|
|||||||
println("version = ${LyngVersion}")
|
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
|
@Test
|
||||||
fun parseNewlines() {
|
fun parseNewlines() {
|
||||||
fun check(expected: String, type: Token.Type, row: Int, col: Int, src: String, offset: Int = 0) {
|
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)
|
for( x in set ) println(x)
|
||||||
assert([1,2,3] == set.toList())
|
assert([1,2,3] == set.toList())
|
||||||
set += 4
|
set += 4
|
||||||
assert(set.toList() == [1,2,3,4])
|
assertEquals(set.toList(), [1,2,3,4])
|
||||||
assert(set == Set(1,2,3,4))
|
assert(set == Set(1,2,3,4))
|
||||||
|
|
||||||
val s1 = [1, 2].toSet()
|
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
|
@Test
|
||||||
fun testLet() = runTest {
|
fun testLet() = runTest {
|
||||||
eval(
|
eval(
|
||||||
@@ -2569,7 +2672,6 @@ class ScriptTest {
|
|||||||
x += i
|
x += i
|
||||||
}
|
}
|
||||||
delay(100)
|
delay(100)
|
||||||
println("-> "+x)
|
|
||||||
assert(x == 5050)
|
assert(x == 5050)
|
||||||
}
|
}
|
||||||
""".trimIndent())
|
""".trimIndent())
|
||||||
@@ -3519,4 +3621,84 @@ class ScriptTest {
|
|||||||
""".trimIndent()).toString())
|
""".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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -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
|
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 {
|
private suspend fun runBooksOnce(): Unit = runBlocking {
|
||||||
// Mirror BookTest set
|
// Mirror BookTest set, but run in bookMode to avoid strict assertions and allow shared context
|
||||||
runDocTests("../docs/tutorial.md")
|
// Profiling should not fail on documentation snippet mismatches.
|
||||||
runDocTests("../docs/math.md")
|
runDocTestsNonFailing("../docs/tutorial.md", bookMode = true)
|
||||||
runDocTests("../docs/advanced_topics.md")
|
runDocTestsNonFailing("../docs/math.md", bookMode = true)
|
||||||
runDocTests("../docs/OOP.md")
|
runDocTestsNonFailing("../docs/advanced_topics.md", bookMode = true)
|
||||||
runDocTests("../docs/Real.md")
|
runDocTestsNonFailing("../docs/OOP.md", bookMode = true)
|
||||||
runDocTests("../docs/List.md")
|
runDocTestsNonFailing("../docs/Real.md", bookMode = true)
|
||||||
runDocTests("../docs/Range.md")
|
runDocTestsNonFailing("../docs/List.md", bookMode = true)
|
||||||
runDocTests("../docs/Set.md")
|
runDocTestsNonFailing("../docs/Range.md", bookMode = true)
|
||||||
runDocTests("../docs/Map.md")
|
runDocTestsNonFailing("../docs/Set.md", bookMode = true)
|
||||||
runDocTests("../docs/Buffer.md")
|
runDocTestsNonFailing("../docs/Map.md", bookMode = true)
|
||||||
|
runDocTestsNonFailing("../docs/Buffer.md", bookMode = true)
|
||||||
|
runDocTestsNonFailing("../docs/when.md", bookMode = true)
|
||||||
// Samples folder, bookMode=true
|
// Samples folder, bookMode=true
|
||||||
for (bt in Files.list(Paths.get("../docs/samples")).toList()) {
|
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")
|
runDocTestsNonFailing("../docs/declaring_arguments.md", bookMode = true)
|
||||||
runDocTests("../docs/exceptions_handling.md")
|
runDocTestsNonFailing("../docs/exceptions_handling.md", bookMode = true)
|
||||||
runDocTests("../docs/time.md")
|
runDocTestsNonFailing("../docs/time.md", bookMode = true)
|
||||||
runDocTests("../docs/parallelism.md")
|
runDocTestsNonFailing("../docs/parallelism.md", bookMode = true)
|
||||||
runDocTests("../docs/RingBuffer.md")
|
runDocTestsNonFailing("../docs/RingBuffer.md", bookMode = true)
|
||||||
runDocTests("../docs/Iterable.md")
|
runDocTestsNonFailing("../docs/Iterable.md", bookMode = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class ProfileResult(val timeNs: Long, val allocBytes: Long)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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("<")
|
||||||
|
'>' -> append(">")
|
||||||
|
'&' -> append("&")
|
||||||
|
'"' -> append(""")
|
||||||
|
'\'' -> append("'")
|
||||||
|
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\">​</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:
|
||||||
|
* - `&` → `&`
|
||||||
|
* - `<` → `<`
|
||||||
|
* - `>` → `>`
|
||||||
|
* - `"` → `"`
|
||||||
|
* - `'` → `'`
|
||||||
|
*
|
||||||
|
* @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("<")
|
||||||
|
'>' -> append(">")
|
||||||
|
'&' -> append("&")
|
||||||
|
'"' -> append(""")
|
||||||
|
'\'' -> append("'")
|
||||||
|
else -> append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun htmlUnescape(s: String): String {
|
||||||
|
return s
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -36,3 +36,5 @@ dependencyResolutionManagement {
|
|||||||
rootProject.name = "lyng"
|
rootProject.name = "lyng"
|
||||||
include(":lynglib")
|
include(":lynglib")
|
||||||
include(":lyng")
|
include(":lyng")
|
||||||
|
include(":site")
|
||||||
|
include(":lyngweb")
|
||||||
@@ -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
|
||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<")
|
||||||
|
'>' -> append(">")
|
||||||
|
'&' -> append("&")
|
||||||
|
'"' -> append(""")
|
||||||
|
'\'' -> append("'")
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<"), "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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\">>>>"), "Doctest lines should start with >>> inside comment span: $html")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user