Improve property accessor indentation handling, add tests, and enhance nested control flow formatting. Fix enum and property keyword highlighting.

This commit is contained in:
Sergey Chernov 2026-01-04 00:09:47 +01:00
parent eca451b5a3
commit abb262d9cf
6 changed files with 150 additions and 20 deletions

View File

@ -98,6 +98,8 @@ All notable changes to this project will be documented in this file.
- Mutually exclusive: `--check` and `--in-place` together now produce an error and exit with code 1.
- Multi-file stdout prints headers `--- <path> ---` per file.
- `lyng --help` shows `fmt`; `lyng fmt --help` displays dedicated help.
- Fix: Property accessors (`get`, `set`, `private set`, `protected set`) are now correctly indented relative to the property declaration.
- Fix: Indentation now correctly carries over into blocks that start on extra‑indented lines (e.g., nested `if` statements or property accessor bodies).
- CLI: Preserved legacy script invocation fast-paths:
- `lyng script.lyng [args...]` executes the script directly.

View File

@ -113,6 +113,15 @@ object Binder {
symbols += fieldSym
classes.last().fields += fieldSym.id
}
// Class fields (val/var in class body, if any are reported here)
for (cf in d.classFields) {
val fs = source.offsetOf(cf.nameStart)
val fe = fs + cf.name.length
val kind = if (cf.mutable) SymbolKind.Variable else SymbolKind.Value
val fieldSym = Symbol(nextId++, cf.name, kind, fs, fe, containerId = sym.id)
symbols += fieldSym
classes.last().fields += fieldSym.id
}
}
// Second pass: functions and top-level/class vals/vars

View File

