idea plugin 0.0.1-SNAPSHOT: basic coloring and editing aids

This commit is contained in:
Sergey Chernov 2025-11-30 23:56:59 +01:00
parent 9c342c5c72
commit 06e8e1579d
46 changed files with 4192 additions and 6 deletions

View File

@ -40,6 +40,7 @@ and it is multithreaded on platforms supporting it (automatically, no code chang
- [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

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.

View File

@ -1,4 +1,4 @@
#!/bin/env lyng
//#!/bin/env lyng
import lyng.io.fs
import lyng.stdlib
@ -6,9 +6,23 @@ import lyng.stdlib
val files = Path("../..").list().toList()
val longestNameLength = files.maxOf { it.name.length }
val format = "%-"+(longestNameLength+1) +"s %d"
for( f in files ) {
var name = f.name
if( f.isDirectory() ) name += "/"
println( format(name, f.size()) )
/*
The comment for our test1.
There are _more_ data
*/
fun test21() {
21
}
val format = "%-"+(longestNameLength+1) +"s %d"
for( f in files )
{
var name = f.name
if( f.isDirectory() )
name += "/"
println( format(name, f.size()) )
}
test21()

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,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.
*
*/
plugins {
kotlin("jvm")
id("org.jetbrains.intellij") version "1.17.3"
}
group = "net.sergeych.lyng"
version = "0.0.1-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"))
}
intellij {
type.set("IC")
// Run sandbox on IntelliJ IDEA 2024.3.x
version.set("2024.3.1")
// Include only available bundled plugins for this IDE build
plugins.set(listOf(
"com.intellij.java"
))
}
tasks {
patchPluginXml {
// Compatible with 2024.3+
sinceBuild.set("243")
untilBuild.set(null as String?)
}
}

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,168 @@
/*
* 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.Source
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)
data class Span(val start: Int, val end: Int, val key: com.intellij.openapi.editor.colors.TextAttributesKey)
data class Result(val modStamp: Long, val spans: List<Span>)
override fun collectInformation(file: PsiFile): Input? {
val doc: Document = file.viewProvider.document ?: return null
return Input(doc.text, doc.modificationStamp)
}
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()
try {
// Call suspend API from blocking context
val src = Source("<ide>", text)
val provider = IdeLenientImportProvider.create()
runBlocking { Compiler.compileWithMini(src, provider, sink) }
} catch (_: Throwable) {
// Fail softly: no semantic layer this pass
return Result(collectedInfo.modStamp, emptyList())
}
ProgressManager.checkCanceled()
val mini = sink.build() ?: return Result(collectedInfo.modStamp, emptyList())
val source = Source("<ide>", text)
val out = ArrayList<Span>(64)
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)
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()
return Result(collectedInfo.modStamp, out)
}
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)
for (s in result.spans) {
holder.newSilentAnnotation(HighlightSeverity.INFORMATION)
.range(TextRange(s.start, s.end))
.textAttributes(s.key)
.create()
}
}
companion object {
private val CACHE_KEY: Key<Result> = Key.create("LYNG_SEMANTIC_CACHE")
}
}

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,173 @@
/*
* 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.Source
import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.util.IdeLenientImportProvider
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)
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
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 = wordRangeAt(text, offset) ?: run {
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)
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 return null.
val sink = MiniAstBuilder()
try {
// Use lenient import provider so unresolved imports (e.g., lyng.io.fs) don't break docs
val src = Source("<ide>", text)
val provider = IdeLenientImportProvider.create()
runBlocking { Compiler.compileWithMini(src, provider, sink) }
} catch (t: Throwable) {
log.warn("[LYNG_DEBUG] QuickDoc: compileWithMini failed: ${t.message}")
return null
}
val mini = sink.build() ?: return null
val source = Source("<ide>", text)
// Try resolve to: function param at position, function/class/val declaration at position
// 1) Check declarations whose name range contains offset
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) {
log.info("[LYNG_DEBUG] QuickDoc: matched decl '${d.name}' kind=${d::class.simpleName}")
return renderDeclDoc(d)
}
}
// 2) Check parameters of functions
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) {
log.info("[LYNG_DEBUG] QuickDoc: matched param '${p.name}' in fun '${fn.name}'")
return renderParamDoc(fn, p)
}
}
}
// 3) As a fallback, if the caret is on an identifier text that matches any declaration name, show that
mini.declarations.firstOrNull { it.name == ident }?.let {
log.info("[LYNG_DEBUG] QuickDoc: fallback by name '${it.name}' kind=${it::class.simpleName}")
return renderDeclDoc(it)
}
log.info("[LYNG_DEBUG] QuickDoc: nothing found for ident='$ident'")
return null
}
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 doc = d.doc?.raw?.let { htmlEscape(it).replace("\n", "<br/>") }
val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>")
if (!doc.isNullOrBlank()) sb.append("<div class='doc-body'>").append(doc).append("</div>")
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 typeOf(t: MiniTypeRef?): String = when (t) {
is MiniTypeName -> ": ${t.segments.joinToString(".") { it.name }}"
is MiniGenericType -> ": ${typeOf(t.base).removePrefix(": ")}<${t.args.joinToString(", ") { typeOf(it).removePrefix(": ") }}>"
is MiniFunctionType -> ": (..) -> .."
is MiniTypeVar -> ": ${t.name}"
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 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 isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit()
}

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,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,207 @@
/*
* 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
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
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 == "}") {
// Optionally reindent the enclosed block without manually touching the '}' line.
val settings = LyngFormatterSettings.getInstance(project)
if (settings.reindentClosedBlockOnEnter) {
reindentClosedBlockAroundBrace(project, file, doc, prevLine)
}
}
}
// 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,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,69 @@
/*
* 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("Variable (semantic)", LyngHighlighterColors.VARIABLE),
AttributesDescriptor("Value (semantic)", LyngHighlighterColors.VALUE),
AttributesDescriptor("Function (semantic)", LyngHighlighterColors.FUNCTION),
AttributesDescriptor("Type (semantic)", LyngHighlighterColors.TYPE),
AttributesDescriptor("Namespace (semantic)", LyngHighlighterColors.NAMESPACE),
AttributesDescriptor("Parameter (semantic)", LyngHighlighterColors.PARAMETER),
)
override fun getColorDescriptors(): Array<ColorDescriptor> = ColorDescriptor.EMPTY_ARRAY
}

View File

@ -0,0 +1,68 @@
/*
* 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 (placeholders for now)
val VARIABLE: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_VARIABLE", DefaultLanguageHighlighterColors.LOCAL_VARIABLE
)
val VALUE: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_VALUE", DefaultLanguageHighlighterColors.INSTANCE_FIELD
)
val FUNCTION: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_FUNCTION", DefaultLanguageHighlighterColors.FUNCTION_CALL
)
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
)
}

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,69 @@
/*
* 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,
)
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 }
companion object {
@JvmStatic
fun getInstance(project: Project): LyngFormatterSettings = project.getService(LyngFormatterSettings::class.java)
}
}

View File

@ -0,0 +1,87 @@
/*
* 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
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]")
// 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)."
p.add(spacingCb)
p.add(wrappingCb)
p.add(reindentClosedBlockCb)
p.add(reindentPasteCb)
p.add(normalizeBlockCommentIndentCb)
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
}
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
}
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
}
}

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,82 @@
<!--
~ 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>
<id>net.sergeych.lyng.idea</id>
<name>Lyng Language Support</name>
<vendor email="real.sergeych@gmail.com">Sergeych Works</vendor>
<description>
<![CDATA[
Basic Lyng language support: file type, syntax highlighting scaffold, and quick docs/highlighter stubs.
]]>
</description>
<depends>com.intellij.modules.platform</depends>
<!-- Needed for editor language features (syntax highlighting, etc.) -->
<depends>com.intellij.modules.lang</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"/>
<!-- Quick documentation provider bound to Lyng language -->
<lang.documentationProvider language="Lyng" implementationClass="net.sergeych.lyng.idea.docs.LyngDocumentationProvider"/>
<!-- 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"/>
<!-- 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,30 @@
<?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>
<style>
.glyph { fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
</style>
</defs>
<g transform="translate(2,2)">
<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.2 KiB

View File

@ -0,0 +1,30 @@
<?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>
<g transform="translate(0.5,0.5)">
<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.2 KiB

View File

@ -72,6 +72,11 @@ val baseScopeDefer = globalDefer {
fun runMain(args: Array<String>) {
if(args.isNotEmpty()) {
// CLI formatter: lyng fmt [--check] [--in-place] <files...>
if (args[0] == "fmt") {
formatCli(args.drop(1))
return
}
if( args.size >= 2 && args[0] == "--" ) {
// -- -file.lyng <args>
executeFileWithArgs(args[1], args.drop(2))
@ -86,6 +91,52 @@ fun runMain(args: Array<String>) {
Lyng { runBlocking { it() } }.main(args)
}
private fun formatCli(args: List<String>) {
var checkOnly = false
var inPlace = true
var enableSpacing = false
var enableWrapping = false
val files = mutableListOf<String>()
for (a in args) {
when (a) {
"--check" -> { checkOnly = true; inPlace = false }
"--in-place", "-i" -> inPlace = true
"--spacing" -> enableSpacing = true
"--wrap", "--wrapping" -> enableWrapping = true
else -> files += a
}
}
if (files.isEmpty()) {
println("Usage: lyng fmt [--check] [--in-place|-i] [--spacing] [--wrap] <file1.lyng> [file2.lyng ...]")
exit(1)
return
}
var changed = false
val cfg = net.sergeych.lyng.format.LyngFormatConfig(
applySpacing = enableSpacing,
applyWrapping = enableWrapping,
)
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)
if (formatted != original) {
changed = true
if (checkOnly) {
println(path)
} else if (inPlace) {
FileSystem.SYSTEM.write(p) { writeUtf8(formatted) }
} else {
// default to stdout if not in-place and not --check
println("--- $path (formatted) ---\n$formatted")
}
}
}
if (checkOnly) {
exit(if (changed) 2 else 0)
}
}
private class Lyng(val launcher: (suspend () -> Unit) -> Unit) : CliktCommand() {
override val printHelpOnEmptyArgs = true

View File

@ -0,0 +1,118 @@
/*
* 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.format
/**
* Simple text-based brace utilities for Lyng.
*
* The scan ignores everything after `//` on a line.
*/
object BraceUtils {
/**
* Finds the index of the matching opening '{' for the closing '}' at [closeIndex].
* Returns null if not found. The scan moves left from [closeIndex] and ignores text after // comments.
*/
fun findMatchingOpenBrace(text: CharSequence, closeIndex: Int): Int? {
if (closeIndex < 0 || closeIndex >= text.length || text[closeIndex] != '}') return null
var i = closeIndex
var balance = 0
while (i >= 0) {
val ch = text[i]
if (ch == '\n') {
// skip comment tails on this line by finding // and ignoring chars after it
val lineStart = lastIndexOf(text, '\n', i - 1) + 1
val slashes = indexOf(text, '/', lineStart, i)
if (slashes >= 0 && slashes + 1 <= i && slashes + 1 < text.length && text[slashes + 1] == '/') {
i = slashes - 1
continue
}
}
when (ch) {
'}' -> balance++
'{' -> {
// When scanning left, the matching '{' is the point where balance==1
// (i.e., it pairs with the first '}' we started from)
if (balance == 1) return i else if (balance > 1) balance--
}
}
i--
}
return null
}
/** Returns the start offset of the line that contains [offset]. */
fun lineStart(text: CharSequence, offset: Int): Int {
var i = (offset.coerceIn(0, text.length)) - 1
while (i >= 0) {
if (text[i] == '\n') return i + 1
i--
}
return 0
}
/** Returns the end offset (exclusive) of the line that contains [offset]. */
fun lineEnd(text: CharSequence, offset: Int): Int {
var i = offset.coerceIn(0, text.length)
while (i < text.length) {
if (text[i] == '\n') return i
i++
}
return text.length
}
/**
* Finds the enclosing block range when pressing Enter after a closing brace '}' at [closeIndex].
* Returns an [IntRange] covering from the start of the opening-brace line through the end of the
* closing-brace line. If [includeTrailingNewline] is true and there is a newline after '}', the range
* extends to the start of the next line.
*/
fun findEnclosingBlockRange(
text: CharSequence,
closeIndex: Int,
includeTrailingNewline: Boolean = true
): IntRange? {
// Be tolerant: if closeIndex doesn't point at '}', scan left to nearest '}'
var ci = closeIndex.coerceIn(0, text.length)
while (ci >= 0 && text[ci] != '}') ci--
if (ci < 0) return null
val open = findMatchingOpenBrace(text, ci) ?: return null
val start = lineStart(text, open)
val closeLineEnd = lineEnd(text, ci)
val endExclusive = if (includeTrailingNewline && closeLineEnd < text.length && text[closeLineEnd] == '\n')
closeLineEnd + 1 else closeLineEnd
return start until endExclusive
}
private fun lastIndexOf(text: CharSequence, ch: Char, fromIndex: Int): Int {
var i = fromIndex
while (i >= 0) {
if (text[i] == ch) return i
i--
}
return -1
}
private fun indexOf(text: CharSequence, ch: Char, fromIndex: Int, toIndexExclusive: Int): Int {
var i = fromIndex
val to = toIndexExclusive.coerceAtMost(text.length)
while (i < to) {
if (text[i] == ch) return i
i++
}
return -1
}
}

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.format
/**
* Formatting configuration for Lyng source code.
* Defaults are Kotlin-like.
*/
data class LyngFormatConfig(
val indentSize: Int = 4,
val useTabs: Boolean = false,
val continuationIndentSize: Int = 4,
val maxLineLength: Int = 120,
val applySpacing: Boolean = false,
val applyWrapping: Boolean = false,
val trailingComma: Boolean = false,
) {
init {
require(indentSize > 0) { "indentSize must be > 0" }
require(continuationIndentSize > 0) { "continuationIndentSize must be > 0" }
require(maxLineLength > 0) { "maxLineLength must be > 0" }
}
}

