Compare commits

..

No commits in common. "c63d64346905402f48fed17b743148faaaf00b2d" and "834f3118c8432fd5fd67a498f7766eab1663a6a2" have entirely different histories.

6 changed files with 132 additions and 234 deletions

Binary file not shown.

View File

@ -1,103 +0,0 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.idea.editor
import com.intellij.application.options.CodeStyle
import com.intellij.codeInsight.editorActions.TypedHandlerDelegate
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
import com.intellij.psi.codeStyle.CodeStyleManager
import net.sergeych.lyng.format.BraceUtils
import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
class LyngTypedHandler : TypedHandlerDelegate() {
private val log = Logger.getInstance(LyngTypedHandler::class.java)
override fun charTyped(c: Char, project: Project, editor: Editor, file: PsiFile): Result {
if (file.language != LyngLanguage) return Result.CONTINUE
if (c != '}') return Result.CONTINUE
val doc = editor.document
PsiDocumentManager.getInstance(project).commitDocument(doc)
val offset = editor.caretModel.offset
val line = doc.getLineNumber((offset - 1).coerceAtLeast(0))
if (line < 0) return Result.CONTINUE
val rawLine = doc.getLineText(line)
val code = rawLine.substringBefore("//").trim()
if (code == "}") {
val settings = LyngFormatterSettings.getInstance(project)
if (settings.reindentClosedBlockOnEnter) {
reindentClosedBlockAroundBrace(project, file, doc, line)
}
// After block reindent, adjust line indent to what platform thinks (no-op in many cases)
val lineStart = doc.getLineStartOffset(line)
CodeStyleManager.getInstance(project).adjustLineIndent(file, lineStart)
}
return Result.CONTINUE
}
private fun reindentClosedBlockAroundBrace(project: Project, file: PsiFile, doc: Document, braceLine: Int) {
val braceLineStart = doc.getLineStartOffset(braceLine)
val braceLineEnd = doc.getLineEndOffset(braceLine)
val rawBraceLine = doc.getText(TextRange(braceLineStart, braceLineEnd))
val codeBraceLine = rawBraceLine.substringBefore("//")
val closeRel = codeBraceLine.lastIndexOf('}')
if (closeRel < 0) return
val closeAbs = braceLineStart + closeRel
val blockRange = BraceUtils.findEnclosingBlockRange(
doc.charsSequence,
closeAbs,
includeTrailingNewline = true
) ?: return
val options = CodeStyle.getIndentOptions(project, doc)
val cfg = LyngFormatConfig(
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
useTabs = options.USE_TAB_CHARACTER,
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
)
val whole = doc.text
val updated = LyngFormatter.reindentRange(whole, blockRange, cfg, preserveBaseIndent = true)
if (updated != whole) {
WriteCommandAction.runWriteCommandAction(project) {
doc.replaceString(0, doc.textLength, updated)
}
PsiDocumentManager.getInstance(project).commitDocument(doc)
if (log.isDebugEnabled) log.debug("[LyngTyped] reindented closed block range=$blockRange")
}
}
private fun Document.getLineText(line: Int): String {
if (line < 0 || line >= lineCount) return ""
val start = getLineStartOffset(line)
val end = getLineEndOffset(line)
return getText(TextRange(start, end))
}
}

View File

