idea plugin 0.0.1-SNAPSHOT: basic coloring and editing aids
This commit is contained in:
parent
9c342c5c72
commit
06e8e1579d
@ -40,6 +40,7 @@ and it is multithreaded on platforms supporting it (automatically, no code chang
|
|||||||
- [Language home](https://lynglang.com)
|
- [Language home](https://lynglang.com)
|
||||||
- [introduction and tutorial](docs/tutorial.md) - start here please
|
- [introduction and tutorial](docs/tutorial.md) - start here please
|
||||||
- [Samples directory](docs/samples)
|
- [Samples directory](docs/samples)
|
||||||
|
- [Formatter (core + CLI + IDE)](docs/formatter.md)
|
||||||
- [Books directory](docs)
|
- [Books directory](docs)
|
||||||
|
|
||||||
## Integration in Kotlin multiplatform
|
## Integration in Kotlin multiplatform
|
||||||
|
|||||||
62
docs/formatter.md
Normal file
62
docs/formatter.md
Normal 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.
|
||||||
@ -1,4 +1,4 @@
|
|||||||
#!/bin/env lyng
|
//#!/bin/env lyng
|
||||||
|
|
||||||
import lyng.io.fs
|
import lyng.io.fs
|
||||||
import lyng.stdlib
|
import lyng.stdlib
|
||||||
@ -6,9 +6,23 @@ import lyng.stdlib
|
|||||||
val files = Path("../..").list().toList()
|
val files = Path("../..").list().toList()
|
||||||
val longestNameLength = files.maxOf { it.name.length }
|
val longestNameLength = files.maxOf { it.name.length }
|
||||||
|
|
||||||
val format = "%-"+(longestNameLength+1) +"s %d"
|
/*
|
||||||
for( f in files ) {
|
The comment for our test1.
|
||||||
var name = f.name
|
There are _more_ data
|
||||||
if( f.isDirectory() ) name += "/"
|
*/
|
||||||
println( format(name, f.size()) )
|
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()
|
||||||
|
|
||||||
|
|||||||
32
images/lyng-icons/lyng_file.svg
Normal file
32
images/lyng-icons/lyng_file.svg
Normal 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 |
33
images/lyng-icons/pluginIcon.svg
Normal file
33
images/lyng-icons/pluginIcon.svg
Normal 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 |
58
lyng-idea/build.gradle.kts
Normal file
58
lyng-idea/build.gradle.kts
Normal 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?)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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")
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
'<' -> "<"
|
||||||
|
'>' -> ">"
|
||||||
|
'&' -> "&"
|
||||||
|
'"' -> """
|
||||||
|
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()
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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))
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 don’t 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
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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('(', ')', '{', '}', '[', ']', '.', ',', ';', ':', '+', '-', '*', '/', '%', '=', '<', '>', '!', '?', '&', '|', '^', '~')
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
lyng-idea/src/main/resources/META-INF/plugin.xml
Normal file
82
lyng-idea/src/main/resources/META-INF/plugin.xml
Normal 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>
|
||||||
30
lyng-idea/src/main/resources/META-INF/pluginIcon.svg
Normal file
30
lyng-idea/src/main/resources/META-INF/pluginIcon.svg
Normal 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 |
30
lyng-idea/src/main/resources/icons/lyng_file.svg
Normal file
30
lyng-idea/src/main/resources/icons/lyng_file.svg
Normal 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 |
@ -72,6 +72,11 @@ val baseScopeDefer = globalDefer {
|
|||||||
|
|
||||||
fun runMain(args: Array<String>) {
|
fun runMain(args: Array<String>) {
|
||||||
if(args.isNotEmpty()) {
|
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] == "--" ) {
|
if( args.size >= 2 && args[0] == "--" ) {
|
||||||
// -- -file.lyng <args>
|
// -- -file.lyng <args>
|
||||||
executeFileWithArgs(args[1], args.drop(2))
|
executeFileWithArgs(args[1], args.drop(2))
|
||||||
@ -86,6 +91,52 @@ fun runMain(args: Array<String>) {
|
|||||||
Lyng { runBlocking { it() } }.main(args)
|
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() {
|
private class Lyng(val launcher: (suspend () -> Unit) -> Unit) : CliktCommand() {
|
||||||
|
|
||||||
override val printHelpOnEmptyArgs = true
|
override val printHelpOnEmptyArgs = true
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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, PSI‑free 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)
|
||||||
|
}
|
||||||
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,3 +39,4 @@ include(":lyng")
|
|||||||
include(":site")
|
include(":site")
|
||||||
include(":lyngweb")
|
include(":lyngweb")
|
||||||
include(":lyngio")
|
include(":lyngio")
|
||||||
|
include(":lyng-idea")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user