View File

@ -0,0 +1,463 @@
/*
* 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.format
/**
* Lightweight, PSIfree formatter for Lyng source code.
*
* Phase 1 focuses on indentation from scratch (idempotent). Spacing/wrapping may be
* extended later based on [LyngFormatConfig] flags.
*/
object LyngFormatter {
/** Returns the input with indentation recomputed from scratch, line by line. */
fun reindent(text: String, config: LyngFormatConfig = LyngFormatConfig()): String {
val lines = text.split('\n')
val sb = StringBuilder(text.length + lines.size)
var blockLevel = 0
var parenBalance = 0
var bracketBalance = 0
var prevBracketContinuation = false
val bracketBaseStack = ArrayDeque<String>()
fun codePart(s: String): String {
val idx = s.indexOf("//")
return if (idx >= 0) s.substring(0, idx) else s
}
fun indentOf(level: Int, continuation: Int): String =
if (config.useTabs) "\t".repeat(level) + " ".repeat(continuation)
else " ".repeat(level * config.indentSize + continuation)
var awaitingSingleIndent = false
fun isControlHeaderNoBrace(s: String): Boolean {
val t = s.trim()
if (t.isEmpty()) return false
// match: if (...) | else if (...) | else
val isIf = Regex("^if\\s*\\(.*\\)\\s*$").matches(t)
val isElseIf = Regex("^else\\s+if\\s*\\(.*\\)\\s*$").matches(t)
val isElse = t == "else"
return isIf || isElseIf || isElse
}
for ((i, rawLine) in lines.withIndex()) {
val line = rawLine
val code = codePart(line)
val trimmedStart = code.dropWhile { it == ' ' || it == '\t' }
// Compute effective indent level for this line
var effectiveLevel = blockLevel
if (trimmedStart.startsWith("}")) effectiveLevel = (effectiveLevel - 1).coerceAtLeast(0)
// else/catch/finally should align with the parent block level; no extra dedent here,
// because the preceding '}' has already reduced [blockLevel] appropriately.
// Single-line control header (if/else/else if) without braces: indent the next
// non-empty, non-'}', non-'else' line by one extra level
val applyAwaiting = awaitingSingleIndent && trimmedStart.isNotEmpty() &&
!trimmedStart.startsWith("else") && !trimmedStart.startsWith("}")
if (applyAwaiting) effectiveLevel += 1
val firstChar = trimmedStart.firstOrNull()
// Do not apply continuation on a line that starts with a closer ')' or ']'
val startsWithCloser = firstChar == ')' || firstChar == ']'
// Kotlin-like rule: continuation persists while inside parentheses; it applies
// even on the line that starts with ')'. For brackets, do not apply on the ']' line itself.
// Continuation rules:
// - For brackets: one-shot continuation for first element after '[', and while inside brackets
// (except on the ']' line) continuation equals one unit.
// - For parentheses: continuation depth scales with nested level; e.g., inside two nested
// parentheses lines get 2 * continuationIndentSize. No continuation on a line that starts with ')'.
val parenContLevels = if (parenBalance > 0 && firstChar != ')') parenBalance else 0
val continuation = when {
// One-shot continuation when previous line ended with '[' to align first element
prevBracketContinuation && firstChar != ']' -> config.continuationIndentSize
// While inside brackets, continuation applies (single unit) except on the closing line
bracketBalance > 0 && firstChar != ']' -> config.continuationIndentSize
// While inside parentheses, continuation applies scaled by nesting level
parenContLevels > 0 -> config.continuationIndentSize * parenContLevels
else -> 0
}
// Replace leading whitespace with the exact target indent; but keep fully blank lines truly empty
val contentStart = line.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) line.length else it }
var content = line.substring(contentStart)
// Collapse spaces right after an opening '[' to avoid "[ 1"; make it "[1"
if (content.startsWith("[")) {
content = "[" + content.drop(1).trimStart()
}
// Determine base indent: for bracket blocks, preserve the exact leading whitespace
val leadingWs = if (contentStart > 0) line.substring(0, contentStart) else ""
val currentBracketBase = if (bracketBaseStack.isNotEmpty()) bracketBaseStack.last() else null
val indentString = if (currentBracketBase != null) {
val cont = if (continuation > 0) {
if (config.useTabs) "\t" else " ".repeat(continuation)
} else ""
currentBracketBase + cont
} else indentOf(effectiveLevel, continuation)
if (content.isEmpty()) {
// preserve truly blank line as empty to avoid trailing spaces on empty lines
// (also keeps continuation blocks visually clean)
// do nothing, just append nothing; newline will be appended below if needed
} else {
sb.append(indentString).append(content)
}
// New line (keep EOF semantics similar to input)
if (i < lines.lastIndex) sb.append('\n')
// Update balances using this line's code content
for (ch in code) when (ch) {
'{' -> blockLevel++
'}' -> if (blockLevel > 0) blockLevel--
'(' -> parenBalance++
')' -> if (parenBalance > 0) parenBalance--
'[' -> bracketBalance++
']' -> if (bracketBalance > 0) bracketBalance--
}
// Update awaitingSingleIndent based on current line
if (applyAwaiting && trimmedStart.isNotEmpty()) {
// we have just consumed the body line
awaitingSingleIndent = false
} else {
// start awaiting if current line is a control header without '{'
val endsWithBrace = code.trimEnd().endsWith("{")
if (!endsWithBrace && isControlHeaderNoBrace(code)) {
awaitingSingleIndent = true
}
}
// Prepare one-shot bracket continuation if the current line ends with '['
// (first element line gets continuation even before balances update propagate).
val endsWithBracket = code.trimEnd().endsWith("[")
// Reset one-shot flag after we used it on this line
if (prevBracketContinuation) prevBracketContinuation = false
// Set for the next iteration if current line ends with '['
if (endsWithBracket) {
prevBracketContinuation = true
// Push base indent of the '[' line for subsequent lines in this bracket block
bracketBaseStack.addLast(leadingWs)
}
// If this line starts with ']' (closing bracket), pop the preserved base for this bracket level
if (trimmedStart.startsWith("]") && bracketBaseStack.isNotEmpty()) {
// ensure stack stays in sync with bracket levels
bracketBaseStack.removeLast()
}
}
return sb.toString()
}
/** Full format. Currently performs indentation only; spacing/wrapping can be added later. */
fun format(text: String, config: LyngFormatConfig = LyngFormatConfig()): String {
// Phase 1: indentation
val indented = reindent(text, config)
if (!config.applySpacing && !config.applyWrapping) return indented
// Phase 2: minimal, safe spacing (PSI-free). Skip block comments completely and
// only apply spacing to the part before '//' on each line.
val lines = indented.split('\n')
val out = StringBuilder(indented.length)
var inBlockComment = false
for ((i, rawLine) in lines.withIndex()) {
var line = rawLine
if (config.applySpacing) {
if (inBlockComment) {
// Pass-through until we see the end of the block comment on some line
val end = line.indexOf("*/")
if (end >= 0) {
inBlockComment = false
}
} else {
// If this line opens a block comment, apply spacing only before the opener
val startIdx = line.indexOf("/*")
val endIdx = line.indexOf("*/")
if (startIdx >= 0 && (endIdx < 0 || endIdx < startIdx)) {
val before = line.substring(0, startIdx)
val after = line.substring(startIdx)
val commentIdx = before.indexOf("//")
val code = if (commentIdx >= 0) before.substring(0, commentIdx) else before
val tail = if (commentIdx >= 0) before.substring(commentIdx) else ""
val spaced = applyMinimalSpacing(code)
line = (spaced + tail) + after
inBlockComment = true
} else {
// Normal code line: respect single-line comments
val commentIdx = line.indexOf("//")
if (commentIdx >= 0) {
val code = line.substring(0, commentIdx)
val tail = line.substring(commentIdx)
val spaced = applyMinimalSpacing(code)
line = spaced + tail
} else {
line = applyMinimalSpacing(line)
}
}
}
}
out.append(line.trimEnd())
if (i < lines.lastIndex) out.append('\n')
}
val spacedText = out.toString()
// Phase 3: controlled wrapping (only if enabled)
if (!config.applyWrapping) return spacedText
return applyControlledWrapping(spacedText, config)
}
private fun startsWithWord(s: String, w: String): Boolean =
s.startsWith(w) && s.getOrNull(w.length)?.let { !it.isLetterOrDigit() && it != '_' } != false
/**
* Reindents a slice of [text] specified by [range] and returns a new string with that slice replaced.
* By default, preserves the base indent of the first line in the slice (so the block stays at
* the same outer indentation level) while normalizing its inner structure according to the formatter rules.
*/
fun reindentRange(
text: String,
range: IntRange,
config: LyngFormatConfig = LyngFormatConfig(),
preserveBaseIndent: Boolean = true,
baseIndentFrom: Int? = null
): String {
if (range.isEmpty()) return text
val start = range.first.coerceIn(0, text.length)
val endExclusive = (range.last + 1).coerceIn(start, text.length)
val slice = text.substring(start, endExclusive)
val formattedZero = reindent(slice, config)
val resultSlice = if (!preserveBaseIndent) formattedZero else run {
// Compute base indent from the beginning of the current line up to [baseStart].
// If there is any non-whitespace between the line start and [start], we consider it a mid-line paste
// and do not apply a base indent (to avoid corrupting code). Otherwise, preserve the existing
// leading whitespace up to the caret as the base for the pasted block.
val baseStartIndex = (baseIndentFrom ?: start).coerceIn(0, text.length)
val lineStart = run {
var i = (baseStartIndex - 1).coerceAtLeast(0)
while (i >= 0 && text[i] != '\n') i--
i + 1
}
var i = lineStart
var onlyWs = true
val base = StringBuilder()
while (i < baseStartIndex) {
val ch = text[i]
if (ch == ' ' || ch == '\t') {
base.append(ch)
} else {
onlyWs = false
break
}
i++
}
var baseIndent = if (onlyWs) base.toString() else ""
var parentBaseIndent: String? = baseIndent
if (baseIndent.isEmpty()) {
// Fallback: use the indent of the nearest previous non-empty line as base.
// This helps when pasting at column 0 of a blank line inside a block.
var j = (lineStart - 2).coerceAtLeast(0)
// move j to end of previous line
while (j >= 0 && text[j] != '\n') j--
// now scan lines backwards to find a non-empty line
var foundIndent: String? = null
var prevLineEndsWithOpenBrace = false
var p = (j - 1).coerceAtLeast(0)
while (p >= 0) {
// find start of this line
var ls = p
while (ls >= 0 && text[ls] != '\n') ls--
val startLine = ls + 1
val endLine = p + 1
// extract line
val lineText = text.subSequence(startLine, endLine)
val trimmed = lineText.dropWhile { it == ' ' || it == '\t' }
if (trimmed.isNotEmpty()) {
// take its leading whitespace as base indent
val wsLen = lineText.length - trimmed.length
foundIndent = lineText.substring(0, wsLen)
// Decide if this line ends with an opening brace before any // comment
val codePart = run {
val s = lineText.toString()
val idx = s.indexOf("//")
if (idx >= 0) s.substring(0, idx) else s
}
prevLineEndsWithOpenBrace = codePart.trimEnd().endsWith("{")
break
}
// move to previous line
p = (ls - 1).coerceAtLeast(-1)
if (p < 0) break
}
if (foundIndent != null) {
// If we are right after a line that opens a block, the base for the pasted
// content should be one indent unit deeper than that line's base.
parentBaseIndent = foundIndent
baseIndent = if (prevLineEndsWithOpenBrace) {
if (config.useTabs) foundIndent + "\t" else foundIndent + " ".repeat(config.indentSize.coerceAtLeast(1))
} else foundIndent
}
if (baseIndent.isEmpty()) {
// Second fallback: compute structural block level up to this line and use it as base.
// We scan from start to lineStart and compute '{' '}' balance ignoring // comments.
var level = 0
var iScan = 0
while (iScan < lineStart && iScan < text.length) {
// read line
var lineEnd = iScan
while (lineEnd < text.length && text[lineEnd] != '\n') lineEnd++
val raw = text.subSequence(iScan, lineEnd).toString()
val code = raw.substringBefore("//")
for (ch in code) {
when (ch) {
'{' -> level++
'}' -> if (level > 0) level--
}
}
iScan = if (lineEnd < text.length) lineEnd + 1 else lineEnd
}
if (level > 0) {
parentBaseIndent = if (config.useTabs) "\t".repeat(level - 1) else " ".repeat((level - 1).coerceAtLeast(0) * config.indentSize.coerceAtLeast(1))
baseIndent = if (config.useTabs) "\t".repeat(level) else " ".repeat(level * config.indentSize.coerceAtLeast(1))
}
}
}
if (baseIndent.isEmpty()) formattedZero else {
val sb = StringBuilder(formattedZero.length + 32)
var i = 0
while (i < formattedZero.length) {
val lineStart = i
var lineEnd = formattedZero.indexOf('\n', lineStart)
if (lineEnd < 0) lineEnd = formattedZero.length
val line = formattedZero.substring(lineStart, lineEnd)
if (line.isNotEmpty()) {
val isCloser = line.dropWhile { it == ' ' || it == '\t' }.startsWith("}")
val indentToUse = if (isCloser && parentBaseIndent != null) parentBaseIndent!! else baseIndent
sb.append(indentToUse).append(line)
} else sb.append(line)
if (lineEnd < formattedZero.length) sb.append('\n')
i = lineEnd + 1
}
sb.toString()
}
}
if (resultSlice == slice) return text
val sb = StringBuilder(text.length - slice.length + resultSlice.length)
sb.append(text, 0, start)
sb.append(resultSlice)
sb.append(text, endExclusive, text.length)
return sb.toString()
}
}
private fun applyMinimalSpacing(code: String): String {
var s = code
// Ensure space before '(' for control-flow keywords
s = s.replace(Regex("\\b(if|for|while)\\("), "$1 (")
// Space before '{' for control-flow headers only (avoid function declarations)
s = s.replace(Regex("\\b(if|for|while)(\\s*\\([^)]*\\))\\s*\\{"), "$1$2 {")
s = s.replace(Regex("\\belse\\s+if(\\s*\\([^)]*\\))\\s*\\{"), "else if$1 {")
// Do NOT globally convert "){" to ") {"; this would break function declarations.
// Normalize control-flow braces explicitly:
s = s.replace(Regex("\\bif\\s*\\(([^)]*)\\)\\s*\\{"), "if ($1) {")
s = s.replace(Regex("\\bfor\\s*\\(([^)]*)\\)\\s*\\{"), "for ($1) {")
s = s.replace(Regex("\\bwhile\\s*\\(([^)]*)\\)\\s*\\{"), "while ($1) {")
s = s.replace(Regex("\\belse\\s+if\\s*\\(([^)]*)\\)\\s*\\{"), "else if ($1) {")
s = s.replace(Regex("\\belse\\{"), "else {")
// Ensure space between closing brace and else
s = s.replace(Regex("\\}\\s*else\\b"), "} else")
// Ensure single space in "else if"
s = s.replace(Regex("\\belse\\s+if\\b"), "else if")
// Ensure space before '{' for try/catch/finally
s = s.replace(Regex("\\b(try|catch|finally)\\{"), "$1 {")
// Ensure space before '{' when catch has parameters: "catch (e){" -> "catch (e) {"
s = s.replace(Regex("(\\bcatch\\s*\\([^)]*\\))\\s*\\{"), "$1 {")
// Ensure space before '(' for catch parameter
s = s.replace(Regex("\\bcatch\\("), "catch (")
// Remove spaces just inside parentheses/brackets: "( a )" -> "(a)"
s = s.replace(Regex("\\(\\s+"), "(")
// Do not strip leading indentation before a closing bracket/paren on its own line
s = s.replace(Regex("(?<=\\S)\\s+\\)"), ")")
s = s.replace(Regex("\\[\\s+"), "[")
s = s.replace(Regex("(?<=\\S)\\s+\\]"), "]")
// Keep function declarations as-is; don't force or remove space before '{' in function headers.
// Commas: no space before, one space after (unless followed by ) or ])
// Comma spacing: ensure one space after, none before; but avoid trailing spaces at EOL
s = s.replace(Regex("\\s*,\\s*"), ", ")
// Remove space after comma if it ends the line or before a closing paren/bracket
s = s.replace(Regex(", \\r?\\n"), ",\n")
s = s.replace(Regex(", \\)"), ",)")
s = s.replace(Regex(", \\]"), ",]")
// Equality/boolean operators: ensure spaces around
s = s.replace(Regex("\\s*(==|!=|<=|>=|&&|\\|\\|)\\s*"), " $1 ")
// Assignment '=' (not part of '==', '!=', '<=', '>=', '=>'): collapse whitespace to single spaces around '='
s = s.replace(Regex("(?<![=!<>])\\s*=\\s*(?![=>])"), " = ")
// Multiply/Divide/Mod as binary: spaces around
s = s.replace(Regex("\\s*([*/%])\\s*"), " $1 ")
// Addition as binary: spaces around '+'. We try not to break '++' and '+=' here.
s = s.replace(Regex("(?<![+])\\s*\\+\\s*(?![+=])"), " + ")
// Subtraction as binary: add spaces when it looks binary. Keep unary '-' tight (after start or ( [ { = : ,)
s = s.replace(Regex("(?<=[^\\s(\\[\\{=:,])-(?=[^=])"), " - ")
// Colon in types/extends: remove spaces before, ensure one space after (keep '::' intact)
s = s.replace(Regex("(?<!:)\\s*:(?!:)\\s*"), ": ")
return s
}
private fun applyControlledWrapping(text: String, cfg: LyngFormatConfig): String {
if (!cfg.applyWrapping) return text
val lines = text.split('\n')
val out = StringBuilder(text.length)
var i = 0
while (i < lines.size) {
val line = lines[i]
if (line.length > cfg.maxLineLength && line.contains('(') && line.contains(',') && !line.contains('"') && !line.contains('\'')) {
val open = line.indexOf('(')
val close = line.lastIndexOf(')')
if (open in 0 until close) {
val head = line.substring(0, open + 1)
val args = line.substring(open + 1, close)
val tail = line.substring(close)
// Split by commas without adding trailing comma
val parts = args.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
if (parts.size >= 2) {
val baseIndent = leadingWhitespaceOf(head)
val contIndent = baseIndent + if (cfg.useTabs) "\t" else " ".repeat(cfg.continuationIndentSize)
out.append(head.trimEnd()).append('\n')
for ((idx, p) in parts.withIndex()) {
out.append(contIndent).append(p)
if (idx < parts.lastIndex) out.append(',')
out.append('\n')
}
out.append(baseIndent).append(tail.trimStart())
out.append('\n')
i++
continue
}
}
}
out.append(line)
if (i < lines.lastIndex) out.append('\n')
i++
}
return out.toString()
}
private fun leadingWhitespaceOf(s: String): String {
var k = 0
while (k < s.length && (s[k] == ' ' || s[k] == '\t')) k++
return s.substring(0, k)
}

