idea plugin 0.0.1-SNAPSHOT: basic coloring and editing aids
This commit is contained in:
1 parent
9c342c5c72
commit
06e8e1579d
46 files changed
+4192
-6
No files matched your search
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.format
|
||||
|
||||
/**
|
||||
* Simple text-based brace utilities for Lyng.
|
||||
*
|
||||
* The scan ignores everything after `//` on a line.
|
||||
*/
|
||||
object BraceUtils {
|
||||
/**
|
||||
* Finds the index of the matching opening '{' for the closing '}' at [closeIndex].
|
||||
* Returns null if not found. The scan moves left from [closeIndex] and ignores text after // comments.
|
||||
*/
|
||||
fun findMatchingOpenBrace(text: CharSequence, closeIndex: Int): Int? {
|
||||
if (closeIndex < 0 || closeIndex >= text.length || text[closeIndex] != '}') return null
|
||||
var i = closeIndex
|
||||
var balance = 0
|
||||
while (i >= 0) {
|
||||
val ch = text[i]
|
||||
if (ch == '\n') {
|
||||
// skip comment tails on this line by finding // and ignoring chars after it
|
||||
val lineStart = lastIndexOf(text, '\n', i - 1) + 1
|
||||
val slashes = indexOf(text, '/', lineStart, i)
|
||||
if (slashes >= 0 && slashes + 1 <= i && slashes + 1 < text.length && text[slashes + 1] == '/') {
|
||||
i = slashes - 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
when (ch) {
|
||||
'}' -> balance++
|
||||
'{' -> {
|
||||
// When scanning left, the matching '{' is the point where balance==1
|
||||
// (i.e., it pairs with the first '}' we started from)
|
||||
if (balance == 1) return i else if (balance > 1) balance--
|
||||
}
|
||||
}
|
||||
i--
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Returns the start offset of the line that contains [offset]. */
|
||||
fun lineStart(text: CharSequence, offset: Int): Int {
|
||||
var i = (offset.coerceIn(0, text.length)) - 1
|
||||
while (i >= 0) {
|
||||
if (text[i] == '\n') return i + 1
|
||||
i--
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Returns the end offset (exclusive) of the line that contains [offset]. */
|
||||
fun lineEnd(text: CharSequence, offset: Int): Int {
|
||||
var i = offset.coerceIn(0, text.length)
|
||||
while (i < text.length) {
|
||||
if (text[i] == '\n') return i
|
||||
i++
|
||||
}
|
||||
return text.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the enclosing block range when pressing Enter after a closing brace '}' at [closeIndex].
|
||||
* Returns an [IntRange] covering from the start of the opening-brace line through the end of the
|
||||
* closing-brace line. If [includeTrailingNewline] is true and there is a newline after '}', the range
|
||||
* extends to the start of the next line.
|
||||
*/
|
||||
fun findEnclosingBlockRange(
|
||||
text: CharSequence,
|
||||
closeIndex: Int,
|
||||
includeTrailingNewline: Boolean = true
|
||||
): IntRange? {
|
||||
// Be tolerant: if closeIndex doesn't point at '}', scan left to nearest '}'
|
||||
var ci = closeIndex.coerceIn(0, text.length)
|
||||
while (ci >= 0 && text[ci] != '}') ci--
|
||||
if (ci < 0) return null
|
||||
val open = findMatchingOpenBrace(text, ci) ?: return null
|
||||
val start = lineStart(text, open)
|
||||
val closeLineEnd = lineEnd(text, ci)
|
||||
val endExclusive = if (includeTrailingNewline && closeLineEnd < text.length && text[closeLineEnd] == '\n')
|
||||
closeLineEnd + 1 else closeLineEnd
|
||||
return start until endExclusive
|
||||
}
|
||||
|
||||
private fun lastIndexOf(text: CharSequence, ch: Char, fromIndex: Int): Int {
|
||||
var i = fromIndex
|
||||
while (i >= 0) {
|
||||
if (text[i] == ch) return i
|
||||
i--
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun indexOf(text: CharSequence, ch: Char, fromIndex: Int, toIndexExclusive: Int): Int {
|
||||
var i = fromIndex
|
||||
val to = toIndexExclusive.coerceAtMost(text.length)
|
||||
while (i < to) {
|
||||
if (text[i] == ch) return i
|
||||
i++
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.format
|
||||
|
||||
/**
|
||||
* Formatting configuration for Lyng source code.
|
||||
* Defaults are Kotlin-like.
|
||||
*/
|
||||
data class LyngFormatConfig(
|
||||
val indentSize: Int = 4,
|
||||
val useTabs: Boolean = false,
|
||||
val continuationIndentSize: Int = 4,
|
||||
val maxLineLength: Int = 120,
|
||||
val applySpacing: Boolean = false,
|
||||
val applyWrapping: Boolean = false,
|
||||
val trailingComma: Boolean = false,
|
||||
) {
|
||||
init {
|
||||
require(indentSize > 0) { "indentSize must be > 0" }
|
||||
require(continuationIndentSize > 0) { "continuationIndentSize must be > 0" }
|
||||
require(maxLineLength > 0) { "maxLineLength must be > 0" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.format
|
||||
|
||||
/**
|
||||
* Lightweight, PSI‑free formatter for Lyng source code.
|
||||
*
|
||||
* Phase 1 focuses on indentation from scratch (idempotent). Spacing/wrapping may be
|
||||
* extended later based on [LyngFormatConfig] flags.
|
||||
*/
|
||||
object LyngFormatter {
|
||||
|
||||
/** Returns the input with indentation recomputed from scratch, line by line. */
|
||||
fun reindent(text: String, config: LyngFormatConfig = LyngFormatConfig()): String {
|
||||
val lines = text.split('\n')
|
||||
val sb = StringBuilder(text.length + lines.size)
|
||||
var blockLevel = 0
|
||||
var parenBalance = 0
|
||||
var bracketBalance = 0
|
||||
var prevBracketContinuation = false
|
||||
val bracketBaseStack = ArrayDeque<String>()
|
||||
|
||||
fun codePart(s: String): String {
|
||||
val idx = s.indexOf("//")
|
||||
return if (idx >= 0) s.substring(0, idx) else s
|
||||
}
|
||||
|
||||
fun indentOf(level: Int, continuation: Int): String =
|
||||
if (config.useTabs) "\t".repeat(level) + " ".repeat(continuation)
|
||||
else " ".repeat(level * config.indentSize + continuation)
|
||||
|
||||
var awaitingSingleIndent = false
|
||||
fun isControlHeaderNoBrace(s: String): Boolean {
|
||||
val t = s.trim()
|
||||
if (t.isEmpty()) return false
|
||||
// match: if (...) | else if (...) | else
|
||||
val isIf = Regex("^if\\s*\\(.*\\)\\s*$").matches(t)
|
||||
val isElseIf = Regex("^else\\s+if\\s*\\(.*\\)\\s*$").matches(t)
|
||||
val isElse = t == "else"
|
||||
return isIf || isElseIf || isElse
|
||||
}
|
||||
|
||||
for ((i, rawLine) in lines.withIndex()) {
|
||||
val line = rawLine
|
||||
val code = codePart(line)
|
||||
val trimmedStart = code.dropWhile { it == ' ' || it == '\t' }
|
||||
|
||||
// Compute effective indent level for this line
|
||||
var effectiveLevel = blockLevel
|
||||
if (trimmedStart.startsWith("}")) effectiveLevel = (effectiveLevel - 1).coerceAtLeast(0)
|
||||
// else/catch/finally should align with the parent block level; no extra dedent here,
|
||||
// because the preceding '}' has already reduced [blockLevel] appropriately.
|
||||
|
||||
// Single-line control header (if/else/else if) without braces: indent the next
|
||||
// non-empty, non-'}', non-'else' line by one extra level
|
||||
val applyAwaiting = awaitingSingleIndent && trimmedStart.isNotEmpty() &&
|
||||
!trimmedStart.startsWith("else") && !trimmedStart.startsWith("}")
|
||||
if (applyAwaiting) effectiveLevel += 1
|
||||
|
||||
val firstChar = trimmedStart.firstOrNull()
|
||||
// Do not apply continuation on a line that starts with a closer ')' or ']'
|
||||
val startsWithCloser = firstChar == ')' || firstChar == ']'
|
||||
// Kotlin-like rule: continuation persists while inside parentheses; it applies
|
||||
// even on the line that starts with ')'. For brackets, do not apply on the ']' line itself.
|
||||
// Continuation rules:
|
||||
// - For brackets: one-shot continuation for first element after '[', and while inside brackets
|
||||
// (except on the ']' line) continuation equals one unit.
|
||||
// - For parentheses: continuation depth scales with nested level; e.g., inside two nested
|
||||
// parentheses lines get 2 * continuationIndentSize. No continuation on a line that starts with ')'.
|
||||
val parenContLevels = if (parenBalance > 0 && firstChar != ')') parenBalance else 0
|
||||
val continuation = when {
|
||||
// One-shot continuation when previous line ended with '[' to align first element
|
||||
prevBracketContinuation && firstChar != ']' -> config.continuationIndentSize
|
||||
// While inside brackets, continuation applies (single unit) except on the closing line
|
||||
bracketBalance > 0 && firstChar != ']' -> config.continuationIndentSize
|
||||
// While inside parentheses, continuation applies scaled by nesting level
|
||||
parenContLevels > 0 -> config.continuationIndentSize * parenContLevels
|
||||
else -> 0
|
||||
}
|
||||
|
||||
// Replace leading whitespace with the exact target indent; but keep fully blank lines truly empty
|
||||
val contentStart = line.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) line.length else it }
|
||||
var content = line.substring(contentStart)
|
||||
// Collapse spaces right after an opening '[' to avoid "[ 1"; make it "[1"
|
||||
if (content.startsWith("[")) {
|
||||
content = "[" + content.drop(1).trimStart()
|
||||
}
|
||||
// Determine base indent: for bracket blocks, preserve the exact leading whitespace
|
||||
val leadingWs = if (contentStart > 0) line.substring(0, contentStart) else ""
|
||||
val currentBracketBase = if (bracketBaseStack.isNotEmpty()) bracketBaseStack.last() else null
|
||||
val indentString = if (currentBracketBase != null) {
|
||||
val cont = if (continuation > 0) {
|
||||
if (config.useTabs) "\t" else " ".repeat(continuation)
|
||||
} else ""
|
||||
currentBracketBase + cont
|
||||
} else indentOf(effectiveLevel, continuation)
|
||||
if (content.isEmpty()) {
|
||||
// preserve truly blank line as empty to avoid trailing spaces on empty lines
|
||||
// (also keeps continuation blocks visually clean)
|
||||
// do nothing, just append nothing; newline will be appended below if needed
|
||||
} else {
|
||||
sb.append(indentString).append(content)
|
||||
}
|
||||
|
||||
// New line (keep EOF semantics similar to input)
|
||||
if (i < lines.lastIndex) sb.append('\n')
|
||||
|
||||
// Update balances using this line's code content
|
||||
for (ch in code) when (ch) {
|
||||
'{' -> blockLevel++
|
||||
'}' -> if (blockLevel > 0) blockLevel--
|
||||
'(' -> parenBalance++
|
||||
')' -> if (parenBalance > 0) parenBalance--
|
||||
'[' -> bracketBalance++
|
||||
']' -> if (bracketBalance > 0) bracketBalance--
|
||||
}
|
||||
|
||||
// Update awaitingSingleIndent based on current line
|
||||
if (applyAwaiting && trimmedStart.isNotEmpty()) {
|
||||
// we have just consumed the body line
|
||||
awaitingSingleIndent = false
|
||||
} else {
|
||||
// start awaiting if current line is a control header without '{'
|
||||
val endsWithBrace = code.trimEnd().endsWith("{")
|
||||
if (!endsWithBrace && isControlHeaderNoBrace(code)) {
|
||||
awaitingSingleIndent = true
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare one-shot bracket continuation if the current line ends with '['
|
||||
// (first element line gets continuation even before balances update propagate).
|
||||
val endsWithBracket = code.trimEnd().endsWith("[")
|
||||
// Reset one-shot flag after we used it on this line
|
||||
if (prevBracketContinuation) prevBracketContinuation = false
|
||||
// Set for the next iteration if current line ends with '['
|
||||
if (endsWithBracket) {
|
||||
prevBracketContinuation = true
|
||||
// Push base indent of the '[' line for subsequent lines in this bracket block
|
||||
bracketBaseStack.addLast(leadingWs)
|
||||
}
|
||||
|
||||
// If this line starts with ']' (closing bracket), pop the preserved base for this bracket level
|
||||
if (trimmedStart.startsWith("]") && bracketBaseStack.isNotEmpty()) {
|
||||
// ensure stack stays in sync with bracket levels
|
||||
bracketBaseStack.removeLast()
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** Full format. Currently performs indentation only; spacing/wrapping can be added later. */
|
||||
fun format(text: String, config: LyngFormatConfig = LyngFormatConfig()): String {
|
||||
// Phase 1: indentation
|
||||
val indented = reindent(text, config)
|
||||
if (!config.applySpacing && !config.applyWrapping) return indented
|
||||
|
||||
// Phase 2: minimal, safe spacing (PSI-free). Skip block comments completely and
|
||||
// only apply spacing to the part before '//' on each line.
|
||||
val lines = indented.split('\n')
|
||||
val out = StringBuilder(indented.length)
|
||||
var inBlockComment = false
|
||||
for ((i, rawLine) in lines.withIndex()) {
|
||||
var line = rawLine
|
||||
if (config.applySpacing) {
|
||||
if (inBlockComment) {
|
||||
// Pass-through until we see the end of the block comment on some line
|
||||
val end = line.indexOf("*/")
|
||||
if (end >= 0) {
|
||||
inBlockComment = false
|
||||
}
|
||||
} else {
|
||||
// If this line opens a block comment, apply spacing only before the opener
|
||||
val startIdx = line.indexOf("/*")
|
||||
val endIdx = line.indexOf("*/")
|
||||
if (startIdx >= 0 && (endIdx < 0 || endIdx < startIdx)) {
|
||||
val before = line.substring(0, startIdx)
|
||||
val after = line.substring(startIdx)
|
||||
val commentIdx = before.indexOf("//")
|
||||
val code = if (commentIdx >= 0) before.substring(0, commentIdx) else before
|
||||
val tail = if (commentIdx >= 0) before.substring(commentIdx) else ""
|
||||
val spaced = applyMinimalSpacing(code)
|
||||
line = (spaced + tail) + after
|
||||
inBlockComment = true
|
||||
} else {
|
||||
// Normal code line: respect single-line comments
|
||||
val commentIdx = line.indexOf("//")
|
||||
if (commentIdx >= 0) {
|
||||
val code = line.substring(0, commentIdx)
|
||||
val tail = line.substring(commentIdx)
|
||||
val spaced = applyMinimalSpacing(code)
|
||||
line = spaced + tail
|
||||
} else {
|
||||
line = applyMinimalSpacing(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.append(line.trimEnd())
|
||||
if (i < lines.lastIndex) out.append('\n')
|
||||
}
|
||||
val spacedText = out.toString()
|
||||
|
||||
// Phase 3: controlled wrapping (only if enabled)
|
||||
if (!config.applyWrapping) return spacedText
|
||||
return applyControlledWrapping(spacedText, config)
|
||||
}
|
||||
|
||||
private fun startsWithWord(s: String, w: String): Boolean =
|
||||
s.startsWith(w) && s.getOrNull(w.length)?.let { !it.isLetterOrDigit() && it != '_' } != false
|
||||
|
||||
/**
|
||||
* Reindents a slice of [text] specified by [range] and returns a new string with that slice replaced.
|
||||
* By default, preserves the base indent of the first line in the slice (so the block stays at
|
||||
* the same outer indentation level) while normalizing its inner structure according to the formatter rules.
|
||||
*/
|
||||
fun reindentRange(
|
||||
text: String,
|
||||
range: IntRange,
|
||||
config: LyngFormatConfig = LyngFormatConfig(),
|
||||
preserveBaseIndent: Boolean = true,
|
||||
baseIndentFrom: Int? = null
|
||||
): String {
|
||||
if (range.isEmpty()) return text
|
||||
val start = range.first.coerceIn(0, text.length)
|
||||
val endExclusive = (range.last + 1).coerceIn(start, text.length)
|
||||
val slice = text.substring(start, endExclusive)
|
||||
val formattedZero = reindent(slice, config)
|
||||
val resultSlice = if (!preserveBaseIndent) formattedZero else run {
|
||||
// Compute base indent from the beginning of the current line up to [baseStart].
|
||||
// If there is any non-whitespace between the line start and [start], we consider it a mid-line paste
|
||||
// and do not apply a base indent (to avoid corrupting code). Otherwise, preserve the existing
|
||||
// leading whitespace up to the caret as the base for the pasted block.
|
||||
val baseStartIndex = (baseIndentFrom ?: start).coerceIn(0, text.length)
|
||||
val lineStart = run {
|
||||
var i = (baseStartIndex - 1).coerceAtLeast(0)
|
||||
while (i >= 0 && text[i] != '\n') i--
|
||||
i + 1
|
||||
}
|
||||
var i = lineStart
|
||||
var onlyWs = true
|
||||
val base = StringBuilder()
|
||||
while (i < baseStartIndex) {
|
||||
val ch = text[i]
|
||||
if (ch == ' ' || ch == '\t') {
|
||||
base.append(ch)
|
||||
} else {
|
||||
onlyWs = false
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
var baseIndent = if (onlyWs) base.toString() else ""
|
||||
var parentBaseIndent: String? = baseIndent
|
||||
if (baseIndent.isEmpty()) {
|
||||
// Fallback: use the indent of the nearest previous non-empty line as base.
|
||||
// This helps when pasting at column 0 of a blank line inside a block.
|
||||
var j = (lineStart - 2).coerceAtLeast(0)
|
||||
// move j to end of previous line
|
||||
while (j >= 0 && text[j] != '\n') j--
|
||||
// now scan lines backwards to find a non-empty line
|
||||
var foundIndent: String? = null
|
||||
var prevLineEndsWithOpenBrace = false
|
||||
var p = (j - 1).coerceAtLeast(0)
|
||||
while (p >= 0) {
|
||||
// find start of this line
|
||||
var ls = p
|
||||
while (ls >= 0 && text[ls] != '\n') ls--
|
||||
val startLine = ls + 1
|
||||
val endLine = p + 1
|
||||
// extract line
|
||||
val lineText = text.subSequence(startLine, endLine)
|
||||
val trimmed = lineText.dropWhile { it == ' ' || it == '\t' }
|
||||
if (trimmed.isNotEmpty()) {
|
||||
// take its leading whitespace as base indent
|
||||
val wsLen = lineText.length - trimmed.length
|
||||
foundIndent = lineText.substring(0, wsLen)
|
||||
// Decide if this line ends with an opening brace before any // comment
|
||||
val codePart = run {
|
||||
val s = lineText.toString()
|
||||
val idx = s.indexOf("//")
|
||||
if (idx >= 0) s.substring(0, idx) else s
|
||||
}
|
||||
prevLineEndsWithOpenBrace = codePart.trimEnd().endsWith("{")
|
||||
break
|
||||
}
|
||||
// move to previous line
|
||||
p = (ls - 1).coerceAtLeast(-1)
|
||||
if (p < 0) break
|
||||
}
|
||||
if (foundIndent != null) {
|
||||
// If we are right after a line that opens a block, the base for the pasted
|
||||
// content should be one indent unit deeper than that line's base.
|
||||
parentBaseIndent = foundIndent
|
||||
baseIndent = if (prevLineEndsWithOpenBrace) {
|
||||
if (config.useTabs) foundIndent + "\t" else foundIndent + " ".repeat(config.indentSize.coerceAtLeast(1))
|
||||
} else foundIndent
|
||||
}
|
||||
if (baseIndent.isEmpty()) {
|
||||
// Second fallback: compute structural block level up to this line and use it as base.
|
||||
// We scan from start to lineStart and compute '{' '}' balance ignoring // comments.
|
||||
var level = 0
|
||||
var iScan = 0
|
||||
while (iScan < lineStart && iScan < text.length) {
|
||||
// read line
|
||||
var lineEnd = iScan
|
||||
while (lineEnd < text.length && text[lineEnd] != '\n') lineEnd++
|
||||
val raw = text.subSequence(iScan, lineEnd).toString()
|
||||
val code = raw.substringBefore("//")
|
||||
for (ch in code) {
|
||||
when (ch) {
|
||||
'{' -> level++
|
||||
'}' -> if (level > 0) level--
|
||||
}
|
||||
}
|
||||
iScan = if (lineEnd < text.length) lineEnd + 1 else lineEnd
|
||||
}
|
||||
if (level > 0) {
|
||||
parentBaseIndent = if (config.useTabs) "\t".repeat(level - 1) else " ".repeat((level - 1).coerceAtLeast(0) * config.indentSize.coerceAtLeast(1))
|
||||
baseIndent = if (config.useTabs) "\t".repeat(level) else " ".repeat(level * config.indentSize.coerceAtLeast(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (baseIndent.isEmpty()) formattedZero else {
|
||||
val sb = StringBuilder(formattedZero.length + 32)
|
||||
var i = 0
|
||||
while (i < formattedZero.length) {
|
||||
val lineStart = i
|
||||
var lineEnd = formattedZero.indexOf('\n', lineStart)
|
||||
if (lineEnd < 0) lineEnd = formattedZero.length
|
||||
val line = formattedZero.substring(lineStart, lineEnd)
|
||||
if (line.isNotEmpty()) {
|
||||
val isCloser = line.dropWhile { it == ' ' || it == '\t' }.startsWith("}")
|
||||
val indentToUse = if (isCloser && parentBaseIndent != null) parentBaseIndent!! else baseIndent
|
||||
sb.append(indentToUse).append(line)
|
||||
} else sb.append(line)
|
||||
if (lineEnd < formattedZero.length) sb.append('\n')
|
||||
i = lineEnd + 1
|
||||
}
|
||||
sb.toString()
|
||||
}
|
||||
}
|
||||
if (resultSlice == slice) return text
|
||||
val sb = StringBuilder(text.length - slice.length + resultSlice.length)
|
||||
sb.append(text, 0, start)
|
||||
sb.append(resultSlice)
|
||||
sb.append(text, endExclusive, text.length)
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyMinimalSpacing(code: String): String {
|
||||
var s = code
|
||||
// Ensure space before '(' for control-flow keywords
|
||||
s = s.replace(Regex("\\b(if|for|while)\\("), "$1 (")
|
||||
// Space before '{' for control-flow headers only (avoid function declarations)
|
||||
s = s.replace(Regex("\\b(if|for|while)(\\s*\\([^)]*\\))\\s*\\{"), "$1$2 {")
|
||||
s = s.replace(Regex("\\belse\\s+if(\\s*\\([^)]*\\))\\s*\\{"), "else if$1 {")
|
||||
// Do NOT globally convert "){" to ") {"; this would break function declarations.
|
||||
// Normalize control-flow braces explicitly:
|
||||
s = s.replace(Regex("\\bif\\s*\\(([^)]*)\\)\\s*\\{"), "if ($1) {")
|
||||
s = s.replace(Regex("\\bfor\\s*\\(([^)]*)\\)\\s*\\{"), "for ($1) {")
|
||||
s = s.replace(Regex("\\bwhile\\s*\\(([^)]*)\\)\\s*\\{"), "while ($1) {")
|
||||
s = s.replace(Regex("\\belse\\s+if\\s*\\(([^)]*)\\)\\s*\\{"), "else if ($1) {")
|
||||
s = s.replace(Regex("\\belse\\{"), "else {")
|
||||
// Ensure space between closing brace and else
|
||||
s = s.replace(Regex("\\}\\s*else\\b"), "} else")
|
||||
// Ensure single space in "else if"
|
||||
s = s.replace(Regex("\\belse\\s+if\\b"), "else if")
|
||||
// Ensure space before '{' for try/catch/finally
|
||||
s = s.replace(Regex("\\b(try|catch|finally)\\{"), "$1 {")
|
||||
// Ensure space before '{' when catch has parameters: "catch (e){" -> "catch (e) {"
|
||||
s = s.replace(Regex("(\\bcatch\\s*\\([^)]*\\))\\s*\\{"), "$1 {")
|
||||
// Ensure space before '(' for catch parameter
|
||||
s = s.replace(Regex("\\bcatch\\("), "catch (")
|
||||
// Remove spaces just inside parentheses/brackets: "( a )" -> "(a)"
|
||||
s = s.replace(Regex("\\(\\s+"), "(")
|
||||
// Do not strip leading indentation before a closing bracket/paren on its own line
|
||||
s = s.replace(Regex("(?<=\\S)\\s+\\)"), ")")
|
||||
s = s.replace(Regex("\\[\\s+"), "[")
|
||||
s = s.replace(Regex("(?<=\\S)\\s+\\]"), "]")
|
||||
// Keep function declarations as-is; don't force or remove space before '{' in function headers.
|
||||
// Commas: no space before, one space after (unless followed by ) or ])
|
||||
// Comma spacing: ensure one space after, none before; but avoid trailing spaces at EOL
|
||||
s = s.replace(Regex("\\s*,\\s*"), ", ")
|
||||
// Remove space after comma if it ends the line or before a closing paren/bracket
|
||||
s = s.replace(Regex(", \\r?\\n"), ",\n")
|
||||
s = s.replace(Regex(", \\)"), ",)")
|
||||
s = s.replace(Regex(", \\]"), ",]")
|
||||
// Equality/boolean operators: ensure spaces around
|
||||
s = s.replace(Regex("\\s*(==|!=|<=|>=|&&|\\|\\|)\\s*"), " $1 ")
|
||||
// Assignment '=' (not part of '==', '!=', '<=', '>=', '=>'): collapse whitespace to single spaces around '='
|
||||
s = s.replace(Regex("(?<![=!<>])\\s*=\\s*(?![=>])"), " = ")
|
||||
// Multiply/Divide/Mod as binary: spaces around
|
||||
s = s.replace(Regex("\\s*([*/%])\\s*"), " $1 ")
|
||||
// Addition as binary: spaces around '+'. We try not to break '++' and '+=' here.
|
||||
s = s.replace(Regex("(?<![+])\\s*\\+\\s*(?![+=])"), " + ")
|
||||
// Subtraction as binary: add spaces when it looks binary. Keep unary '-' tight (after start or ( [ { = : ,)
|
||||
s = s.replace(Regex("(?<=[^\\s(\\[\\{=:,])-(?=[^=])"), " - ")
|
||||
// Colon in types/extends: remove spaces before, ensure one space after (keep '::' intact)
|
||||
s = s.replace(Regex("(?<!:)\\s*:(?!:)\\s*"), ": ")
|
||||
return s
|
||||
}
|
||||
|
||||
private fun applyControlledWrapping(text: String, cfg: LyngFormatConfig): String {
|
||||
if (!cfg.applyWrapping) return text
|
||||
val lines = text.split('\n')
|
||||
val out = StringBuilder(text.length)
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
val line = lines[i]
|
||||
if (line.length > cfg.maxLineLength && line.contains('(') && line.contains(',') && !line.contains('"') && !line.contains('\'')) {
|
||||
val open = line.indexOf('(')
|
||||
val close = line.lastIndexOf(')')
|
||||
if (open in 0 until close) {
|
||||
val head = line.substring(0, open + 1)
|
||||
val args = line.substring(open + 1, close)
|
||||
val tail = line.substring(close)
|
||||
// Split by commas without adding trailing comma
|
||||
val parts = args.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
if (parts.size >= 2) {
|
||||
val baseIndent = leadingWhitespaceOf(head)
|
||||
val contIndent = baseIndent + if (cfg.useTabs) "\t" else " ".repeat(cfg.continuationIndentSize)
|
||||
out.append(head.trimEnd()).append('\n')
|
||||
for ((idx, p) in parts.withIndex()) {
|
||||
out.append(contIndent).append(p)
|
||||
if (idx < parts.lastIndex) out.append(',')
|
||||
out.append('\n')
|
||||
}
|
||||
out.append(baseIndent).append(tail.trimStart())
|
||||
out.append('\n')
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
out.append(line)
|
||||
if (i < lines.lastIndex) out.append('\n')
|
||||
i++
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun leadingWhitespaceOf(s: String): String {
|
||||
var k = 0
|
||||
while (k < s.length && (s[k] == ' ' || s[k] == '\t')) k++
|
||||
return s.substring(0, k)
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
/*
|
||||
* Tests for block detection and partial reindent in Lyng formatter
|
||||
*/
|
||||
package net.sergeych.lyng.format
|
||||
|
||||
import kotlin.math.min
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class BlockReindentTest {
|
||||
@Test
|
||||
fun findMatchingOpen_basic() {
|
||||
val text = """
|
||||
fun f() {
|
||||
{
|
||||
1
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val close = text.lastIndexOf('}')
|
||||
val open = BraceUtils.findMatchingOpenBrace(text, close)
|
||||
assertNotNull(open)
|
||||
// The char at open must be '{'
|
||||
assertEquals('{', text[open!!])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findEnclosingBlock_rangeIncludesWholeBraceLines() {
|
||||
val text = """
|
||||
fun f() {
|
||||
{
|
||||
1
|
||||
}
|
||||
}
|
||||
""".trimIndent() + "\n" // final newline to emulate editor
|
||||
val close = text.indexOfLast { it == '}' }
|
||||
val range = BraceUtils.findEnclosingBlockRange(text, close, includeTrailingNewline = true)
|
||||
assertNotNull(range)
|
||||
// The range must start at the line start of the matching '{' and end at or after the newline after '}'
|
||||
val start = range!!.first
|
||||
val end = range.last + 1
|
||||
val startLinePrefix = text.substring(BraceUtils.lineStart(text, start), start)
|
||||
// start at column 0 of the line
|
||||
assertEquals(0, startLinePrefix.length)
|
||||
// end should be at a line boundary
|
||||
val endsAtNl = (end == text.length) || text.getOrNull(end - 1) == '\n'
|
||||
kotlin.test.assertEquals(true, endsAtNl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialReindent_fixesInnerIndent_preservesBaseIndent() {
|
||||
val original = """
|
||||
fun test21() {
|
||||
{ // inner block wrongly formatted
|
||||
21
|
||||
}
|
||||
}
|
||||
""".trimIndent() + "\n"
|
||||
|
||||
val commentPos = original.indexOf("// inner block")
|
||||
val close = original.indexOf('}', startIndex = commentPos)
|
||||
val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!!
|
||||
|
||||
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = false)
|
||||
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
|
||||
|
||||
// Validate shape: first line starts with '{', second line is indented '21', third line is '}'
|
||||
val slice = updated.substring(range.first, min(updated.length, range.last + 1))
|
||||
val lines = slice.removeSuffix("\n").lines()
|
||||
// remove common leading base indent from lines
|
||||
val baseLen = lines.first().takeWhile { it == ' ' || it == '\t' }.length
|
||||
val l0 = lines.getOrNull(0)?.drop(baseLen) ?: ""
|
||||
val l1 = lines.getOrNull(1)?.drop(baseLen) ?: ""
|
||||
val l2 = lines.getOrNull(2)?.drop(baseLen) ?: ""
|
||||
// First line: opening brace, possibly followed by inline comment
|
||||
kotlin.test.assertEquals(true, l0.startsWith("{"))
|
||||
// Second line must be exactly 4 spaces + 21 with our cfg
|
||||
assertEquals(" 21", l1)
|
||||
// Third line: closing brace
|
||||
assertEquals("}", l2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nestedBlocks_partialReindent_innerOnly() {
|
||||
val original = """
|
||||
fun outer() {
|
||||
{
|
||||
{
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent() + "\n"
|
||||
|
||||
// Target the closing brace of the INNERMOST block (the first closing brace in the snippet)
|
||||
val innerClose = original.indexOf('}')
|
||||
val range = BraceUtils.findEnclosingBlockRange(original, innerClose, includeTrailingNewline = true)!!
|
||||
|
||||
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = false)
|
||||
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
|
||||
|
||||
val slice = updated.substring(range.first, min(updated.length, range.last + 1))
|
||||
val lines = slice.removeSuffix("\n").lines()
|
||||
val baseLen = lines.first().takeWhile { it == ' ' || it == '\t' }.length
|
||||
val l0 = lines[0].drop(baseLen)
|
||||
val l1 = lines[1].drop(baseLen)
|
||||
val l2 = lines[2].drop(baseLen)
|
||||
// Expect properly shaped inner block
|
||||
kotlin.test.assertEquals(true, l0.startsWith("{"))
|
||||
assertEquals(" 1", l1)
|
||||
assertEquals("}", l2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockWithInlineComments_detectAndReindent() {
|
||||
val original = """
|
||||
fun cmt() {
|
||||
{ // open
|
||||
21 // body
|
||||
} // close
|
||||
}
|
||||
""".trimIndent() + "\n"
|
||||
val close = original.indexOf("} // close")
|
||||
val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!!
|
||||
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
|
||||
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
|
||||
val slice = updated.substring(range.first, min(updated.length, range.last + 1))
|
||||
val lines = slice.removeSuffix("\n").lines()
|
||||
val baseLen = lines.first().takeWhile { it == ' ' || it == '\t' }.length
|
||||
val l0 = lines[0].drop(baseLen)
|
||||
val l1 = lines[1].drop(baseLen)
|
||||
val l2 = lines[2].drop(baseLen)
|
||||
kotlin.test.assertEquals(true, l0.startsWith("{ // open"))
|
||||
assertEquals(" 21 // body", l1) // 2-space indent
|
||||
kotlin.test.assertEquals(true, l2.startsWith("} // close"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyBlock_isNormalized() {
|
||||
val original = """
|
||||
fun e() {
|
||||
{ }
|
||||
}
|
||||
""".trimIndent() + "\n"
|
||||
val close = original.indexOf('}')
|
||||
val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!!
|
||||
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = false)
|
||||
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
|
||||
val slice = updated.substring(range.first, range.last + 1)
|
||||
val lines = slice.removeSuffix("\n").lines()
|
||||
val baseLen = lines.first().takeWhile { it == ' ' || it == '\t' }.length
|
||||
// Drop base indent and collapse whitespace; expect only braces remain in order
|
||||
val innerText = lines.joinToString("\n") { it.drop(baseLen) }.trimEnd()
|
||||
val collapsed = innerText.replace(" ", "").replace("\t", "").replace("\n", "")
|
||||
kotlin.test.assertEquals("{}", collapsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tabBaseIndent_preserved() {
|
||||
val original = "\t\t{\n\t 21\n\t}\n" // tabs for base indent, bad body indent
|
||||
val close = original.lastIndexOf('}')
|
||||
val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!!
|
||||
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = true)
|
||||
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
|
||||
val firstLine = updated.substring(range.first, updated.indexOf('\n', range.first).let { if (it < 0) updated.length else it })
|
||||
// Base indent (two tabs) must be preserved
|
||||
kotlin.test.assertEquals(true, firstLine.startsWith("\t\t{"))
|
||||
// Body line must be base (two tabs) + one indent unit (a tab when useTabs=true)
|
||||
val bodyLineStart = updated.indexOf('\n', range.first) + 1
|
||||
val bodyLineEnd = updated.indexOf('\n', bodyLineStart)
|
||||
val bodyLine = updated.substring(bodyLineStart, if (bodyLineEnd < 0) updated.length else bodyLineEnd)
|
||||
kotlin.test.assertEquals(true, bodyLine.startsWith("\t\t\t"))
|
||||
kotlin.test.assertEquals(true, bodyLine.trimStart().startsWith("21"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noTrailingNewline_afterClose_isHandled() {
|
||||
val original = """
|
||||
fun f() {
|
||||
{
|
||||
21
|
||||
}
|
||||
}""".trimIndent() // EOF right after '}'
|
||||
val close = original.lastIndexOf('}')
|
||||
val range = BraceUtils.findEnclosingBlockRange(original, close, includeTrailingNewline = true)!!
|
||||
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
|
||||
val updated = LyngFormatter.reindentRange(original, range, cfg, preserveBaseIndent = true)
|
||||
kotlin.test.assertEquals(true, updated.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bracketContinuation_firstElementIndented() {
|
||||
val original = """
|
||||
val arr = [
|
||||
1,
|
||||
2
|
||||
]
|
||||
""".trimIndent()
|
||||
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
|
||||
val updated = LyngFormatter.reindent(original, cfg)
|
||||
val lines = updated.lines()
|
||||
// Expect first element line to be continuation-indented (4 spaces)
|
||||
assertEquals(" 1,", lines[1])
|
||||
assertEquals(" 2", lines[2])
|
||||
// Closing bracket should align with 'val arr = [' line
|
||||
assertEquals(
|
||||
lines[0].takeWhile { it == ' ' || it == '\t' } + "]",
|
||||
lines[3]
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parenContinuation_multilineCondition() {
|
||||
val original = """
|
||||
if (
|
||||
a &&
|
||||
b ||
|
||||
c
|
||||
)
|
||||
{
|
||||
1
|
||||
}
|
||||
""".trimIndent()
|
||||
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
|
||||
val updated = LyngFormatter.reindent(original, cfg)
|
||||
val lines = updated.lines()
|
||||
// Lines inside parentheses get continuation indent (4 spaces)
|
||||
assertEquals(" a &&", lines[1])
|
||||
assertEquals(" b ||", lines[2])
|
||||
assertEquals(" c", lines[3])
|
||||
// Closing paren line should not get continuation
|
||||
assertEquals(")", lines[4].trimStart())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chainedCalls_withParens_continuationIndent() {
|
||||
val original = """
|
||||
val x = service
|
||||
.call(
|
||||
a,
|
||||
b,
|
||||
c
|
||||
)
|
||||
""".trimIndent()
|
||||
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
|
||||
val updated = LyngFormatter.reindent(original, cfg)
|
||||
val lines = updated.lines()
|
||||
// inside call parentheses lines should have continuation indent (4 spaces)
|
||||
assertEquals(" a,", lines[2])
|
||||
assertEquals(" b,", lines[3])
|
||||
assertEquals(" c", lines[4])
|
||||
// closing ')' line should not have continuation
|
||||
assertEquals(")", lines[5].trimStart())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mixedTabsSpaces_baseIndent_preserved() {
|
||||
// base indent has one tab then two spaces; body lines should preserve base + continuation
|
||||
val original = "\t [\n1,\n2\n]" // no trailing newline
|
||||
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
|
||||
val updated = LyngFormatter.reindent(original, cfg)
|
||||
val lines = updated.lines()
|
||||
// Expect first element line has base ("\t ") plus 4 spaces
|
||||
kotlin.test.assertEquals(true, lines[1].startsWith("\t "))
|
||||
kotlin.test.assertEquals(true, lines[2].startsWith("\t "))
|
||||
// Closing bracket aligns with base only
|
||||
kotlin.test.assertEquals(true, lines[3].startsWith("\t ]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deepParentheses_and_chainedCalls() {
|
||||
val original = """
|
||||
if (
|
||||
a &&
|
||||
(b || c(
|
||||
x,
|
||||
y
|
||||
)) && service
|
||||
.call(
|
||||
p,
|
||||
q
|
||||
)
|
||||
)
|
||||
{
|
||||
1
|
||||
}
|
||||
""".trimIndent()
|
||||
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
|
||||
val updated = LyngFormatter.reindent(original, cfg)
|
||||
val lines = updated.lines()
|
||||
// Inside top-level parens continuation should apply (4 spaces)
|
||||
assertEquals(" a &&", lines[1])
|
||||
// Nested parens of c( x, y ) apply continuation; current rules accumulate with outer parens
|
||||
// resulting in deeper indent for nested arguments
|
||||
assertEquals(" x,", lines[3])
|
||||
assertEquals(" y", lines[4])
|
||||
// Chained call `.call(` lines inside parentheses are continuation-indented
|
||||
assertEquals(" .call(", lines[6].trimEnd())
|
||||
assertEquals(" p,", lines[7])
|
||||
assertEquals(" q", lines[8])
|
||||
// Closing paren line should not get continuation
|
||||
assertEquals(")", lines[10].trimStart())
|
||||
// Block body is indented by one level
|
||||
assertEquals(" 1", lines[12])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eof_edge_without_trailing_newline_in_block_and_arrays() {
|
||||
val original = """
|
||||
fun g() {
|
||||
[
|
||||
1,
|
||||
2
|
||||
]
|
||||
}
|
||||
""".trimIndent().removeSuffix("\n") // ensure no final newline
|
||||
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
|
||||
val updated = LyngFormatter.reindent(original, cfg)
|
||||
val lines = updated.lines()
|
||||
// First element line should have continuation indent (4 spaces) in addition to base
|
||||
assertEquals(" 1,", lines[2])
|
||||
assertEquals(" 2", lines[3])
|
||||
// Closing bracket aligns with base (no continuation)
|
||||
assertEquals("]", lines[4].trimStart())
|
||||
// File ends without extra newline
|
||||
kotlin.test.assertEquals(false, updated.endsWith("\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialPaste_preservesBaseIndent_forAllLines() {
|
||||
// Simulate a paste at the start of an indented line (caret inside leading whitespace)
|
||||
val before = """
|
||||
fun pasteHere() {
|
||||
|
||||
}
|
||||
""".trimIndent() + "\n"
|
||||
val caretLineStart = before.indexOf("\n", before.indexOf("pasteHere")) + 1
|
||||
// base indent on the empty line inside the block is 4 spaces
|
||||
val caretOffset = caretLineStart + 4
|
||||
val paste = """
|
||||
if (x)
|
||||
{
|
||||
1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
// Build the document text as if pasted as-is first
|
||||
val afterPaste = StringBuilder(before).insert(caretOffset, paste).toString()
|
||||
val insertedRange = caretOffset until (caretOffset + paste.length)
|
||||
|
||||
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 8, useTabs = false)
|
||||
val updated = LyngFormatter.reindentRange(afterPaste, insertedRange, cfg, preserveBaseIndent = true)
|
||||
|
||||
// Extract the inserted slice and verify there is a common base indent of 4 spaces
|
||||
val slice = updated.substring(insertedRange.first, insertedRange.last + 1)
|
||||
val lines = slice.lines().filter { it.isNotEmpty() }
|
||||
kotlin.test.assertTrue(lines.isNotEmpty())
|
||||
// Compute minimal common leading whitespace among non-empty lines
|
||||
fun leadingWs(s: String): String = s.takeWhile { it == ' ' || it == '\t' }
|
||||
val commonBase = lines.map(::leadingWs).reduce { acc, s ->
|
||||
var i = 0
|
||||
val max = min(acc.length, s.length)
|
||||
while (i < max && acc[i] == s[i]) i++
|
||||
acc.substring(0, i)
|
||||
}
|
||||
// Expect at least 4 spaces as base indent preserved from caret line
|
||||
kotlin.test.assertTrue(commonBase.startsWith(" "))
|
||||
val base = " "
|
||||
// Also check the content shape after removing detected base indent (4 spaces)
|
||||
val deBased = lines.map { if (it.startsWith(base)) it.removePrefix(base) else it }
|
||||
kotlin.test.assertEquals("if (x) {", deBased[0])
|
||||
kotlin.test.assertEquals(" 1", deBased.getOrNull(1) ?: "") // one level inside the pasted block
|
||||
kotlin.test.assertEquals("}", deBased.getOrNull(2) ?: "")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialPaste_tabsBaseIndent_preserved() {
|
||||
val before = """
|
||||
\t\tpaste()
|
||||
\t\t\n
|
||||
""".trimIndent() + "\n"
|
||||
// Create a caret on the blank line with base indent of two tabs
|
||||
val lineStart = before.indexOf("\n", before.indexOf("paste()")) + 1
|
||||
val caretOffset = lineStart + 2 // two tabs
|
||||
val paste = """
|
||||
[
|
||||
1,
|
||||
2
|
||||
]
|
||||
""".trimIndent()
|
||||
val afterPaste = StringBuilder(before).insert(caretOffset, paste).toString()
|
||||
val insertedRange = caretOffset until (caretOffset + paste.length)
|
||||
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4, useTabs = true)
|
||||
val updated = LyngFormatter.reindentRange(afterPaste, insertedRange, cfg, preserveBaseIndent = true)
|
||||
val slice = updated.substring(insertedRange.first, insertedRange.last + 1)
|
||||
val lines = slice.lines().filter { it.isNotEmpty() }
|
||||
kotlin.test.assertTrue(lines.all { it.startsWith("\t\t") })
|
||||
// After removing base, first element lines should have one continuation tab worth of indent
|
||||
val deBased = lines.map { it.removePrefix("\t\t") }
|
||||
kotlin.test.assertEquals("[", deBased[0])
|
||||
kotlin.test.assertEquals(true, deBased[1].startsWith("\t"))
|
||||
kotlin.test.assertEquals(true, deBased[2].startsWith("\t"))
|
||||
kotlin.test.assertEquals("]", deBased.last().trimEnd())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialPaste_midLine_noBasePrefix() {
|
||||
// Paste occurs mid-line (after non-whitespace): base indent must not be applied
|
||||
val before = "val a = 1\n"
|
||||
val caretOffset = before.indexOf('1') + 1 // after the '1', mid-line
|
||||
val paste = "\n{\n1\n}\n".trimIndent()
|
||||
val afterPaste = StringBuilder(before).insert(caretOffset, paste).toString()
|
||||
val insertedRange = caretOffset until (caretOffset + paste.length)
|
||||
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
|
||||
val updated = LyngFormatter.reindentRange(afterPaste, insertedRange, cfg, preserveBaseIndent = true)
|
||||
// Find the opening brace position and verify structure around it in the updated text
|
||||
val openIdx = updated.indexOf('{', startIndex = insertedRange.first)
|
||||
kotlin.test.assertTrue(openIdx >= 0)
|
||||
// Next line should be indented body line with 2 spaces then '1'
|
||||
val afterOpenNl = updated.indexOf('\n', openIdx) + 1
|
||||
val bodyLineEnd = updated.indexOf('\n', afterOpenNl).let { if (it < 0) updated.length else it }
|
||||
val bodyLine = updated.substring(afterOpenNl, bodyLineEnd)
|
||||
kotlin.test.assertEquals(" 1", bodyLine)
|
||||
// Closing brace should appear on its own line (no leading spaces)
|
||||
val closeLineStart = bodyLineEnd + 1
|
||||
val closeLineEnd = updated.indexOf('\n', closeLineStart).let { if (it < 0) updated.length else it }
|
||||
val closeLine = updated.substring(closeLineStart, closeLineEnd)
|
||||
kotlin.test.assertEquals("}", closeLine)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialPaste_replacingPartOfLeadingWhitespace_usesRemainingAsBase() {
|
||||
// The selection replaces part of the leading whitespace; base should be the whitespace before start
|
||||
val before = """
|
||||
fun g() {
|
||||
|
||||
}
|
||||
""".trimIndent() + "\n"
|
||||
val blankLineStart = before.indexOf("\n", before.indexOf("g()")) + 1
|
||||
val line = before.substring(blankLineStart, before.indexOf('\n', blankLineStart))
|
||||
// line currently has 4 spaces; select and replace the last 2 spaces
|
||||
val selectionStart = blankLineStart + 2
|
||||
val selectionEnd = blankLineStart + 4
|
||||
val paste = "{\n1\n}\n"
|
||||
val afterPaste = before.substring(0, selectionStart) + paste + before.substring(selectionEnd)
|
||||
val insertedRange = selectionStart until (selectionStart + paste.length)
|
||||
val cfg = LyngFormatConfig(indentSize = 2, continuationIndentSize = 4, useTabs = false)
|
||||
val updated = LyngFormatter.reindentRange(afterPaste, insertedRange, cfg, preserveBaseIndent = true)
|
||||
val slice = updated.substring(insertedRange.first, insertedRange.last + 1)
|
||||
val lines = slice.lines().filter { it.isNotEmpty() }
|
||||
// Base indent should be 2 spaces (remaining before selectionStart)
|
||||
kotlin.test.assertTrue(lines.all { it.startsWith(" ") })
|
||||
val deBased = lines.map { it.removePrefix(" ") }
|
||||
kotlin.test.assertEquals("{", deBased.first())
|
||||
kotlin.test.assertEquals(" 1", deBased.getOrNull(1) ?: "")
|
||||
kotlin.test.assertEquals("}", deBased.last().trimEnd())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.format
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class LyngFormatterTest {
|
||||
|
||||
@Test
|
||||
fun reindent_simpleFunction() {
|
||||
val src = """
|
||||
fun test21() {
|
||||
21
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
fun test21() {
|
||||
21
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val cfg = LyngFormatConfig(indentSize = 4, useTabs = false)
|
||||
val out = LyngFormatter.reindent(src, cfg)
|
||||
assertEquals(expected, out)
|
||||
// Idempotent
|
||||
assertEquals(expected, LyngFormatter.reindent(out, cfg))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrapping_longCalls_noTrailingComma() {
|
||||
val longArg = "x1234567890x1234567890x1234567890x1234567890x1234567890"
|
||||
val src = """
|
||||
fun demo() {
|
||||
doSomething(${longArg}, ${longArg}, ${longArg})
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val out = LyngFormatter.format(src, LyngFormatConfig(applyWrapping = true, maxLineLength = 60, continuationIndentSize = 4))
|
||||
// Should contain vertically wrapped args and no trailing comma before ')'
|
||||
val hasTrailingComma = out.lines().any { it.trimEnd().endsWith(",") && it.trimEnd().endsWith("),") }
|
||||
kotlin.test.assertFalse(hasTrailingComma, "Trailing comma before ')' must not be added")
|
||||
// Idempotency
|
||||
val out2 = LyngFormatter.format(out, LyngFormatConfig(applyWrapping = true, maxLineLength = 60, continuationIndentSize = 4))
|
||||
assertEquals(out, out2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrapping_preservesCommentsAndStrings() {
|
||||
val arg1 = "a /*not a block comment*/"
|
||||
val arg2 = "\"(keep)\"" // string with parens
|
||||
val src = """
|
||||
fun demo() { doSomething($arg1, $arg2, c) // end comment }
|
||||
""".trimIndent()
|
||||
|
||||
val formatted = LyngFormatter.format(src, LyngFormatConfig(applyWrapping = true, maxLineLength = 40, continuationIndentSize = 4))
|
||||
// Ensure the string literal remains intact
|
||||
kotlin.test.assertTrue(formatted.contains(arg2), "String literal must be preserved")
|
||||
// Ensure end-of-line comment remains
|
||||
kotlin.test.assertTrue(formatted.contains("// end comment"), "EOL comment must be preserved")
|
||||
// Idempotency
|
||||
val formatted2 = LyngFormatter.format(formatted, LyngFormatConfig(applyWrapping = true, maxLineLength = 40, continuationIndentSize = 4))
|
||||
assertEquals(formatted, formatted2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun propertyBasedIdempotencyDeep() {
|
||||
// Deeper randomized mixes of blocks + lists + calls + operators
|
||||
val blocks = listOf(
|
||||
"if(flag){\nX\n}else{\nY\n}",
|
||||
"try{\nX\n}catch(e){\nY\n}",
|
||||
"fun g(){\nX\n}",
|
||||
"val grid = [[\n1,2\n],[3,4]]\nX",
|
||||
"doWork(A,B,C)\nX",
|
||||
)
|
||||
val leaves = listOf(
|
||||
"sum = a+b* c",
|
||||
"matrix[i][j] = i + j",
|
||||
"call( x, y , z )",
|
||||
"// comment line",
|
||||
"println(\"s\")",
|
||||
)
|
||||
val cfgIndent = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
|
||||
val cfgSpace = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4, applySpacing = true)
|
||||
|
||||
fun assembleDeep(seed: Int): String {
|
||||
val n = 10 + (seed % 6)
|
||||
val sb = StringBuilder()
|
||||
var cur = "X"
|
||||
repeat(n) { k ->
|
||||
val b = blocks[(seed + k * 11) % blocks.size]
|
||||
val leaf = leaves[(seed + k * 7) % leaves.size]
|
||||
cur = b.replace("X", cur.replace("X", leaf))
|
||||
}
|
||||
sb.append(cur)
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
repeat(12) { s ->
|
||||
val doc = assembleDeep(s)
|
||||
val r1 = LyngFormatter.reindent(doc, cfgIndent)
|
||||
val r2 = LyngFormatter.reindent(r1, cfgIndent)
|
||||
assertEquals(r1, r2, "Indent-only idempotency failed for seed=$s")
|
||||
|
||||
val f1 = LyngFormatter.format(doc, cfgSpace)
|
||||
val f2 = LyngFormatter.format(f1, cfgSpace)
|
||||
assertEquals(f1, f2, "Spacing-enabled idempotency failed for seed=$s")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun elseIf_chain() {
|
||||
val src = """
|
||||
if(true){
|
||||
if(false){
|
||||
a()
|
||||
}
|
||||
else if(maybe){
|
||||
b()
|
||||
}
|
||||
else{
|
||||
c()
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
if (true) {
|
||||
if (false) {
|
||||
a()
|
||||
}
|
||||
else if (maybe) {
|
||||
b()
|
||||
}
|
||||
else {
|
||||
c()
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val cfg = LyngFormatConfig(applySpacing = true)
|
||||
val out = LyngFormatter.format(src, cfg)
|
||||
assertEquals(expected, out)
|
||||
assertEquals(expected, LyngFormatter.format(out, cfg))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tryCatchFinally_alignment() {
|
||||
val src = """
|
||||
try{
|
||||
risky()
|
||||
}
|
||||
catch(e){
|
||||
handle(e)
|
||||
}
|
||||
finally{
|
||||
cleanup()
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
try {
|
||||
risky()
|
||||
}
|
||||
catch (e) {
|
||||
handle(e)
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val cfg = LyngFormatConfig(applySpacing = true)
|
||||
val out = LyngFormatter.format(src, cfg)
|
||||
assertEquals(expected, out)
|
||||
assertEquals(expected, LyngFormatter.format(out, cfg))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bracketContinuation_basic() {
|
||||
val src = """
|
||||
val arr = [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
val arr = [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]
|
||||
""".trimIndent()
|
||||
|
||||
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
|
||||
val out = LyngFormatter.reindent(src, cfg)
|
||||
assertEquals(expected, out)
|
||||
assertEquals(expected, LyngFormatter.reindent(out, cfg))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun idempotency_mixedSnippet() {
|
||||
val src = """
|
||||
// mix
|
||||
if(true){ doIt( a,b ,c) }
|
||||
val s = "keep( spaces )" // comment
|
||||
try{ ok() }catch(e){ bad() }
|
||||
""".trimIndent()
|
||||
|
||||
val cfg = LyngFormatConfig(applySpacing = true)
|
||||
val once = LyngFormatter.format(src, cfg)
|
||||
val twice = LyngFormatter.format(once, cfg)
|
||||
assertEquals(once, twice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nestedIfTryChains() {
|
||||
val src = """
|
||||
if(true){
|
||||
try{
|
||||
if(flag){
|
||||
op()
|
||||
} else{
|
||||
other()
|
||||
}
|
||||
}
|
||||
catch(e){
|
||||
handle(e)
|
||||
}
|
||||
finally{
|
||||
done()
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
if (true) {
|
||||
try {
|
||||
if (flag) {
|
||||
op()
|
||||
} else {
|
||||
other()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
handle(e)
|
||||
}
|
||||
finally {
|
||||
done()
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val cfg = LyngFormatConfig(applySpacing = true)
|
||||
val out = LyngFormatter.format(src, cfg)
|
||||
assertEquals(expected, out)
|
||||
assertEquals(expected, LyngFormatter.format(out, cfg))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun continuationWithCommentsAndBlanks() {
|
||||
val src = """
|
||||
fun call() {
|
||||
doIt(
|
||||
a, // first
|
||||
|
||||
b,
|
||||
// middle
|
||||
c
|
||||
)
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
fun call() {
|
||||
doIt(
|
||||
a, // first
|
||||
|
||||
b,
|
||||
// middle
|
||||
c
|
||||
)
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val cfg = LyngFormatConfig(continuationIndentSize = 4)
|
||||
val out = LyngFormatter.reindent(src, cfg)
|
||||
assertEquals(expected, out)
|
||||
assertEquals(expected, LyngFormatter.reindent(out, cfg))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun propertyBasedIdempotencySmall() {
|
||||
// Build small random snippets from blocks and verify idempotency under both modes
|
||||
val parts = listOf(
|
||||
"if(true){\nX\n}",
|
||||
"try{\nX\n}catch(e){\nY\n}",
|
||||
"fun f(){\nX\n}",
|
||||
"doIt(A, B, C)",
|
||||
)
|
||||
val leaves = listOf("op()", "other()", "println(1)")
|
||||
val cfgIndentOnly = LyngFormatConfig()
|
||||
val cfgSpacing = LyngFormatConfig(applySpacing = true)
|
||||
repeat(20) { n ->
|
||||
var snippet = parts[(n + 0) % parts.size]
|
||||
snippet = snippet.replace("X", leaves[n % leaves.size])
|
||||
snippet = snippet.replace("Y", leaves[(n + 1) % leaves.size])
|
||||
val once1 = LyngFormatter.reindent(snippet, cfgIndentOnly)
|
||||
val twice1 = LyngFormatter.reindent(once1, cfgIndentOnly)
|
||||
assertEquals(once1, twice1)
|
||||
val once2 = LyngFormatter.format(snippet, cfgSpacing)
|
||||
val twice2 = LyngFormatter.format(once2, cfgSpacing)
|
||||
assertEquals(once2, twice2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chainedCalls_and_MemberAccess() {
|
||||
val src = """
|
||||
fun demo() {
|
||||
val r = obj.chain().next( a,b ,c).last().value
|
||||
doIt(a,b).then{ op() }.final()
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
fun demo() {
|
||||
val r = obj.chain().next(a, b, c).last().value
|
||||
doIt(a, b).then{ op() }.final()
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
// Dots should remain tight; commas should be spaced
|
||||
val cfg = LyngFormatConfig(applySpacing = true)
|
||||
val out = LyngFormatter.format(src, cfg)
|
||||
assertEquals(expected, out)
|
||||
assertEquals(expected, LyngFormatter.format(out, cfg))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nestedArrays_and_Calls() {
|
||||
val src = """
|
||||
val m = [[
|
||||
1,2], [3,4
|
||||
], [5,
|
||||
6]
|
||||
]
|
||||
fun f(){
|
||||
compute(
|
||||
m[0][1], m[1][0]
|
||||
)
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
val m = [[
|
||||
1, 2], [3, 4
|
||||
], [5,
|
||||
6]
|
||||
]
|
||||
fun f(){
|
||||
compute(
|
||||
m[0][1], m[1][0]
|
||||
)
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
// Indent under brackets and inside calls; commas spaced
|
||||
val out = LyngFormatter.reindent(src, LyngFormatConfig(indentSize = 4, continuationIndentSize = 4))
|
||||
val spaced = LyngFormatter.format(out, LyngFormatConfig(applySpacing = true))
|
||||
assertEquals(expected, spaced)
|
||||
assertEquals(expected, LyngFormatter.format(spaced, LyngFormatConfig(applySpacing = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mixedOperators_spacing() {
|
||||
val src = """
|
||||
fun calc(){
|
||||
val x =1+2* 3
|
||||
val y =x== 7||x!=8&&x<= 9
|
||||
if(true){ if(false){ a=b+ c }else{ a =b*c}}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
fun calc(){
|
||||
val x = 1 + 2 * 3
|
||||
val y = x == 7 || x != 8 && x <= 9
|
||||
if (true) { if (false) { a = b + c } else { a = b * c}}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val cfg = LyngFormatConfig(applySpacing = true)
|
||||
val out = LyngFormatter.format(src, cfg)
|
||||
assertEquals(expected, out)
|
||||
assertEquals(expected, LyngFormatter.format(out, cfg))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun propertyBasedIdempotencyMedium() {
|
||||
// Compose slightly longer random snippets out of templates
|
||||
val blocks = listOf(
|
||||
"if(true){\nX\n}",
|
||||
"if(flag){\nX\n}else{\nY\n}",
|
||||
"try{\nX\n}catch(e){\nY\n}\nfinally{\nZ\n}",
|
||||
"fun f(){\nX\n}",
|
||||
)
|
||||
val leaves = listOf(
|
||||
"doIt(A,B ,C)",
|
||||
"obj.m().n( a , b).k()",
|
||||
"val arr = [\n1,\n2,\n3\n]",
|
||||
"println(\"keep ( raw )\") // cmt",
|
||||
)
|
||||
val cfg1 = LyngFormatConfig()
|
||||
val cfg2 = LyngFormatConfig(applySpacing = true)
|
||||
repeat(15) { n ->
|
||||
val t1 = blocks[n % blocks.size]
|
||||
val t2 = blocks[(n + 1) % blocks.size]
|
||||
val l1 = leaves[n % leaves.size]
|
||||
val l2 = leaves[(n + 1) % leaves.size]
|
||||
val l3 = leaves[(n + 2) % leaves.size]
|
||||
var doc = (t1 + "\n" + t2)
|
||||
doc = doc.replace("X", l1).replace("Y", l2).replace("Z", l3)
|
||||
val r1 = LyngFormatter.reindent(doc, cfg1)
|
||||
val r2 = LyngFormatter.reindent(r1, cfg1)
|
||||
assertEquals(r1, r2)
|
||||
val f1 = LyngFormatter.format(doc, cfg2)
|
||||
val f2 = LyngFormatter.format(f1, cfg2)
|
||||
assertEquals(f1, f2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun propertyBasedIdempotencyLargeRandom() {
|
||||
// Generate larger random snippets from building blocks and verify idempotency
|
||||
val blocks = listOf(
|
||||
"if(true){\nX\n}",
|
||||
"if(flag){\nX\n}else if(maybe){\nY\n}else{\nZ\n}",
|
||||
"try{\nX\n}catch(e){\nY\n}\nfinally{\nZ\n}",
|
||||
"fun f(){\nX\n}",
|
||||
"val arr = [\nX,\nY,\nZ\n]",
|
||||
"doIt(A,B ,C)\nobj.m().n( a , b).k()\n",
|
||||
)
|
||||
val leaves = listOf(
|
||||
"println(1)",
|
||||
"op()",
|
||||
"compute(a, b, c)",
|
||||
"other()",
|
||||
"arr[i][j]",
|
||||
)
|
||||
val cfgIndentOnly = LyngFormatConfig()
|
||||
val cfgSpacing = LyngFormatConfig(applySpacing = true)
|
||||
|
||||
fun assemble(n: Int): String {
|
||||
val sb = StringBuilder()
|
||||
for (i in 0 until n) {
|
||||
val b = blocks[(i * 7 + n) % blocks.size]
|
||||
val x = leaves[(i + 0) % leaves.size]
|
||||
val y = leaves[(i + 1) % leaves.size]
|
||||
val z = leaves[(i + 2) % leaves.size]
|
||||
sb.append(b.replace("X", x).replace("Y", y).replace("Z", z)).append('\n')
|
||||
}
|
||||
return sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
repeat(10) { n ->
|
||||
val doc = assemble(8 + (n % 5))
|
||||
val once1 = LyngFormatter.reindent(doc, cfgIndentOnly)
|
||||
val twice1 = LyngFormatter.reindent(once1, cfgIndentOnly)
|
||||
assertEquals(once1, twice1)
|
||||
val once2 = LyngFormatter.format(doc, cfgSpacing)
|
||||
val twice2 = LyngFormatter.format(once2, cfgSpacing)
|
||||
assertEquals(once2, twice2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun comments_and_strings_untouched() {
|
||||
val src = """
|
||||
// header
|
||||
fun demo() {
|
||||
val s = "( a ) , [ b ]" // keep as-is
|
||||
if(true){
|
||||
println(s)
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
// header
|
||||
fun demo() {
|
||||
val s = "( a ) , [ b ]" // keep as-is
|
||||
if(true){
|
||||
println(s)
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
// Use full format with spacing disabled to guarantee strings/comments remain untouched
|
||||
val out = LyngFormatter.format(src, LyngFormatConfig(applySpacing = false))
|
||||
assertEquals(expected, out)
|
||||
// Idempotent
|
||||
assertEquals(expected, LyngFormatter.format(out, LyngFormatConfig(applySpacing = false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reindent_nestedBlocks_andElse() {
|
||||
val src = """
|
||||
if(true){
|
||||
if (cond) {
|
||||
doIt()
|
||||
}
|
||||
else {
|
||||
other()
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
if (true) {
|
||||
if (cond) {
|
||||
doIt()
|
||||
}
|
||||
else {
|
||||
other()
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val out = LyngFormatter.format(src, LyngFormatConfig(applySpacing = true))
|
||||
assertEquals(expected, out)
|
||||
// Idempotent
|
||||
assertEquals(expected, LyngFormatter.format(out, LyngFormatConfig(applySpacing = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun continuationIndent_parentheses() {
|
||||
val src = """
|
||||
fun call() {
|
||||
doSomething(
|
||||
a,
|
||||
b,
|
||||
c
|
||||
)
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
fun call() {
|
||||
doSomething(
|
||||
a,
|
||||
b,
|
||||
c
|
||||
)
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
|
||||
val out = LyngFormatter.reindent(src, cfg)
|
||||
assertEquals(expected, out)
|
||||
// Idempotent
|
||||
assertEquals(expected, LyngFormatter.reindent(out, cfg))
|
||||
}
|
||||
@Test
|
||||
fun realexample1() {
|
||||
val src = """
|
||||
{
|
||||
if( f.isDirectory() )
|
||||
name += "/"
|
||||
if( condition )
|
||||
callIfTrue()
|
||||
else
|
||||
calliffalse()
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val expected = """
|
||||
{
|
||||
if( f.isDirectory() )
|
||||
name += "/"
|
||||
if( condition )
|
||||
callIfTrue()
|
||||
else
|
||||
calliffalse()
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
|
||||
val out = LyngFormatter.reindent(src, cfg)
|
||||
assertEquals(expected, out)
|
||||
// Idempotent
|
||||
assertEquals(expected, LyngFormatter.reindent(out, cfg))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user