Compare commits

...
Author SHA1 Message Date
sergeych 1d9befe101 fixed fromatter, plugin, site, libs for new language features 2026-01-04 01:25:16 +01:00
sergeych abb262d9cf Improve property accessor indentation handling, add tests, and enhance nested control flow formatting. Fix enum and property keyword highlighting. 2026-01-04 00:09:47 +01:00
sergeych eca451b5a3 Implement restricted setter visibility (private set / protected set) for class fields and properties. 2026-01-03 23:38:53 +01:00
sergeych 54af50d6d6 Implement support for late-initialized val fields in classes. Added ObjUnset, UnsetException, compile-time initialization checks, and write-once enforcement for val fields. 2026-01-03 22:05:29 +01:00
sergeych ce0fc3650d "cache" bug found and fixed, causing some improvemts in lambda processing. 2026-01-03 21:06:09 +01:00
sergeych ce021e85f4 compiler bug with that can cause compilation infinite loop; object is always comparable to self instance or null 2026-01-03 21:05:56 +01:00
sergeych bc6613ec01 attempt to add navigation to plugin (partial success) 2026-01-03 11:59:50 +01:00
sergeych 59aefc5bc2 fixed enum objects wrong package 2026-01-02 16:10:05 +01:00
sergeych fac58675d5 plugin: autocomplete for enums and members, better support for local symbols; fixed bug in space normalization 2026-01-02 16:03:57 +01:00
sergeych 22f6c149db better formatting for multi-line comments 2026-01-02 11:20:52 +01:00
sergeych 6d8b60fcfa more docs on embedding fields 2026-01-02 10:41:16 +01:00
sergeych 54ecffc803 improved docs and tools for embedding kotlin classes with properties 2026-01-02 10:34:50 +01:00
sergeych 0731d63adf Improve error message for ObjList concatenation and set version to 1.1.0-SNAPSHOT. 2025-12-30 02:52:53 +01:00
sergeych 434d74b07f site badge updated 2025-12-25 22:37:56 +01:00
sergeych 55b27f831f 1.1.0-beta2 version 2025-12-25 22:35:13 +01:00
sergeych 5586e027ea more stdlib and docs 2025-12-24 18:56:30 +01:00
sergeych cd2b1a9cb7 Extensions methods and properties now are correctly isolated, respect visibility rules and allow adding class properties and class vals. 2025-12-24 00:29:10 +01:00
sergeych 3b6504d3b1 Add flatten, flatMap, and filterNotNull functions with corresponding tests 2025-12-23 18:06:28 +01:00
sergeych 357585d3ba removed unneeded test via annotation 2025-12-23 13:36:20 +01:00
sergeych 3a9c567c32 version bump 2025-12-23 13:22:54 +01:00
sergeych 8300c2a3ab v1.1.0-beta1: mini function declarations and working properties 2025-12-23 13:06:46 +01:00
sergeych 17b4904ae3 fixed test to reflect small Int caching 2025-12-23 10:48:58 +01:00
sergeych 7e8f1406b5 added optimizations for immutable numbers 2025-12-23 09:09:49 +01:00
sergeych 3f235878c0 Add EfficientIterables.md documentation for Kotlin interop. Optimize ObjInt operations by integrating value caching and updating number operations. Introduce high-performance enumerate method for ObjList, ObjSet, ObjRange, and custom iterables. Update compiler and loop handling for improved enumeration logic. 2025-12-23 08:33:34 +01:00
sergeych 5f3a54d08f Make ObjInt and ObjReal immutable and update number operations accordingly. Add support for class properties with get/set accessors. Rework loop parsing logic to improve clarity and consistency. Update .gitignore and TextMate grammar. Enhance Changelog and document new features. 2025-12-23 08:02:48 +01:00
sergeych 157b716eb7 Implement automatic substitution for named parameters (auto-named arguments). Supported name: shorthand for name: name in function calls and class constructors. Updated documentation and tests. Built and deployed IDEA plugin and site. 2025-12-22 18:11:38 +01:00
sergeych c9f96464f3 bump plugin version 2025-12-22 18:43:24 +01:00
sergeych 0732202c80 improved vairable tracking, fixed plugin to wirk with 1.0.10, fixed lambda comparison 2025-12-22 14:55:53 +01:00
sergeych 1e18a162c4 added exception reporting tests 2025-12-22 12:59:11 +01:00
sergeych 0dbfb473bf fixed scrollspy 2025-12-22 06:30:40 +01:00
sergeych 0add0ab54c fix #95 destructuring assignment and splats 2025-12-22 06:20:24 +01:00
sergeych 3acd56f55a fix #94 overridden toString() now is called correctly in known cases, default implementation is still generated for ObjInstances and some other classes 2025-12-22 05:32:19 +01:00
sergeych b7838b45ec fixed bug with scopes usage 2025-12-22 05:04:47 +01:00
sergeych 92cb088f36 lazy messages for check/require 2025-12-21 22:49:35 +01:00
sergeych 3dfe98a93d fixed inline map splats expansion in function calls 2025-12-21 21:12:06 +01:00
sergeych f6d51e9b94 fixed problems with documentation 2025-12-21 19:57:34 +01:00
sergeych 3ac7fd7ceb Add assert-related testing functions, findFirst/findFirstOrNull methods, and expand documentation. 2025-12-21 19:11:37 +01:00
sergeych 99f883cfc7 adjust site docs to new feature 2025-12-21 18:26:21 +01:00
sergeych 76a1804dc1 Implement init blocks for instance initialization, refactor class initialization logic to ensure correct inheritance and constructor chaining, update documentation and add tests. 2025-12-21 18:14:51 +01:00
sergeych 35cc8fa930 fixed site deploy script to work with cloudflare-backed site 2025-12-20 23:48:16 +01:00
sergeych 6f86e6ff97 fixed bug when some Map operations wer restricted to string keys only 2025-12-20 23:37:56 +01:00
sergeych fa91afa92b migrated to Kotlin 2.3.0 2025-12-18 01:33:49 +01:00
sergeych 00503fa105 cosmetics and docs 2025-12-17 22:12:08 +01:00
sergeych f7a4497f3f Migrate to kotlin.time.Instant and kotlin.time.Clock, update dependencies to support Kotlin 2.2, and adopt JVM 17. 2025-12-17 19:53:36 +01:00
sergeych 1aef64e0fa Add lowercase and uppercase string functions, update documentation accordingly. 2025-12-17 17:59:51 +01:00
sergeych 5d8fdce637 Move qualified identifier resolution to Scope as resolveQualifiedIdentifier, replace inline logic in LynonDecoder. 2025-12-14 00:20:43 +01:00
sergeych 5a8881bfd5 Refactor decodeClassObj to mimic compiler behavior for qualified names, add evaluateQualifiedNameAsCompiled. 2025-12-14 00:06:46 +01:00
sergeych d487886c8f some more trace on strange decpdeClassObj behavior 2025-12-13 23:41:52 +01:00
sergeych 180471e4cd Merge remote-tracking branch 'origin/fix_decodeClassObj' 2025-12-13 23:20:55 +01:00
sergeych 71a37a2906 Revert "Improve decodeClassObj class resolution in LynonDecoder, add fallback lookup mechanisms, and refine related tests"
This reverts commit dd1a1544c6.
2025-12-13 23:14:12 +01:00
sergeych ab05f83e77 Revert "Add documentation for Lynon class-name resolution behavior and future plans for fully-qualified name support"
This reverts commit a2d26fc777.
2025-12-13 23:13:56 +01:00
sergeych 9e11519608 revert to wirking ugly fix for decodeClassObj 2025-12-13 23:11:28 +01:00
sergeych a2d26fc777 Add documentation for Lynon class-name resolution behavior and future plans for fully-qualified name support 2025-12-13 17:16:10 +01:00
sergeych dd1a1544c6 Improve decodeClassObj class resolution in LynonDecoder, add fallback lookup mechanisms, and refine related tests 2025-12-13 17:12:44 +01:00
sergeych fba44622e5 Refactor toString implementations to support Scope context, add inspect, and improve assertions readability. 2025-12-13 13:48:57 +01:00
sergeych 2737aaa14e add mapNotNull to ObjIterable with documentation 2025-12-12 13:48:02 +01:00
sergeych bce88ced43 Merge pull request 'fix/scope-parent-cycle' (#92) from fix/scope-parent-cycle into master
Reviewed-on: #92
2025-12-11 03:09:45 +03:00
110 changed files with 6394 additions and 1874 deletions

No files matched your search

+4 -1
View File
@@ -16,4 +16,7 @@ xcuserdata
/test.lyng /test.lyng
/sample_texts/1.txt.gz /sample_texts/1.txt.gz
/kotlin-js-store/wasm/yarn.lock /kotlin-js-store/wasm/yarn.lock
/distributables /distributables
.output*.txt
debug.log
/build.log
@@ -0,0 +1,28 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Tests in 'lyng.lynglib.jvmTest'" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":lynglib:cleanJvmTest" />
<option value=":lynglib:jvmTest" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>true</RunAsTest>
<GradleProfilingDisabled>false</GradleProfilingDisabled>
<GradleCoverageDisabled>false</GradleCoverageDisabled>
<method v="2" />
</configuration>
</component>
@@ -1,3 +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.
~
-->
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="lyng:site [jsBrowserDevelopmentRun]" type="GradleRunConfiguration" factoryName="Gradle"> <configuration default="false" name="lyng:site [jsBrowserDevelopmentRun]" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings> <ExternalSystemSettings>
@@ -17,8 +34,11 @@
</ExternalSystemSettings> </ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess> <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess> <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
<DebugAllEnabled>false</DebugAllEnabled> <DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest> <RunAsTest>false</RunAsTest>
<GradleProfilingDisabled>true</GradleProfilingDisabled>
<GradleCoverageDisabled>true</GradleCoverageDisabled>
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </component>
+27 -1
View File
@@ -2,7 +2,31 @@
### Unreleased ### Unreleased
- Docs: Scopes and Closures guidance - Language: Class properties with accessors
- Support for `val` (read-only) and `var` (read-write) properties in classes.
- Syntax: `val name [ : Type ] get() { body }` or `var name [ : Type ] get() { body } set(value) { body }`.
- Laconic Expression Shorthand: `val prop get() = expression` and `var prop get() = read set(v) = write`.
- Properties are pure accessors and do **not** have automatic backing fields.
- Validation: `var` properties must have both accessors; `val` must have only a getter.
- Integration: Updated TextMate grammar and IntelliJ plugin (highlighting + keywords).
- Documentation: New "Properties" section in `docs/OOP.md`.
- Language: Restricted Setter Visibility
- Support for `private set` and `protected set` modifiers on `var` fields and properties.
- Allows members to be publicly readable but only writable from within the declaring class or its subclasses.
- Enforcement at runtime: throws `AccessException` on unauthorized writes.
- Supported only for declarations in class bodies (fields and properties).
- Documentation: New "Restricted Setter Visibility" section in `docs/OOP.md`.
- Language: Late-initialized `val` fields in classes
- Support for declaring `val` without an immediate initializer in class bodies.
- Compulsory initialization: every late-init `val` must be assigned at least once within the class body or an `init` block.
- Write-once enforcement: assigning to a `val` is allowed only if its current value is `Unset`.
- Access protection: reading a late-init `val` before it is assigned returns the `Unset` singleton; using `Unset` for most operations throws an `UnsetException`.
- Extension properties do not support late-init.
- Documentation: New "Late-initialized `val` fields" and "The `Unset` singleton" sections in `docs/OOP.md`.
- Docs: OOP improvements
- New page: `docs/scopes_and_closures.md` detailing `ClosureScope` resolution order, recursion‑safe helpers (`chainLookupIgnoreClosure`, `chainLookupWithMembers`, `baseGetIgnoreClosure`), cycle prevention, and capturing lexical environments for callbacks (`snapshotForClosure`). - New page: `docs/scopes_and_closures.md` detailing `ClosureScope` resolution order, recursion‑safe helpers (`chainLookupIgnoreClosure`, `chainLookupWithMembers`, `baseGetIgnoreClosure`), cycle prevention, and capturing lexical environments for callbacks (`snapshotForClosure`).
- Updated: `docs/advanced_topics.md` (link to the new page), `docs/parallelism.md` (closures in `launch`/`flow`), `docs/OOP.md` (visibility from closures with preserved `currentClassCtx`), `docs/exceptions_handling.md` (compatibility alias `SymbolNotFound`). - Updated: `docs/advanced_topics.md` (link to the new page), `docs/parallelism.md` (closures in `launch`/`flow`), `docs/OOP.md` (visibility from closures with preserved `currentClassCtx`), `docs/exceptions_handling.md` (compatibility alias `SymbolNotFound`).
- Tutorial: added quick link to Scopes and Closures. - Tutorial: added quick link to Scopes and Closures.
@@ -74,6 +98,8 @@ All notable changes to this project will be documented in this file.
- Mutually exclusive: `--check` and `--in-place` together now produce an error and exit with code 1. - Mutually exclusive: `--check` and `--in-place` together now produce an error and exit with code 1.
- Multi-file stdout prints headers `--- <path> ---` per file. - Multi-file stdout prints headers `--- <path> ---` per file.
- `lyng --help` shows `fmt`; `lyng fmt --help` displays dedicated help. - `lyng --help` shows `fmt`; `lyng fmt --help` displays dedicated help.
- Fix: Property accessors (`get`, `set`, `private set`, `protected set`) are now correctly indented relative to the property declaration.
- Fix: Indentation now correctly carries over into blocks that start on extra‑indented lines (e.g., nested `if` statements or property accessor bodies).
- CLI: Preserved legacy script invocation fast-paths: - CLI: Preserved legacy script invocation fast-paths:
- `lyng script.lyng [args...]` executes the script directly. - `lyng script.lyng [args...]` executes the script directly.
+10 -3
View File
@@ -4,11 +4,15 @@ Please visit the project homepage: [https://lynglang.com](https://lynglang.com)
- simple, compact, intuitive and elegant modern code: - simple, compact, intuitive and elegant modern code:
``` ```lyng
class Point(x,y) { class Point(x, y) {
fun dist() { sqrt(x*x + y*y) } fun dist() { sqrt(x*x + y*y) }
} }
Point(3,4).dist() //< 5
// Auto-named arguments shorthand (x: is x: x):
val x = 3
val y = 4
Point(x:, y:).dist() //< 5
fun swapEnds(first, args..., last, f) { fun swapEnds(first, args..., last, f) {
f( last, ...args, first) f( last, ...args, first)
@@ -39,6 +43,8 @@ and it is multithreaded on platforms supporting it (automatically, no code chang
- [Language home](https://lynglang.com) - [Language home](https://lynglang.com)
- [introduction and tutorial](docs/tutorial.md) - start here please - [introduction and tutorial](docs/tutorial.md) - start here please
- [Testing and Assertions](docs/Testing.md)
- [Efficient Iterables in Kotlin Interop](docs/EfficientIterables.md)
- [Samples directory](docs/samples) - [Samples directory](docs/samples)
- [Formatter (core + CLI + IDE)](docs/formatter.md) - [Formatter (core + CLI + IDE)](docs/formatter.md)
- [Books directory](docs) - [Books directory](docs)
@@ -184,6 +190,7 @@ Ready features:
- [x] better stack reporting - [x] better stack reporting
- [x] regular exceptions + extended `when` - [x] regular exceptions + extended `when`
- [x] multiple inheritance for user classes - [x] multiple inheritance for user classes
- [x] class properties (accessors)
## plan: towards v1.5 Enhancing ## plan: towards v1.5 Enhancing
+12 -2
View File
@@ -82,7 +82,7 @@ function updateIdeaPluginDownloadLink() {
# default target settings # default target settings
case "com" in case "com" in
com) com)
SSH_HOST=sergeych@lynglang.com # host to deploy to SSH_HOST=sergeych@d.lynglang.com # host to deploy to
SSH_PORT=22 # ssh port on it SSH_PORT=22 # ssh port on it
ROOT=/bigstore/sergeych_pub/lyng # directory to rsync to ROOT=/bigstore/sergeych_pub/lyng # directory to rsync to
;; ;;
@@ -98,10 +98,20 @@ esac
die() { echo "ERROR: $*" 1>&2 ; exit 1; } die() { echo "ERROR: $*" 1>&2 ; exit 1; }
function refreshTextmateZip() {
echo "Refreshing distributables/lyng-textmate.zip from editors/..."
mkdir -p distributables
# We use -r for recursive and -q for quiet (optional)
# -j can be used if we want to junk paths, but the request says "contents of editors/"
# usually we want to preserve the structure inside editors/
(cd editors && zip -rq ../distributables/lyng-textmate.zip .)
}
# Update the IDEA plugin download link in docs (temporarily), then build, then restore the doc # Update the IDEA plugin download link in docs (temporarily), then build, then restore the doc
refreshTextmateZip
updateIdeaPluginDownloadLink || echo "WARN: proceeding without updating IDEA plugin download link" updateIdeaPluginDownloadLink || echo "WARN: proceeding without updating IDEA plugin download link"
./gradlew site:clean site:jsBrowserDistribution ./gradlew site:jsBrowserDistribution
BUILD_RC=$? BUILD_RC=$?
# Always restore original doc if backup exists # Always restore original doc if backup exists
+256
View File
@@ -0,0 +1,256 @@
#!/bin/bash
#
# Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
#
set -e
# Configuration
DOCS_DIR="docs"
OUTPUT_DIR="distributables"
TEMP_DIR="build/temp_docs"
MERGED_MD="$TEMP_DIR/merged.md"
OUTPUT_HTML="$OUTPUT_DIR/lyng_documentation.html"
mkdir -p "$OUTPUT_DIR"
mkdir -p "$TEMP_DIR"
# Files that should come first in specific order
PRIORITY_FILES=(
"tutorial.md"
"OOP.md"
"advanced_topics.md"
"declaring_arguments.md"
"scopes_and_closures.md"
"exceptions_handling.md"
"when.md"
"parallelism.md"
"Testing.md"
)
# Files that should come next (reference)
REFERENCE_FILES=(
"Collection.md"
"Iterable.md"
"Iterator.md"
"List.md"
"Set.md"
"Map.md"
"Array.md"
"Buffer.md"
"RingBuffer.md"
"Range.md"
"Real.md"
"Regex.md"
"math.md"
"time.md"
)
# Files that are about integration/tools
INTEGRATION_FILES=(
"serialization.md"
"json_and_kotlin_serialization.md"
"embedding.md"
"lyng_cli.md"
"lyng.io.fs.md"
"formatter.md"
"EfficientIterables.md"
)
# Tracking processed files to avoid duplicates
PROCESSED_PATHS=()
is_excluded() {
local full_path="$1"
if grep -q "excludeFromIndex" "$full_path"; then
return 0 # true in bash
fi
return 1 # false
}
process_file() {
local rel_path="$1"
local full_path="$DOCS_DIR/$rel_path"
if [[ ! -f "$full_path" ]]; then
return
fi
if is_excluded "$full_path"; then
echo "Skipping excluded: $rel_path"
return
fi
# Check for duplicates
for p in "${PROCESSED_PATHS[@]}"; do
if [[ "$p" == "$rel_path" ]]; then
return
fi
done
PROCESSED_PATHS+=("$rel_path")
echo "Processing: $rel_path"
# 1. Add an anchor for the file based on its path
local anchor_name=$(echo "$rel_path" | sed 's/\//_/g')
echo "<div id=\"$anchor_name\"></div>" >> "$MERGED_MD"
echo "" >> "$MERGED_MD"
# 2. Append content with fixed links
# - [text](file.md) -> [text](#file.md)
# - [text](dir/file.md) -> [text](#dir_file.md)
# - [text](file.md#anchor) -> [text](#anchor)
# - Fix image links: [alt](../images/...) -> [alt](images/...) if needed, but none found yet.
cat "$full_path" | \
perl -pe 's/\[([^\]]+)\]\(([^)]+)\.md\)/"[$1](#" . ($2 =~ s|\/|_|gr) . ".md)"/ge' | \
perl -pe 's/\[([^\]]+)\]\(([^)]+)\.md#([^)]+)\)/[$1](#$3)/g' >> "$MERGED_MD"
echo -e "\n\n---\n\n" >> "$MERGED_MD"
}
# Start with an empty merged file
echo "% Lyng Language Documentation" > "$MERGED_MD"
echo "" >> "$MERGED_MD"
# 1. Process priority files
for f in "${PRIORITY_FILES[@]}"; do
process_file "$f"
done
# 2. Process reference files
for f in "${REFERENCE_FILES[@]}"; do
process_file "$f"
done
# 3. Process integration files
for f in "${INTEGRATION_FILES[@]}"; do
process_file "$f"
done
# 4. Process remaining files in docs root
for f in "$DOCS_DIR"/*.md; do
rel_f=${f#"$DOCS_DIR/"}
process_file "$rel_f"
done
# 5. Process remaining files in subdirs (like samples)
find "$DOCS_DIR" -name "*.md" | sort | while read -r f; do
rel_f=${f#"$DOCS_DIR/"}
process_file "$rel_f"
done
echo "Running pandoc to generate $OUTPUT_HTML..."
# Use a basic but clean CSS
pandoc "$MERGED_MD" -o "$OUTPUT_HTML" \
--toc --toc-depth=2 \
--standalone \
--embed-resources \
--metadata title="Lyng Language Documentation" \
--css <(echo "
body {
font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif;
line-height: 1.6;
max-width: 1000px;
margin: 0 auto;
padding: 2em;
color: #24292e;
background-color: #fff;
}
code {
background-color: rgba(27,31,35,0.05);
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: SFMono-Regular, Consolas, \"Liberation Mono\", Menlo, monospace;
font-size: 85%;
}
pre {
background-color: #f6f8fa;
padding: 16px;
overflow: auto;
border-radius: 3px;
line-height: 1.45;
}
pre code {
background-color: transparent;
padding: 0;
font-size: 100%;
}
h1 {
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
}
h2 {
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
}
hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin: 0 0 16px 0;
}
nav#TOC {
background: #f9f9f9;
padding: 1em;
border: 1px solid #eee;
margin-bottom: 2.5em;
border-radius: 6px;
}
nav#TOC ul {
list-style: none;
padding-left: 1.5em;
}
nav#TOC > ul {
padding-left: 0;
}
table {
border-spacing: 0;
border-collapse: collapse;
margin-top: 0;
margin-bottom: 16px;
}
table th, table td {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
table tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
table tr:nth-child(2n) {
background-color: #f6f8fa;
}
")
echo "-------------------------------------------------------"
echo "Done! Documentation generated successfully."
echo "Location: $OUTPUT_HTML"
echo "-------------------------------------------------------"
+7 -1
View File
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -29,3 +29,9 @@ tasks.register<org.gradle.api.DefaultTask>("runIde") {
description = "Run IntelliJ IDEA with the Lyng plugin (:lyng-idea)" description = "Run IntelliJ IDEA with the Lyng plugin (:lyng-idea)"
dependsOn(":lyng-idea:runIde") dependsOn(":lyng-idea:runIde")
} }
tasks.register<Exec>("generateDocs") {
group = "documentation"
description = "Generates a single-file documentation HTML using bin/generate_docs.sh"
commandLine("./bin/generate_docs.sh")
}
+1 -1
View File
@@ -12,7 +12,7 @@ Is a [Iterable] with known `size`, a finite [Iterable]:
(1) (1)
: `comparator(a,b)` should return -1 if `a < b`, +1 if `a > b` or zero. : `comparator(a,b)` should return -1 if `a < b`, +1 if `a > b` or zero.
See [List], [Set] and [Iterable] See [List], [Set], [Iterable] and [Efficient Iterables in Kotlin Interop](EfficientIterables.md)
[Iterable]: Iterable.md [Iterable]: Iterable.md
[List]: List.md [List]: List.md
+92
View File
@@ -0,0 +1,92 @@
# Efficient Iterables in Kotlin Interop
Lyng provides high-performance iteration mechanisms that allow Kotlin-side code to interact with Lyng iterables efficiently and vice versa.
## 1. Enumerating Lyng Objects from Kotlin
To iterate over a Lyng object (like a `List`, `Set`, or `Range`) from Kotlin code, use the virtual `enumerate` method:
```kotlin
val lyngList: Obj = ...
lyngList.enumerate(scope) { item ->
println("Processing $item")
true // return true to continue, false to break
}
```
### Why it's efficient:
- **Zero allocation**: Unlike traditional iterators, it doesn't create a `LyngIterator` object or any intermediate wrappers.
- **Direct access**: Subclasses like `ObjList` override `enumerate` to iterate directly over their internal Kotlin collections.
- **Reduced overhead**: It avoids multiple `invokeInstanceMethod` calls for `hasNext()` and `next()` on every step, which would normally involve dynamic dispatch and scope overhead.
## 2. Reactive Enumeration with Flow
If you prefer a reactive approach or need to integrate with Kotlin Coroutines flows, use `toFlow()`:
```kotlin
lyngList.toFlow(scope).collect { item ->
// ...
}
```
*Note: `toFlow()` internally uses the Lyng iterator protocol (`iterator()`, `hasNext()`, `next()`), so it's slightly less efficient than `enumerate()` for performance-critical loops, but more idiomatic for flow-based processing.*
## 3. Creating Efficient Iterables for Lyng in Kotlin
When implementing a custom object in Kotlin that should be iterable in Lyng (e.g., usable in `for (x in myObj) { ... }`), follow these steps to ensure maximum performance.
### A. Inherit from `Obj` and use `ObjIterable`
Ensure your object's class has `ObjIterable` as a parent so the Lyng compiler recognizes it as an iterable.
```kotlin
class MyCollection(val items: List<Obj>) : Obj() {
override val objClass = MyCollection.type
companion object {
val type = ObjClass("MyCollection", ObjIterable).apply {
// Provide a Lyng-side iterator for compatibility with
// manual iterator usage in Lyng scripts.
// Using ObjKotlinObjIterator if items are already Obj instances:
addFn("iterator") {
ObjKotlinObjIterator(thisAs<MyCollection>().items.iterator())
}
}
}
}
```
### B. Override `enumerate` for Maximum Performance
The Lyng compiler's `for` loops use the `enumerate` method. By overriding it in your Kotlin class, you provide a "fast path" for iteration.
```kotlin
class MyCollection(val items: List<Obj>) : Obj() {
// ...
override suspend fun enumerate(scope: Scope, callback: suspend (Obj) -> Boolean) {
for (item in items) {
// If callback returns false, it means 'break' was called in Lyng
if (!callback(item)) break
}
}
}
```
### C. Use `ObjInt.of()` for Numeric Data
If your iterable contains integers, always use `ObjInt.of(Long)` instead of the `ObjInt(Long)` constructor. Lyng maintains a cache for small integers (-128 to 127), which significantly reduces object allocations and GC pressure during tight loops.
```kotlin
// Efficiently creating an integer object
val obj = ObjInt.of(42L)
// Or using extension methods which also use the cache:
val obj2 = 42.toObj()
val obj3 = 42L.toObj()
```
#### Note on `toObj()` extensions:
While `<reified T> T.toObj()` is convenient, using specific extensions like `Int.toObj()` or `Long.toObj()` is slightly more efficient as they use the `ObjInt` cache.
## 4. Summary of Best Practices
- **To Consume**: Use `enumerate(scope) { item -> ... true }`.
- **To Implement**: Override `enumerate` in your `Obj` subclass.
- **To Register**: Use `ObjIterable` (or `ObjCollection`) as a parent class in your `ObjClass` definition.
- **To Optimize**: Use `ObjInt.of()` (or `.toObj()`) for all integer object allocations.
+102 -22
View File
@@ -40,13 +40,13 @@ available, for example
## joinToString ## joinToString
This methods convert any iterable to a string joining string representation of each element, optionally transforming it This methods convert any iterable to a string joining string representation of each element, optionally transforming it
and joining using specified suffix. and joining using specified separator.
Iterable.joinToString(suffux=' ', transform=null) Iterable.joinToString(separator=' ', transformer=null)
- if `Iterable` `isEmpty`, the empty string `""` is returned. - if `Iterable` `isEmpty`, the empty string `""` is returned.
- `suffix` is inserted between items when there are more than one. - `separator` is inserted between items when there are more than one.
- `transform` of specified is applied to each element, otherwise its `toString()` method is used. - `transformer` of specified is applied to each element, otherwise its `toString()` method is used.
Here is the sample: Here is the sample:
@@ -55,7 +55,7 @@ Here is the sample:
assertEquals( (1..3).joinToString { it * 10 }, "10 20 30") assertEquals( (1..3).joinToString { it * 10 }, "10 20 30")
>>> void >>> void
## `sum` and `sumBy` ## `sum` and `sumOf`
These, again, does the thing: These, again, does the thing:
@@ -68,6 +68,80 @@ These, again, does the thing:
>>> void >>> void
## map, filter and their variations
Used to transform or filter the whole iterable stream:
val source = [1,2,3,4]
// map: transform every element to something else
assertEquals(["n1", "n2", "n3", "n4"], source.map { "n"+it } )
// filter: keep only elements matching the predicate
assertEquals([2, 4], source.filter { it % 2 == 0 } )
// count: count elements matching the predicate
assertEquals(2, source.count { it % 2 == 0 } )
// mapNotNull: transform every element, skipping null results:
assertEquals(["n1", "n2", "n4"], source.mapNotNull { if( it == 3 ) null else "n"+it } )
// filterNotNull: skip all null elements:
assertEquals([1, 2, 4], [1, 2, null, 4].filterNotNull())
>>> void
You can also use flow variations that return a cold `Flow` instead of a `List`, which is useful for large or infinite sequences:
val source = [1, 2, 3, 4]
// filterFlow: returns a Flow of filtered elements
assert( source.filterFlow { it % 2 == 0 } is Flow )
// filterFlowNotNull: returns a Flow of non-null elements
assert( [1, null, 2].filterFlowNotNull() is Flow )
>>> void
## minOf and maxOf
Find the minimum or maximum value of a function applied to each element:
val source = ["abc", "de", "fghi"]
assertEquals(2, source.minOf { it.length })
assertEquals(4, source.maxOf { it.length })
>>> void
## flatten and flatMap
Work with nested collections:
val nested = [[1, 2], [3, 4]]
// flatten: combine nested collections into one list
assertEquals([1, 2, 3, 4], nested.flatten())
// flatMap: map each element to a collection and flatten the result
assertEquals([1, 10, 2, 20], [1, 2].flatMap { [it, it*10] })
>>> void
## findFirst and findFirstOrNull
Search for the first element that satisfies the given predicate:
val source = [1, 2, 3, 4]
assertEquals( 2, source.findFirst { it % 2 == 0 } )
assertEquals( 2, source.findFirstOrNull { it % 2 == 0 } )
// findFirst throws if not found:
assertThrows( NoSuchElementException ) { source.findFirst { it > 10 } }
// findFirstOrNull returns null if not found:
assertEquals( null, source.findFirstOrNull { it > 10 } )
>>> void
## Instance methods: ## Instance methods:
| fun/method | description | | fun/method | description |
@@ -75,52 +149,56 @@ These, again, does the thing:
| toList() | create a list from iterable | | toList() | create a list from iterable |
| toSet() | create a set from iterable | | toSet() | create a set from iterable |
| contains(i) | check that iterable contains `i` | | contains(i) | check that iterable contains `i` |
| `i in iterator` | same as `contains(i)` | | `i in iterable` | same as `contains(i)` |
| isEmpty() | check iterable is empty | | isEmpty() | check iterable is empty |
| forEach(f) | call f for each element | | forEach(f) | call f for each element |
| toMap() | create a map from list of key-value pairs (arrays of 2 items or like) | | toMap() | create a map from list of key-value pairs (arrays of 2 items or like) |
| any(p) | true if any element matches predicate `p` |
| all(p) | true if all elements match predicate `p` |
| map(f) | create a list of values returned by `f` called for each element of the iterable | | map(f) | create a list of values returned by `f` called for each element of the iterable |
| indexOf(i) | return index if the first encounter of i or a negative value if not found | | indexOf(i) | return index if the first encounter of i or a negative value if not found |
| associateBy(kf) | create a map where keys are returned by kf that will be called for each element | | associateBy(kf) | create a map where keys are returned by kf that will be called for each element |
| filter(p) | create a list of elements matching predicate `p` |
| count(p) | count elements matching predicate `p` |
| filterFlow(p) | create a [Flow] of elements matching predicate `p` |
| filterNotNull() | create a list of non-null elements |
| filterFlowNotNull() | create a [Flow] of non-null elements |
| minOf(f) | return minimum value of `f` applied to elements |
| maxOf(f) | return maximum value of `f` applied to elements |
| flatten() | flatten nested collections into a single [List] |
| flatMap(f) | map each element with `f` and flatten results into a [List] |
| findFirst(p) | return first element matching predicate `p` or throw (1) |
| findFirstOrNull(p) | return first element matching predicate `p` or `null` |
| first | first element (1) | | first | first element (1) |
| last | last element (1) | | last | last element (1) |
| take(n) | return [Iterable] of up to n first elements | | take(n) | return [Iterable] of up to n first elements |
| taleLast(n) | return [Iterable] of up to n last elements | | takeLast(n) | return [Iterable] of up to n last elements |
| drop(n) | return new [Iterable] without first n elements | | drop(n) | return new [Iterable] without first n elements |
| dropLast(n) | return new [Iterable] without last n elements | | dropLast(n) | return new [Iterable] without last n elements |
| sum() | return sum of the collection applying `+` to its elements (3) | | sum() | return sum of the collection applying `+` to its elements (3) |
| sumOf(predicate) | sum of the modified collection items (3) | | sumOf(f) | sum of the modified collection items (3) |
| sorted() | return [List] with collection items sorted naturally | | sorted() | return [List] with collection items sorted naturally |
| sortedWith(comparator) | sort using a comparator that compares elements (1) | | sortedWith(comparator) | sort using a comparator that compares elements (1) |
| sortedBy(predicate) | sort by comparing results of the predicate function | | sortedBy(predicate) | sort by comparing results of the predicate function |
| joinToString(s,t) | convert iterable to string, see (2) | | joinToString(s,t) | convert iterable to string, see (2) |
| reversed() | create a list containing items from this in reverse order | | reversed() | create a list containing items from this in reverse order |
| shuffled() | create a listof shiffled elements | | shuffled() | create a list of shuffled elements |
(1) (1)
: throws `NoSuchElementException` if there is no such element :: throws `NoSuchElementException` if there is no such element
(2) (2)
: `joinToString(suffix=" ",transform=null)`: suffix is inserted between items if there are more than one, trasnfom is :: `joinToString(separator=" ", transformer=null)`: separator is inserted between items if there are more than one, transformer is
optional function applied to each item that must return result string for an item, otherwise `item.toString()` is used. optional function applied to each item that must return result string for an item, otherwise `item.toString()` is used.
(3) (3)
: sum of empty collection is `null` :: sum of empty collection is `null`
fun Iterable.toList(): List
fun Iterable.toSet(): Set
fun Iterable.indexOf(element): Int
fun Iterable.contains(element): Bool
fun Iterable.isEmpty(element): Bool
fun Iterable.forEach(block: (Any?)->Void ): Void
fun Iterable.map(block: (Any?)->Void ): List
fun Iterable.associateBy( keyMaker: (Any?)->Any): Map
## Abstract methods: ## Abstract methods:
fun iterator(): Iterator fun iterator(): Iterator
Creates a list by iterating to the end. So, the Iterator should be finite to be used with it. For high-performance Kotlin-side interop and custom iterable implementation details, see [Efficient Iterables in Kotlin Interop](EfficientIterables.md).
## Included in interfaces: ## Included in interfaces:
@@ -134,6 +212,8 @@ Creates a list by iterating to the end. So, the Iterator should be finite to be
[List]: List.md [List]: List.md
[Flow]: parallelism.md#flow
[Range]: Range.md [Range]: Range.md
[Set]: Set.md [Set]: Set.md
+2
View File
@@ -23,6 +23,8 @@ must throw `ObjIterationFinishedError`.
Iterators are returned when implementing [Iterable] interface. Iterators are returned when implementing [Iterable] interface.
For high-performance Kotlin-side interop and custom iterable implementation details, see [Efficient Iterables in Kotlin Interop](EfficientIterables.md).
## Implemented for classes: ## Implemented for classes:
- [List], [Range] - [List], [Range]
+30 -1
View File
@@ -92,6 +92,34 @@ Open end ranges remove head and tail elements:
assert( [1, 2, 3] !== [1, 2, 3]) assert( [1, 2, 3] !== [1, 2, 3])
>>> void >>> void
## Destructuring
Lists can be used as L-values for destructuring assignments. This allows you to unpack list elements into multiple variables.
### Basic Destructuring
```lyng
val [a, b, c] = [1, 2, 3]
```
### With Splats (Variadic)
A single ellipsis `...` can be used to capture remaining elements into a list. It can be placed at the beginning, middle, or end of the pattern.
```lyng
val [head, rest...] = [1, 2, 3] // head=1, rest=[2, 3]
val [first, middle..., last] = [1, 2, 3, 4, 5] // first=1, middle=[2, 3, 4], last=5
```
### Nested Patterns
Destructuring patterns can be nested to unpack multi-dimensional lists.
```lyng
val [a, [b, c...], d] = [1, [2, 3, 4], 5]
```
### Reassignment
Destructuring can also be used to reassign existing variables:
```lyng
[x, y] = [y, x] // Swap values
```
## In-place sort ## In-place sort
List could be sorted in place, just like [Collection] provide sorted copies, in a very like way: List could be sorted in place, just like [Collection] provide sorted copies, in a very like way:
@@ -130,7 +158,8 @@ List could be sorted in place, just like [Collection] provide sorted copies, in
| `sort()` | in-place sort, natural order | void | | `sort()` | in-place sort, natural order | void |
| `sortBy(predicate)` | in-place sort bu `predicate` call result (3) | void | | `sortBy(predicate)` | in-place sort bu `predicate` call result (3) | void |
| `sortWith(comparator)` | in-place sort using `comarator` function (4) | void | | `sortWith(comparator)` | in-place sort using `comarator` function (4) | void |
| `shiffle()` | in-place shiffle contents | | | `shuffle()` | in-place shuffle contents | |
| `toString()` | string representation like `[a,b,c]` | |
(1) (1)
: optimized implementation that override `Array` one : optimized implementation that override `Array` one
+2 -2
View File
@@ -172,7 +172,7 @@ Maps and entries can also be merged with `+` and `+=`:
Notes: Notes:
- Map literals always use string keys (identifier keys are converted to strings). - 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. - Spreads inside map literals and `+`/`+=` merges allow any objects as keys.
- When you need computed or non-string keys, use the constructor form `Map(...)` or build entries with `=>` and then merge. - When you need computed or non-string keys, use the constructor form `Map(...)`, map literals with computed keys (if supported), or build entries with `=>` and then merge.
[Collection](Collection.md) [Collection](Collection.md)
+286 -6
View File
@@ -42,6 +42,114 @@ a _constructor_ that requires two parameters for fields. So when creating it wit
Form now on `Point` is a class, it's type is `Class`, and we can create instances with it as in the Form now on `Point` is a class, it's type is `Class`, and we can create instances with it as in the
example above. example above.
## Properties
Properties allow you to define member accessors that look like fields but execute code when read or written. Unlike regular fields, properties in Lyng do **not** have automatic backing fields; they are pure accessors.
### Basic Syntax
Properties are declared using `val` (read-only) or `var` (read-write) followed by a name and `get()`/`set()` blocks:
```kotlin
class Person(private var _age: Int) {
// Read-only property
val ageCategory
get() {
if (_age < 18) "Minor" else "Adult"
}
// Read-write property
var age: Int
get() { _age }
set(value) {
if (value >= 0) _age = value
}
}
val p = Person(15)
assertEquals("Minor", p.ageCategory)
p.age = 20
assertEquals("Adult", p.ageCategory)
```
### Laconic Expression Shorthand
For simple accessors and methods, you can use the `=` shorthand for a more elegant and laconic form:
```kotlin
class Circle(val radius: Real) {
val area get() = π * radius * radius
val circumference get() = 2 * π * radius
fun diameter() = radius * 2
}
fun median(a, b) = (a + b) / 2
class Counter {
private var _count = 0
var count get() = _count set(v) = _count = v
}
```
### Key Rules
- **`val` properties** must have a `get()` accessor and cannot have a `set()`.
- **`var` properties** must have both `get()` and `set()` accessors.
- **Functions and methods** can use the `=` shorthand to return the result of a single expression.
- **No Backing Fields**: There is no magic `field` identifier. If you need to store state, you must declare a separate (usually `private`) field.
- **Type Inference**: You can omit the type declaration if it can be inferred or if you don't need strict typing.
### Lazy Properties with `cached`
When you want to define a property that is computed only once (on demand) and then remembered, use the built-in `cached` function. This is more efficient than a regular property with `get()` if the computation is expensive, as it avoids re-calculating the value on every access.
```kotlin
class DataService(val id: Int) {
// The lambda passed to cached is only executed once, the first time data() is called.
val data = cached {
println("Fetching data for " + id)
// Perform expensive operation
"Record " + id
}
}
val service = DataService(42)
// No printing yet
println(service.data()) // Prints "Fetching data for 42", then returns "Record 42"
println(service.data()) // Returns "Record 42" immediately (no second fetch)
```
Note that `cached` returns a lambda, so you access the value by calling it like a method: `service.data()`. This is a powerful pattern for lazy-loading resources, caching results of database queries, or delaying expensive computations until they are truly needed.
## Instance initialization: init block
In addition to the primary constructor arguments, you can provide an `init` block that runs on each instance creation. This is useful for more complex initializations, side effects, or setting up fields that depend on multiple constructor parameters.
class Point(val x, val y) {
var magnitude
init {
magnitude = Math.sqrt(x*x + y*y)
}
}
Key features of `init` blocks:
- **Scope**: They have full access to `this` members and all primary constructor parameters.
- **Order**: In a single-inheritance scenario, `init` blocks run immediately after the instance fields are prepared but before the primary constructor body logic.
- **Multiple blocks**: You can have multiple `init` blocks; they will be executed in the order they appear in the class body.
### Initialization in Multiple Inheritance
In cases of multiple inheritance, `init` blocks are executed following the constructor chaining rule:
1. All ancestors are initialized first, following the inheritance hierarchy (diamond-safe: each ancestor is initialized exactly once).
2. The `init` blocks of each class are executed after its parents have been fully initialized.
3. For a hierarchy `class D : B, C`, the initialization order is: `B`'s chain, then `C`'s chain (skipping common ancestors with `B`), and finally `D`'s own `init` blocks.
### Initialization during Deserialization
When an object is restored from a serialized form (e.g., using `Lynon`), `init` blocks are **re-executed**. This ensures that transient state or derived fields are correctly recalculated upon restoration. However, primary constructors are **not** re-called during deserialization; only the `init` blocks and field initializers are executed to restore the instance state.
Class point has a _method_, or a _member function_ `length()` that uses its _fields_ `x` and `y` to Class point has a _method_, or a _member function_ `length()` that uses its _fields_ `x` and `y` to
calculate the magnitude. Length is called calculate the magnitude. Length is called
@@ -53,8 +161,58 @@ statements discussed later, there could be default values, ellipsis, etc.
class Point(x=0,y=0) class Point(x=0,y=0)
val p = Point() val p = Point()
assert( p.x == 0 && p.y == 0 ) assert( p.x == 0 && p.y == 0 )
// Named arguments in constructor calls use colon syntax:
val p2 = Point(y: 10, x: 5)
assert( p2.x == 5 && p2.y == 10 )
// Auto-substitution shorthand for named arguments:
val x = 1
val y = 2
val p3 = Point(x:, y:)
assert( p3.x == 1 && p3.y == 2 )
>>> void >>> void
Note that unlike **Kotlin**, which uses `=` for named arguments, Lyng uses `:` to avoid ambiguity with assignment expressions.
### Late-initialized `val` fields
You can declare a `val` field without an immediate initializer if you provide an assignment for it within an `init` block or the class body. This is useful when the initial value depends on logic that cannot be expressed in a single expression.
```kotlin
class DataProcessor(data: Object) {
val result: Object
init {
// Complex initialization logic
result = transform(data)
}
}
```
Key rules for late-init `val`:
- **Compile-time Check**: The compiler ensures that every `val` declared without an initializer in a class body has at least one assignment within that class body (including `init` blocks). Failing to do so results in a syntax error.
- **Write-Once**: A `val` can only be assigned once. Even if it was declared without an initializer, once it is assigned a value (e.g., in `init`), any subsequent assignment will throw an `IllegalAssignmentException`.
- **Access before Initialization**: If you attempt to read a late-init `val` before it has been assigned (for example, by calling a method in `init` that reads the field before its assignment), it will hold a special `Unset` value. Using `Unset` for most operations (like arithmetic or method calls) will throw an `UnsetException`.
- **No Extensions**: Extension properties do not support late initialization as they do not have per-instance storage. Extension `val`s must always have an initializer or a `get()` accessor.
### The `Unset` singleton
The `Unset` singleton represents a field that has been declared but not yet initialized. While it can be compared and converted to a string, most other operations on it are forbidden to prevent accidental use of uninitialized data.
```kotlin
class T {
val x
fun check() {
if (x == Unset) println("Not ready")
}
init {
check() // Prints "Not ready"
x = 42
}
}
```
## Methods ## Methods
Functions defined inside a class body are methods, and unless declared Functions defined inside a class body are methods, and unless declared
@@ -274,6 +432,69 @@ Are declared with var
assert( p.isSpecial == true ) assert( p.isSpecial == true )
>>> void >>> void
### Restricted Setter Visibility
You can restrict the visibility of a `var` field's or property's setter by using `private set` or `protected set` modifiers. This allows the member to be publicly readable but only writable from within the class or its subclasses.
#### On Fields
```kotlin
class SecretCounter {
var count = 0
private set // Can be read anywhere, but written only in SecretCounter
fun increment() { count++ }
}
val c = SecretCounter()
println(c.count) // OK
c.count = 10 // Throws AccessException
c.increment() // OK
```
#### On Properties
You can also apply restricted visibility to custom property setters:
```kotlin
class Person(private var _age: Int) {
var age
get() = _age
private set(v) { if (v >= 0) _age = v }
}
```
#### Protected Setters and Inheritance
A `protected set` allows subclasses to modify a field that is otherwise read-only to the public:
```kotlin
class Base {
var state = "initial"
protected set
}
class Derived : Base() {
fun changeState(newVal) {
state = newVal // OK: protected access from subclass
}
}
val d = Derived()
println(d.state) // OK: "initial"
d.changeState("updated")
println(d.state) // OK: "updated"
d.state = "bad" // Throws AccessException: public write not allowed
```
### Key Rules and Limitations
- **Only for `var`**: Restricted setter visibility cannot be used with `val` declarations, as they are inherently read-only. Attempting to use it with `val` results in a syntax error.
- **Class Body Only**: These modifiers can only be used on members declared within the class body. They are not supported for primary constructor parameters.
- **`private set`**: The setter is only accessible within the same class context (specifically, when `this` is an instance of that class).
- **`protected set`**: The setter is accessible within the declaring class and all its transitive subclasses.
- **Multiple Inheritance**: In MI scenarios, visibility is checked against the class that actually declared the member. Qualified access (e.g., `this@Base.field = value`) also respects restricted setter visibility.
### Private fields ### Private fields
Private fields are visible only _inside the class instance_: Private fields are visible only _inside the class instance_:
@@ -393,9 +614,11 @@ As usual, private statics are not accessible from the outside:
# Extending classes # Extending classes
It sometimes happen that the class is missing some particular functionality that can be _added to it_ without rewriting its inner logic and using its private state. In this case _extension methods_ could be used, for example. we want to create an extension method It sometimes happen that the class is missing some particular functionality that can be _added to it_ without rewriting its inner logic and using its private state. In this case _extension members_ could be used.
that would test if some object of unknown type contains something that can be interpreted
as an integer. In this case we _extend_ class `Object`, as it is the parent class for any instance of any type: ## Extension methods
For example, we want to create an extension method that would test if some object of unknown type contains something that can be interpreted as an integer. In this case we _extend_ class `Object`, as it is the parent class for any instance of any type:
fun Object.isInteger() { fun Object.isInteger() {
when(this) { when(this) {
@@ -420,10 +643,67 @@ as an integer. In this case we _extend_ class `Object`, as it is the parent clas
assert( ! "5.2".isInteger() ) assert( ! "5.2".isInteger() )
>>> void >>> void
__Important note__ as for version 0.6.9, extensions are in __global scope__. It means, that once applied to a global type (Int in our sample), they will be available for _all_ contexts, even new created, ## Extension properties
as they are modifying the type, not the context.
Beware of it. We might need to reconsider it later. Just like methods, you can extend existing classes with properties. These can be defined using simple initialization (for `val` only) or with custom accessors.
### Simple val extension
A read-only extension can be defined by assigning an expression:
```kotlin
val String.isLong = length > 10
val s = "Hello, world!"
assert(s.isLong)
```
### Properties with accessors
For more complex logic, use `get()` and `set()` blocks:
```kotlin
class Box(var value: Int)
var Box.doubledValue
get() = value * 2
set(v) = value = v / 2
val b = Box(10)
assertEquals(20, b.doubledValue)
b.doubledValue = 30
assertEquals(15, b.value)
```
Extension members are strictly barred from accessing private members of the class they extend, maintaining encapsulation.
### Extension Scoping and Isolation
Extensions in Lyng are **scope-isolated**. This means an extension is only visible within the scope where it is defined and its child scopes. This reduces the "attack surface" and prevents extensions from polluting the global space or other modules.
#### Scope Isolation Example
You can define different extensions with the same name in different scopes:
```kotlin
fun scopeA() {
val Int.description = "Number: " + toString()
assertEquals("Number: 42", 42.description)
}
fun scopeB() {
val Int.description = "Value: " + toString()
assertEquals("Value: 42", 42.description)
}
scopeA()
scopeB()
// Outside those scopes, Int.description is not defined
assertThrows { 42.description }
```
This isolation ensures that libraries can use extensions internally without worrying about name collisions with other libraries or the user's code. When a module is imported using `use`, its top-level extensions become available in the importing scope.
## dynamic symbols ## dynamic symbols
+109
View File
@@ -0,0 +1,109 @@
# Testing and Assertions
Lyng provides several built-in functions for testing and verifying code behavior. These are available in all scripts.
## Basic Assertions
### `assert`
Assert that a condition is true.
assert(condition, message=null)
- `condition`: A boolean expression.
- `message` (optional): A string message to include in the exception if the assertion fails.
If the condition is false, it throws an `AssertionFailedException`.
```lyng
assert(1 + 1 == 2)
assert(true, "This should be true")
```
### `assertEquals` and `assertEqual`
Assert that two values are equal. `assertEqual` is an alias for `assertEquals`.
assertEquals(expected, actual)
assertEqual(expected, actual)
If `expected != actual`, it throws an `AssertionFailedException` with a message showing both values.
```lyng
assertEquals(4, 2 * 2)
assertEqual("hello", "hel" + "lo")
```
### `assertNotEquals`
Assert that two values are not equal.
assertNotEquals(unexpected, actual)
If `unexpected == actual`, it throws an `AssertionFailedException`.
```lyng
assertNotEquals(5, 2 * 2)
```
## Exception Testing
### `assertThrows`
Assert that a block of code throws an exception.
assertThrows(code)
assertThrows(expectedExceptionClass, code)
- `expectedExceptionClass` (optional): The class of the exception that is expected to be thrown.
- `code`: A lambda block or statement to execute.
If the code does not throw an exception, an `AssertionFailedException` is raised.
If an `expectedExceptionClass` is provided, the thrown exception must be of that class (or its subclass), otherwise an error is raised.
`assertThrows` returns the caught exception object if successful.
```lyng
// Just assert that something is thrown
assertThrows { 1 / 0 }
// Assert that a specific exception class is thrown
assertThrows(NoSuchElementException) {
[1, 2, 3].findFirst { it > 10 }
}
// You can use the returned exception
val ex = assertThrows { throw Exception("custom error") }
assertEquals("custom error", ex.message)
```
## Other Validation Functions
While not strictly for testing, these functions help in defensive programming:
### `require`
require(condition, message="requirement not met")
Throws an `IllegalArgumentException` if the condition is false. Use this for validating function arguments.
If we want to evaluate the message lazily:
require(condition) { "requirement not met: %s"(someData) }
In this case, formatting will only occur if the condition is not met.
### `check`
check(condition, message="check failed")
Throws an `IllegalStateException` if the condition is false. Use this for validating internal state.
With lazy message evaluation:
check(condition) { "check failed: %s"(someData) }
In this case, formatting will only occur if the condition is not met.
+37 -17
View File
@@ -75,6 +75,13 @@ destructuring arrays when calling functions and lambdas:
getFirstAndLast( ...(1..10) ) // see "splats" section below getFirstAndLast( ...(1..10) ) // see "splats" section below
>>> [1,10] >>> [1,10]
Note that array destructuring can also be used in assignments:
val [first, middle..., last] = [1, 2, 3, 4, 5]
[x, y] = [y, x] // Swap
See [tutorial] and [List] documentation for more details on destructuring assignments.
# Splats # Splats
Ellipsis allows to convert argument lists to lists. The inversa algorithm that converts [List], Ellipsis allows to convert argument lists to lists. The inversa algorithm that converts [List],
@@ -100,41 +107,53 @@ There could be any number of splats at any positions. You can splat any other [I
## Named arguments in calls ## Named arguments in calls
Lyng supports named arguments at call sites using colon syntax `name: value`: Lyng supports named arguments at call sites using colon syntax `name: value`.
### Shorthand for Named Arguments
If you want to pass a variable as a named argument and the variable has the same name as the parameter, you can omit the value and use the shorthand `name:`. This is highly readable and matches the shorthand for map literals.
```lyng ```lyng
fun test(a="foo", b="bar", c="bazz") { [a, b, c] } fun test(a, b, c) { [a, b, c] }
assertEquals(["foo", "b", "bazz"], test(b: "b")) val a = 1
assertEquals(["a", "bar", "c"], test("a", c: "c")) val b = 2
val c = 3
// Explicit:
assertEquals([1, 2, 3], test(a: a, b: b, c: c))
// Shorthand (preferred):
assertEquals([1, 2, 3], test(a:, b:, c:))
``` ```
Rules: This shorthand is elegant, reduces boilerplate, and is consistent with Lyng's map literal syntax. It works for both function calls and class constructors.
Rules for named arguments:
- Named arguments must follow positional arguments. After the first named argument, no positional arguments may appear inside the parentheses. - 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). - 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. - 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. - 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. Why `:` and not `=` at call sites? In Lyng, `=` is an expression (assignment), so we use `:` to avoid ambiguity. This is a key difference from **Kotlin**, which uses `=` for named arguments. Declarations in Lyng continue to use `:` for types, while call sites use `as` / `as?` for type operations.
## Named splats (map splats) ## Named splats (map splats)
Splat (`...`) of a Map provides named arguments to the call. Only string keys are allowed: Splat (`...`) of a Map provides named arguments to the call. Only string keys are allowed. You can use the same auto-substitution shorthand inside map literals used for splats:
```lyng ```lyng
fun test(a="a", b="b", c="c", d="d") { [a, b, c, d] } 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) val b = "B!"
``` val d = "D!"
The same with a map literal is often more concise. Define the literal, then splat the variable: // Auto-substitution in map literal:
val patch = { d:, b: }
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) val r = test("A?", ...patch)
assertEquals(["A?","B!","c","D!"], r) assertEquals(["A?","B!","c","D!"], r)
>>> void ```
Constraints: Constraints:
@@ -155,3 +174,4 @@ If a call is immediately followed by a block `{ ... }`, it is treated as an extr
[tutorial]: tutorial.md [tutorial]: tutorial.md
[List]: List.md
+60 -5
View File
@@ -109,7 +109,62 @@ scope.eval("val y = inc(41); log('Answer:', y)")
You can register multiple names (aliases) at once: `addFn<ObjInt>("inc", "increment") { ... }`. You can register multiple names (aliases) at once: `addFn<ObjInt>("inc", "increment") { ... }`.
### 5) Read variable values back in Kotlin ### 5) Add Kotlin‑backed fields
If you need a simple field (with a value) instead of a computed property, use `createField`. This adds a field to the class that will be present in all its instances.
```kotlin
val myClass = ObjClass("MyClass")
// Add a read-only field (constant)
myClass.createField("version", ObjString("1.0.0"), isMutable = false)
// Add a mutable field with an initial value
myClass.createField("count", ObjInt(0), isMutable = true)
scope.addConst("MyClass", myClass)
```
In Lyng:
```lyng
val instance = MyClass()
println(instance.version) // -> "1.0.0"
instance.count = 5
println(instance.count) // -> 5
```
### 6) Add Kotlin‑backed properties
Properties in Lyng are pure accessors (getters and setters) and do not have automatic backing fields. You can add them to a class using `addProperty`.
```kotlin
val myClass = ObjClass("MyClass")
var internalValue: Long = 10
myClass.addProperty(
name = "value",
getter = {
// Return current value as a Lyng object
ObjInt(internalValue)
},
setter = { newValue ->
// newValue is passed as a Lyng object (the first and only argument)
internalValue = (newValue as ObjInt).value
}
)
scope.addConst("MyClass", myClass)
```
Usage in Lyng:
```lyng
val instance = MyClass()
println(instance.value) // -> 10
instance.value = 42
println(instance.value) // -> 42
```
### 7) Read variable values back in Kotlin
The simplest approach: evaluate an expression that yields the value and convert it. The simplest approach: evaluate an expression that yields the value and convert it.
@@ -124,7 +179,7 @@ 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. 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 ### 8) Execute scripts with parameters; call Lyng functions from Kotlin
There are two convenient patterns. There are two convenient patterns.
@@ -157,7 +212,7 @@ 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`. 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 ### 9) 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 ...`. Lyng supports packages that are imported from scripts. You can register packages programmatically via `ImportManager` or by providing source texts that declare `package ...`.
@@ -212,7 +267,7 @@ val s = scope.eval("s").toKotlin(scope) // -> 144
You can also register from parsed `Source` instances via `addSourcePackages(source)`. You can also register from parsed `Source` instances via `addSourcePackages(source)`.
### 8) Executing from files, security, and isolation ### 10) 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))`. - 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. - `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.
@@ -223,7 +278,7 @@ You can also register from parsed `Source` instances via `addSourcePackages(sour
val isolated = net.sergeych.lyng.Scope.new() val isolated = net.sergeych.lyng.Scope.new()
``` ```
### 9) Tips and troubleshooting ### 11) Tips and troubleshooting
- All values that cross the boundary must be Lyng `Obj` instances. Convert Kotlin values explicitly (e.g., `ObjInt`, `ObjReal`, `ObjString`). - 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. - Use `toKotlin(scope)` to get Kotlin values back. Collections convert to Kotlin collections recursively.
+25
View File
@@ -0,0 +1,25 @@
# Migration of Instant and Clock
## History
Before kotlin 2.0, there was an excellent library, kotlinx.datetime, which was widely used everywhere, also in Lyng and its dependencies.
When kotlin 2.0 was released, or soon after, JetBrains made an exptic decision to remove `Instant` and `Clock` from kotlinx.datetime and replace it with _yet experimental_ analogs in `kotlin.time`.
The problem is, these were not quite the same (these weren't `@Serializable`!), so people didn't migrate with ease. Okay, then JetBrains decided to not only deprecate it but also make them unusable on Apple targets. It sort of split auditories of many published libraries to those who hate JetBrains and Apple and continue to use 1.9-2.0 compatible versions that no longer work with Kotlin 2.2 on Apple targets (but work pretty well with earlier Kotlin or on other platforms).
Later JetBrains added serializers for their new `Instant` and `Clock` types, but strangely not in the stdlib, but in newer versions of `kotlinx.serialization`. This means that plain upgrade of dependencies to 2.2 is not enough to make them work.
## Solution
We hereby publish a new version of Lyng, 1.0.8-SNAPSHOT, which uses `ktlin.time.Instant` and `kotlin.time.Clock` instead of `kotlinx.datetime.Instant` and `kotlinx.datetime.Clock; it is in other aspects compatible also with Lynon encoded binaries. Still you might need to migrate your code to use `kotlinx.datetime` types.
So, if you are getting errors with new version, plase do:
- upgrade to Kotlin 2.2
- upgrade to Lyng 1.0.8-SNAPSHOT
- replace in your code imports (or other uses) of`kotlinx.datetime.Clock` to `kotlin.time.Clock` and `kotlinx.datetime.Instant` to `kotlin.time.Instant`.
This should solve the problem and hopefully we'll see no more suh a brillant ideas from IDEA ideologspersons.
Sorry for inconvenicence and send a ray of hate to JetBrains ;)
+20
View File
@@ -68,6 +68,26 @@ Tip: If a closure unexpectedly cannot see an outer local, check whether an inter
- The `visited` sets used for cycle detection are tiny and short‑lived; in typical scripts the overhead is negligible. - The `visited` sets used for cycle detection are tiny and short‑lived; in typical scripts the overhead is negligible.
- If profiling shows hotspots, consider limiting ancestry depth in your custom helpers or using small fixed arrays instead of hash sets—only for extremely hot code paths. - If profiling shows hotspots, consider limiting ancestry depth in your custom helpers or using small fixed arrays instead of hash sets—only for extremely hot code paths.
## Practical Example: `cached`
The `cached` function (defined in `lyng.stdlib`) is a classic example of using closures to maintain state. It wraps a builder into a zero-argument function that computes once and remembers the result:
```kotlin
fun cached(builder) {
var calculated = false
var value = null
{ // This lambda captures `calculated`, `value`, and `builder`
if( !calculated ) {
value = builder()
calculated = true
}
value
}
}
```
Because Lyng now correctly isolates closures for each evaluation of a lambda literal, using `cached` inside a class instance works as expected: each instance maintains its own private `calculated` and `value` state, even if they share the same property declaration.
## Dos and Don’ts ## Dos and Don’ts
- Do use `chainLookupIgnoreClosure` / `chainLookupWithMembers` for ancestry traversals. - Do use `chainLookupIgnoreClosure` / `chainLookupWithMembers` for ancestry traversals.
- Do maintain the resolution order above for predictable behavior. - Do maintain the resolution order above for predictable behavior.
+149 -30
View File
@@ -9,6 +9,7 @@ __Other documents to read__ maybe after this one:
- [Advanced topics](advanced_topics.md), [declaring arguments](declaring_arguments.md), [Scopes and Closures](scopes_and_closures.md) - [Advanced topics](advanced_topics.md), [declaring arguments](declaring_arguments.md), [Scopes and Closures](scopes_and_closures.md)
- [OOP notes](OOP.md), [exception handling](exceptions_handling.md) - [OOP notes](OOP.md), [exception handling](exceptions_handling.md)
- [math in Lyng](math.md), [the `when` statement](when.md) - [math in Lyng](math.md), [the `when` statement](when.md)
- [Testing and Assertions](Testing.md)
- [time](time.md) and [parallelism](parallelism.md) - [time](time.md) and [parallelism](parallelism.md)
- [parallelism] - multithreaded code, coroutines, etc. - [parallelism] - multithreaded code, coroutines, etc.
- Some class - Some class
@@ -106,6 +107,41 @@ Assignemnt is an expression that changes its lvalue and return assigned value:
>>> 11 >>> 11
>>> 6 >>> 6
### Destructuring assignments
Lyng supports destructuring assignments for lists. This allows you to unpack list elements into multiple variables at once:
val [a, b, c] = [1, 2, 3]
assertEquals(1, a)
assertEquals(2, b)
assertEquals(3, c)
It also supports *splats* (ellipsis) to capture multiple elements into a list:
val [head, rest...] = [1, 2, 3]
assertEquals(1, head)
assertEquals([2, 3], rest)
val [first, middle..., last] = [1, 2, 3, 4, 5]
assertEquals(1, first)
assertEquals([2, 3, 4], middle)
assertEquals(5, last)
Destructuring can be nested:
val [x, [y, z...]] = [1, [2, 3, 4]]
assertEquals(1, x)
assertEquals(2, y)
assertEquals([3, 4], z)
And it can be used for reassigning existing variables, for example, to swap values:
var x = 5
var y = 10
[x, y] = [y, x]
assertEquals(10, x)
assertEquals(5, y)
As the assignment itself is an expression, you can use it in strange ways. Just remember As the assignment itself is an expression, you can use it in strange ways. Just remember
to use parentheses as assignment operation insofar is left-associated and will not to use parentheses as assignment operation insofar is left-associated and will not
allow chained assignments (we might fix it later). Use parentheses insofar: allow chained assignments (we might fix it later). Use parentheses insofar:
@@ -311,8 +347,6 @@ Reference quality and object equality example:
assert( null == null) // singletons assert( null == null) // singletons
assert( null === null) assert( null === null)
// but, for non-singletons: // but, for non-singletons:
assert( 5 == 5)
assert( 5 !== 5)
assert( "foo" !== "foo" ) assert( "foo" !== "foo" )
>>> void >>> void
@@ -329,7 +363,7 @@ will be thrown:
// WRONG! Exception will be thrown at next line: // WRONG! Exception will be thrown at next line:
foo + "bar" foo + "bar"
Correct pattern is: The correct pattern is:
foo = "foo" foo = "foo"
// now is OK: // now is OK:
@@ -403,6 +437,19 @@ It is possible to define also vararg using ellipsis:
See the [arguments reference](declaring_arguments.md) for more details. See the [arguments reference](declaring_arguments.md) for more details.
## Named arguments
When calling functions, you can use named arguments with the colon syntax `name: value`. This is particularly useful when you have many parameters with default values.
```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"))
```
**Note for Kotlin users:** Lyng uses `:` instead of `=` for named arguments at call sites. This is because in Lyng, `=` is an expression that returns the assigned value, and using it in an argument list would create ambiguity.
## Closures ## Closures
Each __block has an isolated context that can be accessed from closures__. For example: Each __block has an isolated context that can be accessed from closures__. For example:
@@ -478,6 +525,8 @@ one could be with ellipsis that means "the rest pf arguments as List":
### Using lambda as the parameter ### Using lambda as the parameter
See also: [Testing and Assertions](Testing.md)
// note that fun returns its last calculated value, // note that fun returns its last calculated value,
// in our case, result after in-place addition: // in our case, result after in-place addition:
fun mapValues(iterable, transform) { fun mapValues(iterable, transform) {
@@ -1378,31 +1427,31 @@ Part match:
Typical set of String functions includes: Typical set of String functions includes:
| fun/prop | description / notes | | fun/prop | description / notes |
|--------------------|------------------------------------------------------------| |----------------------|------------------------------------------------------------|
| lower() | change case to unicode upper | | lower(), lowercase() | change case to unicode upper |
| upper() | change case to unicode lower | | upper(), uppercase() | change case to unicode lower |
| trim() | trim space chars from both ends | | trim() | trim space chars from both ends |
| startsWith(prefix) | true if starts with a prefix | | startsWith(prefix) | true if starts with a prefix |
| endsWith(prefix) | true if ends with a prefix | | endsWith(prefix) | true if ends with a prefix |
| last() | get last character of a string or throw | | last() | get last character of a string or throw |
| take(n) | get a new string from up to n first characters | | take(n) | get a new string from up to n first characters |
| takeLast(n) | get a new string from up to n last characters | | takeLast(n) | get a new string from up to n last characters |
| drop(n) | get a new string dropping n first chars, or empty string | | drop(n) | get a new string dropping n first chars, or empty string |
| dropLast(n) | get a new string dropping n last chars, or empty string | | dropLast(n) | get a new string dropping n last chars, or empty string |
| size | size in characters like `length` because String is [Array] | | size | size in characters like `length` because String is [Array] |
| (args...) | sprintf-like formatting, see [string formatting] | | (args...) | sprintf-like formatting, see [string formatting] |
| [index] | character at index | | [index] | character at index |
| [Range] | substring at range (2) | | [Range] | substring at range (2) |
| [Regex] | find first match of regex, like [Regex.find] (2) | | [Regex] | find first match of regex, like [Regex.find] (2) |
| s1 + s2 | concatenation | | s1 + s2 | concatenation |
| s1 += s2 | self-modifying concatenation | | s1 += s2 | self-modifying concatenation |
| toReal() | attempts to parse string as a Real value | | toReal() | attempts to parse string as a Real value |
| toInt() | parse string to Int value | | toInt() | parse string to Int value |
| characters() | create [List] of characters (1) | | characters() | create [List] of characters (1) |
| encodeUtf8() | returns [Buffer] with characters encoded to utf8 | | encodeUtf8() | returns [Buffer] with characters encoded to utf8 |
| matches(re) | matches the regular expression (2) | | matches(re) | matches the regular expression (2) |
| | | | | |
(1) (1)
: List is mutable therefore a new copy is created on each call. : List is mutable therefore a new copy is created on each call.
@@ -1448,7 +1497,7 @@ See [math functions](math.md). Other general purpose functions are:
| print(args...) | Open for overriding, it prints to stdout without newline. | | print(args...) | Open for overriding, it prints to stdout without newline. |
| flow {} | create flow sequence, see [parallelism] | | flow {} | create flow sequence, see [parallelism] |
| delay, launch, yield | see [parallelism] | | delay, launch, yield | see [parallelism] |
| cached(builder) | remembers builder() on first invocation and return it then | | cached(builder) | [Lazy evaluation with `cached`](#lazy-evaluation-with-cached) |
| let, also, apply, run | see above, flow controls | | let, also, apply, run | see above, flow controls |
(1) (1)
@@ -1466,6 +1515,8 @@ Lambda avoid unnecessary execution if assertion is not failed. for example:
[List]: List.md [List]: List.md
[Testing]: Testing.md
[Iterable]: Iterable.md [Iterable]: Iterable.md
[Iterator]: Iterator.md [Iterator]: Iterator.md
@@ -1494,6 +1545,50 @@ Lambda avoid unnecessary execution if assertion is not failed. for example:
[Regex]: Regex.md [Regex]: Regex.md
## Lazy evaluation with `cached`
Sometimes you have an expensive computation that you only want to perform if and when it is actually needed, and then remember (cache) the result for all future calls. Lyng provides the `cached(builder)` function for this purpose.
It is extremely simple to use: you pass it a block (lambda) that performs the computation, and it returns a zero-argument function that manages the caching for you.
### Basic Example
```kotlin
val expensive = cached {
println("Performing expensive calculation...")
2 + 2
}
println(expensive()) // Prints "Performing expensive calculation..." then "4"
println(expensive()) // Prints only "4" (result is cached)
```
### Benefits and Simplicity
1. **Lazy Execution:** The code inside the `cached` block doesn't run until you actually call the resulting function.
2. **Automatic State Management:** You don't need to manually check if a value has been computed or store it in a separate variable.
3. **Closures and Class Support:** `cached` works perfectly with closures. If you use it inside a class, it will correctly capture the instance variables, and each instance will have its own independent cache.
### Use Case: Lazy Properties in Classes
This is the most common use case for `cached`. It allows you to define expensive "fields" that are only computed if someone actually uses them:
```kotlin
class User(val id: Int) {
// The details will be fetched only once, on demand
val details = cached {
println("Fetching details for user " + id)
// Db.query is a hypothetical example
Db.query("SELECT * FROM users WHERE id = " + id)
}
}
val u = User(101)
// ... nothing happens yet ...
val d = u.details() // Computation happens here
val sameD = u.details() // Returns the same result immediately
```
## Multiple Inheritance (quick start) ## Multiple Inheritance (quick start)
Lyng supports multiple inheritance (MI) with simple, predictable rules. For a full reference see OOP notes, this is a quick, copy‑paste friendly overview. Lyng supports multiple inheritance (MI) with simple, predictable rules. For a full reference see OOP notes, this is a quick, copy‑paste friendly overview.
@@ -1557,4 +1652,28 @@ Notes:
- `private` is visible only inside the declaring class; `protected` is visible from the declaring class and any of its transitive subclasses. Qualialsofication (`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. Qualialsofication (`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). ## Extension members
You can add new methods and properties to existing classes without modifying them.
### Extension functions
fun String.shout() = this.upper() + "!!!"
"hello".shout()
>>> "HELLO!!!"
### Extension properties
val Int.isEven = this % 2 == 0
4.isEven
>>> true
Example with custom accessors:
val String.firstChar get() = this[0]
"abc".firstChar
>>> 'a'
Extension members are **scope-isolated**: they are visible only in the scope where they are defined and its children. This prevents name collisions and improves security.
To get details on OOP in Lyng, see [OOP notes](OOP.md).
+110
View File
@@ -0,0 +1,110 @@
# What's New in Lyng
This document highlights the latest additions and improvements to the Lyng language and its ecosystem.
## Language Features
### Class Properties with Accessors
Classes now support properties with custom `get()` and `set()` accessors. Properties in Lyng do **not** have automatic backing fields; they are pure accessors.
```lyng
class Person(private var _age: Int) {
// Read-only property
val ageCategory get() = if (_age < 18) "Minor" else "Adult"
// Read-write property
var age: Int
get() = _age
set(v) {
if (v >= 0) _age = v
}
}
```
### Private and Protected Setters
You can now restrict the visibility of a property's or field's setter using `private set` or `protected set`. This allows members to be publicly readable but only writable from within the declaring class or its subclasses.
```lyng
class Counter {
var count = 0
private set // Field with private setter
fun increment() { count++ }
}
class AdvancedCounter : Counter {
var totalOperations = 0
protected set // Settable here and in further subclasses
}
let c = Counter()
c.increment() // OK
// c.count = 10 // Error: setter is private
```
### Late-initialized `val` Fields
`val` fields in classes can be declared without an immediate initializer, provided they are assigned exactly once. If accessed before initialization, they hold the special `Unset` singleton.
```lyng
class Service {
val logger
fun check() {
if (logger == Unset) println("Not initialized yet")
}
init {
logger = Logger("Service")
}
}
```
### Named Arguments and Named Splats
Function calls now support named arguments using the `name: value` syntax. If the variable name matches the parameter name, you can use the `name:` shorthand.
```lyng
fun greet(name, greeting = "Hello") {
println("$greeting, $name!")
}
val name = "Alice"
greet(name:) // Shorthand for greet(name: name)
greet(greeting: "Hi", name: "Bob")
let params = { name: "Charlie", greeting: "Hey")
greet(...params) // Named splat expansion
```
### Multiple Inheritance (MI)
Lyng now supports multiple inheritance using the C3 Method Resolution Order (MRO). Use `this@Type` or casts for disambiguation.
```lyng
class A { fun foo() = "A" }
class B { fun foo() = "B" }
class Derived : A, B {
fun test() {
println(foo()) // Resolves to A.foo (leftmost)
println(this@B.foo()) // Qualified dispatch to B.foo
}
}
let d = Derived()
println((d as B).foo()) // Disambiguation via cast
```
## Tooling and Infrastructure
### CLI: Formatting Command
A new `fmt` subcommand has been added to the Lyng CLI.
```bash
lyng fmt MyFile.lyng # Print formatted code to stdout
lyng fmt --in-place MyFile.lyng # Format file in-place
lyng fmt --check MyFile.lyng # Check if file needs formatting
```
### IDEA Plugin: Autocompletion
Experimental lightweight autocompletion is now available in the IntelliJ plugin. It features type-aware member suggestions and inheritance-aware completion.
You can enable it in **Settings | Lyng Formatter | Enable Lyng autocompletion**.
@@ -76,8 +76,8 @@
}, },
"labels": { "patterns": [ { "name": "entity.name.label.lyng", "match": "[\\p{L}_][\\p{L}\\p{N}_]*:" } ] }, "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]*" } ] }, "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" } } } ] }, "declarations": { "patterns": [ { "name": "meta.function.declaration.lyng", "match": "\\b(fun|fn)\\s+(?:([\\p{L}_][\\p{L}\\p{N}_]*)\\.)?([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "keyword.declaration.lyng" }, "2": { "name": "entity.name.type.lyng" }, "3": { "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}_]*)\\.)?([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "keyword.declaration.lyng" }, "2": { "name": "entity.name.type.lyng" }, "3": { "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" } ] }, "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|get|set)\\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|π)" } ] }, "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*\\()" } ] }, "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": "[!?]" } ] }, "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": "[!?]" } ] },
+2 -2
View File
@@ -1,11 +1,11 @@
[versions] [versions]
agp = "8.5.2" agp = "8.5.2"
clikt = "5.0.3" clikt = "5.0.3"
kotlin = "2.2.21" kotlin = "2.3.0"
android-minSdk = "24" android-minSdk = "24"
android-compileSdk = "34" android-compileSdk = "34"
kotlinx-coroutines = "1.10.2" kotlinx-coroutines = "1.10.2"
mp_bintools = "0.1.12" mp_bintools = "0.3.2"
firebaseCrashlyticsBuildtools = "3.0.3" firebaseCrashlyticsBuildtools = "3.0.3"
okioVersion = "3.10.2" okioVersion = "3.10.2"
compiler = "3.2.0-alpha11" compiler = "3.2.0-alpha11"
+2 -2
View File
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -21,7 +21,7 @@ plugins {
} }
group = "net.sergeych.lyng" group = "net.sergeych.lyng"
version = "0.0.3-SNAPSHOT" version = "0.0.5-SNAPSHOT"
kotlin { kotlin {
jvmToolchain(17) jvmToolchain(17)
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -117,6 +117,7 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
d.name, d.name,
if (d.mutable) LyngHighlighterColors.VARIABLE else LyngHighlighterColors.VALUE if (d.mutable) LyngHighlighterColors.VARIABLE else LyngHighlighterColors.VALUE
) )
is MiniEnumDecl -> putName(d.nameStart, d.name, LyngHighlighterColors.TYPE)
} }
} }
@@ -157,12 +158,17 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
is MiniFunDecl -> { is MiniFunDecl -> {
addTypeSegments(d.returnType) addTypeSegments(d.returnType)
d.params.forEach { addTypeSegments(it.type) } d.params.forEach { addTypeSegments(it.type) }
addTypeSegments(d.receiver)
}
is MiniValDecl -> {
addTypeSegments(d.type)
addTypeSegments(d.receiver)
} }
is MiniValDecl -> addTypeSegments(d.type)
is MiniClassDecl -> { is MiniClassDecl -> {
d.ctorFields.forEach { addTypeSegments(it.type) } d.ctorFields.forEach { addTypeSegments(it.type) }
d.classFields.forEach { addTypeSegments(it.type) } d.classFields.forEach { addTypeSegments(it.type) }
} }
is MiniEnumDecl -> {}
} }
} }
@@ -179,9 +185,9 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
fun keyForKind(k: SymbolKind) = when (k) { fun keyForKind(k: SymbolKind) = when (k) {
SymbolKind.Function -> LyngHighlighterColors.FUNCTION SymbolKind.Function -> LyngHighlighterColors.FUNCTION
SymbolKind.Class, SymbolKind.Enum -> LyngHighlighterColors.TYPE SymbolKind.Class, SymbolKind.Enum -> LyngHighlighterColors.TYPE
SymbolKind.Param -> LyngHighlighterColors.PARAMETER SymbolKind.Parameter -> LyngHighlighterColors.PARAMETER
SymbolKind.Val -> LyngHighlighterColors.VALUE SymbolKind.Value -> LyngHighlighterColors.VALUE
SymbolKind.Var -> LyngHighlighterColors.VARIABLE SymbolKind.Variable -> LyngHighlighterColors.VARIABLE
} }
// Track covered ranges to not override later heuristics // Track covered ranges to not override later heuristics
@@ -1,3 +1,20 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
/* /*
* Lightweight BASIC completion for Lyng, MVP version. * Lightweight BASIC completion for Lyng, MVP version.
* Uses MiniAst (best-effort) + BuiltinDocRegistry to suggest symbols. * Uses MiniAst (best-effort) + BuiltinDocRegistry to suggest symbols.
@@ -9,18 +26,16 @@ import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.icons.AllIcons import com.intellij.icons.AllIcons
import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Document
import com.intellij.openapi.util.Key
import com.intellij.patterns.PlatformPatterns import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.PsiFile import com.intellij.psi.PsiFile
import com.intellij.util.ProcessingContext import com.intellij.util.ProcessingContext
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Source
import net.sergeych.lyng.highlight.offsetOf import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.idea.LyngLanguage import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
import net.sergeych.lyng.idea.settings.LyngFormatterSettings import net.sergeych.lyng.idea.settings.LyngFormatterSettings
import net.sergeych.lyng.idea.util.DocsBootstrap import net.sergeych.lyng.idea.util.DocsBootstrap
import net.sergeych.lyng.idea.util.IdeLenientImportProvider import net.sergeych.lyng.idea.util.LyngAstManager
import net.sergeych.lyng.idea.util.TextCtx import net.sergeych.lyng.idea.util.TextCtx
import net.sergeych.lyng.miniast.* import net.sergeych.lyng.miniast.*
@@ -48,6 +63,12 @@ class LyngCompletionContributor : CompletionContributor() {
StdlibDocsBootstrap.ensure() StdlibDocsBootstrap.ensure()
val file: PsiFile = parameters.originalFile val file: PsiFile = parameters.originalFile
if (file.language != LyngLanguage) return if (file.language != LyngLanguage) return
// Disable completion inside comments
val pos = parameters.position
val et = pos.node.elementType
if (et == LyngTokenTypes.LINE_COMMENT || et == LyngTokenTypes.BLOCK_COMMENT) return
// Feature toggle: allow turning completion off from settings // Feature toggle: allow turning completion off from settings
val settings = LyngFormatterSettings.getInstance(file.project) val settings = LyngFormatterSettings.getInstance(file.project)
if (!settings.enableLyngCompletionExperimental) return if (!settings.enableLyngCompletionExperimental) return
@@ -77,7 +98,8 @@ class LyngCompletionContributor : CompletionContributor() {
} }
// Build MiniAst (cached) for both global and member contexts to enable local class/val inference // Build MiniAst (cached) for both global and member contexts to enable local class/val inference
val mini = buildMiniAstCached(file, text) val mini = LyngAstManager.getMiniAst(file)
val binding = LyngAstManager.getBinding(file)
// Delegate computation to the shared engine to keep behavior in sync with tests // Delegate computation to the shared engine to keep behavior in sync with tests
val engineItems = try { val engineItems = try {
@@ -104,13 +126,13 @@ class LyngCompletionContributor : CompletionContributor() {
// Try inferring return/receiver class around the dot // Try inferring return/receiver class around the dot
val inferred = val inferred =
// Prefer MiniAst-based inference (return type from member call or receiver type) // Prefer MiniAst-based inference (return type from member call or receiver type)
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported) DocLookupUtils.guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported, binding)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported) ?: DocLookupUtils.guessReceiverClassViaMini(mini, text, memberDotPos, imported, binding)
?: ?:
guessReturnClassFromMemberCallBefore(text, memberDotPos, imported) DocLookupUtils.guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported) ?: DocLookupUtils.guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported) ?: DocLookupUtils.guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: guessReceiverClass(text, memberDotPos, imported) ?: DocLookupUtils.guessReceiverClass(text, memberDotPos, imported, mini)
if (inferred != null) { if (inferred != null) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Fallback inferred receiver/return class='$inferred' — offering its members") if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Fallback inferred receiver/return class='$inferred' — offering its members")
@@ -142,6 +164,8 @@ class LyngCompletionContributor : CompletionContributor() {
.withInsertHandler(ParenInsertHandler) .withInsertHandler(ParenInsertHandler)
Kind.Class_ -> LookupElementBuilder.create(ci.name) Kind.Class_ -> LookupElementBuilder.create(ci.name)
.withIcon(AllIcons.Nodes.Class) .withIcon(AllIcons.Nodes.Class)
Kind.Enum -> LookupElementBuilder.create(ci.name)
.withIcon(AllIcons.Nodes.Enum)
Kind.Value -> LookupElementBuilder.create(ci.name) Kind.Value -> LookupElementBuilder.create(ci.name)
.withIcon(AllIcons.Nodes.Field) .withIcon(AllIcons.Nodes.Field)
.let { b -> if (!ci.typeText.isNullOrBlank()) b.withTypeText(ci.typeText, true) else b } .let { b -> if (!ci.typeText.isNullOrBlank()) b.withTypeText(ci.typeText, true) else b }
@@ -160,18 +184,18 @@ class LyngCompletionContributor : CompletionContributor() {
add("lyng.stdlib") add("lyng.stdlib")
}.toList() }.toList()
val inferredClass = val inferredClass =
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported) DocLookupUtils.guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported, binding)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported) ?: DocLookupUtils.guessReceiverClassViaMini(mini, text, memberDotPos, imported, binding)
?: guessReturnClassFromMemberCallBefore(text, memberDotPos, imported) ?: DocLookupUtils.guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported) ?: DocLookupUtils.guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported) ?: DocLookupUtils.guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: guessReceiverClass(text, memberDotPos, imported) ?: DocLookupUtils.guessReceiverClass(text, memberDotPos, imported, mini)
if (!inferredClass.isNullOrBlank()) { if (!inferredClass.isNullOrBlank()) {
val ext = BuiltinDocRegistry.extensionMethodNamesFor(inferredClass) val ext = BuiltinDocRegistry.extensionMemberNamesFor(inferredClass)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Post-engine extension check for $inferredClass: ${'$'}{ext}") if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Post-engine extension check for $inferredClass: ${'$'}{ext}")
for (name in ext) { for (name in ext) {
if (existing.contains(name)) continue if (existing.contains(name)) continue
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, inferredClass, name) val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, inferredClass, name, mini)
if (resolved != null) { if (resolved != null) {
when (val member = resolved.second) { when (val member = resolved.second) {
is MiniMemberFunDecl -> { is MiniMemberFunDecl -> {
@@ -192,6 +216,7 @@ class LyngCompletionContributor : CompletionContributor() {
emit(builder) emit(builder)
existing.add(name) existing.add(name)
} }
is MiniInitDecl -> {}
} }
} else { } else {
// Fallback: emit simple method name without detailed types // Fallback: emit simple method name without detailed types
@@ -214,12 +239,12 @@ class LyngCompletionContributor : CompletionContributor() {
add("lyng.stdlib") add("lyng.stdlib")
}.toList() }.toList()
val inferred = val inferred =
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported) DocLookupUtils.guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported, binding)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported) ?: DocLookupUtils.guessReceiverClassViaMini(mini, text, memberDotPos, imported, binding)
?: guessReturnClassFromMemberCallBefore(text, memberDotPos, imported) ?: DocLookupUtils.guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported) ?: DocLookupUtils.guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported) ?: DocLookupUtils.guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: guessReceiverClass(text, memberDotPos, imported) ?: DocLookupUtils.guessReceiverClass(text, memberDotPos, imported, mini)
if (inferred != null) { if (inferred != null) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Enrichment inferred class='$inferred' — offering its members") if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Enrichment inferred class='$inferred' — offering its members")
offerMembers(emit, imported, inferred, sourceText = text, mini = mini) offerMembers(emit, imported, inferred, sourceText = text, mini = mini)
@@ -249,7 +274,8 @@ class LyngCompletionContributor : CompletionContributor() {
.withIcon(kindIcon) .withIcon(kindIcon)
.withTypeText(typeOf(d.type), true) .withTypeText(typeOf(d.type), true)
} }
else -> LookupElementBuilder.create(name) is MiniEnumDecl -> LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Enum)
} }
emit(builder) emit(builder)
} }
@@ -276,9 +302,7 @@ class LyngCompletionContributor : CompletionContributor() {
, sourceText: String, , sourceText: String,
mini: MiniScript? = null mini: MiniScript? = null
) { ) {
// Ensure modules are seeded in the registry (triggers lazy stdlib build too) val classes = DocLookupUtils.aggregateClasses(imported, mini)
for (m in imported) BuiltinDocRegistry.docsForModule(m)
val classes = DocLookupUtils.aggregateClasses(imported)
if (DEBUG_COMPLETION) { if (DEBUG_COMPLETION) {
val keys = classes.keys.joinToString(", ") val keys = classes.keys.joinToString(", ")
log.info("[LYNG_DEBUG] offerMembers: imported=${imported} classes=[${keys}] target=${className}") log.info("[LYNG_DEBUG] offerMembers: imported=${imported} classes=[${keys}] target=${className}")
@@ -297,7 +321,7 @@ class LyngCompletionContributor : CompletionContributor() {
} }
// If MiniAst didn't populate members (empty), try to scan class body text for member signatures // If MiniAst didn't populate members (empty), try to scan class body text for member signatures
if (localClass.members.isEmpty()) { if (localClass.members.isEmpty()) {
val scanned = scanLocalClassMembersFromText(mini, text = sourceText, cls = localClass) val scanned = DocLookupUtils.scanLocalClassMembersFromText(mini, text = sourceText, cls = localClass)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Local scan for class ${localClass.name}: found ${scanned.size} members -> ${scanned.keys}") if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Local scan for class ${localClass.name}: found ${scanned.size} members -> ${scanned.keys}")
for ((name, sig) in scanned) { for ((name, sig) in scanned) {
when (sig.kind) { when (sig.kind) {
@@ -329,6 +353,7 @@ class LyngCompletionContributor : CompletionContributor() {
when (m) { when (m) {
is MiniMemberFunDecl -> if (!m.isStatic) continue is MiniMemberFunDecl -> if (!m.isStatic) continue
is MiniMemberValDecl -> if (!m.isStatic) continue is MiniMemberValDecl -> if (!m.isStatic) continue
is MiniInitDecl -> continue
} }
} }
val list = target.getOrPut(m.name) { mutableListOf() } val list = target.getOrPut(m.name) { mutableListOf() }
@@ -401,9 +426,10 @@ class LyngCompletionContributor : CompletionContributor() {
.firstOrNull { it.type != null } ?: rep .firstOrNull { it.type != null } ?: rep
val builder = LookupElementBuilder.create(name) val builder = LookupElementBuilder.create(name)
.withIcon(icon) .withIcon(icon)
.withTypeText(typeOf((chosen as MiniMemberValDecl).type), true) .withTypeText(typeOf(chosen.type), true)
emit(builder) emit(builder)
} }
is MiniInitDecl -> {}
} }
} }
} }
@@ -454,6 +480,7 @@ class LyngCompletionContributor : CompletionContributor() {
emit(builder) emit(builder)
already.add(name) already.add(name)
} }
is MiniInitDecl -> {}
} }
} else { } else {
// Synthetic fallback: method without detailed params/types to improve UX in absence of docs // Synthetic fallback: method without detailed params/types to improve UX in absence of docs
@@ -473,10 +500,10 @@ class LyngCompletionContributor : CompletionContributor() {
} }
} }
// Supplement with stdlib extension-like methods defined in root.lyng (e.g., fun String.trim(...)) // Supplement with stdlib extension members defined in root.lyng (e.g., fun String.trim(...))
run { run {
val already = (directMap.keys + inheritedMap.keys).toMutableSet() val already = (directMap.keys + inheritedMap.keys).toMutableSet()
val ext = BuiltinDocRegistry.extensionMethodNamesFor(className) val ext = BuiltinDocRegistry.extensionMemberNamesFor(className)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Extensions for $className: count=${ext.size} -> ${ext}") if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Extensions for $className: count=${ext.size} -> ${ext}")
for (name in ext) { for (name in ext) {
if (already.contains(name)) continue if (already.contains(name)) continue
@@ -504,6 +531,7 @@ class LyngCompletionContributor : CompletionContributor() {
already.add(name) already.add(name)
continue continue
} }
is MiniInitDecl -> {}
} }
} }
// Fallback: emit without detailed types if we couldn't resolve // Fallback: emit without detailed types if we couldn't resolve
@@ -519,371 +547,6 @@ class LyngCompletionContributor : CompletionContributor() {
// --- MiniAst-based inference helpers --- // --- MiniAst-based inference helpers ---
private fun previousIdentifierBeforeDot(text: String, dotPos: Int): String? {
var i = dotPos - 1
// skip whitespace
while (i >= 0 && text[i].isWhitespace()) i--
val end = i + 1
while (i >= 0 && TextCtx.isIdentChar(text[i])) i--
val start = i + 1
return if (start < end) text.substring(start, end) else null
}
private fun guessReceiverClassViaMini(mini: MiniScript?, text: String, dotPos: Int, imported: List<String>): String? {
if (mini == null) return null
val ident = previousIdentifierBeforeDot(text, dotPos) ?: return null
// 1) Local val/var in the file
val valDecl = mini.declarations.filterIsInstance<MiniValDecl>().firstOrNull { it.name == ident }
val typeFromVal = valDecl?.type?.let { simpleClassNameOf(it) }
if (!typeFromVal.isNullOrBlank()) return typeFromVal
// If initializer exists, try to sniff ClassName(
val initR = valDecl?.initRange
if (initR != null) {
val src = mini.range.start.source
val s = src.offsetOf(initR.start)
val e = src.offsetOf(initR.end).coerceAtMost(text.length)
if (s in 0..e && e <= text.length) {
val init = text.substring(s, e)
Regex("([A-Za-z_][A-Za-z0-9_]*)\\s*\\(").find(init)?.let { m ->
val cls = m.groupValues[1]
return cls
}
}
}
// 2) Parameters in any function (best-effort without scope mapping)
val paramType = mini.declarations.filterIsInstance<MiniFunDecl>()
.asSequence()
.flatMap { it.params.asSequence() }
.firstOrNull { it.name == ident }?.type
val typeFromParam = simpleClassNameOf(paramType)
if (!typeFromParam.isNullOrBlank()) return typeFromParam
return null
}
private fun guessReturnClassFromMemberCallBeforeMini(mini: MiniScript?, text: String, dotPos: Int, imported: List<String>): String? {
if (mini == null) return null
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
// back to matching '('
i--
var depth = 0
while (i >= 0) {
when (text[i]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && TextCtx.isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
// Ensure member call: dot before callee
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k < 0 || text[k] != '.') return null
val prevDot = k
// Resolve receiver class via MiniAst (ident like `x`)
val receiverClass = guessReceiverClassViaMini(mini, text, prevDot, imported) ?: return null
// If receiver class is a locally declared class, resolve member on it
val localClass = mini.declarations.filterIsInstance<MiniClassDecl>().firstOrNull { it.name == receiverClass }
if (localClass != null) {
val mm = localClass.members.firstOrNull { it.name == callee }
if (mm != null) {
val rt = when (mm) {
is MiniMemberFunDecl -> mm.returnType
is MiniMemberValDecl -> mm.type
else -> null
}
return simpleClassNameOf(rt)
} else {
// Try to scan class body text for method signature and extract return type
val sigs = scanLocalClassMembersFromText(mini, text, localClass)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Local scan for return type in ${receiverClass}.${callee}: candidates=${sigs.keys}")
val sig = sigs[callee]
if (sig != null && sig.typeText != null) return sig.typeText
}
}
// Else fallback to registry-based resolution (covers imported classes)
return DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee)?.second?.let { m ->
val rt = when (m) {
is MiniMemberFunDecl -> m.returnType
is MiniMemberValDecl -> m.type
}
simpleClassNameOf(rt)
}
}
private data class ScannedSig(val kind: String, val params: List<String>?, val typeText: String?)
private fun scanLocalClassMembersFromText(mini: MiniScript, text: String, cls: MiniClassDecl): Map<String, ScannedSig> {
val src = mini.range.start.source
val start = src.offsetOf(cls.bodyRange?.start ?: cls.range.start)
val end = src.offsetOf(cls.bodyRange?.end ?: cls.range.end).coerceAtMost(text.length)
if (start !in 0..end) return emptyMap()
val body = text.substring(start, end)
val map = LinkedHashMap<String, ScannedSig>()
// fun name(params): Type
val funRe = Regex("(?m)^\\s*fun\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(([^)]*)\\)\\s*(?::\\s*([A-Za-z_][A-Za-z0-9_]*))?")
for (m in funRe.findAll(body)) {
val name = m.groupValues.getOrNull(1) ?: continue
val params = m.groupValues.getOrNull(2)?.split(',')?.mapNotNull { it.trim().takeIf { it.isNotEmpty() } } ?: emptyList()
val type = m.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() }
map[name] = ScannedSig("fun", params, type)
}
// val/var name: Type
val valRe = Regex("(?m)^\\s*(val|var)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?::\\s*([A-Za-z_][A-Za-z0-9_]*))?")
for (m in valRe.findAll(body)) {
val kind = m.groupValues.getOrNull(1) ?: continue
val name = m.groupValues.getOrNull(2) ?: continue
val type = m.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() }
map.putIfAbsent(name, ScannedSig(kind, null, type))
}
return map
}
private fun guessReceiverClass(text: String, dotPos: Int, imported: List<String>): String? {
// 1) Try call-based: ClassName(...).
DocLookupUtils.guessClassFromCallBefore(text, dotPos, imported)?.let { return it }
// 2) Literal heuristics based on the immediate char before '.'
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i >= 0) {
when (text[i]) {
'"' -> {
// Either regular or triple-quoted string; both map to String
return "String"
}
']' -> return "List" // very rough heuristic
'}' -> return "Dict" // map/dictionary literal heuristic
')' -> {
// Parenthesized expression: walk back to matching '(' and inspect inner expression
var j = i - 1
var depth = 0
while (j >= 0) {
when (text[j]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
j--
}
if (j >= 0 && text[j] == '(') {
val innerS = (j + 1).coerceAtLeast(0)
val innerE = i.coerceAtMost(text.length)
if (innerS < innerE) {
val inner = text.substring(innerS, innerE).trim()
if (inner.startsWith('"') && inner.endsWith('"')) return "String"
if (inner.startsWith('[') && inner.endsWith(']')) return "List"
if (inner.startsWith('{') && inner.endsWith('}')) return "Dict"
}
}
}
}
// Numeric literal: support decimal, hex (0x..), and scientific notation (1e-3)
var j = i
var hasDigits = false
var hasDot = false
var hasExp = false
// Walk over digits, letters for hex, dots, and exponent markers
while (j >= 0) {
val ch = text[j]
if (ch.isDigit()) { hasDigits = true; j-- ; continue }
if (ch == '.') { hasDot = true; j-- ; continue }
if (ch == 'e' || ch == 'E') { hasExp = true; j-- ; // optional sign directly before digits
if (j >= 0 && (text[j] == '+' || text[j] == '-')) j--
continue
}
if (ch in listOf('x','X')) { // part of 0x prefix
j--
continue
}
if (ch == 'a' || ch == 'b' || ch == 'c' || ch == 'd' || ch == 'f' ||
ch == 'A' || ch == 'B' || ch == 'C' || ch == 'D' || ch == 'F') {
// hex digit in 0x...
j--
continue
}
break
}
// Now check for 0x/0X prefix
val k = j
val isHex = k >= 1 && text[k] == '0' && (text[k+1] == 'x' || text[k+1] == 'X')
if (hasDigits) {
return if (isHex) "Int" else if (hasDot || hasExp) "Real" else "Int"
}
}
return null
}
/**
* Try to infer the class of the return value of the member call immediately before the dot.
* Example: `Path(".." ).lines().<caret>` → detects `lines()` on receiver class `Path` and returns `Iterator`.
*/
private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List<String>): String? {
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0) return null
// We expect a call just before the dot, i.e., ')' ... '.'
if (text[i] != ')') return null
// Walk back to matching '('
i--
var depth = 0
while (i >= 0) {
val ch = text[i]
when (ch) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
// Identify callee identifier just before '('
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && TextCtx.isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
// Ensure it's a member call (there must be a dot immediately before the callee, ignoring spaces)
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k < 0 || text[k] != '.') return null
val prevDot = k
// Infer receiver class at the previous dot
val receiverClass = guessReceiverClass(text, prevDot, imported) ?: return null
// Resolve the callee as a member of receiver class, including inheritance
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee) ?: return null
val member = resolved.second
val returnType = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
}
return simpleClassNameOf(returnType)
}
/**
* Infer return class of a top-level call right before the dot: e.g., `files().<caret>`.
* We extract callee name and resolve it among imported modules' top-level functions.
*/
private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List<String>): String? {
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
// Walk back to matching '('
i--
var depth = 0
while (i >= 0) {
val ch = text[i]
when (ch) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
// Extract callee ident before '('
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && TextCtx.isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
// If it's a member call, bail out (handled in member-call inference)
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k >= 0 && text[k] == '.') return null
// Resolve top-level function in imported modules
for (mod in imported) {
val decls = BuiltinDocRegistry.docsForModule(mod)
val fn = decls.asSequence().filterIsInstance<MiniFunDecl>().firstOrNull { it.name == callee }
if (fn != null) return simpleClassNameOf(fn.returnType)
}
return null
}
/**
* Fallback: if we can at least extract a callee name before the dot and it exists across common classes,
* derive its return type using cross-class lookup (Iterable/Iterator/List preference). This ignores the receiver.
* Example: `something.lines().<caret>` where `something` type is unknown, but `lines()` commonly returns Iterator<String>.
*/
private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List<String>): String? {
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
// Walk back to matching '('
i--
var depth = 0
while (i >= 0) {
val ch = text[i]
when (ch) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
// Extract callee ident before '('
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && TextCtx.isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
// Try cross-class resolution
val resolved = DocLookupUtils.findMemberAcrossClasses(imported, callee) ?: return null
val member = resolved.second
val returnType = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
}
return simpleClassNameOf(returnType)
}
/** Convert a MiniTypeRef to a simple class name as used by docs (e.g., Iterator from Iterator<String>). */
private fun simpleClassNameOf(t: MiniTypeRef?): String? = when (t) {
null -> null
is MiniTypeName -> t.segments.lastOrNull()?.name
is MiniGenericType -> simpleClassNameOf(t.base)
is MiniFunctionType -> null
is MiniTypeVar -> null
}
private fun buildMiniAst(text: String): MiniScript? {
return try {
val sink = MiniAstBuilder()
val provider = IdeLenientImportProvider.create()
val src = Source("<ide>", text)
runBlocking { Compiler.compileWithMini(src, provider, sink) }
sink.build()
} catch (_: Throwable) {
null
}
}
// Cached per PsiFile by document modification stamp
private val MINI_KEY = Key.create<MiniScript>("lyng.mini.cache")
private val STAMP_KEY = Key.create<Long>("lyng.mini.cache.stamp")
private fun buildMiniAstCached(file: PsiFile, text: String): MiniScript? {
val doc = file.viewProvider.document ?: return null
val stamp = doc.modificationStamp
val prevStamp = file.getUserData(STAMP_KEY)
val cached = file.getUserData(MINI_KEY)
if (cached != null && prevStamp != null && prevStamp == stamp) return cached
val built = buildMiniAst(text)
// Cache even null? avoid caching failures; only cache non-null
if (built != null) {
file.putUserData(MINI_KEY, built)
file.putUserData(STAMP_KEY, stamp)
}
return built
}
private fun offerParamsInScope(emit: (com.intellij.codeInsight.lookup.LookupElement) -> Unit, mini: MiniScript, text: String, caret: Int) { private fun offerParamsInScope(emit: (com.intellij.codeInsight.lookup.LookupElement) -> Unit, mini: MiniScript, text: String, caret: Int) {
val src = mini.range.start.source val src = mini.range.start.source
// Find function whose body contains caret or whose whole range contains caret // Find function whose body contains caret or whose whole range contains caret
@@ -906,7 +569,7 @@ class LyngCompletionContributor : CompletionContributor() {
// Lenient textual import extractor (duplicated from QuickDoc privately) // Lenient textual import extractor (duplicated from QuickDoc privately)
private fun extractImportsFromText(text: String): List<String> { private fun extractImportsFromText(text: String): List<String> {
val result = LinkedHashSet<String>() val result = LinkedHashSet<String>()
val re = Regex("(?m)^\\s*import\\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)") val re = Regex("^\\s*import\\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)", RegexOption.MULTILINE)
re.findAll(text).forEach { m -> re.findAll(text).forEach { m ->
val raw = m.groupValues.getOrNull(1)?.trim().orEmpty() val raw = m.groupValues.getOrNull(1)?.trim().orEmpty()
if (raw.isNotEmpty()) { if (raw.isNotEmpty()) {
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -69,59 +69,164 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val ident = text.substring(idRange.startOffset, idRange.endOffset) val ident = text.substring(idRange.startOffset, idRange.endOffset)
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: ident='$ident' at ${idRange.startOffset}..${idRange.endOffset} in ${file.name}") if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: ident='$ident' at ${idRange.startOffset}..${idRange.endOffset} in ${file.name}")
// Build MiniAst for this file (fast and resilient). Best-effort; on failure continue with registry lookup only. // Build MiniAst for this file (fast and resilient). Best-effort; on failure continue with partial AST.
val sink = MiniAstBuilder() val sink = MiniAstBuilder()
// Use lenient import provider so unresolved imports (e.g., lyng.io.fs) don't break docs
val provider = IdeLenientImportProvider.create() val provider = IdeLenientImportProvider.create()
val src = Source("<ide>", text) val src = Source("<ide>", text)
var mini: MiniScript? = try { val mini = try {
runBlocking { Compiler.compileWithMini(src, provider, sink) } runBlocking { Compiler.compileWithMini(src, provider, sink) }
sink.build() sink.build()
} catch (t: Throwable) { } catch (t: Throwable) {
// Do not bail out completely: we still can resolve built-in and imported docs (e.g., println) if (DEBUG_LOG) log.warn("[LYNG_DEBUG] QuickDoc: compileWithMini produced partial AST: ${t.message}")
if (DEBUG_LOG) log.warn("[LYNG_DEBUG] QuickDoc: compileWithMini failed: ${t.message}") sink.build()
null } ?: MiniScript(MiniRange(Pos(src, 1, 1), Pos(src, 1, 1)))
}
val haveMini = mini != null
if (mini == null) {
// Ensure we have a dummy script object to avoid NPE in downstream helpers that expect a MiniScript
mini = MiniScript(MiniRange(Pos(src, 1, 1), Pos(src, 1, 1)))
}
val source = src val source = src
// Try resolve to: function param at position, function/class/val declaration at position // Try resolve to: function param at position, function/class/val declaration at position
// 1) Check declarations whose name range contains offset // 1) Use unified declaration detection
if (haveMini) for (d in mini.declarations) { DocLookupUtils.findDeclarationAt(mini, offset, ident)?.let { (name, kind) ->
val s = source.offsetOf(d.nameStart) if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: matched declaration '$name' kind=$kind")
val e = (s + d.name.length).coerceAtMost(text.length) // Find the actual declaration object to render
if (offset in s until e) { for (d in mini.declarations) {
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: matched decl '${d.name}' kind=${d::class.simpleName}") if (d.name == name && source.offsetOf(d.nameStart) <= offset && source.offsetOf(d.nameStart) + d.name.length > offset) {
return renderDeclDoc(d) return renderDeclDoc(d)
}
// Handle members if it was a member
if (d is MiniClassDecl) {
for (m in d.members) {
if (m.name == name && source.offsetOf(m.nameStart) <= offset && source.offsetOf(m.nameStart) + m.name.length > offset) {
return when (m) {
is MiniMemberFunDecl -> renderMemberFunDoc(d.name, m)
is MiniMemberValDecl -> renderMemberValDoc(d.name, m)
is MiniInitDecl -> null
}
}
}
for (cf in d.ctorFields) {
if (cf.name == name && source.offsetOf(cf.nameStart) <= offset && source.offsetOf(cf.nameStart) + cf.name.length > offset) {
// Render as a member val
val mv = MiniMemberValDecl(
range = MiniRange(cf.nameStart, cf.nameStart), // dummy
name = cf.name,
mutable = cf.mutable,
type = cf.type,
doc = null,
nameStart = cf.nameStart
)
return renderMemberValDoc(d.name, mv)
}
}
for (cf in d.classFields) {
if (cf.name == name && source.offsetOf(cf.nameStart) <= offset && source.offsetOf(cf.nameStart) + cf.name.length > offset) {
// Render as a member val
val mv = MiniMemberValDecl(
range = MiniRange(cf.nameStart, cf.nameStart), // dummy
name = cf.name,
mutable = cf.mutable,
type = cf.type,
doc = null,
nameStart = cf.nameStart
)
return renderMemberValDoc(d.name, mv)
}
}
}
if (d is MiniEnumDecl) {
if (d.entries.contains(name) && offset >= source.offsetOf(d.range.start) && offset <= source.offsetOf(d.range.end)) {
// For enum constant, we don't have detailed docs in MiniAst yet, but we can render a title
return "<div class='doc-title'>enum constant ${d.name}.${name}</div>"
}
}
} }
} // Check parameters
// 2) Check parameters of functions for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
if (haveMini) for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) { for (p in fn.params) {
for (p in fn.params) { if (p.name == name && source.offsetOf(p.nameStart) <= offset && source.offsetOf(p.nameStart) + p.name.length > offset) {
val s = source.offsetOf(p.nameStart) return renderParamDoc(fn, p)
val e = (s + p.name.length).coerceAtMost(text.length) }
if (offset in s until e) {
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: matched param '${p.name}' in fun '${fn.name}'")
return renderParamDoc(fn, p)
} }
} }
} }
// 3) Member-context resolution first (dot immediately before identifier): handle literals and calls
// 3) usages in current file via Binder (resolves local variables, parameters, and classes)
try {
val binding = net.sergeych.lyng.binding.Binder.bind(text, mini)
val ref = binding.references.firstOrNull { offset in it.start until it.end }
if (ref != null) {
val sym = binding.symbols.firstOrNull { it.id == ref.symbolId }
if (sym != null) {
// Find local declaration that matches this symbol
val ds = mini.declarations.firstOrNull { decl ->
val s = source.offsetOf(decl.nameStart)
decl.name == sym.name && s == sym.declStart
}
if (ds != null) return renderDeclDoc(ds)
// Check parameters
for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
for (p in fn.params) {
val s = source.offsetOf(p.nameStart)
if (p.name == sym.name && s == sym.declStart) {
return renderParamDoc(fn, p)
}
}
}
// Check class members (fields/functions)
for (cls in mini.declarations.filterIsInstance<MiniClassDecl>()) {
for (m in cls.members) {
val s = source.offsetOf(m.nameStart)
if (m.name == sym.name && s == sym.declStart) {
return when (m) {
is MiniMemberFunDecl -> renderMemberFunDoc(cls.name, m)
is MiniMemberValDecl -> renderMemberValDoc(cls.name, m)
is MiniInitDecl -> null
}
}
}
for (cf in cls.ctorFields) {
val s = source.offsetOf(cf.nameStart)
if (cf.name == sym.name && s == sym.declStart) {
// Render as a member val
val mv = MiniMemberValDecl(
range = MiniRange(cf.nameStart, cf.nameStart), // dummy
name = cf.name,
mutable = cf.mutable,
type = cf.type,
doc = null,
nameStart = cf.nameStart
)
return renderMemberValDoc(cls.name, mv)
}
}
for (cf in cls.classFields) {
val s = source.offsetOf(cf.nameStart)
if (cf.name == sym.name && s == sym.declStart) {
// Render as a member val
val mv = MiniMemberValDecl(
range = MiniRange(cf.nameStart, cf.nameStart), // dummy
name = cf.name,
mutable = cf.mutable,
type = cf.type,
doc = null,
nameStart = cf.nameStart
)
return renderMemberValDoc(cls.name, mv)
}
}
}
}
}
} catch (e: Throwable) {
if (DEBUG_LOG) log.warn("[LYNG_DEBUG] QuickDoc: local binder resolution failed: ${e.message}")
}
// 4) Member-context resolution first (dot immediately before identifier): handle literals and calls
run { run {
val dotPos = TextCtx.findDotLeft(text, idRange.startOffset) val dotPos = TextCtx.findDotLeft(text, idRange.startOffset)
?: TextCtx.findDotLeft(text, offset) ?: TextCtx.findDotLeft(text, offset)
if (dotPos != null) { if (dotPos != null) {
// Build imported modules (MiniAst-derived if available, else lenient from text) and ensure stdlib is present // Build imported modules (MiniAst-derived if available, else lenient from text) and ensure stdlib is present
var importedModules = if (haveMini) DocLookupUtils.canonicalImportedModules(mini) else emptyList() val importedModules = DocLookupUtils.canonicalImportedModules(mini, text)
if (importedModules.isEmpty()) {
val fromText = extractImportsFromText(text)
importedModules = if (fromText.isEmpty()) listOf("lyng.stdlib") else fromText
}
if (!importedModules.contains("lyng.stdlib")) importedModules = importedModules + "lyng.stdlib"
// Try literal and call-based receiver inference around the dot // Try literal and call-based receiver inference around the dot
val i = TextCtx.prevNonWs(text, dotPos - 1) val i = TextCtx.prevNonWs(text, dotPos - 1)
@@ -154,7 +259,27 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
} else null } else null
} else null } else null
} }
else -> DocLookupUtils.guessClassFromCallBefore(text, dotPos, importedModules) else -> {
val guessed = DocLookupUtils.guessClassFromCallBefore(text, dotPos, importedModules)
if (guessed != null) guessed
else {
// handle this@Type or as Type
val i2 = TextCtx.prevNonWs(text, dotPos - 1)
if (i2 >= 0) {
val identRange = TextCtx.wordRangeAt(text, i2 + 1)
if (identRange != null) {
val id = text.substring(identRange.startOffset, identRange.endOffset)
val k = TextCtx.prevNonWs(text, identRange.startOffset - 1)
if (k >= 1 && text[k] == 's' && text[k-1] == 'a' && (k-1 == 0 || !text[k-2].isLetterOrDigit())) {
id
} else if (k >= 0 && text[k] == '@') {
val k2 = TextCtx.prevNonWs(text, k - 1)
if (k2 >= 3 && text.substring(k2 - 3, k2 + 1) == "this") id else null
} else null
} else null
} else null
}
}
} }
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: memberCtx dotPos=${dotPos} chBeforeDot='${if (dotPos>0) text[dotPos-1] else ' '}' classGuess=${className} imports=${importedModules}") if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: memberCtx dotPos=${dotPos} chBeforeDot='${if (dotPos>0) text[dotPos-1] else ' '}' classGuess=${className} imports=${importedModules}")
if (className != null) { if (className != null) {
@@ -163,6 +288,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
return when (member) { return when (member) {
is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) is MiniMemberFunDecl -> renderMemberFunDoc(owner, member)
is MiniMemberValDecl -> renderMemberValDoc(owner, member) is MiniMemberValDecl -> renderMemberValDoc(owner, member)
is MiniInitDecl -> null
} }
} }
log.info("[LYNG_DEBUG] QuickDoc: resolve failed for ${className}.${ident}") log.info("[LYNG_DEBUG] QuickDoc: resolve failed for ${className}.${ident}")
@@ -171,21 +297,14 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
} }
// 4) As a fallback, if the caret is on an identifier text that matches any declaration name, show that // 4) As a fallback, if the caret is on an identifier text that matches any declaration name, show that
if (haveMini) mini.declarations.firstOrNull { it.name == ident }?.let { mini.declarations.firstOrNull { it.name == ident }?.let {
log.info("[LYNG_DEBUG] QuickDoc: fallback by name '${it.name}' kind=${it::class.simpleName}") log.info("[LYNG_DEBUG] QuickDoc: fallback by name '${it.name}' kind=${it::class.simpleName}")
return renderDeclDoc(it) return renderDeclDoc(it)
} }
// 4) Consult BuiltinDocRegistry for imported modules (top-level and class members) // 4) Consult BuiltinDocRegistry for imported modules (top-level and class members)
// Canonicalize import names using ImportManager, as users may write shortened names (e.g., "io.fs") // Canonicalize import names using ImportManager, as users may write shortened names (e.g., "io.fs")
var importedModules = if (haveMini) DocLookupUtils.canonicalImportedModules(mini) else emptyList() var importedModules = DocLookupUtils.canonicalImportedModules(mini, text)
// If MiniAst failed or captured no imports, try a lightweight textual import scan
if (importedModules.isEmpty()) {
val fromText = extractImportsFromText(text)
if (fromText.isNotEmpty()) {
importedModules = fromText
}
}
// Always include stdlib as a fallback context // Always include stdlib as a fallback context
if (!importedModules.contains("lyng.stdlib")) importedModules = importedModules + "lyng.stdlib" if (!importedModules.contains("lyng.stdlib")) importedModules = importedModules + "lyng.stdlib"
// 4a) try top-level decls // 4a) try top-level decls
@@ -204,8 +323,9 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
} }
// Also allow values/consts // Also allow values/consts
docs.filterIsInstance<MiniValDecl>().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it) } docs.filterIsInstance<MiniValDecl>().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it) }
// And classes // And classes/enums
docs.filterIsInstance<MiniClassDecl>().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it) } docs.filterIsInstance<MiniClassDecl>().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it) }
docs.filterIsInstance<MiniEnumDecl>().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it) }
} }
// Defensive fallback: if nothing found and it's a well-known stdlib function, render minimal inline docs // Defensive fallback: if nothing found and it's a well-known stdlib function, render minimal inline docs
if (ident == "println" || ident == "print") { if (ident == "println" || ident == "print") {
@@ -224,6 +344,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
return when (member) { return when (member) {
is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) is MiniMemberFunDecl -> renderMemberFunDoc(owner, member)
is MiniMemberValDecl -> renderMemberValDoc(owner, member) is MiniMemberValDecl -> renderMemberValDoc(owner, member)
is MiniInitDecl -> null
} }
} }
} else { } else {
@@ -242,6 +363,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
return when (member) { return when (member) {
is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) is MiniMemberFunDecl -> renderMemberFunDoc(owner, member)
is MiniMemberValDecl -> renderMemberValDoc(owner, member) is MiniMemberValDecl -> renderMemberValDoc(owner, member)
is MiniInitDecl -> null
} }
} }
} else { } else {
@@ -254,6 +376,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
return when (member) { return when (member) {
is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) is MiniMemberFunDecl -> renderMemberFunDoc(owner, member)
is MiniMemberValDecl -> renderMemberValDoc(owner, member) is MiniMemberValDecl -> renderMemberValDoc(owner, member)
is MiniInitDecl -> null
} }
} }
} }
@@ -268,6 +391,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
return when (m) { return when (m) {
is MiniMemberFunDecl -> renderMemberFunDoc("String", m) is MiniMemberFunDecl -> renderMemberFunDoc("String", m)
is MiniMemberValDecl -> renderMemberValDoc("String", m) is MiniMemberValDecl -> renderMemberValDoc("String", m)
is MiniInitDecl -> null
} }
} }
} }
@@ -277,6 +401,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
return when (member) { return when (member) {
is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) is MiniMemberFunDecl -> renderMemberFunDoc(owner, member)
is MiniMemberValDecl -> renderMemberValDoc(owner, member) is MiniMemberValDecl -> renderMemberValDoc(owner, member)
is MiniInitDecl -> null
} }
} }
} }
@@ -287,25 +412,6 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
return null return null
} }
/**
* Very lenient import extractor for cases when MiniAst is unavailable.
* Looks for lines like `import xxx.yyy` and returns canonical module names
* (prefixing with `lyng.` if missing).
*/
private fun extractImportsFromText(text: String): List<String> {
val result = LinkedHashSet<String>()
val re = Regex("(?m)^\\s*import\\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)")
re.findAll(text).forEach { m ->
val raw = m.groupValues.getOrNull(1)?.trim().orEmpty()
if (raw.isNotEmpty()) {
val canon = if (raw.startsWith("lyng.")) raw else "lyng.$raw"
result.add(canon)
}
}
return result.toList()
}
// External docs registrars discovery via reflection to avoid hard dependencies on optional modules
private val externalDocsLoaded: Boolean by lazy { tryLoadExternalDocs() } private val externalDocsLoaded: Boolean by lazy { tryLoadExternalDocs() }
private fun ensureExternalDocsRegistered() { @Suppress("UNUSED_EXPRESSION") externalDocsLoaded } private fun ensureExternalDocsRegistered() { @Suppress("UNUSED_EXPRESSION") externalDocsLoaded }
@@ -347,15 +453,15 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val title = when (d) { val title = when (d) {
is MiniFunDecl -> "function ${d.name}${signatureOf(d)}" is MiniFunDecl -> "function ${d.name}${signatureOf(d)}"
is MiniClassDecl -> "class ${d.name}" is MiniClassDecl -> "class ${d.name}"
is MiniEnumDecl -> "enum ${d.name} { ${d.entries.joinToString(", ")} }"
is MiniValDecl -> if (d.mutable) "var ${d.name}${typeOf(d.type)}" else "val ${d.name}${typeOf(d.type)}" is MiniValDecl -> if (d.mutable) "var ${d.name}${typeOf(d.type)}" else "val ${d.name}${typeOf(d.type)}"
else -> d.name
} }
// Show full detailed documentation, not just the summary // Show full detailed documentation, not just the summary
val raw = d.doc?.raw val raw = d.doc?.raw
val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw) val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw)
val sb = StringBuilder() val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>") sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>")
if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc!!)) if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc))
return sb.toString() return sb.toString()
} }
@@ -376,7 +482,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw) val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw)
val sb = StringBuilder() val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>") sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>")
if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc!!)) if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc))
return sb.toString() return sb.toString()
} }
@@ -389,7 +495,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw) val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw)
val sb = StringBuilder() val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>") sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>")
if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc!!)) if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc))
return sb.toString() return sb.toString()
} }
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -35,6 +35,8 @@ import com.intellij.psi.codeStyle.CodeStyleManager
import net.sergeych.lyng.format.LyngFormatConfig import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.util.FormattingUtils.computeDesiredIndent
import net.sergeych.lyng.idea.util.FormattingUtils.findFirstNonWs
class LyngEnterHandler : EnterHandlerDelegate { class LyngEnterHandler : EnterHandlerDelegate {
private val log = Logger.getInstance(LyngEnterHandler::class.java) private val log = Logger.getInstance(LyngEnterHandler::class.java)
@@ -80,16 +82,39 @@ class LyngEnterHandler : EnterHandlerDelegate {
val trimmed = prevText.trimStart() val trimmed = prevText.trimStart()
// consider only code part before // comment // consider only code part before // comment
val code = trimmed.substringBefore("//").trim() val code = trimmed.substringBefore("//").trim()
if (code == "}") { if (code == "}" || code == "*/") {
// Previously we reindented the enclosed block on Enter after a lone '}'. // Adjust indent for the previous line if it's a block or comment closer
// Per new behavior, this action is now bound to typing '}' instead. val prevStart = doc.getLineStartOffset(prevLine)
// Keep Enter flow limited to indenting the new line only. if (file.context == null) {
try {
CodeStyleManager.getInstance(project).adjustLineIndent(file, prevStart)
} catch (e: Exception) {
log.warn("Failed to adjust line indent for previous line: ${e.message}")
}
}
// Fallback for previous line: manual application
val desiredPrev = computeDesiredIndent(project, doc, prevLine)
val lineStartPrev = doc.getLineStartOffset(prevLine)
val lineEndPrev = doc.getLineEndOffset(prevLine)
val firstNonWsPrev = findFirstNonWs(doc, lineStartPrev, lineEndPrev)
val currentIndentLenPrev = firstNonWsPrev - lineStartPrev
if (doc.getText(TextRange(lineStartPrev, lineStartPrev + currentIndentLenPrev)) != desiredPrev) {
WriteCommandAction.runWriteCommandAction(project) {
doc.replaceString(lineStartPrev, lineStartPrev + currentIndentLenPrev, desiredPrev)
}
}
} }
} }
// Adjust indent for the current (new) line // Adjust indent for the current (new) line
val currentStart = doc.getLineStartOffsetSafe(currentLine) val currentStart = doc.getLineStartOffsetSafe(currentLine)
val csm = CodeStyleManager.getInstance(project) if (file.context == null) {
csm.adjustLineIndent(file, currentStart) try {
CodeStyleManager.getInstance(project).adjustLineIndent(file, currentStart)
} catch (e: Exception) {
log.warn("Failed to adjust line indent for current line: ${e.message}")
}
}
// Fallback: if the platform didn't physically insert indentation, compute it from our formatter and apply // Fallback: if the platform didn't physically insert indentation, compute it from our formatter and apply
val lineStart = doc.getLineStartOffset(currentLine) val lineStart = doc.getLineStartOffset(currentLine)
@@ -159,35 +184,6 @@ class LyngEnterHandler : EnterHandlerDelegate {
caret.moveToOffset(target) caret.moveToOffset(target)
} }
private fun computeDesiredIndent(project: Project, doc: Document, line: Int): String {
val options = CodeStyle.getIndentOptions(project, doc)
val start = 0
val end = doc.getLineEndOffset(line)
val snippet = doc.getText(TextRange(start, end))
val isBlankLine = doc.getLineText(line).trim().isEmpty()
val snippetForCalc = if (isBlankLine) snippet + "x" else snippet
val cfg = LyngFormatConfig(
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
useTabs = options.USE_TAB_CHARACTER,
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
)
val formatted = LyngFormatter.reindent(snippetForCalc, cfg)
val lastNl = formatted.lastIndexOf('\n')
val lastLine = if (lastNl >= 0) formatted.substring(lastNl + 1) else formatted
val wsLen = lastLine.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) lastLine.length else it }
return lastLine.substring(0, wsLen)
}
private fun findFirstNonWs(doc: Document, start: Int, end: Int): Int {
var i = start
val text = doc.charsSequence
while (i < end) {
val ch = text[i]
if (ch != ' ' && ch != '\t') break
i++
}
return i
}
private fun Document.safeLineNumber(offset: Int): Int = private fun Document.safeLineNumber(offset: Int): Int =
getLineNumber(offset.coerceIn(0, textLength)) getLineNumber(offset.coerceIn(0, textLength))
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -32,31 +32,66 @@ import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.settings.LyngFormatterSettings import net.sergeych.lyng.idea.settings.LyngFormatterSettings
import net.sergeych.lyng.idea.util.FormattingUtils.computeDesiredIndent
import net.sergeych.lyng.idea.util.FormattingUtils.findFirstNonWs
class LyngTypedHandler : TypedHandlerDelegate() { class LyngTypedHandler : TypedHandlerDelegate() {
private val log = Logger.getInstance(LyngTypedHandler::class.java) private val log = Logger.getInstance(LyngTypedHandler::class.java)
override fun charTyped(c: Char, project: Project, editor: Editor, file: PsiFile): Result { override fun charTyped(c: Char, project: Project, editor: Editor, file: PsiFile): Result {
if (file.language != LyngLanguage) return Result.CONTINUE if (file.language != LyngLanguage) return Result.CONTINUE
if (c != '}') return Result.CONTINUE
if (c == '}') {
val doc = editor.document
PsiDocumentManager.getInstance(project).commitDocument(doc)
val doc = editor.document val offset = editor.caretModel.offset
PsiDocumentManager.getInstance(project).commitDocument(doc) val line = doc.getLineNumber((offset - 1).coerceAtLeast(0))
if (line < 0) return Result.CONTINUE
val offset = editor.caretModel.offset val rawLine = doc.getLineText(line)
val line = doc.getLineNumber((offset - 1).coerceAtLeast(0)) val code = rawLine.substringBefore("//").trim()
if (line < 0) return Result.CONTINUE if (code == "}") {
val settings = LyngFormatterSettings.getInstance(project)
val rawLine = doc.getLineText(line) if (settings.reindentClosedBlockOnEnter) {
val code = rawLine.substringBefore("//").trim() reindentClosedBlockAroundBrace(project, file, doc, line)
if (code == "}") { }
val settings = LyngFormatterSettings.getInstance(project) // After block reindent, adjust line indent to what platform thinks (no-op in many cases)
if (settings.reindentClosedBlockOnEnter) { val lineStart = doc.getLineStartOffset(line)
reindentClosedBlockAroundBrace(project, file, doc, line) if (file.context == null) {
try {
CodeStyleManager.getInstance(project).adjustLineIndent(file, lineStart)
} catch (e: Exception) {
log.warn("Failed to adjust line indent for current line: ${e.message}")
}
}
}
} else if (c == '/') {
val doc = editor.document
val offset = editor.caretModel.offset
if (offset >= 2 && doc.getText(TextRange(offset - 2, offset)) == "*/") {
PsiDocumentManager.getInstance(project).commitDocument(doc)
val line = doc.getLineNumber(offset - 1)
val lineStart = doc.getLineStartOffset(line)
if (file.context == null) {
try {
CodeStyleManager.getInstance(project).adjustLineIndent(file, lineStart)
} catch (e: Exception) {
log.warn("Failed to adjust line indent for comment: ${e.message}")
}
}
// Manual application fallback
val desired = computeDesiredIndent(project, doc, line)
val lineEnd = doc.getLineEndOffset(line)
val firstNonWs = findFirstNonWs(doc, lineStart, lineEnd)
val currentIndentLen = firstNonWs - lineStart
if (doc.getText(TextRange(lineStart, lineStart + currentIndentLen)) != desired) {
WriteCommandAction.runWriteCommandAction(project) {
doc.replaceString(lineStart, lineStart + currentIndentLen, desired)
}
}
} }
// After block reindent, adjust line indent to what platform thinks (no-op in many cases)
val lineStart = doc.getLineStartOffset(line)
CodeStyleManager.getInstance(project).adjustLineIndent(file, lineStart)
} }
return Result.CONTINUE return Result.CONTINUE
} }
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -24,9 +24,8 @@ import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.codeStyle.CommonCodeStyleSettings.IndentOptions import com.intellij.psi.codeStyle.CommonCodeStyleSettings.IndentOptions
import com.intellij.psi.codeStyle.lineIndent.LineIndentProvider import com.intellij.psi.codeStyle.lineIndent.LineIndentProvider
import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.util.FormattingUtils
/** /**
* Lightweight indentation provider for Lyng. * Lightweight indentation provider for Lyng.
@@ -45,8 +44,7 @@ class LyngLineIndentProvider : LineIndentProvider {
val options = CodeStyle.getIndentOptions(project, doc) val options = CodeStyle.getIndentOptions(project, doc)
val line = doc.getLineNumberSafe(offset) val line = doc.getLineNumberSafe(offset)
val indent = computeDesiredIndentFromCore(doc, line, options) return FormattingUtils.computeDesiredIndent(project, doc, line)
return indent
} }
override fun isSuitableFor(language: Language?): Boolean = language == null || language == LyngLanguage override fun isSuitableFor(language: Language?): Boolean = language == null || language == LyngLanguage
@@ -79,25 +77,4 @@ class LyngLineIndentProvider : LineIndentProvider {
return spaces / size return spaces / size
} }
private fun computeDesiredIndentFromCore(doc: Document, line: Int, options: IndentOptions): String {
// Build a minimal text consisting of all previous lines and the current line.
// Special case: when the current line is blank (newly created by Enter), compute the
// indent as if there was a non-whitespace character at line start (append a sentinel).
val start = 0
val end = doc.getLineEndOffset(line)
val snippet = doc.getText(TextRange(start, end))
val isBlankLine = doc.getLineText(line).trim().isEmpty()
val snippetForCalc = if (isBlankLine) snippet + "x" else snippet
val cfg = LyngFormatConfig(
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
useTabs = options.USE_TAB_CHARACTER,
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
)
val formatted = LyngFormatter.reindent(snippetForCalc, cfg)
// Grab the last line's leading whitespace as the indent for the current line
val lastNl = formatted.lastIndexOf('\n')
val lastLine = if (lastNl >= 0) formatted.substring(lastNl + 1) else formatted
val wsLen = lastLine.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) lastLine.length else it }
return lastLine.substring(0, wsLen)
}
} }
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -80,7 +80,14 @@ class LyngPreFormatProcessor : PreFormatProcessor {
val lineStart = doc.getLineStartOffset(line) val lineStart = doc.getLineStartOffset(line)
// adjustLineIndent delegates to our LineIndentProvider which computes // adjustLineIndent delegates to our LineIndentProvider which computes
// indentation from scratch; this is safe and idempotent // indentation from scratch; this is safe and idempotent
CodeStyleManager.getInstance(project).adjustLineIndent(file, lineStart) if (file.context == null) {
try {
CodeStyleManager.getInstance(project).adjustLineIndent(file, lineStart)
} catch (e: Exception) {
// Log as debug because this can be called many times during reformat
// and we don't want to spam warnings if it's a known platform issue with injections
}
}
// After indentation, update block/paren/bracket balances using the current line text // After indentation, update block/paren/bracket balances using the current line text
val lineEnd = doc.getLineEndOffset(line) val lineEnd = doc.getLineEndOffset(line)
@@ -34,7 +34,8 @@ class LyngLexer : LexerBase() {
private val keywords = setOf( private val keywords = setOf(
"fun", "val", "var", "class", "type", "import", "as", "fun", "val", "var", "class", "type", "import", "as",
"if", "else", "for", "while", "return", "true", "false", "null", "if", "else", "for", "while", "return", "true", "false", "null",
"when", "in", "is", "break", "continue", "try", "catch", "finally" "when", "in", "is", "break", "continue", "try", "catch", "finally",
"get", "set"
) )
override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) { override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) {
@@ -0,0 +1,109 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.navigation
import com.intellij.icons.AllIcons
import com.intellij.navigation.ItemPresentation
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiNameIdentifierOwner
import com.intellij.psi.impl.light.LightElement
import com.intellij.util.IncorrectOperationException
import net.sergeych.lyng.idea.LyngLanguage
import javax.swing.Icon
/**
* A light PSI element representing a Lyng declaration (function, class, enum, or variable).
* Used for navigation and to provide a stable anchor for "Find Usages".
*/
class LyngDeclarationElement(
private val nameElement: PsiElement,
private val name: String,
val kind: String = "declaration"
) : LightElement(nameElement.manager, LyngLanguage), PsiNameIdentifierOwner {
override fun getName(): String = name
override fun setName(name: String): PsiElement {
throw IncorrectOperationException("Renaming is not yet supported")
}
override fun getNameIdentifier(): PsiElement = nameElement
override fun getNavigationElement(): PsiElement = nameElement
override fun getTextRange(): TextRange = nameElement.textRange
override fun getContainingFile(): PsiFile = nameElement.containingFile
override fun isValid(): Boolean = nameElement.isValid
override fun getPresentation(): ItemPresentation {
return object : ItemPresentation {
override fun getPresentableText(): String = name
override fun getLocationString(): String {
val file = containingFile
val document = PsiDocumentManager.getInstance(file.project).getDocument(file)
val line = if (document != null) document.getLineNumber(textRange.startOffset) + 1 else "?"
val column = if (document != null) {
val lineStart = document.getLineStartOffset(document.getLineNumber(textRange.startOffset))
textRange.startOffset - lineStart + 1
} else "?"
return "${file.name}:$line:$column"
}
override fun getIcon(unused: Boolean): Icon {
return when (kind) {
"Function" -> AllIcons.Nodes.Function
"Class" -> AllIcons.Nodes.Class
"Enum" -> AllIcons.Nodes.Enum
"EnumConstant" -> AllIcons.Nodes.Enum
"Variable" -> AllIcons.Nodes.Variable
"Value" -> AllIcons.Nodes.Field
"Parameter" -> AllIcons.Nodes.Parameter
"Initializer" -> AllIcons.Nodes.Method
else -> AllIcons.Nodes.Property
}
}
}
}
override fun toString(): String = "$kind:$name"
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is LyngDeclarationElement) return false
return name == other.name && nameElement == other.nameElement
}
override fun isEquivalentTo(another: PsiElement?): Boolean {
if (this === another) return true
if (another == nameElement) return true
if (another is LyngDeclarationElement) {
return name == another.name && nameElement == another.nameElement
}
return super.isEquivalentTo(another)
}
override fun hashCode(): Int {
var result = nameElement.hashCode()
result = 31 * result + name.hashCode()
return result
}
}
@@ -0,0 +1,92 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.navigation
import com.intellij.lang.cacheBuilder.DefaultWordsScanner
import com.intellij.lang.cacheBuilder.WordsScanner
import com.intellij.lang.findUsages.FindUsagesProvider
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiElement
import com.intellij.psi.tree.TokenSet
import net.sergeych.lyng.idea.highlight.LyngLexer
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
import net.sergeych.lyng.idea.util.LyngAstManager
import net.sergeych.lyng.miniast.DocLookupUtils
class LyngFindUsagesProvider : FindUsagesProvider {
override fun getWordsScanner(): WordsScanner {
return DefaultWordsScanner(
LyngLexer(),
TokenSet.create(LyngTokenTypes.IDENTIFIER),
TokenSet.create(LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT),
TokenSet.create(LyngTokenTypes.STRING)
)
}
override fun canFindUsagesFor(psiElement: PsiElement): Boolean {
return psiElement is LyngDeclarationElement || isDeclaration(psiElement)
}
private fun isDeclaration(element: PsiElement): Boolean {
val file = element.containingFile ?: return false
val mini = LyngAstManager.getMiniAst(file) ?: return false
val offset = element.textRange.startOffset
val name = element.text ?: ""
return DocLookupUtils.findDeclarationAt(mini, offset, name) != null
}
override fun getHelpId(psiElement: PsiElement): String? = null
override fun getType(element: PsiElement): String {
if (element is LyngDeclarationElement) return element.kind
val file = element.containingFile ?: return "Lyng declaration"
val mini = LyngAstManager.getMiniAst(file) ?: return "Lyng declaration"
val info = DocLookupUtils.findDeclarationAt(mini, element.textRange.startOffset, element.text ?: "")
return info?.second ?: "Lyng declaration"
}
override fun getDescriptiveName(element: PsiElement): String {
if (element is LyngDeclarationElement) {
val file = element.containingFile
val document = PsiDocumentManager.getInstance(file.project).getDocument(file)
val line = if (document != null) document.getLineNumber(element.textRange.startOffset) + 1 else "?"
val column = if (document != null) {
val lineStart = document.getLineStartOffset(document.getLineNumber(element.textRange.startOffset))
element.textRange.startOffset - lineStart + 1
} else "?"
return "${element.name} (${file.name}:$line:$column)"
}
val file = element.containingFile ?: return element.text ?: "unknown"
val mini = LyngAstManager.getMiniAst(file) ?: return element.text ?: "unknown"
val info = DocLookupUtils.findDeclarationAt(mini, element.textRange.startOffset, element.text ?: "")
val document = PsiDocumentManager.getInstance(file.project).getDocument(file)
val line = if (document != null) document.getLineNumber(element.textRange.startOffset) + 1 else "?"
val column = if (document != null) {
val lineStart = document.getLineStartOffset(document.getLineNumber(element.textRange.startOffset))
element.textRange.startOffset - lineStart + 1
} else "?"
val name = info?.first ?: element.text ?: "unknown"
return "$name (${file.name}:$line:$column)"
}
override fun getNodeText(element: PsiElement, useFullName: Boolean): String {
return (element as? LyngDeclarationElement)?.name ?: element.text ?: "unknown"
}
}
@@ -0,0 +1,58 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.navigation
import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler
import com.intellij.openapi.editor.Editor
import com.intellij.psi.PsiElement
/**
* Ensures Ctrl+B (Go to Definition) works on Lyng identifiers by resolving through LyngPsiReference.
*/
class LyngGotoDeclarationHandler : GotoDeclarationHandler {
override fun getGotoDeclarationTargets(sourceElement: PsiElement?, offset: Int, editor: Editor?): Array<PsiElement>? {
if (sourceElement == null) return null
val allTargets = mutableListOf<PsiElement>()
// Find reference at the element or its parent (sometimes the identifier token is wrapped)
val ref = sourceElement.reference ?: sourceElement.parent?.reference
if (ref is LyngPsiReference) {
val resolved = ref.multiResolve(false)
allTargets.addAll(resolved.mapNotNull { it.element })
} else {
// Manual check if not picked up by reference (e.g. if contributor didn't run yet)
val manualRef = LyngPsiReference(sourceElement)
val manualResolved = manualRef.multiResolve(false)
allTargets.addAll(manualResolved.mapNotNull { it.element })
}
if (allTargets.isEmpty()) return null
// If there is only one target and it's equivalent to the source, return null.
// This allows IDEA to treat it as a declaration site and trigger "Show Usages".
if (allTargets.size == 1) {
val target = allTargets[0]
if (target == sourceElement || target.isEquivalentTo(sourceElement)) {
return null
}
}
return allTargets.toTypedArray()
}
}
@@ -0,0 +1,48 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.navigation
import com.intellij.icons.AllIcons
import com.intellij.ide.IconProvider
import com.intellij.psi.PsiElement
import net.sergeych.lyng.idea.util.LyngAstManager
import net.sergeych.lyng.miniast.DocLookupUtils
import javax.swing.Icon
class LyngIconProvider : IconProvider() {
override fun getIcon(element: PsiElement, flags: Int): Icon? {
val file = element.containingFile ?: return null
val mini = LyngAstManager.getMiniAst(file) ?: return null
val info = DocLookupUtils.findDeclarationAt(mini, element.textRange.startOffset, element.text ?: "")
if (info != null) {
return when (info.second) {
"Function" -> AllIcons.Nodes.Function
"Class" -> AllIcons.Nodes.Class
"Enum" -> AllIcons.Nodes.Enum
"EnumConstant" -> AllIcons.Nodes.Enum
"Variable" -> AllIcons.Nodes.Variable
"Value" -> AllIcons.Nodes.Field
"Parameter" -> AllIcons.Nodes.Parameter
"Initializer" -> AllIcons.Nodes.Method
else -> null
}
}
return null
}
}
@@ -0,0 +1,204 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.navigation
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.psi.*
import com.intellij.psi.search.FilenameIndex
import com.intellij.psi.search.GlobalSearchScope
import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.idea.util.LyngAstManager
import net.sergeych.lyng.idea.util.TextCtx
import net.sergeych.lyng.miniast.*
class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiElement>(element, TextRange(0, element.textLength)) {
override fun multiResolve(incompleteCode: Boolean): Array<ResolveResult> {
val file = element.containingFile
val text = file.text
val offset = element.textRange.startOffset
val name = element.text ?: ""
val results = mutableListOf<ResolveResult>()
val mini = LyngAstManager.getMiniAst(file) ?: return emptyArray()
val binding = LyngAstManager.getBinding(file)
// 1. Member resolution (obj.member)
val dotPos = TextCtx.findDotLeft(text, offset)
if (dotPos != null) {
val imported = DocLookupUtils.canonicalImportedModules(mini, text)
val receiverClass = DocLookupUtils.guessReceiverClassViaMini(mini, text, dotPos, imported, binding)
?: DocLookupUtils.guessReceiverClass(text, dotPos, imported, mini)
if (receiverClass != null) {
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, name, mini)
if (resolved != null) {
val owner = resolved.first
val member = resolved.second
// We need to find the actual PSI element for this member
val targetFile = findFileForClass(file.project, owner) ?: file
val targetMini = LyngAstManager.getMiniAst(targetFile)
if (targetMini != null) {
val targetSrc = targetMini.range.start.source
val off = targetSrc.offsetOf(member.nameStart)
targetFile.findElementAt(off)?.let {
val kind = when(member) {
is MiniMemberFunDecl -> "Function"
is MiniMemberValDecl -> if (member.mutable) "Variable" else "Value"
is MiniInitDecl -> "Initializer"
}
results.add(PsiElementResolveResult(LyngDeclarationElement(it, member.name, kind)))
}
}
}
}
// If we couldn't resolve exactly, we might still want to search globally but ONLY for members
if (results.isEmpty()) {
results.addAll(resolveGlobally(file.project, name, membersOnly = true))
}
} else {
// 2. Local resolution via Binder
if (binding != null) {
val ref = binding.references.firstOrNull { offset >= it.start && offset < it.end }
if (ref != null) {
val sym = binding.symbols.firstOrNull { it.id == ref.symbolId }
if (sym != null && sym.declStart >= 0) {
file.findElementAt(sym.declStart)?.let {
results.add(PsiElementResolveResult(LyngDeclarationElement(it, sym.name, sym.kind.name)))
}
}
}
}
// 3. Global project scan
// Only search globally if we haven't found a strong local match
if (results.isEmpty()) {
results.addAll(resolveGlobally(file.project, name))
}
}
// 4. Filter results to exclude duplicates
// Use a more robust de-duplication that prefers the raw element if multiple refer to the same thing
val filtered = mutableListOf<ResolveResult>()
for (res in results) {
val el = res.element ?: continue
val nav = if (el is LyngDeclarationElement) el.navigationElement else el
if (filtered.none { existing ->
val exEl = existing.element
val exNav = if (exEl is LyngDeclarationElement) exEl.navigationElement else exEl
exNav == nav || (exNav != null && exNav.isEquivalentTo(nav))
}) {
filtered.add(res)
}
}
return filtered.toTypedArray()
}
private fun findFileForClass(project: Project, className: String): PsiFile? {
val psiManager = PsiManager.getInstance(project)
// 1. Try file with matching name first (optimization)
val matchingFiles = FilenameIndex.getFilesByName(project, "$className.lyng", GlobalSearchScope.projectScope(project))
for (file in matchingFiles) {
val mini = LyngAstManager.getMiniAst(file) ?: continue
if (mini.declarations.any { (it is MiniClassDecl && it.name == className) || (it is MiniEnumDecl && it.name == className) }) {
return file
}
}
// 2. Fallback to full project scan
val allFiles = FilenameIndex.getAllFilesByExt(project, "lyng", GlobalSearchScope.projectScope(project))
for (vFile in allFiles) {
val file = psiManager.findFile(vFile) ?: continue
if (matchingFiles.contains(file)) continue // already checked
val mini = LyngAstManager.getMiniAst(file) ?: continue
if (mini.declarations.any { (it is MiniClassDecl && it.name == className) || (it is MiniEnumDecl && it.name == className) }) {
return file
}
}
return null
}
override fun resolve(): PsiElement? {
val results = multiResolve(false)
if (results.isEmpty()) return null
val target = results[0].element ?: return null
// If the target is equivalent to our source element, return the source element itself.
// This is crucial for IDEA to recognize we are already at the declaration site
// and trigger "Show Usages" instead of performing a no-op navigation.
if (target == element || target.isEquivalentTo(element)) {
return element
}
return target
}
private fun resolveGlobally(project: Project, name: String, membersOnly: Boolean = false): List<ResolveResult> {
val results = mutableListOf<ResolveResult>()
val files = FilenameIndex.getAllFilesByExt(project, "lyng", GlobalSearchScope.projectScope(project))
val psiManager = PsiManager.getInstance(project)
for (vFile in files) {
val file = psiManager.findFile(vFile) ?: continue
val mini = LyngAstManager.getMiniAst(file) ?: continue
val src = mini.range.start.source
fun addIfMatch(dName: String, nameStart: net.sergeych.lyng.Pos, dKind: String) {
if (dName == name) {
val off = src.offsetOf(nameStart)
file.findElementAt(off)?.let {
results.add(PsiElementResolveResult(LyngDeclarationElement(it, dName, dKind)))
}
}
}
for (d in mini.declarations) {
if (!membersOnly) {
val dKind = when(d) {
is net.sergeych.lyng.miniast.MiniFunDecl -> "Function"
is net.sergeych.lyng.miniast.MiniClassDecl -> "Class"
is net.sergeych.lyng.miniast.MiniEnumDecl -> "Enum"
is net.sergeych.lyng.miniast.MiniValDecl -> if (d.mutable) "Variable" else "Value"
}
addIfMatch(d.name, d.nameStart, dKind)
}
// Check members of classes and enums
val members = when(d) {
is MiniClassDecl -> d.members
is MiniEnumDecl -> DocLookupUtils.enumToSyntheticClass(d).members
else -> emptyList()
}
for (m in members) {
val mKind = when(m) {
is net.sergeych.lyng.miniast.MiniMemberFunDecl -> "Function"
is net.sergeych.lyng.miniast.MiniMemberValDecl -> if (m.mutable) "Variable" else "Value"
is net.sergeych.lyng.miniast.MiniInitDecl -> "Initializer"
}
addIfMatch(m.name, m.nameStart, mKind)
}
}
}
return results
}
override fun getVariants(): Array<Any> = emptyArray()
}
@@ -0,0 +1,54 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.navigation
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.*
import com.intellij.util.ProcessingContext
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
import net.sergeych.lyng.idea.util.LyngAstManager
import net.sergeych.lyng.miniast.DocLookupUtils
class LyngPsiReferenceContributor : PsiReferenceContributor() {
override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) {
registrar.registerReferenceProvider(
PlatformPatterns.psiElement().withLanguage(LyngLanguage),
object : PsiReferenceProvider() {
override fun getReferencesByElement(
element: PsiElement,
context: ProcessingContext
): Array<PsiReference> {
if (element.node.elementType == LyngTokenTypes.IDENTIFIER) {
val file = element.containingFile
val mini = LyngAstManager.getMiniAst(file)
if (mini != null) {
val offset = element.textRange.startOffset
val name = element.text ?: ""
if (DocLookupUtils.findDeclarationAt(mini, offset, name) != null) {
return PsiReference.EMPTY_ARRAY
}
}
return arrayOf(LyngPsiReference(element))
}
return PsiReference.EMPTY_ARRAY
}
}
)
}
}
@@ -0,0 +1,62 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.util
import com.intellij.application.options.CodeStyle
import com.intellij.openapi.editor.Document
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
object FormattingUtils {
fun computeDesiredIndent(project: Project, doc: Document, line: Int): String {
val options = CodeStyle.getIndentOptions(project, doc)
val start = 0
val end = doc.getLineEndOffset(line)
val snippet = doc.getText(TextRange(start, end))
val lineText = if (line < doc.lineCount) {
val ls = doc.getLineStartOffset(line)
val le = doc.getLineEndOffset(line)
doc.getText(TextRange(ls, le))
} else ""
val isBlankLine = lineText.trim().isEmpty()
val snippetForCalc = if (isBlankLine) snippet + "x" else snippet
val cfg = LyngFormatConfig(
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
useTabs = options.USE_TAB_CHARACTER,
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
)
val formatted = LyngFormatter.reindent(snippetForCalc, cfg)
val lastNl = formatted.lastIndexOf('\n')
val lastLine = if (lastNl >= 0) formatted.substring(lastNl + 1) else formatted
val wsLen = lastLine.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) lastLine.length else it }
return lastLine.substring(0, wsLen)
}
fun findFirstNonWs(doc: Document, start: Int, end: Int): Int {
var i = start
val text = doc.charsSequence
while (i < end) {
val ch = text[i]
if (ch != ' ' && ch != '\t') break
i++
}
return i
}
}
@@ -0,0 +1,84 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.util
import com.intellij.openapi.util.Key
import com.intellij.psi.PsiFile
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Source
import net.sergeych.lyng.binding.Binder
import net.sergeych.lyng.binding.BindingSnapshot
import net.sergeych.lyng.miniast.MiniAstBuilder
import net.sergeych.lyng.miniast.MiniScript
object LyngAstManager {
private val MINI_KEY = Key.create<MiniScript>("lyng.mini.cache")
private val BINDING_KEY = Key.create<BindingSnapshot>("lyng.binding.cache")
private val STAMP_KEY = Key.create<Long>("lyng.mini.cache.stamp")
fun getMiniAst(file: PsiFile): MiniScript? {
val doc = file.viewProvider.document ?: return null
val stamp = doc.modificationStamp
val prevStamp = file.getUserData(STAMP_KEY)
val cached = file.getUserData(MINI_KEY)
if (cached != null && prevStamp != null && prevStamp == stamp) return cached
val text = doc.text
val sink = MiniAstBuilder()
val built = try {
val provider = IdeLenientImportProvider.create()
val src = Source(file.name, text)
runBlocking { Compiler.compileWithMini(src, provider, sink) }
sink.build()
} catch (_: Throwable) {
sink.build()
}
if (built != null) {
file.putUserData(MINI_KEY, built)
file.putUserData(STAMP_KEY, stamp)
// Invalidate binding too
file.putUserData(BINDING_KEY, null)
}
return built
}
fun getBinding(file: PsiFile): BindingSnapshot? {
val doc = file.viewProvider.document ?: return null
val stamp = doc.modificationStamp
val prevStamp = file.getUserData(STAMP_KEY)
val cached = file.getUserData(BINDING_KEY)
if (cached != null && prevStamp != null && prevStamp == stamp) return cached
val mini = getMiniAst(file) ?: return null
val text = doc.text
val binding = try {
Binder.bind(text, mini)
} catch (_: Throwable) {
null
}
if (binding != null) {
file.putUserData(BINDING_KEY, binding)
// stamp is already set by getMiniAst
}
return binding
}
}
@@ -1,5 +1,5 @@
<!-- <!--
~ Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com ~ Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
~ ~
~ Licensed under the Apache License, Version 2.0 (the "License"); ~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License. ~ you may not use this file except in compliance with the License.
@@ -96,6 +96,12 @@
<!-- If targeting SDKs with stable RawText API, the EP below can be enabled instead: --> <!-- If targeting SDKs with stable RawText API, the EP below can be enabled instead: -->
<!-- <copyPastePreProcessor implementation="net.sergeych.lyng.idea.editor.LyngCopyPastePreProcessor"/> --> <!-- <copyPastePreProcessor implementation="net.sergeych.lyng.idea.editor.LyngCopyPastePreProcessor"/> -->
<!-- Navigation and Find Usages -->
<psi.referenceContributor language="Lyng" implementation="net.sergeych.lyng.idea.navigation.LyngPsiReferenceContributor"/>
<gotoDeclarationHandler implementation="net.sergeych.lyng.idea.navigation.LyngGotoDeclarationHandler"/>
<lang.findUsagesProvider language="Lyng" implementationClass="net.sergeych.lyng.idea.navigation.LyngFindUsagesProvider"/>
<iconProvider implementation="net.sergeych.lyng.idea.navigation.LyngIconProvider"/>
</extensions> </extensions>
<actions/> <actions/>
+4 -8
View File
@@ -38,7 +38,7 @@ kotlin {
publishLibraryVariants("release") publishLibraryVariants("release")
@OptIn(ExperimentalKotlinGradlePluginApi::class) @OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions { compilerOptions {
jvmTarget.set(JvmTarget.JVM_11) jvmTarget.set(JvmTarget.JVM_17)
} }
} }
iosX64() iosX64()
@@ -71,11 +71,7 @@ kotlin {
sourceSets { sourceSets {
all { all {
languageSettings.optIn("kotlin.ExperimentalUnsignedTypes") languageSettings.optIn("kotlin.ExperimentalUnsignedTypes")
// languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") languageSettings.optIn("kotlin.time.ExperimentalTime")
// Correct opt-in markers for coroutines
// languageSettings.optIn("kotlinx.coroutines.DelicateCoroutinesApi")
// languageSettings.optIn("kotlin.contracts.ExperimentalContracts")
// languageSettings.optIn("kotlinx.coroutines.FlowPreview")
} }
val commonMain by getting { val commonMain by getting {
dependencies { dependencies {
@@ -115,8 +111,8 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_17
} }
lint { lint {
// Prevent Android Lint from failing the build due to Kotlin toolchain // Prevent Android Lint from failing the build due to Kotlin toolchain
@@ -163,7 +163,7 @@ private suspend fun buildFsModule(module: ModuleScope, policy: FsAccessPolicy) {
fsGuard { fsGuard {
val self = this.thisObj as ObjPath val self = this.thisObj as ObjPath
val m = self.ensureMetadata() val m = self.ensureMetadata()
m.createdAtMillis?.let { ObjInstant(kotlinx.datetime.Instant.fromEpochMilliseconds(it)) } ?: ObjNull m.createdAtMillis?.let { ObjInstant(kotlin.time.Instant.fromEpochMilliseconds(it)) } ?: ObjNull
} }
} }
// createdAtMillis(): Int? — milliseconds since epoch or null // createdAtMillis(): Int? — milliseconds since epoch or null
+17 -5
View File
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych" group = "net.sergeych"
version = "1.0.7-SNAPSHOT" version = "1.1.0-rc"
// Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below // Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below
@@ -51,7 +51,7 @@ kotlin {
publishLibraryVariants("release") publishLibraryVariants("release")
@OptIn(ExperimentalKotlinGradlePluginApi::class) @OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions { compilerOptions {
jvmTarget.set(JvmTarget.JVM_11) jvmTarget.set(JvmTarget.JVM_17)
} }
} }
iosX64() iosX64()
@@ -89,16 +89,16 @@ kotlin {
languageSettings.optIn("kotlinx.coroutines.DelicateCoroutinesApi") languageSettings.optIn("kotlinx.coroutines.DelicateCoroutinesApi")
languageSettings.optIn("kotlin.contracts.ExperimentalContracts") languageSettings.optIn("kotlin.contracts.ExperimentalContracts")
languageSettings.optIn("kotlinx.coroutines.FlowPreview") languageSettings.optIn("kotlinx.coroutines.FlowPreview")
languageSettings.optIn("kotlin.time.ExperimentalTime")
} }
val commonMain by getting { val commonMain by getting {
kotlin.srcDir("$buildDir/generated/buildConfig/commonMain/kotlin") kotlin.srcDir("$buildDir/generated/buildConfig/commonMain/kotlin")
dependencies { dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
//put your multiplatform dependencies here //put your multiplatform dependencies here
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
api(libs.mp.bintools) api(libs.mp.bintools)
api("net.sergeych:mp_stools:1.5.2")
} }
} }
val commonTest by getting { val commonTest by getting {
@@ -116,6 +116,18 @@ kotlin {
} }
} }
android {
namespace = "net.sergeych.lynglib"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
// ---- Build-time generation of stdlib text from .lyng files into a Kotlin constant ---- // ---- Build-time generation of stdlib text from .lyng files into a Kotlin constant ----
// Implemented as a proper task type compatible with Gradle Configuration Cache // Implemented as a proper task type compatible with Gradle Configuration Cache
@@ -49,12 +49,14 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
arguments: Arguments = scope.args, arguments: Arguments = scope.args,
defaultAccessType: AccessType = AccessType.Var, defaultAccessType: AccessType = AccessType.Var,
defaultVisibility: Visibility = Visibility.Public, defaultVisibility: Visibility = Visibility.Public,
declaringClass: net.sergeych.lyng.obj.ObjClass? = scope.currentClassCtx
) { ) {
fun assign(a: Item, value: Obj) { fun assign(a: Item, value: Obj) {
scope.addItem(a.name, (a.accessType ?: defaultAccessType).isMutable, scope.addItem(a.name, (a.accessType ?: defaultAccessType).isMutable,
value.byValueCopy(), value.byValueCopy(),
a.visibility ?: defaultVisibility, a.visibility ?: defaultVisibility,
recordType = ObjRecord.Type.Argument) recordType = ObjRecord.Type.Argument,
declaringClass = declaringClass)
} }
// Prepare positional args and parameter count, handle tail-block binding // Prepare positional args and parameter count, handle tail-block binding
@@ -58,17 +58,25 @@ class ClosureScope(val callScope: Scope, val closureScope: Scope) :
?.instanceScope ?.instanceScope
?.objects ?.objects
?.get(name) ?.get(name)
?.let { return it } ?.let { rec ->
closureScope.thisObj.objClass.getInstanceMemberOrNull(name)?.let { return it } if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) return rec
}
findExtension(closureScope.thisObj.objClass, name)?.let { return it }
closureScope.thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) return rec
}
// 3) Closure scope chain (locals/parents + members), ignore ClosureScope overrides to prevent recursion // 3) Closure scope chain (locals/parents + members), ignore ClosureScope overrides to prevent recursion
closureScope.chainLookupWithMembers(name)?.let { return it } closureScope.chainLookupWithMembers(name, currentClassCtx)?.let { return it }
// 4) Caller `this` members // 4) Caller `this` members
callScope.thisObj.objClass.getInstanceMemberOrNull(name)?.let { return it } findExtension(callScope.thisObj.objClass, name)?.let { return it }
callScope.thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) return rec
}
// 5) Caller chain (locals/parents + members) // 5) Caller chain (locals/parents + members)
callScope.chainLookupWithMembers(name)?.let { return it } callScope.chainLookupWithMembers(name, currentClassCtx)?.let { return it }
// 6) Module pseudo-symbols (e.g., __PACKAGE__) — walk caller ancestry and query ModuleScope directly // 6) Module pseudo-symbols (e.g., __PACKAGE__) — walk caller ancestry and query ModuleScope directly
if (name.startsWith("__")) { if (name.startsWith("__")) {
@@ -20,5 +20,7 @@ package net.sergeych.lyng
sealed class CodeContext { sealed class CodeContext {
class Module(@Suppress("unused") val packageName: String?): CodeContext() class Module(@Suppress("unused") val packageName: String?): CodeContext()
class Function(val name: String): CodeContext() class Function(val name: String): CodeContext()
class ClassBody(val name: String): CodeContext() class ClassBody(val name: String): CodeContext() {
val pendingInitializations = mutableMapOf<String, Pos>()
}
} }
File diff suppressed because it is too large. Load diff
@@ -25,11 +25,12 @@ class CompilerContext(val tokens: List<Token>) {
var loopLevel = 0 var loopLevel = 0
inline fun <T> parseLoop(f: () -> T): Pair<Boolean, T> { inline fun <T> parseLoop(f: () -> T): Pair<Boolean, T> {
if (++loopLevel == 0) breakFound = false val oldBreakFound = breakFound
breakFound = false
val result = f() val result = f()
return Pair(breakFound, result).also { val currentBreakFound = breakFound
--loopLevel breakFound = oldBreakFound || currentBreakFound
} return Pair(currentBreakFound, result)
} }
var currentIndex = 0 var currentIndex = 0
@@ -108,7 +109,6 @@ class CompilerContext(val tokens: List<Token>) {
val t = next() val t = next()
return if (t.type != tokenType) { return if (t.type != tokenType) {
if (!isOptional) { if (!isOptional) {
println("unexpected: $t (needed $tokenType)")
throw ScriptError(t.pos, errorMessage) throw ScriptError(t.pos, errorMessage)
} else { } else {
previous() previous()
@@ -45,19 +45,36 @@ class ModuleScope(
if (record.visibility.isPublic) { if (record.visibility.isPublic) {
val newName = symbols?.let { ss: Map<String, String> -> val newName = symbols?.let { ss: Map<String, String> ->
ss[symbol] ss[symbol]
?.also { symbolsToImport!!.remove(it) } ?.also { symbolsToImport!!.remove(symbol) }
?: scope.raiseError("internal error: symbol $symbol not found though the module is cached") ?: return@let null
} ?: symbol } ?: if (symbols == null) symbol else null
val existing = scope.objects[newName]
if (existing != null ) { if (newName != null) {
if (existing.importedFrom != record.importedFrom) val existing = scope.objects[newName]
scope.raiseError("symbol ${existing.importedFrom?.packageName}.$newName already exists, redefinition on import is not allowed") if (existing != null) {
// already imported if (existing.importedFrom != record.importedFrom)
scope.raiseError("symbol ${existing.importedFrom?.packageName}.$newName already exists, redefinition on import is not allowed")
// already imported
} else {
// when importing records, we keep track of its package (not otherwise needed)
if (record.importedFrom == null) record.importedFrom = this
scope.objects[newName] = record
}
} }
else { }
// when importing records, we keep track of its package (not otherwise needed) }
if (record.importedFrom == null) record.importedFrom = this for ((cls, map) in this.extensions) {
scope.objects[newName] = record for ((symbol, record) in map) {
if (record.visibility.isPublic) {
val newName = symbols?.let { ss: Map<String, String> ->
ss[symbol]
?.also { symbolsToImport!!.remove(symbol) }
?: return@let null
} ?: if (symbols == null) symbol else null
if (newName != null) {
scope.addExtension(cls, newName, record)
}
} }
} }
} }
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -324,15 +324,14 @@ private class Parser(fromPos: Pos) {
} }
'?' -> { '?' -> {
when (currentChar.also { pos.advance() }) { when (currentChar) {
':' -> Token("??", from, Token.Type.ELVIS) ':' -> { pos.advance(); Token("?:", from, Token.Type.ELVIS) }
'?' -> Token("??", from, Token.Type.ELVIS) '?' -> { pos.advance(); Token("??", from, Token.Type.ELVIS) }
'.' -> Token("?.", from, Token.Type.NULL_COALESCE) '.' -> { pos.advance(); Token("?.", from, Token.Type.NULL_COALESCE) }
'[' -> Token("?(", from, Token.Type.NULL_COALESCE_INDEX) '[' -> { pos.advance(); Token("?[", from, Token.Type.NULL_COALESCE_INDEX) }
'(' -> Token("?(", from, Token.Type.NULL_COALESCE_INVOKE) '(' -> { pos.advance(); Token("?(", from, Token.Type.NULL_COALESCE_INVOKE) }
'{' -> Token("?{", from, Token.Type.NULL_COALESCE_BLOCKINVOKE) '{' -> { pos.advance(); Token("?{", from, Token.Type.NULL_COALESCE_BLOCKINVOKE) }
else -> { else -> {
pos.back()
Token("?", from, Token.Type.QUESTION) Token("?", from, Token.Type.QUESTION)
} }
} }
@@ -62,6 +62,30 @@ open class Scope(
*/ */
internal val localBindings: MutableMap<String, ObjRecord> = mutableMapOf() internal val localBindings: MutableMap<String, ObjRecord> = mutableMapOf()
internal val extensions: MutableMap<ObjClass, MutableMap<String, ObjRecord>> = mutableMapOf()
fun addExtension(cls: ObjClass, name: String, record: ObjRecord) {
extensions.getOrPut(cls) { mutableMapOf() }[name] = record
}
internal fun findExtension(receiverClass: ObjClass, name: String): ObjRecord? {
var s: Scope? = this
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) break
// Proximity rule: check all extensions in the current scope before going to parent.
// Priority within scope: more specific class in MRO wins.
for (cls in receiverClass.mro) {
s.extensions[cls]?.get(name)?.let { return it }
}
if (s is ClosureScope) {
s.closureScope.findExtension(receiverClass, name)?.let { return it }
}
s = s.parent
}
return null
}
/** Debug helper: ensure assigning [candidateParent] does not create a structural cycle. */ /** Debug helper: ensure assigning [candidateParent] does not create a structural cycle. */
private fun ensureNoCycle(candidateParent: Scope?) { private fun ensureNoCycle(candidateParent: Scope?) {
if (candidateParent == null) return if (candidateParent == null) return
@@ -82,10 +106,17 @@ open class Scope(
* intertwined closure frames. They traverse the plain parent chain and consult only locals * intertwined closure frames. They traverse the plain parent chain and consult only locals
* and bindings of each frame. Instance/class member fallback must be decided by the caller. * and bindings of each frame. Instance/class member fallback must be decided by the caller.
*/ */
private fun tryGetLocalRecord(s: Scope, name: String): ObjRecord? { private fun tryGetLocalRecord(s: Scope, name: String, caller: net.sergeych.lyng.obj.ObjClass?): ObjRecord? {
s.objects[name]?.let { return it } s.objects[name]?.let { rec ->
s.localBindings[name]?.let { return it } if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
s.getSlotIndexOf(name)?.let { return s.getSlotRecord(it) } }
s.localBindings[name]?.let { rec ->
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
}
s.getSlotIndexOf(name)?.let { idx ->
val rec = s.getSlotRecord(idx)
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
}
return null return null
} }
@@ -95,7 +126,7 @@ open class Scope(
val visited = HashSet<Long>(4) val visited = HashSet<Long>(4)
while (s != null) { while (s != null) {
if (!visited.add(s.frameId)) return null if (!visited.add(s.frameId)) return null
tryGetLocalRecord(s, name)?.let { return it } tryGetLocalRecord(s, name, currentClassCtx)?.let { return it }
s = s.parent s = s.parent
} }
return null return null
@@ -110,17 +141,22 @@ open class Scope(
*/ */
internal fun baseGetIgnoreClosure(name: String): ObjRecord? { internal fun baseGetIgnoreClosure(name: String): ObjRecord? {
// 1) locals/bindings in this frame // 1) locals/bindings in this frame
tryGetLocalRecord(this, name)?.let { return it } tryGetLocalRecord(this, name, currentClassCtx)?.let { return it }
// 2) walk parents for plain locals/bindings only // 2) walk parents for plain locals/bindings only
var s = parent var s = parent
val visited = HashSet<Long>(4) val visited = HashSet<Long>(4)
while (s != null) { while (s != null) {
if (!visited.add(s.frameId)) return null if (!visited.add(s.frameId)) return null
tryGetLocalRecord(s, name)?.let { return it } tryGetLocalRecord(s, name, currentClassCtx)?.let { return it }
s = s.parent s = s.parent
} }
// 3) fallback to instance/class members of this frame's thisObj // 3) fallback to instance/class members of this frame's thisObj
return thisObj.objClass.getInstanceMemberOrNull(name) for (cls in thisObj.objClass.mro) {
this.extensions[cls]?.get(name)?.let { return it }
}
return thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) rec else null
}
} }
/** /**
@@ -130,13 +166,18 @@ open class Scope(
* This completely avoids invoking overridden `get` implementations, preventing * This completely avoids invoking overridden `get` implementations, preventing
* ping-pong recursion between `ClosureScope` frames. * ping-pong recursion between `ClosureScope` frames.
*/ */
internal fun chainLookupWithMembers(name: String): ObjRecord? { internal fun chainLookupWithMembers(name: String, caller: net.sergeych.lyng.obj.ObjClass? = currentClassCtx): ObjRecord? {
var s: Scope? = this var s: Scope? = this
val visited = HashSet<Long>(4) val visited = HashSet<Long>(4)
while (s != null) { while (s != null) {
if (!visited.add(s.frameId)) return null if (!visited.add(s.frameId)) return null
tryGetLocalRecord(s, name)?.let { return it } tryGetLocalRecord(s, name, caller)?.let { return it }
s.thisObj.objClass.getInstanceMemberOrNull(name)?.let { return it } for (cls in s.thisObj.objClass.mro) {
s.extensions[cls]?.get(name)?.let { return it }
}
s.thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
}
s = s.parent s = s.parent
} }
return null return null
@@ -152,6 +193,10 @@ open class Scope(
// copy locals and bindings // copy locals and bindings
snap.objects.putAll(this.objects) snap.objects.putAll(this.objects)
snap.localBindings.putAll(this.localBindings) snap.localBindings.putAll(this.localBindings)
// copy extensions
for ((cls, map) in extensions) {
snap.extensions[cls] = map.toMutableMap()
}
// copy slots map preserving indices // copy slots map preserving indices
if (this.slotCount() > 0) { if (this.slotCount() > 0) {
var i = 0 var i = 0
@@ -217,6 +262,9 @@ open class Scope(
fun raiseClassCastError(msg: String): Nothing = raiseError(ObjClassCastException(this, msg)) fun raiseClassCastError(msg: String): Nothing = raiseError(ObjClassCastException(this, msg))
fun raiseUnset(message: String = "property is unset (not initialized)"): Nothing =
raiseError(ObjUnsetException(this, message))
@Suppress("unused") @Suppress("unused")
fun raiseSymbolNotFound(name: String): Nothing = fun raiseSymbolNotFound(name: String): Nothing =
raiseError(ObjSymbolNotDefinedException(this, "symbol is not defined: $name")) raiseError(ObjSymbolNotDefinedException(this, "symbol is not defined: $name"))
@@ -273,13 +321,19 @@ open class Scope(
if (name == "this") thisObj.asReadonly if (name == "this") thisObj.asReadonly
else { else {
// Prefer direct locals/bindings declared in this frame // Prefer direct locals/bindings declared in this frame
(objects[name] (objects[name]?.let { rec ->
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) rec else null
}
// Then, check known local bindings in this frame (helps after suspension) // Then, check known local bindings in this frame (helps after suspension)
?: localBindings[name] ?: localBindings[name]?.let { rec ->
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) rec else null
}
// Walk up ancestry // Walk up ancestry
?: parent?.get(name) ?: parent?.get(name)
// Finally, fallback to class members on thisObj // Finally, fallback to class members on thisObj
?: thisObj.objClass.getInstanceMemberOrNull(name) ?: thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) rec else null
}
) )
} }
@@ -297,6 +351,10 @@ open class Scope(
return idx return idx
} }
fun updateSlotFor(name: String, record: ObjRecord) {
nameToSlot[name]?.let { slots[it] = record }
}
/** /**
* Reset this scope instance so it can be safely reused as a fresh child frame. * Reset this scope instance so it can be safely reused as a fresh child frame.
* Clears locals and slots, assigns new frameId, and sets parent/args/pos/thisObj. * Clears locals and slots, assigns new frameId, and sets parent/args/pos/thisObj.
@@ -313,6 +371,7 @@ open class Scope(
slots.clear() slots.clear()
nameToSlot.clear() nameToSlot.clear()
localBindings.clear() localBindings.clear()
extensions.clear()
// Now safe to validate and re-parent // Now safe to validate and re-parent
ensureNoCycle(parent) ensureNoCycle(parent)
this.parent = parent this.parent = parent
@@ -375,6 +434,7 @@ open class Scope(
name: String, name: String,
value: Obj, value: Obj,
visibility: Visibility = Visibility.Public, visibility: Visibility = Visibility.Public,
writeVisibility: Visibility? = null,
recordType: ObjRecord.Type = ObjRecord.Type.Other recordType: ObjRecord.Type = ObjRecord.Type.Other
): ObjRecord = ): ObjRecord =
objects[name]?.let { objects[name]?.let {
@@ -389,16 +449,18 @@ open class Scope(
callScope.localBindings[name] = it callScope.localBindings[name] = it
} }
it it
} ?: addItem(name, true, value, visibility, recordType) } ?: addItem(name, true, value, visibility, writeVisibility, recordType)
fun addItem( fun addItem(
name: String, name: String,
isMutable: Boolean, isMutable: Boolean,
value: Obj, value: Obj,
visibility: Visibility = Visibility.Public, visibility: Visibility = Visibility.Public,
recordType: ObjRecord.Type = ObjRecord.Type.Other writeVisibility: Visibility? = null,
recordType: ObjRecord.Type = ObjRecord.Type.Other,
declaringClass: net.sergeych.lyng.obj.ObjClass? = currentClassCtx
): ObjRecord { ): ObjRecord {
val rec = ObjRecord(value, isMutable, visibility, declaringClass = currentClassCtx, type = recordType) val rec = ObjRecord(value, isMutable, visibility, writeVisibility, declaringClass = declaringClass, type = recordType)
objects[name] = rec objects[name] = rec
// Index this binding within the current frame to help resolve locals across suspension // Index this binding within the current frame to help resolve locals across suspension
localBindings[name] = rec localBindings[name] = rec
@@ -415,9 +477,12 @@ open class Scope(
callScope.allocateSlotFor(name, rec) callScope.allocateSlotFor(name, rec)
} }
} }
// Map to a slot for fast local access (if not already mapped) // Map to a slot for fast local access (ensure consistency)
if (getSlotIndexOf(name) == null) { val idx = getSlotIndexOf(name)
if (idx == null) {
allocateSlotFor(name, rec) allocateSlotFor(name, rec)
} else {
slots[idx] = rec
} }
return rec return rec
} }
@@ -428,13 +493,13 @@ open class Scope(
} }
inline fun addVoidFn(vararg names: String, crossinline fn: suspend Scope.() -> Unit) { inline fun addVoidFn(vararg names: String, crossinline fn: suspend Scope.() -> Unit) {
addFn<ObjVoid>(*names) { addFn(*names) {
fn(this) fn(this)
ObjVoid ObjVoid
} }
} }
inline fun <reified T : Obj> addFn(vararg names: String, crossinline fn: suspend Scope.() -> T) { fun addFn(vararg names: String, fn: suspend Scope.() -> Obj) {
val newFn = object : Statement() { val newFn = object : Statement() {
override val pos: Pos = Pos.builtIn override val pos: Pos = Pos.builtIn
@@ -516,6 +581,23 @@ open class Scope(
open fun applyClosure(closure: Scope): Scope = ClosureScope(this, closure) open fun applyClosure(closure: Scope): Scope = ClosureScope(this, closure)
/**
* Resolve and evaluate a qualified identifier exactly as compiled code would.
* For input like `A.B.C`, it builds the same ObjRef chain the compiler emits:
* `LocalVarRef("A", Pos.builtIn)` followed by `FieldRef` for each segment, then evaluates it.
* This mirrors `eval("A.B.C")` resolution semantics without invoking the compiler.
*/
suspend fun resolveQualifiedIdentifier(qualifiedName: String): Obj {
val trimmed = qualifiedName.trim()
if (trimmed.isEmpty()) raiseSymbolNotFound("empty identifier")
val parts = trimmed.split('.')
var ref: ObjRef = LocalVarRef(parts[0], Pos.builtIn)
for (i in 1 until parts.size) {
ref = FieldRef(ref, parts[i], false)
}
return ref.evalValue(this)
}
companion object { companion object {
fun new(): Scope = fun new(): Scope =
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -60,6 +60,7 @@ class Script(
internal val rootScope: Scope = Scope(null).apply { internal val rootScope: Scope = Scope(null).apply {
ObjException.addExceptionsToContext(this) ObjException.addExceptionsToContext(this)
addConst("Unset", ObjUnset)
addFn("print") { addFn("print") {
for ((i, a) in args.withIndex()) { for ((i, a) in args.withIndex()) {
if (i > 0) print(' ' + a.toString(this).value) if (i > 0) print(' ' + a.toString(this).value)
@@ -155,71 +156,116 @@ class Script(
sqrt(args.firstAndOnly().toDouble()) sqrt(args.firstAndOnly().toDouble())
) )
} }
addFn( "abs" ) { addFn("abs") {
val x = args.firstAndOnly() val x = args.firstAndOnly()
if( x is ObjInt) ObjInt( x.value.absoluteValue ) else ObjReal( x.toDouble().absoluteValue ) if (x is ObjInt) ObjInt(x.value.absoluteValue) else ObjReal(x.toDouble().absoluteValue)
} }
addVoidFn("assert") { addVoidFn("assert") {
val cond = requiredArg<ObjBool>(0) val cond = requiredArg<ObjBool>(0)
val message = if( args.size > 1 ) val message = if (args.size > 1)
": " + (args[1] as Statement).execute(this).toString(this).value ": " + (args[1] as Statement).execute(this).toString(this).value
else "" else ""
if( !cond.value == true ) if (!cond.value == true)
raiseError(ObjAssertionFailedException(this, "Assertion failed$message")) raiseError(ObjAssertionFailedException(this, "Assertion failed$message"))
} }
addVoidFn("assertEquals") { addVoidFn("assertEquals") {
val a = requiredArg<Obj>(0) val a = requiredArg<Obj>(0)
val b = requiredArg<Obj>(1) val b = requiredArg<Obj>(1)
if( a.compareTo(this, b) != 0 ) if (a.compareTo(this, b) != 0)
raiseError(ObjAssertionFailedException(this,"Assertion failed: ${a.inspect(this)} == ${b.inspect(this)}")) raiseError(
ObjAssertionFailedException(
this,
"Assertion failed: ${a.inspect(this)} == ${b.inspect(this)}"
)
)
} }
// alias used in tests // alias used in tests
addVoidFn("assertEqual") { addVoidFn("assertEqual") {
val a = requiredArg<Obj>(0) val a = requiredArg<Obj>(0)
val b = requiredArg<Obj>(1) val b = requiredArg<Obj>(1)
if( a.compareTo(this, b) != 0 ) if (a.compareTo(this, b) != 0)
raiseError(ObjAssertionFailedException(this,"Assertion failed: ${a.inspect(this)} == ${b.inspect(this)}")) raiseError(
ObjAssertionFailedException(
this,
"Assertion failed: ${a.inspect(this)} == ${b.inspect(this)}"
)
)
} }
addVoidFn("assertNotEquals") { addVoidFn("assertNotEquals") {
val a = requiredArg<Obj>(0) val a = requiredArg<Obj>(0)
val b = requiredArg<Obj>(1) val b = requiredArg<Obj>(1)
if( a.compareTo(this, b) == 0 ) if (a.compareTo(this, b) == 0)
raiseError(ObjAssertionFailedException(this,"Assertion failed: ${a.inspect(this)} != ${b.inspect(this)}")) raiseError(
ObjAssertionFailedException(
this,
"Assertion failed: ${a.inspect(this)} != ${b.inspect(this)}"
)
)
} }
addFn("assertThrows") { addFn("assertThrows") {
val code = requireOnlyArg<Statement>() val code: Statement
val result =try { val expectedClass: ObjClass?
when(args.size) {
1 -> {
code = requiredArg<Statement>(0)
expectedClass = null
}
2 -> {
code = requiredArg<Statement>(1)
expectedClass = requiredArg<ObjClass>(0)
}
else -> raiseIllegalArgument("Expected 1 or 2 arguments, got ${args.size}")
}
val result = try {
code.execute(this) code.execute(this)
null null
} } catch (e: ExecutionError) {
catch( e: ExecutionError ) {
e.errorObject e.errorObject
} } catch (_: ScriptError) {
catch (_: ScriptError) {
ObjNull ObjNull
} }
result ?: raiseError(ObjAssertionFailedException(this,"Expected exception but nothing was thrown")) if( result == null ) raiseError(ObjAssertionFailedException(this, "Expected exception but nothing was thrown"))
expectedClass?.let {
if( result !is ObjException)
raiseError("Expected $expectedClass, got $result")
if (result.exceptionClass != expectedClass) {
raiseError("Expected $expectedClass, got ${result.exceptionClass}")
}
}
result
} }
addFn("dynamic") { addFn("dynamic") {
ObjDynamic.create(this, requireOnlyArg()) ObjDynamic.create(this, requireOnlyArg())
} }
val root = this
val mathClass = ObjClass("Math").apply {
addFn("sqrt") {
ObjReal(sqrt(args.firstAndOnly().toDouble()))
}
}
addItem("Math", false, ObjInstance(mathClass).apply {
instanceScope = Scope(root, thisObj = this)
})
addFn("require") { addFn("require") {
val condition = requiredArg<ObjBool>(0) val condition = requiredArg<ObjBool>(0)
if( !condition.value ) { if (!condition.value) {
val message = args.list.getOrNull(1)?.toString() ?: "requirement not met" var message = args.list.getOrNull(1)
raiseIllegalArgument(message) if( message is Statement ) message = message.execute(this)
raiseIllegalArgument(message?.toString() ?: "requirement not met")
} }
ObjVoid ObjVoid
} }
addFn("check") { addFn("check") {
val condition = requiredArg<ObjBool>(0) val condition = requiredArg<ObjBool>(0)
if( !condition.value ) { if (!condition.value) {
val message = args.list.getOrNull(1)?.toString() ?: "check failed" var message = args.list.getOrNull(1)
raiseIllegalState(message) if( message is Statement ) message = message.execute(this)
raiseIllegalState(message?.toString() ?: "check failed")
} }
ObjVoid ObjVoid
} }
@@ -257,6 +303,7 @@ class Script(
addConst("Deferred", ObjDeferred.type) addConst("Deferred", ObjDeferred.type)
addConst("CompletableDeferred", ObjCompletableDeferred.type) addConst("CompletableDeferred", ObjCompletableDeferred.type)
addConst("Mutex", ObjMutex.type) addConst("Mutex", ObjMutex.type)
addConst("Flow", ObjFlow.type)
addConst("Regex", ObjRegex.type) addConst("Regex", ObjRegex.type)
@@ -340,7 +387,7 @@ class Script(
doc = "Suspend for the given time. Accepts Duration, Int seconds, or Real seconds." doc = "Suspend for the given time. Accepts Duration, Int seconds, or Real seconds."
) { ) {
val a = args.firstAndOnly() val a = args.firstAndOnly()
when(a) { when (a) {
is ObjInt -> delay(a.value * 1000) is ObjInt -> delay(a.value * 1000)
is ObjReal -> delay((a.value * 1000).roundToLong()) is ObjReal -> delay((a.value * 1000).roundToLong())
is ObjDuration -> delay(a.duration) is ObjDuration -> delay(a.duration)
@@ -26,7 +26,7 @@ enum class Visibility {
/** MI-aware visibility check: whether [caller] can access a member declared in [decl] with [visibility]. */ /** MI-aware visibility check: whether [caller] can access a member declared in [decl] with [visibility]. */
fun canAccessMember(visibility: Visibility, decl: net.sergeych.lyng.obj.ObjClass?, caller: net.sergeych.lyng.obj.ObjClass?): Boolean { fun canAccessMember(visibility: Visibility, decl: net.sergeych.lyng.obj.ObjClass?, caller: net.sergeych.lyng.obj.ObjClass?): Boolean {
return when (visibility) { val res = when (visibility) {
Visibility.Public -> true Visibility.Public -> true
Visibility.Private -> (decl != null && caller === decl) Visibility.Private -> (decl != null && caller === decl)
Visibility.Protected -> when { Visibility.Protected -> when {
@@ -36,4 +36,5 @@ fun canAccessMember(visibility: Visibility, decl: net.sergeych.lyng.obj.ObjClass
else -> (caller.allParentsSet.contains(decl)) else -> (caller.allParentsSet.contains(decl))
} }
} }
return res
} }
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -25,12 +25,9 @@ import net.sergeych.lyng.Source
import net.sergeych.lyng.highlight.HighlightKind import net.sergeych.lyng.highlight.HighlightKind
import net.sergeych.lyng.highlight.SimpleLyngHighlighter import net.sergeych.lyng.highlight.SimpleLyngHighlighter
import net.sergeych.lyng.highlight.offsetOf import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.miniast.MiniClassDecl import net.sergeych.lyng.miniast.*
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 } enum class SymbolKind { Class, Enum, Function, Value, Variable, Parameter }
data class Symbol( data class Symbol(
val id: Int, val id: Int,
@@ -111,7 +108,16 @@ object Binder {
for (cf in d.ctorFields) { for (cf in d.ctorFields) {
val fs = source.offsetOf(cf.nameStart) val fs = source.offsetOf(cf.nameStart)
val fe = fs + cf.name.length val fe = fs + cf.name.length
val kind = if (cf.mutable) SymbolKind.Var else SymbolKind.Val val kind = if (cf.mutable) SymbolKind.Variable else SymbolKind.Value
val fieldSym = Symbol(nextId++, cf.name, kind, fs, fe, containerId = sym.id)
symbols += fieldSym
classes.last().fields += fieldSym.id
}
// Class fields (val/var in class body, if any are reported here)
for (cf in d.classFields) {
val fs = source.offsetOf(cf.nameStart)
val fe = fs + cf.name.length
val kind = if (cf.mutable) SymbolKind.Variable else SymbolKind.Value
val fieldSym = Symbol(nextId++, cf.name, kind, fs, fe, containerId = sym.id) val fieldSym = Symbol(nextId++, cf.name, kind, fs, fe, containerId = sym.id)
symbols += fieldSym symbols += fieldSym
classes.last().fields += fieldSym.id classes.last().fields += fieldSym.id
@@ -138,7 +144,7 @@ object Binder {
for (p in d.params) { for (p in d.params) {
val ps = source.offsetOf(p.nameStart) val ps = source.offsetOf(p.nameStart)
val pe = ps + p.name.length val pe = ps + p.name.length
val pk = SymbolKind.Param val pk = SymbolKind.Parameter
val paramSym = Symbol(nextId++, p.name, pk, ps, pe, containerId = sym.id) val paramSym = Symbol(nextId++, p.name, pk, ps, pe, containerId = sym.id)
fnScope.locals += paramSym.id fnScope.locals += paramSym.id
symbols += paramSym symbols += paramSym
@@ -147,7 +153,7 @@ object Binder {
} }
is MiniValDecl -> { is MiniValDecl -> {
val (s, e) = nameOffsets(d.nameStart, d.name) val (s, e) = nameOffsets(d.nameStart, d.name)
val kind = if (d.mutable) SymbolKind.Var else SymbolKind.Val val kind = if (d.mutable) SymbolKind.Variable else SymbolKind.Value
val ownerClass = classContaining(s) val ownerClass = classContaining(s)
if (ownerClass != null) { if (ownerClass != null) {
// class field // class field
@@ -160,6 +166,12 @@ object Binder {
topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id) topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id)
} }
} }
is MiniEnumDecl -> {
val (s, e) = nameOffsets(d.nameStart, d.name)
val sym = Symbol(nextId++, d.name, SymbolKind.Enum, s, e, containerId = null)
symbols += sym
topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id)
}
} }
} }
@@ -191,7 +203,7 @@ object Binder {
.maxByOrNull { it.rangeEnd - it.rangeStart } .maxByOrNull { it.rangeEnd - it.rangeStart }
if (containerFn != null) { if (containerFn != null) {
val fnSymId = containerFn.id val fnSymId = containerFn.id
val kind = if (d.mutable) SymbolKind.Var else SymbolKind.Val val kind = if (d.mutable) SymbolKind.Variable else SymbolKind.Value
val localSym = Symbol(nextId++, d.name, kind, s, e, containerId = fnSymId) val localSym = Symbol(nextId++, d.name, kind, s, e, containerId = fnSymId)
symbols += localSym symbols += localSym
containerFn.locals += localSym.id containerFn.locals += localSym.id
@@ -231,7 +243,7 @@ object Binder {
val inFn = functions.asSequence() val inFn = functions.asSequence()
.filter { it.rangeEnd > it.rangeStart && nameStart >= it.rangeStart && nameStart <= it.rangeEnd } .filter { it.rangeEnd > it.rangeStart && nameStart >= it.rangeStart && nameStart <= it.rangeEnd }
.maxByOrNull { it.rangeEnd - it.rangeStart } .maxByOrNull { it.rangeEnd - it.rangeStart }
val kind = if (kw.equals("var", true)) SymbolKind.Var else SymbolKind.Val val kind = if (kw.equals("var", true)) SymbolKind.Variable else SymbolKind.Value
if (inFn != null) { if (inFn != null) {
val localSym = Symbol(nextId++, text.substring(nameStart, nameEnd), kind, nameStart, nameEnd, containerId = inFn.id) val localSym = Symbol(nextId++, text.substring(nameStart, nameEnd), kind, nameStart, nameEnd, containerId = inFn.id)
symbols += localSym symbols += localSym
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -24,6 +24,24 @@ package net.sergeych.lyng.format
*/ */
object LyngFormatter { object LyngFormatter {
private fun startsWithWord(s: String, w: String): Boolean =
s.startsWith(w) && s.getOrNull(w.length)?.let { !it.isLetterOrDigit() && it != '_' } != false
private fun isPropertyAccessor(s: String): Boolean {
val t = s.trim()
if (t.isEmpty()) return false
if (startsWithWord(t, "get") || startsWithWord(t, "set")) return true
if (startsWithWord(t, "private")) {
val rest = t.substring("private".length).trimStart()
if (startsWithWord(rest, "set")) return true
}
if (startsWithWord(t, "protected")) {
val rest = t.substring("protected".length).trimStart()
if (startsWithWord(rest, "set")) return true
}
return false
}
/** Returns the input with indentation recomputed from scratch, line by line. */ /** Returns the input with indentation recomputed from scratch, line by line. */
fun reindent(text: String, config: LyngFormatConfig = LyngFormatConfig()): String { fun reindent(text: String, config: LyngFormatConfig = LyngFormatConfig()): String {
// Normalize tabs to spaces globally before any transformation; results must contain no tabs // Normalize tabs to spaces globally before any transformation; results must contain no tabs
@@ -34,18 +52,26 @@ object LyngFormatter {
var parenBalance = 0 var parenBalance = 0
var bracketBalance = 0 var bracketBalance = 0
var prevBracketContinuation = false var prevBracketContinuation = false
var inBlockComment = false
val extraIndents = mutableListOf<Int>()
// We don't keep per-"[" base alignment; continuation rules define alignment. // We don't keep per-"[" base alignment; continuation rules define alignment.
fun codePart(s: String): String { fun updateBalances(ch: Char) {
val idx = s.indexOf("//") when (ch) {
return if (idx >= 0) s.substring(0, idx) else s '{' -> blockLevel++
'}' -> if (blockLevel > 0) blockLevel--
'(' -> parenBalance++
')' -> if (parenBalance > 0) parenBalance--
'[' -> bracketBalance++
']' -> if (bracketBalance > 0) bracketBalance--
}
} }
fun indentOf(level: Int, continuation: Int): String = fun indentOf(level: Int, continuation: Int): String =
// Always produce spaces; tabs are not allowed in resulting code // Always produce spaces; tabs are not allowed in resulting code
" ".repeat(level * config.indentSize + continuation) " ".repeat(level * config.indentSize + continuation)
var awaitingSingleIndent = false var awaitingExtraIndent = 0
fun isControlHeaderNoBrace(s: String): Boolean { fun isControlHeaderNoBrace(s: String): Boolean {
val t = s.trim() val t = s.trim()
if (t.isEmpty()) return false if (t.isEmpty()) return false
@@ -53,36 +79,46 @@ object LyngFormatter {
val isIf = Regex("^if\\s*\\(.*\\)\\s*$").matches(t) val isIf = Regex("^if\\s*\\(.*\\)\\s*$").matches(t)
val isElseIf = Regex("^else\\s+if\\s*\\(.*\\)\\s*$").matches(t) val isElseIf = Regex("^else\\s+if\\s*\\(.*\\)\\s*$").matches(t)
val isElse = t == "else" val isElse = t == "else"
return isIf || isElseIf || isElse if (isIf || isElseIf || isElse) return true
// property accessors ending with ) or =
if (isPropertyAccessor(t)) {
return if (t.contains('=')) t.endsWith('=') else t.endsWith(')')
}
return false
} }
for ((i, rawLine) in lines.withIndex()) { for ((i, rawLine) in lines.withIndex()) {
val line = rawLine val (parts, nextInBlockComment) = splitIntoParts(rawLine, inBlockComment)
val code = codePart(line) val code = parts.filter { it.type == PartType.Code }.joinToString("") { it.text }
val trimmedStart = code.dropWhile { it == ' ' || it == '\t' } val trimmedStart = code.dropWhile { it == ' ' || it == '\t' }
val trimmedLine = rawLine.trim()
// Compute effective indent level for this line // Compute effective indent level for this line
var effectiveLevel = blockLevel val currentExtraIndent = extraIndents.sum()
if (trimmedStart.startsWith("}")) effectiveLevel = (effectiveLevel - 1).coerceAtLeast(0) var effectiveLevel = blockLevel + currentExtraIndent
val isAccessor = !inBlockComment && isPropertyAccessor(trimmedStart)
if (isAccessor) effectiveLevel += 1
if (inBlockComment) {
if (!trimmedLine.startsWith("*/")) {
effectiveLevel += 1
}
} else if (trimmedStart.startsWith("}")) {
effectiveLevel = (effectiveLevel - 1).coerceAtLeast(0)
}
// else/catch/finally should align with the parent block level; no extra dedent here, // else/catch/finally should align with the parent block level; no extra dedent here,
// because the preceding '}' has already reduced [blockLevel] appropriately. // because the preceding '}' has already reduced [blockLevel] appropriately.
// Single-line control header (if/else/else if) without braces: indent the next // Single-line control header (if/else/else if) without braces: indent the next
// non-empty, non-'}', non-'else' line by one extra level // non-empty, non-'}', non-'else' line by one extra level
val applyAwaiting = awaitingSingleIndent && trimmedStart.isNotEmpty() && val applyAwaiting = awaitingExtraIndent > 0 && trimmedStart.isNotEmpty() &&
!trimmedStart.startsWith("else") && !trimmedStart.startsWith("}") !trimmedStart.startsWith("else") && !trimmedStart.startsWith("}")
if (applyAwaiting) effectiveLevel += 1 if (applyAwaiting) effectiveLevel += awaitingExtraIndent
val firstChar = trimmedStart.firstOrNull() val firstChar = trimmedStart.firstOrNull()
// Do not apply continuation on a line that starts with a closer ')' or ']' // While inside parentheses, continuation applies scaled by nesting level
val startsWithCloser = firstChar == ')' || firstChar == ']'
// Kotlin-like rule: continuation persists while inside parentheses; it applies
// even on the line that starts with ')'. For brackets, do not apply on the ']' line itself.
// Continuation rules:
// - For brackets: one-shot continuation for first element after '[', and while inside brackets
// (except on the ']' line) continuation equals one unit.
// - For parentheses: continuation depth scales with nested level; e.g., inside two nested
// parentheses lines get 2 * continuationIndentSize. No continuation on a line that starts with ')'.
val parenContLevels = if (parenBalance > 0 && firstChar != ')') parenBalance else 0 val parenContLevels = if (parenBalance > 0 && firstChar != ')') parenBalance else 0
val continuation = when { val continuation = when {
// One-shot continuation when previous line ended with '[' to align first element // One-shot continuation when previous line ended with '[' to align first element
@@ -91,6 +127,7 @@ object LyngFormatter {
bracketBalance > 0 && firstChar != ']' -> config.continuationIndentSize bracketBalance > 0 && firstChar != ']' -> config.continuationIndentSize
// While inside parentheses, continuation applies scaled by nesting level // While inside parentheses, continuation applies scaled by nesting level
parenContLevels > 0 -> config.continuationIndentSize * parenContLevels parenContLevels > 0 -> config.continuationIndentSize * parenContLevels
trimmedStart.startsWith(".") -> config.indentSize
else -> 0 else -> 0
} }
@@ -100,8 +137,8 @@ object LyngFormatter {
} }
// Replace leading whitespace with the exact target indent; but keep fully blank lines truly empty // Replace leading whitespace with the exact target indent; but keep fully blank lines truly empty
val contentStart = line.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) line.length else it } val contentStart = rawLine.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) rawLine.length else it }
var content = line.substring(contentStart) var content = rawLine.substring(contentStart)
// Collapse spaces right after an opening '[' to avoid "[ 1"; make it "[1" // Collapse spaces right after an opening '[' to avoid "[ 1"; make it "[1"
if (content.startsWith("[")) { if (content.startsWith("[")) {
content = "[" + content.drop(1).trimStart() content = "[" + content.drop(1).trimStart()
@@ -115,104 +152,89 @@ object LyngFormatter {
} }
// Determine base indent using structural level and continuation only (spaces only) // Determine base indent using structural level and continuation only (spaces only)
val indentString = indentOf(effectiveLevel, continuation) val indentString = indentOf(effectiveLevel, continuation)
if (content.isEmpty()) { if (content.isNotEmpty()) {
// preserve truly blank line as empty to avoid trailing spaces on empty lines
// (also keeps continuation blocks visually clean)
// do nothing, just append nothing; newline will be appended below if needed
} else {
sb.append(indentString).append(content) sb.append(indentString).append(content)
} }
// New line (keep EOF semantics similar to input) // New line (keep EOF semantics similar to input)
if (i < lines.lastIndex) sb.append('\n') if (i < lines.lastIndex) sb.append('\n')
val oldBlockLevel = blockLevel
// Update balances using this line's code content // Update balances using this line's code content
for (ch in code) when (ch) { for (part in parts) {
'{' -> blockLevel++ if (part.type == PartType.Code) {
'}' -> if (blockLevel > 0) blockLevel-- for (ch in part.text) updateBalances(ch)
'(' -> parenBalance++ }
')' -> if (parenBalance > 0) parenBalance-- }
'[' -> bracketBalance++ val newBlockLevel = blockLevel
']' -> if (bracketBalance > 0) bracketBalance-- if (newBlockLevel > oldBlockLevel) {
val addedThisLine = (if (applyAwaiting) awaitingExtraIndent else 0) + (if (isAccessor) 1 else 0)
repeat(newBlockLevel - oldBlockLevel) {
extraIndents.add(addedThisLine)
}
} else if (newBlockLevel < oldBlockLevel) {
repeat(oldBlockLevel - newBlockLevel) {
if (extraIndents.isNotEmpty()) extraIndents.removeAt(extraIndents.size - 1)
}
} }
// Update awaitingSingleIndent based on current line inBlockComment = nextInBlockComment
// Update awaitingExtraIndent based on current line
if (applyAwaiting && trimmedStart.isNotEmpty()) { if (applyAwaiting && trimmedStart.isNotEmpty()) {
// we have just consumed the body line // we have just applied it.
awaitingSingleIndent = false val endsWithBrace = code.trimEnd().endsWith("{")
if (!endsWithBrace && isControlHeaderNoBrace(code)) {
// It's another header, increment
awaitingExtraIndent += if (isAccessor) 2 else 1
} else {
// It's the body, reset
awaitingExtraIndent = 0
}
} else { } else {
// start awaiting if current line is a control header without '{' // start awaiting if current line is a control header without '{'
val endsWithBrace = code.trimEnd().endsWith("{") val endsWithBrace = code.trimEnd().endsWith("{")
if (!endsWithBrace && isControlHeaderNoBrace(code)) { if (!endsWithBrace && isControlHeaderNoBrace(code)) {
awaitingSingleIndent = true awaitingExtraIndent = if (isAccessor) 2 else 1
} }
} }
// Prepare one-shot bracket continuation if the current line ends with '[' // Prepare one-shot bracket continuation if the current line ends with '['
// (first element line gets continuation even before balances update propagate).
val endsWithBracket = code.trimEnd().endsWith("[") val endsWithBracket = code.trimEnd().endsWith("[")
// Reset one-shot flag after we used it on this line
if (prevBracketContinuation) prevBracketContinuation = false
// Set for the next iteration if current line ends with '['
// Record whether THIS line ends with an opening '[' so the NEXT line gets a one-shot
// continuation indent for the first element.
if (endsWithBracket) { if (endsWithBracket) {
// One-shot continuation for the very next line // One-shot continuation for the very next line
prevBracketContinuation = true prevBracketContinuation = true
} else { } else {
// Reset the one-shot flag if the previous line didn't end with '[' // Reset the one-shot flag if it was used or if line doesn't end with '['
prevBracketContinuation = false prevBracketContinuation = false
} }
} }
return sb.toString() return sb.toString()
} }
/** Full format. Currently performs indentation only; spacing/wrapping can be added later. */
fun format(text: String, config: LyngFormatConfig = LyngFormatConfig()): String { fun format(text: String, config: LyngFormatConfig = LyngFormatConfig()): String {
// Phase 1: indentation // Phase 1: indentation
val indented = reindent(text, config) val indented = reindent(text, config)
if (!config.applySpacing && !config.applyWrapping) return indented if (!config.applySpacing && !config.applyWrapping) return indented
// Phase 2: minimal, safe spacing (PSI-free). Skip block comments completely and // Phase 2: minimal, safe spacing (PSI-free).
// only apply spacing to the part before '//' on each line.
val lines = indented.split('\n') val lines = indented.split('\n')
val out = StringBuilder(indented.length) val out = StringBuilder(indented.length)
var inBlockComment = false var inBlockComment = false
for ((i, rawLine) in lines.withIndex()) { for ((i, rawLine) in lines.withIndex()) {
var line = rawLine var line = rawLine
if (config.applySpacing) { if (config.applySpacing) {
if (inBlockComment) { val (parts, nextInBlockComment) = splitIntoParts(rawLine, inBlockComment)
// Pass-through until we see the end of the block comment on some line val sb = StringBuilder()
val end = line.indexOf("*/") for (part in parts) {
if (end >= 0) { if (part.type == PartType.Code) {
inBlockComment = false sb.append(applyMinimalSpacingRules(part.text))
}
} else {
// If this line opens a block comment, apply spacing only before the opener
val startIdx = line.indexOf("/*")
val endIdx = line.indexOf("*/")
if (startIdx >= 0 && (endIdx < 0 || endIdx < startIdx)) {
val before = line.substring(0, startIdx)
val after = line.substring(startIdx)
val commentIdx = before.indexOf("//")
val code = if (commentIdx >= 0) before.substring(0, commentIdx) else before
val tail = if (commentIdx >= 0) before.substring(commentIdx) else ""
val spaced = applyMinimalSpacing(code)
line = (spaced + tail) + after
inBlockComment = true
} else { } else {
// Normal code line: respect single-line comments sb.append(part.text)
val commentIdx = line.indexOf("//")
if (commentIdx >= 0) {
val code = line.substring(0, commentIdx)
val tail = line.substring(commentIdx)
val spaced = applyMinimalSpacing(code)
line = spaced + tail
} else {
line = applyMinimalSpacing(line)
}
} }
} }
line = sb.toString()
inBlockComment = nextInBlockComment
} }
out.append(line.trimEnd()) out.append(line.trimEnd())
if (i < lines.lastIndex) out.append('\n') if (i < lines.lastIndex) out.append('\n')
@@ -224,9 +246,6 @@ object LyngFormatter {
return applyControlledWrapping(spacedText, config) return applyControlledWrapping(spacedText, config)
} }
private fun startsWithWord(s: String, w: String): Boolean =
s.startsWith(w) && s.getOrNull(w.length)?.let { !it.isLetterOrDigit() && it != '_' } != false
/** /**
* Reindents a slice of [text] specified by [range] and returns a new string with that slice replaced. * Reindents a slice of [text] specified by [range] and returns a new string with that slice replaced.
* By default, preserves the base indent of the first line in the slice (so the block stays at * By default, preserves the base indent of the first line in the slice (so the block stays at
@@ -369,7 +388,80 @@ object LyngFormatter {
} }
} }
private fun applyMinimalSpacing(code: String): String { private enum class PartType { Code, StringLiteral, BlockComment, LineComment }
private data class Part(val text: String, val type: PartType)
/**
* Split a line into parts: code, string literals, and comments.
* Tracks [inBlockComment] state across lines.
*/
private fun splitIntoParts(
text: String,
inBlockCommentInitial: Boolean
): Pair<List<Part>, Boolean> {
val result = mutableListOf<Part>()
var i = 0
var last = 0
var inBlockComment = inBlockCommentInitial
var inString = false
var quoteChar = ' '
while (i < text.length) {
if (inBlockComment) {
if (text.startsWith("*/", i)) {
result.add(Part(text.substring(last, i + 2), PartType.BlockComment))
inBlockComment = false
i += 2
last = i
} else {
i++
}
} else if (inString) {
if (text[i] == quoteChar) {
var escapeCount = 0
var j = i - 1
while (j >= 0 && text[j] == '\\') {
escapeCount++
j--
}
if (escapeCount % 2 == 0) {
inString = false
result.add(Part(text.substring(last, i + 1), PartType.StringLiteral))
last = i + 1
}
}
i++
} else {
if (text.startsWith("//", i)) {
if (i > last) result.add(Part(text.substring(last, i), PartType.Code))
result.add(Part(text.substring(i), PartType.LineComment))
last = text.length
break
} else if (text.startsWith("/*", i)) {
if (i > last) result.add(Part(text.substring(last, i), PartType.Code))
inBlockComment = true
last = i
i += 2
} else if (text[i] == '"' || text[i] == '\'') {
if (i > last) result.add(Part(text.substring(last, i), PartType.Code))
inString = true
quoteChar = text[i]
last = i
i++
} else {
i++
}
}
}
if (last < text.length) {
val leftover = text.substring(last)
val type = if (inBlockComment) PartType.BlockComment else PartType.Code
result.add(Part(leftover, type))
}
return result to inBlockComment
}
private fun applyMinimalSpacingRules(code: String): String {
var s = code var s = code
// Ensure space before '(' for control-flow keywords // Ensure space before '(' for control-flow keywords
s = s.replace(Regex("\\b(if|for|while)\\("), "$1 (") s = s.replace(Regex("\\b(if|for|while)\\("), "$1 (")
@@ -408,15 +500,16 @@ private fun applyMinimalSpacing(code: String): String {
s = s.replace(Regex(", \\)"), ",)") s = s.replace(Regex(", \\)"), ",)")
s = s.replace(Regex(", \\]"), ",]") s = s.replace(Regex(", \\]"), ",]")
// Equality/boolean operators: ensure spaces around // Equality/boolean operators: ensure spaces around
s = s.replace(Regex("\\s*(==|!=|<=|>=|&&|\\|\\|)\\s*"), " $1 ") s = s.replace(Regex("\\s*(==|!=|<=|>=|&&|\\|\\||\\+=|-=|\\*=|/=|=>|->)\\s*"), " $1 ")
// Assignment '=' (not part of '==', '!=', '<=', '>=', '=>'): collapse whitespace to single spaces around '=' // Assignment '=' (not part of '==', '!=', '<=', '>=', '=>', '+=', '-=', '*=', '/=', '->'): collapse whitespace to single spaces around '='
s = s.replace(Regex("(?<![=!<>])\\s*=\\s*(?![=>])"), " = ") s = s.replace(Regex("(?<![=!<>+\\-*/])\\s*=\\s*(?![=>])"), " = ")
// Multiply/Divide/Mod as binary: spaces around // Multiply/Divide/Mod as binary: spaces around. Avoid '*=', '/='
s = s.replace(Regex("\\s*([*/%])\\s*"), " $1 ") s = s.replace(Regex("(?<![*])\\s*([*/%])\\s*(?![=])"), " $1 ")
// Addition as binary: spaces around '+'. We try not to break '++' and '+=' here. // Addition as binary: spaces around '+'. We try not to break '++' and '+=' here.
s = s.replace(Regex("(?<![+])\\s*\\+\\s*(?![+=])"), " + ") s = s.replace(Regex("(?<![+])\\s*\\+\\s*(?![+=])"), " + ")
// Subtraction as binary: add spaces when it looks binary. Keep unary '-' tight (after start or ( [ { = : ,) // Subtraction as binary: add spaces when it looks binary. Keep unary '-' tight (after start or ( [ { = : ,)
s = s.replace(Regex("(?<=[^\\s(\\[\\{=:,])-(?=[^=])"), " - ") // and avoid splitting '-=', '--', '->'
s = s.replace(Regex("(?<=[^\\s(\\[\\{=:,])\\s*-\\s*(?=[^=\\->])"), " - ")
// Colon in types/extends: remove spaces before, ensure one space after (keep '::' intact) // Colon in types/extends: remove spaces before, ensure one space after (keep '::' intact)
s = s.replace(Regex("(?<!:)\\s*:(?!:)\\s*"), ": ") s = s.replace(Regex("(?<!:)\\s*:(?!:)\\s*"), ": ")
return s return s
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -42,10 +42,10 @@ private val fallbackKeywordIds = setOf(
"and", "or", "not", "and", "or", "not",
// declarations & modifiers // declarations & modifiers
"fun", "fn", "class", "enum", "val", "var", "import", "package", "fun", "fn", "class", "enum", "val", "var", "import", "package",
"private", "protected", "static", "open", "extern", "private", "protected", "static", "open", "extern", "init", "get", "set", "by",
// control flow and misc // control flow and misc
"if", "else", "when", "while", "do", "for", "try", "catch", "finally", "if", "else", "when", "while", "do", "for", "try", "catch", "finally",
"throw", "return", "break", "continue", "this", "null", "true", "false" "throw", "return", "break", "continue", "this", "null", "true", "false", "unset"
) )
/** Maps lexer token type (and sometimes value) to a [HighlightKind]. */ /** Maps lexer token type (and sometimes value) to a [HighlightKind]. */
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -105,20 +105,24 @@ object BuiltinDocRegistry : BuiltinDocSource {
} }
/** /**
* List names of extension-like methods defined for [className] in the stdlib text (`root.lyng`). * List names of extension members defined for [className] in the stdlib text (`root.lyng`).
* We do a lightweight regex scan like: `fun ClassName.methodName(` and collect distinct names. * We do a lightweight regex scan like: `fun ClassName.methodName(` or `val ClassName.propName`
* and collect distinct names.
*/ */
fun extensionMethodNamesFor(className: String): List<String> { fun extensionMemberNamesFor(className: String): List<String> {
val src = try { rootLyng } catch (_: Throwable) { null } ?: return emptyList() val src = try { rootLyng } catch (_: Throwable) { null } ?: return emptyList()
val out = LinkedHashSet<String>() val out = LinkedHashSet<String>()
// Match lines like: fun String.trim(...) // Match lines like: fun String.trim(...) or val Int.isEven = ...
val re = Regex("(?m)^\\s*fun\\s+${className}\\.([A-Za-z_][A-Za-z0-9_]*)\\s*\\(") val re = Regex("^\\s*(?:fun|val|var)\\s+${className}\\.([A-Za-z_][A-Za-z0-9_]*)\\b", RegexOption.MULTILINE)
re.findAll(src).forEach { m -> re.findAll(src).forEach { m ->
val name = m.groupValues.getOrNull(1)?.trim() val name = m.groupValues.getOrNull(1)?.trim()
if (!name.isNullOrEmpty()) out.add(name) if (!name.isNullOrEmpty()) out.add(name)
} }
return out.toList() return out.toList()
} }
@Deprecated("Use extensionMemberNamesFor", ReplaceWith("extensionMemberNamesFor(className)"))
fun extensionMethodNamesFor(className: String): List<String> = extensionMemberNamesFor(className)
} }
// ---------------- Builders ---------------- // ---------------- Builders ----------------
@@ -367,8 +371,8 @@ private object StdlibInlineDocIndex {
else -> { else -> {
// Non-comment, non-blank: try to match a declaration just after comments // Non-comment, non-blank: try to match a declaration just after comments
if (buf.isNotEmpty()) { if (buf.isNotEmpty()) {
// fun Class.name( ... ) // fun/val/var Class.name( ... )
val mExt = Regex("^fun\\s+([A-Za-z_][A-Za-z0-9_]*)\\.([A-Za-z_][A-Za-z0-9_]*)\\s*\\(").find(line) val mExt = Regex("^(?:fun|val|var)\\s+([A-Za-z_][A-Za-z0-9_]*)\\.([A-Za-z_][A-Za-z0-9_]*)\\b").find(line)
if (mExt != null) { if (mExt != null) {
val (cls, name) = mExt.destructured val (cls, name) = mExt.destructured
flushTo(Key.Method(cls, name)) flushTo(Key.Method(cls, name))
@@ -600,6 +604,11 @@ private fun buildStdlibDocs(): List<MiniDecl> {
method(name = "printStackTrace", doc = StdlibInlineDocIndex.methodDoc("Exception", "printStackTrace") ?: "Print this exception and its stack trace to standard output.") method(name = "printStackTrace", doc = StdlibInlineDocIndex.methodDoc("Exception", "printStackTrace") ?: "Print this exception and its stack trace to standard output.")
} }
mod.classDoc(name = "Enum", doc = StdlibInlineDocIndex.classDoc("Enum") ?: "Base class for all enums.") {
method(name = "name", doc = "Returns the name of this enum constant.", returns = type("lyng.String"))
method(name = "ordinal", doc = "Returns the ordinal of this enum constant.", returns = type("lyng.Int"))
}
mod.classDoc(name = "String", doc = StdlibInlineDocIndex.classDoc("String") ?: "String helpers.") { mod.classDoc(name = "String", doc = StdlibInlineDocIndex.classDoc("String") ?: "String helpers.") {
// Only include inline-source method here; Kotlin-embedded methods are now documented via DocHelpers near definitions. // Only include inline-source method here; Kotlin-embedded methods are now documented via DocHelpers near definitions.
method(name = "re", doc = StdlibInlineDocIndex.methodDoc("String", "re") ?: "Compile this string into a regular expression.", returns = type("lyng.Regex")) method(name = "re", doc = StdlibInlineDocIndex.methodDoc("String", "re") ?: "Compile this string into a regular expression.", returns = type("lyng.Regex"))
@@ -1,3 +1,20 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
/* /*
* Pure-Kotlin, PSI-free completion engine used for isolated tests and non-IDE harnesses. * Pure-Kotlin, PSI-free completion engine used for isolated tests and non-IDE harnesses.
* Mirrors the IntelliJ MVP logic: MiniAst + BuiltinDocRegistry + lenient imports. * Mirrors the IntelliJ MVP logic: MiniAst + BuiltinDocRegistry + lenient imports.
@@ -16,7 +33,7 @@ data class CompletionItem(
val typeText: String? = null, val typeText: String? = null,
) )
enum class Kind { Function, Class_, Value, Method, Field } enum class Kind { Function, Class_, Enum, Value, Method, Field }
/** /**
* Platform-free, lenient import provider that never fails on unknown packages. * Platform-free, lenient import provider that never fails on unknown packages.
@@ -45,43 +62,33 @@ object CompletionEngineLight {
StdlibDocsBootstrap.ensure() StdlibDocsBootstrap.ensure()
val prefix = prefixAt(text, caret) val prefix = prefixAt(text, caret)
val mini = buildMiniAst(text) val mini = buildMiniAst(text)
// Build imported modules as a UNION of MiniAst-derived and textual extraction, always including stdlib val imported: List<String> = DocLookupUtils.canonicalImportedModules(mini ?: return emptyList(), text)
run {
// no-op block to keep local scope tidy
}
val fromMini: List<String> = mini?.let { DocLookupUtils.canonicalImportedModules(it) } ?: emptyList()
val fromText: List<String> = extractImportsFromText(text)
val imported: List<String> = LinkedHashSet<String>().apply {
fromMini.forEach { add(it) }
fromText.forEach { add(it) }
add("lyng.stdlib")
}.toList()
val cap = 200 val cap = 200
val out = ArrayList<CompletionItem>(64) val out = ArrayList<CompletionItem>(64)
// Member context detection: dot immediately before caret or before current word start // Member context detection: dot immediately before caret or before current word start
val word = wordRangeAt(text, caret) val word = DocLookupUtils.wordRangeAt(text, caret)
val memberDot = findDotLeft(text, word?.first ?: caret) val memberDot = DocLookupUtils.findDotLeft(text, word?.first ?: caret)
if (memberDot != null) { if (memberDot != null) {
// 0) Try chained member call return type inference // 0) Try chained member call return type inference
guessReturnClassFromMemberCallBefore(text, memberDot, imported)?.let { cls -> DocLookupUtils.guessReturnClassFromMemberCallBefore(text, memberDot, imported, mini)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls) offerMembersAdd(out, prefix, imported, cls, mini)
return out return out
} }
// 0a) Top-level call before dot // 0a) Top-level call before dot
guessReturnClassFromTopLevelCallBefore(text, memberDot, imported)?.let { cls -> DocLookupUtils.guessReturnClassFromTopLevelCallBefore(text, memberDot, imported, mini)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls) offerMembersAdd(out, prefix, imported, cls, mini)
return out return out
} }
// 0b) Across-known-callees (Iterable/Iterator/List preference) // 0b) Across-known-callees (Iterable/Iterator/List preference)
guessReturnClassAcrossKnownCallees(text, memberDot, imported)?.let { cls -> DocLookupUtils.guessReturnClassAcrossKnownCallees(text, memberDot, imported, mini)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls) offerMembersAdd(out, prefix, imported, cls, mini)
return out return out
} }
// 1) Receiver inference fallback // 1) Receiver inference fallback
guessReceiverClass(text, memberDot, imported)?.let { cls -> (DocLookupUtils.guessReceiverClassViaMini(mini, text, memberDot, imported) ?: DocLookupUtils.guessReceiverClass(text, memberDot, imported, mini))?.let { cls ->
offerMembersAdd(out, prefix, imported, cls) offerMembersAdd(out, prefix, imported, cls, mini)
return out return out
} }
// In member context and unknown receiver/return type: show nothing (no globals after dot) // In member context and unknown receiver/return type: show nothing (no globals after dot)
@@ -93,9 +100,11 @@ object CompletionEngineLight {
val decls = m.declarations val decls = m.declarations
val funs = decls.filterIsInstance<MiniFunDecl>().sortedBy { it.name.lowercase() } val funs = decls.filterIsInstance<MiniFunDecl>().sortedBy { it.name.lowercase() }
val classes = decls.filterIsInstance<MiniClassDecl>().sortedBy { it.name.lowercase() } val classes = decls.filterIsInstance<MiniClassDecl>().sortedBy { it.name.lowercase() }
val enums = decls.filterIsInstance<MiniEnumDecl>().sortedBy { it.name.lowercase() }
val vals = decls.filterIsInstance<MiniValDecl>().sortedBy { it.name.lowercase() } val vals = decls.filterIsInstance<MiniValDecl>().sortedBy { it.name.lowercase() }
funs.forEach { offerDeclAdd(out, prefix, it) } funs.forEach { offerDeclAdd(out, prefix, it) }
classes.forEach { offerDeclAdd(out, prefix, it) } classes.forEach { offerDeclAdd(out, prefix, it) }
enums.forEach { offerDeclAdd(out, prefix, it) }
vals.forEach { offerDeclAdd(out, prefix, it) } vals.forEach { offerDeclAdd(out, prefix, it) }
} }
@@ -109,9 +118,11 @@ object CompletionEngineLight {
val decls = BuiltinDocRegistry.docsForModule(mod) val decls = BuiltinDocRegistry.docsForModule(mod)
val funs = decls.filterIsInstance<MiniFunDecl>().sortedBy { it.name.lowercase() } val funs = decls.filterIsInstance<MiniFunDecl>().sortedBy { it.name.lowercase() }
val classes = decls.filterIsInstance<MiniClassDecl>().sortedBy { it.name.lowercase() } val classes = decls.filterIsInstance<MiniClassDecl>().sortedBy { it.name.lowercase() }
val enums = decls.filterIsInstance<MiniEnumDecl>().sortedBy { it.name.lowercase() }
val vals = decls.filterIsInstance<MiniValDecl>().sortedBy { it.name.lowercase() } val vals = decls.filterIsInstance<MiniValDecl>().sortedBy { it.name.lowercase() }
funs.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } funs.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } }
classes.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } classes.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } }
enums.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } }
vals.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } vals.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } }
if (out.size >= cap || externalAdded >= budget) break if (out.size >= cap || externalAdded >= budget) break
} }
@@ -130,13 +141,14 @@ object CompletionEngineLight {
add(CompletionItem(d.name, Kind.Function, tailText = tail, typeText = typeOf(d.returnType))) add(CompletionItem(d.name, Kind.Function, tailText = tail, typeText = typeOf(d.returnType)))
} }
is MiniClassDecl -> add(CompletionItem(d.name, Kind.Class_)) is MiniClassDecl -> add(CompletionItem(d.name, Kind.Class_))
is MiniEnumDecl -> add(CompletionItem(d.name, Kind.Enum))
is MiniValDecl -> add(CompletionItem(d.name, Kind.Value, typeText = typeOf(d.type))) is MiniValDecl -> add(CompletionItem(d.name, Kind.Value, typeText = typeOf(d.type)))
else -> add(CompletionItem(d.name, Kind.Value)) // else -> add(CompletionItem(d.name, Kind.Value))
} }
} }
private fun offerMembersAdd(out: MutableList<CompletionItem>, prefix: String, imported: List<String>, className: String) { private fun offerMembersAdd(out: MutableList<CompletionItem>, prefix: String, imported: List<String>, className: String, mini: MiniScript? = null) {
val classes = DocLookupUtils.aggregateClasses(imported) val classes = DocLookupUtils.aggregateClasses(imported, mini)
val visited = mutableSetOf<String>() val visited = mutableSetOf<String>()
val directMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>() val directMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
val inheritedMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>() val inheritedMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
@@ -183,6 +195,7 @@ object CompletionEngineLight {
val ci = CompletionItem(name, Kind.Field, typeText = typeOf((chosen as MiniMemberValDecl).type)) val ci = CompletionItem(name, Kind.Field, typeText = typeOf((chosen as MiniMemberValDecl).type))
if (ci.name.startsWith(prefix, true)) out += ci if (ci.name.startsWith(prefix, true)) out += ci
} }
is MiniInitDecl -> {}
} }
} }
} }
@@ -190,10 +203,10 @@ object CompletionEngineLight {
emitGroup(directMap) emitGroup(directMap)
emitGroup(inheritedMap) emitGroup(inheritedMap)
// Supplement with stdlib extension-like methods defined in root.lyng (e.g., fun String.re(...)) // Supplement with stdlib extension members defined in root.lyng (e.g., fun String.re(...))
run { run {
val already = (directMap.keys + inheritedMap.keys).toMutableSet() val already = (directMap.keys + inheritedMap.keys).toMutableSet()
val ext = BuiltinDocRegistry.extensionMethodNamesFor(className) val ext = BuiltinDocRegistry.extensionMemberNamesFor(className)
for (name in ext) { for (name in ext) {
if (already.contains(name)) continue if (already.contains(name)) continue
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, className, name) val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, className, name)
@@ -210,6 +223,7 @@ object CompletionEngineLight {
if (ci.name.startsWith(prefix, true)) out += ci if (ci.name.startsWith(prefix, true)) out += ci
already.add(name) already.add(name)
} }
is MiniInitDecl -> {}
} }
} else { } else {
// Fallback: emit simple method name without detailed types // Fallback: emit simple method name without detailed types
@@ -223,167 +237,16 @@ object CompletionEngineLight {
// --- Inference helpers (text-only, PSI-free) --- // --- Inference helpers (text-only, PSI-free) ---
private fun guessReceiverClass(text: String, dotPos: Int, imported: List<String>): String? { private suspend fun buildMiniAst(text: String): MiniScript? {
DocLookupUtils.guessClassFromCallBefore(text, dotPos, imported)?.let { return it }
var i = prevNonWs(text, dotPos - 1)
if (i >= 0) {
when (text[i]) {
'"' -> return "String"
']' -> return "List"
'}' -> return "Dict"
')' -> {
// Parenthesized expression: walk back to matching '(' and inspect the inner expression
var j = i - 1
var depth = 0
while (j >= 0) {
when (text[j]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
j--
}
if (j >= 0 && text[j] == '(') {
val innerS = (j + 1).coerceAtLeast(0)
val innerE = i.coerceAtMost(text.length)
if (innerS < innerE) {
val inner = text.substring(innerS, innerE).trim()
if (inner.startsWith('"') && inner.endsWith('"')) return "String"
if (inner.startsWith('[') && inner.endsWith(']')) return "List"
if (inner.startsWith('{') && inner.endsWith('}')) return "Dict"
}
}
}
}
// Numeric literal: decimal/int/hex/scientific
var j = i
var hasDigits = false
var hasDot = false
var hasExp = false
while (j >= 0) {
val ch = text[j]
if (ch.isDigit()) { hasDigits = true; j--; continue }
if (ch == '.') { hasDot = true; j--; continue }
if (ch == 'e' || ch == 'E') { hasExp = true; j--; if (j >= 0 && (text[j] == '+' || text[j] == '-')) j--; continue }
if (ch in listOf('x','X','a','b','c','d','f','A','B','C','D','F')) { j--; continue }
break
}
if (hasDigits) return if (hasDot || hasExp) "Real" else "Int"
}
return null
}
private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List<String>): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
i--
var depth = 0
while (i >= 0) {
when (text[i]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k < 0 || text[k] != '.') return null
val prevDot = k
val receiverClass = guessReceiverClass(text, prevDot, imported) ?: return null
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee) ?: return null
val member = resolved.second
val ret = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
}
return simpleClassNameOf(ret)
}
private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List<String>): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
i--
var depth = 0
while (i >= 0) {
when (text[i]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k >= 0 && text[k] == '.') return null // was a member call
for (mod in imported) {
val decls = BuiltinDocRegistry.docsForModule(mod)
val fn = decls.asSequence().filterIsInstance<MiniFunDecl>().firstOrNull { it.name == callee }
if (fn != null) return simpleClassNameOf(fn.returnType)
}
return null
}
private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List<String>): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
i--
var depth = 0
while (i >= 0) {
when (text[i]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
val resolved = DocLookupUtils.findMemberAcrossClasses(imported, callee) ?: return null
val member = resolved.second
val ret = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
}
return simpleClassNameOf(ret)
}
private fun simpleClassNameOf(t: MiniTypeRef?): String? = when (t) {
null -> null
is MiniTypeName -> t.segments.lastOrNull()?.name
is MiniGenericType -> simpleClassNameOf(t.base)
is MiniFunctionType -> null
is MiniTypeVar -> null
}
// --- MiniAst and small utils ---
private suspend fun buildMiniAst(text: String): MiniScript? = try {
val sink = MiniAstBuilder() val sink = MiniAstBuilder()
val src = Source("<engine>", text) return try {
val provider = LenientImportProvider.create() val src = Source("<engine>", text)
Compiler.compileWithMini(src, provider, sink) val provider = LenientImportProvider.create()
sink.build() Compiler.compileWithMini(src, provider, sink)
} catch (_: Throwable) { sink.build()
null } catch (_: Throwable) {
sink.build()
}
} }
private fun typeOf(t: MiniTypeRef?): String = when (t) { private fun typeOf(t: MiniTypeRef?): String = when (t) {
@@ -404,42 +267,8 @@ object CompletionEngineLight {
private fun prefixAt(text: String, offset: Int): String { private fun prefixAt(text: String, offset: Int): String {
val off = offset.coerceIn(0, text.length) val off = offset.coerceIn(0, text.length)
var i = (off - 1).coerceAtLeast(0) var i = (off - 1).coerceAtLeast(0)
while (i >= 0 && isIdentChar(text[i])) i-- while (i >= 0 && DocLookupUtils.isIdentChar(text[i])) i--
val start = i + 1 val start = i + 1
return if (start in 0..text.length && start <= off) text.substring(start, off) else "" return if (start in 0..text.length && start <= off) text.substring(start, off) else ""
} }
private fun wordRangeAt(text: String, offset: Int): Pair<Int, Int>? {
if (text.isEmpty()) return null
val off = offset.coerceIn(0, text.length)
var s = off
var e = off
while (s > 0 && isIdentChar(text[s - 1])) s--
while (e < text.length && isIdentChar(text[e])) e++
return if (s < e) s to e else null
}
private fun findDotLeft(text: String, offset: Int): Int? {
var i = (offset - 1).coerceAtLeast(0)
while (i >= 0 && text[i].isWhitespace()) i--
return if (i >= 0 && text[i] == '.') i else null
}
private fun prevNonWs(text: String, start: Int): Int {
var i = start.coerceAtMost(text.length - 1)
while (i >= 0 && text[i].isWhitespace()) i--
return i
}
private fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit()
private fun extractImportsFromText(text: String): List<String> {
val result = LinkedHashSet<String>()
val re = Regex("(?m)^\\s*import\\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)")
re.findAll(text).forEach { m ->
val raw = m.groupValues.getOrNull(1)?.trim().orEmpty()
if (raw.isNotEmpty()) result.add(if (raw.startsWith("lyng.")) raw else "lyng.$raw")
}
return result.toList()
}
} }
@@ -1,30 +1,176 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
/* /*
* Shared QuickDoc lookup helpers reusable outside the IDEA plugin. * Shared QuickDoc lookup helpers reusable outside the IDEA plugin.
*/ */
package net.sergeych.lyng.miniast package net.sergeych.lyng.miniast
import net.sergeych.lyng.binding.BindingSnapshot
import net.sergeych.lyng.highlight.offsetOf
object DocLookupUtils { object DocLookupUtils {
fun findDeclarationAt(mini: MiniScript, offset: Int, name: String): Pair<String, String>? {
val src = mini.range.start.source
fun matches(p: net.sergeych.lyng.Pos, len: Int) = src.offsetOf(p).let { s -> offset >= s && offset < s + len }
for (d in mini.declarations) {
if (matches(d.nameStart, d.name.length)) {
val kind = when (d) {
is MiniFunDecl -> "Function"
is MiniClassDecl -> "Class"
is MiniEnumDecl -> "Enum"
is MiniValDecl -> if (d.mutable) "Variable" else "Value"
}
return d.name to kind
}
val members = when (d) {
is MiniFunDecl -> {
for (p in d.params) {
if (matches(p.nameStart, p.name.length)) return p.name to "Parameter"
}
emptyList()
}
is MiniEnumDecl -> {
// Enum entries don't have explicit nameStart yet, but we can check their text range if we had it.
// For now, heuristic: if offset is within enum range and matches an entry name.
// To be more precise, we should check that we are NOT at the 'enum' or enum name.
if (offset >= src.offsetOf(d.range.start) && offset <= src.offsetOf(d.range.end)) {
if (d.entries.contains(name) && !matches(d.nameStart, d.name.length)) {
// verify we are actually at the entry word in text
val off = src.offsetOf(d.range.start)
val end = src.offsetOf(d.range.end)
val text = src.text.substring(off, end)
// This is still a bit loose but better
return name to "EnumConstant"
}
}
emptyList()
}
is MiniClassDecl -> {
for (cf in d.ctorFields) {
if (matches(cf.nameStart, cf.name.length)) return cf.name to (if (cf.mutable) "Variable" else "Value")
}
for (cf in d.classFields) {
if (matches(cf.nameStart, cf.name.length)) return cf.name to (if (cf.mutable) "Variable" else "Value")
}
d.members
}
else -> emptyList()
}
for (m in members) {
if (matches(m.nameStart, m.name.length)) {
val kind = when (m) {
is MiniMemberFunDecl -> "Function"
is MiniMemberValDecl -> if (m.isStatic) "Value" else (if (m.mutable) "Variable" else "Value")
is MiniInitDecl -> "Initializer"
}
return m.name to kind
}
}
}
return null
}
fun findTypeByRange(mini: MiniScript?, name: String, startOffset: Int): MiniTypeRef? {
if (mini == null) return null
val src = mini.range.start.source
for (d in mini.declarations) {
if (d.name == name && src.offsetOf(d.nameStart) == startOffset) {
return when (d) {
is MiniValDecl -> d.type
is MiniFunDecl -> d.returnType
else -> null
}
}
if (d is MiniFunDecl) {
for (p in d.params) {
if (p.name == name && src.offsetOf(p.nameStart) == startOffset) return p.type
}
}
if (d is MiniClassDecl) {
for (m in d.members) {
if (m.name == name && src.offsetOf(m.nameStart) == startOffset) {
return when (m) {
is MiniMemberFunDecl -> m.returnType
is MiniMemberValDecl -> m.type
else -> null
}
}
}
for (cf in d.ctorFields) {
if (cf.name == name && src.offsetOf(cf.nameStart) == startOffset) return cf.type
}
for (cf in d.classFields) {
if (cf.name == name && src.offsetOf(cf.nameStart) == startOffset) return cf.type
}
}
}
return null
}
/** /**
* Convert MiniAst imports to fully-qualified module names expected by BuiltinDocRegistry. * Convert MiniAst imports to fully-qualified module names expected by BuiltinDocRegistry.
* Heuristics: * Heuristics:
* - If an import does not start with "lyng.", prefix it with "lyng." (e.g., "io.fs" -> "lyng.io.fs"). * - If an import does not start with "lyng.", prefix it with "lyng." (e.g., "io.fs" -> "lyng.io.fs").
* - Keep original if it already starts with "lyng.". * - Keep original if it already starts with "lyng.".
* - Always include "lyng.stdlib" to make builtins available for docs. * - Always include "lyng.stdlib" to make builtins available for docs.
* - If [sourceText] is provided, uses textual extraction as a fallback if MiniAst has no imports.
*/ */
fun canonicalImportedModules(mini: MiniScript): List<String> { fun canonicalImportedModules(mini: MiniScript, sourceText: String? = null): List<String> {
val raw = mini.imports.map { it.segments.joinToString(".") { s -> s.name } } val raw = mini.imports.map { it.segments.joinToString(".") { s -> s.name } }
val result = LinkedHashSet<String>() val result = LinkedHashSet<String>()
for (name in raw) { for (name in raw) {
val canon = if (name.startsWith("lyng.")) name else "lyng.$name" val canon = if (name.startsWith("lyng.")) name else "lyng.$name"
result.add(canon) result.add(canon)
} }
if (result.isEmpty() && sourceText != null) {
result.addAll(extractImportsFromText(sourceText))
}
// Always make stdlib available as a fallback context for common types, // Always make stdlib available as a fallback context for common types,
// even when there are no explicit imports in the file // even when there are no explicit imports in the file
result.add("lyng.stdlib") result.add("lyng.stdlib")
return result.toList() return result.toList()
} }
fun aggregateClasses(importedModules: List<String>): Map<String, MiniClassDecl> { fun extractImportsFromText(text: String): List<String> {
val result = LinkedHashSet<String>()
val re = Regex("^\\s*import\\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)", RegexOption.MULTILINE)
re.findAll(text).forEach { m ->
val raw = m.groupValues.getOrNull(1)?.trim().orEmpty()
if (raw.isNotEmpty()) {
val canon = if (raw.startsWith("lyng.")) raw else "lyng.$raw"
result.add(canon)
}
}
return result.toList()
}
fun aggregateClasses(importedModules: List<String>, localMini: MiniScript? = null): Map<String, MiniClassDecl> {
// Collect all class decls by name across modules, then merge duplicates by unioning members and bases. // Collect all class decls by name across modules, then merge duplicates by unioning members and bases.
val buckets = LinkedHashMap<String, MutableList<MiniClassDecl>>() val buckets = LinkedHashMap<String, MutableList<MiniClassDecl>>()
for (mod in importedModules) { for (mod in importedModules) {
@@ -32,6 +178,23 @@ object DocLookupUtils {
for (cls in docs.filterIsInstance<MiniClassDecl>()) { for (cls in docs.filterIsInstance<MiniClassDecl>()) {
buckets.getOrPut(cls.name) { mutableListOf() }.add(cls) buckets.getOrPut(cls.name) { mutableListOf() }.add(cls)
} }
for (en in docs.filterIsInstance<MiniEnumDecl>()) {
buckets.getOrPut(en.name) { mutableListOf() }.add(enumToSyntheticClass(en))
}
}
// Add local declarations
localMini?.declarations?.forEach { d ->
when (d) {
is MiniClassDecl -> {
buckets.getOrPut(d.name) { mutableListOf() }.add(d)
}
is MiniEnumDecl -> {
val syn = enumToSyntheticClass(d)
buckets.getOrPut(d.name) { mutableListOf() }.add(syn)
}
else -> {}
}
} }
fun mergeClassDecls(name: String, list: List<MiniClassDecl>): MiniClassDecl { fun mergeClassDecls(name: String, list: List<MiniClassDecl>): MiniClassDecl {
@@ -72,8 +235,8 @@ object DocLookupUtils {
return result return result
} }
fun resolveMemberWithInheritance(importedModules: List<String>, className: String, member: String): Pair<String, MiniMemberDecl>? { fun resolveMemberWithInheritance(importedModules: List<String>, className: String, member: String, localMini: MiniScript? = null): Pair<String, MiniMemberDecl>? {
val classes = aggregateClasses(importedModules) val classes = aggregateClasses(importedModules, localMini)
fun dfs(name: String, visited: MutableSet<String>): Pair<String, MiniMemberDecl>? { fun dfs(name: String, visited: MutableSet<String>): Pair<String, MiniMemberDecl>? {
val cls = classes[name] ?: return null val cls = classes[name] ?: return null
cls.members.firstOrNull { it.name == member }?.let { return name to it } cls.members.firstOrNull { it.name == member }?.let { return name to it }
@@ -86,12 +249,12 @@ object DocLookupUtils {
return dfs(className, mutableSetOf()) return dfs(className, mutableSetOf())
} }
fun findMemberAcrossClasses(importedModules: List<String>, member: String): Pair<String, MiniMemberDecl>? { fun findMemberAcrossClasses(importedModules: List<String>, member: String, localMini: MiniScript? = null): Pair<String, MiniMemberDecl>? {
val classes = aggregateClasses(importedModules) val classes = aggregateClasses(importedModules, localMini)
// Preferred order for ambiguous common ops // Preferred order for ambiguous common ops
val preference = listOf("Iterable", "Iterator", "List") val preference = listOf("Iterable", "Iterator", "List")
for (name in preference) { for (name in preference) {
resolveMemberWithInheritance(importedModules, name, member)?.let { return it } resolveMemberWithInheritance(importedModules, name, member, localMini)?.let { return it }
} }
for ((name, cls) in classes) { for ((name, cls) in classes) {
cls.members.firstOrNull { it.name == member }?.let { return name to it } cls.members.firstOrNull { it.name == member }?.let { return name to it }
@@ -104,12 +267,8 @@ object DocLookupUtils {
* We walk left from the dot, find a matching `)` and then the identifier immediately before the `(`. * We walk left from the dot, find a matching `)` and then the identifier immediately before the `(`.
* If that identifier matches a known class in any of the imported modules, return it. * If that identifier matches a known class in any of the imported modules, return it.
*/ */
fun guessClassFromCallBefore(text: String, dotPos: Int, importedModules: List<String>): String? { fun guessClassFromCallBefore(text: String, dotPos: Int, importedModules: List<String>, localMini: MiniScript? = null): String? {
var i = (dotPos - 1).coerceAtLeast(0) var i = (dotPos - 1).coerceAtLeast(0)
// Skip spaces
while (i >= 0 && text[i].isWhitespace()) i++
// Note: the previous line is wrong direction; correct implementation below
i = (dotPos - 1).coerceAtLeast(0)
while (i >= 0 && text[i].isWhitespace()) i-- while (i >= 0 && text[i].isWhitespace()) i--
if (i < 0 || text[i] != ')') return null if (i < 0 || text[i] != ')') return null
// Walk back to matching '(' accounting nested parentheses // Walk back to matching '(' accounting nested parentheses
@@ -134,9 +293,396 @@ object DocLookupUtils {
if (start >= end) return null if (start >= end) return null
val name = text.substring(start, end) val name = text.substring(start, end)
// Validate against imported classes // Validate against imported classes
val classes = aggregateClasses(importedModules) val classes = aggregateClasses(importedModules, localMini)
return if (classes.containsKey(name)) name else null return if (classes.containsKey(name)) name else null
} }
private fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit() fun guessReceiverClassViaMini(mini: MiniScript?, text: String, dotPos: Int, imported: List<String>, binding: BindingSnapshot? = null): String? {
if (mini == null) return null
val i = prevNonWs(text, dotPos - 1)
if (i < 0) return null
val wordRange = wordRangeAt(text, i + 1) ?: return null
val ident = text.substring(wordRange.first, wordRange.second)
// 0) Use binding if available for precise resolution
if (binding != null) {
val ref = binding.references.firstOrNull { wordRange.first >= it.start && wordRange.first < it.end }
if (ref != null) {
val sym = binding.symbols.firstOrNull { it.id == ref.symbolId }
if (sym != null) {
val type = findTypeByRange(mini, sym.name, sym.declStart)
simpleClassNameOf(type)?.let { return it }
}
} else {
// Check if it's a declaration (e.g. static access to a class)
val sym = binding.symbols.firstOrNull { it.declStart == wordRange.first && it.name == ident }
if (sym != null) {
val type = findTypeByRange(mini, sym.name, sym.declStart)
simpleClassNameOf(type)?.let { return it }
// if it's a class/enum, return its name directly
if (sym.kind == net.sergeych.lyng.binding.SymbolKind.Class || sym.kind == net.sergeych.lyng.binding.SymbolKind.Enum) return sym.name
}
}
}
// 1) Global declarations in current file (val/var/fun/class/enum)
val d = mini.declarations.firstOrNull { it.name == ident }
if (d != null) {
return when (d) {
is MiniClassDecl -> d.name
is MiniEnumDecl -> d.name
is MiniValDecl -> simpleClassNameOf(d.type)
is MiniFunDecl -> simpleClassNameOf(d.returnType)
}
}
// 2) Parameters in any function (best-effort fallback without scope mapping)
for (fd in mini.declarations.filterIsInstance<MiniFunDecl>()) {
for (p in fd.params) {
if (p.name == ident) return simpleClassNameOf(p.type)
}
}
// 3) Recursive chaining: Base.ident.
val dotBefore = findDotLeft(text, wordRange.first)
if (dotBefore != null) {
val receiverClass = guessReceiverClassViaMini(mini, text, dotBefore, imported, binding)
?: guessReceiverClass(text, dotBefore, imported, mini)
if (receiverClass != null) {
val resolved = resolveMemberWithInheritance(imported, receiverClass, ident, mini)
if (resolved != null) {
val rt = when (val m = resolved.second) {
is MiniMemberFunDecl -> m.returnType
is MiniMemberValDecl -> m.type
else -> null
}
return simpleClassNameOf(rt)
}
}
}
// 4) Check if it's a known class (static access)
val classes = aggregateClasses(imported, mini)
if (classes.containsKey(ident)) return ident
return null
}
fun guessReturnClassFromMemberCallBeforeMini(mini: MiniScript?, text: String, dotPos: Int, imported: List<String>, binding: BindingSnapshot? = null): String? {
if (mini == null) return null
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
// back to matching '('
i--
var depth = 0
while (i >= 0) {
when (text[i]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
// Ensure member call: dot before callee
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k < 0 || text[k] != '.') return null
val prevDot = k
// Resolve receiver class via MiniAst (ident like `x`)
val receiverClass = guessReceiverClassViaMini(mini, text, prevDot, imported, binding) ?: return null
// If receiver class is a locally declared class, resolve member on it
val localClass = mini.declarations.filterIsInstance<MiniClassDecl>().firstOrNull { it.name == receiverClass }
if (localClass != null) {
val mm = localClass.members.firstOrNull { it.name == callee }
if (mm != null) {
val rt = when (mm) {
is MiniMemberFunDecl -> mm.returnType
is MiniMemberValDecl -> mm.type
else -> null
}
return simpleClassNameOf(rt)
} else {
// Try to scan class body text for method signature and extract return type
val sigs = scanLocalClassMembersFromText(mini, text, localClass)
val sig = sigs[callee]
if (sig != null && sig.typeText != null) return sig.typeText
}
}
// Else fallback to registry-based resolution (covers imported classes)
return resolveMemberWithInheritance(imported, receiverClass, callee, mini)?.second?.let { m ->
val rt = when (m) {
is MiniMemberFunDecl -> m.returnType
is MiniMemberValDecl -> m.type
is MiniInitDecl -> null
}
simpleClassNameOf(rt)
}
}
data class ScannedSig(val kind: String, val params: List<String>?, val typeText: String?)
fun scanLocalClassMembersFromText(mini: MiniScript, text: String, cls: MiniClassDecl): Map<String, ScannedSig> {
val src = mini.range.start.source
val start = src.offsetOf(cls.bodyRange?.start ?: cls.range.start)
val end = src.offsetOf(cls.bodyRange?.end ?: cls.range.end).coerceAtMost(text.length)
if (start !in 0..end) return emptyMap()
val body = text.substring(start, end)
val map = LinkedHashMap<String, ScannedSig>()
// fun name(params): Type
val funRe = Regex("^\\s*fun\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(([^)]*)\\)\\s*(?::\\s*([A-Za-z_][A-Za-z0-9_]*))?", RegexOption.MULTILINE)
for (m in funRe.findAll(body)) {
val name = m.groupValues.getOrNull(1) ?: continue
val params = m.groupValues.getOrNull(2)?.split(',')?.mapNotNull { it.trim().takeIf { it.isNotEmpty() } } ?: emptyList()
val type = m.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() }
map[name] = ScannedSig("fun", params, type)
}
// val/var name: Type
val valRe = Regex("^\\s*(val|var)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?::\\s*([A-Za-z_][A-Za-z0-9_]*))?", RegexOption.MULTILINE)
for (m in valRe.findAll(body)) {
val kind = m.groupValues.getOrNull(1) ?: continue
val name = m.groupValues.getOrNull(2) ?: continue
val type = m.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() }
if (!map.containsKey(name)) map[name] = ScannedSig(kind, null, type)
}
return map
}
fun guessReceiverClass(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
guessClassFromCallBefore(text, dotPos, imported, mini)?.let { return it }
var i = prevNonWs(text, dotPos - 1)
if (i >= 0) {
when (text[i]) {
'"' -> return "String"
']' -> return "List"
'}' -> return "Dict"
')' -> {
// Parenthesized expression: walk back to matching '(' and inspect the inner expression
var j = i - 1
var depth = 0
while (j >= 0) {
when (text[j]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
j--
}
if (j >= 0 && text[j] == '(') {
val innerS = (j + 1).coerceAtLeast(0)
val innerE = i.coerceAtMost(text.length)
if (innerS < innerE) {
val inner = text.substring(innerS, innerE).trim()
if (inner.startsWith('"') && inner.endsWith('"')) return "String"
if (inner.startsWith('[') && inner.endsWith(']')) return "List"
if (inner.startsWith('{') && inner.endsWith('}')) return "Dict"
}
}
}
}
// Numeric literal: decimal/int/hex/scientific
var j = i
var hasDigits = false
var hasDot = false
var hasExp = false
while (j >= 0) {
val ch = text[j]
if (ch.isDigit()) {
hasDigits = true; j--; continue
}
if (ch == '.') {
hasDot = true; j--; continue
}
if (ch == 'e' || ch == 'E') {
hasExp = true; j--; if (j >= 0 && (text[j] == '+' || text[j] == '-')) j--; continue
}
if (ch in listOf('x', 'X', 'a', 'b', 'c', 'd', 'f', 'A', 'B', 'C', 'D', 'F')) {
j--; continue
}
break
}
if (hasDigits) return if (hasDot || hasExp) "Real" else "Int"
// 3) this@Type or as Type
val identRange = wordRangeAt(text, i + 1)
if (identRange != null) {
val ident = text.substring(identRange.first, identRange.second)
// if it's "as Type", we want Type
var k = prevNonWs(text, identRange.first - 1)
if (k >= 1 && text[k] == 's' && text[k - 1] == 'a' && (k - 1 == 0 || !text[k - 2].isLetterOrDigit())) {
return ident
}
// if it's "this@Type", we want Type
if (k >= 0 && text[k] == '@') {
val k2 = prevNonWs(text, k - 1)
if (k2 >= 3 && text.substring(k2 - 3, k2 + 1) == "this") {
return ident
}
}
// 4) Check if it's a known class (static access)
val classes = aggregateClasses(imported, mini)
if (classes.containsKey(ident)) return ident
}
}
return null
}
fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
i--
var depth = 0
while (i >= 0) {
when (text[i]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k < 0 || text[k] != '.') return null
val prevDot = k
val receiverClass = guessReceiverClass(text, prevDot, imported, mini) ?: return null
val resolved = resolveMemberWithInheritance(imported, receiverClass, callee, mini) ?: return null
val member = resolved.second
val ret = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
is MiniInitDecl -> null
}
return simpleClassNameOf(ret)
}
fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
i--
var depth = 0
while (i >= 0) {
when (text[i]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k >= 0 && text[k] == '.') return null // was a member call
for (mod in imported) {
val decls = BuiltinDocRegistry.docsForModule(mod)
val fn = decls.asSequence().filterIsInstance<MiniFunDecl>().firstOrNull { it.name == callee }
if (fn != null) return simpleClassNameOf(fn.returnType)
}
// Also check local declarations
mini?.declarations?.filterIsInstance<MiniFunDecl>()?.firstOrNull { it.name == callee }?.let { return simpleClassNameOf(it.returnType) }
return null
}
fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
i--
var depth = 0
while (i >= 0) {
when (text[i]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
val resolved = findMemberAcrossClasses(imported, callee, mini) ?: return null
val member = resolved.second
val ret = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
is MiniInitDecl -> null
}
return simpleClassNameOf(ret)
}
fun simpleClassNameOf(t: MiniTypeRef?): String? = when (t) {
null -> null
is MiniTypeName -> t.segments.lastOrNull()?.name
is MiniGenericType -> simpleClassNameOf(t.base)
is MiniFunctionType -> null
is MiniTypeVar -> null
}
fun findDotLeft(text: String, offset: Int): Int? {
var i = (offset - 1).coerceAtLeast(0)
while (i >= 0 && text[i].isWhitespace()) i--
return if (i >= 0 && text[i] == '.') i else null
}
fun prevNonWs(text: String, start: Int): Int {
var i = start.coerceAtMost(text.length - 1)
while (i >= 0 && text[i].isWhitespace()) i--
return i
}
fun wordRangeAt(text: String, offset: Int): Pair<Int, Int>? {
if (text.isEmpty()) return null
val off = offset.coerceIn(0, text.length)
var s = off
var e = off
while (s > 0 && isIdentChar(text[s - 1])) s--
while (e < text.length && isIdentChar(text[e])) e++
return if (s < e) s to e else null
}
fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit()
fun enumToSyntheticClass(en: MiniEnumDecl): MiniClassDecl {
val staticMembers = mutableListOf<MiniMemberDecl>()
// entries: List
staticMembers.add(MiniMemberValDecl(en.range, "entries", false, null, null, en.nameStart, isStatic = true))
// valueOf(name: String): Enum
staticMembers.add(MiniMemberFunDecl(en.range, "valueOf", listOf(MiniParam("name", null, en.nameStart)), null, null, en.nameStart, isStatic = true))
// Also add each entry as a static member (const)
for (entry in en.entries) {
staticMembers.add(MiniMemberValDecl(en.range, entry, false, MiniTypeName(en.range, listOf(MiniTypeName.Segment(en.name, en.range)), false), null, en.nameStart, isStatic = true))
}
return MiniClassDecl(
range = en.range,
name = en.name,
bases = listOf("Enum"),
bodyRange = null,
doc = en.doc,
nameStart = en.nameStart,
members = staticMembers
)
}
} }
@@ -100,7 +100,7 @@ fun ObjClass.addFnDoc(
code: suspend Scope.() -> Obj code: suspend Scope.() -> Obj
) { ) {
// Register runtime method // Register runtime method
addFn(name, isOpen, visibility, code) addFn(name, isOpen, visibility, code = code)
// Register docs for the member under this class // Register docs for the member under this class
BuiltinDocRegistry.module(moduleName ?: ownerModuleNameFromClassOrUnknown()) { BuiltinDocRegistry.module(moduleName ?: ownerModuleNameFromClassOrUnknown()) {
classDoc(this@addFnDoc.className, doc = "") { classDoc(this@addFnDoc.className, doc = "") {
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -109,7 +109,8 @@ data class MiniFunDecl(
val returnType: MiniTypeRef?, val returnType: MiniTypeRef?,
val body: MiniBlock?, val body: MiniBlock?,
override val doc: MiniDoc?, override val doc: MiniDoc?,
override val nameStart: Pos override val nameStart: Pos,
val receiver: MiniTypeRef? = null
) : MiniDecl ) : MiniDecl
data class MiniValDecl( data class MiniValDecl(
@@ -119,7 +120,8 @@ data class MiniValDecl(
val type: MiniTypeRef?, val type: MiniTypeRef?,
val initRange: MiniRange?, val initRange: MiniRange?,
override val doc: MiniDoc?, override val doc: MiniDoc?,
override val nameStart: Pos override val nameStart: Pos,
val receiver: MiniTypeRef? = null
) : MiniDecl ) : MiniDecl
data class MiniClassDecl( data class MiniClassDecl(
@@ -135,6 +137,14 @@ data class MiniClassDecl(
val members: List<MiniMemberDecl> = emptyList() val members: List<MiniMemberDecl> = emptyList()
) : MiniDecl ) : MiniDecl
data class MiniEnumDecl(
override val range: MiniRange,
override val name: String,
val entries: List<String>,
override val doc: MiniDoc?,
override val nameStart: Pos
) : MiniDecl
data class MiniCtorField( data class MiniCtorField(
val name: String, val name: String,
val mutable: Boolean, val mutable: Boolean,
@@ -183,6 +193,15 @@ data class MiniMemberValDecl(
override val isStatic: Boolean = false, override val isStatic: Boolean = false,
) : MiniMemberDecl ) : MiniMemberDecl
data class MiniInitDecl(
override val range: MiniRange,
override val nameStart: Pos,
) : MiniMemberDecl {
override val name: String get() = "init"
override val doc: MiniDoc? get() = null
override val isStatic: Boolean get() = false
}
// Streaming sink to collect mini-AST during parsing. Implementations may assemble a tree or process events. // Streaming sink to collect mini-AST during parsing. Implementations may assemble a tree or process events.
interface MiniAstSink { interface MiniAstSink {
fun onScriptStart(start: Pos) {} fun onScriptStart(start: Pos) {}
@@ -193,7 +212,9 @@ interface MiniAstSink {
fun onImport(node: MiniImport) {} fun onImport(node: MiniImport) {}
fun onFunDecl(node: MiniFunDecl) {} fun onFunDecl(node: MiniFunDecl) {}
fun onValDecl(node: MiniValDecl) {} fun onValDecl(node: MiniValDecl) {}
fun onInitDecl(node: MiniInitDecl) {}
fun onClassDecl(node: MiniClassDecl) {} fun onClassDecl(node: MiniClassDecl) {}
fun onEnumDecl(node: MiniEnumDecl) {}
fun onBlock(node: MiniBlock) {} fun onBlock(node: MiniBlock) {}
fun onIdentifier(node: MiniIdentifier) {} fun onIdentifier(node: MiniIdentifier) {}
@@ -263,6 +284,12 @@ class MiniAstBuilder : MiniAstSink {
lastDoc = null lastDoc = null
} }
override fun onEnumDecl(node: MiniEnumDecl) {
val attach = node.copy(doc = node.doc ?: lastDoc)
currentScript?.declarations?.add(attach)
lastDoc = null
}
override fun onBlock(node: MiniBlock) { override fun onBlock(node: MiniBlock) {
blocks.addLast(node) blocks.addLast(node)
} }
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -83,23 +83,52 @@ open class Obj {
scope: Scope, scope: Scope,
name: String, name: String,
args: Arguments = Arguments.EMPTY, args: Arguments = Arguments.EMPTY,
onNotFoundResult: (() -> Obj?)? = null onNotFoundResult: (suspend () -> Obj?)? = null
): Obj { ): Obj {
val rec = objClass.getInstanceMemberOrNull(name) // 1. Hierarchy members (excluding root fallback)
if (rec != null) { for (cls in objClass.mro) {
val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name) if (cls.className == "Obj") break
val caller = scope.currentClassCtx val rec = cls.members[name] ?: cls.classScope?.objects?.get(name)
if (!canAccessMember(rec.visibility, decl, caller)) if (rec != null) {
scope.raiseError(ObjAccessException(scope, "can't invoke ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})")) val decl = rec.declaringClass ?: cls
// Propagate declaring class as current class context during method execution val caller = scope.currentClassCtx
val saved = scope.currentClassCtx if (!canAccessMember(rec.visibility, decl, caller))
scope.currentClassCtx = decl scope.raiseError(ObjAccessException(scope, "can't invoke ${name}: not visible (declared in ${decl.className}, caller ${caller?.className ?: "?"})"))
try { val saved = scope.currentClassCtx
return rec.value.invoke(scope, this, args) scope.currentClassCtx = decl
} finally { try {
scope.currentClassCtx = saved return rec.value.invoke(scope, this, args)
} finally {
scope.currentClassCtx = saved
}
} }
} }
// 2. Extensions in scope
val extension = scope.findExtension(objClass, name)
if (extension != null) {
return extension.value.invoke(scope, this, args)
}
// 3. Root object fallback
for (cls in objClass.mro) {
if (cls.className == "Obj") {
cls.members[name]?.let { rec ->
val decl = rec.declaringClass ?: cls
val caller = scope.currentClassCtx
if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError(ObjAccessException(scope, "can't invoke ${name}: not visible (declared in ${decl.className}, caller ${caller?.className ?: "?"})"))
val saved = scope.currentClassCtx
scope.currentClassCtx = decl
try {
return rec.value.invoke(scope, this, args)
} finally {
scope.currentClassCtx = saved
}
}
}
}
return onNotFoundResult?.invoke() return onNotFoundResult?.invoke()
?: scope.raiseError( ?: scope.raiseError(
"no such member: $name on ${objClass.className}. Considered order: ${objClass.renderLinearization(true)}. " + "no such member: $name on ${objClass.className}. Considered order: ${objClass.renderLinearization(true)}. " +
@@ -120,6 +149,8 @@ open class Obj {
// methods that to override // methods that to override
open suspend fun compareTo(scope: Scope, other: Obj): Int { open suspend fun compareTo(scope: Scope, other: Obj): Int {
if( other === this) return 0
if( other === ObjNull ) return 2
scope.raiseNotImplemented() scope.raiseNotImplemented()
} }
@@ -127,6 +158,40 @@ open class Obj {
return invokeInstanceMethod(scope, "contains", other).toBool() return invokeInstanceMethod(scope, "contains", other).toBool()
} }
/**
* Call [callback] for each element of this obj considering it provides [Iterator]
* methods `hasNext` and `next`.
*
* IF callback returns false, iteration is stopped.
*/
open suspend fun enumerate(scope: Scope, callback: suspend (Obj) -> Boolean) {
val iterator = invokeInstanceMethod(scope, "iterator")
val hasNext = iterator.getInstanceMethod(scope, "hasNext")
val next = iterator.getInstanceMethod(scope, "next")
var closeIt = false
try {
while (hasNext.invoke(scope, iterator).toBool()) {
val nextValue = next.invoke(scope, iterator)
val shouldContinue = try {
callback(nextValue)
} catch (e: Exception) {
// iteration aborted due to exception in callback
closeIt = true
throw e
}
if (!shouldContinue) {
closeIt = true
break
}
}
} finally {
if (closeIt) {
// Best-effort cancel on premature termination
iterator.invokeInstanceMethod(scope, "cancelIteration") { ObjVoid }
}
}
}
/** /**
* Default toString implementation: * Default toString implementation:
* *
@@ -142,15 +207,19 @@ open class Obj {
* @param calledFromLyng true if called from Lyng's `toString`. Normally this parameter should be ignored, * @param calledFromLyng true if called from Lyng's `toString`. Normally this parameter should be ignored,
* but it is used to avoid endless recursion in [Obj.toString] base implementation * but it is used to avoid endless recursion in [Obj.toString] base implementation
*/ */
open suspend fun toString(scope: Scope,calledFromLyng: Boolean=false): ObjString { open suspend fun toString(scope: Scope, calledFromLyng: Boolean = false): ObjString {
return if (this is ObjString) this if (this is ObjString) return this
else if( !calledFromLyng ) { return if (!calledFromLyng) {
invokeInstanceMethod(scope, "toString") { invokeInstanceMethod(scope, "toString", Arguments.EMPTY) {
ObjString(this.toString()) defaultToString(scope)
} as ObjString } as ObjString
} else { ObjString(this.toString()) } } else {
defaultToString(scope)
}
} }
open suspend fun defaultToString(scope: Scope): ObjString = ObjString(this.toString())
/** /**
* Class of the object: definition of member functions (top-level), etc. * Class of the object: definition of member functions (top-level), etc.
* Note that using lazy allows to avoid endless recursion here * Note that using lazy allows to avoid endless recursion here
@@ -228,7 +297,7 @@ open class Obj {
open suspend fun assign(scope: Scope, other: Obj): Obj? = null open suspend fun assign(scope: Scope, other: Obj): Obj? = null
open fun getValue(scope: Scope) = this open suspend fun getValue(scope: Scope) = this
/** /**
* a += b * a += b
@@ -275,34 +344,83 @@ open class Obj {
// suspend fun <T> sync(block: () -> T): T = monitor.withLock { block() } // suspend fun <T> sync(block: () -> T): T = monitor.withLock { block() }
open suspend fun readField(scope: Scope, name: String): ObjRecord { open suspend fun readField(scope: Scope, name: String): ObjRecord {
// could be property or class field: // 1. Hierarchy members (excluding root fallback)
val obj = objClass.getInstanceMemberOrNull(name) ?: scope.raiseError( for (cls in objClass.mro) {
if (cls.className == "Obj") break
cls.members[name]?.let { return resolveRecord(scope, it, name, it.declaringClass) }
cls.classScope?.objects?.get(name)?.let { return resolveRecord(scope, it, name, it.declaringClass) }
}
// 2. Extensions
val extension = scope.findExtension(objClass, name)
if (extension != null) {
return resolveRecord(scope, extension, name, extension.declaringClass)
}
// 3. Root fallback
for (cls in objClass.mro) {
if (cls.className == "Obj") {
cls.members[name]?.let {
val decl = it.declaringClass ?: cls
return resolveRecord(scope, it, name, decl)
}
}
}
scope.raiseError(
"no such field: $name on ${objClass.className}. Considered order: ${objClass.renderLinearization(true)}" "no such field: $name on ${objClass.className}. Considered order: ${objClass.renderLinearization(true)}"
) )
val decl = obj.declaringClass ?: objClass.findDeclaringClassOf(name) }
protected suspend fun resolveRecord(scope: Scope, obj: ObjRecord, name: String, decl: ObjClass?): ObjRecord {
val value = obj.value
if (value is ObjProperty) {
return ObjRecord(value.callGetter(scope, this, decl), obj.isMutable)
}
if (value is Statement && decl != null) {
return ObjRecord(value.execute(scope.createChildScope(scope.pos, newThisObj = this)), obj.isMutable)
}
val caller = scope.currentClassCtx val caller = scope.currentClassCtx
// Check visibility for non-property members here if they weren't checked before
if (!canAccessMember(obj.visibility, decl, caller)) if (!canAccessMember(obj.visibility, decl, caller))
scope.raiseError(ObjAccessException(scope, "can't access field ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})")) scope.raiseError(ObjAccessException(scope, "can't access field ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})"))
return when (val value = obj.value) { return obj
is Statement -> {
ObjRecord(value.execute(scope.createChildScope(scope.pos, newThisObj = this)), obj.isMutable)
}
// could be writable property naturally
// null -> ObjNull.asReadonly
else -> obj
}
} }
open suspend fun writeField(scope: Scope, name: String, newValue: Obj) { open suspend fun writeField(scope: Scope, name: String, newValue: Obj) {
willMutate(scope) willMutate(scope)
val field = objClass.getInstanceMemberOrNull(name) ?: scope.raiseError( var field: ObjRecord? = null
// 1. Hierarchy members (excluding root fallback)
for (cls in objClass.mro) {
if (cls.className == "Obj") break
field = cls.members[name] ?: cls.classScope?.objects?.get(name)
if (field != null) break
}
// 2. Extensions
if (field == null) {
field = scope.findExtension(objClass, name)
}
// 3. Root fallback
if (field == null) {
for (cls in objClass.mro) {
if (cls.className == "Obj") {
field = cls.members[name]
if (field != null) break
}
}
}
if (field == null) scope.raiseError(
"no such field: $name on ${objClass.className}. Considered order: ${objClass.renderLinearization(true)}" "no such field: $name on ${objClass.className}. Considered order: ${objClass.renderLinearization(true)}"
) )
val decl = field.declaringClass ?: objClass.findDeclaringClassOf(name)
val decl = field.declaringClass
val caller = scope.currentClassCtx val caller = scope.currentClassCtx
if (!canAccessMember(field.visibility, decl, caller)) if (!canAccessMember(field.effectiveWriteVisibility, decl, caller))
scope.raiseError(ObjAccessException(scope, "can't assign field ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})")) scope.raiseError(ObjAccessException(scope, "can't assign field ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})"))
if (field.isMutable) field.value = newValue else scope.raiseError("can't assign to read-only field: $name") if (field.value is ObjProperty) {
(field.value as ObjProperty).callSetter(scope, this, newValue, decl)
} else if (field.isMutable) field.value = newValue else scope.raiseError("can't assign to read-only field: $name")
} }
open suspend fun getAt(scope: Scope, index: Obj): Obj { open suspend fun getAt(scope: Scope, index: Obj): Obj {
@@ -440,8 +558,8 @@ open class Obj {
is Obj -> obj is Obj -> obj
is Double -> ObjReal(obj) is Double -> ObjReal(obj)
is Float -> ObjReal(obj.toDouble()) is Float -> ObjReal(obj.toDouble())
is Int -> ObjInt(obj.toLong()) is Int -> ObjInt.of(obj.toLong())
is Long -> ObjInt(obj) is Long -> ObjInt.of(obj)
is String -> ObjString(obj) is String -> ObjString(obj)
is CharSequence -> ObjString(obj.toString()) is CharSequence -> ObjString(obj.toString())
is Boolean -> ObjBool(obj) is Boolean -> ObjBool(obj)
@@ -547,6 +665,50 @@ object ObjNull : Obj() {
} }
} }
@Serializable
@SerialName("unset")
object ObjUnset : Obj() {
override suspend fun compareTo(scope: Scope, other: Obj): Int = if (other === this) 0 else -1
override fun equals(other: Any?): Boolean = other === this
override fun toString(): String = "Unset"
override suspend fun readField(scope: Scope, name: String): ObjRecord = scope.raiseUnset()
override suspend fun invokeInstanceMethod(
scope: Scope,
name: String,
args: Arguments,
onNotFoundResult: (suspend () -> Obj?)?
): Obj = scope.raiseUnset()
override suspend fun getAt(scope: Scope, index: Obj): Obj = scope.raiseUnset()
override suspend fun putAt(scope: Scope, index: Obj, newValue: Obj) = scope.raiseUnset()
override suspend fun callOn(scope: Scope): Obj = scope.raiseUnset()
override suspend fun plus(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun minus(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun negate(scope: Scope): Obj = scope.raiseUnset()
override suspend fun mul(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun div(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun mod(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun logicalNot(scope: Scope): Obj = scope.raiseUnset()
override suspend fun logicalAnd(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun logicalOr(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun operatorMatch(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun bitAnd(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun bitOr(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun bitXor(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun shl(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun shr(scope: Scope, other: Obj): Obj = scope.raiseUnset()
override suspend fun bitNot(scope: Scope): Obj = scope.raiseUnset()
override val objClass: ObjClass by lazy {
object : ObjClass("Unset") {
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj {
return ObjUnset
}
}
}
}
/** /**
* TODO: get rid of it. Maybe we ise some Lyng inheritance instead * TODO: get rid of it. Maybe we ise some Lyng inheritance instead
*/ */
@@ -53,6 +53,12 @@ class ObjChar(val value: Char): Obj() {
returns = type("lyng.Int"), returns = type("lyng.Int"),
moduleName = "lyng.stdlib" moduleName = "lyng.stdlib"
) { ObjInt(thisAs<ObjChar>().value.code.toLong()) } ) { ObjInt(thisAs<ObjChar>().value.code.toLong()) }
addFn("isDigit") {
thisAs<ObjChar>().value.isDigit().toObj()
}
addFn("isSpace") {
thisAs<ObjChar>().value.isWhitespace().toObj()
}
} }
} }
} }
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -175,7 +175,11 @@ open class ObjClass(
} }
/** Full C3 MRO including this class at index 0. */ /** Full C3 MRO including this class at index 0. */
val mro: List<ObjClass> by lazy { c3Linearize(this, mutableMapOf()) } val mro: List<ObjClass> by lazy {
val base = c3Linearize(this, mutableMapOf())
if (this.className == "Obj" || base.any { it.className == "Obj" }) base
else base + rootObjectType
}
/** Parents in C3 order (no self). */ /** Parents in C3 order (no self). */
val mroParents: List<ObjClass> by lazy { mro.drop(1) } val mroParents: List<ObjClass> by lazy { mro.drop(1) }
@@ -200,12 +204,23 @@ open class ObjClass(
override suspend fun compareTo(scope: Scope, other: Obj): Int = if (other === this) 0 else -1 override suspend fun compareTo(scope: Scope, other: Obj): Int = if (other === this) 0 else -1
override suspend fun callOn(scope: Scope): Obj { override suspend fun callOn(scope: Scope): Obj {
val instance = createInstance(scope)
initializeInstance(instance, scope.args, runConstructors = true)
return instance
}
/**
* Create an instance of this class and initialize its [ObjInstance.instanceScope] with
* methods. Does NOT run initializers or constructors.
*/
internal fun createInstance(scope: Scope): ObjInstance {
val instance = ObjInstance(this) val instance = ObjInstance(this)
// Avoid capturing a transient (pooled) call frame as the parent of the instance scope. // Avoid capturing a transient (pooled) call frame as the parent of the instance scope.
// Bind instance scope to the caller's parent chain directly so name resolution (e.g., stdlib like sqrt) // Bind instance scope to the caller's parent chain directly so name resolution (e.g., stdlib like sqrt)
// remains stable even when call frames are pooled and reused. // remains stable even when call frames are pooled and reused.
val stableParent = scope.parent val stableParent = classScope ?: scope.parent
instance.instanceScope = Scope(stableParent, scope.args, scope.pos, instance) instance.instanceScope = Scope(stableParent, scope.args, scope.pos, instance)
instance.instanceScope.currentClassCtx = null
// Expose instance methods (and other callable members) directly in the instance scope for fast lookup // Expose instance methods (and other callable members) directly in the instance scope for fast lookup
// This mirrors Obj.autoInstanceScope behavior for ad-hoc scopes and makes fb.method() resolution robust // This mirrors Obj.autoInstanceScope behavior for ad-hoc scopes and makes fb.method() resolution robust
// 1) members-defined methods // 1) members-defined methods
@@ -223,75 +238,101 @@ open class ObjClass(
} }
} }
} }
// Constructor chaining MVP: initialize base classes left-to-right, then this class. return instance
// Ensure each ancestor is initialized at most once (diamond-safe). }
val visited = hashSetOf<ObjClass>()
suspend fun initClass(c: ObjClass, argsForThis: Arguments?, isRoot: Boolean = false) { /**
if (!visited.add(c)) return * Run initializers and optionally constructors for the given [instance].
// For the most-derived class, bind its constructor params BEFORE parents so base arg thunks can see them * Handles Multiple Inheritance correctly (diamond-safe).
if (isRoot) { */
c.constructorMeta?.let { meta -> internal suspend fun initializeInstance(
val argsHere = argsForThis ?: Arguments.EMPTY instance: ObjInstance,
// Assign constructor params into instance scope (unmangled) args: Arguments?,
meta.assignToContext(instance.instanceScope, argsHere) runConstructors: Boolean
// Also expose them under MI-mangled storage keys `${Class}::name` so qualified views can access them ) {
// and so that base-class casts like `(obj as Base).field` work. val visited = hashSetOf<ObjClass>()
for (p in meta.params) { initClassInternal(instance, visited, this, args, isRoot = true, runConstructors = runConstructors)
val rec = instance.instanceScope.objects[p.name] }
if (rec != null) {
val mangled = "${c.className}::${p.name}" private suspend fun initClassInternal(
// Always point the mangled name to the current record to keep writes consistent instance: ObjInstance,
// across re-bindings (e.g., second pass before ctor) visited: MutableSet<ObjClass>,
instance.instanceScope.objects[mangled] = rec c: ObjClass,
} argsForThis: Arguments?,
} @Suppress("UNUSED_PARAMETER") isRoot: Boolean = false,
runConstructors: Boolean = true
) {
if (!visited.add(c)) return
// Bind constructor parameters (both mangled and unmangled)
// These are needed for:
// 1) base constructor argument evaluation (if called from a derived class)
// 2) this class's field initializers and `init` blocks
// 3) this class's constructor body
// 4) `compareTo` and other structural operations
c.constructorMeta?.let { meta ->
val argsHere = argsForThis ?: Arguments.EMPTY
// Assign constructor params into instance scope (unmangled)
meta.assignToContext(instance.instanceScope, argsHere, declaringClass = c)
// Also expose them under MI-mangled storage keys `${Class}::name` so qualified views can access them
// and so that base-class casts like `(obj as Base).field` work.
for (p in meta.params) {
val rec = instance.instanceScope.objects[p.name]
if (rec != null) {
val mangled = "${c.className}::${p.name}"
// Always point the mangled name to the current record to keep writes consistent
// across re-bindings
instance.instanceScope.objects[mangled] = rec
} }
} }
// Initialize direct parents first, in order
for (p in c.directParents) {
val raw = c.directParentArgs[p]?.toArguments(instance.instanceScope, false)
val limited = if (raw != null) {
val need = p.constructorMeta?.params?.size ?: 0
if (need == 0) Arguments.EMPTY else Arguments(raw.list.take(need), tailBlockMode = false)
} else Arguments.EMPTY
initClass(p, limited)
}
// Execute per-instance initializers collected from class body for this class
if (c.instanceInitializers.isNotEmpty()) {
val savedCtx = instance.instanceScope.currentClassCtx
instance.instanceScope.currentClassCtx = c
try {
for (initStmt in c.instanceInitializers) {
initStmt.execute(instance.instanceScope)
}
} finally {
instance.instanceScope.currentClassCtx = savedCtx
}
}
// Then run this class' constructor, if any
c.instanceConstructor?.let { ctor ->
// Bind this class's constructor parameters into the instance scope now, right before ctor
c.constructorMeta?.let { meta ->
val argsHere = argsForThis ?: Arguments.EMPTY
meta.assignToContext(instance.instanceScope, argsHere)
// Ensure mangled aliases exist for qualified access starting from this class
for (p in meta.params) {
val rec = instance.instanceScope.objects[p.name]
if (rec != null) {
val mangled = "${c.className}::${p.name}"
// Overwrite to ensure alias refers to the latest ObjRecord after re-binding
instance.instanceScope.objects[mangled] = rec
}
}
}
val execScope = instance.instanceScope.createChildScope(args = argsForThis ?: Arguments.EMPTY, newThisObj = instance)
ctor.execute(execScope)
}
} }
initClass(this, instance.instanceScope.args, isRoot = true) // Initialize direct parents first, in order
return instance for (p in c.directParents) {
val raw = c.directParentArgs[p]?.toArguments(instance.instanceScope, false)
val limited = if (raw != null) {
val need = p.constructorMeta?.params?.size ?: 0
if (need == 0) Arguments.EMPTY else Arguments(raw.list.take(need), tailBlockMode = false)
} else Arguments.EMPTY
initClassInternal(instance, visited, p, limited, false, runConstructors)
}
// Re-bind this class's parameters right before running its initializers and constructor.
// This ensures that unmangled names in the instance scope correctly refer to THIS class's
// parameters even if they were shadowed/overwritten by parent class initialization.
c.constructorMeta?.let { meta ->
val argsHere = argsForThis ?: Arguments.EMPTY
meta.assignToContext(instance.instanceScope, argsHere, declaringClass = c)
// Re-sync mangled names to point to the fresh records to keep them consistent
for (p in meta.params) {
val rec = instance.instanceScope.objects[p.name]
if (rec != null) {
val mangled = "${c.className}::${p.name}"
instance.instanceScope.objects[mangled] = rec
}
}
}
// Execute per-instance initializers collected from class body for this class
if (c.instanceInitializers.isNotEmpty()) {
val savedCtx = instance.instanceScope.currentClassCtx
instance.instanceScope.currentClassCtx = c
try {
for (initStmt in c.instanceInitializers) {
initStmt.execute(instance.instanceScope)
}
} finally {
instance.instanceScope.currentClassCtx = savedCtx
}
}
// Then run this class' constructor, if any
if (runConstructors) {
c.instanceConstructor?.let { ctor ->
val execScope =
instance.instanceScope.createChildScope(args = argsForThis ?: Arguments.EMPTY, newThisObj = instance)
ctor.execute(execScope)
}
}
} }
suspend fun callWithArgs(scope: Scope, vararg plainArgs: Obj): Obj { suspend fun callWithArgs(scope: Scope, vararg plainArgs: Obj): Obj {
@@ -304,14 +345,16 @@ open class ObjClass(
initialValue: Obj, initialValue: Obj,
isMutable: Boolean = false, isMutable: Boolean = false,
visibility: Visibility = Visibility.Public, visibility: Visibility = Visibility.Public,
pos: Pos = Pos.builtIn writeVisibility: Visibility? = null,
pos: Pos = Pos.builtIn,
declaringClass: ObjClass? = this
) { ) {
// Allow overriding ancestors: only prevent redefinition if THIS class already defines an immutable member // Allow overriding ancestors: only prevent redefinition if THIS class already defines an immutable member
val existingInSelf = members[name] val existingInSelf = members[name]
if (existingInSelf != null && existingInSelf.isMutable == false) if (existingInSelf != null && existingInSelf.isMutable == false)
throw ScriptError(pos, "$name is already defined in $objClass") throw ScriptError(pos, "$name is already defined in $objClass")
// Install/override in this class // Install/override in this class
members[name] = ObjRecord(initialValue, isMutable, visibility, declaringClass = this) members[name] = ObjRecord(initialValue, isMutable, visibility, writeVisibility, declaringClass = declaringClass)
// Structural change: bump layout version for PIC invalidation // Structural change: bump layout version for PIC invalidation
layoutVersion += 1 layoutVersion += 1
} }
@@ -326,13 +369,14 @@ open class ObjClass(
initialValue: Obj, initialValue: Obj,
isMutable: Boolean = false, isMutable: Boolean = false,
visibility: Visibility = Visibility.Public, visibility: Visibility = Visibility.Public,
writeVisibility: Visibility? = null,
pos: Pos = Pos.builtIn pos: Pos = Pos.builtIn
) { ) {
initClassScope() initClassScope()
val existing = classScope!!.objects[name] val existing = classScope!!.objects[name]
if (existing != null) if (existing != null)
throw ScriptError(pos, "$name is already defined in $objClass or one of its supertypes") throw ScriptError(pos, "$name is already defined in $objClass or one of its supertypes")
classScope!!.addItem(name, isMutable, initialValue, visibility) classScope!!.addItem(name, isMutable, initialValue, visibility, writeVisibility)
// Structural change: bump layout version for PIC invalidation // Structural change: bump layout version for PIC invalidation
layoutVersion += 1 layoutVersion += 1
} }
@@ -341,13 +385,31 @@ open class ObjClass(
name: String, name: String,
isOpen: Boolean = false, isOpen: Boolean = false,
visibility: Visibility = Visibility.Public, visibility: Visibility = Visibility.Public,
writeVisibility: Visibility? = null,
declaringClass: ObjClass? = this,
code: suspend Scope.() -> Obj code: suspend Scope.() -> Obj
) { ) {
val stmt = statement { code() } val stmt = statement { code() }
createField(name, stmt, isOpen, visibility) createField(name, stmt, isOpen, visibility, writeVisibility, Pos.builtIn, declaringClass)
} }
fun addConst(name: String, value: Obj) = createField(name, value, isMutable = false) fun addConst(name: String, value: Obj) = createField(name, value, isMutable = false)
fun addProperty(
name: String,
getter: (suspend Scope.() -> Obj)? = null,
setter: (suspend Scope.(Obj) -> Unit)? = null,
visibility: Visibility = Visibility.Public,
writeVisibility: Visibility? = null,
declaringClass: ObjClass? = this
) {
val g = getter?.let { statement { it() } }
val s = setter?.let { statement { it(requiredArg(0)); ObjVoid } }
val prop = ObjProperty(name, g, s)
members[name] = ObjRecord(prop, false, visibility, writeVisibility, declaringClass, type = ObjRecord.Type.Property)
layoutVersion += 1
}
fun addClassConst(name: String, value: Obj) = createClassField(name, value) fun addClassConst(name: String, value: Obj) = createClassField(name, value)
fun addClassFn(name: String, isOpen: Boolean = false, code: suspend Scope.() -> Obj) { fun addClassFn(name: String, isOpen: Boolean = false, code: suspend Scope.() -> Obj) {
createClassField(name, statement { code() }, isOpen) createClassField(name, statement { code() }, isOpen)
@@ -415,7 +477,7 @@ open class ObjClass(
override suspend fun invokeInstanceMethod( override suspend fun invokeInstanceMethod(
scope: Scope, name: String, args: Arguments, scope: Scope, name: String, args: Arguments,
onNotFoundResult: (() -> Obj?)? onNotFoundResult: (suspend () -> Obj?)?
): Obj { ): Obj {
return classScope?.objects?.get(name)?.value?.invoke(scope, this, args) return classScope?.objects?.get(name)?.value?.invoke(scope, this, args)
?: super.invokeInstanceMethod(scope, name, args, onNotFoundResult) ?: super.invokeInstanceMethod(scope, name, args, onNotFoundResult)
@@ -81,7 +81,7 @@ open class ObjDynamic(var readCallback: Statement? = null, var writeCallback: St
scope: Scope, scope: Scope,
name: String, name: String,
args: Arguments, args: Arguments,
onNotFoundResult: (() -> Obj?)? onNotFoundResult: (suspend () -> Obj?)?
): Obj { ): Obj {
val execBase = builderScope?.let { ClosureScope(scope, it) } ?: scope val execBase = builderScope?.let { ClosureScope(scope, it) } ?: scope
val over = readCallback?.execute(execBase.createChildScope(Arguments(ObjString(name)))) val over = readCallback?.execute(execBase.createChildScope(Arguments(ObjString(name))))
@@ -1,4 +1,21 @@
/* /*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.obj/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
@@ -18,7 +35,6 @@
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
import net.sergeych.lyng.obj.*
import net.sergeych.lynon.LynonDecoder import net.sergeych.lynon.LynonDecoder
import net.sergeych.lynon.LynonEncoder import net.sergeych.lynon.LynonEncoder
import net.sergeych.lynon.LynonType import net.sergeych.lynon.LynonType
@@ -71,7 +71,7 @@ open class ObjException(
) )
} else { } else {
// Fallback textual entry if StackTraceEntry class is not available in this scope // Fallback textual entry if StackTraceEntry class is not available in this scope
result.list += ObjString("${'$'}{pos.source.objSourceName}:${'$'}{pos.line}:${'$'}{pos.column}: ${'$'}{pos.currentLine}") result.list += ObjString("${pos.source.objSourceName}:${pos.line}:${pos.column}: ${pos.currentLine}")
} }
} }
s = s.parent s = s.parent
@@ -89,7 +89,7 @@ open class ObjException(
override val objClass: ObjClass = exceptionClass override val objClass: ObjClass = exceptionClass
override suspend fun toString(scope: Scope, calledFromLyng: Boolean): ObjString { override suspend fun defaultToString(scope: Scope): ObjString {
val at = getStackTrace().list.firstOrNull()?.toString(scope) val at = getStackTrace().list.firstOrNull()?.toString(scope)
?: ObjString("(unknown)") ?: ObjString("(unknown)")
return ObjString("${objClass.className}: $message at $at") return ObjString("${objClass.className}: $message at $at")
@@ -194,7 +194,9 @@ open class ObjException(
"AccessException", "AccessException",
"UnknownException", "UnknownException",
"NotFoundException", "NotFoundException",
"IllegalOperationException" "IllegalOperationException",
"UnsetException",
"SyntaxError"
)) { )) {
scope.addConst(name, getOrCreateExceptionClass(name)) scope.addConst(name, getOrCreateExceptionClass(name))
} }
@@ -245,3 +247,6 @@ class ObjIllegalOperationException(scope: Scope, message: String = "Operation is
class ObjNotFoundException(scope: Scope, message: String = "not found") : class ObjNotFoundException(scope: Scope, message: String = "not found") :
ObjException("NotFoundException", scope, message) ObjException("NotFoundException", scope, message)
class ObjUnsetException(scope: Scope, message: String = "property is unset (not initialized)") :
ObjException("UnsetException", scope, message)
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -32,24 +32,25 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
override suspend fun readField(scope: Scope, name: String): ObjRecord { override suspend fun readField(scope: Scope, name: String): ObjRecord {
// Direct (unmangled) lookup first // Direct (unmangled) lookup first
instanceScope[name]?.let { instanceScope[name]?.let { rec ->
val decl = it.declaringClass ?: objClass.findDeclaringClassOf(name) val decl = rec.declaringClass
// When execution passes through suspension points (e.g., withLock),
// currentClassCtx could be lost. Fall back to the instance scope class ctx
// to preserve correct visibility semantics for private/protected members
// accessed from within the class methods.
// Allow unconditional access when accessing through `this` of the same instance // Allow unconditional access when accessing through `this` of the same instance
if (scope.thisObj === this) return it // BUT only if we are in the class context (not extension)
val caller0 = scope.currentClassCtx ?: instanceScope.currentClassCtx if (scope.thisObj !== this || scope.currentClassCtx == null) {
val caller = caller0 // do not default to objClass for outsiders val caller = scope.currentClassCtx
if (!canAccessMember(it.visibility, decl, caller)) if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError( scope.raiseError(
ObjAccessException( ObjAccessException(
scope, scope,
"can't access field $name (declared in ${decl?.className ?: "?"})" "can't access field $name (declared in ${decl?.className ?: "?"})"
)
) )
) }
return it if (rec.type == ObjRecord.Type.Property) {
val prop = rec.value as ObjProperty
return rec.copy(value = prop.callGetter(scope, this, decl))
}
return rec
} }
// Try MI-mangled lookup along linearization (C3 MRO): ClassName::name // Try MI-mangled lookup along linearization (C3 MRO): ClassName::name
val cls = objClass val cls = objClass
@@ -70,16 +71,20 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
instanceScope.objects.containsKey("${cls.className}::$name") -> cls instanceScope.objects.containsKey("${cls.className}::$name") -> cls
else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") } else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") }
} }
if (scope.thisObj === this) return rec if (scope.thisObj !== this || scope.currentClassCtx == null) {
val caller0 = scope.currentClassCtx ?: instanceScope.currentClassCtx val caller = scope.currentClassCtx
val caller = caller0 // do not default to objClass for outsiders if (!canAccessMember(rec.visibility, declaring, caller))
if (!canAccessMember(rec.visibility, declaring, caller)) scope.raiseError(
scope.raiseError( ObjAccessException(
ObjAccessException( scope,
scope, "can't access field $name (declared in ${declaring?.className ?: "?"})"
"can't access field $name (declared in ${declaring?.className ?: "?"})" )
) )
) }
if (rec.type == ObjRecord.Type.Property) {
val prop = rec.value as ObjProperty
return rec.copy(value = prop.callGetter(scope, this, declaring))
}
return rec return rec
} }
// Fall back to methods/properties on class // Fall back to methods/properties on class
@@ -89,19 +94,21 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
override suspend fun writeField(scope: Scope, name: String, newValue: Obj) { override suspend fun writeField(scope: Scope, name: String, newValue: Obj) {
// Direct (unmangled) first // Direct (unmangled) first
instanceScope[name]?.let { f -> instanceScope[name]?.let { f ->
val decl = f.declaringClass ?: objClass.findDeclaringClassOf(name) val decl = f.declaringClass
if (scope.thisObj === this) { if (scope.thisObj !== this || scope.currentClassCtx == null) {
// direct self-assignment allowed; enforce mutability below val caller = scope.currentClassCtx
} else { if (!canAccessMember(f.effectiveWriteVisibility, decl, caller))
val caller0 = scope.currentClassCtx ?: instanceScope.currentClassCtx ObjAccessException(
val caller = caller0 // do not default to objClass for outsiders
if (!canAccessMember(f.visibility, decl, caller))
ObjIllegalAssignmentException(
scope, scope,
"can't assign to field $name (declared in ${decl?.className ?: "?"})" "can't assign to field $name (declared in ${decl?.className ?: "?"})"
).raise() ).raise()
} }
if (!f.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (f.type == ObjRecord.Type.Property) {
val prop = f.value as ObjProperty
prop.callSetter(scope, this, newValue, decl)
return
}
if (!f.isMutable && f.value !== ObjUnset) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise()
if (f.value.assign(scope, newValue) == null) if (f.value.assign(scope, newValue) == null)
f.value = newValue f.value = newValue
return return
@@ -122,16 +129,20 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
instanceScope.objects.containsKey("${cls.className}::$name") -> cls instanceScope.objects.containsKey("${cls.className}::$name") -> cls
else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") } else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") }
} }
if (scope.thisObj !== this) { if (scope.thisObj !== this || scope.currentClassCtx == null) {
val caller0 = scope.currentClassCtx ?: instanceScope.currentClassCtx val caller = scope.currentClassCtx
val caller = caller0 // do not default to objClass for outsiders if (!canAccessMember(rec.effectiveWriteVisibility, declaring, caller))
if (!canAccessMember(rec.visibility, declaring, caller)) ObjAccessException(
ObjIllegalAssignmentException(
scope, scope,
"can't assign to field $name (declared in ${declaring?.className ?: "?"})" "can't assign to field $name (declared in ${declaring?.className ?: "?"})"
).raise() ).raise()
} }
if (!rec.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (rec.type == ObjRecord.Type.Property) {
val prop = rec.value as ObjProperty
prop.callSetter(scope, this, newValue, declaring)
return
}
if (!rec.isMutable && rec.value !== ObjUnset) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise()
if (rec.value.assign(scope, newValue) == null) if (rec.value.assign(scope, newValue) == null)
rec.value = newValue rec.value = newValue
return return
@@ -141,12 +152,11 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
override suspend fun invokeInstanceMethod( override suspend fun invokeInstanceMethod(
scope: Scope, name: String, args: Arguments, scope: Scope, name: String, args: Arguments,
onNotFoundResult: (() -> Obj?)? onNotFoundResult: (suspend () -> Obj?)?
): Obj = ): Obj =
instanceScope[name]?.let { rec -> instanceScope[name]?.let { rec ->
val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name) val decl = rec.declaringClass
val caller0 = scope.currentClassCtx ?: instanceScope.currentClassCtx val caller = scope.currentClassCtx ?: if (scope.thisObj === this) objClass else null
val caller = caller0 ?: if (scope.thisObj === this) objClass else null
if (!canAccessMember(rec.visibility, decl, caller)) if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError( scope.raiseError(
ObjAccessException( ObjAccessException(
@@ -154,25 +164,17 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
"can't invoke method $name (declared in ${decl?.className ?: "?"})" "can't invoke method $name (declared in ${decl?.className ?: "?"})"
) )
) )
// execute with lexical class context propagated to declaring class rec.value.invoke(
val saved = instanceScope.currentClassCtx instanceScope,
instanceScope.currentClassCtx = decl this,
try { args
rec.value.invoke( )
instanceScope,
this,
args
)
} finally {
instanceScope.currentClassCtx = saved
}
} }
?: run { ?: run {
// fallback: class-scope function (registered during class body execution) // fallback: class-scope function (registered during class body execution)
objClass.classScope?.objects?.get(name)?.let { rec -> objClass.classScope?.objects?.get(name)?.let { rec ->
val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name) val decl = rec.declaringClass
val caller0 = scope.currentClassCtx ?: instanceScope.currentClassCtx val caller = scope.currentClassCtx ?: if (scope.thisObj === this) objClass else null
val caller = caller0 ?: if (scope.thisObj === this) objClass else null
if (!canAccessMember(rec.visibility, decl, caller)) if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError( scope.raiseError(
ObjAccessException( ObjAccessException(
@@ -180,13 +182,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
"can't invoke method $name (declared in ${decl?.className ?: "?"})" "can't invoke method $name (declared in ${decl?.className ?: "?"})"
) )
) )
val saved = instanceScope.currentClassCtx rec.value.invoke(instanceScope, this, args)
instanceScope.currentClassCtx = decl
try {
rec.value.invoke(instanceScope, this, args)
} finally {
instanceScope.currentClassCtx = saved
}
} }
} }
?: super.invokeInstanceMethod(scope, name, args, onNotFoundResult) ?: super.invokeInstanceMethod(scope, name, args, onNotFoundResult)
@@ -197,9 +193,20 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
!it.key.contains("::") && it.value.visibility.isPublic && it.value.type.serializable !it.key.contains("::") && it.value.visibility.isPublic && it.value.type.serializable
} }
override fun toString(): String { override suspend fun defaultToString(scope: Scope): ObjString {
val fields = publicFields.map { "${it.key}=${it.value.value}" }.joinToString(",") return ObjString(buildString {
return "${objClass.className}($fields)" append("${objClass.className}(")
var first = true
for ((name, value) in publicFields) {
if (first) first = false else append(",")
append("$name=${value.value.toString(scope)}")
}
append(")")
})
}
override suspend fun inspect(scope: Scope): String {
return toString(scope).value
} }
override suspend fun serialize(scope: Scope, encoder: LynonEncoder, lynonType: LynonType?) { override suspend fun serialize(scope: Scope, encoder: LynonEncoder, lynonType: LynonType?) {
@@ -210,10 +217,8 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
// using objlist allow for some optimizations: // using objlist allow for some optimizations:
val params = meta.params.map { readField(scope, it.name).value } val params = meta.params.map { readField(scope, it.name).value }
println("serializing $objClass with params: $params")
encoder.encodeAnyList(scope, params) encoder.encodeAnyList(scope, params)
val vars = serializingVars.values.map { it.value } val vars = serializingVars.values.map { it.value }
println("encoding vars: $vars")
if (vars.isNotEmpty<Obj>()) { if (vars.isNotEmpty<Obj>()) {
encoder.encodeAnyList(scope, vars) encoder.encodeAnyList(scope, vars)
} }
@@ -235,7 +240,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
for (i in serializingVars) { for (i in serializingVars) {
// remove T:: prefix from the field name for JSON // remove T:: prefix from the field name for JSON
val parts = i.key.split("::") val parts = i.key.split("::")
result[if( parts.size == 1 ) parts[0] else parts.last()] = i.value.value.toJson(scope) result[if (parts.size == 1) parts[0] else parts.last()] = i.value.value.toJson(scope)
} }
return JsonObject(result) return JsonObject(result)
} }
@@ -248,7 +253,8 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
instanceScope.objects.filter { instanceScope.objects.filter {
it.value.type.serializable && it.value.type.serializable &&
it.value.type == ObjRecord.Type.Field && it.value.type == ObjRecord.Type.Field &&
it.value.isMutable } it.value.isMutable
}
} }
internal suspend fun deserializeStateVars(scope: Scope, decoder: LynonDecoder) { internal suspend fun deserializeStateVars(scope: Scope, decoder: LynonDecoder) {
@@ -265,7 +271,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
protected val comparableVars: Map<String, ObjRecord> by lazy { protected val comparableVars: Map<String, ObjRecord> by lazy {
instanceScope.objects.filter { instanceScope.objects.filter {
it.value.type.comparable it.value.type.comparable && (it.value.type != ObjRecord.Type.Field || it.value.isMutable)
} }
} }
@@ -344,12 +350,12 @@ class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjCla
instance.instanceScope.objects[mangled]?.let { f -> instance.instanceScope.objects[mangled]?.let { f ->
val decl = f.declaringClass ?: startClass val decl = f.declaringClass ?: startClass
val caller = scope.currentClassCtx val caller = scope.currentClassCtx
if (!canAccessMember(f.visibility, decl, caller)) if (!canAccessMember(f.effectiveWriteVisibility, decl, caller))
ObjIllegalAssignmentException( ObjAccessException(
scope, scope,
"can't assign to field $name (declared in ${decl.className})" "can't assign to field $name (declared in ${decl.className})"
).raise() ).raise()
if (!f.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (!f.isMutable && f.value !== ObjUnset) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise()
if (f.value.assign(scope, newValue) == null) f.value = newValue if (f.value.assign(scope, newValue) == null) f.value = newValue
return return
} }
@@ -358,12 +364,12 @@ class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjCla
instance.instanceScope[name]?.let { f -> instance.instanceScope[name]?.let { f ->
val decl = f.declaringClass ?: instance.objClass.findDeclaringClassOf(name) val decl = f.declaringClass ?: instance.objClass.findDeclaringClassOf(name)
val caller = scope.currentClassCtx val caller = scope.currentClassCtx
if (!canAccessMember(f.visibility, decl, caller)) if (!canAccessMember(f.effectiveWriteVisibility, decl, caller))
ObjIllegalAssignmentException( ObjAccessException(
scope, scope,
"can't assign to field $name (declared in ${decl?.className ?: "?"})" "can't assign to field $name (declared in ${decl?.className ?: "?"})"
).raise() ).raise()
if (!f.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (!f.isMutable && f.value !== ObjUnset) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise()
if (f.value.assign(scope, newValue) == null) f.value = newValue if (f.value.assign(scope, newValue) == null) f.value = newValue
return return
} }
@@ -371,8 +377,8 @@ class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjCla
val r = memberFromAncestor(name) ?: scope.raiseError("no such field: $name") val r = memberFromAncestor(name) ?: scope.raiseError("no such field: $name")
val decl = r.declaringClass ?: startClass val decl = r.declaringClass ?: startClass
val caller = scope.currentClassCtx val caller = scope.currentClassCtx
if (!canAccessMember(r.visibility, decl, caller)) if (!canAccessMember(r.effectiveWriteVisibility, decl, caller))
ObjIllegalAssignmentException(scope, "can't assign to field $name (declared in ${decl.className})").raise() ObjAccessException(scope, "can't assign to field $name (declared in ${decl.className})").raise()
if (!r.isMutable) scope.raiseError("can't assign to read-only field: $name") if (!r.isMutable) scope.raiseError("can't assign to read-only field: $name")
if (r.value.assign(scope, newValue) == null) r.value = newValue if (r.value.assign(scope, newValue) == null) r.value = newValue
} }
@@ -381,7 +387,7 @@ class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjCla
scope: Scope, scope: Scope,
name: String, name: String,
args: Arguments, args: Arguments,
onNotFoundResult: (() -> Obj?)? onNotFoundResult: (suspend () -> Obj?)?
): Obj { ): Obj {
// Qualified method dispatch must start from the specified ancestor, not from the instance scope. // Qualified method dispatch must start from the specified ancestor, not from the instance scope.
memberFromAncestor(name)?.let { rec -> memberFromAncestor(name)?.let { rec ->
@@ -33,7 +33,9 @@ class ObjInstanceClass(val name: String, vararg parents: ObjClass) : ObjClass(na
if (args.size > actualSize) if (args.size > actualSize)
scope.raiseIllegalArgument("constructor $name has only $actualSize but serialized version has ${args.size}") scope.raiseIllegalArgument("constructor $name has only $actualSize but serialized version has ${args.size}")
val newScope = scope.createChildScope(args = Arguments(args)) val newScope = scope.createChildScope(args = Arguments(args))
return (callOn(newScope) as ObjInstance).apply { val instance = createInstance(newScope)
initializeInstance(instance, newScope.args, runConstructors = false)
return instance.apply {
deserializeStateVars(scope, decoder) deserializeStateVars(scope, decoder)
invokeInstanceMethod(scope, "onDeserialized") { ObjVoid } invokeInstanceMethod(scope, "onDeserialized") { ObjVoid }
} }
@@ -41,7 +43,7 @@ class ObjInstanceClass(val name: String, vararg parents: ObjClass) : ObjClass(na
init { init {
addFn("toString", true) { addFn("toString", true) {
ObjString(thisObj.toString()) thisObj.toString(this, true)
} }
} }
@@ -17,10 +17,6 @@
package net.sergeych.lyng.obj package net.sergeych.lyng.obj
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.isDistantFuture
import kotlinx.datetime.isDistantPast
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
@@ -28,6 +24,10 @@ import net.sergeych.lynon.LynonDecoder
import net.sergeych.lynon.LynonEncoder import net.sergeych.lynon.LynonEncoder
import net.sergeych.lynon.LynonSettings import net.sergeych.lynon.LynonSettings
import net.sergeych.lynon.LynonType import net.sergeych.lynon.LynonType
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.time.isDistantFuture
import kotlin.time.isDistantPast
class ObjInstant(val instant: Instant,val truncateMode: LynonSettings.InstantTruncateMode=LynonSettings.InstantTruncateMode.Microsecond) : Obj() { class ObjInstant(val instant: Instant,val truncateMode: LynonSettings.InstantTruncateMode=LynonSettings.InstantTruncateMode.Microsecond) : Obj() {
override val objClass: ObjClass get() = type override val objClass: ObjClass get() = type
@@ -24,36 +24,32 @@ import net.sergeych.lynon.LynonDecoder
import net.sergeych.lynon.LynonEncoder import net.sergeych.lynon.LynonEncoder
import net.sergeych.lynon.LynonType import net.sergeych.lynon.LynonType
class ObjInt(var value: Long, override val isConst: Boolean = false) : Obj(), Numeric { class ObjInt(val value: Long, override val isConst: Boolean = false) : Obj(), Numeric {
override val longValue get() = value override val longValue get() = value
override val doubleValue get() = value.toDouble() override val doubleValue get() = value.toDouble()
override val toObjInt get() = this override val toObjInt get() = this
override val toObjReal = ObjReal(doubleValue) override val toObjReal = ObjReal.of(doubleValue)
override fun byValueCopy(): Obj = ObjInt(value) override fun byValueCopy(): Obj = this
override fun hashCode(): Int { override fun hashCode(): Int {
return value.hashCode() return value.hashCode()
} }
override suspend fun getAndIncrement(scope: Scope): Obj { override suspend fun getAndIncrement(scope: Scope): Obj {
ensureNotConst(scope) return this
return ObjInt(value).also { value++ }
} }
override suspend fun getAndDecrement(scope: Scope): Obj { override suspend fun getAndDecrement(scope: Scope): Obj {
ensureNotConst(scope) return this
return ObjInt(value).also { value-- }
} }
override suspend fun incrementAndGet(scope: Scope): Obj { override suspend fun incrementAndGet(scope: Scope): Obj {
ensureNotConst(scope) return of(value + 1)
return ObjInt(++value)
} }
override suspend fun decrementAndGet(scope: Scope): Obj { override suspend fun decrementAndGet(scope: Scope): Obj {
ensureNotConst(scope) return of(value - 1)
return ObjInt(--value)
} }
override suspend fun compareTo(scope: Scope, other: Obj): Int { override suspend fun compareTo(scope: Scope, other: Obj): Int {
@@ -67,41 +63,35 @@ class ObjInt(var value: Long, override val isConst: Boolean = false) : Obj(), Nu
override suspend fun plus(scope: Scope, other: Obj): Obj = override suspend fun plus(scope: Scope, other: Obj): Obj =
if (other is ObjInt) if (other is ObjInt)
ObjInt(this.value + other.value) of(this.value + other.value)
else else
ObjReal(this.doubleValue + other.toDouble()) ObjReal.of(this.doubleValue + other.toDouble())
override suspend fun minus(scope: Scope, other: Obj): Obj = override suspend fun minus(scope: Scope, other: Obj): Obj =
if (other is ObjInt) if (other is ObjInt)
ObjInt(this.value - other.value) of(this.value - other.value)
else else
ObjReal(this.doubleValue - other.toDouble()) ObjReal.of(this.doubleValue - other.toDouble())
override suspend fun mul(scope: Scope, other: Obj): Obj = override suspend fun mul(scope: Scope, other: Obj): Obj =
if (other is ObjInt) { if (other is ObjInt) {
ObjInt(this.value * other.value) of(this.value * other.value)
} else ObjReal(this.value * other.toDouble()) } else ObjReal.of(this.value * other.toDouble())
override suspend fun div(scope: Scope, other: Obj): Obj = override suspend fun div(scope: Scope, other: Obj): Obj =
if (other is ObjInt) if (other is ObjInt)
ObjInt(this.value / other.value) of(this.value / other.value)
else ObjReal(this.value / other.toDouble()) else ObjReal.of(this.value / other.toDouble())
override suspend fun mod(scope: Scope, other: Obj): Obj = override suspend fun mod(scope: Scope, other: Obj): Obj =
if (other is ObjInt) if (other is ObjInt)
ObjInt(this.value % other.value) of(this.value % other.value)
else ObjReal(this.value.toDouble() % other.toDouble()) else ObjReal.of(this.value.toDouble() % other.toDouble())
/** /**
* We are by-value type ([byValueCopy] is implemented) so we can do in-place * Numbers are now immutable, so we can't do in-place assignment.
* assignment
*/ */
override suspend fun assign(scope: Scope, other: Obj): Obj? { override suspend fun assign(scope: Scope, other: Obj): Obj? = null
return if (!isConst && other is ObjInt) {
value = other.value
this
} else null
}
override suspend fun toKotlin(scope: Scope): Any { override suspend fun toKotlin(scope: Scope): Any {
return value return value
@@ -117,31 +107,31 @@ class ObjInt(var value: Long, override val isConst: Boolean = false) : Obj(), Nu
} }
override suspend fun negate(scope: Scope): Obj { override suspend fun negate(scope: Scope): Obj {
return ObjInt(-value) return of(-value)
} }
// Bitwise operations // Bitwise operations
override suspend fun bitAnd(scope: Scope, other: Obj): Obj = override suspend fun bitAnd(scope: Scope, other: Obj): Obj =
if (other is ObjInt) ObjInt(this.value and other.value) if (other is ObjInt) of(this.value and other.value)
else scope.raiseIllegalArgument("bitwise and '&' requires Int, got ${other.objClass.className}") else scope.raiseIllegalArgument("bitwise and '&' requires Int, got ${other.objClass.className}")
override suspend fun bitOr(scope: Scope, other: Obj): Obj = override suspend fun bitOr(scope: Scope, other: Obj): Obj =
if (other is ObjInt) ObjInt(this.value or other.value) if (other is ObjInt) of(this.value or other.value)
else scope.raiseIllegalArgument("bitwise or '|' requires Int, got ${other.objClass.className}") else scope.raiseIllegalArgument("bitwise or '|' requires Int, got ${other.objClass.className}")
override suspend fun bitXor(scope: Scope, other: Obj): Obj = override suspend fun bitXor(scope: Scope, other: Obj): Obj =
if (other is ObjInt) ObjInt(this.value xor other.value) if (other is ObjInt) of(this.value xor other.value)
else scope.raiseIllegalArgument("bitwise xor '^' requires Int, got ${other.objClass.className}") else scope.raiseIllegalArgument("bitwise xor '^' requires Int, got ${other.objClass.className}")
override suspend fun shl(scope: Scope, other: Obj): Obj = override suspend fun shl(scope: Scope, other: Obj): Obj =
if (other is ObjInt) ObjInt(this.value shl (other.value.toInt() and 63)) if (other is ObjInt) of(this.value shl (other.value.toInt() and 63))
else scope.raiseIllegalArgument("shift left '<<' requires Int, got ${other.objClass.className}") else scope.raiseIllegalArgument("shift left '<<' requires Int, got ${other.objClass.className}")
override suspend fun shr(scope: Scope, other: Obj): Obj = override suspend fun shr(scope: Scope, other: Obj): Obj =
if (other is ObjInt) ObjInt(this.value shr (other.value.toInt() and 63)) if (other is ObjInt) of(this.value shr (other.value.toInt() and 63))
else scope.raiseIllegalArgument("shift right '>>' requires Int, got ${other.objClass.className}") else scope.raiseIllegalArgument("shift right '>>' requires Int, got ${other.objClass.className}")
override suspend fun bitNot(scope: Scope): Obj = ObjInt(this.value.inv()) override suspend fun bitNot(scope: Scope): Obj = of(this.value.inv())
override suspend fun lynonType(): LynonType = when (value) { override suspend fun lynonType(): LynonType = when (value) {
0L -> LynonType.Int0 0L -> LynonType.Int0
@@ -168,20 +158,32 @@ class ObjInt(var value: Long, override val isConst: Boolean = false) : Obj(), Nu
} }
companion object { companion object {
val Zero = ObjInt(0, true) private val cache = Array(256) { ObjInt((it - 128).toLong(), true) }
val One = ObjInt(1, true)
fun of(value: Long): ObjInt {
return if (value in -128L..127L) cache[(value + 128).toInt()]
else ObjInt(value)
}
val Zero = of(0)
val One = of(1)
val type = object : ObjClass("Int") { val type = object : ObjClass("Int") {
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj = override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj =
when (lynonType) { when (lynonType) {
null -> ObjInt(decoder.unpackSigned()) null -> of(decoder.unpackSigned())
LynonType.Int0 -> Zero LynonType.Int0 -> Zero
LynonType.IntPositive -> ObjInt(decoder.unpackUnsigned().toLong()) LynonType.IntPositive -> of(decoder.unpackUnsigned().toLong())
LynonType.IntNegative -> ObjInt(-decoder.unpackUnsigned().toLong()) LynonType.IntNegative -> of(-decoder.unpackUnsigned().toLong())
LynonType.IntSigned -> ObjInt(decoder.unpackSigned()) LynonType.IntSigned -> of(decoder.unpackSigned())
else -> scope.raiseIllegalState("illegal type code for Int: $lynonType") else -> scope.raiseIllegalState("illegal type code for Int: $lynonType")
} }
}.apply {
addFn("toInt") {
thisObj
}
} }
} }
} }
fun Int.toObj() = ObjInt(this.toLong()) fun Int.toObj() = ObjInt.of(this.toLong())
fun Long.toObj() = ObjInt.of(this)
@@ -104,11 +104,14 @@ val ObjIterable by lazy {
returns = type("lyng.Map"), returns = type("lyng.Map"),
moduleName = "lyng.stdlib" moduleName = "lyng.stdlib"
) { ) {
val result = ObjMap() val result = mutableMapOf<Obj, Obj>()
thisObj.toFlow(this).collect { pair -> thisObj.toFlow(this).collect { pair ->
result.map[pair.getAt(this, 0)] = pair.getAt(this, 1) when (pair) {
is ObjMapEntry -> result[pair.key] = pair.value
else -> result[pair.getAt(this, 0)] = pair.getAt(this, 1)
}
} }
result ObjMap(result)
} }
addFnDoc( addFnDoc(
@@ -158,6 +161,23 @@ val ObjIterable by lazy {
ObjList(result) ObjList(result)
} }
addFnDoc(
name = "mapNotNull",
doc = "Transform elements by applying the given lambda unless it returns null.",
params = listOf(ParamDoc("transform")),
returns = type("lyng.List"),
isOpen = true,
moduleName = "lyng.stdlib"
) {
val fn = requiredArg<Statement>(0)
val result = mutableListOf<Obj>()
thisObj.toFlow(this).collect {
val transformed = fn.call(this, it)
if( transformed != ObjNull) result += transformed
}
ObjList(result)
}
addFnDoc( addFnDoc(
name = "take", name = "take",
doc = "Take the first N elements and return them as a list.", doc = "Take the first N elements and return them as a list.",
@@ -76,36 +76,3 @@ fun Obj.toFlow(scope: Scope): Flow<Obj> = flow {
} }
} }
/**
* Call [callback] for each element of this obj considering it provides [Iterator]
* methods `hasNext` and `next`.
*
* IF callback returns false, iteration is stopped.
*/
suspend fun Obj.enumerate(scope: Scope, callback: suspend (Obj) -> Boolean) {
val iterator = invokeInstanceMethod(scope, "iterator")
val hasNext = iterator.getInstanceMethod(scope, "hasNext")
val next = iterator.getInstanceMethod(scope, "next")
var closeIt = false
try {
while (hasNext.invoke(scope, iterator).toBool()) {
val nextValue = next.invoke(scope, iterator)
val shouldContinue = try {
callback(nextValue)
} catch (e: Exception) {
// iteration aborted due to exception in callback
closeIt = true
throw e
}
if (!shouldContinue) {
closeIt = true
break
}
}
} finally {
if (closeIt) {
// Best-effort cancel on premature termination
iterator.invokeInstanceMethod(scope, "cancelIteration") { ObjVoid }
}
}
}
@@ -105,7 +105,7 @@ class ObjList(val list: MutableList<Obj> = mutableListOf()) : Obj() {
} }
else -> else ->
scope.raiseError("'+': can't concatenate $this with $other") scope.raiseError("'+': can't concatenate ${this.toString(scope)} with ${other.toString(scope)}")
} }
@@ -140,6 +140,12 @@ class ObjList(val list: MutableList<Obj> = mutableListOf()) : Obj() {
return list.contains(other) return list.contains(other)
} }
override suspend fun enumerate(scope: Scope, callback: suspend (Obj) -> Boolean) {
for (item in list) {
if (!callback(item)) break
}
}
override val objClass: ObjClass override val objClass: ObjClass
get() = type get() = type
@@ -198,6 +204,18 @@ class ObjList(val list: MutableList<Obj> = mutableListOf()) : Obj() {
return JsonArray(list.map { it.toJson(scope) }) return JsonArray(list.map { it.toJson(scope) })
} }
override suspend fun defaultToString(scope: Scope): ObjString {
return ObjString(buildString {
append("[")
var first = true
for (v in list) {
if (first) first = false else append(",")
append(v.toString(scope).value)
}
append("]")
})
}
companion object { companion object {
val type = object : ObjClass("List", ObjArray) { val type = object : ObjClass("List", ObjArray) {
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj { override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj {
@@ -52,8 +52,8 @@ class ObjMapEntry(val key: Obj, val value: Obj) : Obj() {
else -> scope.raiseIndexOutOfBounds() else -> scope.raiseIndexOutOfBounds()
} }
override fun toString(): String { override suspend fun defaultToString(scope: Scope): ObjString {
return "$key=>$value" return ObjString("(${key.toString(scope).value} => ${value.toString(scope).value})")
} }
override val objClass = type override val objClass = type
@@ -124,7 +124,7 @@ class ObjMap(val map: MutableMap<Obj, Obj> = mutableMapOf()) : Obj() {
return -1 return -1
} }
override suspend fun toString(scope: Scope, calledFromLyng: Boolean): ObjString { override suspend fun defaultToString(scope: Scope): ObjString {
val reusult = buildString { val reusult = buildString {
append("Map(") append("Map(")
var first = true var first = true
@@ -174,18 +174,21 @@ class ObjMap(val map: MutableMap<Obj, Obj> = mutableMapOf()) : Obj() {
val map = mutableMapOf<Obj, Obj>() val map = mutableMapOf<Obj, Obj>()
if (list.isEmpty()) return map if (list.isEmpty()) return map
val first = list.first()
if (first.isInstanceOf(ObjArray)) {
if (first.invokeInstanceMethod(scope, "size").toInt() != 2)
scope.raiseIllegalArgument(
"list to construct map entry should exactly be 2 element Array like [key,value], got $list"
)
} else scope.raiseIllegalArgument("first element of map list be a Collection of 2 elements; got $first")
list.forEach { list.forEach {
map[it.getAt(scope, ObjInt.Zero)] = it.getAt(scope, ObjInt.One) when (it) {
is ObjMapEntry -> map[it.key] = it.value
else -> {
if (it.isInstanceOf(ObjArray)) {
if (it.invokeInstanceMethod(scope, "size").toInt() != 2)
scope.raiseIllegalArgument(
"Array to construct map entry should exactly be 2 elements [key,value], got $it"
)
map[it.getAt(scope, ObjInt.Zero)] = it.getAt(scope, ObjInt.One)
} else {
scope.raiseIllegalArgument("elements to construct map must be MapEntry or Array of 2 elements; got $it")
}
}
}
} }
return map return map
} }
@@ -296,13 +299,11 @@ class ObjMap(val map: MutableMap<Obj, Obj> = mutableMapOf()) : Obj() {
is ObjMap -> { is ObjMap -> {
// Rightmost wins: copy all entries from `other` over existing ones // Rightmost wins: copy all entries from `other` over existing ones
for ((k, v) in other.map) { for ((k, v) in other.map) {
val key = k as? ObjString ?: scope.raiseIllegalArgument("map merge expects string keys; got $k") map[k] = v
map[key] = v
} }
} }
is ObjMapEntry -> { is ObjMapEntry -> {
val key = other.key as? ObjString ?: scope.raiseIllegalArgument("map merge expects string keys; got ${other.key}") map[other.key] = other.value
map[key] = other.value
} }
is ObjList -> { is ObjList -> {
// Treat as list of map entries // Treat as list of map entries
@@ -311,8 +312,7 @@ class ObjMap(val map: MutableMap<Obj, Obj> = mutableMapOf()) : Obj() {
is ObjMapEntry -> e is ObjMapEntry -> e
else -> scope.raiseIllegalArgument("map can only be merged with MapEntry elements; got $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[entry.key] = entry.value
map[key] = entry.value
} }
} }
else -> scope.raiseIllegalArgument("map can only be merged with Map, MapEntry, or List<MapEntry>") else -> scope.raiseIllegalArgument("map can only be merged with Map, MapEntry, or List<MapEntry>")
@@ -0,0 +1,56 @@
/*
* 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.obj
import net.sergeych.lyng.Arguments
import net.sergeych.lyng.ClosureScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Statement
/**
* Property accessor storage. Per instructions, properties do NOT have
* automatic backing fields. They are pure accessors.
*/
class ObjProperty(
val name: String,
val getter: Statement?,
val setter: Statement?
) : Obj() {
suspend fun callGetter(scope: Scope, instance: Obj, declaringClass: ObjClass? = null): Obj {
val g = getter ?: scope.raiseError("property $name has no getter")
// Execute getter in a child scope of the instance with 'this' properly set
// Use ClosureScope to match extension function behavior (access to instance scope + call scope)
val instanceScope = (instance as? ObjInstance)?.instanceScope ?: instance.autoInstanceScope(scope)
val execScope = ClosureScope(scope, instanceScope).createChildScope(newThisObj = instance)
execScope.currentClassCtx = declaringClass
return g.execute(execScope)
}
suspend fun callSetter(scope: Scope, instance: Obj, value: Obj, declaringClass: ObjClass? = null) {
val s = setter ?: scope.raiseError("property $name has no setter")
// Execute setter in a child scope of the instance with 'this' properly set and the value as an argument
// Use ClosureScope to match extension function behavior
val instanceScope = (instance as? ObjInstance)?.instanceScope ?: instance.autoInstanceScope(scope)
val execScope = ClosureScope(scope, instanceScope).createChildScope(args = Arguments(value), newThisObj = instance)
execScope.currentClassCtx = declaringClass
s.execute(execScope)
}
override fun toString(): String = "Property($name)"
}
@@ -29,7 +29,7 @@ class ObjRange(val start: Obj?, val end: Obj?, val isEndInclusive: Boolean) : Ob
override val objClass: ObjClass = type override val objClass: ObjClass = type
override suspend fun toString(scope: Scope,calledFromLyng: Boolean): ObjString { override suspend fun defaultToString(scope: Scope): ObjString {
val result = StringBuilder() val result = StringBuilder()
result.append("${start?.inspect(scope) ?: '∞'} ..") result.append("${start?.inspect(scope) ?: '∞'} ..")
if (!isEndInclusive) result.append('<') if (!isEndInclusive) result.append('<')
@@ -114,6 +114,36 @@ class ObjRange(val start: Obj?, val end: Obj?, val isEndInclusive: Boolean) : Ob
start is ObjChar && end is ObjChar start is ObjChar && end is ObjChar
} }
override suspend fun enumerate(scope: Scope, callback: suspend (Obj) -> Boolean) {
if (isIntRange) {
val s = (start as ObjInt).value
val e = (end as ObjInt).value
if (isEndInclusive) {
for (i in s..e) {
if (!callback(ObjInt.of(i))) break
}
} else {
for (i in s..<e) {
if (!callback(ObjInt.of(i))) break
}
}
} else if (isCharRange) {
val s = (start as ObjChar).value
val e = (end as ObjChar).value
if (isEndInclusive) {
for (c in s..e) {
if (!callback(ObjChar(c))) break
}
} else {
for (c in s..<e) {
if (!callback(ObjChar(c))) break
}
}
} else {
super.enumerate(scope, callback)
}
}
override suspend fun compareTo(scope: Scope, other: Obj): Int { override suspend fun compareTo(scope: Scope, other: Obj): Int {
return (other as? ObjRange)?.let { return (other as? ObjRange)?.let {
if( start == other.start && end == other.end ) 0 else -1 if( start == other.start && end == other.end ) 0 else -1
@@ -32,14 +32,14 @@ import kotlin.math.floor
import kotlin.math.roundToLong import kotlin.math.roundToLong
data class ObjReal(val value: Double) : Obj(), Numeric { data class ObjReal(val value: Double) : Obj(), Numeric {
override val longValue: Long by lazy { floor(value).toLong() } override val longValue: Long get() = floor(value).toLong()
override val doubleValue: Double by lazy { value } override val doubleValue: Double get() = value
override val toObjInt: ObjInt by lazy { ObjInt(longValue) } override val toObjInt: ObjInt get() = ObjInt.of(longValue)
override val toObjReal: ObjReal by lazy { ObjReal(value) } override val toObjReal: ObjReal get() = this
override val objClass: ObjClass = type override val objClass: ObjClass = type
override fun byValueCopy(): Obj = ObjReal(value) override fun byValueCopy(): Obj = this
override suspend fun compareTo(scope: Scope, other: Obj): Int { override suspend fun compareTo(scope: Scope, other: Obj): Int {
if (other !is Numeric) return -2 if (other !is Numeric) return -2
@@ -65,19 +65,19 @@ data class ObjReal(val value: Double) : Obj(), Numeric {
} }
override suspend fun plus(scope: Scope, other: Obj): Obj = override suspend fun plus(scope: Scope, other: Obj): Obj =
ObjReal(this.value + other.toDouble()) of(this.value + other.toDouble())
override suspend fun minus(scope: Scope, other: Obj): Obj = override suspend fun minus(scope: Scope, other: Obj): Obj =
ObjReal(this.value - other.toDouble()) of(this.value - other.toDouble())
override suspend fun mul(scope: Scope, other: Obj): Obj = override suspend fun mul(scope: Scope, other: Obj): Obj =
ObjReal(this.value * other.toDouble()) of(this.value * other.toDouble())
override suspend fun div(scope: Scope, other: Obj): Obj = override suspend fun div(scope: Scope, other: Obj): Obj =
ObjReal(this.value / other.toDouble()) of(this.value / other.toDouble())
override suspend fun mod(scope: Scope, other: Obj): Obj = override suspend fun mod(scope: Scope, other: Obj): Obj =
ObjReal(this.value % other.toDouble()) of(this.value % other.toDouble())
/** /**
* Returns unboxed Double value * Returns unboxed Double value
@@ -96,7 +96,7 @@ data class ObjReal(val value: Double) : Obj(), Numeric {
} }
override suspend fun negate(scope: Scope): Obj { override suspend fun negate(scope: Scope): Obj {
return ObjReal(-value) return of(-value)
} }
override suspend fun lynonType(): LynonType = LynonType.Real override suspend fun lynonType(): LynonType = LynonType.Real
@@ -110,9 +110,18 @@ data class ObjReal(val value: Double) : Obj(), Numeric {
} }
companion object { companion object {
val Zero = ObjReal(0.0)
val One = ObjReal(1.0)
fun of(value: Double): ObjReal = when (value) {
0.0 -> Zero
1.0 -> One
else -> ObjReal(value)
}
val type: ObjClass = object : ObjClass("Real") { val type: ObjClass = object : ObjClass("Real") {
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj = override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj =
ObjReal(decoder.unpackDouble()) of(decoder.unpackDouble())
}.apply { }.apply {
// roundToInt: number rounded to the nearest integer // roundToInt: number rounded to the nearest integer
addConstDoc( addConstDoc(
@@ -130,7 +139,7 @@ data class ObjReal(val value: Double) : Obj(), Numeric {
returns = type("lyng.Int"), returns = type("lyng.Int"),
moduleName = "lyng.stdlib" moduleName = "lyng.stdlib"
) { ) {
ObjInt(thisAs<ObjReal>().value.toLong()) ObjInt.of(thisAs<ObjReal>().value.toLong())
} }
} }
} }
@@ -26,12 +26,14 @@ data class ObjRecord(
var value: Obj, var value: Obj,
val isMutable: Boolean, val isMutable: Boolean,
val visibility: Visibility = Visibility.Public, val visibility: Visibility = Visibility.Public,
val writeVisibility: Visibility? = null,
/** If non-null, denotes the class that declared this member (field/method). */ /** If non-null, denotes the class that declared this member (field/method). */
val declaringClass: ObjClass? = null, val declaringClass: ObjClass? = null,
var importedFrom: Scope? = null, var importedFrom: Scope? = null,
val isTransient: Boolean = false, val isTransient: Boolean = false,
val type: Type = Type.Other, val type: Type = Type.Other,
) { ) {
val effectiveWriteVisibility: Visibility get() = writeVisibility ?: visibility
enum class Type(val comparable: Boolean = false,val serializable: Boolean = false) { enum class Type(val comparable: Boolean = false,val serializable: Boolean = false) {
Field(true, true), Field(true, true),
@Suppress("unused") @Suppress("unused")
@@ -42,6 +44,7 @@ data class ObjRecord(
@Suppress("unused") @Suppress("unused")
Class, Class,
Enum, Enum,
Property,
Other; Other;
val isArgument get() = this == Argument val isArgument get() = this == Argument
@@ -27,6 +27,7 @@ import net.sergeych.lyng.*
*/ */
sealed interface ObjRef { sealed interface ObjRef {
suspend fun get(scope: Scope): ObjRecord suspend fun get(scope: Scope): ObjRecord
/** /**
* Fast path for evaluating an expression to a raw Obj value without wrapping it into ObjRecord. * Fast path for evaluating an expression to a raw Obj value without wrapping it into ObjRecord.
* Default implementation calls [get] and returns its value. Nodes can override to avoid record traffic. * Default implementation calls [get] and returns its value. Nodes can override to avoid record traffic.
@@ -35,6 +36,20 @@ sealed interface ObjRef {
suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) { suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
throw ScriptError(pos, "can't assign value") throw ScriptError(pos, "can't assign value")
} }
/**
* Calls [block] for each variable name that this reference targets for writing.
* Used for declaring local variables in destructuring.
*/
fun forEachVariable(block: (String) -> Unit) {}
/**
* Calls [block] for each variable name that this reference targets for writing,
* including its source position if available.
*/
fun forEachVariableWithPos(block: (String, Pos) -> Unit) {
forEachVariable { block(it, Pos.UNKNOWN) }
}
} }
/** Runtime-computed read-only reference backed by a lambda. */ /** Runtime-computed read-only reference backed by a lambda. */
@@ -117,16 +132,16 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
val av = a.value val av = a.value
val bv = b.value val bv = b.value
val r: Obj? = when (op) { val r: Obj? = when (op) {
BinOp.PLUS -> ObjInt(av + bv) BinOp.PLUS -> ObjInt.of(av + bv)
BinOp.MINUS -> ObjInt(av - bv) BinOp.MINUS -> ObjInt.of(av - bv)
BinOp.STAR -> ObjInt(av * bv) BinOp.STAR -> ObjInt.of(av * bv)
BinOp.SLASH -> if (bv != 0L) ObjInt(av / bv) else null BinOp.SLASH -> if (bv != 0L) ObjInt.of(av / bv) else null
BinOp.PERCENT -> if (bv != 0L) ObjInt(av % bv) else null BinOp.PERCENT -> if (bv != 0L) ObjInt.of(av % bv) else null
BinOp.BAND -> ObjInt(av and bv) BinOp.BAND -> ObjInt.of(av and bv)
BinOp.BXOR -> ObjInt(av xor bv) BinOp.BXOR -> ObjInt.of(av xor bv)
BinOp.BOR -> ObjInt(av or bv) BinOp.BOR -> ObjInt.of(av or bv)
BinOp.SHL -> ObjInt(av shl (bv.toInt() and 63)) BinOp.SHL -> ObjInt.of(av shl (bv.toInt() and 63))
BinOp.SHR -> ObjInt(av shr (bv.toInt() and 63)) BinOp.SHR -> ObjInt.of(av shr (bv.toInt() and 63))
BinOp.EQ -> if (av == bv) ObjTrue else ObjFalse BinOp.EQ -> if (av == bv) ObjTrue else ObjFalse
BinOp.NEQ -> if (av != bv) ObjTrue else ObjFalse BinOp.NEQ -> if (av != bv) ObjTrue else ObjFalse
BinOp.LT -> if (av < bv) ObjTrue else ObjFalse BinOp.LT -> if (av < bv) ObjTrue else ObjFalse
@@ -201,11 +216,11 @@ class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val r
val ad: Double = if (a is ObjInt) a.doubleValue else (a as ObjReal).value val ad: Double = if (a is ObjInt) a.doubleValue else (a as ObjReal).value
val bd: Double = if (b is ObjInt) b.doubleValue else (b as ObjReal).value val bd: Double = if (b is ObjInt) b.doubleValue else (b as ObjReal).value
val rNum: Obj? = when (op) { val rNum: Obj? = when (op) {
BinOp.PLUS -> ObjReal(ad + bd) BinOp.PLUS -> ObjReal.of(ad + bd)
BinOp.MINUS -> ObjReal(ad - bd) BinOp.MINUS -> ObjReal.of(ad - bd)
BinOp.STAR -> ObjReal(ad * bd) BinOp.STAR -> ObjReal.of(ad * bd)
BinOp.SLASH -> ObjReal(ad / bd) BinOp.SLASH -> ObjReal.of(ad / bd)
BinOp.PERCENT -> ObjReal(ad % bd) BinOp.PERCENT -> ObjReal.of(ad % bd)
BinOp.LT -> if (ad < bd) ObjTrue else ObjFalse BinOp.LT -> if (ad < bd) ObjTrue else ObjFalse
BinOp.LTE -> if (ad <= bd) ObjTrue else ObjFalse BinOp.LTE -> if (ad <= bd) ObjTrue else ObjFalse
BinOp.GT -> if (ad > bd) ObjTrue else ObjFalse BinOp.GT -> if (ad > bd) ObjTrue else ObjFalse
@@ -360,22 +375,12 @@ class IncDecRef(
if (!rec.isMutable) scope.raiseError("Cannot ${if (isIncrement) "increment" else "decrement"} immutable value") if (!rec.isMutable) scope.raiseError("Cannot ${if (isIncrement) "increment" else "decrement"} immutable value")
val v = rec.value val v = rec.value
val one = ObjInt.One val one = ObjInt.One
return if (v.isConst) { // We now treat numbers as immutable and always perform write-back via setAt.
// Mirror existing semantics in Compiler for const values // This avoids issues where literals are shared and mutated in-place.
val result = if (isIncrement) v.plus(scope, one) else v.minus(scope, one) // For post-inc: return ORIGINAL value; for pre-inc: return NEW value.
// write back val result = if (isIncrement) v.plus(scope, one) else v.minus(scope, one)
target.setAt(atPos, scope, result) target.setAt(atPos, scope, result)
// For post-inc: previous code returned NEW value; for pre-inc: returned ORIGINAL value return (if (isPost) v else result).asReadonly
if (isPost) result.asReadonly else v.asReadonly
} else {
val res = when {
isIncrement && isPost -> v.getAndIncrement(scope)
isIncrement && !isPost -> v.incrementAndGet(scope)
!isIncrement && isPost -> v.getAndDecrement(scope)
else -> v.decrementAndGet(scope)
}
res.asReadonly
}
} }
} }
@@ -437,9 +442,9 @@ class ConstRef(private val record: ObjRecord) : ObjRef {
* Reference to an object's field with optional chaining. * Reference to an object's field with optional chaining.
*/ */
class FieldRef( class FieldRef(
private val target: ObjRef, val target: ObjRef,
private val name: String, val name: String,
private val isOptional: Boolean, val isOptional: Boolean,
) : ObjRef { ) : ObjRef {
// 4-entry PIC for reads/writes (guarded by PerfFlags.FIELD_PIC) // 4-entry PIC for reads/writes (guarded by PerfFlags.FIELD_PIC)
// Reads // Reads
@@ -620,12 +625,16 @@ class FieldRef(
val (k, v) = receiverKeyAndVersion(base) val (k, v) = receiverKeyAndVersion(base)
val rec = tRecord val rec = tRecord
if (rec != null && tKey == k && tVer == v && tFrameId == scope.frameId) { if (rec != null && tKey == k && tVer == v && tFrameId == scope.frameId) {
// visibility/mutability checks // If it is a property, we must go through writeField (slow path for now)
if (!rec.isMutable) scope.raiseError(ObjIllegalAssignmentException(scope, "can't reassign val $name")) // or handle it here.
if (!rec.visibility.isPublic) if (rec.type != ObjRecord.Type.Property) {
scope.raiseError(ObjAccessException(scope, "can't access non-public field $name")) // visibility/mutability checks
if (rec.value.assign(scope, newValue) == null) rec.value = newValue if (!rec.isMutable) scope.raiseError(ObjIllegalAssignmentException(scope, "can't reassign val $name"))
return if (!rec.visibility.isPublic)
scope.raiseError(ObjAccessException(scope, "can't access non-public field $name"))
if (rec.value.assign(scope, newValue) == null) rec.value = newValue
return
}
} }
} }
if (fieldPic) { if (fieldPic) {
@@ -1169,18 +1178,25 @@ class MethodCallRef(
when (base) { when (base) {
is ObjInstance -> { is ObjInstance -> {
// Prefer resolved class member to avoid per-call lookup on hit // Prefer resolved class member to avoid per-call lookup on hit
val member = base.objClass.getInstanceMemberOrNull(name) // BUT only if it's NOT a root object member (which can be shadowed by extensions)
if (member != null) { var hierarchyMember: ObjRecord? = null
val visibility = member.visibility for (cls in base.objClass.mro) {
val callable = member.value if (cls.className == "Obj") break
hierarchyMember = cls.members[name] ?: cls.classScope?.objects?.get(name)
if (hierarchyMember != null) break
}
if (hierarchyMember != null) {
val visibility = hierarchyMember.visibility
val callable = hierarchyMember.value
mKey1 = key; mVer1 = ver; mInvoker1 = { obj, sc, a -> mKey1 = key; mVer1 = ver; mInvoker1 = { obj, sc, a ->
val inst = obj as ObjInstance val inst = obj as ObjInstance
if (!visibility.isPublic) if (!visibility.isPublic && !canAccessMember(visibility, hierarchyMember.declaringClass ?: inst.objClass, sc.currentClassCtx))
sc.raiseError(ObjAccessException(sc, "can't invoke non-public method $name")) sc.raiseError(ObjAccessException(sc, "can't invoke non-public method $name"))
callable.invoke(inst.instanceScope, inst, a) callable.invoke(inst.instanceScope, inst, a)
} }
} else { } else {
// Fallback to name-based lookup per call (uncommon) // Fallback to name-based lookup per call (handles extensions and root members)
mKey1 = key; mVer1 = ver; mInvoker1 = { obj, sc, a -> obj.invokeInstanceMethod(sc, name, a) } mKey1 = key; mVer1 = ver; mInvoker1 = { obj, sc, a -> obj.invokeInstanceMethod(sc, name, a) }
} }
} }
@@ -1214,7 +1230,14 @@ class MethodCallRef(
/** /**
* Reference to a local/visible variable by name (Phase A: scope lookup). * Reference to a local/visible variable by name (Phase A: scope lookup).
*/ */
class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef { class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef {
override fun forEachVariable(block: (String) -> Unit) {
block(name)
}
override fun forEachVariableWithPos(block: (String, Pos) -> Unit) {
block(name, atPos)
}
// Per-frame slot cache to avoid repeated name lookups // Per-frame slot cache to avoid repeated name lookups
private var cachedFrameId: Long = 0L private var cachedFrameId: Long = 0L
private var cachedSlot: Int = -1 private var cachedSlot: Int = -1
@@ -1231,7 +1254,6 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
scope.pos = atPos scope.pos = atPos
// 1) Try fast slot/local
if (!PerfFlags.LOCAL_SLOT_PIC) { if (!PerfFlags.LOCAL_SLOT_PIC) {
scope.getSlotIndexOf(name)?.let { scope.getSlotIndexOf(name)?.let {
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.localVarPicHit++ if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.localVarPicHit++
@@ -1266,10 +1288,15 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
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 (PerfFlags.PIC_DEBUG_COUNTERS) { val rec = scope.getSlotRecord(slot)
if (hit) PerfStats.localVarPicHit++ else PerfStats.localVarPicMiss++ if (rec.declaringClass != null && !canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) {
// Not visible via slot, fallback to other lookups
} else {
if (PerfFlags.PIC_DEBUG_COUNTERS) {
if (hit) PerfStats.localVarPicHit++ else PerfStats.localVarPicMiss++
}
return rec
} }
return scope.getSlotRecord(slot)
} }
if (PerfFlags.PIC_DEBUG_COUNTERS) 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`
@@ -1321,7 +1348,11 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
} }
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) {
val rec = scope.getSlotRecord(slot)
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx))
return rec.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 }
run { run {
@@ -1385,9 +1416,11 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
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) {
val rec = scope.getSlotRecord(slot) val rec = scope.getSlotRecord(slot)
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value") if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) {
rec.value = newValue if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value")
return rec.value = newValue
return
}
} }
scope[name]?.let { stored -> scope[name]?.let { stored ->
if (stored.isMutable) stored.value = newValue if (stored.isMutable) stored.value = newValue
@@ -1429,17 +1462,25 @@ class BoundLocalVarRef(
) : ObjRef { ) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
scope.pos = atPos scope.pos = atPos
return scope.getSlotRecord(slot) val rec = scope.getSlotRecord(slot)
if (rec.declaringClass != null && !canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx))
scope.raiseError(ObjAccessException(scope, "private field access"))
return rec
} }
override suspend fun evalValue(scope: Scope): Obj { override suspend fun evalValue(scope: Scope): Obj {
scope.pos = atPos scope.pos = atPos
return scope.getSlotRecord(slot).value val rec = scope.getSlotRecord(slot)
if (rec.declaringClass != null && !canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx))
scope.raiseError(ObjAccessException(scope, "private field access"))
return rec.value
} }
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) { override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
scope.pos = atPos scope.pos = atPos
val rec = scope.getSlotRecord(slot) val rec = scope.getSlotRecord(slot)
if (rec.declaringClass != null && !canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx))
scope.raiseError(ObjAccessException(scope, "private field access"))
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value") if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value")
rec.value = newValue rec.value = newValue
} }
@@ -1450,9 +1491,12 @@ class BoundLocalVarRef(
* It resolves the slot once per frame and never falls back to global/module lookup. * It resolves the slot once per frame and never falls back to global/module lookup.
*/ */
class FastLocalVarRef( class FastLocalVarRef(
private val name: String, val name: String,
private val atPos: Pos, private val atPos: Pos,
) : ObjRef { ) : ObjRef {
override fun forEachVariable(block: (String) -> Unit) {
block(name)
}
// Cache the exact scope frame that owns the slot, not just the current frame // Cache the exact scope frame that owns the slot, not just the current frame
private var cachedOwnerScope: Scope? = null private var cachedOwnerScope: Scope? = null
private var cachedOwnerFrameId: Long = 0L private var cachedOwnerFrameId: Long = 0L
@@ -1500,10 +1544,13 @@ 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 (PerfFlags.PIC_DEBUG_COUNTERS) { val rec = actualOwner.getSlotRecord(slot)
if (ownerValid) PerfStats.fastLocalHit++ else PerfStats.fastLocalMiss++ if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) {
if (PerfFlags.PIC_DEBUG_COUNTERS) {
if (ownerValid) PerfStats.fastLocalHit++ else PerfStats.fastLocalMiss++
}
return rec
} }
return actualOwner.getSlotRecord(slot)
} }
// Try per-frame local binding maps in the ancestry first (locals declared in frames) // Try per-frame local binding maps in the ancestry first (locals declared in frames)
run { run {
@@ -1540,7 +1587,11 @@ class FastLocalVarRef(
val ownerValid = isOwnerValidFor(scope) val ownerValid = isOwnerValidFor(scope)
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) return actualOwner.getSlotRecord(slot).value if (slot >= 0 && actualOwner != null) {
val rec = actualOwner.getSlotRecord(slot)
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx))
return rec.value
}
// Try per-frame local binding maps in the ancestry first // Try per-frame local binding maps in the ancestry first
run { run {
var s: Scope? = scope var s: Scope? = scope
@@ -1577,9 +1628,11 @@ class FastLocalVarRef(
val actualOwner = cachedOwnerScope val actualOwner = cachedOwnerScope
if (slot >= 0 && actualOwner != null) { if (slot >= 0 && actualOwner != null) {
val rec = actualOwner.getSlotRecord(slot) val rec = actualOwner.getSlotRecord(slot)
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value") if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) {
rec.value = newValue if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value")
return rec.value = newValue
return
}
} }
// Try per-frame local binding maps in the ancestry first // Try per-frame local binding maps in the ancestry first
run { run {
@@ -1610,6 +1663,24 @@ class FastLocalVarRef(
} }
class ListLiteralRef(private val entries: List<ListEntry>) : ObjRef { class ListLiteralRef(private val entries: List<ListEntry>) : ObjRef {
override fun forEachVariable(block: (String) -> Unit) {
for (e in entries) {
when (e) {
is ListEntry.Element -> e.ref.forEachVariable(block)
is ListEntry.Spread -> e.ref.forEachVariable(block)
}
}
}
override fun forEachVariableWithPos(block: (String, Pos) -> Unit) {
for (e in entries) {
when (e) {
is ListEntry.Element -> e.ref.forEachVariableWithPos(block)
is ListEntry.Spread -> e.ref.forEachVariableWithPos(block)
}
}
}
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
// Heuristic capacity hint: count element entries; spreads handled opportunistically // Heuristic capacity hint: count element entries; spreads handled opportunistically
val elemCount = entries.count { it is ListEntry.Element } val elemCount = entries.count { it is ListEntry.Element }
@@ -1625,7 +1696,7 @@ class ListLiteralRef(private val entries: List<ListEntry>) : ObjRef {
when (elements) { when (elements) {
is ObjList -> { is ObjList -> {
// Grow underlying array once when possible // Grow underlying array once when possible
if (list is ArrayList) list.ensureCapacity(list.size + elements.list.size) list.ensureCapacity(list.size + elements.list.size)
list.addAll(elements.list) list.addAll(elements.list)
} }
else -> scope.raiseError("Spread element must be list") else -> scope.raiseError("Spread element must be list")
@@ -1633,7 +1704,54 @@ class ListLiteralRef(private val entries: List<ListEntry>) : ObjRef {
} }
} }
} }
return ObjList(list).asReadonly return ObjList(list).asMutable
}
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
val sourceList = (newValue as? ObjList)?.list
?: throw ScriptError(pos, "destructuring assignment requires a list on the right side")
val ellipsisIdx = entries.indexOfFirst { it is ListEntry.Spread }
if (entries.count { it is ListEntry.Spread } > 1) {
throw ScriptError(pos, "destructuring pattern can have only one splat")
}
if (ellipsisIdx < 0) {
if (sourceList.size < entries.size)
throw ScriptError(pos, "too few elements for destructuring")
for (i in entries.indices) {
val entry = entries[i]
if (entry is ListEntry.Element) {
entry.ref.setAt(pos, scope, sourceList[i])
}
}
} else {
val headCount = ellipsisIdx
val tailCount = entries.size - ellipsisIdx - 1
if (sourceList.size < headCount + tailCount)
throw ScriptError(pos, "too few elements for destructuring")
// head
for (i in 0 until headCount) {
val entry = entries[i]
if (entry is ListEntry.Element) {
entry.ref.setAt(pos, scope, sourceList[i])
}
}
// tail
for (i in 0 until tailCount) {
val entry = entries[entries.size - 1 - i]
if (entry is ListEntry.Element) {
entry.ref.setAt(pos, scope, sourceList[sourceList.size - 1 - i])
}
}
// ellipsis
val spreadEntry = entries[ellipsisIdx] as ListEntry.Spread
val spreadList = sourceList.subList(headCount, sourceList.size - tailCount)
spreadEntry.ref.setAt(pos, scope, ObjList(spreadList.toMutableList()))
}
} }
} }
@@ -1656,10 +1774,8 @@ class MapLiteralRef(private val entries: List<MapLiteralEntry>) : ObjRef {
is MapLiteralEntry.Spread -> { is MapLiteralEntry.Spread -> {
val m = if (PerfFlags.RVAL_FASTPATH) e.ref.evalValue(scope) else e.ref.get(scope).value 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") 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) { for ((k, v) in m.map) {
val sKey = k as? ObjString ?: scope.raiseIllegalArgument("spread map must have string keys; got $k") result.map[k] = v
result.map[sKey] = v
} }
} }
} }
@@ -1691,10 +1807,16 @@ class AssignRef(
) : ObjRef { ) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val v = if (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) // For properties, we should not call get() on target because it invokes the getter.
if (!rec.isMutable) throw ScriptError(atPos, "cannot assign to immutable variable") // Instead, we call setAt directly.
if (rec.value.assign(scope, v) == null) { if (target is FieldRef || target is IndexRef || target is LocalVarRef || target is FastLocalVarRef || target is BoundLocalVarRef) {
target.setAt(atPos, scope, v) target.setAt(atPos, scope, v)
} else {
val rec = target.get(scope)
if (!rec.isMutable) throw ScriptError(atPos, "cannot assign to immutable variable")
if (rec.value.assign(scope, v) == null) {
target.setAt(atPos, scope, v)
}
} }
return v.asReadonly return v.asReadonly
} }
@@ -103,7 +103,7 @@ class ObjRegexMatch(val match: MatchResult) : Obj() {
) )
} }
override suspend fun toString(scope: Scope,calledFromLyng: Boolean): ObjString { override suspend fun defaultToString(scope: Scope): ObjString {
return ObjString("RegexMath(${objRange.toString(scope)},${objGroups.toString(scope)})") return ObjString("RegexMath(${objRange.toString(scope)},${objGroups.toString(scope)})")
} }
@@ -122,6 +122,12 @@ class ObjRingBuffer(val capacity: Int) : Obj() {
returns = type("lyng.Void"), returns = type("lyng.Void"),
moduleName = "lyng.stdlib" moduleName = "lyng.stdlib"
) { thisAs<ObjRingBuffer>().apply { buffer.add(requireOnlyArg<Obj>()) } } ) { thisAs<ObjRingBuffer>().apply { buffer.add(requireOnlyArg<Obj>()) } }
addFnDoc(
name = "first",
doc = "Return the oldest element in the buffer.",
returns = type("lyng.Any"),
moduleName = "lyng.stdlib"
) { thisAs<ObjRingBuffer>().buffer.first() }
} }
} }
} }
@@ -34,6 +34,12 @@ class ObjSet(val set: MutableSet<Obj> = mutableSetOf()) : Obj() {
return set.contains(other) return set.contains(other)
} }
override suspend fun enumerate(scope: Scope, callback: suspend (Obj) -> Boolean) {
for (item in set) {
if (!callback(item)) break
}
}
override suspend fun plus(scope: Scope, other: Obj): Obj { override suspend fun plus(scope: Scope, other: Obj): Obj {
return ObjSet( return ObjSet(
if (other is ObjSet) if (other is ObjSet)
@@ -134,7 +134,7 @@ data class ObjString(val value: String) : Obj() {
returns = type("lyng.Int"), returns = type("lyng.Int"),
moduleName = "lyng.stdlib" moduleName = "lyng.stdlib"
) { ) {
ObjInt( ObjInt.of(
thisAs<ObjString>().value.toLongOrNull() thisAs<ObjString>().value.toLongOrNull()
?: raiseIllegalArgument("can't convert to int: $thisObj") ?: raiseIllegalArgument("can't convert to int: $thisObj")
) )
@@ -159,7 +159,7 @@ data class ObjString(val value: String) : Obj() {
} }
addConstDoc( addConstDoc(
name = "length", name = "length",
value = statement { ObjInt(thisAs<ObjString>().value.length.toLong()) }, value = statement { ObjInt.of(thisAs<ObjString>().value.length.toLong()) },
doc = "Number of UTF-16 code units in this string.", doc = "Number of UTF-16 code units in this string.",
type = type("lyng.Int"), type = type("lyng.Int"),
moduleName = "lyng.stdlib" moduleName = "lyng.stdlib"
@@ -216,6 +216,14 @@ data class ObjString(val value: String) : Obj() {
) { ) {
thisAs<ObjString>().value.lowercase().let(::ObjString) thisAs<ObjString>().value.lowercase().let(::ObjString)
} }
addFnDoc(
name = "lowercase",
doc = "Lowercase version of this string (default locale).",
returns = type("lyng.String"),
moduleName = "lyng.stdlib"
) {
thisAs<ObjString>().value.lowercase().let(::ObjString)
}
addFnDoc( addFnDoc(
name = "upper", name = "upper",
doc = "Uppercase version of this string (default locale).", doc = "Uppercase version of this string (default locale).",
@@ -224,6 +232,14 @@ data class ObjString(val value: String) : Obj() {
) { ) {
thisAs<ObjString>().value.uppercase().let(::ObjString) thisAs<ObjString>().value.uppercase().let(::ObjString)
} }
addFnDoc(
name = "uppercase",
doc = "Uppercase version of this string (default locale).",
returns = type("lyng.String"),
moduleName = "lyng.stdlib"
) {
thisAs<ObjString>().value.uppercase().let(::ObjString)
}
addFnDoc( addFnDoc(
name = "characters", name = "characters",
doc = "List of characters of this string.", doc = "List of characters of this string.",
@@ -253,14 +269,14 @@ data class ObjString(val value: String) : Obj() {
doc = "Alias for length: the number of characters (code units) in this string.", doc = "Alias for length: the number of characters (code units) in this string.",
returns = type("lyng.Int"), returns = type("lyng.Int"),
moduleName = "lyng.stdlib" moduleName = "lyng.stdlib"
) { ObjInt(thisAs<ObjString>().value.length.toLong()) } ) { ObjInt.of(thisAs<ObjString>().value.length.toLong()) }
addFnDoc( addFnDoc(
name = "toReal", name = "toReal",
doc = "Parse this string as a real number (floating point).", doc = "Parse this string as a real number (floating point).",
returns = type("lyng.Real"), returns = type("lyng.Real"),
moduleName = "lyng.stdlib" moduleName = "lyng.stdlib"
) { ) {
ObjReal(thisAs<ObjString>().value.toDouble()) ObjReal.of(thisAs<ObjString>().value.toDouble())
} }
addFnDoc( addFnDoc(
name = "trim", name = "trim",
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -19,7 +19,6 @@ package net.sergeych.lyng
import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjClass import net.sergeych.lyng.obj.ObjClass
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjVoid import net.sergeych.lyng.obj.ObjVoid
fun String.toSource(name: String = "eval"): Source = Source(name, this) fun String.toSource(name: String = "eval"): Source = Source(name, this)
@@ -46,8 +45,8 @@ abstract class Statement(
abstract suspend fun execute(scope: Scope): Obj abstract suspend fun execute(scope: Scope): Obj
override suspend fun compareTo(scope: Scope, other: Obj): Int { override suspend fun compareTo(scope: Scope, other: Obj): Int {
if( other == ObjNull || other == ObjVoid ) return 1 if( other === this ) return 0
throw UnsupportedOperationException("not comparable") return -3
} }
override suspend fun callOn(scope: Scope): Obj { override suspend fun callOn(scope: Scope): Obj {
@@ -82,9 +82,17 @@ open class LynonDecoder(val bin: BitInput, val settings: LynonSettings = LynonSe
if (it !is ObjClass) if (it !is ObjClass)
scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}") scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}")
it it
} ?: scope.raiseSymbolNotFound("can't deserialize: not found type $className") } ?: run {
// Use Scope API that mirrors compiler-emitted ObjRef chain for qualified identifiers
val evaluated = scope.resolveQualifiedIdentifier(className.value)
if (evaluated !is ObjClass)
scope.raiseClassCastError("Expected obj class but got ${evaluated::class.simpleName}")
evaluated
}
} }
// helper moved to Scope as resolveQualifiedIdentifier
suspend fun decodeAnyList(scope: Scope, fixedSize: Int? = null): MutableList<Obj> { suspend fun decodeAnyList(scope: Scope, fixedSize: Int? = null): MutableList<Obj> {
return if (bin.getBit() == 1) { return if (bin.getBit() == 1) {
// homogenous // homogenous
@@ -17,7 +17,7 @@
package net.sergeych.tools package net.sergeych.tools
import kotlinx.datetime.Clock import kotlin.time.Clock
inline fun bm(text: String="", f: ()->Unit) { inline fun bm(text: String="", f: ()->Unit) {
val start = Clock.System.now() val start = Clock.System.now()
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -49,10 +49,10 @@ class BindingHighlightTest {
val binding = Binder.bind(text, mini!!) val binding = Binder.bind(text, mini!!)
// Find the top-level symbol for counter and ensure it is mutable (Var) // Find the top-level symbol for counter and ensure it is mutable (Variable)
val sym = binding.symbols.firstOrNull { it.name == "counter" } val sym = binding.symbols.firstOrNull { it.name == "counter" }
assertNotNull(sym, "Top-level var 'counter' must be registered as a symbol") assertNotNull(sym, "Top-level var 'counter' must be registered as a symbol")
assertEquals(SymbolKind.Var, sym.kind, "'counter' declared with var should be SymbolKind.Var") assertEquals(SymbolKind.Variable, sym.kind, "'counter' declared with var should be SymbolKind.Variable")
// Declaration position // Declaration position
val declRange = sym.declStart to sym.declEnd val declRange = sym.declStart to sym.declEnd
@@ -82,7 +82,7 @@ class BindingHighlightTest {
val sym = binding.symbols.firstOrNull { it.name == "answer" } val sym = binding.symbols.firstOrNull { it.name == "answer" }
assertNotNull(sym, "Top-level val 'answer' must be registered as a symbol") assertNotNull(sym, "Top-level val 'answer' must be registered as a symbol")
assertEquals(SymbolKind.Val, sym.kind, "'answer' declared with val should be SymbolKind.Val") assertEquals(SymbolKind.Value, sym.kind, "'answer' declared with val should be SymbolKind.Value")
val declRange = sym.declStart to sym.declEnd val declRange = sym.declStart to sym.declEnd
val refs = binding.references.filter { it.symbolId == sym.id && (it.start to it.end) != declRange } val refs = binding.references.filter { it.symbolId == sym.id && (it.start to it.end) != declRange }
@@ -119,7 +119,7 @@ class BindingHighlightTest {
// Ensure we registered the local var/val symbol for `name` // Ensure we registered the local var/val symbol for `name`
val nameSym = binding.symbols.firstOrNull { it.name == "name" } val nameSym = binding.symbols.firstOrNull { it.name == "name" }
assertNotNull(nameSym, "Local variable 'name' should be registered as a symbol") assertNotNull(nameSym, "Local variable 'name' should be registered as a symbol")
assertEquals(SymbolKind.Var, nameSym.kind, "'name' is declared with var and must be SymbolKind.Var") assertEquals(SymbolKind.Variable, nameSym.kind, "'name' is declared with var and must be SymbolKind.Variable")
// Ensure there is at least one usage reference to `name` (not just the declaration) // Ensure there is at least one usage reference to `name` (not just the declaration)
val nameRefs = binding.references.filter { it.symbolId == nameSym.id } val nameRefs = binding.references.filter { it.symbolId == nameSym.id }
@@ -165,7 +165,7 @@ class BindingHighlightTest {
val binding = Binder.bind(text, mini!!) val binding = Binder.bind(text, mini!!)
val nameSym = binding.symbols.firstOrNull { it.name == "name" && (it.kind == SymbolKind.Var || it.kind == SymbolKind.Val) } val nameSym = binding.symbols.firstOrNull { it.name == "name" && (it.kind == SymbolKind.Variable || it.kind == SymbolKind.Value) }
assertNotNull(nameSym, "Local variable 'name' should be registered as a symbol") assertNotNull(nameSym, "Local variable 'name' should be registered as a symbol")
// Find the specific usage inside string-literal invocation: "%s is directory"(name) // Find the specific usage inside string-literal invocation: "%s is directory"(name)
+2 -2
View File
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -49,7 +49,7 @@ class BindingTest {
} }
""" """
) )
// Expect at least one Param symbol "a" and one Val symbol "x" // Expect at least one Parameter symbol "a" and one Value symbol "x"
val aIds = snap.symbols.filter { it.name == "a" }.map { it.id } val aIds = snap.symbols.filter { it.name == "a" }.map { it.id }
val xIds = snap.symbols.filter { it.name == "x" }.map { it.id } val xIds = snap.symbols.filter { it.name == "x" }.map { it.id }
assertTrue(aIds.isNotEmpty()) assertTrue(aIds.isNotEmpty())
+12 -12
View File
@@ -24,7 +24,6 @@ import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.ScriptError import net.sergeych.lyng.ScriptError
import net.sergeych.lyng.eval import net.sergeych.lyng.eval
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
class MapLiteralTest { class MapLiteralTest {
@@ -119,20 +118,21 @@ class MapLiteralTest {
} }
@Test @Test
fun spreadNonStringKeysIsRuntimeError() = runTest { fun spreadNonStringKeysIsAllowed() = runTest {
assertFailsWith<ExecutionError> { eval("""
eval("""{ ...Map(1 => "x") }""") val m = { ...Map(1 => "x") }
} assertEquals("x", m[1])
""")
} }
@Test @Test
fun mergeNonStringKeyIsRuntimeError() = runTest { fun mergeNonStringKeyIsAllowed() = runTest {
assertFailsWith<ExecutionError> { eval("""
eval(""" val e = (1 => "x")
val e = (1 => "x") val m = { "a": 1 } + e
{ "a": 1 } + e assertEquals(1, m["a"])
""") assertEquals("x", m[1])
} """)
} }
@Test @Test
+75 -1
View File
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -131,4 +131,78 @@ class MiniAstTest {
// Bases captured as plain names for now // Bases captured as plain names for now
assertEquals(listOf("Base1", "Base2"), cd.bases) assertEquals(listOf("Base1", "Base2"), cd.bases)
} }
@Test
fun miniAst_captures_enum_entries_and_doc() = runTest {
val code = """
/** Enum E docs */
enum E {
A,
B,
C
}
"""
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
val ed = mini!!.declarations.filterIsInstance<MiniEnumDecl>().firstOrNull { it.name == "E" }
assertNotNull(ed)
assertNotNull(ed.doc)
assertTrue(ed.doc!!.raw.contains("Enum E docs"))
assertEquals(listOf("A", "B", "C"), ed.entries)
assertEquals("E", ed.name)
}
@Test
fun enum_to_synthetic_class_members() = runTest {
val code = """
enum MyEnum { V1, V2 }
"""
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
// I'll check via aggregateClasses by mocking the registry or just checking it includes Enum base.
val stdlib = BuiltinDocRegistry.docsForModule("lyng.stdlib")
val enumBase = stdlib.filterIsInstance<MiniClassDecl>().firstOrNull { it.name == "Enum" }
assertNotNull(enumBase, "Enum base class should be in stdlib")
assertTrue(enumBase.members.any { it.name == "name" })
assertTrue(enumBase.members.any { it.name == "ordinal" })
// Check if aggregateClasses handles enums from local MiniScript
val classes = DocLookupUtils.aggregateClasses(listOf("lyng.stdlib"), mini)
val myEnum = classes["MyEnum"]
assertNotNull(myEnum, "Local enum should be aggregated as a class")
assertEquals(listOf("Enum"), myEnum.bases)
assertTrue(myEnum.members.any { it.name == "entries" }, "Should have entries")
assertTrue(myEnum.members.any { it.name == "valueOf" }, "Should have valueOf")
assertTrue(myEnum.members.any { it.name == "V1" }, "Should have V1")
assertTrue(myEnum.members.any { it.name == "V2" }, "Should have V2")
}
@Test
fun complete_enum_members() = runTest {
val code = """
enum MyEnum { V1, V2 }
val x = MyEnum.V1.<caret>
"""
val items = CompletionEngineLight.completeAtMarkerSuspend(code)
val names = items.map { it.name }.toSet()
assertTrue(names.contains("name"), "Should contain name from Enum base")
assertTrue(names.contains("ordinal"), "Should contain ordinal from Enum base")
}
@Test
fun complete_enum_class_members() = runTest {
val code = """
enum MyEnum { V1, V2 }
val x = MyEnum.<caret>
"""
val items = CompletionEngineLight.completeAtMarkerSuspend(code)
val names = items.map { it.name }.toSet()
assertTrue(names.contains("entries"), "Should contain entries")
assertTrue(names.contains("valueOf"), "Should contain valueOf")
assertTrue(names.contains("V1"), "Should contain V1")
assertTrue(names.contains("V2"), "Should contain V2")
}
} }
+346 -17
View File
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -16,13 +16,19 @@
*/ */
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.Script
import net.sergeych.lyng.eval import net.sergeych.lyng.eval
import net.sergeych.lyng.obj.ObjInstance
import net.sergeych.lyng.obj.ObjList
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
class OOTest { class OOTest {
@Test @Test
fun testClassProps() = runTest { fun testClassProps() = runTest {
eval(""" eval(
"""
import lyng.time import lyng.time
class Point(x,y) { class Point(x,y) {
@@ -35,11 +41,14 @@ class OOTest {
assertEquals(Point(0,0), Point.origin) assertEquals(Point(0,0), Point.origin)
assertEquals(Point(1,2), Point.center) assertEquals(Point(1,2), Point.center)
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
fun testClassMethods() = runTest { fun testClassMethods() = runTest {
eval(""" eval(
"""
import lyng.time import lyng.time
class Point(x,y) { class Point(x,y) {
@@ -58,12 +67,14 @@ class OOTest {
assertEquals(null, Point.getData() ) assertEquals(null, Point.getData() )
Point.setData("foo") Point.setData("foo")
assertEquals( "foo!", Point.getData() ) assertEquals( "foo!", Point.getData() )
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
fun testDynamicGet() = runTest { fun testDynamicGet() = runTest {
eval(""" eval(
"""
val accessor = dynamic { val accessor = dynamic {
get { name -> get { name ->
if( name == "foo" ) "bar" else null if( name == "foo" ) "bar" else null
@@ -74,12 +85,14 @@ class OOTest {
assertEquals("bar", accessor.foo) assertEquals("bar", accessor.foo)
assertEquals(null, accessor.bar) assertEquals(null, accessor.bar)
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
fun testDelegateSet() = runTest { fun testDelegateSet() = runTest {
eval(""" eval(
"""
var setValueForBar = null var setValueForBar = null
val accessor = dynamic { val accessor = dynamic {
get { name -> get { name ->
@@ -104,12 +117,14 @@ class OOTest {
assertThrows { assertThrows {
accessor.bad = "!23" accessor.bad = "!23"
} }
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
fun testDynamicIndexAccess() = runTest { fun testDynamicIndexAccess() = runTest {
eval(""" eval(
"""
val store = Map() val store = Map()
val accessor = dynamic { val accessor = dynamic {
get { name -> get { name ->
@@ -124,23 +139,27 @@ class OOTest {
accessor["foo"] = "bar" accessor["foo"] = "bar"
assertEquals("bar", accessor["foo"]) assertEquals("bar", accessor["foo"])
assertEquals("bar", accessor.foo) assertEquals("bar", accessor.foo)
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
fun testMultilineConstructor() = runTest { fun testMultilineConstructor() = runTest {
eval(""" eval(
"""
class Point( class Point(
x, x,
y y
) )
assertEquals(Point(1,2), Point(1,2) ) assertEquals(Point(1,2), Point(1,2) )
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
fun testDynamicClass() = runTest { fun testDynamicClass() = runTest {
eval(""" eval(
"""
fun getContract(contractName) { fun getContract(contractName) {
dynamic { dynamic {
@@ -150,14 +169,16 @@ class OOTest {
} }
} }
getContract("foo").bar getContract("foo").bar
""") """
)
} }
@Test @Test
fun testDynamicClassReturn2() = runTest { fun testDynamicClassReturn2() = runTest {
// todo: should work without extra parenthesis // todo: should work without extra parenthesis
// see below // see below
eval(""" eval(
"""
fun getContract(contractName) { fun getContract(contractName) {
println("1") println("1")
@@ -184,6 +205,314 @@ class OOTest {
assertEquals(6, x(1,2,3)) assertEquals(6, x(1,2,3))
// v HERE v // v HERE v
assertEquals(15, cc.foo.bar(10,2,3)) assertEquals(15, cc.foo.bar(10,2,3))
""") """
)
} }
@Test
fun testClassInitialization() = runTest {
eval(
"""
var countInstances = 0
class Point(val x: Int, val y: Int) {
println("Class initializer is called 1")
var magnitude
/*
init {} section optionally provide initialization code that is called on each instance creation.
it should have the same access to this.* and constructor parameters as any other member
function.
*/
init {
countInstances++
magnitude = Math.sqrt(x*x + y*y)
}
}
val p = Point(1, 2)
assertEquals(1, countInstances)
assertEquals(p, Point(1,2) )
assertEquals(2, countInstances)
""".trimIndent()
)
}
@Test
fun testMIInitialization() = runTest {
eval(
"""
var order = []
class A {
init { order.add("A") }
}
class B : A {
init { order.add("B") }
}
class C {
init { order.add("C") }
}
class D : B, C {
init { order.add("D") }
}
D()
assertEquals(["A", "B", "C", "D"], order)
"""
)
}
@Test
fun testMIDiamondInitialization() = runTest {
eval(
"""
var order = []
class A {
init { order.add("A") }
}
class B : A {
init { order.add("B") }
}
class C : A {
init { order.add("C") }
}
class D : B, C {
init { order.add("D") }
}
D()
assertEquals(["A", "B", "C", "D"], order)
"""
)
}
@Test
fun testInitBlockInDeserialization() = runTest {
eval(
"""
import lyng.serialization
var count = 0
class A {
init { count++ }
}
val a1 = A()
val coded = Lynon.encode(a1)
val a2 = Lynon.decode(coded)
assertEquals(2, count)
"""
)
}
@Test
fun testDefaultCompare() = runTest {
eval(
"""
class Point(val x: Int, val y: Int)
assertEquals(Point(1,2), Point(1,2) )
assert( Point(1,2) != Point(2,1) )
assert( Point(1,2) == Point(1,2) )
""".trimIndent()
)
}
@Test
fun testConstructorCallsWithNamedParams() = runTest {
val scope = Script.newScope()
val list = scope.eval(
"""
import lyng.time
class BarRequest(
id,
vaultId, userAddress, isDepositRequest, grossWeight, fineness, notes="",
createdAt = Instant.now().truncateToSecond(),
updatedAt = Instant.now().truncateToSecond()
) {
// unrelated for comparison
static val stateNames = [1, 2, 3]
val cell = cached { Cell[id] }
}
assertEquals( 5,5.toInt())
val b1 = BarRequest(1, "v1", "u1", true, 1000, 999)
val b2 = BarRequest(1, "v1", "u1", true, 1000, 999, createdAt: b1.createdAt, updatedAt: b1.updatedAt)
assertEquals(b1, b2)
assertEquals( 0, b1 <=> b2)
[b1, b2]
""".trimIndent()
) as ObjList
val b1 = list.list[0] as ObjInstance
val b2 = list.list[1] as ObjInstance
assertEquals(0, b1.compareTo(scope, b2))
}
@Test
fun testPropAsExtension() = runTest {
val scope = Script.newScope()
scope.eval("""
class A(x) {
private val privateVal = 100
val p1 get() = x + 1
}
assertEquals(2, A(1).p1)
fun A.f() = x + 5
assertEquals(7, A(2).f())
// The same, we should be able to add member values to a class;
// notice it should access to the class public instance members,
// somewhat like it is declared in the class body
val A.simple = x + 3
assertEquals(5, A(2).simple)
// it should also work with properties:
val A.p10 get() = x * 10
assertEquals(20, A(2).p10)
""".trimIndent())
// important is that such extensions should not be able to access private members
// and thus remove privateness:
assertFails {
scope.eval("val A.exportPrivateVal = privateVal; A(1).exportPrivateVal")
}
assertFails {
scope.eval("val A.exportPrivateValProp get() = privateVal; A(1).exportPrivateValProp")
}
}
@Test
fun testExtensionsAreScopeIsolated() = runTest {
val scope1 = Script.newScope()
scope1.eval("""
val String.totalDigits get() {
// notice using `this`:
this.characters.filter{ it.isDigit() }.size()
}
assertEquals(2, "answer is 42".totalDigits)
""")
val scope2 = Script.newScope()
scope2.eval("""
// in scope2 we didn't override `totalDigits` extension:
assertThrows { "answer is 42".totalDigits }
""".trimIndent())
}
@Test
fun testCacheInClass() = runTest {
eval("""
class T(salt) {
private var c
init {
println("create cached with "+salt)
c = cached { salt + "." }
}
fun getResult() = c()
}
val t1 = T("foo")
val t2 = T("bar")
assertEquals("bar.", t2.getResult())
assertEquals("foo.", t1.getResult())
""".trimIndent())
}
@Test
fun testLateInitValsInClasses() = runTest {
assertFails {
eval("""
class T {
val x
}
""")
}
assertFails {
eval("val String.late")
}
eval("""
// but we can "late-init" them in init block:
class OK {
val x
init {
x = "foo"
}
}
val ok = OK()
assertEquals("foo", ok.x)
// they can't be reassigned:
assertThrows(IllegalAssignmentException) {
ok.x = "bar"
}
// To test access before init, we need a trick:
class AccessBefore {
val x
fun readX() = x
init {
assertEquals(x, Unset)
// if we call readX() here, x is Unset.
// Just reading it is fine, but using it should throw:
assertThrows(UnsetException) { readX() + 1 }
x = 42
}
}
AccessBefore()
""".trimIndent())
}
@Test
fun testPrivateSet() = runTest {
eval("""
class A {
var y = 100
private set
fun setValue(newValue) { y = newValue }
}
assertEquals(100, A().y)
assertThrows(AccessException) { A().y = 200 }
val a = A()
a.setValue(200)
assertEquals(200, a.y)
class B(initial) {
var y = initial
protected set
}
class C(initial) : B(initial) {
fun setBValue(v) { y = v }
}
val c = C(10)
assertEquals(10, c.y)
assertThrows(AccessException) { c.y = 20 }
c.setBValue(30)
assertEquals(30, c.y)
class D {
private var _y = 0
var y
get() = _y
private set(v) { _y = v }
fun setY(v) { y = v }
}
val d = D()
assertEquals(0, d.y)
assertThrows(AccessException) { d.y = 10 }
d.setY(20)
assertEquals(20, d.y)
""")
}
@Test
fun testValPrivateSetError() = runTest {
assertFails {
eval("class E { val x = 1 private set }")
}
}
} }
@@ -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.
*
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.PerfFlags
import net.sergeych.lyng.eval
import kotlin.test.Test
import kotlin.test.assertEquals
class ScopePoolingRegressionTest {
@Test
fun testPooledScopeInstance() = runTest {
val saved = PerfFlags.SCOPE_POOL
PerfFlags.SCOPE_POOL = true
try {
val result = eval("""
class A {
fun test() {
// println is a global function
println("Calling println from A")
"method ok"
}
}
// Use a transient scope (lambda) to create the instance
val creator = { A() }
val a = creator()
// Re-use the pool to ensure the scope used above is reset/repurposed
val other = { 1 + 2 }
other()
other()
// Now call method on 'a'. It should still find global 'println'
a.test()
""".trimIndent())
assertEquals("method ok", result.toString())
} finally {
PerfFlags.SCOPE_POOL = saved
}
}
}
+348 -57
View File
@@ -23,8 +23,6 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@@ -36,7 +34,9 @@ import net.sergeych.lyng.pacman.InlineSourcesImportProvider
import net.sergeych.mp_tools.globalDefer import net.sergeych.mp_tools.globalDefer
import net.sergeych.tools.bm import net.sergeych.tools.bm
import kotlin.test.* import kotlin.test.*
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
@@ -2318,29 +2318,33 @@ class ScriptTest {
@Test @Test
fun doWhileValuesLabelTest() = runTest { fun doWhileValuesLabelTest() = runTest {
withTimeout(5.seconds) { withTimeout(5.seconds) {
eval( try {
""" eval(
var count = 0 """
var count2 = 0 var count = 0
var count3 = 0 var count2 = 0
val result = outer@ do { var count3 = 0
count2++ val result = outer@ do {
count = 0 count2++
do { count = 0
count++ do {
if( count < 10 || count2 < 5 ) { count++
continue if( count < 10 || count2 < 5 ) {
} continue
if( count % 2 == 1 ) }
break@outer "found "+count + "/" + count2 if( count % 2 == 1 )
} while(count < 14) break@outer "found "+count + "/" + count2
count3++ } while(count < 14)
} while( count2 < 100 ) count3++
else "no" } while( count2 < 100 )
assertEquals("found 11/5", result) else "no"
assertEquals( 4, count3) assertEquals("found 11/5", result)
""".trimIndent() assertEquals( 4, count3)
) """.trimIndent()
)
} catch (e: ExecutionError) {
throw e
}
} }
} }
@@ -3020,6 +3024,37 @@ class ScriptTest {
) )
} }
@Test
fun testMapWithNonStringKeys() = runTest {
eval(
"""
val map = Map( 1 => "one", 2 => "two" )
assertEquals( "one", map[1] )
assertEquals( "two", map[2] )
assertEquals( null, map[3] )
map[3] = "three"
assertEquals( "three", map[3] )
map += (4 => "four")
assertEquals( "four", map[4] )
// Test toMap()
val map2 = [1 => "a", 2 => "b"].toMap()
assertEquals("a", map2[1])
assertEquals("b", map2[2])
// Test Map constructor with mixed entries and arrays
val map3 = Map( 1 => "a", [2, "b"] )
assertEquals("a", map3[1])
assertEquals("b", map3[2])
// Test plus
val map4 = map3 + (3 => "c")
assertEquals("c", map4[3])
assertEquals("a", map4[1])
""".trimIndent()
)
}
@Test @Test
fun testBuffer() = runTest { fun testBuffer() = runTest {
eval( eval(
@@ -3662,7 +3697,7 @@ class ScriptTest {
} }
// @Test // @Test
fun testMinimumOptimization() = runTest { fun testMinimumOptimization() = runTest {
for (i in 1..200) { for (i in 1..200) {
bm { bm {
@@ -3919,13 +3954,15 @@ class ScriptTest {
@Test @Test
fun testJsonTime() = runTest { fun testJsonTime() = runTest {
val now = Clock.System.now() val now = Clock.System.now()
val x = eval(""" val x = eval(
"""
import lyng.time import lyng.time
Instant.now().truncateToSecond() Instant.now().truncateToSecond()
""".trimIndent()).decodeSerializable<Instant>() """.trimIndent()
).decodeSerializable<Instant>()
println(x) println(x)
assertIs<Instant>(x) assertIs<Instant>(x)
assertTrue( (now - x).absoluteValue < 2.seconds) assertTrue((now - x).absoluteValue < 2.seconds)
} }
@Test @Test
@@ -4038,15 +4075,17 @@ class ScriptTest {
@Serializable @Serializable
data class TestJson2( data class TestJson2(
val value: Int, val value: Int,
val inner: Map<String,Int> val inner: Map<String, Int>
) )
@Test @Test
fun deserializeMapWithJsonTest() = runTest { fun deserializeMapWithJsonTest() = runTest {
val x = eval(""" val x = eval(
"""
import lyng.serialization import lyng.serialization
{ value: 1, inner: { "foo": 1, "bar": 2 }} { value: 1, inner: { "foo": 1, "bar": 2 }}
""".trimIndent()).decodeSerializable<TestJson2>() """.trimIndent()
).decodeSerializable<TestJson2>()
assertEquals(TestJson2(1, mapOf("foo" to 1, "bar" to 2)), x) assertEquals(TestJson2(1, mapOf("foo" to 1, "bar" to 2)), x)
} }
@@ -4055,42 +4094,56 @@ class ScriptTest {
val value: Int, val value: Int,
val inner: JsonObject val inner: JsonObject
) )
@Test @Test
fun deserializeAnyMapWithJsonTest() = runTest { fun deserializeAnyMapWithJsonTest() = runTest {
val x = eval(""" val x = eval(
"""
import lyng.serialization import lyng.serialization
{ value: 12, inner: { "foo": 1, "bar": "two" }} { value: 12, inner: { "foo": 1, "bar": "two" }}
""".trimIndent()).decodeSerializable<TestJson3>() """.trimIndent()
assertEquals(TestJson3(12, JsonObject(mapOf("foo" to JsonPrimitive(1), "bar" to Json.encodeToJsonElement("two")))), x) ).decodeSerializable<TestJson3>()
assertEquals(
TestJson3(
12,
JsonObject(mapOf("foo" to JsonPrimitive(1), "bar" to Json.encodeToJsonElement("two")))
), x
)
} }
@Serializable @Serializable
enum class TestEnum { enum class TestEnum {
One, Two One, Two
} }
@Serializable @Serializable
data class TestJson4(val value: TestEnum) data class TestJson4(val value: TestEnum)
@Test @Test
fun deserializeEnumJsonTest() = runTest { fun deserializeEnumJsonTest() = runTest {
val x = eval(""" val x = eval(
"""
import lyng.serialization import lyng.serialization
enum TestEnum { One, Two } enum TestEnum { One, Two }
{ value: TestEnum.One } { value: TestEnum.One }
""".trimIndent()).decodeSerializable<TestJson4>() """.trimIndent()
assertEquals( TestJson4(TestEnum.One), x) ).decodeSerializable<TestJson4>()
assertEquals(TestJson4(TestEnum.One), x)
} }
@Test @Test
fun testStringLast() = runTest { fun testStringLast() = runTest {
eval(""" eval(
"""
assertEquals('t', "assert".last()) assertEquals('t', "assert".last())
""".trimIndent()) """.trimIndent()
)
} }
@Test @Test
fun testLogicalNot() = runTest { fun testLogicalNot() = runTest {
eval(""" eval(
"""
val vf = false val vf = false
fun f() { false } fun f() { false }
assert( !false ) assert( !false )
@@ -4127,7 +4180,8 @@ class ScriptTest {
@Test @Test
fun testHangOnPrintlnInMethods() = runTest { fun testHangOnPrintlnInMethods() = runTest {
eval(""" eval(
"""
class T(someList) { class T(someList) {
fun f() { fun f() {
val x = [...someList] val x = [...someList]
@@ -4135,12 +4189,14 @@ class ScriptTest {
} }
} }
T([1,2]).f() T([1,2]).f()
""") """
)
} }
@Test @Test
fun testHangOnNonexistingMethod() = runTest { fun testHangOnNonexistingMethod() = runTest {
eval(""" eval(
"""
class T(someList) { class T(someList) {
fun f() { fun f() {
nonExistingMethod() nonExistingMethod()
@@ -4156,12 +4212,14 @@ class ScriptTest {
println(t::class) println(t::class)
// ok // ok
} }
""") """
)
} }
@Test @Test
fun testUsingClassConstructorVars() = runTest { fun testUsingClassConstructorVars() = runTest {
val r = eval(""" val r = eval(
"""
import lyng.time import lyng.time
class Request { class Request {
@@ -4185,7 +4243,8 @@ class ScriptTest {
} }
test() test()
""".trimIndent()).toJson() """.trimIndent()
).toJson()
println(r) println(r)
} }
@@ -4193,7 +4252,8 @@ class ScriptTest {
fun testScopeShortCircuit() = runTest() { fun testScopeShortCircuit() = runTest() {
val baseScope = Script.newScope() val baseScope = Script.newScope()
baseScope.eval(""" baseScope.eval(
"""
val exports = Map() val exports = Map()
fun Export(name,f) { fun Export(name,f) {
exports[name] = f exports[name] = f
@@ -4204,7 +4264,8 @@ class ScriptTest {
val exports: MutableMap<Obj, Obj> = (baseScope.eval("exports") as ObjMap).map val exports: MutableMap<Obj, Obj> = (baseScope.eval("exports") as ObjMap).map
baseScope.eval(""" baseScope.eval(
"""
class A(val a) { class A(val a) {
fun methodA() { fun methodA() {
a + 1 a + 1
@@ -4221,24 +4282,254 @@ class ScriptTest {
fun exportedFunction(x) { fun exportedFunction(x) {
someFunction(x) someFunction(x)
} }
""".trimIndent()) """.trimIndent()
)
// Calling from the script is ok: // Calling from the script is ok:
val instanceScope = baseScope.createChildScope() val instanceScope = baseScope.createChildScope()
instanceScope.eval(""" instanceScope.eval(
"""
val a1 = a0 + 1 val a1 = a0 + 1
""".trimIndent()) """.trimIndent()
assertEquals( ObjInt(2), instanceScope.eval(""" )
assertEquals(
ObjInt(2), instanceScope.eval(
"""
exportedFunction(1) exportedFunction(1)
""")) """
assertEquals( ObjInt(103), instanceScope.eval(""" )
)
assertEquals(
ObjInt(103), instanceScope.eval(
"""
exportedFunction(a1 + 1) exportedFunction(a1 + 1)
""")) """
)
)
val dummyThis = Obj() val dummyThis = Obj()
// but we should be able to call it directly // but we should be able to call it directly
val otherScope = baseScope.createChildScope() val otherScope = baseScope.createChildScope()
val r = (exports["exportedFunction".toObj()] as Statement).invoke(otherScope, dummyThis,ObjInt(50)) val r = (exports["exportedFunction".toObj()] as Statement).invoke(otherScope, dummyThis, ObjInt(50))
println(r) println(r)
assertEquals(51, r.toInt()) assertEquals(51, r.toInt())
} }
@Test
fun testFirstInEnum() = runTest {
eval(
"""
enum E {
one, two, three
}
println(E.entries)
assertEquals( E.two, E.entries.findFirst {
println(it.name)
it.name in ["aaa", "two"]
} )
""".trimIndent()
)
}
@Test
fun testAutoSplatArgs() = runTest {
eval(
"""
fun tf(x, y, z) {
"x=%s, y=%s, z=%s"(x,y,z)
}
assertEquals(tf(1, 2, 3), "x=1, y=2, z=3")
val a = { x: 3, y: 4, z: 5 }
assertEquals(tf(...a), "x=3, y=4, z=5")
assertEquals(tf(...{ x: 3, y: 4, z: 50 }), "x=3, y=4, z=50")
""".trimIndent()
)
}
@Test
fun testCached() = runTest {
eval(
"""
var counter = 0
val f = cached { ++counter }
assertEquals(1,f())
assertEquals(1, counter)
assertEquals(1,f())
assertEquals(1, counter)
""".trimIndent()
)
}
@Test
fun testCustomToStringBug() = runTest {
eval(
"""
class A(x,y)
class B(x,y) {
fun toString() {
"B(%d,%d)"(x,y)
}
}
assertEquals("B(1,2)", B(1,2).toString())
assertEquals("A(x=1,y=2)", A(1,2).toString())
// now tricky part: this _should_ cakk custom toString()
assertEquals(":B(1,2)", ":" + B(1,2).toString())
// and this must be exactly same:
assertEquals(":B(1,2)", ":" + B(1,2))
""".trimIndent()
)
}
@Test
fun testDestructuringAssignment() = runTest {
eval(
"""
val abc = [1, 2, 3]
// plain:
val [a, b, c] = abc
assertEquals( 1, a )
assertEquals( 2, b )
assertEquals( 3, c )
// with splats, receiving into list:
val [ab..., c1] = abc
assertEquals( [1, 2], ab )
assertEquals( 3, c1 )
// also in the end
val [a1, rest...] = abc
assertEquals( 1, a1 )
assertEquals( [2, 3], rest )
// and in the middle
val [a_mid, middle..., e0, e1] = [ 1, 2, 3, 4, 5, 6]
assertEquals( [2, 3, 4], middle )
assertEquals( 5, e0 )
assertEquals( 6, e1 )
assertEquals( 1, a_mid )
// nested destructuring:
val [n1, [n2, n3...], n4] = [1, [2, 3, 4], 5]
assertEquals(1, n1)
assertEquals(2, n2)
assertEquals([3, 4], n3)
assertEquals(5, n4)
// also it could be used to reassign vars:
var x = 5
var y = 10
[x, y] = [y, x]
assertEquals( 10, x )
assertEquals( 5, y )
""".trimIndent()
)
}
@Test
fun testProperlyReportExceptionPos() = runTest {
var x = assertFailsWith<ExecutionError> {
eval(
"""
val tmp = 11
if( tmp < 100 ) {
throw "success"
}
""".trimIndent()
)
}
println(x)
assertEquals(3, x.pos.line)
assertContains(x.message!!, "throw \"success\"")
// comments shoudl not change reporting:
x = assertFailsWith<ExecutionError> {
eval(
"""
val tmp = 11
/*
This should not change position:
*/
fun test() {
throw "success"
}
test()
""".trimIndent()
)
}
println(x)
assertEquals(5, x.pos.line)
assertContains(x.message!!, "throw \"success\"")
}
@Test
fun testClassAndFunAutoNamedArgs() = runTest {
// Shorthand for named arguments: name: is equivalent to name: name.
// This is consistent with map literal shorthand in Lyng.
eval(
"""
fun test(a, b, c) {
"%s-%s-%s"(a,b,c)
}
val a = 1
val b = 2
val c = 3
// Basic usage:
assertEquals( "1-2-3", test(a:, b:, c:) )
assertEquals( "1-2-3", test(c:, b:, a:) )
// Class constructors also support it:
class Point(x, y) {
val r = "x:%s, y:%s"(x, y)
}
val x = 10
val y = 20
assertEquals( "x:10, y:20", Point(x:, y:).r )
assertEquals( "x:10, y:20", Point(y:, x:).r )
// Mixed with positional arguments:
assertEquals( "0-2-3", test(0, b:, c:) )
// Mixed with regular named arguments:
assertEquals( "1-99-3", test(a:, b: 99, c:) )
// Integration with splats (spread arguments):
val args = { b:, c: } // map literal shorthand
assertEquals( "1-2-3", test(a:, ...args) )
// Default values:
fun sum(a, b=10, c=100) { a + b + c }
assertEquals( 111, sum(a:) )
assertEquals( 103, sum(a:, b:) )
// Complex scenario with multiple splats and shorthands:
val p1 = 1
val p2 = 2
val more = { c: 3, d: 4 }
fun quad(a, b, c, d) { a + b + c + d }
assertEquals( 10, quad(a: p1, b: p2, ...more) )
""".trimIndent()
)
}
@Test
fun testFunMiniDeclaration() = runTest {
eval("""
class T(x) {
fun method() = x + 1
}
fun median(a,b) = (a+b)/2
assertEquals(11, T(10).method())
assertEquals(2, median(1,3))
""".trimIndent())
}
} }
@@ -91,4 +91,26 @@ class StdlibTest {
assertEquals([1,2], (1..8).dropLast(6).toList() ) assertEquals([1,2], (1..8).dropLast(6).toList() )
""".trimIndent()) """.trimIndent())
} }
@Test
fun testFlattenAndFilter() = runTest {
eval("""
assertEquals([1,2,3,4,5,6], [1,3,5].map { [it, it+1] }.flatten() )
assertEquals([1,3,5], [null,1,null, 3,5].filterNotNull().toList())
""")
}
@Test
fun testFlatMap() = runTest {
eval("""
assertEquals([1,2,3,4,5,6], [1,3,5].flatMap { [it,it+1] }.toList() )
""")
}
@Test
fun testCount() = runTest {
eval("""
assertEquals(5, (1..10).toList().count { it % 2 == 1 } )
""")
}
} }
@@ -0,0 +1,66 @@
package net.sergeych.lyng
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
class PropsTest {
@Test
fun propsProposal() = runTest {
eval("""
class WithProps {
// readonly property without declared type:
val readonlyProp
get() {
"readonly foo"
}
val readonlyWithType: Int get() { 42 }
private var field = 0
private var field2 = ""
// with type declaration
var propName: Int
get() {
field * 10
}
set(value) {
field = value
}
// or without
var propNameWithoutType
get() {
"/"+ field2 + "/"
}
set(value) {
field2 = value
}
}
val w = WithProps()
assertEquals("readonly foo", w.readonlyProp)
assertEquals(42, w.readonlyWithType)
w.propNameWithoutType = "foo"
assertEquals("/foo/", w.propNameWithoutType)
w.propName = 123
assertEquals(1230, w.propName)
class Shorthand {
private var _p = 0
var p: Int
get() = _p * 2
set(v) = _p = v
}
val s = Shorthand()
s.p = 21
assertEquals(42, s.p)
""".trimIndent())
}
}
@@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -331,6 +331,25 @@ class LyngFormatterTest {
} }
} }
@Test
fun multiLineChainCalls() {
val src = """
somevar.where
.isGreen()
.isWarm()
""".trimIndent()
val expected = """
somevar.where
.isGreen()
.isWarm()
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
}
@Test @Test
fun chainedCalls_and_MemberAccess() { fun chainedCalls_and_MemberAccess() {
val src = """ val src = """
@@ -413,6 +432,55 @@ class LyngFormatterTest {
assertEquals(expected, LyngFormatter.format(out, cfg)) assertEquals(expected, LyngFormatter.format(out, cfg))
} }
@Test
fun multilineCommentIndentation() {
val src = """
/*
This is
a comment
*/
fun f() {
/*
Inner
comment
*/
val x = 1 /* end of line
comment */
}
""".trimIndent()
val expected = """
/*
This is
a comment
*/
fun f() {
/*
Inner
comment
*/
val x = 1 /* end of line
comment */
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
}
@Test
fun multicharacterOperators_spacing() {
val operators = listOf("+=", "-=", "*=", "/=", "=>", "==", "!=", "<=", ">=", "&&", "||", "->")
for (op in operators) {
val src = "a${op}b"
val expected = "a $op b"
val cfg = LyngFormatConfig(applySpacing = true)
val out = LyngFormatter.format(src, cfg)
assertEquals(expected, out, "Failed for operator $op")
}
}
@Test @Test
fun propertyBasedIdempotencyMedium() { fun propertyBasedIdempotencyMedium() {
// Compose slightly longer random snippets out of templates // Compose slightly longer random snippets out of templates
@@ -608,4 +676,178 @@ class LyngFormatterTest {
// Idempotent // Idempotent
assertEquals(expected, LyngFormatter.reindent(out, cfg)) assertEquals(expected, LyngFormatter.reindent(out, cfg))
} }
@Test
fun stringLiterals_areNotNormalized() {
val src = """
val s = "a=b"
val s2 = "a + b"
val s3 = "a - b"
val s4 = "a,b"
val s5 = "if(x){}"
""".trimIndent()
val cfg = LyngFormatConfig(applySpacing = true)
val formatted = LyngFormatter.format(src, cfg)
assertEquals(src, formatted, "String literals should not be changed by space normalization")
}
@Test
fun mixedCodeAndStrings_normalization() {
val src = "val x=1+\"a+b\"+2"
val expected = "val x = 1 + \"a+b\" + 2"
val cfg = LyngFormatConfig(applySpacing = true)
val formatted = LyngFormatter.format(src, cfg)
assertEquals(expected, formatted)
}
@Test
fun reindent_ignoresBracesInStrings() {
val src = """
fun test() {
val s = "{"
val s2 = "}"
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4)
val formatted = LyngFormatter.reindent(src, cfg)
// If it fails, s2 will be indented differently
assertEquals(src, formatted, "Braces in strings should not affect indentation")
}
@Test
fun blockComments_spacing_robustness() {
val src = "val x=1/*a=b*/+2"
val expected = "val x = 1/*a=b*/ + 2"
val cfg = LyngFormatConfig(applySpacing = true)
val formatted = LyngFormatter.format(src, cfg)
assertEquals(expected, formatted)
}
@Test
fun privateSet_indentation() {
val src = """
class A {
var x = 1
private set
}
""".trimIndent()
// What we expect: private set should be indented relative to the property
val expected = """
class A {
var x = 1
private set
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
}
@Test
fun propertyAccessors_indentation() {
val src = """
class A {
var x
get() = 1
private set(v) {
_x = v
}
}
""".trimIndent()
val expected = """
class A {
var x
get() = 1
private set(v) {
_x = v
}
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
}
@Test
fun nestedAwaitingIndentation() {
val src = """
if (cond)
if (other)
fun f() {
stmt
}
""".trimIndent()
val expected = """
if (cond)
if (other)
fun f() {
stmt
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
}
@Test
fun propertyAccessor_followedByMethod() {
val src = """
var state: BarRequestState get() = getState()
set(value) = setState(value)
fun save() { cell.value = this }
""".trimIndent()
val expected = """
var state: BarRequestState get() = getState()
set(value) = setState(value)
fun save() { cell.value = this }
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
}
@Test
fun propertyAccessor_dangling() {
val src1 = """
var x
get()
= 1
""".trimIndent()
val expected1 = """
var x
get()
= 1
""".trimIndent()
assertEquals(expected1, LyngFormatter.reindent(src1, LyngFormatConfig(indentSize = 4)))
val src2 = """
var x
get() =
1
""".trimIndent()
val expected2 = """
var x
get() =
1
""".trimIndent()
assertEquals(expected2, LyngFormatter.reindent(src2, LyngFormatConfig(indentSize = 4)))
}
} }
@@ -18,15 +18,15 @@
package net.sergeych.lyng package net.sergeych.lyng
actual object PerfDefaults { actual object PerfDefaults {
actual val LOCAL_SLOT_PIC: Boolean = true actual val LOCAL_SLOT_PIC: Boolean = false
actual val EMIT_FAST_LOCAL_REFS: Boolean = true actual val EMIT_FAST_LOCAL_REFS: Boolean = false
actual val ARG_BUILDER: Boolean = true actual val ARG_BUILDER: Boolean = false
actual val SKIP_ARGS_ON_NULL_RECEIVER: Boolean = true actual val SKIP_ARGS_ON_NULL_RECEIVER: Boolean = false
actual val SCOPE_POOL: Boolean = true actual val SCOPE_POOL: Boolean = false
actual val FIELD_PIC: Boolean = true actual val FIELD_PIC: Boolean = false
actual val METHOD_PIC: Boolean = true actual val METHOD_PIC: Boolean = false
actual val FIELD_PIC_SIZE_4: Boolean = false actual val FIELD_PIC_SIZE_4: Boolean = false
actual val METHOD_PIC_SIZE_4: Boolean = false actual val METHOD_PIC_SIZE_4: Boolean = false
actual val PIC_ADAPTIVE_2_TO_4: Boolean = false actual val PIC_ADAPTIVE_2_TO_4: Boolean = false
@@ -35,8 +35,8 @@ actual object PerfDefaults {
actual val PIC_DEBUG_COUNTERS: Boolean = false actual val PIC_DEBUG_COUNTERS: Boolean = false
actual val PRIMITIVE_FASTOPS: Boolean = true actual val PRIMITIVE_FASTOPS: Boolean = false
actual val RVAL_FASTPATH: Boolean = true actual val RVAL_FASTPATH: Boolean = false
// Regex caching (JVM-first): enabled by default on JVM // Regex caching (JVM-first): enabled by default on JVM
actual val REGEX_CACHE: Boolean = true actual val REGEX_CACHE: Boolean = true
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more