@ -738,12 +738,8 @@ class Compiler(
val startAfterLbrace = cc.savePos() val startAfterLbrace = cc.savePos()
// Peek first non-ws token to decide whether it's likely a map literal // Peek first non-ws token to decide whether it's likely a map literal
val first = cc.peekNextNonWhitespace() val first = cc.peekNextNonWhitespace()
// Empty {} should be parsed as an empty map literal in expression context // Empty {} should NOT be taken as a map literal to preserve block/lambda semantics
if (first.type == Token.Type.RBRACE) { if (first.type == Token.Type.RBRACE) return null
// consume '}' and return empty map literal
cc.next() // consume the RBRACE
return MapLiteralRef(emptyList())
}
if (first.type !in listOf(Token.Type.STRING, Token.Type.ID, Token.Type.ELLIPSIS)) return null if (first.type !in listOf(Token.Type.STRING, Token.Type.ID, Token.Type.ELLIPSIS)) return null
// Commit to map literal parsing // Commit to map literal parsing
@ -1936,11 +1932,7 @@ class Compiler(
val tOp = cc.next() val tOp = cc.next()
if (tOp.value == "in") { if (tOp.value == "in") {
// in loop // in loop
// We must parse an expression here. Using parseStatement() would treat a leading '{' val source = parseStatement() ?: throw ScriptError(start, "Bad for statement: expected expression")
// as a block, breaking inline map literals like: for (i in {foo: "bar"}) { ... }
// So we parse an expression explicitly and wrap it into a StatementRef.
val exprAfterIn = parseExpression() ?: throw ScriptError(start, "Bad for statement: expected expression")
val source: Statement = exprAfterIn
ensureRparen() ensureRparen()
// Expose the loop variable name to the parser so identifiers inside the loop body // Expose the loop variable name to the parser so identifiers inside the loop body

View File

@ -26,15 +26,13 @@ object LyngFormatter {
/** Returns the input with indentation recomputed from scratch, line by line. */ /** Returns the input with indentation recomputed from scratch, line by line. */
fun reindent(text: String, config: LyngFormatConfig = LyngFormatConfig()): String { fun reindent(text: String, config: LyngFormatConfig = LyngFormatConfig()): String {
// Normalize tabs to spaces globally before any transformation; results must contain no tabs val lines = text.split('\n')
val normalized = if (text.indexOf('\t') >= 0) text.replace("\t", " ".repeat(config.indentSize)) else text
val lines = normalized.split('\n')
val sb = StringBuilder(text.length + lines.size) val sb = StringBuilder(text.length + lines.size)
var blockLevel = 0 var blockLevel = 0
var parenBalance = 0 var parenBalance = 0
var bracketBalance = 0 var bracketBalance = 0
var prevBracketContinuation = false var prevBracketContinuation = false
// We don't keep per-"[" base alignment; continuation rules define alignment. val bracketBaseStack = ArrayDeque<String>()
fun codePart(s: String): String { fun codePart(s: String): String {
val idx = s.indexOf("//") val idx = s.indexOf("//")
@ -42,8 +40,8 @@ object LyngFormatter {
} }
fun indentOf(level: Int, continuation: Int): String = fun indentOf(level: Int, continuation: Int): String =
// Always produce spaces; tabs are not allowed in resulting code if (config.useTabs) "\t".repeat(level) + " ".repeat(continuation)
" ".repeat(level * config.indentSize + continuation) else " ".repeat(level * config.indentSize + continuation)
var awaitingSingleIndent = false var awaitingSingleIndent = false
fun isControlHeaderNoBrace(s: String): Boolean { fun isControlHeaderNoBrace(s: String): Boolean {
@ -94,11 +92,6 @@ object LyngFormatter {
else -> 0 else -> 0
} }
// Special rule: inside bracket lists, do not add base block indent for element lines.
if (bracketBalance > 0 && firstChar != ']') {
effectiveLevel = 0
}
// Replace leading whitespace with the exact target indent; but keep fully blank lines truly empty // 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 } val contentStart = line.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) line.length else it }
var content = line.substring(contentStart) var content = line.substring(contentStart)
@ -106,15 +99,15 @@ object LyngFormatter {
if (content.startsWith("[")) { if (content.startsWith("[")) {
content = "[" + content.drop(1).trimStart() content = "[" + content.drop(1).trimStart()
} }
// Normalize empty block on a single line: "{ }" -> "{}" (safe, idempotent) // Determine base indent: for bracket blocks, preserve the exact leading whitespace
run { val leadingWs = if (contentStart > 0) line.substring(0, contentStart) else ""
val t = content.trim() val currentBracketBase = if (bracketBaseStack.isNotEmpty()) bracketBaseStack.last() else null
if (t.length >= 2 && t.first() == '{' && t.last() == '}' && t.substring(1, t.length - 1).isBlank()) { val indentString = if (currentBracketBase != null) {
content = "{}" val cont = if (continuation > 0) {
} if (config.useTabs) "\t" else " ".repeat(continuation)
} } else ""
// Determine base indent using structural level and continuation only (spaces only) currentBracketBase + cont
val indentString = indentOf(effectiveLevel, continuation) } else indentOf(effectiveLevel, continuation)
if (content.isEmpty()) { if (content.isEmpty()) {
// preserve truly blank line as empty to avoid trailing spaces on empty lines // preserve truly blank line as empty to avoid trailing spaces on empty lines
// (also keeps continuation blocks visually clean) // (also keeps continuation blocks visually clean)
@ -154,14 +147,16 @@ object LyngFormatter {
// Reset one-shot flag after we used it on this line // Reset one-shot flag after we used it on this line
if (prevBracketContinuation) prevBracketContinuation = false if (prevBracketContinuation) prevBracketContinuation = false
// Set for the next iteration if current line ends with '[' // Set for the next iteration if current line ends with '['
// Record whether THIS line ends with an opening '[' so the NEXT line gets a one-shot
// continuation indent for the first element.
if (endsWithBracket) { if (endsWithBracket) {
// One-shot continuation for the very next line
prevBracketContinuation = true prevBracketContinuation = true
} else { // Push base indent of the '[' line for subsequent lines in this bracket block
// Reset the one-shot flag if the previous line didn't end with '[' bracketBaseStack.addLast(leadingWs)
prevBracketContinuation = false }
// 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() return sb.toString()
@ -268,8 +263,7 @@ object LyngFormatter {
} }
i++ i++
} }
// Normalize collected base indent: replace tabs with spaces var baseIndent = if (onlyWs) base.toString() else ""
var baseIndent = if (onlyWs) base.toString().replace("\t", " ".repeat(config.indentSize)) else ""
var parentBaseIndent: String? = baseIndent var parentBaseIndent: String? = baseIndent
if (baseIndent.isEmpty()) { if (baseIndent.isEmpty()) {
// Fallback: use the indent of the nearest previous non-empty line as base. // Fallback: use the indent of the nearest previous non-empty line as base.
@ -310,11 +304,10 @@ object LyngFormatter {
if (foundIndent != null) { if (foundIndent != null) {
// If we are right after a line that opens a block, the base for the pasted // 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. // content should be one indent unit deeper than that line's base.
val normFound = foundIndent.replace("\t", " ".repeat(config.indentSize)) parentBaseIndent = foundIndent
parentBaseIndent = normFound
baseIndent = if (prevLineEndsWithOpenBrace) { baseIndent = if (prevLineEndsWithOpenBrace) {
normFound + " ".repeat(config.indentSize.coerceAtLeast(1)) if (config.useTabs) foundIndent + "\t" else foundIndent + " ".repeat(config.indentSize.coerceAtLeast(1))
} else normFound } else foundIndent
} }
if (baseIndent.isEmpty()) { if (baseIndent.isEmpty()) {
// Second fallback: compute structural block level up to this line and use it as base. // Second fallback: compute structural block level up to this line and use it as base.
@ -336,8 +329,8 @@ object LyngFormatter {
iScan = if (lineEnd < text.length) lineEnd + 1 else lineEnd iScan = if (lineEnd < text.length) lineEnd + 1 else lineEnd
} }
if (level > 0) { if (level > 0) {
parentBaseIndent = " ".repeat((level - 1).coerceAtLeast(0) * config.indentSize.coerceAtLeast(1)) parentBaseIndent = if (config.useTabs) "\t".repeat(level - 1) else " ".repeat((level - 1).coerceAtLeast(0) * config.indentSize.coerceAtLeast(1))
baseIndent = " ".repeat(level * config.indentSize.coerceAtLeast(1)) baseIndent = if (config.useTabs) "\t".repeat(level) else " ".repeat(level * config.indentSize.coerceAtLeast(1))
} }
} }
} }
@ -350,9 +343,9 @@ object LyngFormatter {
if (lineEnd < 0) lineEnd = formattedZero.length if (lineEnd < 0) lineEnd = formattedZero.length
val line = formattedZero.substring(lineStart, lineEnd) val line = formattedZero.substring(lineStart, lineEnd)
if (line.isNotEmpty()) { if (line.isNotEmpty()) {
// Apply the SAME base indent to all lines in the slice, including '}' lines. val isCloser = line.dropWhile { it == ' ' || it == '\t' }.startsWith("}")
// Structural alignment of braces is already handled inside formattedZero. val indentToUse = if (isCloser && parentBaseIndent != null) parentBaseIndent!! else baseIndent
sb.append(baseIndent).append(line) sb.append(indentToUse).append(line)
} else sb.append(line) } else sb.append(line)
if (lineEnd < formattedZero.length) sb.append('\n') if (lineEnd < formattedZero.length) sb.append('\n')
i = lineEnd + 1 i = lineEnd + 1

View File

@ -3743,27 +3743,4 @@ class ScriptTest {
// assertEquals( "foo!. bar?", "${buzz[0]+"!"}. ${buzz[1]+"?"}" ) // assertEquals( "foo!. bar?", "${buzz[0]+"!"}. ${buzz[1]+"?"}" )
// """.trimIndent()) // """.trimIndent())
// } // }
@Test
fun testInlineArrayLiteral() = runTest {
eval("""
val res = []
for( i in [4,3,1] ) {
res.add(i)
}
assertEquals( [4,3,1], res )
""".trimIndent())
}
@Test
fun testInlineMapLiteral() = runTest {
eval("""
val res = {}
for( i in {foo: "bar"} ) {
res[i.key] = i.value
}
assertEquals( {foo: "bar"}, res )
""".trimIndent())
}
} }

View File

@ -62,7 +62,7 @@ class BlockReindentTest {
assertEquals(0, startLinePrefix.length) assertEquals(0, startLinePrefix.length)
// end should be at a line boundary // end should be at a line boundary
val endsAtNl = (end == text.length) || text.getOrNull(end - 1) == '\n' val endsAtNl = (end == text.length) || text.getOrNull(end - 1) == '\n'
assertEquals(true, endsAtNl) kotlin.test.assertEquals(true, endsAtNl)
} }
@Test @Test
@ -84,15 +84,18 @@ class BlockReindentTest {
// Validate shape: first line starts with '{', second line is indented '21', third line is '}' // 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 slice = updated.substring(range.first, min(updated.length, range.last + 1))
assertEquals("""
fun test21() {
{ // inner block wrongly formatted
21
}
}
""".trimIndent()+"\n", updated)
val lines = slice.removeSuffix("\n").lines() 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 @Test
@ -121,7 +124,7 @@ class BlockReindentTest {
val l1 = lines[1].drop(baseLen) val l1 = lines[1].drop(baseLen)
val l2 = lines[2].drop(baseLen) val l2 = lines[2].drop(baseLen)
// Expect properly shaped inner block // Expect properly shaped inner block
assertEquals(true, l0.startsWith("{")) kotlin.test.assertEquals(true, l0.startsWith("{"))
assertEquals(" 1", l1) assertEquals(" 1", l1)
assertEquals("}", l2) assertEquals("}", l2)
} }
@ -145,9 +148,9 @@ class BlockReindentTest {
val l0 = lines[0].drop(baseLen) val l0 = lines[0].drop(baseLen)
val l1 = lines[1].drop(baseLen) val l1 = lines[1].drop(baseLen)
val l2 = lines[2].drop(baseLen) val l2 = lines[2].drop(baseLen)
assertEquals(true, l0.startsWith("{ // open")) kotlin.test.assertEquals(true, l0.startsWith("{ // open"))
assertEquals(" 21 // body", l1) // 2-space indent assertEquals(" 21 // body", l1) // 2-space indent
assertEquals(true, l2.startsWith("} // close")) kotlin.test.assertEquals(true, l2.startsWith("} // close"))
} }
@Test @Test
@ -167,7 +170,7 @@ class BlockReindentTest {
// Drop base indent and collapse whitespace; expect only braces remain in order // Drop base indent and collapse whitespace; expect only braces remain in order
val innerText = lines.joinToString("\n") { it.drop(baseLen) }.trimEnd() val innerText = lines.joinToString("\n") { it.drop(baseLen) }.trimEnd()
val collapsed = innerText.replace(" ", "").replace("\t", "").replace("\n", "") val collapsed = innerText.replace(" ", "").replace("\t", "").replace("\n", "")
assertEquals("{}", collapsed) kotlin.test.assertEquals("{}", collapsed)
} }
@Test @Test
@ -177,8 +180,15 @@ class BlockReindentTest {
val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!! val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!!
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = true) val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = true)
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true) val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
// New policy: resulting code must not contain tabs val firstLine = updated.substring(range.first, updated.indexOf('\n', range.first).let { if (it < 0) updated.length else it })
kotlin.test.assertTrue(!updated.contains('\t')) // 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 @Test
@ -193,7 +203,7 @@ class BlockReindentTest {
val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!! val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!!
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false) val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true) val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
assertEquals(true, updated.isNotEmpty()) kotlin.test.assertEquals(true, updated.isNotEmpty())
} }
@Test @Test
@ -206,8 +216,7 @@ class BlockReindentTest {
""".trimIndent() """.trimIndent()
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false) val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindent(original, cfg) val updated = LyngFormatter.reindent(original, cfg)
val allLines = updated.lines() val lines = updated.lines()
val lines = allLines.dropLastWhile { it.isBlank() }
// Expect first element line to be continuation-indented (4 spaces) // Expect first element line to be continuation-indented (4 spaces)
assertEquals(" 1,", lines[1]) assertEquals(" 1,", lines[1])
assertEquals(" 2", lines[2]) assertEquals(" 2", lines[2])
@ -264,11 +273,16 @@ class BlockReindentTest {
@Test @Test
fun mixedTabsSpaces_baseIndent_preserved() { fun mixedTabsSpaces_baseIndent_preserved() {
// base indent has one tab then two spaces; after normalization no tabs should remain // base indent has one tab then two spaces; body lines should preserve base + continuation
val original = "\t [\n1,\n2\n]" // no trailing newline val original = "\t [\n1,\n2\n]" // no trailing newline
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false) val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindent(original, cfg) val updated = LyngFormatter.reindent(original, cfg)
kotlin.test.assertTrue(!updated.contains('\t')) 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 @Test
@ -327,7 +341,7 @@ class BlockReindentTest {
// Closing bracket aligns with base (no continuation) // Closing bracket aligns with base (no continuation)
assertEquals("]", lines[4].trimStart()) assertEquals("]", lines[4].trimStart())
// File ends without extra newline // File ends without extra newline
assertEquals(false, updated.endsWith("\n")) kotlin.test.assertEquals(false, updated.endsWith("\n"))
} }
@Test @Test
@ -342,7 +356,8 @@ class BlockReindentTest {
// base indent on the empty line inside the block is 4 spaces // base indent on the empty line inside the block is 4 spaces
val caretOffset = caretLineStart + 4 val caretOffset = caretLineStart + 4
val paste = """ val paste = """
if (x) { if (x)
{
1 1
} }
""".trimIndent() """.trimIndent()
@ -354,29 +369,56 @@ class BlockReindentTest {
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = false) val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = false)
val updated = LyngFormatter.reindentRange(afterPaste, insertedRange, cfg, preserveBaseIndent = true) val updated = LyngFormatter.reindentRange(afterPaste, insertedRange, cfg, preserveBaseIndent = true)
// Policy: spaces-only, structure preserved. Check shape instead of exact whitespace. // Extract the inserted slice and verify there is a common base indent of 4 spaces
kotlin.test.assertTrue(!updated.contains('\t')) val slice = updated.substring(insertedRange.first, insertedRange.last + 1)
val lines = updated.lines().dropLastWhile { it.isBlank() } val lines = slice.lines().filter { it.isNotEmpty() }
// Keep function header and footer kotlin.test.assertTrue(lines.isNotEmpty())
assertEquals("fun pasteHere() {", lines[0]) // Compute minimal common leading whitespace among non-empty lines
assertEquals("}", lines.last()) fun leadingWs(s: String): String = s.takeWhile { it == ' ' || it == '\t' }
// Inside the block, one of two styles is acceptable: val commonBase = lines.map(::leadingWs).reduce { acc, s ->
// 1) compact brace on condition line var i = 0
// 2) condition then brace on next line val max = min(acc.length, s.length)
val blockLines = lines.subList(1, lines.size - 1) while (i < max && acc[i] == s[i]) i++
val compact = blockLines.firstOrNull()?.trim() == "if (x) {" acc.substring(0, i)
if (compact) {
// Body should be indented by at least one level (4 spaces)
kotlin.test.assertTrue(blockLines.getOrNull(1)?.startsWith(" ") == true ||
blockLines.getOrNull(1)?.startsWith(" ") == true)
// Closing brace should appear in one of subsequent lines
kotlin.test.assertTrue(blockLines.drop(1).any { it.trim() == "}" })
} else {
assertEquals("if (x)", blockLines.getOrNull(0))
assertEquals("{", blockLines.getOrNull(1))
kotlin.test.assertTrue(blockLines.getOrNull(2)?.startsWith(" ") == true)
kotlin.test.assertTrue(blockLines.drop(2).any { it.trim() == "}" })
} }
// 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 @Test
@ -396,12 +438,12 @@ class BlockReindentTest {
val afterOpenNl = updated.indexOf('\n', openIdx) + 1 val afterOpenNl = updated.indexOf('\n', openIdx) + 1
val bodyLineEnd = updated.indexOf('\n', afterOpenNl).let { if (it < 0) updated.length else it } val bodyLineEnd = updated.indexOf('\n', afterOpenNl).let { if (it < 0) updated.length else it }
val bodyLine = updated.substring(afterOpenNl, bodyLineEnd) val bodyLine = updated.substring(afterOpenNl, bodyLineEnd)
assertEquals(" 1", bodyLine) kotlin.test.assertEquals(" 1", bodyLine)
// Closing brace should appear on its own line (no leading spaces) // Closing brace should appear on its own line (no leading spaces)
val closeLineStart = bodyLineEnd + 1 val closeLineStart = bodyLineEnd + 1
val closeLineEnd = updated.indexOf('\n', closeLineStart).let { if (it < 0) updated.length else it } val closeLineEnd = updated.indexOf('\n', closeLineStart).let { if (it < 0) updated.length else it }
val closeLine = updated.substring(closeLineStart, closeLineEnd) val closeLine = updated.substring(closeLineStart, closeLineEnd)
assertEquals("}", closeLine) kotlin.test.assertEquals("}", closeLine)
} }
@Test @Test
@ -413,7 +455,7 @@ class BlockReindentTest {
} }
""".trimIndent() + "\n" """.trimIndent() + "\n"
val blankLineStart = before.indexOf("\n", before.indexOf("g()")) + 1 val blankLineStart = before.indexOf("\n", before.indexOf("g()")) + 1
before.substring(blankLineStart, before.indexOf('\n', blankLineStart)) val line = before.substring(blankLineStart, before.indexOf('\n', blankLineStart))
// line currently has 4 spaces; select and replace the last 2 spaces // line currently has 4 spaces; select and replace the last 2 spaces
val selectionStart = blankLineStart + 2 val selectionStart = blankLineStart + 2
val selectionEnd = blankLineStart + 4 val selectionEnd = blankLineStart + 4
@ -422,16 +464,13 @@ class BlockReindentTest {
val insertedRange = selectionStart until (selectionStart + paste.length) val insertedRange = selectionStart until (selectionStart + paste.length)
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false) val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
val updated = LyngFormatter.reindentRange(afterPaste, insertedRange, cfg, preserveBaseIndent = true) val updated = LyngFormatter.reindentRange(afterPaste, insertedRange, cfg, preserveBaseIndent = true)
// Spaces-only and structural validation val slice = updated.substring(insertedRange.first, insertedRange.last + 1)
kotlin.test.assertTrue(!updated.contains('\t')) val lines = slice.lines().filter { it.isNotEmpty() }
val all2 = updated.lines() // Base indent should be 2 spaces (remaining before selectionStart)
val lines = all2.dropLastWhile { it.isBlank() } kotlin.test.assertTrue(lines.all { it.startsWith(" ") })
assertEquals("fun g() {", lines.first()) val deBased = lines.map { it.removePrefix(" ") }
assertEquals("}", lines.last()) kotlin.test.assertEquals("{", deBased.first())
// Find the inner block lines and check indentation levels exist (spaces) kotlin.test.assertEquals(" 1", deBased.getOrNull(1) ?: "")
val innerStart = lines.indexOfFirst { it.trimStart().startsWith("{") } kotlin.test.assertEquals("}", deBased.last().trimEnd())
kotlin.test.assertTrue(innerStart > 0)
kotlin.test.assertTrue(lines.getOrNull(innerStart + 1)?.trimStart()?.startsWith("1") == true)
kotlin.test.assertEquals("}", lines.getOrNull(innerStart + 2)?.trim())
} }
} }