Compare commits

...

64 Commits

Author SHA1 Message Date
5d8fdce637 Move qualified identifier resolution to Scope as resolveQualifiedIdentifier, replace inline logic in LynonDecoder. 2025-12-14 00:20:43 +01:00
5a8881bfd5 Refactor decodeClassObj to mimic compiler behavior for qualified names, add evaluateQualifiedNameAsCompiled. 2025-12-14 00:06:46 +01:00
d487886c8f some more trace on strange decpdeClassObj behavior 2025-12-13 23:41:52 +01:00
180471e4cd Merge remote-tracking branch 'origin/fix_decodeClassObj' 2025-12-13 23:20:55 +01:00
71a37a2906 Revert "Improve decodeClassObj class resolution in LynonDecoder, add fallback lookup mechanisms, and refine related tests"
This reverts commit dd1a1544c6d49641783d221b15e23c7150010161.
2025-12-13 23:14:12 +01:00
ab05f83e77 Revert "Add documentation for Lynon class-name resolution behavior and future plans for fully-qualified name support"
This reverts commit a2d26fc7775508c4fc9c5bb62496ad4b88d74662.
2025-12-13 23:13:56 +01:00
9e11519608 revert to wirking ugly fix for decodeClassObj 2025-12-13 23:11:28 +01:00
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
dd1a1544c6 Improve decodeClassObj class resolution in LynonDecoder, add fallback lookup mechanisms, and refine related tests 2025-12-13 17:12:44 +01:00
fba44622e5 Refactor toString implementations to support Scope context, add inspect, and improve assertions readability. 2025-12-13 13:48:57 +01:00
2737aaa14e add mapNotNull to ObjIterable with documentation 2025-12-12 13:48:02 +01:00
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
fd473a32d8 refine some tests 2025-12-11 00:55:05 +01:00
d15dfb6087 core: prevent scope parent-chain cycles when reusing pooled frames
- Scope.resetForReuse: fully detach before re-parenting (clear state, parent=null, new frameId), then validate with ensureNoCycle and assign parent/args/pos/thisObj
- ScopePool.borrow (JVM/Android/JS/Native/Wasm): defensive fallback to fresh Scope allocation if resetForReuse detects a cycle
- docs: add docs/fix-scope-parent-cycle.md describing the change and expectations
- test: add ScopeCycleRegressionTest to ensure instance method call pattern does not crash and returns "ok"
2025-12-11 00:50:46 +01:00
b953282251 docs on updated scopes 2025-12-10 00:04:07 +01:00
bcabfc8962 fix endless recursion in scope resolution in some specific cases 2025-12-09 23:55:50 +01:00
c0fab3d60e bump version to 1.0.7-SNAPSHOT; fix potential infinite loops in Scope traversal 2025-12-09 07:33:05 +01:00
55caa65f97 fixed dependencies for plugin building 2025-12-08 18:59:16 +01:00
b73891d19b ref $91 trace removed 2025-12-08 18:52:38 +01:00
8750040926 fix #91 syntax highlighting of kotlin code blocks on the site 2025-12-08 18:51:55 +01:00
708b908415 fix #88 fix #90 plugin with autocompletion 2025-12-08 03:28:27 +01:00
c35d684df1 started autocompletion in the plugin 2025-12-07 23:06:05 +01:00
678cfbf45e fix #85 lynon empty list encoding 2025-12-07 21:50:47 +01:00
bfffea7e69 fix #83 import-aware quickdocs 2025-12-06 22:25:21 +01:00
40f11b6f29 fix #82 refactored and added builtin docs to all symbols 2025-12-06 21:10:40 +01:00
e25fc95cbf fix #81 site search improved 2025-12-06 18:03:15 +01:00
a6085b11a1 ignore distributables 2025-12-06 17:37:31 +01:00
819fdd82b3 removed distributables from git (derivatives, now there is a copy on the site) 2025-12-06 17:29:11 +01:00
2e96d75b9f fix #80 edge case !isSomething() bug fixed
+String.last()
2025-12-06 15:07:38 +01:00
f616326383 docs on cli tool, restored cli building tools 2025-12-05 21:46:19 +01:00
1e2bbe1fc5 fix #79 enum toJson serialization 2025-12-05 21:39:43 +01:00
b630d69186 fix #78 add fmt CLI subcommand and improve legacy script execution paths 2025-12-05 21:02:18 +01:00
20f4e54a02 red #77 tests and docs for jsom map serialization 2025-12-05 15:42:26 +01:00
e58896f087 red #77 more json docs 2025-12-05 11:57:21 +01:00
080eac2e1a fix #77 Instant.toJson 2025-12-05 11:47:42 +01:00
a31befef0b plugin update 2025-12-05 00:33:20 +01:00
65a7555e93 fix $76 add support for enum constants highlighting and initial enum documentation 2025-12-05 00:30:32 +01:00
84f2f8fac4 fix #75 simple classes JSON serialization with custom formats 2025-12-04 23:58:31 +01:00
0c31ec63ee fix #74 duplicate constructor amd state vars with Lynon serialization 2025-12-04 23:23:31 +01:00
603023962e fix $73 reg #74 val assignment bug fix. Also, cosmetics on syntax highlighting 2025-12-04 22:11:49 +01:00
e765784170 plugin updated to v1.0.5 2025-12-04 17:06:41 +01:00
171e413c5f v1.0.5-SNAPSHOT started json and kotlinx serialization support 2025-12-04 17:05:07 +01:00
5cfc15cf17 fix #72 comments are allowed in class constructor; some configuration bigs fixed 2025-12-04 16:09:27 +01:00
b8f27c7a18 migrated stdlib to separate .lyng files and added build-time generation of Kotlin constants. reduced noise in plugin 2025-12-04 12:26:38 +01:00
6a6de83972 updated plugin binary 2025-12-03 17:28:44 +01:00
a3b8dbd9d8 improved annotation caching and error range handling for highlighting 2025-12-03 16:27:05 +01:00
c63d643469 fixed formatter bugs and some tests; upgraded formatting in plugin 2025-12-03 15:37:35 +01:00
f592689631 annotation highlighting (missing) 2025-12-03 13:49:27 +01:00
834f3118c8 annotation highlighting 2025-12-03 13:49:09 +01:00
d285335e1c plugin: spell checker f0r 2025 2025-12-03 12:48:06 +01:00
2e17297355 Add Grazie-backed grammar checker and dictionary support to Lyng IDEA plugin and variants for replacements with or without Grazie interfaces 2025-12-03 01:29:34 +01:00
fbea13570e idea plugin now shows errors that prevent syntax highlighting and show cached syntax coloring remembered from the last successive pass 2025-12-02 03:29:14 +01:00
067970b80c Add MiniAst documentation system and Markdown rendering for built-in and IDE documentation. 2025-12-02 03:04:14 +01:00
c52e132dcc more readme 2025-12-01 19:14:43 +01:00
53f00e6c6c readme fixes 2025-12-01 18:11:03 +01:00
ec49bbbf52 idea plugin 0.0.2-SNAPSHOT, improced, added reformat code. Formatting tools improved in lynglib. Site information added 2025-12-01 17:50:27 +01:00
06e8e1579d idea plugin 0.0.1-SNAPSHOT: basic coloring and editing aids 2025-11-30 23:57:04 +01:00
9c342c5c72 Make lyng-mark white and regenerate 256x256 PNG 2025-11-29 10:54:27 +01:00
59055ace8c Add 256x256 PNG export of lyng-mark.svg at site/src/jsMain/resources/icons/lyng-mark-256.png 2025-11-29 10:51:01 +01:00
2005f405e4 site icon 2025-11-29 09:58:53 +01:00
062f344676 published lyngio to maven, added to docs 2025-11-29 09:02:28 +01:00
438e48959e - fixed bug in compiler (rare)
- added lyng.io.fs (multiplatform)
- CLI tools now have access to the filesystem
2025-11-29 00:51:01 +01:00
41746f22e5 some more samples 2025-11-28 12:38:43 +01:00
e584c7aa63 site/docs improvements 2025-11-28 11:25:47 +01:00
194 changed files with 17883 additions and 962 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
*.zip filter=lfs diff=lfs merge=lfs -text

1
.gitignore vendored
View File

@ -16,3 +16,4 @@ xcuserdata
/test.lyng
/sample_texts/1.txt.gz
/kotlin-js-store/wasm/yarn.lock
/distributables

View File

@ -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>

View File

@ -2,6 +2,20 @@
### Unreleased
- Docs: Scopes and Closures guidance
- 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`).
- Tutorial: added quick link to Scopes and Closures.
- IDEA plugin: Lightweight autocompletion (experimental)
- Global completion: local declarations, in‑scope parameters, imported modules, and stdlib symbols.
- Member completion: after a dot, suggests only members of the inferred receiver type (incl. chained calls like `Path(".." ).lines().``Iterator` methods). No global identifiers appear after a dot.
- Inheritance-aware: direct class members first, then inherited (e.g., `List` includes `Collection`/`Iterable` methods).
- Heuristics: handles literals (`"…"``String`, numbers → `Int/Real`, `[...]``List`, `{...}``Dict`) and static `Namespace.` members.
- Performance: capped results, early prefix filtering, per‑document MiniAst cache, cancellation checks.
- Toggle: Settings | Lyng Formatter → "Enable Lyng autocompletion (experimental)" (default ON).
- Stabilization: DEBUG completion/Quick Doc logs are OFF by default; behavior aligned between IDE and isolated engine tests.
- Language: Named arguments and named splats
- New call-site syntax for named arguments using colon: `name: value`.
- Positional arguments must come before named; positionals after a named argument inside parentheses are rejected.
@ -43,3 +57,27 @@
Notes:
- Existing single-inheritance code continues to work; resolution reduces to the single base.
- If code previously relied on non-deterministic parent set iteration, C3 MRO provides a predictable order; disambiguate explicitly if needed using `this@Type`/casts.
# Changelog
All notable changes to this project will be documented in this file.
## Unreleased
- CLI: Added `fmt` as a first-class Clikt subcommand.
- Default behavior: formats files to stdout (no in-place edits by default).
- Options:
- `--check`: check only; print files that would change; exit with code 2 if any changes are needed.
- `-i, --in-place`: write formatted result back to files.
- `--spacing`: apply spacing normalization.
- `--wrap`, `--wrapping`: enable line wrapping.
- Mutually exclusive: `--check` and `--in-place` together now produce an error and exit with code 1.
- Multi-file stdout prints headers `--- <path> ---` per file.
- `lyng --help` shows `fmt`; `lyng fmt --help` displays dedicated help.
- CLI: Preserved legacy script invocation fast-paths:
- `lyng script.lyng [args...]` executes the script directly.
- `lyng -- -file.lyng [args...]` executes a script whose name begins with `-`.
- CLI: Fixed a regression where the root help banner could print before subcommands.
- Root command no longer prints help when a subcommand (e.g., `fmt`) is invoked.

View File

@ -1,8 +1,6 @@
# Lyng: modern scripting for kotlin multiplatform
Please visit the project homepage: [https://lynglang.com](https://lynglang.com)
A KMP library and a standalone interpreter. v1.0.0-SNAPSHOT is now available.
Please visit the project homepage: [https://lynglang.com](https://lynglang.com) and a [telegram channel](https://t.me/lynglang) for updates.
- simple, compact, intuitive and elegant modern code:
@ -39,8 +37,10 @@ and it is multithreaded on platforms supporting it (automatically, no code chang
## Resources:
- [Language home](https://lynglang.com)
- [introduction and tutorial](docs/tutorial.md) - start here please
- [Samples directory](docs/samples)
- [Formatter (core + CLI + IDE)](docs/formatter.md)
- [Books directory](docs)
## Integration in Kotlin multiplatform
@ -117,6 +117,28 @@ scope.eval("sumOf(1,2,3)") // <- 6
```
Note that the scope stores all changes in it so you can make calls on a single scope to preserve state between calls.
## IntelliJ IDEA plugin: Lightweight autocompletion (experimental)
The IDEA plugin provides a fast, lightweight BASIC completion for Lyng code (IntelliJ IDEA 2024.3+).
What it does:
- Global suggestions: in-scope parameters, same-file declarations (functions/classes/vals), imported modules, and stdlib symbols.
- Member completion after dot: offers only members of the inferred receiver type. It works for chained calls like `Path(".." ).lines().` (suggests `Iterator` methods), and for literals like `"abc".` (String methods) or `[1,2,3].` (List/Iterable methods).
- Inheritance-aware: shows direct class members first, then inherited. For example, `List` also exposes common `Collection`/`Iterable` methods.
- Static/namespace members: `Name.` lists only static members when `Name` is a known class or container (e.g., `Math`).
- Performance: suggestions are capped; prefix filtering is early; parsing is cached; computation is cancellation-friendly.
What it does NOT do (yet):
- No heavy resolve or project-wide indexing. It’s best-effort, driven by a tiny MiniAst + built-in docs registry.
- No control/data-flow type inference.
Enable/disable:
- Settings | Lyng Formatter → "Enable Lyng autocompletion (experimental)" (default: ON).
Tips:
- After a dot, globals are intentionally suppressed (e.g., `lines().Path` is not valid), only the receiver’s members are suggested.
- If completion seems sparse, make sure related modules are imported (e.g., `import lyng.io.fs` so that `Path` and its methods are known).
## Why?
Designed to add scripting to kotlin multiplatform application in easy and efficient way. This is attempt to achieve what Lua is for C/++.
@ -136,21 +158,8 @@ Designed to add scripting to kotlin multiplatform application in easy and effici
# Language Roadmap
```mermaid
flowchart
v0([the idea])
v1([v1: make it happen])
v2([v1.5: testmake it fasted and rich featured])
v3([v2: ideal script language])
v0 --code MVP--> v1
v1 --refine and extend--> v2
v2 -- optimize --> v3
```
## v1.0.0 "Make it happen" - done!
Planned autumn 2025. Complete dynamic language with sufficient standard library:
We are now at **v1.0**: basic optimization performed, battery included: standard library is 90% here, initial
support in HTML, popular editors, and IDEA; tools to syntax highlight and format code are ready. It was released closed to schedule.
Ready features:
@ -176,22 +185,30 @@ Ready features:
- [x] regular exceptions + extended `when`
- [x] multiple inheritance for user classes
## plan: v1.0 - v1.5 Stability, integration, optimizations
## plan: towards v1.5 Enhancing
- [x] site with integrated interpreter to give a try
- [x] kotlin part public API good docs, integration focused
- [ ] type specifications
- [ ] language server or compose-based lyng-aware editor
- [x] Textmate Bundle
- [x] IDEA plugin
- [ ] source docs and maybe lyng.md to a standard
- [ ] metadata first class access from lyng
- [x] aggressive optimizations
- [ ] JVM bytecode optimizations
- [ ] compile to JVM bytecode optimization
## After 1.5 "Ideal scripting"
Estimated winter 2027
Estimated summer 2026
- [ ] client with GUI support based on compose multiplatform somehow
- [ ] notebook - style workbooks with graphs, formulae, etc.
- propose your feature!
## Authors
@-links are for contacting authors on [project home](https://gitea.sergeych.net/SergeychWorks/lyng): this simplest s to open issue for the person you need to convey any information about this project.
__Sergey Chernov__ @sergeych: Initial idea and architecture, language concept, design, implementation.
__Yulia Nezhinskaya__ @AlterEgoJuliaN: System analysis, math and features design.
[parallelism]: docs/parallelism.md

View File

@ -27,6 +27,58 @@ function checkState() {
}
# Update docs/idea_plugin.md to point to the latest built IDEA plugin zip
# from ./distributables before building the site. The change is temporary and
# the original file is restored right after the build.
DOC_IDEA_PLUGIN="docs/idea_plugin.md"
DOC_IDEA_PLUGIN_BACKUP="${DOC_IDEA_PLUGIN}.deploy_backup"
function updateIdeaPluginDownloadLink() {
if [[ ! -f "$DOC_IDEA_PLUGIN" ]]; then
echo "WARN: $DOC_IDEA_PLUGIN not found; skipping plugin link update"
return 0
fi
# Find the most recently modified plugin zip
local latest
latest=$(ls -t distributables/lyng-idea-*.zip 2>/dev/null | head -n 1)
if [[ -z "$latest" ]]; then
echo "WARN: no distributables/lyng-idea-*.zip found; leaving $DOC_IDEA_PLUGIN unchanged"
return 0
fi
local base
base=$(basename "$latest")
local version
version="${base#lyng-idea-}"
version="${version%.zip}"
local url
url="https://lynglang.com/distributables/${base}"
local newline
newline="### [Download plugin v${version}](${url})"
# Backup and rewrite the specific markdown line if present
cp "$DOC_IDEA_PLUGIN" "$DOC_IDEA_PLUGIN_BACKUP" || {
echo "ERROR: can't backup $DOC_IDEA_PLUGIN"; return 1; }
# Replace the line that starts with the download header; if not found, append it
awk -v repl="$newline" 'BEGIN{done=0} \
/^### \[Download plugin v/ { print repl; done=1; next } \
{ print } \
END { if (done==0) exit 42 }' "$DOC_IDEA_PLUGIN_BACKUP" > "$DOC_IDEA_PLUGIN"
local rc=$?
if [[ $rc -eq 42 ]]; then
echo "WARN: download link not found in $DOC_IDEA_PLUGIN; appending generated link"
echo >> "$DOC_IDEA_PLUGIN"
echo "$newline" >> "$DOC_IDEA_PLUGIN"
elif [[ $rc -ne 0 ]]; then
echo "ERROR: failed to update $DOC_IDEA_PLUGIN; restoring original"
mv -f "$DOC_IDEA_PLUGIN_BACKUP" "$DOC_IDEA_PLUGIN" 2>/dev/null
return 1
fi
}
# default target settings
case "com" in
com)
@ -46,15 +98,25 @@ esac
die() { echo "ERROR: $*" 1>&2 ; exit 1; }
./gradlew site:clean site:jsBrowserDistribution || die "compilation failed"
# Update the IDEA plugin download link in docs (temporarily), then build, then restore the doc
updateIdeaPluginDownloadLink || echo "WARN: proceeding without updating IDEA plugin download link"
if [[ $? != 0 ]]; then
./gradlew site:clean site:jsBrowserDistribution
BUILD_RC=$?
# Always restore original doc if backup exists
if [[ -f "$DOC_IDEA_PLUGIN_BACKUP" ]]; then
mv -f "$DOC_IDEA_PLUGIN_BACKUP" "$DOC_IDEA_PLUGIN"
fi
if [[ $BUILD_RC -ne 0 ]]; then
echo
echo -- build failed. deploy aborted.
echo
exit 100
fi
#exit 0
# Prepare working dir
@ -77,8 +139,8 @@ rsync -e "ssh -p ${SSH_PORT}" -avz -r -d --delete ${SRC}/* ${SSH_HOST}:${ROOT}/b
checkState
#rsync -e "ssh -p ${SSH_PORT}" -avz ./static/* ${SSH_HOST}:${ROOT}/build/dist
#checkState
#rsync -e "ssh -p ${SSH_PORT}" -avz -r -d --delete private_data/* ${SSH_HOST}:${ROOT}/build/private_data
#checkState
rsync -e "ssh -p ${SSH_PORT}" -avz -r -d --delete distributables/* ${SSH_HOST}:${ROOT}/build/dist/distributables
checkState
echo
echo finalizing the deploy...

View File

@ -25,3 +25,6 @@ file=./lyng/build/bin/linuxX64/releaseExecutable/lyng.kexe
strip $file
upx $file
cp $file ~/bin/lyng
cp $file ./distributables/lyng
zip ./distributables/lyng-linuxX64 ./distributables/lyng
rm ./distributables/lyng

View File

@ -20,3 +20,12 @@ plugins {
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.vanniktech.mavenPublish) apply false
}
// Convenience alias to run the IntelliJ IDE with the Lyng plugin from the project root.
// Usage: ./gradlew runIde
// It simply delegates to :lyng-idea:runIde provided by the Gradle IntelliJ Plugin.
tasks.register<org.gradle.api.DefaultTask>("runIde") {
group = "intellij"
description = "Run IntelliJ IDEA with the Lyng plugin (:lyng-idea)"
dependsOn(":lyng-idea:runIde")
}

View File

@ -68,6 +68,20 @@ These, again, does the thing:
>>> void
## map and mapNotNull
Used to transform either the whole iterable stream or also skipping som elements from it:
val source = [1,2,3,4]
// transform every element to string or null:
assertEquals(["n1", "n2", null, "n4"], source.map { if( it == 3 ) null else "n"+it } )
// transform every element to stirng, skipping 3:
assertEquals(["n1", "n2", "n4"], source.mapNotNull { if( it == 3 ) null else "n"+it } )
>>> void
## Instance methods:
| fun/method | description |

View File

@ -1,6 +1,10 @@
# OO implementation in Lyng
# Object-oriented programming
## Declaration
[//]: # (topMenu)
Lyng supports first class OOP constructs, based on classes with multiple inheritance.
## Class Declaration
The class clause looks like
@ -164,6 +168,80 @@ Compatibility notes:
Earlier drafts and docs described a declaration‑order depth‑first linearization. Lyng now uses C3 MRO for member lookup and disambiguation. Most code should continue to work unchanged, but in rare edge cases involving diamonds or complex multiple inheritance, the chosen base for an ambiguous member may change to reflect C3. If needed, disambiguate explicitly using `this@Type.member(...)` inside class bodies or casts `(expr as Type).member(...)` from outside.
## Enums
Lyng provides lightweight enums for representing a fixed set of named constants. Enums are classes whose instances are predefined and singletons.
Current syntax supports simple enum declarations with just entry names:
enum Color {
RED, GREEN, BLUE
}
Usage:
- Type of entries: every entry is an instance of its enum type.
assert( Color.RED is Color )
- Order and names: each entry has zero‑based `ordinal` and string `name`.
assertEquals(0, Color.RED.ordinal)
assertEquals("BLUE", Color.BLUE.name)
- All entries as a list in declaration order: `EnumType.entries`.
assertEquals([Color.RED, Color.GREEN, Color.BLUE], Color.entries)
- Lookup by name: `EnumType.valueOf("NAME")` → entry.
assertEquals(Color.GREEN, Color.valueOf("GREEN"))
- Equality and comparison:
- Equality uses identity of entries, e.g., `Color.RED == Color.valueOf("RED")`.
- Cross‑enum comparisons are not allowed.
- Ordering comparisons use `ordinal`.
assert( Color.RED == Color.valueOf("RED") )
assert( Color.RED.ordinal < Color.BLUE.ordinal )
>>> void
### Enums with `when`
Use `when(subject)` with equality branches for enums. See full `when` guide: [The `when` statement](when.md).
enum Color { RED, GREEN, BLUE }
fun describe(c) {
when(c) {
Color.RED, Color.GREEN -> "primary-like"
Color.BLUE -> "blue"
else -> "unknown" // if you pass something that is not a Color
}
}
assertEquals("primary-like", describe(Color.RED))
assertEquals("blue", describe(Color.BLUE))
>>> void
### Serialization
Enums are serialized compactly with Lynon: the encoded value stores just the entry ordinal within the enum type, which is both space‑efficient and fast.
import lyng.serialization
enum Color { RED, GREEN, BLUE }
val e = Lynon.encode(Color.BLUE)
val decoded = Lynon.decode(e)
assertEquals(Color.BLUE, decoded)
>>> void
Notes and limitations (current version):
- Enum declarations support only simple entry lists: no per‑entry bodies, no custom constructors, and no user‑defined methods/fields on the enum itself yet.
- `name` and `ordinal` are read‑only properties of an entry.
- `entries` is a read‑only list owned by the enum type.
## fields and visibility
It is possible to add non-constructor fields:
@ -526,3 +604,8 @@ Regular methods are called on instances as usual `instance.method()`. The method
TBD
[argument list](declaring_arguments.md)
### Visibility from within closures and instance scopes
When a closure executes within a method, the closure retains the lexical class context of its creation site. This means private/protected members of that class remain accessible where expected (subject to usual visibility rules). Field resolution checks the declaring class and validates access using the preserved `currentClassCtx`.
See also: [Scopes and Closures: resolution and safety](scopes_and_closures.md)

View File

@ -158,3 +158,9 @@ Function annotation can have more args specified at call time. There arguments m
>>> void
[parallelism]: parallelism.md
## Scopes and Closures: resolution and safety
Closures and dynamic scope graphs require care to avoid accidental recursion and to keep name resolution predictable. See the dedicated page for detailed rules, helper APIs, and best practices:
- Scopes and Closures: resolution and safety → [scopes_and_closures.md](scopes_and_closures.md)

View File

@ -1,5 +1,7 @@
# Declaring arguments in Lyng
[//]: # (topMenu)
It is a common thing that occurs in many places in Lyng, function declarations,
lambdas and class declarations.

View File

@ -169,3 +169,18 @@ _this functionality is not yet released_
| UnknownException | unexpected kotlin exception caught |
| | |
### Symbol resolution errors
For compatibility, `SymbolNotFound` is an alias of `SymbolNotDefinedException`. You can catch either name in examples and tests.
Example:
```lyng
try {
nonExistingMethod()
}
catch(e: SymbolNotFound) {
// handle
}
```

View File

@ -0,0 +1,34 @@
## Fix: prevent cycles in scope parent chain during pooled frame reuse
### What changed
- Scope.resetForReuse now fully detaches a reused scope from its previous chain/state before re-parenting:
- sets `parent = null` and regenerates `frameId`
- clears locals/slots/bindings caches
- only after that, validates the new parent with `ensureNoCycle` and assigns it
- ScopePool.borrow on all targets (JVM, Android, JS, Native, Wasm) now has a defensive fallback:
- if `resetForReuse` throws `IllegalStateException` indicating a parent-chain cycle, the pool allocates a fresh `Scope` instead of failing.
### Why
In some nested call patterns (notably instance method calls where an instance is produced by another function and immediately used), the same pooled `Scope` object can be rebound into a chain that already (transitively) contains it. Reassigning `parent` in that case forms a structural cycle, which `ensureNoCycle` correctly detects and throws. This could surface as:
```
IllegalStateException: cycle detected in scope parent chain assignment
at net.sergeych.lyng.Scope.ensureNoCycle(...)
at net.sergeych.lyng.Scope.resetForReuse(...)
at net.sergeych.lyng.ScopePool.borrow(...)
... during instance method invocation
```
The fix removes the failure mode by:
1) Detaching the reused frame from its prior chain/state before validating and assigning the new parent.
2) Falling back to a new frame allocation if a cycle is still detected (extremely rare and cheap vs. a crash).
### Expected effects
- Eliminates sporadic `cycle detected in scope parent chain` crashes during instance method invocation.
- No change to public API or normal semantics.
- Pooling remains enabled by default; the fallback only triggers on the detected cycle edge case.
- Negligible performance impact: fresh allocation is used only when a cycle would have crashed the VM previously.
### Notes
- The fix is platform-wide (all ScopePool actuals are covered).
- We recommend adding/keeping a regression test that exercises: a class with a method, a function returning an instance, and an exported function calling the instance method. The test should pass without exceptions.

62
docs/formatter.md Normal file
View File

@ -0,0 +1,62 @@
# Lyng formatter (core, CLI, and IDE)
This document describes the Lyng code formatter included in this repository. The formatter lives in the core library (`:lynglib`), is available from the CLI (`lyng fmt`), and is used by the IntelliJ plugin.
## Core library
Package: `net.sergeych.lyng.format`
- `LyngFormatConfig`
- `indentSize` (default 4)
- `useTabs` (default false)
- `continuationIndentSize` (default 8)
- `maxLineLength` (default 120)
- `applySpacing` (default false)
- `applyWrapping` (default false)
- `LyngFormatter`
- `reindent(text, config)` — recomputes indentation from scratch (braces, `else/catch/finally` alignment, continuation indent under `(` `)` and `[` `]`), idempotent.
- `format(text, config)` — runs `reindent` and, depending on `config`, optionally applies:
- a safe spacing pass (commas/operators/colons/keyword parens; member access `.` remains tight; no changes to strings/comments), and
- a controlled wrapping pass for long call arguments (no trailing commas).
Both passes are designed to be idempotent. Extensive tests live under `:lynglib/src/commonTest/.../format`.
## CLI formatter
```
lyng fmt [--check] [--in-place|-i] [--spacing] [--wrap] <file1.lyng> [file2.lyng ...]
```
- Defaults: indent-only; spacing and wrapping are OFF unless flags are provided.
- `--check` prints files that would change and exits with code 2 if any changes are detected.
- `--in-place`/`-i` rewrites files in place (default if not using `--check`).
- `--spacing` enables the safe spacing pass (commas/operators/colons/keyword parens).
- `--wrap` enables controlled wrapping of long call argument lists (respects `maxLineLength`, no trailing commas).
Examples:
```
# check formatting without modifying files
lyng fmt --check docs/samples/fs_sample.lyng
# format in place with spacing rules enabled
lyng fmt --spacing -i docs/samples/fs_sample.lyng
# format in place with spacing + wrapping
lyng fmt --spacing --wrap -i src/**/*.lyng
```
## IntelliJ plugin
- Indentation: always enabled, idempotent; the plugin computes per-line indent via the core formatter.
- Spacing/wrapping: optional and OFF by default.
- Settings/Preferences → Lyng Formatter provides toggles:
- "Enable spacing normalization (commas/operators/colons/keyword parens)"
- "Enable line wrapping (120 cols) [experimental]"
- Reformat Code applies: indentation first, then spacing, then wrapping if toggled.
## Design notes
- Single source of truth: The core formatter is used by CLI and IDE to keep behavior consistent.
- Stability first: Spacing/wrapping are gated by flags/toggles; indentation from scratch is always safe and idempotent.
- Non-destructive: The formatter carefully avoids changing string/char literals and comment contents.

29
docs/idea_plugin.md Normal file
View File

@ -0,0 +1,29 @@
# Plugin for IntelliJ IDEA
[//]: # (excludeFromIndex)
We introduce the alpha version of the plugin for IntelliJ IDEA 2024.3.x+ IDE variants. It is compatible with 2025.x and
should be compatible with other IDEA flavors, notably [OpenIDE](https://openide.ru/). It supports the following features:
- syntax highlighting (2 stage, fast and more accurate that analyses in background)
- reformat code (indents, spaces)
- reformat on paste
- smart enter key
Features are configurable via the plugin settings page, in system settings.
> Recommended for IntelliJ-based IDEs: While IntelliJ can import TextMate bundles
> (Settings/Preferences → Editor → TextMate Bundles), the native Lyng plugin provides
> better support (formatting, smart enter, background analysis, etc.). Prefer installing
> this plugin over using a TextMate bundle.
### Install
- From ZIP: download the archive below, then in IntelliJ IDEA open Settings/Preferences → Plugins →
gear icon → Install Plugin from Disk… and select the downloaded ZIP. Restart IDE if prompted.
- Alternatively, if/when the plugin is published to a marketplace, you will be able to install it
directly from the “Marketplace” tab (not yet available).
### [Download plugin v0.0.2-SNAPSHOT](https://lynglang.com/distributables/lyng-idea-0.0.2-SNAPSHOT.zip)
Your ideas and bugreports are welcome on the [project gitea page](https://gitea.sergeych.net/SergeychWorks/lyng/issues)

View File

@ -0,0 +1,167 @@
# Json support
Since 1.0.5 we start adding JSON support. Versions 1,0,6* support serialization of the basic types, including lists and
maps, and simple classes. Multiple inheritance may produce incorrect results, it is work in progress.
## Serialization in Lyng
// in lyng
assertEquals("{\"a\":1}", {a: 1}.toJsonString())
void
>>> void
Simple classes serialization is supported:
import lyng.serialization
class Point(foo,bar) {
val t = 42
}
// val is not serialized
assertEquals( "{\"foo\":1,\"bar\":2}", Point(1,2).toJsonString() )
>>> void
Note that mutable members are serialized:
import lyng.serialization
class Point2(foo,bar) {
var reason = 42
// but we override json serialization:
fun toJsonObject() {
{ "custom": true }
}
}
// var is serialized instead
assertEquals( "{\"custom\":true}", Point2(1,2).toJsonString() )
>>> void
Custom serialization of user classes is possible by overriding `toJsonObject` method. It must return an object which is
serializable to Json. Most often it is a map, but any object is accepted, that makes it very flexible:
import lyng.serialization
class Point2(foo,bar) {
var reason = 42
// but we override json serialization:
fun toJsonObject() {
{ "custom": true }
}
}
class Custom {
fun toJsonObject() {
"full freedom"
}
}
// var is serialized instead
assertEquals( "\"full freedom\"", Custom().toJsonString() )
>>> void
Please note that `toJsonString` should be used to get serialized string representation of the object. Don't call
`toJsonObject` directly, it is not intended to be used outside the serialization library.
## Kotlin side interfaces
The "Batteries included" principle is also applied to serialization.
- `Obj.toJson()` provides Kotlin `JsonElement`
- `Obj.toJsonString()` provides Json string representation
- `Obj.decodeSerializableWith()` and `Obj.decodeSerializable()` allows to decode Lyng classes as Kotlin objects using
`kotlinx.serialization`:
```kotlin
/**
* Decodes the current object into a deserialized form using the provided deserialization strategy.
* It is based on [Obj.toJson] and uses existing Kotlin Json serialization, without string representation
* (only `JsonElement` to carry information between Kotlin and Lyng serialization worlds), thus efficient.
*
* @param strategy The deserialization strategy that defines how the object should be decoded.
* @param scope An optional scope used during deserialization to define the context. Defaults to a new instance of Scope.
* @return The deserialized object of type T.
*/
suspend fun <T> Obj.decodeSerializableWith(strategy: DeserializationStrategy<T>, scope: Scope = Scope()): T =
Json.decodeFromJsonElement(strategy, toJson(scope))
/**
* Decodes a serializable object of type [T] using the provided decoding scope. The deserialization uses
* [Obj.toJson] and existing Json based serialization ithout using actual string representation, thus
* efficient.
*
* @param T The type of the object to be decoded. Must be a reified type.
* @param scope The scope used during decoding. Defaults to a new instance of [Scope].
*/
suspend inline fun <reified T> Obj.decodeSerializable(scope: Scope = Scope()) =
decodeSerializableWith<T>(serializer<T>(), scope)
```
Note that lyng-2-kotlin deserialization with `kotlinx.serialization` uses JsonElement as information carrier without
formatting and parsing actual Json strings. This is why we use `Json.decodeFromJsonElement` instead of
`Json.decodeFromString`. Such an approach gives satisfactory performance without writing and supporting custom
`kotlinx.serialization` codecs.
### Pitfall: JSON objects and Map<String, Any?>
Kotlin serialization does not support `Map<String, Any?>` as a serializable type, more general, it can't serialize `Any`. This in particular means that you can deserialize Kotlin `Map<String, T>` as long as `T` is `@Serializable` in Kotlin:
```kotlin
@Serializable
data class TestJson2(
val value: Int,
val inner: Map<String,Int>
)
@Test
fun deserializeMapWithJsonTest() = runTest {
val x = eval("""
import lyng.serialization
{ value: 1, inner: { "foo": 1, "bar": 2 }}
""".trimIndent()).decodeSerializable<TestJson2>()
// That works perfectly well:
assertEquals(TestJson2(1, mapOf("foo" to 1, "bar" to 2)), x)
}
```
But what if your map has objects of different types? The approach of using polymorphism is partially applicable, but what to do with `{ one: 1, two: "two" }`?
The answer is pretty simple: use `JsonObject` in your deserializable object. This class is capable of holding any JSON types and structures and is sort of a silver bullet for such cases:
~~~kotlin
@Serializable
data class TestJson3(
val value: Int,
val inner: JsonObject
)
@Test
fun deserializeAnyMapWithJsonTest() = runTest {
val x = eval("""
import lyng.serialization
{ value: 12, inner: { "foo": 1, "bar": "two" }}
""".trimIndent()).decodeSerializable<TestJson3>()
assertEquals(TestJson3(12, JsonObject(mapOf("foo" to JsonPrimitive(1), "bar" to Json.encodeToJsonElement("two")))), x)
}
~~~
# List of supported types
| Lyng type | JSON type | notes |
|-----------|-----------|-------------|
| `Int` | number | |
| `Real` | number | |
| `String` | string | |
| `Bool` | boolean | |
| `null` | null | |
| `Instant` | string | ISO8601 (1) |
| `List` | array | (2) |
| `Map` | object | (2) |
(1)
: ISO8601 flavor 1970-05-06T06:00:00.000Z in used; number of fractional digits depends on the truncation
on [Instant](time.md), see `Instant.truncateTo...` functions.
(2)
: List may contain any objects serializable to Json.
(3)
: Map keys must be strings, map values may be any objects serializable to Json.

228
docs/lyng.io.fs.md Normal file
View File

@ -0,0 +1,228 @@
### lyng.io.fs — async filesystem access for Lyng scripts
This module provides a uniform, suspend-first filesystem API to Lyng scripts, backed by Kotlin Multiplatform implementations.
- JVM/Android/Native: Okio `FileSystem.SYSTEM` (non-blocking via coroutine dispatcher)
- JS/Node: Node filesystem (currently via Okio node backend; a native `fs/promises` backend is planned)
- JS/Browser and Wasm: in-memory virtual filesystem for now
It exposes a Lyng class `Path` with methods for file and directory operations, including streaming readers for large files.
It is a separate library because access to teh filesystem is a security risk we compensate with a separate API that user must explicitly include to the dependency and allow. Together with `FsAceessPolicy` that is required to `createFs()` which actually adds the filesystem to the scope, the security risk is isolated.
Also, it helps keep Lyng core small and focused.
---
#### Add the library to your project (Gradle)
If you use this repository as a multi-module project, add a dependency on `:lyngio`:
```kotlin
dependencies {
implementation("net.sergeych:lyngio:0.0.1-SNAPSHOT")
}
```
Note on maven repository. Lyngio uses ths same maven as Lyng code (`lynglib`) so it is most likely already in your project. If ont, add it to the proper section of your `build.gradle.kts` or settings.gradle.kts:
```kotlin
repositories {
maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
}
```
This brings in:
- `:lynglib` (Lyng engine)
- Okio (`okio`, `okio-fakefilesystem`, and `okio-nodefilesystem` for JS)
- Kotlin coroutines
---
#### Install the module into a Lyng Scope
The filesystem module is not installed automatically. You must explicitly register it in the scope’s `ImportManager` using the installer. You can customize access control via `FsAccessPolicy`.
Kotlin (host) bootstrap example (imports omitted for brevity):
```kotlin
val scope: Scope = Scope.new()
val installed: Boolean = createFs(PermitAllAccessPolicy, scope)
// installed == true on first registration in this ImportManager, false on repeats
// In scripts (or via scope.eval), import the module to use its symbols:
scope.eval("import lyng.io.fs")
```
You can install with a custom policy too (see Access policy below).
---
#### Using from Lyng scripts
```lyng
val p = Path("/tmp/hello.txt")
// Text I/O
p.writeUtf8("Hello Lyng!\n")
println(p.readUtf8())
// Binary I/O
val data = Buffer.fromHex("deadbeef")
p.writeBytes(data)
// Existence and directories
assertTrue(p.exists())
Path("/tmp/work").mkdirs()
// Listing
for (entry in Path("/tmp").list()) {
println(entry)
}
// Globbing
val txts = Path("/tmp").glob("**/*.txt").toList()
// Copy / Move / Delete
Path("/tmp/a.txt").copy("/tmp/b.txt", overwrite=true)
Path("/tmp/b.txt").move("/tmp/c.txt", overwrite=true)
Path("/tmp/c.txt").delete()
// Streaming large files (does not load whole file into memory)
var bytes = 0
val it = Path("/tmp/big.bin").readChunks(1_048_576) // 1MB chunks
val iter = it.iterator()
while (iter.hasNext()) {
val chunk = iter.next()
bytes = bytes + chunk.size()
}
// Text chunks and lines
for (s in Path("/tmp/big.txt").readUtf8Chunks(64_000)) {
// process each string chunk
}
for (ln in Path("/tmp/big.txt").lines()) {
// process line by line
}
```
---
#### API (Lyng class `Path`)
Constructor:
- `Path(path: String)` — creates a Path object
- `Paths(path: String)` — alias
File and directory operations (all suspend under the hood):
- `name`: name, `String`
- `segments`: list of parsed path segments (directories)
- `parent`: parent directory, `Path?`; null if root
- `exists(): Bool`
- `isFile(): Bool` — true if the path points to a regular file (cached metadata)
- `isDirectory(): Bool` — true if the path points to a directory (cached metadata)
- `size(): Int?` — size in bytes or null if unknown (cached metadata)
- `createdAt(): Instant?` — creation time as Lyng `Instant`, or null (cached metadata)
- `createdAtMillis(): Int?` — creation time in epoch milliseconds, or null (cached metadata)
- `modifiedAt(): Instant?` — last modification time as Lyng `Instant`, or null (cached metadata)
- `modifiedAtMillis(): Int?` — last modification time in epoch milliseconds, or null (cached metadata)
- `list(): List<Path>` — children of a directory
- `readBytes(): Buffer`
- `writeBytes(bytes: Buffer)`
- `appendBytes(bytes: Buffer)`
- `readUtf8(): String`
- `writeUtf8(text: String)`
- `appendUtf8(text: String)`
- `metadata(): Map` — keys: `isFile`, `isDirectory`, `size`, `createdAtMillis`, `modifiedAtMillis`, `isSymlink`
- `mkdirs(mustCreate: Bool = false)`
- `move(to: Path|String, overwrite: Bool = false)`
- `delete(mustExist: Bool = false, recursively: Bool = false)`
- `copy(to: Path|String, overwrite: Bool = false)`
- `glob(pattern: String): List<Path>` — supports `**`, `*`, `?` (POSIX-style)
Streaming readers for big files:
- `readChunks(size: Int = 65536): Iterator<Buffer>` — iterate fixed-size byte chunks
- `readUtf8Chunks(size: Int = 65536): Iterator<String>` — iterate text chunks by character count
- `lines(): Iterator<String>` — line iterator built on `readUtf8Chunks`
Notes:
- Iterators implement Lyng iterator protocol. If you break early from a loop, the runtime will attempt to call `cancelIteration()` when available.
- Current implementations chunk in memory. The public API is stable; internals will evolve to true streaming on all platforms.
- Attribute accessors (`isFile`, `isDirectory`, `size`, `createdAt*`, `modifiedAt*`) cache a metadata snapshot inside the `Path` instance to avoid repeated filesystem calls during a sequence of queries. `metadata()` remains available for bulk access.
---
#### Access policy (security)
Access control is enforced by `FsAccessPolicy`. You pass a policy at installation time. The module wraps the filesystem with a secured decorator that consults the policy for each primitive operation.
Main types:
- `FsAccessPolicy` — your policy implementation
- `PermitAllAccessPolicy` — allows all operations (default for testing)
- `AccessOp` (sealed) — operations the policy can decide on:
- `ListDir(path)`
- `CreateFile(path)`
- `OpenRead(path)`
- `OpenWrite(path)`
- `OpenAppend(path)`
- `Delete(path)`
- `Rename(from, to)`
- `UpdateAttributes(path)` — defaults to write-level semantics
Minimal denying policy example (imports omitted for brevity):
```kotlin
val denyWrites = object : FsAccessPolicy {
override suspend fun check(op: AccessOp, ctx: AccessContext): AccessDecision = when (op) {
is AccessOp.OpenRead, is AccessOp.ListDir -> AccessDecision(Decision.Allow)
else -> AccessDecision(Decision.Deny, reason = "read-only policy")
}
}
createFs(denyWrites, scope)
scope.eval("import lyng.io.fs")
```
Composite operations like `copy` and `move` are checked as a set of primitives (e.g., `OpenRead(src)` + `Delete(dst)` if overwriting + `CreateFile(dst)` + `OpenWrite(dst)`).
---
#### Errors and exceptions
Policy denials are surfaced as Lyng runtime errors, not raw Kotlin exceptions:
- Internally, a denial throws `AccessDeniedException`. The module maps it to `ObjIllegalOperationException` wrapped into an `ExecutionError` visible to scripts.
Examples (Lyng):
```lyng
import lyng.io.fs
val p = Path("/protected/file.txt")
try {
p.writeUtf8("x")
fail("expected error")
} catch (e) {
// e is an ExecutionError; message contains the policy reason
}
```
Other I/O failures (e.g., not found, not a directory) are also raised as Lyng errors (`ObjIllegalStateException`, `ObjIllegalArgumentException`, etc.) depending on context.
---
#### Platform notes
- JVM/Android/Native: synchronous Okio calls are executed on `Dispatchers.IO` (JVM/Android) or `Dispatchers.Default` (Native) to avoid blocking the main thread.
- NodeJS: currently uses Okio’s Node backend. For heavy I/O, a native `fs/promises` backend is planned to fully avoid event-loop blocking.
- Browser/Wasm: uses an in-memory filesystem for now. Persistent backends (IndexedDB or File System Access API) are planned.
---
#### Roadmap
- Native NodeJS backend using `fs/promises`
- Browser persistent storage (IndexedDB)
- Streaming readers/writers over real OS streams
- Attribute setters and richer metadata
If you have specific needs (e.g., sandboxing, virtual roots), implement a custom `FsAccessPolicy` or ask us to add a helper.

141
docs/lyng_cli.md Normal file
View File

@ -0,0 +1,141 @@
### Lyng CLI (`lyng`)
The Lyng CLI is the reference command-line tool for the Lyng language. It lets you:
- Run Lyng scripts from files or inline strings (shebangs accepted)
- Use standard argument passing (`ARGV`) to your scripts.
- Format Lyng source files via the built-in `fmt` subcommand.
#### Building on Linux
Requirements:
- JDK 17+ (for Gradle and the JVM distribution)
- GNU zip utilities (for packaging the native executable)
- upx tool (executable in-place compression)
The repository provides convenience scripts in `bin/` for local builds and installation into `~/bin`.
Note: In this repository the scripts are named `bin/local_release` and `bin/local_jrelease`. In some environments these may be aliased as `bin/release` and `bin/jrelease`. The steps below use the actual file names present here.
##### Option A: Native linuxX64 executable (`lyng`)
1) Build the native binary:
```
./gradlew :lyng:linkReleaseExecutableLinuxX64
```
2) Install and package locally:
```
bin/local_release
```
What this does:
- Copies the built executable to `~/bin/lyng` for easy use in your shell.
- Produces `distributables/lyng-linuxX64.zip` containing the `lyng` executable.
##### Option B: JVM distribution (`jlyng` launcher)
This creates a JVM distribution with a launcher script and links it to `~/bin/jlyng`.
```
bin/local_jrelease
```
What this does:
- Runs `./gradlew :lyng:installJvmDist` to build the JVM app distribution to `lyng/build/install/lyng-jvm`.
- Copies the distribution under `~/bin/jlyng-jvm`.
- Creates a symlink `~/bin/jlyng` pointing to the launcher script.
#### Usage
Once installed, ensure `~/bin` is on your `PATH`. You can then use either the native `lyng` or the JVM `jlyng` launcher (both have the same CLI surface).
##### Running scripts
- Run a script by file name and pass arguments to `ARGV`:
```
lyng path/to/script.lyng arg1 arg2
```
- Run a script whose name starts with `-` using `--` to stop option parsing:
```
lyng -- -my-script.lyng arg1 arg2
```
- Execute inline code with `-x/--execute` and pass positional args to `ARGV`:
```
lyng -x "println(\"Hello\")" more args
```
- Print version/help:
```
lyng --version
lyng --help
```
### Use in shell scripts
Standard unix shebangs (`#!`) are supported, so you can make Lyng scripts directly executable on Unix-like systems. For example:
#!/usr/bin/env lyng
println("Hello, world!")
##### Formatting source: `fmt` subcommand
Format Lyng files with the built-in formatter.
Basic usage:
```
lyng fmt [OPTIONS] FILE...
```
Options:
- `--check` — Check-only mode. Prints file paths that would change and exits with code 2 if any changes are needed, 0 otherwise.
- `-i, --in-place` — Write formatted content back to the source files (off by default).
- `--spacing` — Apply spacing normalization.
- `--wrap`, `--wrapping` — Enable line wrapping.
Semantics and exit codes:
- Default behavior is to write formatted content to stdout. When multiple files are provided, the output is separated with `--- <path> ---` headers.
- `--check` and `--in-place` are mutually exclusive; using both results in an error and exit code 1.
- `--check` exits with 2 if any file would change, with 0 otherwise.
- Other errors (e.g., I/O issues) result in a non-zero exit code.
Examples:
```
# Print formatted content to stdout
lyng fmt src/file.lyng
# Format multiple files to stdout with headers
lyng fmt src/a.lyng src/b.lyng
# Check mode: list files that would change; exit 2 if changes are needed
lyng fmt --check src/**/*.lyng
# In-place formatting
lyng fmt -i src/**/*.lyng
# Enable spacing normalization and wrapping
lyng fmt --spacing --wrap src/file.lyng
```
#### Notes
- Both native and JVM distributions expose the same CLI interface. Use whichever best fits your environment.
- When executing scripts, all positional arguments after the script name are available in Lyng as `ARGV`.
- The interpreter recognizes shebang lines (`#!`) at the beginning of a script file and ignores them at runtime, so you can make Lyng scripts directly executable on Unix-like systems.

