From ec49bbbf524259882ec4ce89fdde3785c7ba31f2 Mon Sep 17 00:00:00 2001 From: sergeych Date: Mon, 1 Dec 2025 17:50:27 +0100 Subject: [PATCH] idea plugin 0.0.2-SNAPSHOT, improced, added reformat code. Formatting tools improved in lynglib. Site information added --- .gitattributes | 1 + bin/deploy_site | 5 +- bin/local_release | 11 +- distributables/lyng-idea-0.0.2-SNAPSHOT.zip | 3 + distributables/lyng-linuxX64.zip | 3 + distributables/lyng-textmate.zip | 3 + docs/idea_plugin.md | 29 +++ docs/samples/fs_sample.lyng | 21 +- docs/textmate_bundle.md | 77 ++++++++ lyng-idea/build.gradle.kts | 28 ++- .../idea/annotators/LyngExternalAnnotator.kt | 91 ++++++++- .../idea/highlight/LyngColorSettingsPage.kt | 1 + .../idea/highlight/LyngHighlighterColors.kt | 13 +- .../src/main/resources/META-INF/plugin.xml | 8 +- .../main/resources/META-INF/pluginIcon.svg | 21 +- .../src/main/resources/icons/lyng_file.svg | 21 +- .../net/sergeych/lyng/binding/Binder.kt | 52 +++++ .../commonTest/kotlin/BindingHighlightTest.kt | 182 ++++++++++++++++++ site/src/jsMain/resources/index.html | 14 ++ 19 files changed, 530 insertions(+), 54 deletions(-) create mode 100644 .gitattributes create mode 100644 distributables/lyng-idea-0.0.2-SNAPSHOT.zip create mode 100644 distributables/lyng-linuxX64.zip create mode 100644 distributables/lyng-textmate.zip create mode 100644 docs/idea_plugin.md create mode 100644 docs/textmate_bundle.md create mode 100644 lynglib/src/commonTest/kotlin/BindingHighlightTest.kt diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..486a232 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.zip filter=lfs diff=lfs merge=lfs -text diff --git a/bin/deploy_site b/bin/deploy_site index 4270c65..eebe406 100755 --- a/bin/deploy_site +++ b/bin/deploy_site @@ -46,6 +46,7 @@ esac die() { echo "ERROR: $*" 1>&2 ; exit 1; } +#./gradlew site:clean site:jsBrowserDistribution || die "compilation failed" ./gradlew site:clean site:jsBrowserDistribution || die "compilation failed" if [[ $? != 0 ]]; then @@ -77,8 +78,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... diff --git a/bin/local_release b/bin/local_release index 3b6a4a2..d9baccb 100755 --- a/bin/local_release +++ b/bin/local_release @@ -21,7 +21,10 @@ set -e file=./lyng/build/bin/linuxX64/releaseExecutable/lyng.kexe -./gradlew :lyng:linkReleaseExecutableLinuxX64 -strip $file -upx $file -cp $file ~/bin/lyng \ No newline at end of file +#./gradlew :lyng:linkReleaseExecutableLinuxX64 +#strip $file +#upx $file +cp $file ~/bin/lyng +cp $file ./distributables/lyng +zip ./distributables/lyng-linuxX64 ./distributables/lyng +rm ./distributables/lyng diff --git a/distributables/lyng-idea-0.0.2-SNAPSHOT.zip b/distributables/lyng-idea-0.0.2-SNAPSHOT.zip new file mode 100644 index 0000000..72afc0c --- /dev/null +++ b/distributables/lyng-idea-0.0.2-SNAPSHOT.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87fa872ca5fd55187d28f7ced04661147f735eee5753aedca737ef0582357182 +size 5766976 diff --git a/distributables/lyng-linuxX64.zip b/distributables/lyng-linuxX64.zip new file mode 100644 index 0000000..adf0bfc --- /dev/null +++ b/distributables/lyng-linuxX64.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36a47f3a4b1bdbe4ce76ad512b7c6e1b48e8da165e2aceff05c9c5a3928ad27d +size 1650617 diff --git a/distributables/lyng-textmate.zip b/distributables/lyng-textmate.zip new file mode 100644 index 0000000..9a6a322 --- /dev/null +++ b/distributables/lyng-textmate.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a87623e39f96f1813db506b113acae8aa2a921a094ffb48bcb9a98b19c787270 +size 4714 diff --git a/docs/idea_plugin.md b/docs/idea_plugin.md new file mode 100644 index 0000000..72ff19a --- /dev/null +++ b/docs/idea_plugin.md @@ -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) \ No newline at end of file diff --git a/docs/samples/fs_sample.lyng b/docs/samples/fs_sample.lyng index 152642f..9505c8a 100755 --- a/docs/samples/fs_sample.lyng +++ b/docs/samples/fs_sample.lyng @@ -1,4 +1,4 @@ -//#!/bin/env lyng +#!/bin/env lyng import lyng.io.fs import lyng.stdlib @@ -6,23 +6,14 @@ import lyng.stdlib val files = Path("../..").list().toList() val longestNameLength = files.maxOf { it.name.length } -/* -The comment for our test1. -There are _more_ data -*/ -fun test21() { -21 -} - -val format = "%-"+(longestNameLength+1) +"s %d" +val format = "%"+(longestNameLength+1) +"s %d" for( f in files ) { -var name = f.name -if( f.isDirectory() ) -name += "/" -println( format(name, f.size()) ) + var name = f.name + if( f.isDirectory() ) + name += "/" + println( format(name, f.size()) ) } -test21() diff --git a/docs/textmate_bundle.md b/docs/textmate_bundle.md new file mode 100644 index 0000000..9d0f7f1 --- /dev/null +++ b/docs/textmate_bundle.md @@ -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. + diff --git a/lyng-idea/build.gradle.kts b/lyng-idea/build.gradle.kts index b42d0c1..9483735 100644 --- a/lyng-idea/build.gradle.kts +++ b/lyng-idea/build.gradle.kts @@ -21,7 +21,7 @@ plugins { } group = "net.sergeych.lyng" -version = "0.0.1-SNAPSHOT" +version = "0.0.2-SNAPSHOT" kotlin { jvmToolchain(17) @@ -41,8 +41,10 @@ dependencies { intellij { type.set("IC") - // Run sandbox on IntelliJ IDEA 2024.3.x + // Build against a modern baseline. Install range is controlled by since/until below. version.set("2024.3.1") + // We manage 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" @@ -51,8 +53,24 @@ intellij { tasks { patchPluginXml { - // Compatible with 2024.3+ - sinceBuild.set("243") - untilBuild.set(null as String?) + // 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("buildInstallablePlugin") { + dependsOn("buildPlugin") + + // The Gradle IntelliJ Plugin produces: build/distributions/-.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 } } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt index 967740d..d1c15e2 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt @@ -28,6 +28,10 @@ import com.intellij.psi.PsiFile import kotlinx.coroutines.runBlocking import net.sergeych.lyng.Compiler import net.sergeych.lyng.Source +import net.sergeych.lyng.binding.Binder +import net.sergeych.lyng.binding.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 @@ -59,7 +63,8 @@ class LyngExternalAnnotator : ExternalAnnotator", text) val provider = IdeLenientImportProvider.create() runBlocking { Compiler.compileWithMini(src, provider, sink) } - } catch (_: Throwable) { + } catch (e: Throwable) { + if (e is com.intellij.openapi.progress.ProcessCanceledException) throw e // Fail softly: no semantic layer this pass return Result(collectedInfo.modStamp, emptyList()) } @@ -67,7 +72,7 @@ class LyngExternalAnnotator : ExternalAnnotator", text) - val out = ArrayList(64) + val out = ArrayList(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) @@ -85,7 +90,7 @@ class LyngExternalAnnotator : ExternalAnnotator putName(d.nameStart, d.name, LyngHighlighterColors.FUNCTION) + is MiniFunDecl -> putName(d.nameStart, d.name, LyngHighlighterColors.FUNCTION_DECLARATION) is MiniClassDecl -> putName(d.nameStart, d.name, LyngHighlighterColors.TYPE) is MiniValDecl -> putName( d.nameStart, @@ -142,6 +147,86 @@ class LyngExternalAnnotator : ExternalAnnotator>(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>() + + 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(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 + } + return Result(collectedInfo.modStamp, out) } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngColorSettingsPage.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngColorSettingsPage.kt index db9f7c6..f59fd42 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngColorSettingsPage.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngColorSettingsPage.kt @@ -60,6 +60,7 @@ class LyngColorSettingsPage : ColorSettingsPage { 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), diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngHighlighterColors.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngHighlighterColors.kt index b59bad9..1142ff9 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngHighlighterColors.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngHighlighterColors.kt @@ -46,15 +46,22 @@ object LyngHighlighterColors { "LYNG_PUNCT", DefaultLanguageHighlighterColors.DOT ) - // Semantic layer keys (placeholders for now) + // Semantic layer keys val VARIABLE: TextAttributesKey = TextAttributesKey.createTextAttributesKey( - "LYNG_VARIABLE", DefaultLanguageHighlighterColors.LOCAL_VARIABLE + // 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( - "LYNG_FUNCTION", DefaultLanguageHighlighterColors.FUNCTION_CALL + // 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 diff --git a/lyng-idea/src/main/resources/META-INF/plugin.xml b/lyng-idea/src/main/resources/META-INF/plugin.xml index 4aeb0e6..3dcdc71 100644 --- a/lyng-idea/src/main/resources/META-INF/plugin.xml +++ b/lyng-idea/src/main/resources/META-INF/plugin.xml @@ -16,13 +16,17 @@ --> + + net.sergeych.lyng.idea Lyng Language Support - Sergeych Works + Sergey Chernov diff --git a/lyng-idea/src/main/resources/META-INF/pluginIcon.svg b/lyng-idea/src/main/resources/META-INF/pluginIcon.svg index 6b62c82..128e976 100644 --- a/lyng-idea/src/main/resources/META-INF/pluginIcon.svg +++ b/lyng-idea/src/main/resources/META-INF/pluginIcon.svg @@ -15,16 +15,17 @@ - limitations under the License. - --> - - - Lyng Plugin Icon (temporary λ) - + - - - - - + + + λ + y + diff --git a/lyng-idea/src/main/resources/icons/lyng_file.svg b/lyng-idea/src/main/resources/icons/lyng_file.svg index da8deb8..128e976 100644 --- a/lyng-idea/src/main/resources/icons/lyng_file.svg +++ b/lyng-idea/src/main/resources/icons/lyng_file.svg @@ -15,16 +15,17 @@ - limitations under the License. - --> - - - Lyng File Icon (temporary λ) - + - - - - - + + + λ + y + diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt index c378445..b2c50fa 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt @@ -198,6 +198,58 @@ object Binder { } } + // Extra pass: detect local val/var declarations that are not present in mini.declarations + // (e.g., locals inside blocks and top-level statements). Use token stream heuristics. + run { + val spans = highlighter.highlight(text) + var iTok = 0 + while (iTok < spans.size) { + val t = spans[iTok] + if (t.kind == HighlightKind.Keyword) { + val kw = try { text.substring(t.range.start, t.range.endExclusive) } catch (_: Throwable) { "" } + if (kw.equals("val", ignoreCase = true) || kw.equals("var", ignoreCase = true)) { + // Find the next Identifier span which should be the declared name + var j = iTok + 1 + var nameStart = -1 + var nameEnd = -1 + while (j < spans.size) { + val s = spans[j] + if (s.kind == HighlightKind.Identifier) { + nameStart = s.range.start + nameEnd = s.range.endExclusive + break + } + // Stop if we hit newline/comment too far ahead (avoid wide scans) + if (s.kind == HighlightKind.Comment) break + j++ + } + if (nameStart >= 0 && nameEnd > nameStart) { + // Skip if a symbol with exactly this decl range already exists + val exists = symbols.any { it.declStart == nameStart && it.declEnd == nameEnd } + if (!exists) { + // Determine enclosing function body (if any) to attach as local; else treat as top-level + val inFn = functions.asSequence() + .filter { it.rangeEnd > it.rangeStart && nameStart >= it.rangeStart && nameStart <= it.rangeEnd } + .maxByOrNull { it.rangeEnd - it.rangeStart } + val kind = if (kw.equals("var", true)) SymbolKind.Var else SymbolKind.Val + if (inFn != null) { + val localSym = Symbol(nextId++, text.substring(nameStart, nameEnd), kind, nameStart, nameEnd, containerId = inFn.id) + symbols += localSym + inFn.locals += localSym.id + } else { + val localSym = Symbol(nextId++, text.substring(nameStart, nameEnd), kind, nameStart, nameEnd, containerId = null) + symbols += localSym + topLevelByName.getOrPut(localSym.name) { mutableListOf() }.add(localSym.id) + } + } + } + iTok = j // continue from the name or comment + } + } + iTok++ + } + } + // Build name -> symbol ids index per function (locals+params) and per class (fields) for faster resolution data class Idx(val byName: Map>) fun buildIndex(ids: List): Idx { diff --git a/lynglib/src/commonTest/kotlin/BindingHighlightTest.kt b/lynglib/src/commonTest/kotlin/BindingHighlightTest.kt new file mode 100644 index 0000000..35f5400 --- /dev/null +++ b/lynglib/src/commonTest/kotlin/BindingHighlightTest.kt @@ -0,0 +1,182 @@ +/* + * 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 + +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.binding.Binder +import net.sergeych.lyng.binding.SymbolKind +import net.sergeych.lyng.miniast.MiniAstBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class BindingHighlightTest { + + private suspend fun compileWithMini(code: String): Pair { + val sink = MiniAstBuilder() + val script = Compiler.compileWithMini(code.trimIndent(), sink) + return script to sink + } + + @Test + fun binder_registers_top_level_var_and_binds_usages() = runTest { + val code = """ + var counter = 0 + counter = counter + 1 + println(counter) + """ + + val text = code.trimIndent() + val (_, sink) = compileWithMini(text) + val mini = sink.build() + assertNotNull(mini, "Mini-AST must be built") + + val binding = Binder.bind(text, mini!!) + + // Find the top-level symbol for counter and ensure it is mutable (Var) + val sym = binding.symbols.firstOrNull { it.name == "counter" } + assertNotNull(sym, "Top-level var 'counter' must be registered as a symbol") + assertEquals(SymbolKind.Var, sym.kind, "'counter' declared with var should be SymbolKind.Var") + + // Declaration position + val declRange = sym.declStart to sym.declEnd + + // Collect all references to counter (excluding the declaration itself) + val refs = binding.references.filter { it.symbolId == sym.id && (it.start to it.end) != declRange } + assertTrue(refs.isNotEmpty(), "Usages of top-level var 'counter' should be bound") + + // Expect at least two usages: assignment LHS and println argument + assertTrue(refs.size >= 2, "Expected at least two usages of 'counter'") + } + + @Test + fun binder_registers_top_level_val_and_binds_usages() = runTest { + val code = """ + val answer = 41 + val next = answer + 1 + println(answer) + """ + + val text = code.trimIndent() + val (_, sink) = compileWithMini(text) + val mini = sink.build() + assertNotNull(mini, "Mini-AST must be built") + + val binding = Binder.bind(text, mini!!) + + val sym = binding.symbols.firstOrNull { it.name == "answer" } + assertNotNull(sym, "Top-level val 'answer' must be registered as a symbol") + assertEquals(SymbolKind.Val, sym.kind, "'answer' declared with val should be SymbolKind.Val") + + val declRange = sym.declStart to sym.declEnd + val refs = binding.references.filter { it.symbolId == sym.id && (it.start to it.end) != declRange } + assertTrue(refs.isNotEmpty(), "Usages of top-level val 'answer' should be bound") + } + + @Test + fun binder_binds_locals_in_top_level_block_and_function_call() = runTest { + val code = """ + fun test21() { + 21 + } + + val format = "%" + "s" + + for( f in files ) { + var name = f.name + if( f.isDirectory() ) + println("is directory") + name += "/" + println( format(name, f.size()) ) + } + + test21() + """ + + val text = code.trimIndent() + val (_, sink) = compileWithMini(text) + val mini = sink.build() + assertNotNull(mini, "Mini-AST must be built") + + val binding = Binder.bind(text, mini!!) + + // Ensure we registered the local var/val symbol for `name` + val nameSym = binding.symbols.firstOrNull { it.name == "name" } + assertNotNull(nameSym, "Local variable 'name' should be registered as a symbol") + assertEquals(SymbolKind.Var, nameSym.kind, "'name' is declared with var and must be SymbolKind.Var") + + // Ensure there is at least one usage reference to `name` (not just the declaration) + val nameRefs = binding.references.filter { it.symbolId == nameSym.id } + println("[DEBUG_LOG] name decl at ${nameSym.declStart}..${nameSym.declEnd}") + println("[DEBUG_LOG] name refs: ${nameRefs.map { it.start to it.end }}") + assertTrue(nameRefs.isNotEmpty(), "Usages of 'name' should be bound to its declaration") + + // We expect at least two usages of `name`: in "+=" and in the call argument. + assertTrue(nameRefs.size >= 2, "Binder should bind multiple usages of 'name'") + + // Ensure function call at top-level is bound to the function symbol + val fnSym = binding.symbols.firstOrNull { it.name == "test21" && it.kind == SymbolKind.Function } + assertNotNull(fnSym, "Function 'test21' symbol must be present") + val callIdx = text.lastIndexOf("test21()") + assertTrue(callIdx > 0, "Test snippet must contain a 'test21()' call") + val callRef = binding.references.firstOrNull { it.symbolId == fnSym.id && it.start == callIdx && it.end == callIdx + "test21".length } + assertNotNull(callRef, "Binder should bind the top-level call 'test21()' to its declaration") + + // Sanity: no references point exactly to the declaration range of test21 + val declStart = fnSym.declStart + val declEnd = fnSym.declEnd + assertTrue(binding.references.none { it.start == declStart && it.end == declEnd }, "Declaration should not be duplicated as a reference") + } + + @Test + fun binder_binds_name_used_in_string_literal_invoke() = runTest { + val code = """ + val format = "%" + "s" + + for( f in files ) { + var name = f.name + if( f.isDirectory() ) + println("%s is directory"(name)) + name += "/" + println( format(name, f.size()) ) + } + """ + + val text = code.trimIndent() + val (_, sink) = compileWithMini(text) + val mini = sink.build() + assertNotNull(mini, "Mini-AST must be built") + + val binding = Binder.bind(text, mini!!) + + val nameSym = binding.symbols.firstOrNull { it.name == "name" && (it.kind == SymbolKind.Var || it.kind == SymbolKind.Val) } + assertNotNull(nameSym, "Local variable 'name' should be registered as a symbol") + + // Find the specific usage inside string-literal invocation: "%s is directory"(name) + val pattern = "\"%s is directory\"(name)" + val lineIdx = text.indexOf(pattern) + assertTrue(lineIdx >= 0, "Pattern with string invoke should be present in the snippet") + val nameStart = lineIdx + pattern.indexOf("name") + val nameEnd = nameStart + "name".length + + val hasRefAtInvoke = binding.references.any { it.symbolId == nameSym.id && it.start == nameStart && it.end == nameEnd } + println("[DEBUG_LOG] refs for 'name': ${binding.references.filter { it.symbolId == nameSym.id }.map { it.start to it.end }}") + assertTrue(hasRefAtInvoke, "Binder should bind 'name' used as an argument to a string-literal invocation") + } +} diff --git a/site/src/jsMain/resources/index.html b/site/src/jsMain/resources/index.html index 06fdc28..7d7bee5 100644 --- a/site/src/jsMain/resources/index.html +++ b/site/src/jsMain/resources/index.html @@ -228,6 +228,20 @@ + +