From 06e8e1579d3764d2696d27cd308cfa17fb3c066d Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 30 Nov 2025 23:56:59 +0100 Subject: [PATCH] idea plugin 0.0.1-SNAPSHOT: basic coloring and editing aids --- README.md | 1 + docs/formatter.md | 62 ++ docs/samples/fs_sample.lyng | 26 +- images/lyng-icons/lyng_file.svg | 32 + images/lyng-icons/pluginIcon.svg | 33 + lyng-idea/build.gradle.kts | 58 ++ .../net/sergeych/lyng/idea/LyngFileType.kt | 27 + .../net/sergeych/lyng/idea/LyngIcons.kt | 24 + .../net/sergeych/lyng/idea/LyngLanguage.kt | 21 + .../idea/annotators/LyngExternalAnnotator.kt | 168 +++++ .../lyng/idea/comment/LyngCommenter.kt | 27 + .../idea/docs/LyngDocumentationProvider.kt | 173 +++++ .../idea/docs/LyngDocumentationTargets.kt | 21 + .../lyng/idea/editor/LyngBackspaceHandler.kt | 36 ++ .../idea/editor/LyngCopyPastePreProcessor.kt | 23 + .../editor/LyngCopyPastePreProcessor243.kt | 23 + .../lyng/idea/editor/LyngEnterHandler.kt | 207 ++++++ .../lyng/idea/editor/LyngOnPasteProcessor.kt | 22 + .../lyng/idea/editor/LyngPasteHandler.kt | 166 +++++ .../lyng/idea/editor/LyngPastePreProcessor.kt | 83 +++ .../idea/editor/LyngSmartPastePreProcessor.kt | 38 ++ .../idea/format/LyngFormattingModelBuilder.kt | 58 ++ .../idea/format/LyngLineIndentProvider.kt | 103 +++ .../idea/format/LyngPostFormatProcessor.kt | 22 + .../idea/format/LyngPreFormatProcessor.kt | 159 +++++ .../idea/highlight/LyngColorSettingsPage.kt | 69 ++ .../idea/highlight/LyngHighlighterColors.kt | 68 ++ .../sergeych/lyng/idea/highlight/LyngLexer.kt | 162 +++++ .../idea/highlight/LyngSyntaxHighlighter.kt | 40 ++ .../highlight/LyngSyntaxHighlighterFactory.kt | 25 + .../lyng/idea/highlight/LyngTokenTypes.kt | 34 + .../net/sergeych/lyng/idea/psi/LyngFile.kt | 28 + .../lyng/idea/psi/LyngParserDefinition.kt | 67 ++ .../idea/settings/LyngFormatterSettings.kt | 69 ++ .../LyngFormatterSettingsConfigurable.kt | 87 +++ .../idea/util/IdeLenientImportProvider.kt | 37 ++ .../src/main/resources/META-INF/plugin.xml | 82 +++ .../main/resources/META-INF/pluginIcon.svg | 30 + .../src/main/resources/icons/lyng_file.svg | 30 + lyng/src/commonMain/kotlin/Common.kt | 51 ++ .../net/sergeych/lyng/format/BraceUtils.kt | 118 ++++ .../sergeych/lyng/format/LyngFormatConfig.kt | 37 ++ .../net/sergeych/lyng/format/LyngFormatter.kt | 463 +++++++++++++ .../sergeych/lyng/format/BlockReindentTest.kt | 476 ++++++++++++++ .../sergeych/lyng/format/LyngFormatterTest.kt | 611 ++++++++++++++++++ settings.gradle.kts | 1 + 46 files changed, 4192 insertions(+), 6 deletions(-) create mode 100644 docs/formatter.md create mode 100644 images/lyng-icons/lyng_file.svg create mode 100644 images/lyng-icons/pluginIcon.svg create mode 100644 lyng-idea/build.gradle.kts create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngFileType.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngIcons.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngLanguage.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/comment/LyngCommenter.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationTargets.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngBackspaceHandler.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngCopyPastePreProcessor.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngCopyPastePreProcessor243.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngEnterHandler.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngOnPasteProcessor.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngPasteHandler.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngPastePreProcessor.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngSmartPastePreProcessor.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngFormattingModelBuilder.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngLineIndentProvider.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngPostFormatProcessor.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngPreFormatProcessor.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngColorSettingsPage.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngHighlighterColors.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngSyntaxHighlighter.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngSyntaxHighlighterFactory.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngTokenTypes.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/psi/LyngFile.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/psi/LyngParserDefinition.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettings.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettingsConfigurable.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/IdeLenientImportProvider.kt create mode 100644 lyng-idea/src/main/resources/META-INF/plugin.xml create mode 100644 lyng-idea/src/main/resources/META-INF/pluginIcon.svg create mode 100644 lyng-idea/src/main/resources/icons/lyng_file.svg create mode 100644 lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/BraceUtils.kt create mode 100644 lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatConfig.kt create mode 100644 lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt create mode 100644 lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/BlockReindentTest.kt create mode 100644 lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt diff --git a/README.md b/README.md index 568fe09..02519cc 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ and it is multithreaded on platforms supporting it (automatically, no code chang - [Language home](https://lynglang.com) - [introduction and tutorial](docs/tutorial.md) - start here please - [Samples directory](docs/samples) +- [Formatter (core + CLI + IDE)](docs/formatter.md) - [Books directory](docs) ## Integration in Kotlin multiplatform diff --git a/docs/formatter.md b/docs/formatter.md new file mode 100644 index 0000000..d2fb8b8 --- /dev/null +++ b/docs/formatter.md @@ -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] [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. diff --git a/docs/samples/fs_sample.lyng b/docs/samples/fs_sample.lyng index 2c15f83..152642f 100755 --- a/docs/samples/fs_sample.lyng +++ b/docs/samples/fs_sample.lyng @@ -1,4 +1,4 @@ -#!/bin/env lyng +//#!/bin/env lyng import lyng.io.fs import lyng.stdlib @@ -6,9 +6,23 @@ import lyng.stdlib val files = Path("../..").list().toList() val longestNameLength = files.maxOf { it.name.length } -val format = "%-"+(longestNameLength+1) +"s %d" -for( f in files ) { - var name = f.name - if( f.isDirectory() ) name += "/" - println( format(name, f.size()) ) +/* +The comment for our test1. +There are _more_ data +*/ +fun test21() { +21 } + + +val format = "%-"+(longestNameLength+1) +"s %d" +for( f in files ) +{ +var name = f.name +if( f.isDirectory() ) +name += "/" +println( format(name, f.size()) ) +} + +test21() + diff --git a/images/lyng-icons/lyng_file.svg b/images/lyng-icons/lyng_file.svg new file mode 100644 index 0000000..560e144 --- /dev/null +++ b/images/lyng-icons/lyng_file.svg @@ -0,0 +1,32 @@ + + + + + Lyng File Icon (temporary λ) + + + + + + + + + + diff --git a/images/lyng-icons/pluginIcon.svg b/images/lyng-icons/pluginIcon.svg new file mode 100644 index 0000000..fad2e88 --- /dev/null +++ b/images/lyng-icons/pluginIcon.svg @@ -0,0 +1,33 @@ + + + + + Lyng Plugin Icon (temporary λ) + + + + + + + + + + + diff --git a/lyng-idea/build.gradle.kts b/lyng-idea/build.gradle.kts new file mode 100644 index 0000000..b42d0c1 --- /dev/null +++ b/lyng-idea/build.gradle.kts @@ -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?) + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngFileType.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngFileType.kt new file mode 100644 index 0000000..1e03306 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngFileType.kt @@ -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 +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngIcons.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngIcons.kt new file mode 100644 index 0000000..add8d95 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngIcons.kt @@ -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) +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngLanguage.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngLanguage.kt new file mode 100644 index 0000000..2df67b4 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngLanguage.kt @@ -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") diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt new file mode 100644 index 0000000..967740d --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt @@ -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() { + 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) + + 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("", 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("", text) + + val out = ArrayList(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()) { + 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 = Key.create("LYNG_SEMANTIC_CACHE") + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/comment/LyngCommenter.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/comment/LyngCommenter.kt new file mode 100644 index 0000000..e41c630 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/comment/LyngCommenter.kt @@ -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 +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt new file mode 100644 index 0000000..e078316 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt @@ -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("", 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("", 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()) { + 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", "
") } + val sb = StringBuilder() + sb.append("
").append(htmlEscape(title)).append("
") + if (!doc.isNullOrBlank()) sb.append("
").append(doc).append("
") + 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 "
${htmlEscape(title)}
" + } + + 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() +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationTargets.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationTargets.kt new file mode 100644 index 0000000..6ba9daa --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationTargets.kt @@ -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 diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngBackspaceHandler.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngBackspaceHandler.kt new file mode 100644 index 0000000..cf267a4 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngBackspaceHandler.kt @@ -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 + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngCopyPastePreProcessor.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngCopyPastePreProcessor.kt new file mode 100644 index 0000000..1e2f167 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngCopyPastePreProcessor.kt @@ -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 diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngCopyPastePreProcessor243.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngCopyPastePreProcessor243.kt new file mode 100644 index 0000000..f8c988a --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngCopyPastePreProcessor243.kt @@ -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 diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngEnterHandler.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngEnterHandler.kt new file mode 100644 index 0000000..9b3b054 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngEnterHandler.kt @@ -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, + caretAdvance: Ref, + 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)) +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngOnPasteProcessor.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngOnPasteProcessor.kt new file mode 100644 index 0000000..9a14acb --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngOnPasteProcessor.kt @@ -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 diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngPasteHandler.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngPasteHandler.kt new file mode 100644 index 0000000..512cce6 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngPasteHandler.kt @@ -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(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(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 +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngPastePreProcessor.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngPastePreProcessor.kt new file mode 100644 index 0000000..3d9667c --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngPastePreProcessor.kt @@ -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 + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngSmartPastePreProcessor.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngSmartPastePreProcessor.kt new file mode 100644 index 0000000..dc2e66f --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngSmartPastePreProcessor.kt @@ -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) + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngFormattingModelBuilder.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngFormattingModelBuilder.kt new file mode 100644 index 0000000..ecdad57 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngFormattingModelBuilder.kt @@ -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 = 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 diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngLineIndentProvider.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngLineIndentProvider.kt new file mode 100644 index 0000000..dc1cc1d --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngLineIndentProvider.kt @@ -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) + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngPostFormatProcessor.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngPostFormatProcessor.kt new file mode 100644 index 0000000..189a98f --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngPostFormatProcessor.kt @@ -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 diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngPreFormatProcessor.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngPreFormatProcessor.kt new file mode 100644 index 0000000..fd2f01a --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngPreFormatProcessor.kt @@ -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()) + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngColorSettingsPage.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngColorSettingsPage.kt new file mode 100644 index 0000000..db9f7c6 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngColorSettingsPage.kt @@ -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? = null + + override fun getAttributeDescriptors(): Array = 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.EMPTY_ARRAY +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngHighlighterColors.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngHighlighterColors.kt new file mode 100644 index 0000000..b59bad9 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngHighlighterColors.kt @@ -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 + ) +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt new file mode 100644 index 0000000..773735a --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt @@ -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('(', ')', '{', '}', '[', ']', '.', ',', ';', ':', '+', '-', '*', '/', '%', '=', '<', '>', '!', '?', '&', '|', '^', '~') +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngSyntaxHighlighter.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngSyntaxHighlighter.kt new file mode 100644 index 0000000..f7341cd --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngSyntaxHighlighter.kt @@ -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 = 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 = arrayOf(*keys) +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngSyntaxHighlighterFactory.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngSyntaxHighlighterFactory.kt new file mode 100644 index 0000000..2145782 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngSyntaxHighlighterFactory.kt @@ -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() +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngTokenTypes.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngTokenTypes.kt new file mode 100644 index 0000000..9cfdd35 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngTokenTypes.kt @@ -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") +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/psi/LyngFile.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/psi/LyngFile.kt new file mode 100644 index 0000000..74a179e --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/psi/LyngFile.kt @@ -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" +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/psi/LyngParserDefinition.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/psi/LyngParserDefinition.kt new file mode 100644 index 0000000..3787f25 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/psi/LyngParserDefinition.kt @@ -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 +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettings.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettings.kt new file mode 100644 index 0000000..944c822 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettings.kt @@ -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 { + + 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) + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettingsConfigurable.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettingsConfigurable.kt new file mode 100644 index 0000000..7e80462 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettingsConfigurable.kt @@ -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 + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/IdeLenientImportProvider.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/IdeLenientImportProvider.kt new file mode 100644 index 0000000..58d2f61 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/IdeLenientImportProvider.kt @@ -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) + } +} diff --git a/lyng-idea/src/main/resources/META-INF/plugin.xml b/lyng-idea/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..4aeb0e6 --- /dev/null +++ b/lyng-idea/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,82 @@ + + + + net.sergeych.lyng.idea + Lyng Language Support + Sergeych Works + + + + + + com.intellij.modules.platform + + com.intellij.modules.lang + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lyng-idea/src/main/resources/META-INF/pluginIcon.svg b/lyng-idea/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..6b62c82 --- /dev/null +++ b/lyng-idea/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1,30 @@ + + + + + Lyng Plugin Icon (temporary λ) + + + + + + + + diff --git a/lyng-idea/src/main/resources/icons/lyng_file.svg b/lyng-idea/src/main/resources/icons/lyng_file.svg new file mode 100644 index 0000000..da8deb8 --- /dev/null +++ b/lyng-idea/src/main/resources/icons/lyng_file.svg @@ -0,0 +1,30 @@ + + + + + Lyng File Icon (temporary λ) + + + + + + + + diff --git a/lyng/src/commonMain/kotlin/Common.kt b/lyng/src/commonMain/kotlin/Common.kt index 3ae9d79..f400c8b 100644 --- a/lyng/src/commonMain/kotlin/Common.kt +++ b/lyng/src/commonMain/kotlin/Common.kt @@ -72,6 +72,11 @@ val baseScopeDefer = globalDefer { fun runMain(args: Array) { if(args.isNotEmpty()) { + // CLI formatter: lyng fmt [--check] [--in-place] + if (args[0] == "fmt") { + formatCli(args.drop(1)) + return + } if( args.size >= 2 && args[0] == "--" ) { // -- -file.lyng executeFileWithArgs(args[1], args.drop(2)) @@ -86,6 +91,52 @@ fun runMain(args: Array) { Lyng { runBlocking { it() } }.main(args) } +private fun formatCli(args: List) { + var checkOnly = false + var inPlace = true + var enableSpacing = false + var enableWrapping = false + val files = mutableListOf() + 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] [file2.lyng ...]") + exit(1) + return + } + var changed = false + val cfg = net.sergeych.lyng.format.LyngFormatConfig( + applySpacing = enableSpacing, + applyWrapping = enableWrapping, + ) + for (path in files) { + val p = path.toPath() + val original = FileSystem.SYSTEM.source(p).use { it.buffer().use { bs -> bs.readUtf8() } } + val formatted = net.sergeych.lyng.format.LyngFormatter.format(original, cfg) + if (formatted != original) { + changed = true + if (checkOnly) { + println(path) + } else if (inPlace) { + FileSystem.SYSTEM.write(p) { writeUtf8(formatted) } + } else { + // default to stdout if not in-place and not --check + println("--- $path (formatted) ---\n$formatted") + } + } + } + if (checkOnly) { + exit(if (changed) 2 else 0) + } +} + private class Lyng(val launcher: (suspend () -> Unit) -> Unit) : CliktCommand() { override val printHelpOnEmptyArgs = true diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/BraceUtils.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/BraceUtils.kt new file mode 100644 index 0000000..0cac967 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/BraceUtils.kt @@ -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 + } +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatConfig.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatConfig.kt new file mode 100644 index 0000000..959c679 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatConfig.kt @@ -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" } + } +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt new file mode 100644 index 0000000..7482d7e --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt @@ -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() + + 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("(? 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) +} diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/BlockReindentTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/BlockReindentTest.kt new file mode 100644 index 0000000..9823076 --- /dev/null +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/BlockReindentTest.kt @@ -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()) + } +} diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt new file mode 100644 index 0000000..8938cce --- /dev/null +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt @@ -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)) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1aa25a1..4ea6283 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,3 +39,4 @@ include(":lyng") include(":site") include(":lyngweb") include(":lyngio") +include(":lyng-idea")