View File

@ -1,4 +1,6 @@
# Parallelism in Lyng
# Multithreading/parallel execution
[//]: # (topMenu)
Lyng is built to me multithreaded where possible (e.g. all targets byt JS and wasmJS as for now)
and cooperatively parallel (coroutine based) everywhere.
@ -219,3 +221,22 @@ Lyng includes an optional optimization for function/method calls on JVM: scope f
- Expected effect (from our JVM micro‑benchmarks): in deep call loops, enabling pooling reduced total time by about 1.38× in a dedicated pooling benchmark; mileage may vary depending on workload.
Future work: introduce thread‑safe pooling (e.g., per‑thread pools or confinement strategies) before considering enabling it by default in multi‑threaded environments.
### Closures inside coroutine helpers (launch/flow)
Closures executed by `launch { ... }` and `flow { ... }` resolve names using the `ClosureScope` rules:
1. Closure frame locals/arguments
2. Captured receiver instance/class members
3. Closure ancestry locals + each frame’s `this` members (cycle‑safe)
4. Caller `this` members
5. Caller ancestry locals + each frame’s `this` members (cycle‑safe)
6. Module pseudo‑symbols (e.g., `__PACKAGE__`)
7. Direct module/global fallback (nearest `ModuleScope` and its parent/root)
Implications:
- Outer locals (e.g., `counter`) stay visible across suspension points.
- Global helpers like `delay(ms)` and `yield()` are available from inside closures.
- If you write your own async helpers, execute user lambdas under `ClosureScope(callScope, capturedCreatorScope)` and avoid manual ancestry walking.
See also: [Scopes and Closures: resolution and safety](scopes_and_closures.md)

View File

@ -1,6 +1,8 @@
# Map literals — refined proposal
[//]: # (excludeFromIndex)
Implement JavaScript-like literals for maps. The syntax and semantics align with named arguments in function calls, but map literals are expressions that construct `Map` values.
Keys can be either:

View File

@ -1,4 +1,7 @@
# Named arguments proposal
# Named arguments in calls
[//]: # (excludeFromIndex)
Extend function/method calls to allow setting arguments by name using colon syntax at call sites. This is especially convenient with many parameters and default values.

25
docs/samples/fs_sample.lyng Executable file
View File

@ -0,0 +1,25 @@
#!/bin/env lyng
import lyng.io.fs
import lyng.stdlib
val files = Path("../..").list().toList()
// most long is longest?
val longestNameLength = files.maxOf { it.name.length }
// testdoc
fun test() {
22
}
val format = "%"+(longestNameLength+1) +"s %d"
for( f in files )
{
var name = f.name
if( f.isDirectory() )
name += "/"
println( format(name, f.size()) )
}

View File

@ -0,0 +1,10 @@
#!/bin/env lyng
val переменная = "значение"
val data = { greeting: "привет", переменная:, поле: "содержимое" }
assertEquals(data["переменная"], "значение")
assertEquals(data["поле"], "содержимое")
println(data)

View File

@ -0,0 +1,75 @@
# Scopes and Closures: resolution and safety
This page documents how name resolution works with `ClosureScope`, how to avoid recursion pitfalls, and how to safely capture and execute callbacks that need access to outer locals.
## Why this matters
Name lookup across nested scopes and closures can accidentally form recursive resolution paths or hide expected symbols (outer locals, module/global functions). The rules below ensure predictable resolution and prevent infinite recursion.
## Resolution order in ClosureScope
When evaluating an identifier `name` inside a closure, `ClosureScope.get(name)` resolves in this order:
1. Closure frame locals and arguments
2. Captured receiver (`closureScope.thisObj`) instance/class members
3. Closure ancestry locals + each frame’s `thisObj` members (cycle‑safe)
4. Caller `this` members
5. Caller ancestry locals + each frame’s `thisObj` members (cycle‑safe)
6. Module pseudo‑symbols (e.g., `__PACKAGE__`) from the nearest `ModuleScope`
7. Direct module/global fallback (nearest `ModuleScope` and its parent/root scope)
8. Final fallback: base local/parent lookup for the current frame
This preserves intuitive visibility (locals → captured receiver → closure chain → caller members → caller chain → module/root) while preventing infinite recursion between scope types.
## Use raw‑chain helpers for ancestry walks
When authoring new scope types or advanced lookups, avoid calling virtual `get` while walking parents. Instead, use the non‑dispatch helpers on `Scope`:
- `chainLookupIgnoreClosure(name)`
- Walk raw `parent` chain and check only per‑frame locals/bindings/slots.
- Ignores overridden `get` (e.g., in `ClosureScope`). Cycle‑safe.
- `chainLookupWithMembers(name)`
- Like above, but after locals/bindings it also checks each frame’s `thisObj` members.
- Ignores overridden `get`. Cycle‑safe.
- `baseGetIgnoreClosure(name)`
- For the current frame only: check locals/bindings, then walk raw parents (locals/bindings), then fallback to this frame’s `thisObj` members.
These helpers avoid ping‑pong recursion and make structural cycles harmless (lookups terminate).
## Preventing structural cycles
- Don’t construct parent chains that can point back to a descendant.
- A debug‑time guard throws if assigning a parent would create a cycle; keep it enabled for development builds.
- Even with a cycle, chain helpers break out via a small `visited` set keyed by `frameId`.
## Capturing lexical environments for callbacks
For dynamic objects or custom builders, capture the creator’s lexical scope so callbacks can see outer locals/parameters:
1. Use `snapshotForClosure()` on the caller scope to capture locals/bindings/slots and parent.
2. Store this snapshot and run callbacks under `ClosureScope(callScope, captured)`.
Kotlin sketch:
```kotlin
val captured = scope.snapshotForClosure()
val execScope = ClosureScope(currentCallScope, captured)
callback.execute(execScope)
```
This ensures expressions like `contractName` used inside dynamic `get { name -> ... }` resolve to outer variables defined at the creation site.
## Closures in coroutines (launch/flow)
- The closure frame still prioritizes its own locals/args.
- Outer locals declared before suspension points remain visible through slot‑aware ancestry lookups.
- Global functions like `delay(ms)` and `yield()` are resolved via module/root fallbacks from within closures.
Tip: If a closure unexpectedly cannot see an outer local, check whether an intermediate runtime helper introduced an extra call frame; the built‑in lookup already traverses caller ancestry, so prefer the standard helpers rather than custom dispatch.
## Local variable references and missing symbols
- Unqualified identifier resolution first prefers locals/bindings/slots before falling back to `this` members.
- If neither locals nor members contain the symbol, missing field lookups map to `SymbolNotFound` (compatibility alias for `SymbolNotDefinedException`).
## Performance notes
- 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.
## Dos and Don’ts
- Do use `chainLookupIgnoreClosure` / `chainLookupWithMembers` for ancestry traversals.
- Do maintain the resolution order above for predictable behavior.
- Don’t call virtual `get` while walking parents; it risks recursion across scope types.
- Don’t attach instance scopes to transient/pool frames; bind to a stable parent scope instead.

77
docs/textmate_bundle.md Normal file
View File

@ -0,0 +1,77 @@
# TextMate bundle
[//]: # (excludeFromIndex)
The TextMate-format bundle contains a syntax definition for initial language support in
popular editors that understand TextMate grammars: TextMate, Visual Studio Code, Sublime Text, etc.
- [Download TextMate Bundle for Lyng](https://lynglang.com/distributables/lyng-textmate.zip)
> Note for IntelliJ-based IDEs (IntelliJ IDEA, Fleet, etc.): although you can import TextMate
> bundles there (Settings/Preferences → Editor → TextMate Bundles), we strongly recommend using the
> dedicated plugin instead — it provides much better support (formatting, smart enter, background
> analysis, etc.). See: [IDEA Plugin](#/docs/idea_plugin.md).
## Visual Studio Code
VS Code uses TextMate grammars packaged as extensions. A minimal local extension is easy to set up:
1) Download and unzip the bundle above. Inside you will find the grammar file (usually
`*.tmLanguage.json` or `*.tmLanguage` plist).
2) Create a new folder somewhere, e.g. `lyng-textmate-vscode/` with the following structure:
```
lyng-textmate-vscode/
package.json
syntaxes/
lyng.tmLanguage.json # copy the grammar file here (rename if needed)
```
3) Put this minimal `package.json` into that folder (adjust file names if needed):
```
{
"name": "lyng-textmate",
"displayName": "Lyng (TextMate grammar)",
"publisher": "local",
"version": "0.0.1",
"engines": { "vscode": "^1.70.0" },
"contributes": {
"languages": [
{ "id": "lyng", "aliases": ["Lyng"], "extensions": [".lyng"] }
],
"grammars": [
{
"language": "lyng",
"scopeName": "source.lyng",
"path": "./syntaxes/lyng.tmLanguage.json"
}
]
}
}
```
4) Open a terminal in `lyng-textmate-vscode/` and run:
```
code --install-extension .
```
Alternatively, open the folder in VS Code and press F5 to run an Extension Development Host.
5) Reload VS Code. Files with the `.lyng` extension should now get Lyng highlighting.
## Sublime Text 3/4
1) Download and unzip the bundle.
2) In Sublime Text, use “Preferences → Browse Packages…”, then copy the unzipped bundle
to a folder like `Packages/Lyng/`.
3) Open a `.lyng` file; Sublime should pick up the syntax automatically. If not, use
“View → Syntax → Lyng”.
## TextMate 2
1) Download and unzip the bundle.
2) Double‑click the `.tmBundle`/grammar package or drag it onto TextMate to install, or place
it into `~/Library/Application Support/TextMate/Bundles/`.
3) Restart TextMate if needed and open a `.lyng` file.

View File