View File

@ -0,0 +1,476 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
/*
* Tests for block detection and partial reindent in Lyng formatter
*/
package net.sergeych.lyng.format
import kotlin.math.min
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class BlockReindentTest {
@Test
fun findMatchingOpen_basic() {
val text = """
fun f() {
{
1
}
}
""".trimIndent()
val close = text.lastIndexOf('}')
val open = BraceUtils.findMatchingOpenBrace(text, close)
assertNotNull(open)
// The char at open must be '{'
assertEquals('{', text[open!!])
}
@Test
fun findEnclosingBlock_rangeIncludesWholeBraceLines() {
val text = """
fun f() {
{
1
}
}
""".trimIndent() + "\n" // final newline to emulate editor
val close = text.indexOfLast { it == '}' }
val range = BraceUtils.findEnclosingBlockRange(text, close, includeTrailingNewline = true)
assertNotNull(range)
// The range must start at the line start of the matching '{' and end at or after the newline after '}'
val start = range!!.first
val end = range.last + 1
val startLinePrefix = text.substring(BraceUtils.lineStart(text, start), start)
// start at column 0 of the line
assertEquals(0, startLinePrefix.length)
// end should be at a line boundary
val endsAtNl = (end == text.length) || text.getOrNull(end - 1) == '\n'
kotlin.test.assertEquals(true, endsAtNl)
}
@Test
fun partialReindent_fixesInnerIndent_preservesBaseIndent() {
val original = """
fun test21() {
{ // inner block wrongly formatted
21
}
}
""".trimIndent() + "\n"
val commentPos = original.indexOf("// inner block")
val close = original.indexOf('}', startIndex = commentPos)
val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!!
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = false)
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
// Validate shape: first line starts with '{', second line is indented '21', third line is '}'
val slice = updated.substring(range.first, min(updated.length, range.last + 1))
val lines = slice.removeSuffix("\n").lines()
// remove common leading base indent from lines
val baseLen = lines.first().takeWhile { it == ' ' || it == '\t' }.length
val l0 = lines.getOrNull(0)?.drop(baseLen) ?: ""
val l1 = lines.getOrNull(1)?.drop(baseLen) ?: ""
val l2 = lines.getOrNull(2)?.drop(baseLen) ?: ""
// First line: opening brace, possibly followed by inline comment
kotlin.test.assertEquals(true, l0.startsWith("{"))
// Second line must be exactly 4 spaces + 21 with our cfg
assertEquals(" 21", l1)
// Third line: closing brace
assertEquals("}", l2)
}
@Test
fun nestedBlocks_partialReindent_innerOnly() {
val original = """
fun outer() {
{
{
1
}
}
}
""".trimIndent() + "\n"
// Target the closing brace of the INNERMOST block (the first closing brace in the snippet)
val innerClose = original.indexOf('}')
val range = BraceUtils.findEnclosingBlockRange(original, innerClose, includeTrailingNewline = true)!!
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = false)
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
val slice = updated.substring(range.first, min(updated.length, range.last + 1))
val lines = slice.removeSuffix("\n").lines()
val baseLen = lines.first().takeWhile { it == ' ' || it == '\t' }.length
val l0 = lines[0].drop(baseLen)
val l1 = lines[1].drop(baseLen)
val l2 = lines[2].drop(baseLen)
// Expect properly shaped inner block
kotlin.test.assertEquals(true, l0.startsWith("{"))
assertEquals(" 1", l1)
assertEquals("}", l2)
}
@Test
fun blockWithInlineComments_detectAndReindent() {
val original = """
fun cmt() {
{ // open
21 // body
} // close
}
""".trimIndent() + "\n"
val close = original.indexOf("} // close")
val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!!
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
val slice = updated.substring(range.first, min(updated.length, range.last + 1))
val lines = slice.removeSuffix("\n").lines()
val baseLen = lines.first().takeWhile { it == ' ' || it == '\t' }.length
val l0 = lines[0].drop(baseLen)
val l1 = lines[1].drop(baseLen)
val l2 = lines[2].drop(baseLen)
kotlin.test.assertEquals(true, l0.startsWith("{ // open"))
assertEquals(" 21 // body", l1) // 2-space indent
kotlin.test.assertEquals(true, l2.startsWith("} // close"))
}
@Test
fun emptyBlock_isNormalized() {
val original = """
fun e() {
{ }
}
""".trimIndent() + "\n"
val close = original.indexOf('}')
val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!!
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = false)
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
val slice = updated.substring(range.first, range.last + 1)
val lines = slice.removeSuffix("\n").lines()
val baseLen = lines.first().takeWhile { it == ' ' || it == '\t' }.length
// Drop base indent and collapse whitespace; expect only braces remain in order
val innerText = lines.joinToString("\n") { it.drop(baseLen) }.trimEnd()
val collapsed = innerText.replace(" ", "").replace("\t", "").replace("\n", "")
kotlin.test.assertEquals("{}", collapsed)
}
@Test
fun tabBaseIndent_preserved() {
val original = "\t\t{\n\t 21\n\t}\n" // tabs for base indent, bad body indent
val close = original.lastIndexOf('}')
val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!!
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = true)
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
val firstLine = updated.substring(range.first, updated.indexOf('\n', range.first).let { if (it < 0) updated.length else it })
// Base indent (two tabs) must be preserved
kotlin.test.assertEquals(true, firstLine.startsWith("\t\t{"))
// Body line must be base (two tabs) + one indent unit (a tab when useTabs=true)
val bodyLineStart = updated.indexOf('\n', range.first) + 1
val bodyLineEnd = updated.indexOf('\n', bodyLineStart)
val bodyLine = updated.substring(bodyLineStart, if (bodyLineEnd < 0) updated.length else bodyLineEnd)
kotlin.test.assertEquals(true, bodyLine.startsWith("\t\t\t"))
kotlin.test.assertEquals(true, bodyLine.trimStart().startsWith("21"))
}
@Test
fun noTrailingNewline_afterClose_isHandled() {
val original = """
fun f() {
{
21
}
}""".trimIndent() // EOF right after '}'
val close = original.lastIndexOf('}')
val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!!
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
kotlin.test.assertEquals(true, updated.isNotEmpty())
}
@Test
fun bracketContinuation_firstElementIndented() {
val original = """
val arr = [
1,
2
]
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindent(original, cfg)
val lines = updated.lines()
// Expect first element line to be continuation-indented (4 spaces)
assertEquals(" 1,", lines[1])
assertEquals(" 2", lines[2])
// Closing bracket should align with 'val arr = [' line
assertEquals(
lines[0].takeWhile { it == ' ' || it == '\t' } + "]",
lines[3]
)
}
@Test
fun parenContinuation_multilineCondition() {
val original = """
if (
a &&
b ||
c
)
{
1
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindent(original, cfg)
val lines = updated.lines()
// Lines inside parentheses get continuation indent (4 spaces)
assertEquals(" a &&", lines[1])
assertEquals(" b ||", lines[2])
assertEquals(" c", lines[3])
// Closing paren line should not get continuation
assertEquals(")", lines[4].trimStart())
}
@Test
fun chainedCalls_withParens_continuationIndent() {
val original = """
val x = service
.call(
a,
b,
c
)
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindent(original, cfg)
val lines = updated.lines()
// inside call parentheses lines should have continuation indent (4 spaces)
assertEquals(" a,", lines[2])
assertEquals(" b,", lines[3])
assertEquals(" c", lines[4])
// closing ')' line should not have continuation
assertEquals(")", lines[5].trimStart())
}
@Test
fun mixedTabsSpaces_baseIndent_preserved() {
// base indent has one tab then two spaces; body lines should preserve base + continuation
val original = "\t [\n1,\n2\n]" // no trailing newline
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindent(original, cfg)
val lines = updated.lines()
// Expect first element line has base ("\t ") plus 4 spaces
kotlin.test.assertEquals(true, lines[1].startsWith("\t "))
kotlin.test.assertEquals(true, lines[2].startsWith("\t "))
// Closing bracket aligns with base only
kotlin.test.assertEquals(true, lines[3].startsWith("\t ]"))
}
@Test
fun deepParentheses_and_chainedCalls() {
val original = """
if (
a &&
(b || c(
x,
y
)) && service
.call(
p,
q
)
)
{
1
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindent(original, cfg)
val lines = updated.lines()
// Inside top-level parens continuation should apply (4 spaces)
assertEquals(" a &&", lines[1])
// Nested parens of c( x, y ) apply continuation; current rules accumulate with outer parens
// resulting in deeper indent for nested arguments
assertEquals(" x,", lines[3])
assertEquals(" y", lines[4])
// Chained call `.call(` lines inside parentheses are continuation-indented
assertEquals(" .call(", lines[6].trimEnd())
assertEquals(" p,", lines[7])
assertEquals(" q", lines[8])
// Closing paren line should not get continuation
assertEquals(")", lines[10].trimStart())
// Block body is indented by one level
assertEquals(" 1", lines[12])
}
@Test
fun eof_edge_without_trailing_newline_in_block_and_arrays() {
val original = """
fun g() {
[
1,
2
]
}
""".trimIndent().removeSuffix("\n") // ensure no final newline
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindent(original, cfg)
val lines = updated.lines()
// First element line should have continuation indent (4 spaces) in addition to base
assertEquals(" 1,", lines[2])
assertEquals(" 2", lines[3])
// Closing bracket aligns with base (no continuation)
assertEquals("]", lines[4].trimStart())
// File ends without extra newline
kotlin.test.assertEquals(false, updated.endsWith("\n"))
}
@Test
fun partialPaste_preservesBaseIndent_forAllLines() {
// Simulate a paste at the start of an indented line (caret inside leading whitespace)
val before = """
fun pasteHere() {
}
""".trimIndent() + "\n"
val caretLineStart = before.indexOf("\n", before.indexOf("pasteHere")) + 1
// base indent on the empty line inside the block is 4 spaces
val caretOffset = caretLineStart + 4
val paste = """
if (x)
{
1
}
""".trimIndent()
// Build the document text as if pasted as-is first
val afterPaste = StringBuilder(before).insert(caretOffset, paste).toString()
val insertedRange = caretOffset until (caretOffset + paste.length)
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = false)
val updated = LyngFormatter.reindentRange(afterPaste, insertedRange, cfg, preserveBaseIndent = true)
// Extract the inserted slice and verify there is a common base indent of 4 spaces
val slice = updated.substring(insertedRange.first, insertedRange.last + 1)
val lines = slice.lines().filter { it.isNotEmpty() }
kotlin.test.assertTrue(lines.isNotEmpty())
// Compute minimal common leading whitespace among non-empty lines
fun leadingWs(s: String): String = s.takeWhile { it == ' ' || it == '\t' }
val commonBase = lines.map(::leadingWs).reduce { acc, s ->
var i = 0
val max = min(acc.length, s.length)
while (i < max && acc[i] == s[i]) i++
acc.substring(0, i)
}
// Expect at least 4 spaces as base indent preserved from caret line
kotlin.test.assertTrue(commonBase.startsWith(" "))
val base = " "
// Also check the content shape after removing detected base indent (4 spaces)
val deBased = lines.map { if (it.startsWith(base)) it.removePrefix(base) else it }
kotlin.test.assertEquals("if (x) {", deBased[0])
kotlin.test.assertEquals(" 1", deBased.getOrNull(1) ?: "") // one level inside the pasted block
kotlin.test.assertEquals("}", deBased.getOrNull(2) ?: "")
}
@Test
fun partialPaste_tabsBaseIndent_preserved() {
val before = """
\t\tpaste()
\t\t\n
""".trimIndent() + "\n"
// Create a caret on the blank line with base indent of two tabs
val lineStart = before.indexOf("\n", before.indexOf("paste()")) + 1
val caretOffset = lineStart + 2 // two tabs
val paste = """
[
1,
2
]
""".trimIndent()
val afterPaste = StringBuilder(before).insert(caretOffset, paste).toString()
val insertedRange = caretOffset until (caretOffset + paste.length)
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4, useTabs = true)
val updated = LyngFormatter.reindentRange(afterPaste, insertedRange, cfg, preserveBaseIndent = true)
val slice = updated.substring(insertedRange.first, insertedRange.last + 1)
val lines = slice.lines().filter { it.isNotEmpty() }
kotlin.test.assertTrue(lines.all { it.startsWith("\t\t") })
// After removing base, first element lines should have one continuation tab worth of indent
val deBased = lines.map { it.removePrefix("\t\t") }
kotlin.test.assertEquals("[", deBased[0])
kotlin.test.assertEquals(true, deBased[1].startsWith("\t"))
kotlin.test.assertEquals(true, deBased[2].startsWith("\t"))
kotlin.test.assertEquals("]", deBased.last().trimEnd())
}
@Test
fun partialPaste_midLine_noBasePrefix() {
// Paste occurs mid-line (after non-whitespace): base indent must not be applied
val before = "val a = 1\n"
val caretOffset = before.indexOf('1') + 1 // after the '1', mid-line
val paste = "\n{\n1\n}\n".trimIndent()
val afterPaste = StringBuilder(before).insert(caretOffset, paste).toString()
val insertedRange = caretOffset until (caretOffset + paste.length)
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindentRange(afterPaste, insertedRange, cfg, preserveBaseIndent = true)
// Find the opening brace position and verify structure around it in the updated text
val openIdx = updated.indexOf('{', startIndex = insertedRange.first)
kotlin.test.assertTrue(openIdx >= 0)
// Next line should be indented body line with 2 spaces then '1'
val afterOpenNl = updated.indexOf('\n', openIdx) + 1
val bodyLineEnd = updated.indexOf('\n', afterOpenNl).let { if (it < 0) updated.length else it }
val bodyLine = updated.substring(afterOpenNl, bodyLineEnd)
kotlin.test.assertEquals(" 1", bodyLine)
// Closing brace should appear on its own line (no leading spaces)
val closeLineStart = bodyLineEnd + 1
val closeLineEnd = updated.indexOf('\n', closeLineStart).let { if (it < 0) updated.length else it }
val closeLine = updated.substring(closeLineStart, closeLineEnd)
kotlin.test.assertEquals("}", closeLine)
}
@Test
fun partialPaste_replacingPartOfLeadingWhitespace_usesRemainingAsBase() {
// The selection replaces part of the leading whitespace; base should be the whitespace before start
val before = """
fun g() {
}
""".trimIndent() + "\n"
val blankLineStart = before.indexOf("\n", before.indexOf("g()")) + 1
val line = before.substring(blankLineStart, before.indexOf('\n', blankLineStart))
// line currently has 4 spaces; select and replace the last 2 spaces
val selectionStart = blankLineStart + 2
val selectionEnd = blankLineStart + 4
val paste = "{\n1\n}\n"
val afterPaste = before.substring(0, selectionStart) + paste + before.substring(selectionEnd)
val insertedRange = selectionStart until (selectionStart + paste.length)
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindentRange(afterPaste, insertedRange, cfg, preserveBaseIndent = true)
val slice = updated.substring(insertedRange.first, insertedRange.last + 1)
val lines = slice.lines().filter { it.isNotEmpty() }
// Base indent should be 2 spaces (remaining before selectionStart)
kotlin.test.assertTrue(lines.all { it.startsWith(" ") })
val deBased = lines.map { it.removePrefix(" ") }
kotlin.test.assertEquals("{", deBased.first())
kotlin.test.assertEquals(" 1", deBased.getOrNull(1) ?: "")
kotlin.test.assertEquals("}", deBased.last().trimEnd())
}
}