@ -24,6 +24,24 @@ package net.sergeych.lyng.format
*/
object LyngFormatter {
private fun startsWithWord(s: String, w: String): Boolean =
s.startsWith(w) && s.getOrNull(w.length)?.let { !it.isLetterOrDigit() && it != '_' } != false
private fun isPropertyAccessor(s: String): Boolean {
val t = s.trim()
if (t.isEmpty()) return false
if (startsWithWord(t, "get") || startsWithWord(t, "set")) return true
if (startsWithWord(t, "private")) {
val rest = t.substring("private".length).trimStart()
if (startsWithWord(rest, "set")) return true
}
if (startsWithWord(t, "protected")) {
val rest = t.substring("protected".length).trimStart()
if (startsWithWord(rest, "set")) return true
}
return false
}
/** Returns the input with indentation recomputed from scratch, line by line. */
fun reindent(text: String, config: LyngFormatConfig = LyngFormatConfig()): String {
// Normalize tabs to spaces globally before any transformation; results must contain no tabs
@ -35,6 +53,7 @@ object LyngFormatter {
var bracketBalance = 0
var prevBracketContinuation = false
var inBlockComment = false
val extraIndents = mutableListOf<Int>()
// We don't keep per-"[" base alignment; continuation rules define alignment.
fun updateBalances(ch: Char) {
@ -52,7 +71,7 @@ object LyngFormatter {
// Always produce spaces; tabs are not allowed in resulting code
" ".repeat(level * config.indentSize + continuation)
var awaitingSingleIndent = false
var awaitingExtraIndent = 0
fun isControlHeaderNoBrace(s: String): Boolean {
val t = s.trim()
if (t.isEmpty()) return false
@ -60,7 +79,13 @@ object LyngFormatter {
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
if (isIf || isElseIf || isElse) return true
// property accessors ending with ) or =
if (isPropertyAccessor(t)) {
return t.endsWith(")") || t.endsWith("=")
}
return false
}
for ((i, rawLine) in lines.withIndex()) {
@ -70,7 +95,12 @@ object LyngFormatter {
val trimmedLine = rawLine.trim()
// Compute effective indent level for this line
var effectiveLevel = blockLevel
val currentExtraIndent = extraIndents.sum()
var effectiveLevel = blockLevel + currentExtraIndent
val isAccessor = !inBlockComment && isPropertyAccessor(trimmedStart)
if (isAccessor) effectiveLevel += 1
if (inBlockComment) {
if (!trimmedLine.startsWith("*/")) {
effectiveLevel += 1
@ -83,9 +113,9 @@ object LyngFormatter {
// 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() &&
val applyAwaiting = awaitingExtraIndent > 0 && trimmedStart.isNotEmpty() &&
!trimmedStart.startsWith("else") && !trimmedStart.startsWith("}")
if (applyAwaiting) effectiveLevel += 1
if (applyAwaiting) effectiveLevel += awaitingExtraIndent
val firstChar = trimmedStart.firstOrNull()
// While inside parentheses, continuation applies scaled by nesting level
@ -129,23 +159,43 @@ object LyngFormatter {
// New line (keep EOF semantics similar to input)
if (i < lines.lastIndex) sb.append('\n')
val oldBlockLevel = blockLevel
// Update balances using this line's code content
for (part in parts) {
if (part.type == PartType.Code) {
for (ch in part.text) updateBalances(ch)
}
}
val newBlockLevel = blockLevel
if (newBlockLevel > oldBlockLevel) {
val addedThisLine = (if (applyAwaiting) awaitingExtraIndent else 0) + (if (isAccessor) 1 else 0)
repeat(newBlockLevel - oldBlockLevel) {
extraIndents.add(addedThisLine)
}
} else if (newBlockLevel < oldBlockLevel) {
repeat(oldBlockLevel - newBlockLevel) {
if (extraIndents.isNotEmpty()) extraIndents.removeAt(extraIndents.size - 1)
}
}
inBlockComment = nextInBlockComment
// Update awaitingSingleIndent based on current line
// Update awaitingExtraIndent based on current line
if (applyAwaiting && trimmedStart.isNotEmpty()) {
// we have just consumed the body line
awaitingSingleIndent = false
// we have just applied it.
val endsWithBrace = code.trimEnd().endsWith("{")
if (!endsWithBrace && isControlHeaderNoBrace(code)) {
// It's another header, increment
awaitingExtraIndent += 1
} else {
// It's the body, reset
awaitingExtraIndent = 0
}
} else {
// start awaiting if current line is a control header without '{'
val endsWithBrace = code.trimEnd().endsWith("{")
if (!endsWithBrace && isControlHeaderNoBrace(code)) {
awaitingSingleIndent = true
awaitingExtraIndent = 1
}
}
@ -196,9 +246,6 @@ object LyngFormatter {
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

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 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.
@ -42,10 +42,10 @@ private val fallbackKeywordIds = setOf(
"and", "or", "not",
// declarations & modifiers
"fun", "fn", "class", "enum", "val", "var", "import", "package",
"private", "protected", "static", "open", "extern",
"private", "protected", "static", "open", "extern", "init", "get", "set", "by",
// control flow and misc
"if", "else", "when", "while", "do", "for", "try", "catch", "finally",
"throw", "return", "break", "continue", "this", "null", "true", "false"
"throw", "return", "break", "continue", "this", "null", "true", "false", "unset"
)
/** Maps lexer token type (and sometimes value) to a [HighlightKind]. */

View File

@ -730,4 +730,76 @@ class LyngFormatterTest {
assertEquals(expected, formatted)
}
@Test
fun privateSet_indentation() {
val src = """
class A {
var x = 1
private set
}
""".trimIndent()
// What we expect: private set should be indented relative to the property
val expected = """
class A {
var x = 1
private set
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
}
@Test
fun propertyAccessors_indentation() {
val src = """
class A {
var x
get() = 1
private set(v) {
_x = v
}
}
""".trimIndent()
val expected = """
class A {
var x
get() = 1
private set(v) {
_x = v
}
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
}
@Test
fun nestedAwaitingIndentation() {
val src = """
if (cond)
if (other)
fun f() {
stmt
}
""".trimIndent()
val expected = """
if (cond)
if (other)
fun f() {
stmt
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4, continuationIndentSize = 4)
val out = LyngFormatter.reindent(src, cfg)
assertEquals(expected, out)
}
}

View File

@ -28,10 +28,7 @@ import net.sergeych.lyng.binding.SymbolKind
import net.sergeych.lyng.highlight.HighlightKind
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.miniast.MiniAstBuilder
import net.sergeych.lyng.miniast.MiniClassDecl
import net.sergeych.lyng.miniast.MiniFunDecl
import net.sergeych.lyng.miniast.MiniTypeName
import net.sergeych.lyng.miniast.*
import org.w3c.dom.HTMLStyleElement
fun ensureBootstrapCodeBlocks(html: String): String {
@ -367,6 +364,7 @@ suspend fun applyLyngHighlightToTextAst(text: String): String {
is MiniFunDecl -> putName(d.nameStart, d.name, "hl-fn")
is MiniClassDecl -> putName(d.nameStart, d.name, "hl-class")
is net.sergeych.lyng.miniast.MiniValDecl -> putName(d.nameStart, d.name, if (d.mutable) "hl-var" else "hl-val")
is MiniEnumDecl -> putName(d.nameStart, d.name, "hl-class")
}
}
// Imports: color each segment as directive/path
@ -404,6 +402,7 @@ suspend fun applyLyngHighlightToTextAst(text: String): String {
}
is net.sergeych.lyng.miniast.MiniValDecl -> addTypeSegments(d.type)
is MiniClassDecl -> {}
is MiniEnumDecl -> {}
}
}
@ -534,7 +533,8 @@ private fun detectDeclarationAndParamOverrides(text: String): Map<Pair<Int, Int>
"package", "import", "fun", "fn", "class", "enum", "val", "var",
"if", "else", "while", "do", "for", "when", "try", "catch", "finally",
"throw", "return", "break", "continue", "in", "is", "as", "as?", "not",
"true", "false", "null", "private", "protected", "open", "extern", "static"
"true", "false", "null", "private", "protected", "open", "extern", "static",
"init", "get", "set", "Unset", "by"
)
fun skipWs(idx0: Int): Int {
var idx = idx0