@ -6,7 +6,7 @@ In other word, the code usually works as expected when you see it. So, nothing u
__Other documents to read__ maybe after this one:
- [Advanced topics](advanced_topics.md), [declaring arguments](declaring_arguments.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)
- [math in Lyng](math.md), [the `when` statement](when.md)
- [time](time.md) and [parallelism](parallelism.md)
@ -66,6 +66,26 @@ You can use blocks in if statement, as expected:
limited
>>> 120.0
## Enums (quick intro)
Lyng supports simple enums for a fixed set of named constants. Declare with `enum Name { ... }` and use entries as `Name.ENTRY`.
enum Color {
RED, GREEN, BLUE
}
assert( Color.RED is Color )
assertEquals( 0, Color.RED.ordinal )
assertEquals( "BLUE", Color.BLUE.name )
// All entries as a list, in order:
assertEquals( [Color.RED, Color.GREEN, Color.BLUE], Color.entries )
// Lookup by name:
assertEquals( Color.GREEN, Color.valueOf("GREEN") )
For more details (usage patterns, `when` switching, serialization), see OOP notes: [Enums in detail](OOP.md#enums).
When putting multiple statments in the same line it is convenient and recommended to use `;`:
var from; var to
@ -205,7 +225,7 @@ Much like let, but it does not alter returned value:
>>> void
While it is not altering return value, the source object could be changed:
also
class Point(x,y)
val p = Point(1,2).also { it.x++ }
assertEquals(p.x, 2)
@ -1365,6 +1385,7 @@ Typical set of String functions includes:
| trim() | trim space chars from both ends |
| startsWith(prefix) | true if starts with a prefix |
| endsWith(prefix) | true if ends with a prefix |
| last() | get last character of a string or throw |
| take(n) | get a new string from up to n first 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 |
@ -1533,7 +1554,7 @@ assertEquals(null, (buzz as? Foo)?.runA())
Notes:
- Resolution order uses C3 MRO (active): deterministic, monotonic order suitable for diamonds and complex hierarchies. Example: for `class D() : B(), C()` where both `B()` and `C()` derive from `A()`, the C3 order is `D → B → C → A`. The first visible match wins.
- `private` is visible only inside the declaring class; `protected` is visible from the declaring class and any of its transitive subclasses. Qualification (`this@Type`) or casts do not bypass visibility.
- `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.
To get details on OOP in Lyng, see [OOP notes](oop.md).

View File

@ -1,5 +1,7 @@
# The `when` statement (expression)
[//]: # (topMenu)
Lyng provides a concise multi-branch selection with `when`, heavily inspired by Kotlin. In Lyng, `when` is an expression: it evaluates to a value. If the selected branch contains no value (e.g., it ends with `void` or calls a void function like `println`), the whole `when` expression evaluates to `void`.
Currently, Lyng implements the "subject" form: `when(value) { ... }`. The subject-less form `when { condition -> ... }` is not implemented yet.

View File

@ -32,6 +32,9 @@ kotlin.native.cacheKind.linuxX64=none
# Workaround: Ensure Gradle uses a JDK with `jlink` available for AGP's JDK image transform.
# On this environment, the system JDK 21 installation lacks `jlink`, causing
# :lynglib:androidJdkImage to fail. Point Gradle to JDK 17 which includes `jlink`.
# :lynglib:androidJdkImage to fail. Point Gradle to a JDK that includes `jlink`.
# This affects only the JDK Gradle runs with; Kotlin/JVM target remains compatible.
#org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
#org.gradle.java.home=/home/sergeych/.jdks/corretto-21.0.9
android.experimental.lint.migrateToK2=false
android.lint.useK2Uast=false
kotlin.mpp.applyDefaultHierarchyTemplate=true

View File

@ -20,6 +20,7 @@ mp_bintools = { module = "net.sergeych:mp_bintools", version.ref = "mp_bintools"
firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" }
okio = { module = "com.squareup.okio:okio", version.ref = "okioVersion" }
okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okioVersion" }
okio-nodefilesystem = { module = "com.squareup.okio:okio-nodefilesystem", version.ref = "okioVersion" }
compiler = { group = "androidx.databinding", name = "compiler", version.ref = "compiler" }
[plugins]

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
- Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
-
-->
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" aria-labelledby="title" role="img">
<title>Lyng File Icon (temporary λ)</title>
<defs>
<style>
.g { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
</style>
</defs>
<!-- Keep shapes crisp on small canvas; slight inset to avoid clipping -->
<g transform="translate(0.5,0.5)">
<!-- Stylized lambda fitted to 14x14 -->
<path class="g" d="M4.5 2.5 L7 9.5 C7.6 11.2 9.0 12.5 11.0 12.5 L14.5 12.5"/>
<path class="g" d="M7 9.5 L2.5 14.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
- Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
-
-->
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg" aria-labelledby="title" role="img">
<title>Lyng Plugin Icon (temporary λ)</title>
<defs>
<!-- Monochrome, theme-friendly -->
<style>
.glyph { fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
</style>
</defs>
<!-- Safe inset to avoid edge clipping in 40x40 canvas -->
<g transform="translate(2,2)">
<!-- Stylized lambda: rising stem + curved tail -->
<path class="glyph" d="M12 6 L18 22 C19.2 25.5 22.2 28 26 28 L32 28"/>
<path class="glyph" d="M18 22 L8 34"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,88 @@
/*
* 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.
*
*/
plugins {
kotlin("jvm")
id("org.jetbrains.intellij") version "1.17.3"
}
group = "net.sergeych.lyng"
version = "0.0.3-SNAPSHOT"
kotlin {
jvmToolchain(17)
}
repositories {
mavenCentral()
// Use the same repositories as the rest of the project so plugin runtime deps resolve
maven("https://maven.universablockchain.com/")
maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
mavenLocal()
}
dependencies {
implementation(project(":lynglib"))
// Include lyngio so Quick Docs can reflectively load fs docs registrar (FsBuiltinDocs)
implementation(project(":lyngio"))
// Rich Markdown renderer for Quick Docs
implementation("com.vladsch.flexmark:flexmark-all:0.64.8")
// Tests for IntelliJ Platform fixtures rely on JUnit 3/4 API (junit.framework.TestCase)
// Add JUnit 4 which contains the JUnit 3 compatibility classes used by BasePlatformTestCase/UsefulTestCase
testImplementation("junit:junit:4.13.2")
}
intellij {
type.set("IC")
// Build against a modern baseline. Install range is controlled by since/until below.
version.set("2024.3.1")
// We manage <idea-version> ourselves in plugin.xml to keep it open-ended (no upper cap)
updateSinceUntilBuild.set(false)
// Include only available bundled plugins for this IDE build
plugins.set(listOf(
"com.intellij.java",
// Provide Grazie API on compile classpath (bundled in 2024.3+, but add here for compilation)
"tanvd.grazi"
// Do not list com.intellij.spellchecker here: it is expected to be bundled with the IDE.
// Listing it causes Gradle to search for a separate plugin artifact and fail on IC 2024.3.
))
}
tasks {
patchPluginXml {
// Keep version and other metadata patched by Gradle, but since/until are controlled in plugin.xml.
// (intellij.updateSinceUntilBuild=false prevents Gradle from injecting an until-build cap)
}
// Build an installable plugin zip and copy it to $PROJECT_ROOT/distributables
// Usage: ./gradlew :lyng-idea:buildInstallablePlugin
// It depends on buildPlugin and overwrites any existing file with the same name
register<Copy>("buildInstallablePlugin") {
dependsOn("buildPlugin")
// The Gradle IntelliJ Plugin produces: build/distributions/<project.name>-<version>.zip
val zipName = "${project.name}-${project.version}.zip"
val sourceZip = layout.buildDirectory.file("distributions/$zipName")
from(sourceZip)
into(rootProject.layout.projectDirectory.dir("distributables"))
// Overwrite if a file with the same name exists
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
}

View File

@ -0,0 +1,27 @@
/*
* 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.idea
import com.intellij.openapi.fileTypes.LanguageFileType
import javax.swing.Icon
object LyngFileType : LanguageFileType(LyngLanguage) {
override fun getName(): String = "Lyng"
override fun getDescription(): String = "Lyng language file"
override fun getDefaultExtension(): String = "lyng"
override fun getIcon(): Icon? = LyngIcons.FILE
}

View File

@ -0,0 +1,24 @@
/*
* 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.idea
import com.intellij.openapi.util.IconLoader
import javax.swing.Icon
object LyngIcons {
val FILE: Icon = IconLoader.getIcon("/icons/lyng_file.svg", LyngIcons::class.java)
}

View File

@ -0,0 +1,21 @@
/*
* 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.idea
import com.intellij.lang.Language
object LyngLanguage : Language("Lyng")

View File

@ -0,0 +1,387 @@
/*
* 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.idea.annotators
import com.intellij.lang.annotation.AnnotationHolder
import com.intellij.lang.annotation.ExternalAnnotator
import com.intellij.lang.annotation.HighlightSeverity
import com.intellij.openapi.editor.Document
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiFile
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.ScriptError
import net.sergeych.lyng.Source
import net.sergeych.lyng.binding.Binder
import net.sergeych.lyng.binding.SymbolKind
import net.sergeych.lyng.highlight.HighlightKind
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.idea.highlight.LyngHighlighterColors
import net.sergeych.lyng.idea.util.IdeLenientImportProvider
import net.sergeych.lyng.miniast.*
/**
* ExternalAnnotator that runs Lyng MiniAst on the document text in background
* and applies semantic highlighting comparable with the web highlighter.
*/
class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, LyngExternalAnnotator.Result>() {
data class Input(val text: String, val modStamp: Long, val previousSpans: List<Span>?)
data class Span(val start: Int, val end: Int, val key: com.intellij.openapi.editor.colors.TextAttributesKey)
data class Error(val start: Int, val end: Int, val message: String)
data class Result(val modStamp: Long, val spans: List<Span>, val error: Error? = null,
val spellIdentifiers: List<IntRange> = emptyList(),
val spellComments: List<IntRange> = emptyList(),
val spellStrings: List<IntRange> = emptyList())
override fun collectInformation(file: PsiFile): Input? {
val doc: Document = file.viewProvider.document ?: return null
val cached = file.getUserData(CACHE_KEY)
// Fast fix (1): reuse cached spans only if they were computed for the same modification stamp
val prev = if (cached != null && cached.modStamp == doc.modificationStamp) cached.spans else null
return Input(doc.text, doc.modificationStamp, prev)
}
override fun doAnnotate(collectedInfo: Input?): Result? {
if (collectedInfo == null) return null
ProgressManager.checkCanceled()
val text = collectedInfo.text
// Build Mini-AST using the same mechanism as web highlighter
val sink = MiniAstBuilder()
val source = Source("<ide>", text)
try {
// Call suspend API from blocking context
val provider = IdeLenientImportProvider.create()
runBlocking { Compiler.compileWithMini(source, provider, sink) }
} catch (e: Throwable) {
if (e is com.intellij.openapi.progress.ProcessCanceledException) throw e
// On script parse error: keep previous spans and report the error location
if (e is ScriptError) {
val off = try { source.offsetOf(e.pos) } catch (_: Throwable) { -1 }
val start0 = off.coerceIn(0, text.length.coerceAtLeast(0))
val (start, end) = expandErrorRange(text, start0)
// Fast fix (5): clear cached highlighting after the error start position
val trimmed = collectedInfo.previousSpans?.filter { it.end <= start } ?: emptyList()
return Result(
collectedInfo.modStamp,
trimmed,
Error(start, end, e.errorMessage)
)
}
// Other failures: keep previous spans without error
return Result(collectedInfo.modStamp, collectedInfo.previousSpans ?: emptyList(), null)
}
ProgressManager.checkCanceled()
val mini = sink.build() ?: return Result(collectedInfo.modStamp, collectedInfo.previousSpans ?: emptyList())
val out = ArrayList<Span>(256)
fun putRange(start: Int, end: Int, key: com.intellij.openapi.editor.colors.TextAttributesKey) {
if (start in 0..end && end <= text.length && start < end) out += Span(start, end, key)
}
fun putName(startPos: net.sergeych.lyng.Pos, name: String, key: com.intellij.openapi.editor.colors.TextAttributesKey) {
val s = source.offsetOf(startPos)
putRange(s, (s + name.length).coerceAtMost(text.length), key)
}
fun putMiniRange(r: MiniRange, key: com.intellij.openapi.editor.colors.TextAttributesKey) {
val s = source.offsetOf(r.start)
val e = source.offsetOf(r.end)
putRange(s, e, key)
}
// Declarations
for (d in mini.declarations) {
when (d) {
is MiniFunDecl -> putName(d.nameStart, d.name, LyngHighlighterColors.FUNCTION_DECLARATION)
is MiniClassDecl -> putName(d.nameStart, d.name, LyngHighlighterColors.TYPE)
is MiniValDecl -> putName(
d.nameStart,
d.name,
if (d.mutable) LyngHighlighterColors.VARIABLE else LyngHighlighterColors.VALUE
)
}
}
// Imports: each segment as namespace/path
for (imp in mini.imports) {
for (seg in imp.segments) putMiniRange(seg.range, LyngHighlighterColors.NAMESPACE)
}
// Parameters
for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
for (p in fn.params) putName(p.nameStart, p.name, LyngHighlighterColors.PARAMETER)
}
// Type name segments (including generics base & args)
fun addTypeSegments(t: MiniTypeRef?) {
when (t) {
is MiniTypeName -> t.segments.forEach { seg ->
val s = source.offsetOf(seg.range.start)
putRange(s, (s + seg.name.length).coerceAtMost(text.length), LyngHighlighterColors.TYPE)
}
is MiniGenericType -> {
addTypeSegments(t.base)
t.args.forEach { addTypeSegments(it) }
}
is MiniFunctionType -> {
t.receiver?.let { addTypeSegments(it) }
t.params.forEach { addTypeSegments(it) }
addTypeSegments(t.returnType)
}
is MiniTypeVar -> { /* name is in range; could be highlighted as TYPE as well */
putMiniRange(t.range, LyngHighlighterColors.TYPE)
}
null -> {}
}
}
for (d in mini.declarations) {
when (d) {
is MiniFunDecl -> {
addTypeSegments(d.returnType)
d.params.forEach { addTypeSegments(it.type) }
}
is MiniValDecl -> addTypeSegments(d.type)
is MiniClassDecl -> {
d.ctorFields.forEach { addTypeSegments(it.type) }
d.classFields.forEach { addTypeSegments(it.type) }
}
}
}
ProgressManager.checkCanceled()
// Semantic usages via Binder (best-effort)
try {
val binding = Binder.bind(text, mini)
// Map declaration ranges to avoid duplicating them as usages
val declKeys = HashSet<Pair<Int, Int>>(binding.symbols.size * 2)
for (sym in binding.symbols) declKeys += (sym.declStart to sym.declEnd)
fun keyForKind(k: SymbolKind) = when (k) {
SymbolKind.Function -> LyngHighlighterColors.FUNCTION
SymbolKind.Class, SymbolKind.Enum -> LyngHighlighterColors.TYPE
SymbolKind.Param -> LyngHighlighterColors.PARAMETER
SymbolKind.Val -> LyngHighlighterColors.VALUE
SymbolKind.Var -> LyngHighlighterColors.VARIABLE
}
// Track covered ranges to not override later heuristics
val covered = HashSet<Pair<Int, Int>>()
for (ref in binding.references) {
val key = ref.start to ref.end
if (declKeys.contains(key)) continue
val sym = binding.symbols.firstOrNull { it.id == ref.symbolId } ?: continue
val color = keyForKind(sym.kind)
putRange(ref.start, ref.end, color)
covered += key
}
// Heuristics on top of binder: function call-sites and simple name-based roles
ProgressManager.checkCanceled()
val tokens = try { SimpleLyngHighlighter().highlight(text) } catch (_: Throwable) { emptyList() }
fun isFollowedByParenOrBlock(rangeEnd: Int): Boolean {
var i = rangeEnd
while (i < text.length) {
val ch = text[i]
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') { i++; continue }
return ch == '(' || ch == '{'
}
return false
}
// Build simple name -> role map for top-level vals/vars and parameters
val nameRole = HashMap<String, com.intellij.openapi.editor.colors.TextAttributesKey>(8)
for (d in mini.declarations) when (d) {
is MiniValDecl -> nameRole[d.name] = if (d.mutable) LyngHighlighterColors.VARIABLE else LyngHighlighterColors.VALUE
is MiniFunDecl -> d.params.forEach { p -> nameRole[p.name] = LyngHighlighterColors.PARAMETER }
else -> {}
}
for (s in tokens) if (s.kind == HighlightKind.Identifier) {
val start = s.range.start
val end = s.range.endExclusive
val key = start to end
if (key in covered || key in declKeys) continue
// Call-site detection first so it wins over var/param role
if (isFollowedByParenOrBlock(end)) {
putRange(start, end, LyngHighlighterColors.FUNCTION)
covered += key
continue
}
// Simple role by known names
val ident = try { text.substring(start, end) } catch (_: Throwable) { null }
if (ident != null) {
val roleKey = nameRole[ident]
if (roleKey != null) {
putRange(start, end, roleKey)
covered += key
}
}
}
} catch (e: Throwable) {
// Must rethrow cancellation; otherwise ignore binder failures (best-effort)
if (e is com.intellij.openapi.progress.ProcessCanceledException) throw e
}
// Add annotation coloring using token highlighter (treat @Label as annotation)
run {
val tokens = try { SimpleLyngHighlighter().highlight(text) } catch (_: Throwable) { emptyList() }
for (s in tokens) if (s.kind == HighlightKind.Label) {
val start = s.range.start
val end = s.range.endExclusive
if (start in 0..end && end <= text.length && start < end) {
val lexeme = try { text.substring(start, end) } catch (_: Throwable) { null }
if (lexeme != null && lexeme.startsWith("@")) {
putRange(start, end, LyngHighlighterColors.ANNOTATION)
}
}
}
}
// Map Enum constants from token highlighter to IDEA enum constant color
run {
val tokens = try { SimpleLyngHighlighter().highlight(text) } catch (_: Throwable) { emptyList() }
for (s in tokens) if (s.kind == HighlightKind.EnumConstant) {
val start = s.range.start
val end = s.range.endExclusive
if (start in 0..end && end <= text.length && start < end) {
putRange(start, end, LyngHighlighterColors.ENUM_CONSTANT)
}
}
}
// Build spell index payload: identifiers from symbols + references; comments/strings from simple highlighter
val idRanges = mutableSetOf<IntRange>()
try {
val binding = Binder.bind(text, mini)
for (sym in binding.symbols) {
val s = sym.declStart; val e = sym.declEnd
if (s in 0..e && e <= text.length && s < e) idRanges += (s until e)
}
for (ref in binding.references) {
val s = ref.start; val e = ref.end
if (s in 0..e && e <= text.length && s < e) idRanges += (s until e)
}
} catch (_: Throwable) {
// Best-effort; no identifiers if binder fails
}
val tokens = try { SimpleLyngHighlighter().highlight(text) } catch (_: Throwable) { emptyList() }
val commentRanges = tokens.filter { it.kind == HighlightKind.Comment }.map { it.range.start until it.range.endExclusive }
val stringRanges = tokens.filter { it.kind == HighlightKind.String }.map { it.range.start until it.range.endExclusive }
return Result(collectedInfo.modStamp, out, null,
spellIdentifiers = idRanges.toList(),
spellComments = commentRanges,
spellStrings = stringRanges)
}
override fun apply(file: PsiFile, annotationResult: Result?, holder: AnnotationHolder) {
if (annotationResult == null) return
// Skip if cache is up-to-date
val doc = file.viewProvider.document
val currentStamp = doc?.modificationStamp
val cached = file.getUserData(CACHE_KEY)
val result = if (cached != null && currentStamp != null && cached.modStamp == currentStamp) cached else annotationResult
file.putUserData(CACHE_KEY, result)
// Store spell index for spell/grammar engines to consume (suspend until ready)
val ids = result.spellIdentifiers.map { TextRange(it.first, it.last + 1) }
val coms = result.spellComments.map { TextRange(it.first, it.last + 1) }
val strs = result.spellStrings.map { TextRange(it.first, it.last + 1) }
net.sergeych.lyng.idea.spell.LyngSpellIndex.store(file,
net.sergeych.lyng.idea.spell.LyngSpellIndex.Data(
modStamp = result.modStamp,
identifiers = ids,
comments = coms,
strings = strs
)
)
// Optional diagnostic overlay: visualize the ranges we will feed to spellcheckers
val settings = net.sergeych.lyng.idea.settings.LyngFormatterSettings.getInstance(file.project)
if (settings.debugShowSpellFeed) {
fun paint(r: TextRange, label: String) {
holder.newAnnotation(HighlightSeverity.WEAK_WARNING, "spell-feed: $label")
.range(r)
.create()
}
ids.forEach { paint(it, "id") }
coms.forEach { paint(it, "comment") }
if (settings.spellCheckStringLiterals) strs.forEach { paint(it, "string") }
}
for (s in result.spans) {
holder.newSilentAnnotation(HighlightSeverity.INFORMATION)
.range(TextRange(s.start, s.end))
.textAttributes(s.key)
.create()
}
// Show syntax error if present
val err = result.error
if (err != null) {
val start = err.start.coerceIn(0, (doc?.textLength ?: 0))
val end = err.end.coerceIn(start, (doc?.textLength ?: start))
if (end > start) {
holder.newAnnotation(HighlightSeverity.ERROR, err.message)
.range(TextRange(start, end))
.create()
}
}
}
companion object {
private val CACHE_KEY: Key<Result> = Key.create("LYNG_SEMANTIC_CACHE")
}
/**
* Make the error highlight a bit wider than a single character so it is easier to see and click.
* Strategy:
* - If the offset points inside an identifier-like token (letters/digits/underscore), expand to the full token.
* - Otherwise select a small range starting at the offset with a minimum width, but not crossing the line end.
*/
private fun expandErrorRange(text: String, rawStart: Int): Pair<Int, Int> {
if (text.isEmpty()) return 0 to 0
val len = text.length
val start = rawStart.coerceIn(0, len)
fun isWord(ch: Char) = ch == '_' || ch.isLetterOrDigit()
if (start < len && isWord(text[start])) {
var s = start
var e = start
while (s > 0 && isWord(text[s - 1])) s--
while (e < len && isWord(text[e])) e++
return s to e
}
// Not inside a word: select a short, visible range up to EOL
val lineEnd = text.indexOf('\n', start).let { if (it == -1) len else it }
val minWidth = 4
val end = (start + minWidth).coerceAtMost(lineEnd).coerceAtLeast((start + 1).coerceAtMost(lineEnd))
return start to end
}
}

View File

@ -0,0 +1,27 @@
/*
* 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.idea.comment
import com.intellij.lang.Commenter
class LyngCommenter : Commenter {
override fun getLineCommentPrefix(): String = "//"
override fun getBlockCommentPrefix(): String = "/*"
override fun getBlockCommentSuffix(): String = "*/"
override fun getCommentedBlockCommentPrefix(): String? = null
override fun getCommentedBlockCommentSuffix(): String? = null
}

View File

@ -0,0 +1,934 @@
/*
* Lightweight BASIC completion for Lyng, MVP version.
* Uses MiniAst (best-effort) + BuiltinDocRegistry to suggest symbols.
*/
package net.sergeych.lyng.idea.completion
import com.intellij.codeInsight.completion.*
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.icons.AllIcons
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Document
import com.intellij.openapi.util.Key
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.PsiFile
import com.intellij.util.ProcessingContext
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Source
import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
import net.sergeych.lyng.idea.util.DocsBootstrap
import net.sergeych.lyng.idea.util.IdeLenientImportProvider
import net.sergeych.lyng.idea.util.TextCtx
import net.sergeych.lyng.miniast.*
class LyngCompletionContributor : CompletionContributor() {
init {
extend(
CompletionType.BASIC,
PlatformPatterns.psiElement().withLanguage(LyngLanguage),
Provider
)
}
private object Provider : CompletionProvider<CompletionParameters>() {
private val log = Logger.getInstance(LyngCompletionContributor::class.java)
private const val DEBUG_COMPLETION = false
override fun addCompletions(
parameters: CompletionParameters,
context: ProcessingContext,
result: CompletionResultSet
) {
// Ensure external/bundled docs are registered (e.g., lyng.io.fs with Path)
DocsBootstrap.ensure()
// Ensure stdlib Obj*-defined docs (e.g., String methods via ObjString.addFnDoc) are initialized
StdlibDocsBootstrap.ensure()
val file: PsiFile = parameters.originalFile
if (file.language != LyngLanguage) return
// Feature toggle: allow turning completion off from settings
val settings = LyngFormatterSettings.getInstance(file.project)
if (!settings.enableLyngCompletionExperimental) return
val document: Document = file.viewProvider.document ?: return
val text = document.text
val caret = parameters.offset.coerceIn(0, text.length)
val prefix = TextCtx.prefixAt(text, caret)
val withPrefix = result.withPrefixMatcher(prefix).caseInsensitive()
// Emission with cap
val cap = 200
var added = 0
val emit: (com.intellij.codeInsight.lookup.LookupElement) -> Unit = { le ->
if (added < cap) {
withPrefix.addElement(le)
added++
}
}
// Determine if we are in member context (dot before caret or before word start)
val wordRange = TextCtx.wordRangeAt(text, caret)
val memberDotPos = (wordRange?.let { TextCtx.findDotLeft(text, it.startOffset) })
?: TextCtx.findDotLeft(text, caret)
if (DEBUG_COMPLETION) {
log.info("[LYNG_DEBUG] Completion: caret=$caret prefix='${prefix}' memberDotPos=${memberDotPos} file='${file.name}'")
}
// Build MiniAst (cached) for both global and member contexts to enable local class/val inference
val mini = buildMiniAstCached(file, text)
// Delegate computation to the shared engine to keep behavior in sync with tests
val engineItems = try {
runBlocking { CompletionEngineLight.completeSuspend(text, caret) }
} catch (t: Throwable) {
if (DEBUG_COMPLETION) log.warn("[LYNG_DEBUG] Engine completion failed: ${t.message}")
emptyList()
}
if (DEBUG_COMPLETION) {
val preview = engineItems.take(10).joinToString { it.name }
log.info("[LYNG_DEBUG] Engine items: count=${engineItems.size} preview=[${preview}]")
}
// If we are in member context and the engine produced nothing, try a guarded local fallback
if (memberDotPos != null && engineItems.isEmpty()) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Fallback: engine returned 0 in member context; trying local inference")
// Build imported modules from text (lenient) + stdlib; avoid heavy MiniAst here
val fromText = extractImportsFromText(text)
val imported = LinkedHashSet<String>().apply {
fromText.forEach { add(it) }
add("lyng.stdlib")
}.toList()
// Try inferring return/receiver class around the dot
val inferred =
// Prefer MiniAst-based inference (return type from member call or receiver type)
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported)
?:
guessReturnClassFromMemberCallBefore(text, memberDotPos, imported)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported)
?: guessReceiverClass(text, memberDotPos, imported)
if (inferred != null) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Fallback inferred receiver/return class='$inferred' — offering its members")
offerMembers(emit, imported, inferred, sourceText = text, mini = mini)
return
} else {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Fallback could not infer class; keeping list empty (no globals after dot)")
return
}
}
// In global context, add params in scope first (engine does not include them)
if (memberDotPos == null && mini != null) {
offerParamsInScope(emit, mini, text, caret)
}
// Render engine items
for (ci in engineItems) {
val builder = when (ci.kind) {
Kind.Function -> LookupElementBuilder.create(ci.name)
.withIcon(AllIcons.Nodes.Function)
.let { b -> if (!ci.tailText.isNullOrBlank()) b.withTailText(ci.tailText, true) else b }
.let { b -> if (!ci.typeText.isNullOrBlank()) b.withTypeText(ci.typeText, true) else b }
.withInsertHandler(ParenInsertHandler)
Kind.Method -> LookupElementBuilder.create(ci.name)
.withIcon(AllIcons.Nodes.Method)
.let { b -> if (!ci.tailText.isNullOrBlank()) b.withTailText(ci.tailText, true) else b }
.let { b -> if (!ci.typeText.isNullOrBlank()) b.withTypeText(ci.typeText, true) else b }
.withInsertHandler(ParenInsertHandler)
Kind.Class_ -> LookupElementBuilder.create(ci.name)
.withIcon(AllIcons.Nodes.Class)
Kind.Value -> LookupElementBuilder.create(ci.name)
.withIcon(AllIcons.Nodes.Field)
.let { b -> if (!ci.typeText.isNullOrBlank()) b.withTypeText(ci.typeText, true) else b }
Kind.Field -> LookupElementBuilder.create(ci.name)
.withIcon(AllIcons.Nodes.Field)
.let { b -> if (!ci.typeText.isNullOrBlank()) b.withTypeText(ci.typeText, true) else b }
}
emit(builder)
}
// In member context, ensure stdlib extension-like methods (e.g., String.re) are present
if (memberDotPos != null) {
val existing = engineItems.map { it.name }.toMutableSet()
val fromText = extractImportsFromText(text)
val imported = LinkedHashSet<String>().apply {
fromText.forEach { add(it) }
add("lyng.stdlib")
}.toList()
val inferredClass =
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported)
?: guessReturnClassFromMemberCallBefore(text, memberDotPos, imported)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported)
?: guessReceiverClass(text, memberDotPos, imported)
if (!inferredClass.isNullOrBlank()) {
val ext = BuiltinDocRegistry.extensionMethodNamesFor(inferredClass)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Post-engine extension check for $inferredClass: ${'$'}{ext}")
for (name in ext) {
if (existing.contains(name)) continue
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, inferredClass, name)
if (resolved != null) {
when (val member = resolved.second) {
is MiniMemberFunDecl -> {
val params = member.params.joinToString(", ") { it.name }
val ret = typeOf(member.returnType)
val builder = LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Method)
.withTailText("(${ '$' }params)", true)
.withTypeText(ret, true)
.withInsertHandler(ParenInsertHandler)
emit(builder)
existing.add(name)
}
is MiniMemberValDecl -> {
val builder = LookupElementBuilder.create(name)
.withIcon(if (member.mutable) AllIcons.Nodes.Variable else AllIcons.Nodes.Field)
.withTypeText(typeOf(member.type), true)
emit(builder)
existing.add(name)
}
}
} else {
// Fallback: emit simple method name without detailed types
val builder = LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Method)
.withTailText("()", true)
.withInsertHandler(ParenInsertHandler)
emit(builder)
existing.add(name)
}
}
}
}
// If in member context and engine items are suspiciously sparse, try to enrich via local inference + offerMembers
if (memberDotPos != null && engineItems.size < 3) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Engine produced only ${engineItems.size} items in member context — trying enrichment")
val fromText = extractImportsFromText(text)
val imported = LinkedHashSet<String>().apply {
fromText.forEach { add(it) }
add("lyng.stdlib")
}.toList()
val inferred =
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported)
?: guessReturnClassFromMemberCallBefore(text, memberDotPos, imported)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported)
?: guessReceiverClass(text, memberDotPos, imported)
if (inferred != null) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Enrichment inferred class='$inferred' — offering its members")
offerMembers(emit, imported, inferred, sourceText = text, mini = mini)
}
}
return
}
private fun offerDecl(emit: (com.intellij.codeInsight.lookup.LookupElement) -> Unit, d: MiniDecl) {
val name = d.name
val builder = when (d) {
is MiniFunDecl -> {
val params = d.params.joinToString(", ") { it.name }
val ret = typeOf(d.returnType)
val tail = "(${params})"
LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Function)
.withTailText(tail, true)
.withTypeText(ret, true)
.withInsertHandler(ParenInsertHandler)
}
is MiniClassDecl -> LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Class)
is MiniValDecl -> {
val kindIcon = if (d.mutable) AllIcons.Nodes.Variable else AllIcons.Nodes.Field
LookupElementBuilder.create(name)
.withIcon(kindIcon)
.withTypeText(typeOf(d.type), true)
}
else -> LookupElementBuilder.create(name)
}
emit(builder)
}
private object ParenInsertHandler : InsertHandler<com.intellij.codeInsight.lookup.LookupElement> {
override fun handleInsert(context: InsertionContext, item: com.intellij.codeInsight.lookup.LookupElement) {
val doc = context.document
val tailOffset = context.tailOffset
val nextChar = doc.charsSequence.getOrNull(tailOffset)
if (nextChar != '(') {
doc.insertString(tailOffset, "()")
context.editor.caretModel.moveToOffset(tailOffset + 1)
}
}
}
// --- Member completion helpers ---
private fun offerMembers(
emit: (com.intellij.codeInsight.lookup.LookupElement) -> Unit,
imported: List<String>,
className: String,
staticOnly: Boolean = false
, sourceText: String,
mini: MiniScript? = null
) {
// Ensure modules are seeded in the registry (triggers lazy stdlib build too)
for (m in imported) BuiltinDocRegistry.docsForModule(m)
val classes = DocLookupUtils.aggregateClasses(imported)
if (DEBUG_COMPLETION) {
val keys = classes.keys.joinToString(", ")
log.info("[LYNG_DEBUG] offerMembers: imported=${imported} classes=[${keys}] target=${className}")
}
val visited = mutableSetOf<String>()
// Collect separated to keep tiers: direct first, then inherited
val directMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
val inheritedMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
// 0) Prefer locally-declared class members (same-file) when available
val localClass = mini?.declarations?.filterIsInstance<MiniClassDecl>()?.firstOrNull { it.name == className }
if (localClass != null) {
for (m in localClass.members) {
val list = directMap.getOrPut(m.name) { mutableListOf() }
list.add(m)
}
// If MiniAst didn't populate members (empty), try to scan class body text for member signatures
if (localClass.members.isEmpty()) {
val scanned = 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}")
for ((name, sig) in scanned) {
when (sig.kind) {
"fun" -> {
val builder = LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Method)
.withTailText("(" + (sig.params?.joinToString(", ") ?: "") + ")", true)
.let { b -> sig.typeText?.let { b.withTypeText(": $it", true) } ?: b }
.withInsertHandler(ParenInsertHandler)
emit(builder)
}
"val", "var" -> {
val builder = LookupElementBuilder.create(name)
.withIcon(if (sig.kind == "var") AllIcons.Nodes.Variable else AllIcons.Nodes.Field)
.let { b -> sig.typeText?.let { b.withTypeText(": $it", true) } ?: b }
emit(builder)
}
}
}
}
}
fun addMembersOf(clsName: String, tierDirect: Boolean) {
val cls = classes[clsName] ?: return
val target = if (tierDirect) directMap else inheritedMap
for (m in cls.members) {
if (staticOnly) {
// Filter only static members in namespace/static context
when (m) {
is MiniMemberFunDecl -> if (!m.isStatic) continue
is MiniMemberValDecl -> if (!m.isStatic) continue
}
}
val list = target.getOrPut(m.name) { mutableListOf() }
list.add(m)
}
// Then inherited
for (base in cls.bases) {
if (visited.add(base)) addMembersOf(base, false)
}
}
visited.add(className)
addMembersOf(className, true)
if (DEBUG_COMPLETION) {
log.info("[LYNG_DEBUG] offerMembers: direct=${directMap.size} inherited=${inheritedMap.size} for ${className}")
}
// If the docs model lacks explicit bases for some core container classes,
// conservatively supplement with preferred parents to expose common ops.
fun supplementPreferredBases(receiver: String) {
// Preference/known lineage map kept tiny and safe
val extras = when (receiver) {
"List" -> listOf("Collection", "Iterable")
"Array" -> listOf("Collection", "Iterable")
// In practice, many high-level ops users expect on iteration live on Iterable.
// For editor assistance, expose Iterable ops for Iterator receivers too.
"Iterator" -> listOf("Iterable")
else -> emptyList()
}
for (base in extras) {
if (visited.add(base)) addMembersOf(base, false)
}
}
supplementPreferredBases(className)
fun emitGroup(map: LinkedHashMap<String, MutableList<MiniMemberDecl>>) {
val keys = map.keys.sortedBy { it.lowercase() }
for (name in keys) {
val list = map[name] ?: continue
// Choose a representative for display:
// 1) Prefer a method with a known return type
// 2) Else any method
// 3) Else the first variant
val rep =
list.asSequence()
.filterIsInstance<MiniMemberFunDecl>()
.firstOrNull { it.returnType != null }
?: list.firstOrNull { it is MiniMemberFunDecl }
?: list.first()
when (rep) {
is MiniMemberFunDecl -> {
val params = rep.params.joinToString(", ") { it.name }
val ret = typeOf(rep.returnType)
val extra = list.count { it is MiniMemberFunDecl } - 1
val overloads = if (extra > 0) " (+$extra overloads)" else ""
val tail = "(${params})$overloads"
val icon = AllIcons.Nodes.Method
val builder = LookupElementBuilder.create(name)
.withIcon(icon)
.withTailText(tail, true)
.withTypeText(ret, true)
.withInsertHandler(ParenInsertHandler)
emit(builder)
}
is MiniMemberValDecl -> {
val icon = if (rep.mutable) AllIcons.Nodes.Variable else AllIcons.Nodes.Field
// Prefer a field variant with known type if available
val chosen = list.asSequence()
.filterIsInstance<MiniMemberValDecl>()
.firstOrNull { it.type != null } ?: rep
val builder = LookupElementBuilder.create(name)
.withIcon(icon)
.withTypeText(typeOf((chosen as MiniMemberValDecl).type), true)
emit(builder)
}
}
}
}
// Emit what we have first
emitGroup(directMap)
emitGroup(inheritedMap)
// If suggestions are suspiciously sparse for known container classes,
// try to conservatively supplement using a curated list resolved via docs registry.
val totalSuggested = directMap.size + inheritedMap.size
val isContainer = className in setOf("Iterator", "Iterable", "Collection", "List", "Array")
if (isContainer && totalSuggested < 3) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Supplementing members for $className; had=$totalSuggested")
val common = when (className) {
"Iterator" -> listOf(
"hasNext", "next", "forEach", "map", "filter", "take", "drop", "toList", "count", "any", "all"
)
else -> listOf(
// Iterable/Collection/List/Array common ops
"size", "isEmpty", "map", "flatMap", "filter", "first", "last", "contains",
"any", "all", "count", "forEach", "toList", "toSet"
)
}
val already = (directMap.keys + inheritedMap.keys).toMutableSet()
for (name in common) {
if (name in already) continue
// Try resolve across classes first to get types/params; if it fails, emit a synthetic safe suggestion.
val resolved = DocLookupUtils.findMemberAcrossClasses(imported, name)
if (resolved != null) {
val member = resolved.second
when (member) {
is MiniMemberFunDecl -> {
val params = member.params.joinToString(", ") { it.name }
val ret = typeOf(member.returnType)
val builder = LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Method)
.withTailText("(${params})", true)
.withTypeText(ret, true)
.withInsertHandler(ParenInsertHandler)
emit(builder)
already.add(name)
}
is MiniMemberValDecl -> {
val builder = LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Field)
.withTypeText(typeOf(member.type), true)
emit(builder)
already.add(name)
}
}
} else {
// Synthetic fallback: method without detailed params/types to improve UX in absence of docs
val isProperty = name in setOf("size", "length")
val builder = if (isProperty) {
LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Field)
} else {
LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Method)
.withTailText("()", true)
.withInsertHandler(ParenInsertHandler)
}
emit(builder)
already.add(name)
}
}
}
// Supplement with stdlib extension-like methods defined in root.lyng (e.g., fun String.trim(...))
run {
val already = (directMap.keys + inheritedMap.keys).toMutableSet()
val ext = BuiltinDocRegistry.extensionMethodNamesFor(className)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Extensions for $className: count=${ext.size} -> ${ext}")
for (name in ext) {
if (already.contains(name)) continue
// Try to resolve full signature via registry first to get params and return type
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, className, name)
if (resolved != null) {
when (val member = resolved.second) {
is MiniMemberFunDecl -> {
val params = member.params.joinToString(", ") { it.name }
val ret = typeOf(member.returnType)
val builder = LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Method)
.withTailText("(${params})", true)
.withTypeText(ret, true)
.withInsertHandler(ParenInsertHandler)
emit(builder)
already.add(name)
continue
}
is MiniMemberValDecl -> {
val builder = LookupElementBuilder.create(name)
.withIcon(if (member.mutable) AllIcons.Nodes.Variable else AllIcons.Nodes.Field)
.withTypeText(typeOf(member.type), true)
emit(builder)
already.add(name)
continue
}
}
}
// Fallback: emit without detailed types if we couldn't resolve
val builder = LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Method)
.withTailText("()", true)
.withInsertHandler(ParenInsertHandler)
emit(builder)
already.add(name)
}
}
}
// --- 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) {
val src = mini.range.start.source
// Find function whose body contains caret or whose whole range contains caret
val fns = mini.declarations.filterIsInstance<MiniFunDecl>()
for (fn in fns) {
val start = src.offsetOf(fn.range.start)
val end = src.offsetOf(fn.range.end).coerceAtMost(text.length)
if (caret in start..end) {
for (p in fn.params) {
val builder = LookupElementBuilder.create(p.name)
.withIcon(AllIcons.Nodes.Variable)
.withTypeText(typeOf(p.type), true)
emit(builder)
}
return
}
}
}
// Lenient textual import extractor (duplicated from QuickDoc privately)
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()
}
private fun typeOf(t: MiniTypeRef?): String {
return when (t) {
null -> ""
is MiniTypeName -> t.segments.lastOrNull()?.name?.let { ": $it" } ?: ""
is MiniGenericType -> {
val base = typeOf(t.base).removePrefix(": ")
val args = t.args.joinToString(",") { typeOf(it).removePrefix(": ") }
": ${base}<${args}>"
}
is MiniFunctionType -> ": (fn)"
is MiniTypeVar -> ": ${t.name}"
}
}
}
}

View File

@ -0,0 +1,55 @@
/*
* Minimal fallback docs seeding for `lyng.io.fs` used only inside the IDEA plugin
* when external docs module (lyngio) is not present on the classpath.
*
* We keep it tiny and plugin-local to avoid coupling core library to external packages.
*/
package net.sergeych.lyng.idea.docs
import net.sergeych.lyng.miniast.BuiltinDocRegistry
import net.sergeych.lyng.miniast.ParamDoc
import net.sergeych.lyng.miniast.TypeGenericDoc
import net.sergeych.lyng.miniast.type
internal object FsDocsFallback {
@Volatile
private var seeded = false
fun ensureOnce(): Boolean {
if (seeded) return true
synchronized(this) {
if (seeded) return true
BuiltinDocRegistry.module("lyng.io.fs") {
// Class Path summary and a few commonly used methods
classDoc(name = "Path", doc = "Filesystem path class. Construct with a string: `Path(\"/tmp\")`.") {
method(name = "exists", doc = "Whether the path exists on the filesystem.", returns = type("lyng.Bool"))
method(name = "isFile", doc = "Whether the path exists and is a file.", returns = type("lyng.Bool"))
method(name = "isDir", doc = "Whether the path exists and is a directory.", returns = type("lyng.Bool"))
method(name = "readUtf8", doc = "Read the entire file as UTF-8 string.", returns = type("lyng.String"))
method(
name = "writeUtf8",
doc = "Write UTF-8 string to the file (overwrite).",
params = listOf(ParamDoc("text", type("lyng.String")))
)
method(
name = "bytes",
doc = "Iterate file content as `Buffer` chunks.",
params = listOf(ParamDoc("size", type("lyng.Int"))),
returns = TypeGenericDoc(type("lyng.Iterator"), listOf(type("lyng.Buffer")))
)
method(
name = "lines",
doc = "Iterate file as lines of text.",
returns = TypeGenericDoc(type("lyng.Iterator"), listOf(type("lyng.String")))
)
}
// Top-level exported constants
valDoc(name = "Path", doc = "Filesystem path class. Construct with a string: `Path(\"/tmp\")`.", type = type("Path"))
valDoc(name = "Paths", doc = "Alias of `Path` for those who prefer plural form.", type = type("Path"))
}
seeded = true
return true
}
}
}

View File