View File

@ -0,0 +1,611 @@
/*
* 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.format
import kotlin.test.Test
import kotlin.test.assertEquals
class LyngFormatterTest {
@Test
fun reindent_simpleFunction() {
val src = """
fun test21() {
21
}
""".trimIndent()
val expected = """
fun test21() {
21
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4, useTabs = false)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
// Idempotent
assertEquals(expected, LyngFormatter.reindent(out, cfg))
}
@Test
fun wrapping_longCalls_noTrailingComma() {
val longArg = "x1234567890x1234567890x1234567890x1234567890x1234567890"
val src = """
fun demo() {
doSomething(${longArg}, ${longArg}, ${longArg})
}
""".trimIndent()
val out = LyngFormatter.format(src, LyngFormatConfig(applyWrapping = true, maxLineLength = 60, continuationIndentSize = 4))
// Should contain vertically wrapped args and no trailing comma before ')'
val hasTrailingComma = out.lines().any { it.trimEnd().endsWith(",") && it.trimEnd().endsWith("),") }
kotlin.test.assertFalse(hasTrailingComma, "Trailing comma before ')' must not be added")
// Idempotency
val out2 = LyngFormatter.format(out, LyngFormatConfig(applyWrapping = true, maxLineLength = 60, continuationIndentSize = 4))
assertEquals(out, out2)
}
@Test
fun wrapping_preservesCommentsAndStrings() {
val arg1 = "a /*not a block comment*/"
val arg2 = "\"(keep)\"" // string with parens
val src = """
fun demo() { doSomething($arg1, $arg2, c) // end comment }
""".trimIndent()
val formatted = LyngFormatter.format(src, LyngFormatConfig(applyWrapping = true, maxLineLength = 40, continuationIndentSize = 4))
// Ensure the string literal remains intact
kotlin.test.assertTrue(formatted.contains(arg2), "String literal must be preserved")
// Ensure end-of-line comment remains
kotlin.test.assertTrue(formatted.contains("// end comment"), "EOL comment must be preserved")
// Idempotency
val formatted2 = LyngFormatter.format(formatted, LyngFormatConfig(applyWrapping = true, maxLineLength = 40, continuationIndentSize = 4))
assertEquals(formatted, formatted2)
}
@Test
fun propertyBasedIdempotencyDeep() {
// Deeper randomized mixes of blocks + lists + calls + operators
val blocks = listOf(
"if(flag){\nX\n}else{\nY\n}",
"try{\nX\n}catch(e){\nY\n}",
"fun g(){\nX\n}",
"val grid = [[\n1,2\n],[3,4]]\nX",
"doWork(A,B,C)\nX",
)
val leaves = listOf(
"sum = a+b* c",
"matrix[i][j] = i + j",
"call( x, y , z )",
"// comment line",
"println(\"s\")",
)
val cfgIndent = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
val cfgSpace = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4, applySpacing = true)
fun assembleDeep(seed: Int): String {
val n = 10 + (seed % 6)
val sb = StringBuilder()
var cur = "X"
repeat(n) { k ->
val b = blocks[(seed + k * 11) % blocks.size]
val leaf = leaves[(seed + k * 7) % leaves.size]
cur = b.replace("X", cur.replace("X", leaf))
}
sb.append(cur)
return sb.toString()
}
repeat(12) { s ->
val doc = assembleDeep(s)
val r1 = LyngFormatter.reindent(doc, cfgIndent)
val r2 = LyngFormatter.reindent(r1, cfgIndent)
assertEquals(r1, r2, "Indent-only idempotency failed for seed=$s")
val f1 = LyngFormatter.format(doc, cfgSpace)
val f2 = LyngFormatter.format(f1, cfgSpace)
assertEquals(f1, f2, "Spacing-enabled idempotency failed for seed=$s")
}
}
@Test
fun elseIf_chain() {
val src = """
if(true){
if(false){
a()
}
else if(maybe){
b()
}
else{
c()
}
}
""".trimIndent()
val expected = """
if (true) {
if (false) {
a()
}
else if (maybe) {
b()
}
else {
c()
}
}
""".trimIndent()
val cfg = LyngFormatConfig(applySpacing = true)
val out = LyngFormatter.format(src, cfg)
assertEquals(expected, out)
assertEquals(expected, LyngFormatter.format(out, cfg))
}
@Test
fun tryCatchFinally_alignment() {
val src = """
try{
risky()
}
catch(e){
handle(e)
}
finally{
cleanup()
}
""".trimIndent()
val expected = """
try {
risky()
}
catch (e) {
handle(e)
}
finally {
cleanup()
}
""".trimIndent()
val cfg = LyngFormatConfig(applySpacing = true)
val out = LyngFormatter.format(src, cfg)
assertEquals(expected, out)
assertEquals(expected, LyngFormatter.format(out, cfg))
}
@Test
fun bracketContinuation_basic() {
val src = """
val arr = [
1,
2,
3
]
""".trimIndent()
val expected = """
val arr = [
1,
2,
3
]
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
assertEquals(expected, LyngFormatter.reindent(out, cfg))
}
@Test
fun idempotency_mixedSnippet() {
val src = """
// mix
if(true){ doIt( a,b ,c) }
val s = "keep( spaces )" // comment
try{ ok() }catch(e){ bad() }
""".trimIndent()
val cfg = LyngFormatConfig(applySpacing = true)
val once = LyngFormatter.format(src, cfg)
val twice = LyngFormatter.format(once, cfg)
assertEquals(once, twice)
}
@Test
fun nestedIfTryChains() {
val src = """
if(true){
try{
if(flag){
op()
} else{
other()
}
}
catch(e){
handle(e)
}
finally{
done()
}
}
""".trimIndent()
val expected = """
if (true) {
try {
if (flag) {
op()
} else {
other()
}
}
catch (e) {
handle(e)
}
finally {
done()
}
}
""".trimIndent()
val cfg = LyngFormatConfig(applySpacing = true)
val out = LyngFormatter.format(src, cfg)
assertEquals(expected, out)
assertEquals(expected, LyngFormatter.format(out, cfg))
}
@Test
fun continuationWithCommentsAndBlanks() {
val src = """
fun call() {
doIt(
a, // first
b,
// middle
c
)
}
""".trimIndent()
val expected = """
fun call() {
doIt(
a, // first
b,
// middle
c
)
}
""".trimIndent()
val cfg = LyngFormatConfig(continuationIndentSize = 4)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
assertEquals(expected, LyngFormatter.reindent(out, cfg))
}
@Test
fun propertyBasedIdempotencySmall() {
// Build small random snippets from blocks and verify idempotency under both modes
val parts = listOf(
"if(true){\nX\n}",
"try{\nX\n}catch(e){\nY\n}",
"fun f(){\nX\n}",
"doIt(A, B, C)",
)
val leaves = listOf("op()", "other()", "println(1)")
val cfgIndentOnly = LyngFormatConfig()
val cfgSpacing = LyngFormatConfig(applySpacing = true)
repeat(20) { n ->
var snippet = parts[(n + 0) % parts.size]
snippet = snippet.replace("X", leaves[n % leaves.size])
snippet = snippet.replace("Y", leaves[(n + 1) % leaves.size])
val once1 = LyngFormatter.reindent(snippet, cfgIndentOnly)
val twice1 = LyngFormatter.reindent(once1, cfgIndentOnly)
assertEquals(once1, twice1)
val once2 = LyngFormatter.format(snippet, cfgSpacing)
val twice2 = LyngFormatter.format(once2, cfgSpacing)
assertEquals(once2, twice2)
}
}
@Test
fun chainedCalls_and_MemberAccess() {
val src = """
fun demo() {
val r = obj.chain().next( a,b ,c).last().value
doIt(a,b).then{ op() }.final()
}
""".trimIndent()
val expected = """
fun demo() {
val r = obj.chain().next(a, b, c).last().value
doIt(a, b).then{ op() }.final()
}
""".trimIndent()
// Dots should remain tight; commas should be spaced
val cfg = LyngFormatConfig(applySpacing = true)
val out = LyngFormatter.format(src, cfg)
assertEquals(expected, out)
assertEquals(expected, LyngFormatter.format(out, cfg))
}
@Test
fun nestedArrays_and_Calls() {
val src = """
val m = [[
1,2], [3,4
], [5,
6]
]
fun f(){
compute(
m[0][1], m[1][0]
)
}
""".trimIndent()
val expected = """
val m = [[
1, 2], [3, 4
], [5,
6]
]
fun f(){
compute(
m[0][1], m[1][0]
)
}
""".trimIndent()
// Indent under brackets and inside calls; commas spaced
val out = LyngFormatter.reindent(src, LyngFormatConfig(indentSize = 4, continuationIndentSize = 4))
val spaced = LyngFormatter.format(out, LyngFormatConfig(applySpacing = true))
assertEquals(expected, spaced)
assertEquals(expected, LyngFormatter.format(spaced, LyngFormatConfig(applySpacing = true)))
}
@Test
fun mixedOperators_spacing() {
val src = """
fun calc(){
val x =1+2* 3
val y =x== 7||x!=8&&x<= 9
if(true){ if(false){ a=b+ c }else{ a =b*c}}
}
""".trimIndent()
val expected = """
fun calc(){
val x = 1 + 2 * 3
val y = x == 7 || x != 8 && x <= 9
if (true) { if (false) { a = b + c } else { a = b * c}}
}
""".trimIndent()
val cfg = LyngFormatConfig(applySpacing = true)
val out = LyngFormatter.format(src, cfg)
assertEquals(expected, out)
assertEquals(expected, LyngFormatter.format(out, cfg))
}
@Test
fun propertyBasedIdempotencyMedium() {
// Compose slightly longer random snippets out of templates
val blocks = listOf(
"if(true){\nX\n}",
"if(flag){\nX\n}else{\nY\n}",
"try{\nX\n}catch(e){\nY\n}\nfinally{\nZ\n}",
"fun f(){\nX\n}",
)
val leaves = listOf(
"doIt(A,B ,C)",
"obj.m().n( a , b).k()",
"val arr = [\n1,\n2,\n3\n]",
"println(\"keep ( raw )\") // cmt",
)
val cfg1 = LyngFormatConfig()
val cfg2 = LyngFormatConfig(applySpacing = true)
repeat(15) { n ->
val t1 = blocks[n % blocks.size]
val t2 = blocks[(n + 1) % blocks.size]
val l1 = leaves[n % leaves.size]
val l2 = leaves[(n + 1) % leaves.size]
val l3 = leaves[(n + 2) % leaves.size]
var doc = (t1 + "\n" + t2)
doc = doc.replace("X", l1).replace("Y", l2).replace("Z", l3)
val r1 = LyngFormatter.reindent(doc, cfg1)
val r2 = LyngFormatter.reindent(r1, cfg1)
assertEquals(r1, r2)
val f1 = LyngFormatter.format(doc, cfg2)
val f2 = LyngFormatter.format(f1, cfg2)
assertEquals(f1, f2)
}
}
@Test
fun propertyBasedIdempotencyLargeRandom() {
// Generate larger random snippets from building blocks and verify idempotency
val blocks = listOf(
"if(true){\nX\n}",
"if(flag){\nX\n}else if(maybe){\nY\n}else{\nZ\n}",
"try{\nX\n}catch(e){\nY\n}\nfinally{\nZ\n}",
"fun f(){\nX\n}",
"val arr = [\nX,\nY,\nZ\n]",
"doIt(A,B ,C)\nobj.m().n( a , b).k()\n",
)
val leaves = listOf(
"println(1)",
"op()",
"compute(a, b, c)",
"other()",
"arr[i][j]",
)
val cfgIndentOnly = LyngFormatConfig()
val cfgSpacing = LyngFormatConfig(applySpacing = true)
fun assemble(n: Int): String {
val sb = StringBuilder()
for (i in 0 until n) {
val b = blocks[(i * 7 + n) % blocks.size]
val x = leaves[(i + 0) % leaves.size]
val y = leaves[(i + 1) % leaves.size]
val z = leaves[(i + 2) % leaves.size]
sb.append(b.replace("X", x).replace("Y", y).replace("Z", z)).append('\n')
}
return sb.toString().trimEnd()
}
repeat(10) { n ->
val doc = assemble(8 + (n % 5))
val once1 = LyngFormatter.reindent(doc, cfgIndentOnly)
val twice1 = LyngFormatter.reindent(once1, cfgIndentOnly)
assertEquals(once1, twice1)
val once2 = LyngFormatter.format(doc, cfgSpacing)
val twice2 = LyngFormatter.format(once2, cfgSpacing)
assertEquals(once2, twice2)
}
}
@Test
fun comments_and_strings_untouched() {
val src = """
// header
fun demo() {
val s = "( a ) , [ b ]" // keep as-is
if(true){
println(s)
}
}
""".trimIndent()
val expected = """
// header
fun demo() {
val s = "( a ) , [ b ]" // keep as-is
if(true){
println(s)
}
}
""".trimIndent()
// Use full format with spacing disabled to guarantee strings/comments remain untouched
val out = LyngFormatter.format(src, LyngFormatConfig(applySpacing = false))
assertEquals(expected, out)
// Idempotent
assertEquals(expected, LyngFormatter.format(out, LyngFormatConfig(applySpacing = false)))
}
@Test
fun reindent_nestedBlocks_andElse() {
val src = """
if(true){
if (cond) {
doIt()
}
else {
other()
}
}
""".trimIndent()
val expected = """
if (true) {
if (cond) {
doIt()
}
else {
other()
}
}
""".trimIndent()
val out = LyngFormatter.format(src, LyngFormatConfig(applySpacing = true))
assertEquals(expected, out)
// Idempotent
assertEquals(expected, LyngFormatter.format(out, LyngFormatConfig(applySpacing = true)))
}
@Test
fun continuationIndent_parentheses() {
val src = """
fun call() {
doSomething(
a,
b,
c
)
}
""".trimIndent()
val expected = """
fun call() {
doSomething(
a,
b,
c
)
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
// Idempotent
assertEquals(expected, LyngFormatter.reindent(out, cfg))
}
@Test
fun realexample1() {
val src = """
{
if( f.isDirectory() )
name += "/"
if( condition )
callIfTrue()
else
calliffalse()
}
""".trimIndent()
val expected = """
{
if( f.isDirectory() )
name += "/"
if( condition )
callIfTrue()
else
calliffalse()
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
// Idempotent
assertEquals(expected, LyngFormatter.reindent(out, cfg))
}
}

View File

@ -39,3 +39,4 @@ include(":lyng")
include(":site")
include(":lyngweb")
include(":lyngio")
include(":lyng-idea")