@ -0,0 +1,564 @@
/*
* 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.idea.docs
import com.intellij.lang.documentation.AbstractDocumentationProvider
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Source
import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.util.IdeLenientImportProvider
import net.sergeych.lyng.idea.util.TextCtx
import net.sergeych.lyng.miniast.*
/**
* Quick Docs backed by MiniAst: when caret is on an identifier that corresponds
* to a declaration name or parameter, render a simple HTML with kind, signature,
* and doc summary if present.
*/
class LyngDocumentationProvider : AbstractDocumentationProvider() {
private val log = Logger.getInstance(LyngDocumentationProvider::class.java)
// Toggle to trace inheritance-based resolutions in Quick Docs. Keep false for normal use.
private val DEBUG_INHERITANCE = false
// Global Quick Doc debug toggle (OFF by default). When false, [LYNG_DEBUG] logs are suppressed.
private val DEBUG_LOG = false
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
// Try load external docs registrars (e.g., lyngio) if present on classpath
ensureExternalDocsRegistered()
// Ensure stdlib Obj*-defined docs (e.g., String methods via ObjString.addFnDoc) are initialized
try {
net.sergeych.lyng.miniast.StdlibDocsBootstrap.ensure()
} catch (_: Throwable) {
// best-effort; absence must not break Quick Doc
}
if (element == null) return null
val file: PsiFile = element.containingFile ?: return null
val document: Document = file.viewProvider.document ?: return null
val text = document.text
// Determine caret/lookup offset from the element range
val offset = originalElement?.textRange?.startOffset ?: element.textRange.startOffset
val idRange = TextCtx.wordRangeAt(text, offset) ?: run {
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: no word at offset=$offset in ${file.name}")
return null
}
if (idRange.isEmpty) return null
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}")
// Build MiniAst for this file (fast and resilient). Best-effort; on failure continue with registry lookup only.
val sink = MiniAstBuilder()
// Use lenient import provider so unresolved imports (e.g., lyng.io.fs) don't break docs
val provider = IdeLenientImportProvider.create()
val src = Source("<ide>", text)
var mini: MiniScript? = try {
runBlocking { Compiler.compileWithMini(src, provider, sink) }
sink.build()
} 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 failed: ${t.message}")
null
}
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
// Try resolve to: function param at position, function/class/val declaration at position
// 1) Check declarations whose name range contains offset
if (haveMini) for (d in mini.declarations) {
val s = source.offsetOf(d.nameStart)
val e = (s + d.name.length).coerceAtMost(text.length)
if (offset in s until e) {
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: matched decl '${d.name}' kind=${d::class.simpleName}")
return renderDeclDoc(d)
}
}
// 2) Check parameters of functions
if (haveMini) for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
for (p in fn.params) {
val s = source.offsetOf(p.nameStart)
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
run {
val dotPos = TextCtx.findDotLeft(text, idRange.startOffset)
?: TextCtx.findDotLeft(text, offset)
if (dotPos != null) {
// 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()
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
val i = TextCtx.prevNonWs(text, dotPos - 1)
val className: String? = when {
i >= 0 && text[i] == '"' -> "String"
i >= 0 && text[i] == ']' -> "List"
i >= 0 && text[i] == '}' -> "Dict"
i >= 0 && text[i] == ')' -> {
// 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()
when {
inner.startsWith('"') && inner.endsWith('"') -> "String"
inner.startsWith('[') && inner.endsWith(']') -> "List"
inner.startsWith('{') && inner.endsWith('}') -> "Dict"
else -> null
}
} else null
} else null
}
else -> DocLookupUtils.guessClassFromCallBefore(text, dotPos, 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) {
DocLookupUtils.resolveMemberWithInheritance(importedModules, className, ident)?.let { (owner, member) ->
if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] QuickDoc: literal/call '$ident' resolved to $owner.${member.name}")
return when (member) {
is MiniMemberFunDecl -> renderMemberFunDoc(owner, member)
is MiniMemberValDecl -> renderMemberValDoc(owner, member)
}
}
log.info("[LYNG_DEBUG] QuickDoc: resolve failed for ${className}.${ident}")
}
}
}
// 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 {
log.info("[LYNG_DEBUG] QuickDoc: fallback by name '${it.name}' kind=${it::class.simpleName}")
return renderDeclDoc(it)
}
// 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")
var importedModules = if (haveMini) DocLookupUtils.canonicalImportedModules(mini) else emptyList()
// 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
if (!importedModules.contains("lyng.stdlib")) importedModules = importedModules + "lyng.stdlib"
// 4a) try top-level decls
importedModules.forEach { mod ->
val docs = BuiltinDocRegistry.docsForModule(mod)
val matches = docs.filterIsInstance<MiniFunDecl>().filter { it.name == ident }
if (matches.isNotEmpty()) {
// Prefer overload by arity when caret is in a call position; otherwise show first
val arity = callArity(text, idRange.endOffset)
val chosen = arity?.let { a -> matches.firstOrNull { it.params.size == a } } ?: matches.first()
// If multiple and none matched arity, consider showing an overloads list
if (arity != null && chosen.params.size != arity && matches.size > 1) {
return renderOverloads(ident, matches)
}
return renderDeclDoc(chosen)
}
// Also allow values/consts
docs.filterIsInstance<MiniValDecl>().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it) }
// And classes
docs.filterIsInstance<MiniClassDecl>().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
if (ident == "println" || ident == "print") {
val fallback = if (ident == "println")
"Print values to the standard output and append a newline. Accepts any number of arguments." else
"Print values to the standard output without a trailing newline. Accepts any number of arguments."
val title = "function $ident(values)"
return "<div class='doc-title'>${htmlEscape(title)}</div>" + styledMarkdown(htmlEscape(fallback))
}
// 4b) try class members like ClassName.member with inheritance fallback
val lhs = previousWordBefore(text, idRange.startOffset)
if (lhs != null && hasDotBetween(text, lhs.endOffset, idRange.startOffset)) {
val className = text.substring(lhs.startOffset, lhs.endOffset)
DocLookupUtils.resolveMemberWithInheritance(importedModules, className, ident)?.let { (owner, member) ->
if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Inheritance resolved $className.$ident to $owner.${member.name}")
return when (member) {
is MiniMemberFunDecl -> renderMemberFunDoc(owner, member)
is MiniMemberValDecl -> renderMemberValDoc(owner, member)
}
}
} else {
// Heuristics when LHS is not an identifier (literals or call results):
// - List literal like [..].member → assume class List
// - Otherwise, try to find a unique class across imported modules that defines this member
val dotPos = findDotLeft(text, idRange.startOffset)
if (dotPos != null) {
val guessed = when {
looksLikeListLiteralBefore(text, dotPos) -> "List"
else -> DocLookupUtils.guessClassFromCallBefore(text, dotPos, importedModules)
}
if (guessed != null) {
DocLookupUtils.resolveMemberWithInheritance(importedModules, guessed, ident)?.let { (owner, member) ->
if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Heuristic '$guessed.$ident' resolved via inheritance to $owner.${member.name}")
return when (member) {
is MiniMemberFunDecl -> renderMemberFunDoc(owner, member)
is MiniMemberValDecl -> renderMemberValDoc(owner, member)
}
}
} else {
// Extra fallback: try a small set of known receiver classes (covers literals when guess failed)
run {
val candidates = listOf("String", "Iterable", "Iterator", "List", "Collection", "Array", "Dict", "Regex")
for (c in candidates) {
DocLookupUtils.resolveMemberWithInheritance(importedModules, c, ident)?.let { (owner, member) ->
if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Candidate '$c.$ident' resolved via inheritance to $owner.${member.name}")
return when (member) {
is MiniMemberFunDecl -> renderMemberFunDoc(owner, member)
is MiniMemberValDecl -> renderMemberValDoc(owner, member)
}
}
}
}
// As a last resort try aggregated String members (extensions from stdlib text)
run {
val classes = DocLookupUtils.aggregateClasses(importedModules)
val stringCls = classes["String"]
val m = stringCls?.members?.firstOrNull { it.name == ident }
if (m != null) {
if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Aggregated fallback resolved String.$ident")
return when (m) {
is MiniMemberFunDecl -> renderMemberFunDoc("String", m)
is MiniMemberValDecl -> renderMemberValDoc("String", m)
}
}
}
// Search across classes; prefer Iterable, then Iterator, then List for common ops
DocLookupUtils.findMemberAcrossClasses(importedModules, ident)?.let { (owner, member) ->
if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Cross-class '$ident' resolved to $owner.${member.name}")
return when (member) {
is MiniMemberFunDecl -> renderMemberFunDoc(owner, member)
is MiniMemberValDecl -> renderMemberValDoc(owner, member)
}
}
}
}
}
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: nothing found for ident='$ident'")
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 fun ensureExternalDocsRegistered() { @Suppress("UNUSED_EXPRESSION") externalDocsLoaded }
private fun tryLoadExternalDocs(): Boolean {
return try {
// Try known registrars; ignore failures if module is absent
val cls = Class.forName("net.sergeych.lyngio.docs.FsBuiltinDocs")
val m = cls.getMethod("ensure")
m.invoke(null)
log.info("[LYNG_DEBUG] QuickDoc: external docs loaded: net.sergeych.lyngio.docs.FsBuiltinDocs.ensure() OK")
true
} catch (_: Throwable) {
// Seed a minimal plugin-local fallback so Path docs still work without lyngio
val seeded = try {
FsDocsFallback.ensureOnce()
} catch (_: Throwable) { false }
if (seeded) {
log.info("[LYNG_DEBUG] QuickDoc: external docs NOT found; seeded plugin fallback for lyng.io.fs")
} else {
log.info("[LYNG_DEBUG] QuickDoc: external docs NOT found (lyngio absent on classpath)")
}
seeded
}
}
override fun getCustomDocumentationElement(
editor: Editor,
file: PsiFile,
contextElement: PsiElement?,
targetOffset: Int
): PsiElement? {
// Ensure our provider gets a chance for Lyng files regardless of PSI sophistication
if (file.language != LyngLanguage) return null
return contextElement ?: file.findElementAt(targetOffset)
}
private fun renderDeclDoc(d: MiniDecl): String {
val title = when (d) {
is MiniFunDecl -> "function ${d.name}${signatureOf(d)}"
is MiniClassDecl -> "class ${d.name}"
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
val raw = d.doc?.raw
val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw)
val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>")
if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc!!))
return sb.toString()
}
private fun renderParamDoc(fn: MiniFunDecl, p: MiniParam): String {
val title = "parameter ${p.name}${typeOf(p.type)} in ${fn.name}${signatureOf(fn)}"
return "<div class='doc-title'>${htmlEscape(title)}</div>"
}
private fun renderMemberFunDoc(className: String, m: MiniMemberFunDecl): String {
val params = m.params.joinToString(", ") { p ->
val ts = typeOf(p.type)
if (ts.isNotBlank()) "${p.name}${ts}" else p.name
}
val ret = typeOf(m.returnType)
val staticStr = if (m.isStatic) "static " else ""
val title = "${staticStr}method $className.${m.name}(${params})${ret}"
val raw = m.doc?.raw
val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw)
val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>")
if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc!!))
return sb.toString()
}
private fun renderMemberValDoc(className: String, m: MiniMemberValDecl): String {
val ts = typeOf(m.type)
val kind = if (m.mutable) "var" else "val"
val staticStr = if (m.isStatic) "static " else ""
val title = "${staticStr}${kind} $className.${m.name}${ts}"
val raw = m.doc?.raw
val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw)
val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>")
if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc!!))
return sb.toString()
}
private fun typeOf(t: MiniTypeRef?): String = when (t) {
is MiniTypeName -> ": ${t.segments.joinToString(".") { it.name }}${if (t.nullable) "?" else ""}"
is MiniGenericType -> {
val base = typeOf(t.base).removePrefix(": ")
val args = t.args.joinToString(", ") { typeOf(it).removePrefix(": ") }
": ${base}<${args}>${if (t.nullable) "?" else ""}"
}
is MiniFunctionType -> ": (..) -> ..${if (t.nullable) "?" else ""}"
is MiniTypeVar -> ": ${t.name}${if (t.nullable) "?" else ""}"
null -> ""
}
private fun signatureOf(fn: MiniFunDecl): String {
val params = fn.params.joinToString(", ") { p ->
val ts = typeOf(p.type)
if (ts.isNotBlank()) "${p.name}${ts}" else p.name
}
val ret = typeOf(fn.returnType)
return "(${params})${ret}"
}
private fun htmlEscape(s: String): String = buildString(s.length) {
for (ch in s) append(
when (ch) {
'<' -> "&lt;"
'>' -> "&gt;"
'&' -> "&amp;"
'"' -> "&quot;"
else -> ch
}
)
}
private fun styledMarkdown(html: String): String {
// IntelliJ doc renderer sanitizes and may surface <style> content as text.
// Strip any style tags defensively and keep markup lean; rely on platform defaults.
val safe = stripStyleTags(html)
return """
<div class="lyng-doc-md" style="max-width:72ch; line-height:1.4; font-size:0.96em;">
$safe
</div>
""".trimIndent()
}
private fun stripStyleTags(src: String): String {
// Remove any <style>...</style> blocks (case-insensitive, dotall)
val styleRegex = Regex("(?is)<style[^>]*>.*?</style>")
return src.replace(styleRegex, "")
}
// --- Simple helpers to support overload selection and heuristics ---
/**
* If identifier at [rightAfterIdent] is followed by a call like `(a, b)`,
* return the argument count; otherwise return null. Nested parentheses are
* handled conservatively to skip commas inside lambdas/parentheses.
*/
private fun callArity(text: String, rightAfterIdent: Int): Int? {
var i = rightAfterIdent
// Skip whitespace
while (i < text.length && text[i].isWhitespace()) i++
if (i >= text.length || text[i] != '(') return null
i++
var depth = 0
var commas = 0
var hasToken = false
while (i < text.length) {
val ch = text[i]
when (ch) {
'(' -> { depth++; hasToken = true }
')' -> {
if (depth == 0) {
// Empty parentheses => arity 0 if no token and no commas
if (!hasToken && commas == 0) return 0
return commas + 1
} else depth--
}
',' -> if (depth == 0) { commas++; hasToken = false }
'\n' -> {}
else -> if (!ch.isWhitespace()) hasToken = true
}
i++
}
return null
}
private fun renderOverloads(name: String, overloads: List<MiniFunDecl>): String {
val sb = StringBuilder()
sb.append("<div class='doc-title'>Overloads for ").append(htmlEscape(name)).append("</div>")
sb.append("<ul>")
overloads.forEach { fn ->
sb.append("<li><code>")
.append(htmlEscape("fun ${fn.name}${signatureOf(fn)}"))
.append("</code>")
fn.doc?.summary?.let { sum -> sb.append("").append(htmlEscape(sum)) }
sb.append("</li>")
}
sb.append("</ul>")
return sb.toString()
}
private fun wordRangeAt(text: String, offset: Int): TextRange? {
if (text.isEmpty()) return null
var s = offset.coerceIn(0, text.length)
var e = s
while (s > 0 && isIdentChar(text[s - 1])) s--
while (e < text.length && isIdentChar(text[e])) e++
return if (e > s) TextRange(s, e) else null
}
private fun previousWordBefore(text: String, offset: Int): TextRange? {
// skip spaces and dots to the left, but stop after hitting a non-identifier or dot boundary
var i = (offset - 1).coerceAtLeast(0)
// first, move left past spaces
while (i > 0 && text[i].isWhitespace()) i--
// remember position to check for dot between words
val end = i + 1
// now find the start of the identifier
while (i >= 0 && isIdentChar(text[i])) i--
val start = (i + 1)
return if (start < end && start >= 0) TextRange(start, end) else null
}
private fun hasDotBetween(text: String, leftEnd: Int, rightStart: Int): Boolean {
val s = leftEnd.coerceAtLeast(0)
val e = rightStart.coerceAtMost(text.length)
if (e <= s) return false
for (i in s until e) if (text[i] == '.') return true
return false
}
private fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit()
// --- Helpers for inheritance-aware and heuristic member lookup ---
// Removed: member/class resolution helpers moved to lynglib DocLookupUtils for reuse
private fun findDotLeft(text: String, rightStart: Int): Int? {
var i = (rightStart - 1).coerceAtLeast(0)
while (i >= 0 && text[i].isWhitespace()) i--
while (i >= 0) {
val ch = text[i]
if (ch == '.') return i
if (ch == '\n') return null
i--
}
return null
}
private fun looksLikeListLiteralBefore(text: String, dotPos: Int): Boolean {
// Look left for a closing ']' possibly with spaces, then a matching '[' before a comma or assignment
var i = (dotPos - 1).coerceAtLeast(0)
while (i >= 0 && text[i].isWhitespace()) i--
if (i < 0 || text[i] != ']') return false
var depth = 0
i--
while (i >= 0) {
val ch = text[i]
when (ch) {
']' -> depth++
'[' -> if (depth == 0) return true else depth--
'\n' -> return false
}
i--
}
return false
}
// Removed: guessClassFromCallBefore moved to DocLookupUtils
}

View File

@ -0,0 +1,21 @@
/*
* 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.idea.docs
// 243: We do not use DocumentationTarget API here. Quick Docs works via
// AbstractDocumentationProvider registered as lang.documentationProvider.
internal object LyngDocumentationTargetsPlaceholder

View File

@ -0,0 +1,64 @@
/*
* 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.idea.docs
import com.vladsch.flexmark.ext.autolink.AutolinkExtension
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
import com.vladsch.flexmark.ext.tables.TablesExtension
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.data.MutableDataSet
/**
* Rich Markdown renderer for the IDEA Quick Docs using Flexmark.
*
* - Supports fenced code blocks (with language class "language-xyz")
* - Autolinks, tables, strikethrough
* - Converts soft breaks to <br/>
* - Tiny in-memory cache to avoid repeated parsing of the same doc blocks
*/
object MarkdownRenderer {
private val options = MutableDataSet().apply {
set(Parser.EXTENSIONS, listOf(
AutolinkExtension.create(),
TablesExtension.create(),
StrikethroughExtension.create(),
))
// Add CSS class for code fences like ```lyng → class="language-lyng"
set(HtmlRenderer.FENCED_CODE_LANGUAGE_CLASS_PREFIX, "language-")
// Treat single newlines as a space (soft break) so consecutive lines remain one paragraph.
// Real paragraph breaks require an empty line, hard breaks still work via Markdown (two spaces + \n).
set(HtmlRenderer.SOFT_BREAK, " ")
}
private val parser: Parser = Parser.builder(options).build()
private val renderer: HtmlRenderer = HtmlRenderer.builder(options).build()
private val cache = object : LinkedHashMap<String, String>(256, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean = size > 256
}
fun render(markdown: String): String {
// Fast path: cache
synchronized(cache) { cache[markdown]?.let { return it } }
val node = parser.parse(markdown)
val html = renderer.render(node)
synchronized(cache) { cache[markdown] = html }
return html
}
}

View File

@ -0,0 +1,36 @@
/*
* 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.idea.editor
import com.intellij.codeInsight.editorActions.BackspaceHandlerDelegate
import com.intellij.openapi.editor.Editor
import com.intellij.psi.PsiFile
/**
* Backspace handler (currently inactive, not registered). Minimal stub to keep build green.
* We will enable and implement smart behavior after API verification on the target IDE.
*/
class LyngBackspaceHandler : BackspaceHandlerDelegate() {
override fun beforeCharDeleted(c: Char, file: PsiFile, editor: Editor) {
// no-op
}
override fun charDeleted(c: Char, file: PsiFile, editor: Editor): Boolean {
// no-op; let default behavior stand
return false
}
}

View File

@ -0,0 +1,23 @@
/*
* 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.idea.editor
/**
* Disabled placeholder. We currently use the EditorPaste action handler (LyngPasteHandler)
* which works across IDE builds without relying on RawText API.
*/
class LyngCopyPastePreProcessorDisabled

View File

@ -0,0 +1,23 @@
/*
* 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.idea.editor
/**
* Placeholder for 2024.3+ RawText-based CopyPastePreProcessor.
* Not compiled against current SDK classpath; kept for future activation.
*/
class LyngCopyPastePreProcessor243Disabled

View File

@ -0,0 +1,204 @@
/*
* 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.idea.editor
import com.intellij.application.options.CodeStyle
import com.intellij.codeInsight.editorActions.enter.EnterHandlerDelegate
import com.intellij.codeInsight.editorActions.enter.EnterHandlerDelegate.Result
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.actionSystem.EditorActionHandler
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Ref
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
import com.intellij.psi.codeStyle.CodeStyleManager
import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage
class LyngEnterHandler : EnterHandlerDelegate {
private val log = Logger.getInstance(LyngEnterHandler::class.java)
override fun preprocessEnter(
file: PsiFile,
editor: Editor,
caretOffset: Ref<Int>,
caretAdvance: Ref<Int>,
dataContext: DataContext,
originalHandler: EditorActionHandler?
): Result {
if (file.language != LyngLanguage) return Result.Continue
if (log.isDebugEnabled) log.debug("[LyngEnter] preprocess in Lyng file at caretOffset=${caretOffset.get()}")
// Let the platform insert the newline; we will fix indentation in postProcessEnter.
return Result.Continue
}
override fun postProcessEnter(file: PsiFile, editor: Editor, dataContext: DataContext): Result {
if (file.language != LyngLanguage) return Result.Continue
val project = file.project
val doc = editor.document
val psiManager = PsiDocumentManager.getInstance(project)
psiManager.commitDocument(doc)
// Handle all carets independently to keep multi-caret scenarios sane
for (caret in editor.caretModel.allCarets) {
val line = doc.safeLineNumber(caret.offset)
if (line < 0) continue
if (log.isDebugEnabled) log.debug("[LyngEnter] postProcess at line=$line offset=${caret.offset}")
// Adjust previous '}' line if applicable, then indent the current line using our rules
adjustBraceAndCurrentIndent(project, file, doc, line)
// After indenting, ensure caret sits after the computed indent of this new line
moveCaretToIndentIfOnLeadingWs(editor, doc, file, line, caret)
}
return Result.Continue
}
private fun adjustBraceAndCurrentIndent(project: Project, file: PsiFile, doc: Document, currentLine: Int) {
val prevLine = currentLine - 1
if (prevLine >= 0) {
val prevText = doc.getLineText(prevLine)
val trimmed = prevText.trimStart()
// consider only code part before // comment
val code = trimmed.substringBefore("//").trim()
if (code == "}") {
// Previously we reindented the enclosed block on Enter after a lone '}'.
// Per new behavior, this action is now bound to typing '}' instead.
// Keep Enter flow limited to indenting the new line only.
}
}
// Adjust indent for the current (new) line
val currentStart = doc.getLineStartOffsetSafe(currentLine)
val csm = CodeStyleManager.getInstance(project)
csm.adjustLineIndent(file, currentStart)
// Fallback: if the platform didn't physically insert indentation, compute it from our formatter and apply
val lineStart = doc.getLineStartOffset(currentLine)
val lineEnd = doc.getLineEndOffset(currentLine)
val desiredIndent = computeDesiredIndent(project, doc, currentLine)
val firstNonWs = findFirstNonWs(doc, lineStart, lineEnd)
val currentIndentLen = firstNonWs - lineStart
if (desiredIndent.isNotEmpty() || currentIndentLen != 0) {
// Replace existing leading whitespace to match desired indent exactly
val replaceFrom = lineStart
val replaceTo = lineStart + currentIndentLen
if (doc.getText(TextRange(replaceFrom, replaceTo)) != desiredIndent) {
com.intellij.openapi.command.WriteCommandAction.runWriteCommandAction(project) {
doc.replaceString(replaceFrom, replaceTo, desiredIndent)
}
PsiDocumentManager.getInstance(project).commitDocument(doc)
if (log.isDebugEnabled) {
val dbg = desiredIndent.replace("\t", "\\t")
log.debug("[LyngEnter] rewrote current line leading WS to '$dbg' at line=$currentLine")
}
}
}
}
private fun reindentClosedBlockAroundBrace(project: Project, file: PsiFile, doc: Document, braceLine: Int) {
// Find the absolute index of the '}' at or before end of braceLine
val braceLineStart = doc.getLineStartOffset(braceLine)
val braceLineEnd = doc.getLineEndOffset(braceLine)
val rawBraceLine = doc.getText(TextRange(braceLineStart, braceLineEnd))
val codeBraceLine = rawBraceLine.substringBefore("//")
val closeRel = codeBraceLine.lastIndexOf('}')
if (closeRel < 0) return
val closeAbs = braceLineStart + closeRel
// Compute the enclosing block range in raw text (document char sequence)
val blockRange = net.sergeych.lyng.format.BraceUtils.findEnclosingBlockRange(doc.charsSequence, closeAbs, includeTrailingNewline = true)
?: return
val options = CodeStyle.getIndentOptions(project, doc)
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)),
)
// Run partial reindent over the slice and replace only if changed
val whole = doc.text
val updated = LyngFormatter.reindentRange(whole, blockRange, cfg, preserveBaseIndent = true)
if (updated != whole) {
WriteCommandAction.runWriteCommandAction(project) {
doc.replaceString(0, doc.textLength, updated)
}
PsiDocumentManager.getInstance(project).commitDocument(doc)
if (log.isDebugEnabled) log.debug("[LyngEnter] reindented closed block range=${'$'}blockRange")
}
}
private fun moveCaretToIndentIfOnLeadingWs(editor: Editor, doc: Document, file: PsiFile, line: Int, caret: Caret) {
if (line < 0 || line >= doc.lineCount) return
val lineStart = doc.getLineStartOffset(line)
val lineEnd = doc.getLineEndOffset(line)
val desiredIndent = computeDesiredIndent(file.project, doc, line)
val firstNonWs = (lineStart + desiredIndent.length).coerceAtMost(doc.textLength)
val caretOffset = caret.offset
// If caret is at beginning of the line or still within the leading whitespace, move it after indent
val target = firstNonWs.coerceIn(lineStart, lineEnd)
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 =
getLineNumber(offset.coerceIn(0, textLength))
private fun Document.getLineText(line: Int): String {
if (line < 0 || line >= lineCount) return ""
val start = getLineStartOffset(line)
val end = getLineEndOffset(line)
return getText(TextRange(start, end))
}
private fun Document.getLineStartOffsetSafe(line: Int): Int =
if (line < 0) 0 else getLineStartOffset(line.coerceAtMost(lineCount - 1).coerceAtLeast(0))
}

View File

@ -0,0 +1,22 @@
/*
* 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.idea.editor
/**
* Disabled placeholder: we rely on LyngPasteHandler (EditorPaste action handler).
*/
class LyngOnPasteProcessorDisabled

View File

@ -0,0 +1,166 @@
/*
* 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.idea.editor
import com.intellij.application.options.CodeStyle
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.actionSystem.EditorWriteActionHandler
import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.psi.PsiDocumentManager
import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
import java.awt.datatransfer.DataFlavor
/**
* Smart Paste using an editor action handler (avoids RawText API variance).
* Reindents pasted blocks when caret is in leading whitespace and the setting is enabled.
*/
class LyngPasteHandler : EditorWriteActionHandler(true) {
private val log = com.intellij.openapi.diagnostic.Logger.getInstance(LyngPasteHandler::class.java)
override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext) {
val project = editor.project
if (project == null) return
val psiDocMgr = PsiDocumentManager.getInstance(project)
val file = psiDocMgr.getPsiFile(editor.document)
if (file == null || file.language != LyngLanguage) {
pasteAsIs(editor)
return
}
val settings = LyngFormatterSettings.getInstance(project)
if (!settings.reindentPastedBlocks) {
pasteAsIs(editor)
return
}
val text = CopyPasteManager.getInstance().getContents<String>(DataFlavor.stringFlavor)
if (text == null) {
pasteAsIs(editor)
return
}
val caretModel = editor.caretModel
val effectiveCaret = caret ?: caretModel.currentCaret
val doc = editor.document
PsiDocumentManager.getInstance(project).commitDocument(doc)
// Paste the text as-is first, then compute the inserted range and reindent that slice
val options = CodeStyle.getIndentOptions(project, doc)
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)),
)
// Replace selection (if any) or insert at caret with original clipboard text
val selModel = editor.selectionModel
val replaceStart = if (selModel.hasSelection()) selModel.selectionStart else effectiveCaret.offset
val replaceEnd = if (selModel.hasSelection()) selModel.selectionEnd else effectiveCaret.offset
WriteCommandAction.runWriteCommandAction(project) {
log.info("[LyngPaste] handler invoked for Lyng file; setting ON=${settings.reindentPastedBlocks}")
// Step 1: paste as-is
val beforeLen = doc.textLength
doc.replaceString(replaceStart, replaceEnd, text)
psiDocMgr.commitDocument(doc)
// Step 2: compute the freshly inserted range robustly (account for line-separator normalization)
val insertedStart = replaceStart
val delta = doc.textLength - beforeLen + (replaceEnd - replaceStart)
val insertedEndExclusive = (insertedStart + delta).coerceIn(insertedStart, doc.textLength)
// Expand to full lines to let the formatter compute proper base/closing alignment
val lineStart = run {
var i = (insertedStart - 1).coerceAtLeast(0)
while (i >= 0 && doc.charsSequence[i] != '\n') i--
i + 1
}
var lineEndInclusive = run {
var i = insertedEndExclusive
val seq = doc.charsSequence
while (i < seq.length && seq[i] != '\n') i++
// include trailing newline if present
if (i < seq.length && seq[i] == '\n') i + 1 else i
}
// If the next non-whitespace char right after the insertion is a closing brace '}',
// include that brace line into the formatting slice for better block alignment.
run {
val seq = doc.charsSequence
var j = insertedEndExclusive
while (j < seq.length && (seq[j] == ' ' || seq[j] == '\t' || seq[j] == '\n' || seq[j] == '\r')) j++
if (j < seq.length && seq[j] == '}') {
var k = j
while (k < seq.length && seq[k] != '\n') k++
lineEndInclusive = if (k < seq.length && seq[k] == '\n') k + 1 else k
}
}
val fullTextBefore = doc.text
val expandedRange = (lineStart until lineEndInclusive)
log.info("[LyngPaste] inserted=[$insertedStart,$insertedEndExclusive) expanded=[$lineStart,$lineEndInclusive)")
val updatedFull = LyngFormatter.reindentRange(
fullTextBefore,
expandedRange,
cfg,
preserveBaseIndent = true,
baseIndentFrom = insertedStart
)
if (updatedFull != fullTextBefore) {
val delta = updatedFull.length - fullTextBefore.length
doc.replaceString(0, doc.textLength, updatedFull)
psiDocMgr.commitDocument(doc)
caretModel.moveToOffset((insertedEndExclusive + delta).coerceIn(0, doc.textLength))
log.info("[LyngPaste] applied reindent to expanded range")
} else {
// No changes after reindent — just move caret to end of the inserted text
caretModel.moveToOffset(insertedEndExclusive)
log.info("[LyngPaste] no changes after reindent")
}
selModel.removeSelection()
}
}
private fun pasteAsIs(editor: Editor) {
val text = CopyPasteManager.getInstance().getContents<String>(DataFlavor.stringFlavor) ?: return
pasteText(editor, text)
}
private fun pasteText(editor: Editor, text: String) {
val project = editor.project ?: return
val doc = editor.document
val caretModel = editor.caretModel
val selModel = editor.selectionModel
WriteCommandAction.runWriteCommandAction(project) {
val replaceStart = if (selModel.hasSelection()) selModel.selectionStart else caretModel.offset
val replaceEnd = if (selModel.hasSelection()) selModel.selectionEnd else caretModel.offset
doc.replaceString(replaceStart, replaceEnd, text)
PsiDocumentManager.getInstance(project).commitDocument(doc)
caretModel.moveToOffset(replaceStart + text.length)
selModel.removeSelection()
}
}
// no longer used
}

View File

@ -0,0 +1,83 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.editor
import com.intellij.application.options.CodeStyle
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
/**
* Helper for preparing reindented pasted text. EP wiring is deferred until API
* signature is finalized for the target IDE build.
*/
object LyngPastePreProcessor {
fun reindentForPaste(
project: Project,
editor: Editor,
file: PsiFile,
text: String
): String {
if (file.language != LyngLanguage) return text
val settings = LyngFormatterSettings.getInstance(project)
if (!settings.reindentPastedBlocks) return text
val doc = editor.document
PsiDocumentManager.getInstance(project).commitDocument(doc)
val options = CodeStyle.getIndentOptions(project, doc)
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)),
)
// Only apply smart paste when caret is in leading whitespace position of its line
val caret = editor.caretModel.currentCaret
val line = doc.getLineNumber(caret.offset.coerceIn(0, doc.textLength))
if (line < 0 || line >= doc.lineCount) return text
val lineStart = doc.getLineStartOffset(line)
val firstNonWs = firstNonWhitespace(doc, lineStart, doc.getLineEndOffset(line))
if (caret.offset > firstNonWs) return text
val baseIndent = doc.charsSequence.subSequence(lineStart, caret.offset).toString()
val reindented = LyngFormatter.reindent(text, cfg)
// Prefix each non-empty line with base indent to preserve surrounding indentation
val lines = reindented.split('\n')
val sb = StringBuilder(reindented.length + lines.size * baseIndent.length)
for ((idx, ln) in lines.withIndex()) {
if (ln.isNotEmpty()) sb.append(baseIndent).append(ln) else sb.append(ln)
if (idx < lines.lastIndex) sb.append('\n')
}
return sb.toString()
}
private fun firstNonWhitespace(doc: com.intellij.openapi.editor.Document, from: Int, to: Int): Int {
val seq = doc.charsSequence
var i = from
while (i < to) {
val ch = seq[i]
if (ch != ' ' && ch != '\t') break
i++
}
return i
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.editor
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
/**
* Smart Paste helper for Lyng. Not registered as EP yet to keep build stable across IDE SDKs.
* Use `processOnPasteIfEnabled` from a CopyPastePreProcessor adapter once API signature is finalized.
*/
object LyngSmartPastePreProcessorHelper {
fun processOnPasteIfEnabled(project: Project, file: PsiFile, editor: Editor, text: String): String {
if (file.language != LyngLanguage) return text
val settings = LyngFormatterSettings.getInstance(project)
if (!settings.reindentPastedBlocks) return text
PsiDocumentManager.getInstance(project).commitDocument(editor.document)
return LyngPastePreProcessor.reindentForPaste(project, editor, file, text)
}
}

View File

@ -0,0 +1,103 @@
/*
* 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.idea.editor
import com.intellij.application.options.CodeStyle
import com.intellij.codeInsight.editorActions.TypedHandlerDelegate
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
import com.intellij.psi.codeStyle.CodeStyleManager
import net.sergeych.lyng.format.BraceUtils
import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
class LyngTypedHandler : TypedHandlerDelegate() {
private val log = Logger.getInstance(LyngTypedHandler::class.java)
override fun charTyped(c: Char, project: Project, editor: Editor, file: PsiFile): Result {
if (file.language != LyngLanguage) return Result.CONTINUE
if (c != '}') return Result.CONTINUE
val doc = editor.document
PsiDocumentManager.getInstance(project).commitDocument(doc)
val offset = editor.caretModel.offset
val line = doc.getLineNumber((offset - 1).coerceAtLeast(0))
if (line < 0) return Result.CONTINUE
val rawLine = doc.getLineText(line)
val code = rawLine.substringBefore("//").trim()
if (code == "}") {
val settings = LyngFormatterSettings.getInstance(project)
if (settings.reindentClosedBlockOnEnter) {
reindentClosedBlockAroundBrace(project, file, doc, line)
}
// 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
}
private fun reindentClosedBlockAroundBrace(project: Project, file: PsiFile, doc: Document, braceLine: Int) {
val braceLineStart = doc.getLineStartOffset(braceLine)
val braceLineEnd = doc.getLineEndOffset(braceLine)
val rawBraceLine = doc.getText(TextRange(braceLineStart, braceLineEnd))
val codeBraceLine = rawBraceLine.substringBefore("//")
val closeRel = codeBraceLine.lastIndexOf('}')
if (closeRel < 0) return
val closeAbs = braceLineStart + closeRel
val blockRange = BraceUtils.findEnclosingBlockRange(
doc.charsSequence,
closeAbs,
includeTrailingNewline = true
) ?: return
val options = CodeStyle.getIndentOptions(project, doc)
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 whole = doc.text
val updated = LyngFormatter.reindentRange(whole, blockRange, cfg, preserveBaseIndent = true)
if (updated != whole) {
WriteCommandAction.runWriteCommandAction(project) {
doc.replaceString(0, doc.textLength, updated)
}
PsiDocumentManager.getInstance(project).commitDocument(doc)
if (log.isDebugEnabled) log.debug("[LyngTyped] reindented closed block range=$blockRange")
}
}
private fun Document.getLineText(line: Int): String {
if (line < 0 || line >= lineCount) return ""
val start = getLineStartOffset(line)
val end = getLineEndOffset(line)
return getText(TextRange(start, end))
}
}

View File

@ -0,0 +1,58 @@
/*
* 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.idea.format
import com.intellij.formatting.*
import com.intellij.lang.ASTNode
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.codeStyle.CodeStyleSettings
/**
* Minimal formatting model: enables Reformat Code to at least re-apply indentation via LineIndentProvider
* and normalize whitespace. We dont implement a full PSI-based tree yet; this block treats the whole file
* as a single formatting region and lets platform query line indents.
*/
class LyngFormattingModelBuilder : FormattingModelBuilder {
override fun createModel(element: PsiElement, settings: CodeStyleSettings): FormattingModel {
val file = element.containingFile
val rootBlock = LineBlocksRootBlock(file, settings)
return FormattingModelProvider.createFormattingModelForPsiFile(file, rootBlock, settings)
}
override fun getRangeAffectingIndent(file: PsiFile, offset: Int, elementAtOffset: ASTNode?): TextRange? = null
}
private class LineBlocksRootBlock(
private val file: PsiFile,
private val settings: CodeStyleSettings
) : Block {
override fun getTextRange(): TextRange = file.textRange
override fun getSubBlocks(): List<Block> = emptyList()
override fun getWrap(): Wrap? = null
override fun getIndent(): Indent? = Indent.getNoneIndent()
override fun getAlignment(): Alignment? = null
override fun getSpacing(child1: Block?, child2: Block): Spacing? = null
override fun getChildAttributes(newChildIndex: Int): ChildAttributes = ChildAttributes(Indent.getNoneIndent(), null)
override fun isIncomplete(): Boolean = false
override fun isLeaf(): Boolean = false
}
// Intentionally no sub-blocks/spacing: indentation is handled by PreFormatProcessor + LineIndentProvider

View File

@ -0,0 +1,103 @@
/*
* 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.idea.format
import com.intellij.application.options.CodeStyle
import com.intellij.lang.Language
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.codeStyle.CommonCodeStyleSettings.IndentOptions
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
/**
* Lightweight indentation provider for Lyng.
*
* Rules (heuristic, text-based):
* - New lines after an opening brace/paren increase indent level.
* - Lines starting with a closing brace/paren decrease indent level by one.
* - Keeps previous non-empty line's indent as baseline otherwise.
*/
class LyngLineIndentProvider : LineIndentProvider {
override fun getLineIndent(project: com.intellij.openapi.project.Project, editor: Editor, language: Language?, offset: Int): String? {
if (language != null && language != LyngLanguage) return null
val doc = editor.document
PsiDocumentManager.getInstance(project).commitDocument(doc)
val options = CodeStyle.getIndentOptions(project, doc)
val line = doc.getLineNumberSafe(offset)
val indent = computeDesiredIndentFromCore(doc, line, options)
return indent
}
override fun isSuitableFor(language: Language?): Boolean = language == null || language == LyngLanguage
private fun Document.getLineNumberSafe(offset: Int): Int =
getLineNumber(offset.coerceIn(0, textLength))
private fun Document.getLineText(line: Int): String {
if (line < 0 || line >= lineCount) return ""
val start = getLineStartOffset(line)
val end = getLineEndOffset(line)
return getText(TextRange(start, end))
}
private fun indentUnit(options: IndentOptions): String =
if (options.USE_TAB_CHARACTER) "\t" else " ".repeat(options.INDENT_SIZE.coerceAtLeast(1))
private fun indentOfLine(doc: Document, line: Int): String {
val s = doc.getLineText(line)
val i = s.indexOfFirst { !it.isWhitespace() }
return if (i <= 0) s.takeWhile { it == ' ' || it == '\t' } else s.substring(0, i)
}
private fun countIndentUnits(indent: String, options: IndentOptions): Int {
if (indent.isEmpty()) return 0
if (options.USE_TAB_CHARACTER) return indent.count { it == '\t' }
val size = options.INDENT_SIZE.coerceAtLeast(1)
var spaces = 0
for (ch in indent) spaces += if (ch == '\t') size else 1
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)
}
}

View File

@ -0,0 +1,22 @@
/*
* 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.idea.format
// Placeholder: we planned a post-format processor fallback, but the 2024.3 platform
// does not expose the older PostFormatProcessor API in our current dependency set.
// Reformat Code will use the registered lang.formatter + LineIndentProvider.
internal object LyngPostFormatProcessorPlaceholder

View File

@ -0,0 +1,159 @@
/*
* 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.idea.format
import com.intellij.application.options.CodeStyle
import com.intellij.lang.ASTNode
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.psi.codeStyle.CodeStyleManager
import com.intellij.psi.impl.source.codeStyle.PreFormatProcessor
import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage
/**
* Idempotent indentation fixer executed by Reformat Code before formatting.
* It walks all lines in the affected range and applies exact indentation using
* CodeStyleManager.adjustLineIndent(), which delegates to our LineIndentProvider.
*/
class LyngPreFormatProcessor : PreFormatProcessor {
override fun process(element: ASTNode, range: TextRange): TextRange {
val file = element.psi?.containingFile ?: return range
if (file.language != LyngLanguage) return range
val project: Project = file.project
val doc = file.viewProvider.document ?: return range
val psiDoc = com.intellij.psi.PsiDocumentManager.getInstance(project)
val options = CodeStyle.getIndentOptions(project, doc)
val settings = net.sergeych.lyng.idea.settings.LyngFormatterSettings.getInstance(project)
// When both spacing and wrapping are OFF, still fix indentation for the whole file to
// guarantee visible changes on Reformat Code.
val runFullFileIndent = !settings.enableSpacing && !settings.enableWrapping
// Maintain a working range and a modification flag to avoid stale offsets after replacements
var modified = false
fun fullRange(): TextRange = TextRange(0, doc.textLength)
var workingRange: TextRange = range.intersection(fullRange()) ?: fullRange()
val startLine = if (runFullFileIndent) 0 else doc.getLineNumber(workingRange.startOffset)
val endLine = if (runFullFileIndent) (doc.lineCount - 1).coerceAtLeast(0)
else doc.getLineNumber(workingRange.endOffset.coerceAtMost(doc.textLength))
fun codePart(s: String): String {
val idx = s.indexOf("//")
return if (idx >= 0) s.substring(0, idx) else s
}
// Pre-scan to compute balances up to startLine
var blockLevel = 0
var parenBalance = 0
var bracketBalance = 0
for (ln in 0 until startLine) {
val text = doc.getText(TextRange(doc.getLineStartOffset(ln), doc.getLineEndOffset(ln)))
for (ch in codePart(text)) when (ch) {
'{' -> blockLevel++
'}' -> if (blockLevel > 0) blockLevel--
'(' -> parenBalance++
')' -> if (parenBalance > 0) parenBalance--
'[' -> bracketBalance++
']' -> if (bracketBalance > 0) bracketBalance--
}
}
// Re-indent each line deterministically (idempotent). We avoid any content
// rewriting here to prevent long-running passes or re-entrant formatting.
for (line in startLine..endLine) {
val lineStart = doc.getLineStartOffset(line)
// adjustLineIndent delegates to our LineIndentProvider which computes
// indentation from scratch; this is safe and idempotent
CodeStyleManager.getInstance(project).adjustLineIndent(file, lineStart)
// After indentation, update block/paren/bracket balances using the current line text
val lineEnd = doc.getLineEndOffset(line)
val text = doc.getText(TextRange(lineStart, lineEnd))
val code = codePart(text)
for (ch in code) when (ch) {
'{' -> blockLevel++
'}' -> if (blockLevel > 0) blockLevel--
'(' -> parenBalance++
')' -> if (parenBalance > 0) parenBalance--
'[' -> bracketBalance++
']' -> if (bracketBalance > 0) bracketBalance--
}
}
// If both spacing and wrapping are OFF, explicitly reindent the text using core formatter to
// guarantee indentation is fixed even when the platform doesn't rewrite whitespace by itself.
if (!settings.enableSpacing && !settings.enableWrapping) {
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 full = fullRange()
val r = if (runFullFileIndent) full else workingRange.intersection(full) ?: full
val text = doc.getText(r)
val formatted = LyngFormatter.reindent(text, cfg)
if (formatted != text) {
doc.replaceString(r.startOffset, r.endOffset, formatted)
modified = true
psiDoc.commitDocument(doc)
workingRange = fullRange()
}
}
// Optionally apply spacing using the core formatter if enabled in settings (wrapping stays off)
if (settings.enableSpacing) {
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)),
applySpacing = true,
applyWrapping = false,
)
val safe = workingRange.intersection(fullRange()) ?: fullRange()
val text = doc.getText(safe)
val formatted = LyngFormatter.format(text, cfg)
if (formatted != text) {
doc.replaceString(safe.startOffset, safe.endOffset, formatted)
modified = true
psiDoc.commitDocument(doc)
workingRange = fullRange()
}
}
// Optionally apply wrapping (after spacing) when enabled
if (settings.enableWrapping) {
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)),
applySpacing = settings.enableSpacing,
applyWrapping = true,
)
val safe2 = workingRange.intersection(fullRange()) ?: fullRange()
val text2 = doc.getText(safe2)
val wrapped = LyngFormatter.format(text2, cfg)
if (wrapped != text2) {
doc.replaceString(safe2.startOffset, safe2.endOffset, wrapped)
modified = true
psiDoc.commitDocument(doc)
workingRange = fullRange()
}
}
// Return a safe range for the formatter to continue with, preventing stale offsets
return if (modified) fullRange() else (range.intersection(fullRange()) ?: fullRange())
}
}

View File

@ -0,0 +1,43 @@
/*
* 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.idea.grazie
import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer
import com.intellij.codeInsight.intention.IntentionAction
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiFile
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
/**
* Lightweight quick-fix that adds a word to the per-project Lyng dictionary.
*/
class AddToLyngDictionaryFix(private val word: String) : IntentionAction {
override fun getText(): String = "Add '$word' to Lyng dictionary"
override fun getFamilyName(): String = "Lyng Spelling"
override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = word.isNotBlank()
override fun startInWriteAction(): Boolean = true
override fun invoke(project: Project, editor: Editor?, file: PsiFile?) {
val settings = LyngFormatterSettings.getInstance(project)
val learned = settings.learnedWords
learned.add(word.lowercase())
settings.learnedWords = learned
// Restart daemon to refresh highlights
if (file != null) DaemonCodeAnalyzer.getInstance(project).restart(file)
}
}

View File

@ -0,0 +1,83 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.grazie
import com.intellij.openapi.diagnostic.Logger
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.zip.GZIPInputStream
/**
* Very simple English dictionary loader for offline suggestions on IC-243.
* It loads a word list from classpath resources. Supports plain text (one word per line)
* and gzipped text if the resource ends with .gz.
*/
object EnglishDictionary {
private val log = Logger.getInstance(EnglishDictionary::class.java)
@Volatile private var loaded = false
@Volatile private var words: Set<String> = emptySet()
/**
* Load dictionary from bundled resources (once).
* If multiple candidates exist, the first found is used.
*/
private fun ensureLoaded() {
if (loaded) return
synchronized(this) {
if (loaded) return
val candidates = listOf(
// preferred large bundles first (add en-basic.txt.gz ~3–5MB here)
"/dictionaries/en-basic.txt.gz",
"/dictionaries/en-large.txt.gz",
// plain text fallbacks
"/dictionaries/en-basic.txt",
"/dictionaries/en-large.txt",
)
val merged = HashSet<String>(128_000)
for (res in candidates) {
try {
val stream = javaClass.getResourceAsStream(res) ?: continue
val reader = if (res.endsWith(".gz"))
BufferedReader(InputStreamReader(GZIPInputStream(stream)))
else
BufferedReader(InputStreamReader(stream))
var loadedCount = 0
reader.useLines { seq -> seq.forEach { line ->
val w = line.trim()
if (w.isNotEmpty() && !w.startsWith("#")) { merged += w.lowercase(); loadedCount++ }
} }
log.info("EnglishDictionary: loaded $loadedCount words from $res (total=${merged.size})")
} catch (t: Throwable) {
log.info("EnglishDictionary: failed to load $res: ${t.javaClass.simpleName}: ${t.message}")
}
}
if (merged.isEmpty()) {
// Fallback minimal set
merged += setOf("comment","comments","error","errors","found","file","not","word","words","count","value","name","class","function","string")
log.info("EnglishDictionary: using minimal built-in set (${merged.size})")
}
words = merged
loaded = true
}
}
fun allWords(): Set<String> {
ensureLoaded()
return words
}
}

View File

@ -0,0 +1,608 @@
/*
* 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.
*
*/
/*
* Grazie-backed annotator for Lyng files.
*
* It consumes the MiniAst-driven LyngSpellIndex and, when Grazie is present,
* tries to run Grazie checks on the extracted TextContent. Results are painted
* as warnings in the editor. If the Grazie API changes, we use reflection and
* fail softly with INFO logs (no errors shown to users).
*/
package net.sergeych.lyng.idea.grazie
import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer
import com.intellij.grazie.text.TextContent
import com.intellij.grazie.text.TextContent.TextDomain
import com.intellij.ide.plugins.PluginManagerCore
import com.intellij.lang.annotation.AnnotationHolder
import com.intellij.lang.annotation.ExternalAnnotator
import com.intellij.lang.annotation.HighlightSeverity
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.colors.TextAttributesKey
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiFile
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
import net.sergeych.lyng.idea.spell.LyngSpellIndex
class LyngGrazieAnnotator : ExternalAnnotator<LyngGrazieAnnotator.Input, LyngGrazieAnnotator.Result>(), DumbAware {
private val log = Logger.getInstance(LyngGrazieAnnotator::class.java)
companion object {
// Cache GrammarChecker availability to avoid repeated reflection + noisy logs
@Volatile
private var grammarCheckerAvailable: Boolean? = null
@Volatile
private var grammarCheckerMissingLogged: Boolean = false
private fun isGrammarCheckerKnownMissing(): Boolean = (grammarCheckerAvailable == false)
private fun markGrammarCheckerMissingOnce(log: Logger, message: String) {
if (!grammarCheckerMissingLogged) {
// Downgrade to debug to reduce log noise across projects/sessions
log.debug(message)
grammarCheckerMissingLogged = true
}
}
private val RETRY_KEY: Key<Long> = Key.create("LYNG_GRAZIE_ANN_RETRY_STAMP")
}
data class Input(val modStamp: Long)
data class Finding(val range: TextRange, val message: String)
data class Result(val modStamp: Long, val findings: List<Finding>)
override fun collectInformation(file: PsiFile): Input? {
val doc: Document = file.viewProvider.document ?: return null
// Only require Grazie presence; index readiness is checked in apply with a retry.
val grazie = isGrazieInstalled()
if (!grazie) {
log.info("LyngGrazieAnnotator.collectInformation: skip (grazie=false) file='${file.name}'")
return null
}
log.info("LyngGrazieAnnotator.collectInformation: file='${file.name}', modStamp=${doc.modificationStamp}")
return Input(doc.modificationStamp)
}
override fun doAnnotate(collectedInfo: Input?): Result? {
// All heavy lifting is done in apply where we have the file context
return collectedInfo?.let { Result(it.modStamp, emptyList()) }
}
override fun apply(file: PsiFile, annotationResult: Result?, holder: AnnotationHolder) {
if (annotationResult == null || !isGrazieInstalled()) return
val doc = file.viewProvider.document ?: return
val idx = LyngSpellIndex.getUpToDate(file) ?: run {
log.info("LyngGrazieAnnotator.apply: index not ready for '${file.name}', scheduling one-shot restart")
scheduleOneShotRestart(file, annotationResult.modStamp)
return
}
val settings = LyngFormatterSettings.getInstance(file.project)
// Build TextContent fragments for comments/strings/identifiers according to settings
val fragments = mutableListOf<Pair<TextContent, TextRange>>()
try {
fun addFragments(ranges: List<TextRange>, domain: TextDomain) {
for (r in ranges) {
val local = rangeToTextContent(file, domain, r) ?: continue
fragments += local to r
}
}
// Comments always via COMMENTS
addFragments(idx.comments, TextDomain.COMMENTS)
// Strings: LITERALS if requested, else COMMENTS if fallback enabled
if (settings.spellCheckStringLiterals) {
val domain = if (settings.grazieTreatLiteralsAsComments) TextDomain.COMMENTS else TextDomain.LITERALS
addFragments(idx.strings, domain)
}
// Identifiers via COMMENTS to force painting in 243 unless user disables fallback
val idsDomain = if (settings.grazieTreatIdentifiersAsComments) TextDomain.COMMENTS else TextDomain.DOCUMENTATION
addFragments(idx.identifiers, idsDomain)
log.info(
"LyngGrazieAnnotator.apply: file='${file.name}', idxCounts ids=${idx.identifiers.size}, comments=${idx.comments.size}, strings=${idx.strings.size}, builtFragments=${fragments.size}"
)
} catch (e: Throwable) {
log.info("LyngGrazieAnnotator: failed to build TextContent fragments: ${e.javaClass.simpleName}: ${e.message}")
return
}
if (fragments.isEmpty()) return
val findings = mutableListOf<Finding>()
var totalReturned = 0
var chosenEntry: String? = null
for ((content, hostRange) in fragments) {
try {
val (typos, entryNote) = runGrazieChecksWithTracing(file, content)
if (chosenEntry == null) chosenEntry = entryNote
if (typos != null) {
totalReturned += typos.size
for (t in typos) {
val rel = extractRangeFromTypo(t) ?: continue
// Map relative range inside fragment to host file range
val abs = TextRange(hostRange.startOffset + rel.startOffset, hostRange.startOffset + rel.endOffset)
findings += Finding(abs, extractMessageFromTypo(t) ?: "Spelling/Grammar")
}
}
} catch (e: Throwable) {
log.info("LyngGrazieAnnotator: Grazie check failed: ${e.javaClass.simpleName}: ${e.message}")
}
}
log.info("LyngGrazieAnnotator.apply: used=${chosenEntry ?: "<none>"}, totalFindings=$totalReturned, painting=${findings.size}")
// IMPORTANT: Do NOT fallback to the tiny bundled vocabulary on modern IDEs.
// If Grazie/Natural Languages processing returned nothing, we simply exit here
// to avoid low‑quality results from the legacy dictionary.
if (findings.isEmpty()) return
for (f in findings) {
val ab = holder.newAnnotation(HighlightSeverity.INFORMATION, f.message).range(f.range)
applyTypoStyleIfRequested(file, ab)
ab.create()
}
}
private fun scheduleOneShotRestart(file: PsiFile, modStamp: Long) {
try {
val last = file.getUserData(RETRY_KEY)
if (last == modStamp) {
log.info("LyngGrazieAnnotator.restart: already retried for modStamp=$modStamp, skip")
return
}
file.putUserData(RETRY_KEY, modStamp)
ApplicationManager.getApplication().invokeLater({
try {
DaemonCodeAnalyzer.getInstance(file.project).restart(file)
log.info("LyngGrazieAnnotator.restart: daemon restarted for '${file.name}'")
} catch (e: Throwable) {
log.info("LyngGrazieAnnotator.restart failed: ${e.javaClass.simpleName}: ${e.message}")
}
})
} catch (e: Throwable) {
log.info("LyngGrazieAnnotator.scheduleOneShotRestart failed: ${e.javaClass.simpleName}: ${e.message}")
}
}
private fun isGrazieInstalled(): Boolean {
return PluginManagerCore.isPluginInstalled(com.intellij.openapi.extensions.PluginId.getId("com.intellij.grazie")) ||
PluginManagerCore.isPluginInstalled(com.intellij.openapi.extensions.PluginId.getId("tanvd.grazi"))
}
private fun rangeToTextContent(file: PsiFile, domain: TextDomain, range: TextRange): TextContent? {
// Build TextContent via reflection: prefer psiFragment(domain, element)
return try {
// Try to find an element that fully covers the target range
var element = file.findElementAt(range.startOffset) ?: return null
val start = range.startOffset
val end = range.endOffset
while (element.parent != null && (element.textRange.startOffset > start || element.textRange.endOffset < end)) {
element = element.parent
}
if (element.textRange.startOffset > start || element.textRange.endOffset < end) return null
// In many cases, the element may not span the whole range; use file + range via suitable factory
val methods = TextContent::class.java.methods.filter { it.name == "psiFragment" }
val byElementDomain = methods.firstOrNull { it.parameterCount == 2 && it.parameterTypes[0].name.endsWith("PsiElement") }
if (byElementDomain != null) {
@Suppress("UNCHECKED_CAST")
return (byElementDomain.invoke(null, element, domain) as? TextContent)?.let { tc ->
val relStart = start - element.textRange.startOffset
val relEnd = end - element.textRange.startOffset
if (relStart < 0 || relEnd > tc.length || relStart >= relEnd) return null
tc.subText(TextRange(relStart, relEnd))
}
}
val byDomainElement = methods.firstOrNull { it.parameterCount == 2 && it.parameterTypes[0].name.endsWith("TextDomain") }
if (byDomainElement != null) {
@Suppress("UNCHECKED_CAST")
return (byDomainElement.invoke(null, domain, element) as? TextContent)?.let { tc ->
val relStart = start - element.textRange.startOffset
val relEnd = end - element.textRange.startOffset
if (relStart < 0 || relEnd > tc.length || relStart >= relEnd) return null
tc.subText(TextRange(relStart, relEnd))
}
}
null
} catch (e: Throwable) {
log.info("LyngGrazieAnnotator: rangeToTextContent failed: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
private fun runGrazieChecksWithTracing(file: PsiFile, content: TextContent): Pair<Collection<Any>?, String?> {
// Try known entry points via reflection to avoid hard dependencies on Grazie internals
if (isGrammarCheckerKnownMissing()) return null to null
try {
// 1) Static GrammarChecker.check(TextContent)
val checkerCls = try {
Class.forName("com.intellij.grazie.grammar.GrammarChecker").also { grammarCheckerAvailable = true }
} catch (t: Throwable) {
grammarCheckerAvailable = false
markGrammarCheckerMissingOnce(log, "LyngGrazieAnnotator: GrammarChecker class not found: ${t.javaClass.simpleName}: ${t.message}")
null
}
if (checkerCls != null) {
// Diagnostic: list available 'check' methods once
runCatching {
val checks = checkerCls.methods.filter { it.name == "check" }
val sig = checks.joinToString { m ->
val params = m.parameterTypes.joinToString(prefix = "(", postfix = ")") { it.simpleName }
"${m.name}$params static=${java.lang.reflect.Modifier.isStatic(m.modifiers)}"
}
log.info("LyngGrazieAnnotator: GrammarChecker.check candidates: ${if (sig.isEmpty()) "<none>" else sig}")
}
checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") }?.let { m ->
@Suppress("UNCHECKED_CAST")
val res = m.invoke(null, content) as? Collection<Any>
return res to "GrammarChecker.check(TextContent) static"
}
// 2) GrammarChecker.getInstance().check(TextContent)
val getInstance = checkerCls.methods.firstOrNull { it.name == "getInstance" && it.parameterCount == 0 }
val inst = getInstance?.invoke(null)
if (inst != null) {
val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") }
if (m != null) {
@Suppress("UNCHECKED_CAST")
val res = m.invoke(inst, content) as? Collection<Any>
return res to "GrammarChecker.getInstance().check(TextContent)"
}
}
// 3) GrammarChecker.getDefault().check(TextContent)
val getDefault = checkerCls.methods.firstOrNull { it.name == "getDefault" && it.parameterCount == 0 }
val def = getDefault?.invoke(null)
if (def != null) {
val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") }
if (m != null) {
@Suppress("UNCHECKED_CAST")
val res = m.invoke(def, content) as? Collection<Any>
return res to "GrammarChecker.getDefault().check(TextContent)"
}
}
// 4) Service from project/application: GrammarChecker as a service
runCatching {
val app = com.intellij.openapi.application.ApplicationManager.getApplication()
val getService = app::class.java.methods.firstOrNull { it.name == "getService" && it.parameterCount == 1 }
val svc = getService?.invoke(app, checkerCls)
if (svc != null) {
val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") }
if (m != null) {
@Suppress("UNCHECKED_CAST")
val res = m.invoke(svc, content) as? Collection<Any>
if (res != null) return res to "Application.getService(GrammarChecker).check(TextContent)"
}
}
}
runCatching {
val getService = file.project::class.java.methods.firstOrNull { it.name == "getService" && it.parameterCount == 1 }
val svc = getService?.invoke(file.project, checkerCls)
if (svc != null) {
val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") }
if (m != null) {
@Suppress("UNCHECKED_CAST")
val res = m.invoke(svc, content) as? Collection<Any>
if (res != null) return res to "Project.getService(GrammarChecker).check(TextContent)"
}
}
}
}
// 5) Fallback: search any public method named check that accepts TextContent in any Grazie class (static)
val candidateClasses = listOf(
"com.intellij.grazie.grammar.GrammarChecker",
"com.intellij.grazie.grammar.GrammarRunner",
"com.intellij.grazie.grammar.Grammar" // historical names
)
for (cn in candidateClasses) {
val cls = try { Class.forName(cn) } catch (_: Throwable) { continue }
val m = cls.methods.firstOrNull { it.name == "check" && it.parameterTypes.any { p -> p.name.endsWith("TextContent") } }
if (m != null) {
val args = arrayOfNulls<Any>(m.parameterCount)
// place content to the first TextContent parameter; others left null (common defaults)
for (i in 0 until m.parameterCount) if (m.parameterTypes[i].name.endsWith("TextContent")) { args[i] = content; break }
@Suppress("UNCHECKED_CAST")
val res = m.invoke(null, *args) as? Collection<Any>
if (res != null) return res to "$cn.${m.name}(TextContent)"
}
}
// 6) Kotlin top-level function: GrammarCheckerKt.check(TextContent)
runCatching {
val kt = Class.forName("com.intellij.grazie.grammar.GrammarCheckerKt")
val m = kt.methods.firstOrNull { it.name == "check" && it.parameterTypes.any { p -> p.name.endsWith("TextContent") } }
if (m != null) {
val args = arrayOfNulls<Any>(m.parameterCount)
for (i in 0 until m.parameterCount) if (m.parameterTypes[i].name.endsWith("TextContent")) { args[i] = content; break }
@Suppress("UNCHECKED_CAST")
val res = m.invoke(null, *args) as? Collection<Any>
if (res != null) return res to "GrammarCheckerKt.check(TextContent)"
}
}
} catch (e: Throwable) {
log.info("LyngGrazieAnnotator: runGrazieChecks reflection failed: ${e.javaClass.simpleName}: ${e.message}")
}
return null to null
}
private fun extractRangeFromTypo(typo: Any): TextRange? {
// Try to get a relative range from returned Grazie issue/typo via common accessors
return try {
// Common getters
val m1 = typo.javaClass.methods.firstOrNull { it.name == "getRange" && it.parameterCount == 0 }
val r1 = if (m1 != null) m1.invoke(typo) else null
when (r1) {
is TextRange -> return r1
is IntRange -> return TextRange(r1.first, r1.last + 1)
}
val m2 = typo.javaClass.methods.firstOrNull { it.name == "getHighlightRange" && it.parameterCount == 0 }
val r2 = if (m2 != null) m2.invoke(typo) else null
when (r2) {
is TextRange -> return r2
is IntRange -> return TextRange(r2.first, r2.last + 1)
}
// Separate from/to ints
val fromM = typo.javaClass.methods.firstOrNull { it.name == "getFrom" && it.parameterCount == 0 && it.returnType == Int::class.javaPrimitiveType }
val toM = typo.javaClass.methods.firstOrNull { it.name == "getTo" && it.parameterCount == 0 && it.returnType == Int::class.javaPrimitiveType }
if (fromM != null && toM != null) {
val s = (fromM.invoke(typo) as? Int) ?: return null
val e = (toM.invoke(typo) as? Int) ?: return null
if (e > s) return TextRange(s, e)
}
null
} catch (_: Throwable) { null }
}
private fun extractMessageFromTypo(typo: Any): String? {
return try {
val m = typo.javaClass.methods.firstOrNull { it.name == "getMessage" && it.parameterCount == 0 }
(m?.invoke(typo) as? String)
} catch (_: Throwable) { null }
}
// Fallback that uses legacy SpellCheckerManager (if present) via reflection to validate words in fragments.
// Returns number of warnings painted.
private fun fallbackWithLegacySpellcheckerIfAvailable(
file: PsiFile,
fragments: List<Pair<TextContent, TextRange>>,
holder: AnnotationHolder
): Int {
return try {
val mgrCls = Class.forName("com.intellij.spellchecker.SpellCheckerManager")
val getInstance = mgrCls.methods.firstOrNull { it.name == "getInstance" && it.parameterCount == 1 }
val isCorrect = mgrCls.methods.firstOrNull { it.name == "isCorrect" && it.parameterCount == 1 && it.parameterTypes[0] == String::class.java }
if (getInstance == null || isCorrect == null) {
// No legacy spellchecker API available — fall back to naive painter
return naiveFallbackPaint(file, fragments, holder)
}
val mgr = getInstance.invoke(null, file.project)
if (mgr == null) {
// Legacy manager not present for this project — use naive fallback
return naiveFallbackPaint(file, fragments, holder)
}
var painted = 0
val docText = file.viewProvider.document?.text ?: return 0
val tokenRegex = Regex("[A-Za-z][A-Za-z0-9_']{2,}")
for ((content, hostRange) in fragments) {
val text = try { docText.substring(hostRange.startOffset, hostRange.endOffset) } catch (_: Throwable) { null } ?: continue
var seen = 0
var flagged = 0
for (m in tokenRegex.findAll(text)) {
val token = m.value
if ('%' in token) continue // skip printf fragments defensively
// Split snake_case and camelCase within the token
val parts = splitIdentifier(token)
for (part in parts) {
if (part.length <= 2) continue
if (isAllowedWord(part)) continue
// Quick allowlist for very common words to reduce noise if dictionaries differ
val ok = try { isCorrect.invoke(mgr, part) as? Boolean } catch (_: Throwable) { null }
if (ok == false) {
// Map part back to original token occurrence within this hostRange
val localStart = m.range.first + token.indexOf(part)
val localEnd = localStart + part.length
val abs = TextRange(hostRange.startOffset + localStart, hostRange.startOffset + localEnd)
paintTypoAnnotation(file, holder, abs, part)
painted++
flagged++
}
seen++
}
}
log.info("LyngGrazieAnnotator.fallback: fragment words=$seen, flagged=$flagged")
}
painted
} catch (_: Throwable) {
// If legacy manager is not available, fall back to a very naive heuristic (no external deps)
return naiveFallbackPaint(file, fragments, holder)
}
}
private fun naiveFallbackPaint(
file: PsiFile,
fragments: List<Pair<TextContent, TextRange>>,
holder: AnnotationHolder
): Int {
var painted = 0
val docText = file.viewProvider.document?.text
val tokenRegex = Regex("[A-Za-z][A-Za-z0-9_']{2,}")
val baseWords = setOf(
// small, common vocabulary to catch near-miss typos in typical code/comments
"comment","comments","error","errors","found","file","not","word","words","count","value","name","class","function","string"
)
for ((content, hostRange) in fragments) {
val text: String? = docText?.let { dt ->
try { dt.substring(hostRange.startOffset, hostRange.endOffset) } catch (_: Throwable) { null }
}
if (text.isNullOrBlank()) continue
var seen = 0
var flagged = 0
for (m in tokenRegex.findAll(text)) {
val token = m.value
if ('%' in token) continue
val parts = splitIdentifier(token)
for (part in parts) {
seen++
val lower = part.lowercase()
if (lower.length <= 2 || isAllowedWord(part)) continue
// Heuristic: no vowels OR 3 repeated chars OR ends with unlikely double consonants
val noVowel = lower.none { it in "aeiouy" }
val triple = Regex("(.)\\1\\1").containsMatchIn(lower)
val dblCons = Regex("[bcdfghjklmnpqrstvwxyz]{2}$").containsMatchIn(lower)
var looksWrong = noVowel || triple || dblCons
// Additional: low vowel ratio for length>=4
if (!looksWrong && lower.length >= 4) {
val vowels = lower.count { it in "aeiouy" }
val ratio = if (lower.isNotEmpty()) vowels.toDouble() / lower.length else 1.0
if (ratio < 0.25) looksWrong = true
}
// Additional: near-miss to a small base vocabulary (edit distance 1, or 2 for words >=6)
if (!looksWrong) {
for (bw in baseWords) {
val d = editDistance(lower, bw)
if (d == 1 || (d == 2 && lower.length >= 6)) { looksWrong = true; break }
}
}
if (looksWrong) {
val localStart = m.range.first + token.indexOf(part)
val localEnd = localStart + part.length
val abs = TextRange(hostRange.startOffset + localStart, hostRange.startOffset + localEnd)
paintTypoAnnotation(file, holder, abs, part)
painted++
flagged++
}
}
}
log.info("LyngGrazieAnnotator.fallback(naive): fragment words=$seen, flagged=$flagged")
}
return painted
}
private fun paintTypoAnnotation(file: PsiFile, holder: AnnotationHolder, range: TextRange, word: String) {
val settings = LyngFormatterSettings.getInstance(file.project)
val ab = holder.newAnnotation(HighlightSeverity.INFORMATION, "Possible typo")
.range(range)
applyTypoStyleIfRequested(file, ab)
if (settings.offerLyngTypoQuickFixes) {
// Offer lightweight fixes; for 243 provide Add-to-dictionary always
ab.withFix(net.sergeych.lyng.idea.grazie.AddToLyngDictionaryFix(word))
// Offer "Replace with…" candidates (top 7)
val cands = suggestReplacements(file, word).take(7)
for (c in cands) {
ab.withFix(net.sergeych.lyng.idea.grazie.ReplaceWordFix(range, word, c))
}
}
ab.create()
}
private fun applyTypoStyleIfRequested(file: PsiFile, ab: com.intellij.lang.annotation.AnnotationBuilder) {
val settings = LyngFormatterSettings.getInstance(file.project)
if (!settings.showTyposWithGreenUnderline) return
// Use the standard TYPO text attributes key used by the platform
val TYPO: TextAttributesKey = TextAttributesKey.createTextAttributesKey("TYPO")
try {
ab.textAttributes(TYPO)
} catch (_: Throwable) {
// some IDEs may not allow setting attributes on INFORMATION; ignore gracefully
}
}
private fun suggestReplacements(file: PsiFile, word: String): List<String> {
val lower = word.lowercase()
val fromProject = collectProjectWords(file)
val fromTech = TechDictionary.allWords()
val fromEnglish = EnglishDictionary.allWords()
// Merge with priority: project (p=0), tech (p=1), english (p=2)
val all = LinkedHashSet<String>()
all.addAll(fromProject)
all.addAll(fromTech)
all.addAll(fromEnglish)
data class Cand(val w: String, val d: Int, val p: Int)
val cands = ArrayList<Cand>(32)
for (w in all) {
if (w == lower) continue
if (kotlin.math.abs(w.length - lower.length) > 2) continue
val d = editDistance(lower, w)
val p = when {
w in fromProject -> 0
w in fromTech -> 1
else -> 2
}
cands += Cand(w, d, p)
}
cands.sortWith(compareBy<Cand> { it.d }.thenBy { it.p }.thenBy { it.w })
// Return a larger pool so callers can choose desired display count
return cands.take(16).map { it.w }
}
private fun collectProjectWords(file: PsiFile): Set<String> {
// Simple approach: use current file text; can be extended to project scanning later
val text = file.viewProvider.document?.text ?: return emptySet()
val out = LinkedHashSet<String>()
val tokenRegex = Regex("[A-Za-z][A-Za-z0-9_']{2,}")
for (m in tokenRegex.findAll(text)) {
val parts = splitIdentifier(m.value)
parts.forEach { out += it.lowercase() }
}
// Include learned words
val settings = LyngFormatterSettings.getInstance(file.project)
out.addAll(settings.learnedWords.map { it.lowercase() })
return out
}
private fun splitIdentifier(token: String): List<String> {
// Split on underscores and camelCase boundaries
val unders = token.split('_').filter { it.isNotBlank() }
val out = mutableListOf<String>()
val camelBoundary = Regex("(?<=[a-z])(?=[A-Z])")
for (u in unders) out += u.split(camelBoundary).filter { it.isNotBlank() }
return out
}
private fun isAllowedWord(w: String): Boolean {
val s = w.lowercase()
return s in setOf(
// common code words / language keywords to avoid noise
"val","var","fun","class","enum","type","import","package","return","if","else","when","while","for","try","catch","finally","true","false","null",
// very common English words
"the","and","or","not","with","from","into","this","that","file","found","count","name","value","object"
)
}
private fun editDistance(a: String, b: String): Int {
if (a == b) return 0
if (a.isEmpty()) return b.length
if (b.isEmpty()) return a.length
val dp = IntArray(b.length + 1) { it }
for (i in 1..a.length) {
var prev = dp[0]
dp[0] = i
for (j in 1..b.length) {
val temp = dp[j]
dp[j] = minOf(
dp[j] + 1, // deletion
dp[j - 1] + 1, // insertion
prev + if (a[i - 1] == b[j - 1]) 0 else 1 // substitution
)
prev = temp
}
}
return dp[b.length]
}
}

View File

@ -0,0 +1,139 @@
/*
* 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.idea.grazie
import com.intellij.grazie.grammar.strategy.GrammarCheckingStrategy
import com.intellij.grazie.grammar.strategy.GrammarCheckingStrategy.TextDomain
import com.intellij.ide.plugins.PluginManagerCore
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.extensions.PluginId
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiElement
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
import net.sergeych.lyng.idea.spell.LyngSpellIndex
/**
* Grazie/Natural Languages strategy for Lyng.
*
* - Comments: checked as natural language (TextDomain.COMMENTS)
* - String literals: optionally checked (setting), skipping printf-like specifiers via stealth ranges (TextDomain.LITERALS)
* - Identifiers (non-keywords): checked under TextDomain.CODE so "Process code" controls apply
* - Keywords: skipped
*/
class LyngGrazieStrategy : GrammarCheckingStrategy {
private val log = Logger.getInstance(LyngGrazieStrategy::class.java)
@Volatile private var loggedOnce = false
@Volatile private var loggedFirstMatch = false
private val seenTypes: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
private fun legacySpellcheckerInstalled(): Boolean =
PluginManagerCore.isPluginInstalled(PluginId.getId("com.intellij.spellchecker"))
// Regex for printf-style specifiers: %[flags][width][.precision][length]type
private val spec = Regex("%(?:[-+ #0]*(?:\\d+)?(?:\\.\\d+)?[a-zA-Z%])")
override fun isMyContextRoot(element: PsiElement): Boolean {
val type = element.node?.elementType
val settings = LyngFormatterSettings.getInstance(element.project)
val legacyPresent = legacySpellcheckerInstalled()
if (type != null && seenTypes.size < 10) {
val name = type.toString()
if (seenTypes.add(name)) {
log.info("LyngGrazieStrategy: saw PSI type=$name")
}
}
if (!loggedOnce) {
loggedOnce = true
log.info("LyngGrazieStrategy activated: legacyPresent=$legacyPresent, preferGrazieForCommentsAndLiterals=${settings.preferGrazieForCommentsAndLiterals}, spellCheckStringLiterals=${settings.spellCheckStringLiterals}, grazieChecksIdentifiers=${settings.grazieChecksIdentifiers}")
}
val file = element.containingFile ?: return false
val index = LyngSpellIndex.getUpToDate(file) ?: return false // Suspend until ready
// To ensure Grazie asks TextExtractor for all leafs, accept any Lyng element once index is ready.
// The extractor will decide per-range/domain what to actually provide.
if (!loggedFirstMatch) {
loggedFirstMatch = true
log.info("LyngGrazieStrategy: enabling Grazie on all Lyng elements (index ready)")
}
return true
}
override fun getContextRootTextDomain(root: PsiElement): TextDomain {
val type = root.node?.elementType
val settings = LyngFormatterSettings.getInstance(root.project)
val file = root.containingFile
val index = if (file != null) LyngSpellIndex.getUpToDate(file) else null
val r = root.textRange
fun overlaps(list: List<TextRange>): Boolean = r != null && list.any { it.intersects(r) }
return when (type) {
LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TextDomain.COMMENTS
LyngTokenTypes.STRING -> if (settings.grazieTreatLiteralsAsComments) TextDomain.COMMENTS else TextDomain.LITERALS
LyngTokenTypes.IDENTIFIER -> {
// For Grazie-only reliability in 243, route identifiers via COMMENTS when configured
if (settings.grazieTreatIdentifiersAsComments && index != null && r != null && overlaps(index.identifiers))
TextDomain.COMMENTS
else TextDomain.PLAIN_TEXT
}
else -> TextDomain.PLAIN_TEXT
}
}
// Note: do not override getLanguageSupport to keep compatibility with 243 API
override fun getStealthyRanges(root: PsiElement, text: CharSequence): java.util.LinkedHashSet<IntRange> {
val result = LinkedHashSet<IntRange>()
val type = root.node?.elementType
if (type == LyngTokenTypes.STRING) {
if (!shouldCheckLiterals(root)) {
// Hide the entire string when literals checking is disabled by settings
result += (0 until text.length)
return result
}
// Hide printf-like specifiers in strings
val (start, end) = stripQuotesBounds(text)
if (end > start) {
val content = text.subSequence(start, end)
for (m in spec.findAll(content)) {
val ms = start + m.range.first
val me = start + m.range.last
result += (ms..me)
}
if (result.isNotEmpty()) {
log.debug("LyngGrazieStrategy: hidden ${result.size} printf specifier ranges in string literal")
}
}
}
return result
}
override fun isEnabledByDefault(): Boolean = true
private fun shouldCheckLiterals(root: PsiElement): Boolean =
LyngFormatterSettings.getInstance(root.project).spellCheckStringLiterals
private fun stripQuotesBounds(text: CharSequence): Pair<Int, Int> {
if (text.length < 2) return 0 to text.length
val first = text.first()
val last = text.last()
return if ((first == '"' && last == '"') || (first == '\'' && last == '\''))
1 to (text.length - 1) else (0 to text.length)
}
}

View File

@ -0,0 +1,104 @@
/*
* 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.idea.grazie
import com.intellij.grazie.text.TextContent
import com.intellij.grazie.text.TextContent.TextDomain
import com.intellij.grazie.text.TextExtractor
import com.intellij.openapi.diagnostic.Logger
import com.intellij.psi.PsiElement
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
import net.sergeych.lyng.idea.spell.LyngSpellIndex
/**
* Provides Grazie with extractable text for Lyng PSI elements.
* We return text for identifiers, comments, and (optionally) string literals.
* printf-like specifiers are filtered by the Grammar strategy via stealth ranges.
*/
class LyngTextExtractor : TextExtractor() {
private val log = Logger.getInstance(LyngTextExtractor::class.java)
@Volatile private var loggedOnce = false
private val seen: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
override fun buildTextContent(element: PsiElement, allowedDomains: Set<TextDomain>): TextContent? {
val type = element.node?.elementType ?: return null
if (!loggedOnce) {
loggedOnce = true
log.info("LyngTextExtractor active; allowedDomains=${allowedDomains.joinToString()}")
}
val settings = LyngFormatterSettings.getInstance(element.project)
val file = element.containingFile
val index = if (file != null) LyngSpellIndex.getUpToDate(file) else null
val r = element.textRange
fun overlaps(list: List<com.intellij.openapi.util.TextRange>): Boolean = r != null && list.any { it.intersects(r) }
// Decide target domain by intersection with our MiniAst-driven index; prefer comments > strings > identifiers
var domain: TextDomain? = null
if (index != null && r != null) {
if (overlaps(index.comments)) domain = TextDomain.COMMENTS
else if (overlaps(index.strings) && settings.spellCheckStringLiterals) domain = TextDomain.LITERALS
else if (overlaps(index.identifiers)) domain = if (settings.grazieTreatIdentifiersAsComments) TextDomain.COMMENTS else TextDomain.DOCUMENTATION
} else {
// Fallback to token type if index is not ready (rare timing), mostly for comments
domain = when (type) {
LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TextDomain.COMMENTS
else -> null
}
}
if (domain == null) return null
// If literals aren't requested but fallback is enabled, route strings as COMMENTS
if (domain == TextDomain.LITERALS && !allowedDomains.contains(TextDomain.LITERALS) && settings.grazieTreatLiteralsAsComments) {
domain = TextDomain.COMMENTS
}
if (!allowedDomains.contains(domain)) {
if (seen.add("deny-${domain.name}")) {
log.info("LyngTextExtractor: domain ${domain.name} not in allowedDomains; skipping")
}
return null
}
return try {
// Try common factory names across versions
val methods = TextContent::class.java.methods.filter { it.name == "psiFragment" }
val built: TextContent? = when {
// Try psiFragment(PsiElement, TextDomain)
methods.any { it.parameterCount == 2 && it.parameterTypes[0].name.contains("PsiElement") } -> {
val m = methods.first { it.parameterCount == 2 && it.parameterTypes[0].name.contains("PsiElement") }
@Suppress("UNCHECKED_CAST")
(m.invoke(null, element, domain) as? TextContent)?.also {
if (seen.add("ok-${domain.name}")) log.info("LyngTextExtractor: provided ${domain.name} for ${type} via psiFragment(element, domain)")
}
}
// Try psiFragment(TextDomain, PsiElement)
methods.any { it.parameterCount == 2 && it.parameterTypes[0].name.endsWith("TextDomain") } -> {
val m = methods.first { it.parameterCount == 2 && it.parameterTypes[0].name.endsWith("TextDomain") }
@Suppress("UNCHECKED_CAST")
(m.invoke(null, domain, element) as? TextContent)?.also {
if (seen.add("ok-${domain.name}")) log.info("LyngTextExtractor: provided ${domain.name} for ${type} via psiFragment(domain, element)")
}
}
else -> null
}
built
} catch (e: Throwable) {
log.info("LyngTextExtractor: failed to build TextContent: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
}

View File

@ -0,0 +1,86 @@
/*
* 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.idea.grazie
import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer
import com.intellij.codeInsight.intention.IntentionAction
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.editor.CaretModel
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiFile
/**
* Lightweight quick-fix to replace a misspelled word (subrange) with a suggested alternative.
* Works without the legacy Spell Checker. The replacement is applied directly to the file text.
*/
class ReplaceWordFix(
private val range: TextRange,
private val original: String,
private val replacementRaw: String
) : IntentionAction {
override fun getText(): String = "Replace '$original' with '$replacementRaw'"
override fun getFamilyName(): String = "Lyng Spelling"
override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean =
editor != null && file != null && range.startOffset in 0..range.endOffset
override fun startInWriteAction(): Boolean = true
override fun invoke(project: Project, editor: Editor?, file: PsiFile?) {
if (editor == null) return
val doc: Document = editor.document
val safeRange = range.constrainTo(doc)
val current = doc.getText(safeRange)
// Preserve basic case style based on the original token
val replacement = adaptCaseStyle(current, replacementRaw)
WriteCommandAction.runWriteCommandAction(project, "Replace word", null, Runnable {
doc.replaceString(safeRange.startOffset, safeRange.endOffset, replacement)
}, file)
// Move caret to end of replacement for convenience
try {
val caret: CaretModel = editor.caretModel
caret.moveToOffset(safeRange.startOffset + replacement.length)
} catch (_: Throwable) {}
// Restart daemon to refresh highlights
if (file != null) DaemonCodeAnalyzer.getInstance(project).restart(file)
}
private fun TextRange.constrainTo(doc: Document): TextRange {
val start = startOffset.coerceIn(0, doc.textLength)
val end = endOffset.coerceIn(start, doc.textLength)
return TextRange(start, end)
}
private fun adaptCaseStyle(sample: String, suggestion: String): String {
if (suggestion.isEmpty()) return suggestion
return when {
sample.all { it.isUpperCase() } -> suggestion.uppercase()
// PascalCase / Capitalized single word
sample.firstOrNull()?.isUpperCase() == true && sample.drop(1).any { it.isLowerCase() } ->
suggestion.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
// snake_case -> lower
sample.contains('_') -> suggestion.lowercase()
// camelCase -> lower first
sample.firstOrNull()?.isLowerCase() == true && sample.any { it.isUpperCase() } ->
suggestion.replaceFirstChar { it.lowercase() }
else -> suggestion
}
}
}

View File

@ -0,0 +1,77 @@
/*
* 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.idea.grazie
import com.intellij.openapi.diagnostic.Logger
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.zip.GZIPInputStream
/**
* Lightweight technical/Lyng vocabulary dictionary.
* Loaded from classpath resources; supports .txt and .txt.gz. Merged with EnglishDictionary.
*/
object TechDictionary {
private val log = Logger.getInstance(TechDictionary::class.java)
@Volatile private var loaded = false
@Volatile private var words: Set<String> = emptySet()
private fun ensureLoaded() {
if (loaded) return
synchronized(this) {
if (loaded) return
val candidates = listOf(
"/dictionaries/tech-lyng.txt.gz",
"/dictionaries/tech-lyng.txt"
)
val merged = HashSet<String>(8_000)
for (res in candidates) {
try {
val stream = javaClass.getResourceAsStream(res) ?: continue
val reader = if (res.endsWith(".gz"))
BufferedReader(InputStreamReader(GZIPInputStream(stream)))
else
BufferedReader(InputStreamReader(stream))
var n = 0
reader.useLines { seq -> seq.forEach { line ->
val w = line.trim()
if (w.isNotEmpty() && !w.startsWith("#")) { merged += w.lowercase(); n++ }
} }
log.info("TechDictionary: loaded $n words from $res (total=${merged.size})")
} catch (t: Throwable) {
log.info("TechDictionary: failed to load $res: ${t.javaClass.simpleName}: ${t.message}")
}
}
if (merged.isEmpty()) {
merged += setOf(
// minimal Lyng/tech seeding to avoid empty dictionary
"lyng","miniast","binder","printf","specifier","specifiers","regex","token","tokens",
"identifier","identifiers","keyword","keywords","comment","comments","string","strings",
"literal","literals","formatting","formatter","grazie","typo","typos","dictionary","dictionaries"
)
log.info("TechDictionary: using minimal built-in set (${merged.size})")
}
words = merged
loaded = true
}
}
fun allWords(): Set<String> {
ensureLoaded()
return words
}
}

View File

@ -0,0 +1,72 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.highlight
import com.intellij.openapi.editor.colors.TextAttributesKey
import com.intellij.openapi.fileTypes.SyntaxHighlighter
import com.intellij.openapi.options.colors.AttributesDescriptor
import com.intellij.openapi.options.colors.ColorDescriptor
import com.intellij.openapi.options.colors.ColorSettingsPage
import javax.swing.Icon
class LyngColorSettingsPage : ColorSettingsPage {
override fun getDisplayName(): String = "Lyng"
override fun getIcon(): Icon? = null
override fun getHighlighter(): SyntaxHighlighter = LyngSyntaxHighlighter()
override fun getDemoText(): String = """
// Lyng demo
import lyng.stdlib as std
class Sample {
fun greet(name: String): String {
val message = "Hello, " + name
return message
}
}
var counter = 0
counter = counter + 1
""".trimIndent()
override fun getAdditionalHighlightingTagToDescriptorMap(): MutableMap<String, TextAttributesKey>? = null
override fun getAttributeDescriptors(): Array<AttributesDescriptor> = arrayOf(
AttributesDescriptor("Keyword", LyngHighlighterColors.KEYWORD),
AttributesDescriptor("String", LyngHighlighterColors.STRING),
AttributesDescriptor("Number", LyngHighlighterColors.NUMBER),
AttributesDescriptor("Line comment", LyngHighlighterColors.LINE_COMMENT),
AttributesDescriptor("Block comment", LyngHighlighterColors.BLOCK_COMMENT),
AttributesDescriptor("Identifier", LyngHighlighterColors.IDENTIFIER),
AttributesDescriptor("Punctuation", LyngHighlighterColors.PUNCT),
// Semantic
AttributesDescriptor("Annotation (semantic)", LyngHighlighterColors.ANNOTATION),
AttributesDescriptor("Variable (semantic)", LyngHighlighterColors.VARIABLE),
AttributesDescriptor("Value (semantic)", LyngHighlighterColors.VALUE),
AttributesDescriptor("Function (semantic)", LyngHighlighterColors.FUNCTION),
AttributesDescriptor("Function declaration (semantic)", LyngHighlighterColors.FUNCTION_DECLARATION),
AttributesDescriptor("Type (semantic)", LyngHighlighterColors.TYPE),
AttributesDescriptor("Namespace (semantic)", LyngHighlighterColors.NAMESPACE),
AttributesDescriptor("Parameter (semantic)", LyngHighlighterColors.PARAMETER),
AttributesDescriptor("Enum constant (semantic)", LyngHighlighterColors.ENUM_CONSTANT),
)
override fun getColorDescriptors(): Array<ColorDescriptor> = ColorDescriptor.EMPTY_ARRAY
}

View File

@ -0,0 +1,85 @@
/*
* 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.
*
*/
/*
* Text attribute keys for Lyng token and semantic highlighting
*/
package net.sergeych.lyng.idea.highlight
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.openapi.editor.colors.TextAttributesKey
object LyngHighlighterColors {
val KEYWORD: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_KEYWORD", DefaultLanguageHighlighterColors.KEYWORD
)
val STRING: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_STRING", DefaultLanguageHighlighterColors.STRING
)
val NUMBER: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_NUMBER", DefaultLanguageHighlighterColors.NUMBER
)
val LINE_COMMENT: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_LINE_COMMENT", DefaultLanguageHighlighterColors.LINE_COMMENT
)
val BLOCK_COMMENT: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_BLOCK_COMMENT", DefaultLanguageHighlighterColors.BLOCK_COMMENT
)
val IDENTIFIER: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_IDENTIFIER", DefaultLanguageHighlighterColors.IDENTIFIER
)
val PUNCT: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_PUNCT", DefaultLanguageHighlighterColors.DOT
)
// Semantic layer keys
val VARIABLE: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
// Use a distinctive default to ensure visibility across common themes.
// Users can still customize it separately from VALUE.
"LYNG_VARIABLE", DefaultLanguageHighlighterColors.INSTANCE_FIELD
)
val VALUE: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_VALUE", DefaultLanguageHighlighterColors.INSTANCE_FIELD
)
val FUNCTION: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
// Primary approach: make function calls as visible as declarations by default
// (users can still customize separately in the color scheme UI).
"LYNG_FUNCTION", DefaultLanguageHighlighterColors.FUNCTION_DECLARATION
)
val FUNCTION_DECLARATION: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_FUNCTION_DECLARATION", DefaultLanguageHighlighterColors.FUNCTION_DECLARATION
)
val TYPE: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_TYPE", DefaultLanguageHighlighterColors.CLASS_REFERENCE
)
val NAMESPACE: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_NAMESPACE", DefaultLanguageHighlighterColors.PREDEFINED_SYMBOL
)
val PARAMETER: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_PARAMETER", DefaultLanguageHighlighterColors.PARAMETER
)
// Annotations (@Something) — use Kotlin/Java metadata default color
val ANNOTATION: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_ANNOTATION", DefaultLanguageHighlighterColors.METADATA
)
// Enum constant (declaration or usage)
val ENUM_CONSTANT: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_ENUM_CONSTANT", DefaultLanguageHighlighterColors.STATIC_FIELD
)
}

View File

@ -0,0 +1,162 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
/*
* Minimal hand-written lexer for Lyng token highlighting
*/
package net.sergeych.lyng.idea.highlight
import com.intellij.lexer.LexerBase
import com.intellij.psi.tree.IElementType
class LyngLexer : LexerBase() {
private var buffer: CharSequence = ""
private var startOffset: Int = 0
private var endOffset: Int = 0
private var myTokenStart: Int = 0
private var myTokenEnd: Int = 0
private var myTokenType: IElementType? = null
private val keywords = setOf(
"fun", "val", "var", "class", "type", "import", "as",
"if", "else", "for", "while", "return", "true", "false", "null",
"when", "in", "is", "break", "continue", "try", "catch", "finally"
)
override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) {
this.buffer = buffer
this.startOffset = startOffset
this.endOffset = endOffset
this.myTokenStart = startOffset
this.myTokenEnd = startOffset
this.myTokenType = null
advance()
}
override fun getState(): Int = 0
override fun getTokenType(): IElementType? = myTokenType
override fun getTokenStart(): Int = myTokenStart
override fun getTokenEnd(): Int = myTokenEnd
override fun getBufferSequence(): CharSequence = buffer
override fun getBufferEnd(): Int = endOffset
override fun advance() {
if (myTokenEnd >= endOffset) {
myTokenType = null
return
}
var i = if (myTokenEnd == 0) startOffset else myTokenEnd
// Skip nothing; set start
myTokenStart = i
if (i >= endOffset) { myTokenType = null; return }
val ch = buffer[i]
// Whitespace
if (ch.isWhitespace()) {
i++
while (i < endOffset && buffer[i].isWhitespace()) i++
myTokenEnd = i
myTokenType = LyngTokenTypes.WHITESPACE
return
}
// Line comment //...
if (ch == '/' && i + 1 < endOffset && buffer[i + 1] == '/') {
i += 2
while (i < endOffset && buffer[i] != '\n' && buffer[i] != '\r') i++
myTokenEnd = i
myTokenType = LyngTokenTypes.LINE_COMMENT
return
}
// Block comment /* ... */
if (ch == '/' && i + 1 < endOffset && buffer[i + 1] == '*') {
i += 2
while (i + 1 < endOffset && !(buffer[i] == '*' && buffer[i + 1] == '/')) i++
if (i + 1 < endOffset) i += 2 // consume */
myTokenEnd = i
myTokenType = LyngTokenTypes.BLOCK_COMMENT
return
}
// String "..." with simple escape handling
if (ch == '"') {
i++
while (i < endOffset) {
val c = buffer[i]
if (c == '\\') { // escape
i += 2
continue
}
if (c == '"') { i++; break }
i++
}
myTokenEnd = i
myTokenType = LyngTokenTypes.STRING
return
}
// Number
if (ch.isDigit()) {
i++
var hasDot = false
while (i < endOffset) {
val c = buffer[i]
if (c.isDigit()) { i++; continue }
if (c == '.' && !hasDot) { hasDot = true; i++; continue }
break
}
myTokenEnd = i
myTokenType = LyngTokenTypes.NUMBER
return
}
// Identifier / keyword
if (ch.isIdentifierStart()) {
i++
while (i < endOffset && buffer[i].isIdentifierPart()) i++
myTokenEnd = i
val text = buffer.subSequence(myTokenStart, myTokenEnd).toString()
myTokenType = if (text in keywords) LyngTokenTypes.KEYWORD else LyngTokenTypes.IDENTIFIER
return
}
// Punctuation
if (isPunct(ch)) {
i++
myTokenEnd = i
myTokenType = LyngTokenTypes.PUNCT
return
}
// Fallback bad char
myTokenEnd = i + 1
myTokenType = LyngTokenTypes.BAD_CHAR
}
private fun Char.isWhitespace(): Boolean = this == ' ' || this == '\t' || this == '\n' || this == '\r' || this == '\u000C'
private fun Char.isDigit(): Boolean = this in '0'..'9'
private fun Char.isIdentifierStart(): Boolean = this == '_' || this.isLetter()
private fun Char.isIdentifierPart(): Boolean = this.isIdentifierStart() || this.isDigit()
private fun isPunct(c: Char): Boolean = c in setOf('(', ')', '{', '}', '[', ']', '.', ',', ';', ':', '+', '-', '*', '/', '%', '=', '<', '>', '!', '?', '&', '|', '^', '~')
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.highlight
import com.intellij.lexer.Lexer
import com.intellij.openapi.editor.colors.TextAttributesKey
import com.intellij.openapi.fileTypes.SyntaxHighlighter
import com.intellij.psi.tree.IElementType
class LyngSyntaxHighlighter : SyntaxHighlighter {
override fun getHighlightingLexer(): Lexer = LyngLexer()
override fun getTokenHighlights(tokenType: IElementType): Array<TextAttributesKey> = when (tokenType) {
LyngTokenTypes.KEYWORD -> pack(LyngHighlighterColors.KEYWORD)
LyngTokenTypes.STRING -> pack(LyngHighlighterColors.STRING)
LyngTokenTypes.NUMBER -> pack(LyngHighlighterColors.NUMBER)
LyngTokenTypes.LINE_COMMENT -> pack(LyngHighlighterColors.LINE_COMMENT)
LyngTokenTypes.BLOCK_COMMENT -> pack(LyngHighlighterColors.BLOCK_COMMENT)
LyngTokenTypes.PUNCT -> pack(LyngHighlighterColors.PUNCT)
LyngTokenTypes.IDENTIFIER -> pack(LyngHighlighterColors.IDENTIFIER)
else -> emptyArray()
}
private fun pack(vararg keys: TextAttributesKey): Array<TextAttributesKey> = arrayOf(*keys)
}

View File

@ -0,0 +1,25 @@
/*
* 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.idea.highlight
import com.intellij.openapi.fileTypes.SingleLazyInstanceSyntaxHighlighterFactory
import com.intellij.openapi.fileTypes.SyntaxHighlighter
class LyngSyntaxHighlighterFactory : SingleLazyInstanceSyntaxHighlighterFactory() {
override fun createHighlighter(): SyntaxHighlighter = LyngSyntaxHighlighter()
}

View File

@ -0,0 +1,34 @@
/*
* 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.idea.highlight
import com.intellij.psi.tree.IElementType
import net.sergeych.lyng.idea.LyngLanguage
class LyngTokenType(debugName: String) : IElementType(debugName, LyngLanguage)
object LyngTokenTypes {
val WHITESPACE = LyngTokenType("WHITESPACE")
val LINE_COMMENT = LyngTokenType("LINE_COMMENT")
val BLOCK_COMMENT = LyngTokenType("BLOCK_COMMENT")
val STRING = LyngTokenType("STRING")
val NUMBER = LyngTokenType("NUMBER")
val KEYWORD = LyngTokenType("KEYWORD")
val IDENTIFIER = LyngTokenType("IDENTIFIER")
val PUNCT = LyngTokenType("PUNCT")
val BAD_CHAR = LyngTokenType("BAD_CHAR")
}

View File

@ -0,0 +1,28 @@
/*
* 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.idea.psi
import com.intellij.extapi.psi.PsiFileBase
import com.intellij.openapi.fileTypes.FileType
import com.intellij.psi.FileViewProvider
import net.sergeych.lyng.idea.LyngFileType
import net.sergeych.lyng.idea.LyngLanguage
class LyngFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, LyngLanguage) {
override fun getFileType(): FileType = LyngFileType
override fun toString(): String = "Lyng File"
}

View File

@ -0,0 +1,67 @@
/*
* 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.idea.psi
import com.intellij.extapi.psi.ASTWrapperPsiElement
import com.intellij.lang.ASTNode
import com.intellij.lang.ParserDefinition
import com.intellij.lang.PsiBuilder
import com.intellij.lang.PsiParser
import com.intellij.lexer.Lexer
import com.intellij.openapi.project.Project
import com.intellij.psi.FileViewProvider
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.TokenType
import com.intellij.psi.tree.IFileElementType
import com.intellij.psi.tree.TokenSet
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.highlight.LyngLexer
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
class LyngParserDefinition : ParserDefinition {
companion object {
val FILE: IFileElementType = IFileElementType(LyngLanguage)
private val WHITE_SPACES: TokenSet = TokenSet.create(LyngTokenTypes.WHITESPACE, TokenType.WHITE_SPACE)
private val COMMENTS: TokenSet = TokenSet.create(LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT)
private val STRINGS: TokenSet = TokenSet.create(LyngTokenTypes.STRING)
}
override fun createLexer(project: Project?): Lexer = LyngLexer()
override fun createParser(project: Project?): PsiParser = PsiParser { root, builder ->
val mark: PsiBuilder.Marker = builder.mark()
while (!builder.eof()) builder.advanceLexer()
mark.done(root)
builder.treeBuilt
}
override fun getFileNodeType(): IFileElementType = FILE
override fun getWhitespaceTokens(): TokenSet = WHITE_SPACES
override fun getCommentTokens(): TokenSet = COMMENTS
override fun getStringLiteralElements(): TokenSet = STRINGS
override fun createElement(node: ASTNode): PsiElement = ASTWrapperPsiElement(node)
override fun createFile(viewProvider: FileViewProvider): PsiFile = LyngFile(viewProvider)
override fun spaceExistenceTypeBetweenTokens(left: ASTNode, right: ASTNode): ParserDefinition.SpaceRequirements =
ParserDefinition.SpaceRequirements.MAY
}

View File

@ -0,0 +1,129 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.settings
import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import com.intellij.openapi.project.Project
@Service(Service.Level.PROJECT)
@State(name = "LyngFormatterSettings", storages = [Storage("lyng_idea.xml")])
class LyngFormatterSettings(private val project: Project) : PersistentStateComponent<LyngFormatterSettings.State> {
data class State(
var enableSpacing: Boolean = false,
var enableWrapping: Boolean = false,
var reindentClosedBlockOnEnter: Boolean = true,
var reindentPastedBlocks: Boolean = true,
var normalizeBlockCommentIndent: Boolean = false,
var spellCheckStringLiterals: Boolean = true,
// When Grazie/Natural Languages is present, prefer it for comments and literals (avoid legacy duplicates)
var preferGrazieForCommentsAndLiterals: Boolean = true,
// When Grazie is available, also check identifiers via Grazie.
// Default OFF because Grazie typically doesn't flag code identifiers; legacy Spellchecker is better for code.
var grazieChecksIdentifiers: Boolean = false,
// Grazie-only fallback: treat identifiers as comments domain so Grazie applies spelling rules
var grazieTreatIdentifiersAsComments: Boolean = true,
// Grazie-only fallback: treat string literals as comments domain when LITERALS domain is not requested
var grazieTreatLiteralsAsComments: Boolean = true,
// Debug helper: show the exact ranges we feed to Grazie/legacy as weak warnings
var debugShowSpellFeed: Boolean = false,
// Visuals: render Lyng typos using the standard Typo green underline styling
var showTyposWithGreenUnderline: Boolean = true,
// Enable lightweight quick-fixes (Replace..., Add to dictionary) without legacy Spell Checker
var offerLyngTypoQuickFixes: Boolean = true,
// Per-project learned words (do not flag again)
var learnedWords: MutableSet<String> = mutableSetOf(),
// Experimental: enable Lyng autocompletion (can be disabled if needed)
var enableLyngCompletionExperimental: Boolean = true,
)
private var myState: State = State()
override fun getState(): State = myState
override fun loadState(state: State) {
myState = state
}
var enableSpacing: Boolean
get() = myState.enableSpacing
set(value) { myState.enableSpacing = value }
var enableWrapping: Boolean
get() = myState.enableWrapping
set(value) { myState.enableWrapping = value }
var reindentClosedBlockOnEnter: Boolean
get() = myState.reindentClosedBlockOnEnter
set(value) { myState.reindentClosedBlockOnEnter = value }
var reindentPastedBlocks: Boolean
get() = myState.reindentPastedBlocks
set(value) { myState.reindentPastedBlocks = value }
var normalizeBlockCommentIndent: Boolean
get() = myState.normalizeBlockCommentIndent
set(value) { myState.normalizeBlockCommentIndent = value }
var spellCheckStringLiterals: Boolean
get() = myState.spellCheckStringLiterals
set(value) { myState.spellCheckStringLiterals = value }
var preferGrazieForCommentsAndLiterals: Boolean
get() = myState.preferGrazieForCommentsAndLiterals
set(value) { myState.preferGrazieForCommentsAndLiterals = value }
var grazieChecksIdentifiers: Boolean
get() = myState.grazieChecksIdentifiers
set(value) { myState.grazieChecksIdentifiers = value }
var grazieTreatIdentifiersAsComments: Boolean
get() = myState.grazieTreatIdentifiersAsComments
set(value) { myState.grazieTreatIdentifiersAsComments = value }
var grazieTreatLiteralsAsComments: Boolean
get() = myState.grazieTreatLiteralsAsComments
set(value) { myState.grazieTreatLiteralsAsComments = value }
var debugShowSpellFeed: Boolean
get() = myState.debugShowSpellFeed
set(value) { myState.debugShowSpellFeed = value }
var showTyposWithGreenUnderline: Boolean
get() = myState.showTyposWithGreenUnderline
set(value) { myState.showTyposWithGreenUnderline = value }
var offerLyngTypoQuickFixes: Boolean
get() = myState.offerLyngTypoQuickFixes
set(value) { myState.offerLyngTypoQuickFixes = value }
var learnedWords: MutableSet<String>
get() = myState.learnedWords
set(value) { myState.learnedWords = value }
var enableLyngCompletionExperimental: Boolean
get() = myState.enableLyngCompletionExperimental
set(value) { myState.enableLyngCompletionExperimental = value }
companion object {
@JvmStatic
fun getInstance(project: Project): LyngFormatterSettings = project.getService(LyngFormatterSettings::class.java)
}
}

View File

@ -0,0 +1,149 @@
/*
* 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.idea.settings
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.project.Project
import javax.swing.BoxLayout
import javax.swing.JCheckBox
import javax.swing.JComponent
import javax.swing.JPanel
class LyngFormatterSettingsConfigurable(private val project: Project) : Configurable {
private var panel: JPanel? = null
private var spacingCb: JCheckBox? = null
private var wrappingCb: JCheckBox? = null
private var reindentClosedBlockCb: JCheckBox? = null
private var reindentPasteCb: JCheckBox? = null
private var normalizeBlockCommentIndentCb: JCheckBox? = null
private var spellCheckLiteralsCb: JCheckBox? = null
private var preferGrazieCommentsLiteralsCb: JCheckBox? = null
private var grazieChecksIdentifiersCb: JCheckBox? = null
private var grazieIdsAsCommentsCb: JCheckBox? = null
private var grazieLiteralsAsCommentsCb: JCheckBox? = null
private var debugShowSpellFeedCb: JCheckBox? = null
private var showTyposGreenCb: JCheckBox? = null
private var offerQuickFixesCb: JCheckBox? = null
private var enableCompletionCb: JCheckBox? = null
override fun getDisplayName(): String = "Lyng Formatter"
override fun createComponent(): JComponent {
val p = JPanel()
p.layout = BoxLayout(p, BoxLayout.Y_AXIS)
spacingCb = JCheckBox("Enable spacing normalization (commas/operators/colons/keyword parens)")
wrappingCb = JCheckBox("Enable line wrapping (120 cols) [experimental]")
reindentClosedBlockCb = JCheckBox("Reindent enclosed block on Enter after '}'")
reindentPasteCb = JCheckBox("Reindent pasted blocks (align pasted code to current indent)")
normalizeBlockCommentIndentCb = JCheckBox("Normalize block comment indentation [experimental]")
spellCheckLiteralsCb = JCheckBox("Spell check string literals (skip % specifiers like %s, %d, %-12s)")
preferGrazieCommentsLiteralsCb = JCheckBox("Prefer Natural Languages/Grazie for comments and string literals (avoid duplicates)")
grazieChecksIdentifiersCb = JCheckBox("Check identifiers via Natural Languages/Grazie when available")
grazieIdsAsCommentsCb = JCheckBox("Natural Languages/Grazie: treat identifiers as comments (forces spelling checks in 2024.3)")
grazieLiteralsAsCommentsCb = JCheckBox("Natural Languages/Grazie: treat string literals as comments when literals are not processed")
debugShowSpellFeedCb = JCheckBox("Debug: show spell-feed ranges (weak warnings)")
showTyposGreenCb = JCheckBox("Show Lyng typos with green underline (TYPO styling)")
offerQuickFixesCb = JCheckBox("Offer Lyng typo quick fixes (Replace…, Add to dictionary) without Spell Checker")
enableCompletionCb = JCheckBox("Enable Lyng autocompletion (experimental)")
// Tooltips / short help
spacingCb?.toolTipText = "Applies minimal, safe spacing (e.g., around commas/operators, control-flow parens)."
wrappingCb?.toolTipText = "Experimental: wrap long argument lists to keep lines under ~120 columns."
reindentClosedBlockCb?.toolTipText = "On Enter after a closing '}', reindent the just-closed {…} block using formatter rules."
reindentPasteCb?.toolTipText = "When caret is in leading whitespace, reindent the pasted text and align it to the caret's indent."
normalizeBlockCommentIndentCb?.toolTipText = "Experimental: normalize indentation inside /* … */ comments (code is not modified)."
preferGrazieCommentsLiteralsCb?.toolTipText = "When ON and Natural Languages/Grazie is installed, comments and string literals are checked by Grazie. Turn OFF to force legacy Spellchecker to check them."
grazieChecksIdentifiersCb?.toolTipText = "When ON and Natural Languages/Grazie is installed, identifiers (non-keywords) are checked by Grazie too."
grazieIdsAsCommentsCb?.toolTipText = "Grazie-only fallback: route identifiers as COMMENTS domain so Grazie applies spelling in 2024.3."
grazieLiteralsAsCommentsCb?.toolTipText = "Grazie-only fallback: when Grammar doesn't process literals, route strings as COMMENTS so they are checked."
debugShowSpellFeedCb?.toolTipText = "Show the exact ranges we feed to spellcheckers (ids/comments/strings) as weak warnings."
showTyposGreenCb?.toolTipText = "Render Lyng typos using the platform's green TYPO underline instead of generic warnings."
offerQuickFixesCb?.toolTipText = "Provide lightweight Replace… and Add to dictionary quick-fixes without requiring the legacy Spell Checker."
enableCompletionCb?.toolTipText = "Turn on/off the lightweight Lyng code completion (BASIC)."
p.add(spacingCb)
p.add(wrappingCb)
p.add(reindentClosedBlockCb)
p.add(reindentPasteCb)
p.add(normalizeBlockCommentIndentCb)
p.add(spellCheckLiteralsCb)
p.add(preferGrazieCommentsLiteralsCb)
p.add(grazieChecksIdentifiersCb)
p.add(grazieIdsAsCommentsCb)
p.add(grazieLiteralsAsCommentsCb)
p.add(debugShowSpellFeedCb)
p.add(showTyposGreenCb)
p.add(offerQuickFixesCb)
p.add(enableCompletionCb)
panel = p
reset()
return p
}
override fun isModified(): Boolean {
val s = LyngFormatterSettings.getInstance(project)
return spacingCb?.isSelected != s.enableSpacing ||
wrappingCb?.isSelected != s.enableWrapping ||
reindentClosedBlockCb?.isSelected != s.reindentClosedBlockOnEnter ||
reindentPasteCb?.isSelected != s.reindentPastedBlocks ||
normalizeBlockCommentIndentCb?.isSelected != s.normalizeBlockCommentIndent ||
spellCheckLiteralsCb?.isSelected != s.spellCheckStringLiterals ||
preferGrazieCommentsLiteralsCb?.isSelected != s.preferGrazieForCommentsAndLiterals ||
grazieChecksIdentifiersCb?.isSelected != s.grazieChecksIdentifiers ||
grazieIdsAsCommentsCb?.isSelected != s.grazieTreatIdentifiersAsComments ||
grazieLiteralsAsCommentsCb?.isSelected != s.grazieTreatLiteralsAsComments ||
debugShowSpellFeedCb?.isSelected != s.debugShowSpellFeed ||
showTyposGreenCb?.isSelected != s.showTyposWithGreenUnderline ||
offerQuickFixesCb?.isSelected != s.offerLyngTypoQuickFixes ||
enableCompletionCb?.isSelected != s.enableLyngCompletionExperimental
}
override fun apply() {
val s = LyngFormatterSettings.getInstance(project)
s.enableSpacing = spacingCb?.isSelected == true
s.enableWrapping = wrappingCb?.isSelected == true
s.reindentClosedBlockOnEnter = reindentClosedBlockCb?.isSelected == true
s.reindentPastedBlocks = reindentPasteCb?.isSelected == true
s.normalizeBlockCommentIndent = normalizeBlockCommentIndentCb?.isSelected == true
s.spellCheckStringLiterals = spellCheckLiteralsCb?.isSelected == true
s.preferGrazieForCommentsAndLiterals = preferGrazieCommentsLiteralsCb?.isSelected == true
s.grazieChecksIdentifiers = grazieChecksIdentifiersCb?.isSelected == true
s.grazieTreatIdentifiersAsComments = grazieIdsAsCommentsCb?.isSelected == true
s.grazieTreatLiteralsAsComments = grazieLiteralsAsCommentsCb?.isSelected == true
s.debugShowSpellFeed = debugShowSpellFeedCb?.isSelected == true
s.showTyposWithGreenUnderline = showTyposGreenCb?.isSelected == true
s.offerLyngTypoQuickFixes = offerQuickFixesCb?.isSelected == true
s.enableLyngCompletionExperimental = enableCompletionCb?.isSelected == true
}
override fun reset() {
val s = LyngFormatterSettings.getInstance(project)
spacingCb?.isSelected = s.enableSpacing
wrappingCb?.isSelected = s.enableWrapping
reindentClosedBlockCb?.isSelected = s.reindentClosedBlockOnEnter
reindentPasteCb?.isSelected = s.reindentPastedBlocks
normalizeBlockCommentIndentCb?.isSelected = s.normalizeBlockCommentIndent
spellCheckLiteralsCb?.isSelected = s.spellCheckStringLiterals
preferGrazieCommentsLiteralsCb?.isSelected = s.preferGrazieForCommentsAndLiterals
grazieChecksIdentifiersCb?.isSelected = s.grazieChecksIdentifiers
grazieIdsAsCommentsCb?.isSelected = s.grazieTreatIdentifiersAsComments
grazieLiteralsAsCommentsCb?.isSelected = s.grazieTreatLiteralsAsComments
debugShowSpellFeedCb?.isSelected = s.debugShowSpellFeed
showTyposGreenCb?.isSelected = s.showTyposWithGreenUnderline
offerQuickFixesCb?.isSelected = s.offerLyngTypoQuickFixes
enableCompletionCb?.isSelected = s.enableLyngCompletionExperimental
}
}

View File

@ -0,0 +1,50 @@
/*
* 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.idea.spell
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiFile
/**
* Per-file cached spellcheck index built from MiniAst-based highlighting and the lynglib highlighter.
* It exposes identifier, comment, and string literal ranges. Strategies should suspend until data is ready.
*/
object LyngSpellIndex {
private val LOG = Logger.getInstance(LyngSpellIndex::class.java)
data class Data(
val modStamp: Long,
val identifiers: List<TextRange>,
val comments: List<TextRange>,
val strings: List<TextRange>,
)
private val KEY: Key<Data> = Key.create("LYNG_SPELL_INDEX")
fun getUpToDate(file: PsiFile): Data? {
val doc = file.viewProvider.document ?: return null
val d = file.getUserData(KEY) ?: return null
return if (d.modStamp == doc.modificationStamp) d else null
}
fun store(file: PsiFile, data: Data) {
file.putUserData(KEY, data)
LOG.info("LyngSpellIndex built: ids=${data.identifiers.size}, comments=${data.comments.size}, strings=${data.strings.size}")
}
}

View File

@ -0,0 +1,155 @@
/*
* 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.idea.spell
// Avoid Tokenizers helper to keep compatibility; implement our own tokenizers
import com.intellij.ide.plugins.PluginManagerCore
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.extensions.PluginId
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiElement
import com.intellij.spellchecker.inspections.PlainTextSplitter
import com.intellij.spellchecker.tokenizer.SpellcheckingStrategy
import com.intellij.spellchecker.tokenizer.TokenConsumer
import com.intellij.spellchecker.tokenizer.Tokenizer
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
/**
* Spellchecking strategy for Lyng:
* - Identifiers: checked as identifiers
* - Comments: checked as plain text
* - Keywords: skipped
* - String literals: optional (controlled by settings), and we exclude printf-style format specifiers like
* %s, %d, %-12s, %0.2f, etc.
*/
class LyngSpellcheckingStrategy : SpellcheckingStrategy() {
private val log = Logger.getInstance(LyngSpellcheckingStrategy::class.java)
@Volatile private var loggedOnce = false
private fun grazieInstalled(): Boolean {
// Support both historical and bundled IDs
return PluginManagerCore.isPluginInstalled(PluginId.getId("com.intellij.grazie")) ||
PluginManagerCore.isPluginInstalled(PluginId.getId("tanvd.grazi"))
}
private fun grazieApiAvailable(): Boolean = try {
// If this class is absent (as in IC-243), third-party plugins can't run Grazie programmatically
Class.forName("com.intellij.grazie.grammar.GrammarChecker")
true
} catch (_: Throwable) { false }
override fun getTokenizer(element: PsiElement): Tokenizer<*> {
val hasGrazie = grazieInstalled()
val hasGrazieApi = grazieApiAvailable()
val settings = LyngFormatterSettings.getInstance(element.project)
if (!loggedOnce) {
loggedOnce = true
log.info("LyngSpellcheckingStrategy activated: hasGrazie=$hasGrazie, grazieApi=$hasGrazieApi, preferGrazieForCommentsAndLiterals=${settings.preferGrazieForCommentsAndLiterals}, spellCheckStringLiterals=${settings.spellCheckStringLiterals}, grazieChecksIdentifiers=${settings.grazieChecksIdentifiers}")
}
val file = element.containingFile ?: return EMPTY_TOKENIZER
val index = LyngSpellIndex.getUpToDate(file) ?: run {
// Suspend legacy spellcheck until MiniAst-based index is ready
return EMPTY_TOKENIZER
}
val elRange = element.textRange ?: return EMPTY_TOKENIZER
fun overlaps(list: List<TextRange>) = list.any { it.intersects(elRange) }
// Decide responsibility per settings
// If Grazie is present but its public API is not available (IC-243), do NOT delegate to it.
val preferGrazie = hasGrazie && hasGrazieApi && settings.preferGrazieForCommentsAndLiterals
val grazieIds = hasGrazie && hasGrazieApi && settings.grazieChecksIdentifiers
// Identifiers: only if range is within identifiers index and not delegated to Grazie
if (overlaps(index.identifiers) && !grazieIds) return IDENTIFIER_TOKENIZER
// Comments: only if not delegated to Grazie and overlapping indexed comments
if (!preferGrazie && overlaps(index.comments)) return COMMENT_TEXT_TOKENIZER
// Strings: only if not delegated to Grazie, literals checking enabled, and overlapping indexed strings
if (!preferGrazie && settings.spellCheckStringLiterals && overlaps(index.strings)) return STRING_WITH_PRINTF_EXCLUDES
return EMPTY_TOKENIZER
}
private object EMPTY_TOKENIZER : Tokenizer<PsiElement>() {
override fun tokenize(element: PsiElement, consumer: TokenConsumer) {}
}
private object IDENTIFIER_TOKENIZER : Tokenizer<PsiElement>() {
private val splitter = PlainTextSplitter.getInstance()
override fun tokenize(element: PsiElement, consumer: TokenConsumer) {
val text = element.text
if (text.isNullOrEmpty()) return
consumer.consumeToken(element, text, false, 0, TextRange(0, text.length), splitter)
}
}
private object COMMENT_TEXT_TOKENIZER : Tokenizer<PsiElement>() {
private val splitter = PlainTextSplitter.getInstance()
override fun tokenize(element: PsiElement, consumer: TokenConsumer) {
val text = element.text
if (text.isNullOrEmpty()) return
consumer.consumeToken(element, text, false, 0, TextRange(0, text.length), splitter)
}
}
private object STRING_WITH_PRINTF_EXCLUDES : Tokenizer<PsiElement>() {
private val splitter = PlainTextSplitter.getInstance()
// Regex for printf-style specifiers: %[flags][width][.precision][length]type
// This is intentionally permissive to skip common cases like %s, %d, %-12s, %08x, %.2f, %%
private val SPEC = Regex("%(?:[-+ #0]*(?:\\d+)?(?:\\.\\d+)?[a-zA-Z%])")
override fun tokenize(element: PsiElement, consumer: TokenConsumer) {
// Check project settings whether literals should be spell-checked
val settings = LyngFormatterSettings.getInstance(element.project)
if (!settings.spellCheckStringLiterals) return
val text = element.text
if (text.isEmpty()) return
// Try to strip surrounding quotes (simple lexer token for Lyng strings)
var startOffsetInElement = 0
var endOffsetInElement = text.length
if (text.length >= 2 && (text.first() == '"' && text.last() == '"' || text.first() == '\'' && text.last() == '\'')) {
startOffsetInElement = 1
endOffsetInElement = text.length - 1
}
if (endOffsetInElement <= startOffsetInElement) return
val content = text.substring(startOffsetInElement, endOffsetInElement)
var last = 0
for (m in SPEC.findAll(content)) {
val ms = m.range.first
val me = m.range.last + 1
if (ms > last) {
val range = TextRange(startOffsetInElement + last, startOffsetInElement + ms)
consumer.consumeToken(element, text, false, 0, range, splitter)
}
last = me
}
if (last < content.length) {
val range = TextRange(startOffsetInElement + last, startOffsetInElement + content.length)
consumer.consumeToken(element, text, false, 0, range, splitter)
}
}
}
}

View File

@ -0,0 +1,44 @@
/*
* Ensure external/bundled docs are registered in BuiltinDocRegistry
* so completion/quickdoc can resolve things like lyng.io.fs.Path.
*/
package net.sergeych.lyng.idea.util
import com.intellij.openapi.diagnostic.Logger
import net.sergeych.lyng.idea.docs.FsDocsFallback
object DocsBootstrap {
private val log = Logger.getInstance(DocsBootstrap::class.java)
@Volatile private var ensured = false
fun ensure() {
if (ensured) return
synchronized(this) {
if (ensured) return
val loaded = tryLoadExternal() || trySeedFallback()
if (loaded) ensured = true else ensured = true // mark done to avoid repeated attempts
}
}
private fun tryLoadExternal(): Boolean = try {
val cls = Class.forName("net.sergeych.lyngio.docs.FsBuiltinDocs")
val m = cls.getMethod("ensure")
m.invoke(null)
log.info("[LYNG_DEBUG] DocsBootstrap: external docs loaded: net.sergeych.lyngio.docs.FsBuiltinDocs.ensure() OK")
true
} catch (_: Throwable) {
false
}
private fun trySeedFallback(): Boolean = try {
val seeded = FsDocsFallback.ensureOnce()
if (seeded) {
log.info("[LYNG_DEBUG] DocsBootstrap: external docs not found; seeded plugin fallback for lyng.io.fs")
} else {
log.info("[LYNG_DEBUG] DocsBootstrap: external docs not found; no fallback seeded")
}
seeded
} catch (_: Throwable) {
false
}
}

View File

@ -0,0 +1,37 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.util
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Script
import net.sergeych.lyng.pacman.ImportProvider
/**
* Import provider for IDE background features that never throws on missing modules.
* It allows all imports and returns an empty [ModuleScope] for unknown packages so
* the compiler can still build MiniAst for Quick Docs / highlighting.
*/
class IdeLenientImportProvider private constructor(root: Scope) : ImportProvider(root) {
override suspend fun createModuleScope(pos: Pos, packageName: String): ModuleScope = ModuleScope(this, pos, packageName)
companion object {
/** Create a provider based on the default manager's root scope. */
fun create(): IdeLenientImportProvider = IdeLenientImportProvider(Script.defaultImportManager.rootScope)
}
}

View File

@ -0,0 +1,60 @@
/*
* Shared tiny, PSI-free text helpers for Lyng editor features (Quick Doc, Completion).
*/
package net.sergeych.lyng.idea.util
import com.intellij.openapi.util.TextRange
object TextCtx {
fun prefixAt(text: String, offset: Int): String {
val off = offset.coerceIn(0, text.length)
var i = (off - 1).coerceAtLeast(0)
while (i >= 0 && isIdentChar(text[i])) i--
val start = i + 1
return if (start in 0..text.length && start <= off) text.substring(start, off) else ""
}
fun wordRangeAt(text: String, offset: Int): TextRange? {
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) TextRange(s, e) else 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 previousWordBefore(text: String, offset: Int): String? {
var i = prevNonWs(text, (offset - 1).coerceAtLeast(0))
// Skip trailing identifier at caret if inside word
while (i >= 0 && isIdentChar(text[i])) i--
i = prevNonWs(text, i)
if (i < 0) return null
val end = i + 1
while (i >= 0 && isIdentChar(text[i])) i--
val start = i + 1
return if (start < end) text.substring(start, end) else null
}
fun hasDotBetween(text: String, start: Int, end: Int): Boolean {
if (start >= end) return false
val s = start.coerceAtLeast(0)
val e = end.coerceAtMost(text.length)
for (i in s until e) if (text[i] == '.') return true
return false
}
fun prevNonWs(text: String, start: Int): Int {
var i = start.coerceAtMost(text.length - 1)
while (i >= 0 && text[i].isWhitespace()) i--
return i
}
fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit()
}

View File

@ -0,0 +1,29 @@
<!--
~ Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
-->
<!--
Grazie (bundled Natural Languages) optional descriptor for Lyng. Loaded when plugin ID com.intellij.grazie is present.
-->
<idea-plugin>
<extensions defaultExtensionNs="com.intellij">
<grazie.grammar.strategy language="Lyng"
implementationClass="net.sergeych.lyng.idea.grazie.LyngGrazieStrategy"/>
<!-- Provide text extraction for Lyng PSI so Grazie (bundled Natural Languages) can check content -->
<grazie.textExtractor language="Lyng"
implementationClass="net.sergeych.lyng.idea.grazie.LyngTextExtractor"/>
</extensions>
</idea-plugin>

View File

@ -0,0 +1,30 @@
<!--
~ 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.
~
-->
<!--
Grazie Lite/Pro optional descriptor for Lyng. Loaded when plugin ID tanvd.grazi is present.
It delegates to the same strategy class as the bundled Natural Languages.
-->
<idea-plugin>
<extensions defaultExtensionNs="com.intellij">
<grazie.grammar.strategy language="Lyng"
implementationClass="net.sergeych.lyng.idea.grazie.LyngGrazieStrategy"/>
<!-- Provide text extraction for Lyng PSI so Grazie can actually check content -->
<grazie.textExtractor language="Lyng"
implementationClass="net.sergeych.lyng.idea.grazie.LyngTextExtractor"/>
</extensions>
</idea-plugin>

View File

@ -0,0 +1,28 @@
<!--
~ 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.
~
-->
<!--
Grazie (Lite/Pro/bundled) grammar checker extensions for Lyng. Loaded only when
the Grazie plugin is present. plugin.xml declares optional dependency with this config file.
-->
<idea-plugin>
<extensions defaultExtensionNs="com.intellij">
<!-- Register Lyng strategy for Grazie (Natural Languages). -->
<grazie.grammar.strategy language="Lyng"
implementationClass="net.sergeych.lyng.idea.grazie.LyngGrazieStrategy"/>
</extensions>
</idea-plugin>

View File

@ -0,0 +1,102 @@
<!--
~ 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.
~
-->
<idea-plugin>
<!-- Open-ended compatibility: 2024.3+ (build 243 and newer) -->
<idea-version since-build="243"/>
<id>net.sergeych.lyng.idea</id>
<name>Lyng Language Support</name>
<vendor email="real.sergeych@gmail.com">Sergey Chernov</vendor>
<description>
<![CDATA[
Basic Lyng language support: file type, syntax highlighting,
editing assistance (on Enter indent), reformatting code (indents and spaces),
and quick docs.
]]>
</description>
<depends>com.intellij.modules.platform</depends>
<!-- Needed for editor language features (syntax highlighting, etc.) -->
<depends>com.intellij.modules.lang</depends>
<!-- Spellchecker support (optional). If present, load spellchecker.xml which registers our strategy. -->
<depends optional="true" config-file="spellchecker.xml">com.intellij.spellchecker</depends>
<!-- Grazie (Lite/Pro) grammar checker support (optional). If present, load grazie-lite.xml -->
<depends optional="true" config-file="grazie-lite.xml">tanvd.grazi</depends>
<!-- Some IDE builds may expose Grazie/Natural Languages under another ID; load grazie-bundled.xml -->
<depends optional="true" config-file="grazie-bundled.xml">com.intellij.grazie</depends>
<extensions defaultExtensionNs="com.intellij">
<!-- Language and file type -->
<fileType implementationClass="net.sergeych.lyng.idea.LyngFileType" name="Lyng" extensions="lyng" fieldName="INSTANCE" language="Lyng"/>
<!-- Minimal parser/PSI to fully wire editor services for the language -->
<lang.parserDefinition language="Lyng" implementationClass="net.sergeych.lyng.idea.psi.LyngParserDefinition"/>
<!-- Syntax highlighter: register under language EP -->
<lang.syntaxHighlighterFactory language="Lyng" implementationClass="net.sergeych.lyng.idea.highlight.LyngSyntaxHighlighterFactory"/>
<!-- Color settings page -->
<colorSettingsPage implementation="net.sergeych.lyng.idea.highlight.LyngColorSettingsPage"/>
<!-- External annotator for semantic highlighting -->
<externalAnnotator language="Lyng" implementationClass="net.sergeych.lyng.idea.annotators.LyngExternalAnnotator"/>
<!-- Grazie-backed spell/grammar annotator (runs only when Grazie is installed) -->
<externalAnnotator language="Lyng" implementationClass="net.sergeych.lyng.idea.grazie.LyngGrazieAnnotator"/>
<!-- Quick documentation provider bound to Lyng language -->
<lang.documentationProvider language="Lyng" implementationClass="net.sergeych.lyng.idea.docs.LyngDocumentationProvider"/>
<!-- Basic code completion (MVP) -->
<completion.contributor language="Lyng" implementationClass="net.sergeych.lyng.idea.completion.LyngCompletionContributor"/>
<!-- Comment toggling support -->
<lang.commenter language="Lyng" implementationClass="net.sergeych.lyng.idea.comment.LyngCommenter"/>
<!-- Indentation provider to improve auto-indent and reformat behavior -->
<lineIndentProvider implementation="net.sergeych.lyng.idea.format.LyngLineIndentProvider"/>
<!-- Formatting model so Reformat Code (Ctrl+Alt+L) applies indentation across the file -->
<lang.formatter language="Lyng" implementationClass="net.sergeych.lyng.idea.format.LyngFormattingModelBuilder"/>
<!-- Ensure idempotent line indentation before formatting using our LineIndentProvider -->
<preFormatProcessor implementation="net.sergeych.lyng.idea.format.LyngPreFormatProcessor"/>
<!-- Settings UI -->
<projectConfigurable instance="net.sergeych.lyng.idea.settings.LyngFormatterSettingsConfigurable"
displayName="Lyng Formatter"/>
<!-- Smart Enter handler -->
<enterHandlerDelegate implementation="net.sergeych.lyng.idea.editor.LyngEnterHandler"/>
<!-- Trigger reindent of enclosed block when typing a standalone '}' -->
<typedHandler implementation="net.sergeych.lyng.idea.editor.LyngTypedHandler"/>
<!-- Smart Backspace handler (deferred) -->
<!-- <backspaceHandlerDelegate implementation="net.sergeych.lyng.idea.editor.LyngBackspaceHandler"/> -->
<!-- Smart Paste via action handler (ensure our handler participates first) -->
<editorActionHandler action="EditorPaste" order="first" implementationClass="net.sergeych.lyng.idea.editor.LyngPasteHandler"/>
<!-- If targeting SDKs with stable RawText API, the EP below can be enabled instead: -->
<!-- <copyPastePreProcessor implementation="net.sergeych.lyng.idea.editor.LyngCopyPastePreProcessor"/> -->
</extensions>
<actions/>
</idea-plugin>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
- Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
-
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Lyng favicon">
<style>
:root { color-scheme: light dark; }
.mark { fill: currentColor; }
.math { font-family: 'STIX Two Math', 'Cambria Math', 'Times New Roman', serif; }
</style>
<g class="mark math">
<!-- Keep favicon legible: lambda with superscript y only -->
<text x="3" y="17" font-size="19" font-weight="700"
color="#009000" stroke="#002000" stroke-width="0.3">λ</text>
<text x="11.2" y="4" font-size="16" color="#009000" stroke="#002000"
stroke-width="0.1">y</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,29 @@
<!--
~ Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
-->
<!--
Spellchecker extensions for Lyng are registered here and loaded only when
the com.intellij.spellchecker plugin is available. The dependency is marked
optional in plugin.xml with config-file="spellchecker.xml".
-->
<idea-plugin>
<extensions defaultExtensionNs="com.intellij">
<!-- Spellchecker strategy: identifiers + comments; literals configurable, skipping printf-like specs -->
<spellchecker.support language="Lyng"
implementationClass="net.sergeych.lyng.idea.spell.LyngSpellcheckingStrategy"/>
</extensions>
</idea-plugin>

View File

@ -0,0 +1,466 @@
the
be
to
of
and
a
in
that
have
I
it
for
not
on
with
he
as
you
do
at
this
but
his
by
from
they
we
say
her
she
or
an
will
my
one
all
would
there
their
what
so
up
out
if
about
who
get
which
go
me
when
make
can
like
time
no
just
him
know
take
people
into
year
your
good
some
could
them
see
other
than
then
now
look
only
come
its
over
think
also
back
after
use
two
how
our
work
first
well
way
even
new
want
because
any
these
give
day
most
us
is
are
was
were
been
being
does
did
done
has
had
having
may
might
must
shall
should
ought
need
used
here
therefore
where
why
while
until
since
before
afterward
between
among
without
within
through
across
against
toward
upon
above
below
under
around
near
far
early
late
often
always
never
seldom
sometimes
usually
really
very
quite
rather
almost
already
again
still
yet
soon
today
tomorrow
yesterday
number
string
boolean
true
false
null
none
file
files
path
paths
line
lines
word
words
count
value
values
name
names
title
text
message
error
errors
warning
warnings
info
information
debug
trace
format
printf
specifier
specifiers
pattern
patterns
match
matches
regex
version
versions
module
modules
package
packages
import
imports
export
exports
class
classes
object
objects
function
functions
method
methods
parameter
parameters
argument
arguments
variable
variables
constant
constants
type
types
generic
generics
map
maps
list
lists
array
arrays
set
sets
queue
stack
graph
tree
node
nodes
edge
edges
pair
pairs
key
keys
value
values
index
indices
length
size
empty
contains
equals
compare
greater
less
minimum
maximum
average
sum
total
random
round
floor
ceil
sin
cos
tan
sqrt
abs
min
max
read
write
open
close
append
create
delete
remove
update
save
load
start
stop
run
execute
return
break
continue
try
catch
finally
throw
throws
if
else
when
while
for
loop
range
case
switch
default
optional
required
enable
disable
enabled
disabled
visible
hidden
public
private
protected
internal
external
inline
override
abstract
sealed
open
final
static
const
lazy
late
init
initialize
configuration
settings
option
options
preference
preferences
project
projects
module
modules
build
builds
compile
compiles
compiler
test
tests
testing
assert
assertion
result
results
success
failure
status
state
context
scope
scopes
token
tokens
identifier
identifiers
keyword
keywords
comment
comments
string
strings
literal
literals
formatting
formatter
spell
spelling
dictionary
dictionaries
language
languages
natural
grazie
typo
typos
suggest
suggestion
suggestions
replace
replacement
replacements
learn
learned
learns
filter
filters
exclude
excludes
include
includes
bundle
bundled
resource
resources
gzipped
plain
text
editor
editors
inspection
inspections
highlight
highlighting
underline
underlines
style
styles
range
ranges
offset
offsets
position
positions
apply
applies
provides
present
absent
available
unavailable
version
build
platform
ide
intellij
plugin
plugins
sandbox
gradle
kotlin
java
linux
macos
windows
unix
system
systems
support
supports
compatible
compatibility
fallback
native
automatic
autoswitch
switch
switches

View File

@ -0,0 +1,282 @@
# Lyng/tech vocabulary – one word per line, lowercase
lyng
miniast
binder
printf
specifier
specifiers
regex
regexp
token
tokens
lexer
parser
syntax
semantic
highlight
highlighting
underline
typo
typos
dictionary
dictionaries
grazie
natural
languages
inspection
inspections
annotation
annotator
annotations
quickfix
quickfixes
intention
intentions
replacement
replacements
identifier
identifiers
keyword
keywords
comment
comments
string
strings
literal
literals
formatting
formatter
splitter
camelcase
snakecase
pascalcase
uppercase
lowercase
titlecase
case
cases
project
module
modules
resource
resources
bundle
bundled
gzipped
plaintext
text
range
ranges
offset
offsets
position
positions
apply
applies
runtime
compile
build
artifact
artifacts
plugin
plugins
intellij
idea
sandbox
gradle
kotlin
java
jvm
coroutines
suspend
scope
scopes
context
contexts
tokenizer
tokenizers
spell
spelling
spellcheck
spellchecker
fallback
native
autoswitch
switch
switching
enable
disable
enabled
disabled
setting
settings
preference
preferences
editor
filetype
filetypes
language
languages
psi
psielement
psifile
textcontent
textdomain
stealth
stealthy
printfspec
format
formats
pattern
patterns
match
matches
group
groups
node
nodes
tree
graph
edge
edges
pair
pairs
map
maps
list
lists
array
arrays
set
sets
queue
stack
index
indices
length
size
empty
contains
equals
compare
greater
less
minimum
maximum
average
sum
total
random
round
floor
ceil
sin
cos
tan
sqrt
abs
min
max
read
write
open
close
append
create
delete
remove
update
save
load
start
stop
run
execute
return
break
continue
try
catch
finally
throw
throws
if
else
when
while
for
loop
rangeop
caseop
switchop
default
optional
required
public
private
protected
internal
external
inline
override
abstract
sealed
open
final
static
const
lazy
late
init
initialize
configuration
option
options
projectwide
workspace
crossplatform
multiplatform
commonmain
jsmain
native
platform
api
implementation
dependency
dependencies
classpath
source
sources
document
documents
logging
logger
info
debug
trace
warning
error
severity
severitylevel
intentionaction
daemon
daemoncodeanalyzer
restart
textattributes
textattributeskey
typostyle
learned
learn
tech
vocabulary
domain
term
terms
us
uk
american
british
colour
color
organisation
organization

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--
- 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.
-
-->
<svg
viewBox="0 0 16.114843 20.543751"
role="img"
aria-label="Lyng favicon"
version="1.1"
id="svg2"
sodipodi:docname="lyng_file.svg"
width="16.114843"
height="20.543751"
inkscape:version="1.4.2 (1:1.4.2+202505120737+ebf0e940d0)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
>
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="49.791667"
inkscape:cx="8.9271967"
inkscape:cy="10.574059"
inkscape:window-width="2512"
inkscape:window-height="1403"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<style
id="style1">
:root { color-scheme: light dark; }
.mark { fill: currentColor; }
.math { font-family: 'STIX Two Math', 'Cambria Math', 'Times New Roman', serif; }
</style>
<g
class="mark math"
id="g2"
transform="translate(-3.0726562,3.39375)">
<!-- Keep favicon legible: lambda with superscript y only -->
<text
x="3"
y="17"
font-size="19px"
font-weight="700"
color="#009000"
stroke="#002000"
stroke-width="0.3"
id="text1">λ</text>
<text
x="11.2"
y="4"
font-size="16px"
color="#009000"
stroke="#002000"
stroke-width="0.1"
id="text2">y</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--
- 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.
-
-->
<svg
viewBox="0 0 32.129775 32.129776"
role="img"
aria-label="Lyng favicon"
version="1.1"
id="svg2"
sodipodi:docname="lyngicon.svg"
width="32.129776"
height="32.129776"
inkscape:version="1.4.2 (1:1.4.2+202505120737+ebf0e940d0)"
inkscape:export-filename="lyngicon.png"
inkscape:export-xdpi="1529.7959"
inkscape:export-ydpi="1529.7959"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
>
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="16"
inkscape:cx="12.65625"
inkscape:cy="5.90625"
inkscape:window-width="2512"
inkscape:window-height="1403"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<style
id="style1">
:root { color-scheme: light dark; }
.mark { fill: currentColor; }
.math { font-family: 'STIX Two Math', 'Cambria Math', 'Times New Roman', serif; }
</style>
<circle
style="fill:#2b0022;stroke:none;stroke-width:10.6332"
id="path2"
cx="16.064888"
cy="16.064888"
r="16.064888" />
<g
class="mark math"
id="g2"
transform="translate(4.8666825,8.3889988)"
style="fill:#00aa44;stroke:#005522">
<!-- Keep favicon legible: lambda with superscript y only -->
<text
x="3"
y="17"
font-size="19px"
font-weight="700"
color="#009000"
stroke="#002000"
stroke-width="0.3"
id="text1"
style="fill:#00aa44;stroke:#005522">λ</text>
<text
x="11.2"
y="4"
font-size="16px"
color="#009000"
stroke="#002000"
stroke-width="0.1"
id="text2"
style="fill:#00aa44;stroke:#005522">y</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,121 @@
package net.sergeych.lyng.idea.completion
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import net.sergeych.lyng.idea.util.DocsBootstrap
import net.sergeych.lyng.miniast.BuiltinDocRegistry
import net.sergeych.lyng.miniast.DocLookupUtils
import net.sergeych.lyng.miniast.MiniClassDecl
class LyngCompletionMemberTest : BasePlatformTestCase() {
override fun getTestDataPath(): String = ""
private fun complete(code: String): List<String> {
myFixture.configureByText("test.lyng", code)
val items = myFixture.completeBasic()
return myFixture.lookupElementStrings ?: emptyList()
}
private fun ensureDocs(imports: List<String>) {
// Make sure external/bundled docs like lyng.io.fs are registered
DocsBootstrap.ensure()
// Touch modules to force stdlib lazy load and optional modules
for (m in imports) BuiltinDocRegistry.docsForModule(m)
}
private fun aggregateMemberNames(className: String, imported: List<String>): Set<String> {
val classes = DocLookupUtils.aggregateClasses(imported)
val visited = mutableSetOf<String>()
val result = linkedSetOf<String>()
fun dfs(name: String) {
val cls: MiniClassDecl = classes[name] ?: return
for (m in cls.members) result.add(m.name)
if (!visited.add(name)) return
for (b in cls.bases) dfs(b)
}
dfs(className)
// Conservative supplementation mirroring contributor behavior
when (className) {
"List" -> listOf("Collection", "Iterable").forEach { dfs(it) }
"Array" -> listOf("Collection", "Iterable").forEach { dfs(it) }
}
return result
}
fun test_NoGlobalsAfterDot_IteratorFromLines() {
val code = """
import lyng.io.fs
import lyng.stdlib
val files = Path("../..").lines().<caret>
""".trimIndent()
val imported = listOf("lyng.io.fs", "lyng.stdlib")
ensureDocs(imported)
val items = complete(code)
// Must not propose globals after dot
assertFalse(items.contains("Path"))
assertFalse(items.contains("Array"))
assertFalse(items.contains("String"))
// Should contain a reasonable subset of Iterator members
val expected = aggregateMemberNames("Iterator", imported)
// At least one expected member must appear
val intersection = expected.intersect(items.toSet())
assertTrue("Expected Iterator members, but got: $items", intersection.isNotEmpty())
}
fun test_IteratorAfterLines_WithPrefix() {
val code = """
import lyng.io.fs
import lyng.stdlib
val files = Path("../..").lines().t<caret>
""".trimIndent()
val imported = listOf("lyng.io.fs", "lyng.stdlib")
ensureDocs(imported)
val items = complete(code)
// Must not propose globals after dot even with prefix
assertFalse(items.contains("Path"))
assertFalse(items.contains("Array"))
assertFalse(items.contains("String"))
// All suggestions should start with the typed prefix (case-insensitive)
assertTrue(items.all { it.startsWith("t", ignoreCase = true) })
// Some Iterator member starting with 't' should be present (e.g., toList)
val expected = aggregateMemberNames("Iterator", imported).filter { it.startsWith("t", true) }.toSet()
if (expected.isNotEmpty()) {
val intersection = expected.intersect(items.toSet())
assertTrue("Expected Iterator members with prefix 't', got: $items", intersection.isNotEmpty())
} else {
// If registry has no 't*' members, at least suggestions should not be empty
assertTrue(items.isNotEmpty())
}
}
fun test_ListLiteral_MembersWithInherited() {
val code = """
import lyng.stdlib
val x = [1,2,3].<caret>
""".trimIndent()
val imported = listOf("lyng.stdlib")
ensureDocs(imported)
val items = complete(code)
// Must not propose globals after dot
assertFalse(items.contains("Array"))
assertFalse(items.contains("String"))
assertFalse(items.contains("Path"))
// Expect members from List plus parents (Collection/Iterable)
val expected = aggregateMemberNames("List", imported)
val intersection = expected.intersect(items.toSet())
assertTrue("Expected List/Collection/Iterable members, got: $items", intersection.isNotEmpty())
// Heuristic: we expect more than a couple of items (not just size/toList)
assertTrue("Too few member suggestions after list literal: $items", items.size >= 3)
}
}

1
lyng/${file} Normal file
View File

@ -0,0 +1 @@
hello from cli test

View File

@ -57,6 +57,9 @@ kotlin {
dependencies {
implementation(kotlin("stdlib-common"))
implementation(project(":lynglib"))
// Provide Lyng FS module to the CLI tool so it can install
// filesystem access into the execution Scope by default.
implementation(project(":lyngio"))
implementation(libs.okio)
implementation(libs.clikt)
implementation(kotlin("stdlib-common"))
@ -72,6 +75,12 @@ kotlin {
implementation(libs.okio.fakefilesystem)
}
}
val jvmTest by getting {
dependencies {
implementation(kotlin("test"))
implementation(kotlin("test-junit"))
}
}
// val nativeMain by getting {
// dependencies {
// implementation(kotlin("stdlib-common"))

View File

@ -20,6 +20,7 @@ package net.sergeych
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.main
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.multiple
import com.github.ajalt.clikt.parameters.arguments.optional
@ -30,7 +31,9 @@ import net.sergeych.lyng.LyngVersion
import net.sergeych.lyng.Script
import net.sergeych.lyng.ScriptError
import net.sergeych.lyng.Source
import net.sergeych.lyng.io.fs.createFs
import net.sergeych.lyng.obj.*
import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy
import net.sergeych.mp_tools.globalDefer
import okio.FileSystem
import okio.Path.Companion.toPath
@ -62,23 +65,89 @@ val baseScopeDefer = globalDefer {
exit(requireOnlyArg<ObjInt>().toInt())
ObjVoid
}
// Install lyng.io.fs module with full access by default for the CLI tool's Scope.
// Scripts still need to `import lyng.io.fs` to use Path API.
createFs(PermitAllAccessPolicy, this)
}
}
fun runMain(args: Array<String>) {
// Fast paths for legacy/positional script execution that should work without requiring explicit options
if (args.isNotEmpty()) {
// Support: jyng -- -file.lyng <args>
if (args.size >= 2 && args[0] == "--") {
// -- -file.lyng <args>
executeFileWithArgs(args[1], args.drop(2))
return
} else if( args[0][0] != '-') {
// file.lyng <args>
}
// Support: jyng script.lyng <args> (when first token is not an option and not a subcommand name)
if (!args[0].startsWith('-') && args[0] != "fmt") {
executeFileWithArgs(args[0], args.drop(1))
return
}
}
// normal processing
Lyng { runBlocking { it() } }.main(args)
// Delegate all other parsing and dispatching to Clikt with proper subcommands.
Lyng { runBlocking { it() } }
.subcommands(Fmt())
.main(args)
}
private class Fmt : CliktCommand(name = "fmt") {
private val checkOnly by option("--check", help = "Check only; print files that would change").flag()
private val inPlace by option("-i", "--in-place", help = "Write changes back to files").flag()
private val enableSpacing by option("--spacing", help = "Apply spacing normalization").flag()
private val enableWrapping by option("--wrap", "--wrapping", help = "Enable line wrapping").flag()
private val files by argument(help = "One or more .lyng files to format").multiple()
override fun help(context: Context): String = "Format Lyng source files"
override fun run() {
// Validate inputs
if (files.isEmpty()) {
println("Error: no files specified. See --help for usage.")
exit(1)
}
if (checkOnly && inPlace) {
println("Error: --check and --in-place cannot be used together")
exit(1)
}
val cfg = net.sergeych.lyng.format.LyngFormatConfig(
applySpacing = enableSpacing,
applyWrapping = enableWrapping,
)
var anyChanged = false
val multiFile = files.size > 1
for (path in files) {
val p = path.toPath()
val original = FileSystem.SYSTEM.source(p).use { it.buffer().use { bs -> bs.readUtf8() } }
val formatted = net.sergeych.lyng.format.LyngFormatter.format(original, cfg)
val changed = formatted != original
if (checkOnly) {
if (changed) {
println(path)
anyChanged = true
}
} else if (inPlace) {
// Write back regardless, but only touch file if content differs
if (changed) {
FileSystem.SYSTEM.write(p) { writeUtf8(formatted) }
}
} else {
// Default: stdout output
if (multiFile) {
println("--- $path ---")
}
println(formatted)
}
}
if (checkOnly) {
exit(if (anyChanged) 2 else 0)
}
}
}
private class Lyng(val launcher: (suspend () -> Unit) -> Unit) : CliktCommand() {
@ -106,6 +175,10 @@ private class Lyng(val launcher: (suspend () -> Unit) -> Unit) : CliktCommand()
""".trimIndent()
override fun run() {
// If a subcommand (like `fmt`) was invoked, do nothing in the root command.
// This prevents the root from printing help before the subcommand runs.
if (currentContext.invokedSubcommand != null) return
runBlocking {
val baseScope = baseScopeDefer.await()
when {
@ -132,13 +205,7 @@ private class Lyng(val launcher: (suspend () -> Unit) -> Unit) : CliktCommand()
else -> {
if (script == null) {
println(
"""
Error: no script specified.
""".trimIndent()
)
println("Error: no script specified.\n")
echoFormattedHelp()
} else {
baseScope.addConst("ARGV", ObjList(args.map { ObjString(it) }.toMutableList()))

View File

@ -19,6 +19,11 @@ package net.sergeych
import kotlin.system.exitProcess
// Allow tests to override JVM exit behavior without terminating the whole VM.
// In production, this points to exitProcess; tests can replace it to throw.
@PublishedApi
internal var jvmExitImpl: (Int) -> Nothing = { code -> exitProcess(code) }
actual fun exit(code: Int) {
exitProcess(code)
jvmExitImpl(code)
}

View File

@ -0,0 +1,134 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng_cli
import net.sergeych.jvmExitImpl
import net.sergeych.runMain
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.ByteArrayOutputStream
import java.io.PrintStream
import java.nio.file.Files
import java.nio.file.Path
class CliFmtJvmTest {
private val originalOut: PrintStream = System.out
private val originalErr: PrintStream = System.err
private class TestExit(val code: Int) : RuntimeException()
@Before
fun setUp() {
// Make exit() throw in tests so we can assert the code
jvmExitImpl = { code -> throw TestExit(code) }
}
@After
fun tearDown() {
System.setOut(originalOut)
System.setErr(originalErr)
// restore default exit behavior for safety
jvmExitImpl = { code -> kotlin.system.exitProcess(code) }
}
private data class CliResult(val out: String, val err: String, val exitCode: Int?)
private fun runCli(vararg args: String): CliResult {
val outBuf = ByteArrayOutputStream()
val errBuf = ByteArrayOutputStream()
System.setOut(PrintStream(outBuf, true, Charsets.UTF_8))
System.setErr(PrintStream(errBuf, true, Charsets.UTF_8))
var exitCode: Int? = null
try {
runMain(arrayOf(*args))
} catch (e: TestExit) {
exitCode = e.code
} finally {
System.out.flush()
System.err.flush()
}
return CliResult(outBuf.toString("UTF-8"), errBuf.toString("UTF-8"), exitCode)
}
@Test
fun fmtDoesNotPrintRootHelp() {
val tmp: Path = Files.createTempFile("lyng_fmt_", ".lyng")
try {
Files.writeString(tmp, "println(1)\n")
val r = runCli("fmt", tmp.toString())
// Root help banner should not appear
assertFalse(r.out.contains("The Lyng script language interpreter"))
// Should output formatted content (stdout default)
assertTrue("Expected some output", r.out.isNotBlank())
} finally {
Files.deleteIfExists(tmp)
}
}
@Test
fun fmtCheckAndInPlaceAreMutuallyExclusive() {
val r = runCli("fmt", "--check", "--in-place", "nonexistent.lyng")
// Should exit with code 1 and print an error
assertTrue("Expected exit code 1", r.exitCode == 1)
assertTrue(r.out.contains("cannot be used together"))
}
@Test
fun fmtMultipleFilesPrintsHeaders() {
val tmp1: Path = Files.createTempFile("lyng_fmt_", ".lyng")
val tmp2: Path = Files.createTempFile("lyng_fmt_", ".lyng")
try {
Files.writeString(tmp1, "println(1)\n")
Files.writeString(tmp2, "println(2)\n")
val r = runCli("fmt", tmp1.toString(), tmp2.toString())
assertTrue(r.out.contains("--- ${tmp1.toString()} ---"))
assertTrue(r.out.contains("--- ${tmp2.toString()} ---"))
} finally {
Files.deleteIfExists(tmp1)
Files.deleteIfExists(tmp2)
}
}
@Test
fun legacyPositionalScriptExecutes() {
// Create a tiny script and ensure it runs when passed positionally
val tmp: Path = Files.createTempFile("lyng_script_", ".lyng")
try {
Files.writeString(tmp, "println(\"OK\")\n")
val r = runCli(tmp.toString())
assertTrue(r.out.contains("OK"))
} finally {
Files.deleteIfExists(tmp)
}
}
@Test
fun legacyDoubleDashStopsParsingAndExecutesScript() {
val tmp: Path = Files.createTempFile("lyng_script_", ".lyng")
try {
Files.writeString(tmp, "println(\"DASH\")\n")
val r = runCli("--", tmp.toString())
assertTrue(r.out.contains("DASH"))
} finally {
Files.deleteIfExists(tmp)
}
}
}

View File

@ -0,0 +1,64 @@
/*
* 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_cli
import kotlinx.coroutines.runBlocking
import net.sergeych.baseScopeDefer
import org.junit.Test
import kotlin.io.path.createTempDirectory
class FsIntegrationJvmTest {
@Test
fun scopeHasFsModuleInstalled() {
runBlocking {
val scope = baseScopeDefer.await()
// Ensure we can import the FS module and use Path bindings from Lyng script
val dir = createTempDirectory("lyng_cli_fs_test_")
try {
val file = dir.resolve("hello.txt")
// Drive the operation via Lyng code to validate bindings end-to-end
scope.eval(
"""
import lyng.io.fs
val p = Path("${'$'}{file}")
p.writeUtf8("hello from cli test")
assertEquals(true, p.exists())
assertEquals("hello from cli test", p.readUtf8())
""".trimIndent()
)
} finally {
dir.toFile().deleteRecursively()
}
}
}
@Test
fun scopeHasFsSeesRealFs() {
runBlocking {
val scope = baseScopeDefer.await()
// Drive the operation via Lyng code to validate bindings end-to-end
scope.eval(
"""
import lyng.io.fs
// list current folder files
println( Path(".").list().toList() )
""".trimIndent()
)
}
}
}

152
lyngio/build.gradle.kts Normal file
View File

@ -0,0 +1,152 @@
/*
* 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.
*
*/
/*
* LyngIO: Compose Multiplatform library module depending on :lynglib
*/
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
`maven-publish`
}
group = "net.sergeych"
version = "0.0.1-SNAPSHOT"
kotlin {
jvm()
androidTarget {
publishLibraryVariants("release")
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
iosX64()
iosArm64()
iosSimulatorArm64()
macosX64()
macosArm64()
mingwX64()
linuxX64()
linuxArm64()
js {
browser()
nodejs()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs() {
browser()
nodejs()
}
// Keep expect/actual warning suppressed consistently with other modules
targets.configureEach {
compilations.configureEach {
compilerOptions.configure {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
}
sourceSets {
all {
languageSettings.optIn("kotlin.ExperimentalUnsignedTypes")
// languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
// 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 {
dependencies {
api(project(":lynglib"))
api(libs.okio)
api(libs.kotlinx.coroutines.core)
}
}
val commonTest by getting {
dependencies {
implementation(libs.kotlin.test)
}
}
// JS: use runtime detection in jsMain to select Node vs Browser implementation
val jsMain by getting {
dependencies {
api(libs.okio)
implementation(libs.okio.fakefilesystem)
implementation("com.squareup.okio:okio-nodefilesystem:${libs.versions.okioVersion.get()}")
}
}
// For Wasm we use in-memory VFS for now
val wasmJsMain by getting {
dependencies {
api(libs.okio)
implementation(libs.okio.fakefilesystem)
}
}
}
}
android {
namespace = "net.sergeych.lyngio"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
lint {
// Prevent Android Lint from failing the build due to Kotlin toolchain
// version mismatches in the environment. This keeps CI green while
// still generating lint reports locally.
abortOnError = false
checkReleaseBuilds = false
}
}
// Disable Android Lint tasks for this module to avoid toolchain incompatibility
// until AGP and Kotlin versions align perfectly in the environment.
tasks.matching { it.name.startsWith("lint", ignoreCase = true) }.configureEach {
this.enabled = false
}
publishing {
val mavenToken by lazy {
File("${System.getProperty("user.home")}/.gitea_token").readText()
}
repositories {
maven {
credentials(HttpHeaderCredentials::class) {
name = "Authorization"
value = mavenToken
}
url = uri("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
authentication {
create("Authorization", HttpHeaderAuthentication::class)
}
}
}
}

View File

@ -0,0 +1,25 @@
/*
* 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.lyngio.fs
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import okio.FileSystem
actual fun platformAsyncFs(): LyngFs = OkioAsyncFs(FileSystem.SYSTEM)
actual val LyngIoDispatcher: CoroutineDispatcher = Dispatchers.IO

View File

@ -0,0 +1,683 @@
/*
* 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.
*
*/
/*
* Lyng FS module installer and bindings
*/
package net.sergeych.lyng.io.fs
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.miniast.*
import net.sergeych.lyng.obj.*
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyngio.fs.LyngFS
import net.sergeych.lyngio.fs.LyngFs
import net.sergeych.lyngio.fs.LyngPath
import net.sergeych.lyngio.fs.security.AccessDeniedException
import net.sergeych.lyngio.fs.security.FsAccessPolicy
import net.sergeych.lyngio.fs.security.LyngFsSecured
import okio.Path.Companion.toPath
/**
* Install Lyng module `lyng.io.fs` into the given scope's ImportManager.
* Returns true if installed, false if it was already registered in this manager.
*/
fun createFsModule(policy: FsAccessPolicy, scope: Scope): Boolean =
createFsModule(policy, scope.importManager)
// Alias as requested earlier in discussions
fun createFs(policy: FsAccessPolicy, scope: Scope): Boolean = createFsModule(policy, scope)
/** Same as [createFsModule] but with explicit [ImportManager]. */
fun createFsModule(policy: FsAccessPolicy, manager: ImportManager): Boolean {
val name = "lyng.io.fs"
// Avoid re-registering in this ImportManager
if (manager.packageNames.contains(name)) return false
manager.addPackage(name) { module ->
buildFsModule(module, policy)
}
return true
}
// Alias overload for ImportManager
fun createFs(policy: FsAccessPolicy, manager: ImportManager): Boolean = createFsModule(policy, manager)
// --- Module builder ---
private suspend fun buildFsModule(module: ModuleScope, policy: FsAccessPolicy) {
// Per-module secured FS, captured by all factories and methods
val base: LyngFs = LyngFS.system()
val secured = LyngFsSecured(base, policy)
// Path class bound to this module
val pathType = object : ObjClass("Path") {
override suspend fun callOn(scope: Scope): Obj {
val arg = scope.requireOnlyArg<ObjString>()
val str = arg.value
return ObjPath(this, secured, str.toPath())
}
}.apply {
addFnDoc(
name = "name",
doc = "Base name of the path (last segment).",
returns = type("lyng.String"),
moduleName = module.packageName
) {
val self = thisAs<ObjPath>()
self.path.name.toObj()
}
addFnDoc(
name = "parent",
doc = "Parent directory as a Path or null if none.",
returns = type("Path", nullable = true),
moduleName = module.packageName
) {
val self = thisAs<ObjPath>()
self.path.parent?.let {
ObjPath( this@apply, self.secured, it)
} ?: ObjNull
}
addFnDoc(
name = "segments",
doc = "List of path segments.",
// returns: List<String>
returns = TypeGenericDoc(type("lyng.List"), listOf(type("lyng.String"))),
moduleName = module.packageName
) {
val self = thisAs<ObjPath>()
ObjList(self.path.segments.map { ObjString(it) }.toMutableList())
}
// exists(): Bool
addFnDoc(
name = "exists",
doc = "Check whether this path exists.",
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
(self.secured.exists(self.path)).toObj()
}
}
// isFile(): Bool — cached metadata
addFnDoc(
name = "isFile",
doc = "True if this path is a regular file (based on cached metadata).",
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
self.ensureMetadata().let { ObjBool(it.isRegularFile) }
}
}
// isDirectory(): Bool — cached metadata
addFnDoc(
name = "isDirectory",
doc = "True if this path is a directory (based on cached metadata).",
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
self.ensureMetadata().let { ObjBool(it.isDirectory) }
}
}
// size(): Int? — null when unavailable
addFnDoc(
name = "size",
doc = "File size in bytes, or null when unavailable.",
returns = type("lyng.Int", nullable = true),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val m = self.ensureMetadata()
m.size?.let { ObjInt(it) } ?: ObjNull
}
}
// createdAt(): Instant? — Lyng Instant, null when unavailable
addFnDoc(
name = "createdAt",
doc = "Creation time as `Instant`, or null when unavailable.",
returns = type("lyng.Instant", nullable = true),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val m = self.ensureMetadata()
m.createdAtMillis?.let { ObjInstant(kotlinx.datetime.Instant.fromEpochMilliseconds(it)) } ?: ObjNull
}
}
// createdAtMillis(): Int? — milliseconds since epoch or null
addFnDoc(
name = "createdAtMillis",
doc = "Creation time in milliseconds since epoch, or null when unavailable.",
returns = type("lyng.Int", nullable = true),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val m = self.ensureMetadata()
m.createdAtMillis?.let { ObjInt(it) } ?: ObjNull
}
}
// modifiedAt(): Instant? — Lyng Instant, null when unavailable
addFnDoc(
name = "modifiedAt",
doc = "Last modification time as `Instant`, or null when unavailable.",
returns = type("lyng.Instant", nullable = true),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val m = self.ensureMetadata()
m.modifiedAtMillis?.let { ObjInstant(kotlinx.datetime.Instant.fromEpochMilliseconds(it)) } ?: ObjNull
}
}
// modifiedAtMillis(): Int? — milliseconds since epoch or null
addFnDoc(
name = "modifiedAtMillis",
doc = "Last modification time in milliseconds since epoch, or null when unavailable.",
returns = type("lyng.Int", nullable = true),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val m = self.ensureMetadata()
m.modifiedAtMillis?.let { ObjInt(it) } ?: ObjNull
}
}
// list(): List<Path>
addFnDoc(
name = "list",
doc = "List directory entries as `Path` objects.",
returns = TypeGenericDoc(type("lyng.List"), listOf(type("Path"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val items = self.secured.list(self.path).map { ObjPath(self.objClass, self.secured, it) }
ObjList(items.toMutableList())
}
}
// readBytes(): Buffer
addFnDoc(
name = "readBytes",
doc = "Read the file into a binary buffer.",
returns = type("lyng.Buffer"),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val bytes = self.secured.readBytes(self.path)
ObjBuffer(bytes.asUByteArray())
}
}
// writeBytes(bytes: Buffer)
addFnDoc(
name = "writeBytes",
doc = "Write a binary buffer to the file, replacing content.",
params = listOf(ParamDoc("bytes", type("lyng.Buffer"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val buf = requiredArg<ObjBuffer>(0)
self.secured.writeBytes(self.path, buf.byteArray.asByteArray(), append = false)
ObjVoid
}
}
// appendBytes(bytes: Buffer)
addFnDoc(
name = "appendBytes",
doc = "Append a binary buffer to the end of the file.",
params = listOf(ParamDoc("bytes", type("lyng.Buffer"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val buf = requiredArg<ObjBuffer>(0)
self.secured.writeBytes(self.path, buf.byteArray.asByteArray(), append = true)
ObjVoid
}
}
// readUtf8(): String
addFnDoc(
name = "readUtf8",
doc = "Read the file as a UTF-8 string.",
returns = type("lyng.String"),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
self.secured.readUtf8(self.path).toObj()
}
}
// writeUtf8(text: String)
addFnDoc(
name = "writeUtf8",
doc = "Write a UTF-8 string to the file, replacing content.",
params = listOf(ParamDoc("text", type("lyng.String"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val text = requireOnlyArg<ObjString>().value
self.secured.writeUtf8(self.path, text, append = false)
ObjVoid
}
}
// appendUtf8(text: String)
addFnDoc(
name = "appendUtf8",
doc = "Append UTF-8 text to the end of the file.",
params = listOf(ParamDoc("text", type("lyng.String"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val text = requireOnlyArg<ObjString>().value
self.secured.writeUtf8(self.path, text, append = true)
ObjVoid
}
}
// metadata(): Map
addFnDoc(
name = "metadata",
doc = "Fetch cached metadata as a map of fields: `isFile`, `isDirectory`, `size`, `createdAtMillis`, `modifiedAtMillis`, `isSymlink`.",
returns = TypeGenericDoc(type("lyng.Map"), listOf(type("lyng.String"), type("lyng.Any"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val m = self.secured.metadata(self.path)
ObjMap(mutableMapOf(
ObjString("isFile") to ObjBool(m.isRegularFile),
ObjString("isDirectory") to ObjBool(m.isDirectory),
ObjString("size") to (m.size?.toLong() ?: 0L).toObj(),
ObjString("createdAtMillis") to ((m.createdAtMillis ?: 0L)).toObj(),
ObjString("modifiedAtMillis") to ((m.modifiedAtMillis ?: 0L)).toObj(),
ObjString("isSymlink") to ObjBool(m.isSymlink),
))
}
}
// mkdirs(mustCreate: Bool=false)
addFnDoc(
name = "mkdirs",
doc = "Create directories (like `mkdir -p`). If `mustCreate` is true and the path already exists, the call fails. Otherwise it is a no‑op when the directory exists.",
params = listOf(ParamDoc("mustCreate", type("lyng.Bool"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val mustCreate = args.list.getOrNull(0)?.toBool() ?: false
self.secured.createDirectories(self.path, mustCreate)
ObjVoid
}
}
// move(to: Path|String, overwrite: Bool=false)
addFnDoc(
name = "move",
doc = "Move this path to a new location. `to` may be a `Path` or `String`. When `overwrite` is false and the target exists, the operation fails (provider may throw `AccessDeniedException`).",
// types vary; keep generic description in doc
params = listOf(ParamDoc("to"), ParamDoc("overwrite", type("lyng.Bool"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val toPath = parsePathArg(this, self, requiredArg<Obj>(0))
val overwrite = args.list.getOrNull(1)?.toBool() ?: false
self.secured.move(self.path, toPath, overwrite)
ObjVoid
}
}
// delete(mustExist: Bool=false, recursively: Bool=false)
addFnDoc(
name = "delete",
doc = "Delete this path. `mustExist=true` causes failure if the path does not exist. `recursively=true` removes directories with their contents. Providers can throw `AccessDeniedException` on policy violations.",
params = listOf(ParamDoc("mustExist", type("lyng.Bool")), ParamDoc("recursively", type("lyng.Bool"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val mustExist = args.list.getOrNull(0)?.toBool() ?: false
val recursively = args.list.getOrNull(1)?.toBool() ?: false
self.secured.delete(self.path, mustExist, recursively)
ObjVoid
}
}
// copy(to: Path|String, overwrite: Bool=false)
addFnDoc(
name = "copy",
doc = "Copy this path to a new location. `to` may be a `Path` or `String`. When `overwrite` is false and the target exists, the operation fails (provider may throw `AccessDeniedException`).",
params = listOf(ParamDoc("to"), ParamDoc("overwrite", type("lyng.Bool"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val toPath = parsePathArg(this, self, requiredArg<Obj>(0))
val overwrite = args.list.getOrNull(1)?.toBool() ?: false
self.secured.copy(self.path, toPath, overwrite)
ObjVoid
}
}
// glob(pattern: String): List<Path>
addFnDoc(
name = "glob",
doc = "List entries matching a glob pattern (no recursion).",
params = listOf(ParamDoc("pattern", type("lyng.String"))),
returns = TypeGenericDoc(type("lyng.List"), listOf(type("Path"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val pattern = requireOnlyArg<ObjString>().value
val matches = self.secured.glob(self.path, pattern)
ObjList(matches.map { ObjPath(self.objClass, self.secured, it) }.toMutableList())
}
}
// --- streaming readers (initial version: chunk from whole content, API stable) ---
// readChunks(size: Int = 65536) -> Iterator<Buffer>
addFnDoc(
name = "readChunks",
doc = "Read file in fixed-size chunks as an iterator of `Buffer`.",
params = listOf(ParamDoc("size", type("lyng.Int"))),
returns = TypeGenericDoc(type("lyng.Iterator"), listOf(type("lyng.Buffer"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val size = args.list.getOrNull(0)?.toInt() ?: 65536
val bytes = self.secured.readBytes(self.path)
ObjFsBytesIterator(bytes, size)
}
}
// readUtf8Chunks(size: Int = 65536) -> Iterator<String>
addFnDoc(
name = "readUtf8Chunks",
doc = "Read UTF-8 text in fixed-size chunks as an iterator of `String`.",
params = listOf(ParamDoc("size", type("lyng.Int"))),
returns = TypeGenericDoc(type("lyng.Iterator"), listOf(type("lyng.String"))),
moduleName = module.packageName
) {
fsGuard {
val self = this.thisObj as ObjPath
val size = args.list.getOrNull(0)?.toInt() ?: 65536
val text = self.secured.readUtf8(self.path)
ObjFsStringChunksIterator(text, size)
}
}
// lines() -> Iterator<String>, implemented via readUtf8Chunks
addFnDoc(
name = "lines",
doc = "Iterate lines of the file as `String` values.",
returns = TypeGenericDoc(type("lyng.Iterator"), listOf(type("lyng.String"))),
moduleName = module.packageName
) {
fsGuard {
val chunkIt = thisObj.invokeInstanceMethod(this, "readUtf8Chunks")
ObjFsLinesIterator(chunkIt)
}
}
}
// Export into the module scope with docs
module.addConstDoc(
name = "Path",
value = pathType,
doc = "Filesystem path class. Construct with a string: `Path(\"/tmp\")`.",
type = type("Path"),
moduleName = module.packageName
)
// Alias as requested (Path(s) style)
module.addConstDoc(
name = "Paths",
value = pathType,
doc = "Alias of `Path` for those who prefer plural form.",
type = type("Path"),
moduleName = module.packageName
)
}
// --- Helper classes and utilities ---
private fun parsePathArg(scope: Scope, self: ObjPath, arg: Obj): LyngPath {
return when (arg) {
is ObjString -> arg.value.toPath()
is ObjPath -> arg.path
else -> scope.raiseIllegalArgument("expected Path or String argument")
}
}
// Map Fs access denials to Lyng runtime exceptions for script-friendly errors
private suspend inline fun Scope.fsGuard(crossinline block: suspend () -> Obj): Obj {
return try {
block()
} catch (e: AccessDeniedException) {
raiseError(ObjIllegalOperationException(this, e.reasonDetail ?: "access denied"))
}
}
/** Kotlin-side instance backing the Lyng class `Path`. */
class ObjPath(
private val klass: ObjClass,
val secured: LyngFs,
val path: LyngPath,
) : Obj() {
// Cache for metadata to avoid repeated FS calls within the same object instance usage
private var _metadata: net.sergeych.lyngio.fs.LyngMetadata? = null
override val objClass: ObjClass get() = klass
override fun toString(): String = path.toString()
suspend fun ensureMetadata(): net.sergeych.lyngio.fs.LyngMetadata {
val cached = _metadata
if (cached != null) return cached
val m = secured.metadata(path)
_metadata = m
return m
}
}
/** Iterator over byte chunks as Buffers. */
class ObjFsBytesIterator(
private val data: ByteArray,
private val chunkSize: Int,
) : Obj() {
private var pos = 0
override val objClass: ObjClass = BytesIteratorType
companion object {
val BytesIteratorType = object : ObjClass("BytesIterator", ObjIterator) {
init {
// make it usable in for-loops
addFnDoc(
name = "iterator",
doc = "Return this iterator instance (enables `for` loops).",
returns = type("BytesIterator"),
moduleName = "lyng.io.fs"
) { thisObj }
addFnDoc(
name = "hasNext",
doc = "Whether there is another chunk available.",
returns = type("lyng.Bool"),
moduleName = "lyng.io.fs"
) {
val self = thisAs<ObjFsBytesIterator>()
(self.pos < self.data.size).toObj()
}
addFnDoc(
name = "next",
doc = "Return the next chunk as a `Buffer`.",
returns = type("lyng.Buffer"),
moduleName = "lyng.io.fs"
) {
val self = thisAs<ObjFsBytesIterator>()
if (self.pos >= self.data.size) raiseIllegalState("iterator exhausted")
val end = minOf(self.pos + self.chunkSize, self.data.size)
val chunk = self.data.copyOfRange(self.pos, end)
self.pos = end
ObjBuffer(chunk.asUByteArray())
}
addFnDoc(
name = "cancelIteration",
doc = "Stop the iteration early; subsequent `hasNext` returns false.",
moduleName = "lyng.io.fs"
) {
val self = thisAs<ObjFsBytesIterator>()
self.pos = self.data.size
ObjVoid
}
}
}
}
}
/** Iterator over utf-8 text chunks (character-counted chunks). */
class ObjFsStringChunksIterator(
private val text: String,
private val chunkChars: Int,
) : Obj() {
private var pos = 0
override val objClass: ObjClass = StringChunksIteratorType
companion object {
val StringChunksIteratorType = object : ObjClass("StringChunksIterator", ObjIterator) {
init {
// make it usable in for-loops
addFnDoc(
name = "iterator",
doc = "Return this iterator instance (enables `for` loops).",
returns = type("StringChunksIterator"),
moduleName = "lyng.io.fs"
) { thisObj }
addFnDoc(
name = "hasNext",
doc = "Whether there is another chunk available.",
returns = type("lyng.Bool"),
moduleName = "lyng.io.fs"
) {
val self = thisAs<ObjFsStringChunksIterator>()
(self.pos < self.text.length).toObj()
}
addFnDoc(
name = "next",
doc = "Return the next UTF-8 chunk as a `String`.",
returns = type("lyng.String"),
moduleName = "lyng.io.fs"
) {
val self = thisAs<ObjFsStringChunksIterator>()
if (self.pos >= self.text.length) raiseIllegalState("iterator exhausted")
val end = minOf(self.pos + self.chunkChars, self.text.length)
val chunk = self.text.substring(self.pos, end)
self.pos = end
ObjString(chunk)
}
addFnDoc(
name = "cancelIteration",
doc = "Stop the iteration early; subsequent `hasNext` returns false.",
moduleName = "lyng.io.fs"
) { ObjVoid }
}
}
}
}
/** Iterator that yields lines using an underlying chunks iterator. */
class ObjFsLinesIterator(
private val chunksIterator: Obj,
) : Obj() {
private var buffer: String = ""
private var exhausted = false
override val objClass: ObjClass = LinesIteratorType
companion object {
val LinesIteratorType = object : ObjClass("LinesIterator", ObjIterator) {
init {
// make it usable in for-loops
addFnDoc(
name = "iterator",
doc = "Return this iterator instance (enables `for` loops).",
returns = type("LinesIterator"),
moduleName = "lyng.io.fs"
) { thisObj }
addFnDoc(
name = "hasNext",
doc = "Whether another line is available.",
returns = type("lyng.Bool"),
moduleName = "lyng.io.fs"
) {
val self = thisAs<ObjFsLinesIterator>()
self.ensureBufferFilled(this)
(self.buffer.isNotEmpty() || !self.exhausted).toObj()
}
addFnDoc(
name = "next",
doc = "Return the next line as `String`.",
returns = type("lyng.String"),
moduleName = "lyng.io.fs"
) {
val self = thisAs<ObjFsLinesIterator>()
self.ensureBufferFilled(this)
if (self.buffer.isEmpty() && self.exhausted) raiseIllegalState("iterator exhausted")
val idx = self.buffer.indexOf('\n')
val line = if (idx >= 0) {
val l = self.buffer.substring(0, idx)
self.buffer = self.buffer.substring(idx + 1)
l
} else {
// last line without trailing newline
val l = self.buffer
self.buffer = ""
self.exhausted = true
l
}
ObjString(line)
}
addFnDoc(
name = "cancelIteration",
doc = "Stop the iteration early; subsequent `hasNext` returns false.",
moduleName = "lyng.io.fs"
) { ObjVoid }
}
}
}
private suspend fun ensureBufferFilled(scope: Scope) {
if (buffer.contains('\n') || exhausted) return
// Pull next chunk from the underlying iterator
val it = chunksIterator.invokeInstanceMethod(scope, "iterator")
val hasNext = it.invokeInstanceMethod(scope, "hasNext").toBool()
if (!hasNext) {
exhausted = true
return
}
val next = it.invokeInstanceMethod(scope, "next")
buffer += next.toString()
}
}

View File

@ -0,0 +1,30 @@
/*
* 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.lyngio
/**
* LyngIO: foundation for uniform file access APIs across JVM, Native and JS.
*
* This module depends on `:lynglib` and is configured as a Compose Multiplatform
* library, but it does not require Compose at runtime. Actual file system APIs
* will be added after we agree on the backend choices (Okio, NodeJS FS, browser VFS).
*/
object LyngIoInfo {
val name: String = "LyngIO"
val version: String = "0.0.1-SNAPSHOT"
}

Some files were not shown because too many files have changed in this diff Show More