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. - Mutually exclusive: `--check` and `--in-place` together now produce an error and exit with code 1.
- Multi-file stdout prints headers `--- <path> ---` per file. - Multi-file stdout prints headers `--- <path> ---` per file.
- `lyng --help` shows `fmt`; `lyng fmt --help` displays dedicated help. - `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: - CLI: Preserved legacy script invocation fast-paths:
- `lyng script.lyng [args...]` executes the script directly. - `lyng script.lyng [args...]` executes the script directly.

View File

@ -113,6 +113,15 @@ object Binder {
symbols += fieldSym symbols += fieldSym
classes.last().fields += fieldSym.id 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 // Second pass: functions and top-level/class vals/vars

View File

@ -24,6 +24,24 @@ package net.sergeych.lyng.format
*/ */
object LyngFormatter { 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. */ /** 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 // Normalize tabs to spaces globally before any transformation; results must contain no tabs
@ -35,6 +53,7 @@ object LyngFormatter {
var bracketBalance = 0 var bracketBalance = 0
var prevBracketContinuation = false var prevBracketContinuation = false
var inBlockComment = false var inBlockComment = false
val extraIndents = mutableListOf<Int>()
// We don't keep per-"[" base alignment; continuation rules define alignment. // We don't keep per-"[" base alignment; continuation rules define alignment.
fun updateBalances(ch: Char) { fun updateBalances(ch: Char) {
@ -52,7 +71,7 @@ object LyngFormatter {
// Always produce spaces; tabs are not allowed in resulting code // Always produce spaces; tabs are not allowed in resulting code
" ".repeat(level * config.indentSize + continuation) " ".repeat(level * config.indentSize + continuation)
var awaitingSingleIndent = false var awaitingExtraIndent = 0
fun isControlHeaderNoBrace(s: String): Boolean { fun isControlHeaderNoBrace(s: String): Boolean {
val t = s.trim() val t = s.trim()
if (t.isEmpty()) return false if (t.isEmpty()) return false
@ -60,7 +79,13 @@ object LyngFormatter {
val isIf = Regex("^if\\s*\\(.*\\)\\s*$").matches(t) val isIf = Regex("^if\\s*\\(.*\\)\\s*$").matches(t)
val isElseIf = Regex("^else\\s+if\\s*\\(.*\\)\\s*$").matches(t) val isElseIf = Regex("^else\\s+if\\s*\\(.*\\)\\s*$").matches(t)
val isElse = t == "else" 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()) { for ((i, rawLine) in lines.withIndex()) {
@ -70,7 +95,12 @@ object LyngFormatter {
val trimmedLine = rawLine.trim() val trimmedLine = rawLine.trim()
// Compute effective indent level for this line // 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 (inBlockComment) {
if (!trimmedLine.startsWith("*/")) { if (!trimmedLine.startsWith("*/")) {
effectiveLevel += 1 effectiveLevel += 1
@ -83,9 +113,9 @@ object LyngFormatter {
// Single-line control header (if/else/else if) without braces: indent the next // Single-line control header (if/else/else if) without braces: indent the next
// non-empty, non-'}', non-'else' line by one extra level // 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("}") !trimmedStart.startsWith("else") && !trimmedStart.startsWith("}")
if (applyAwaiting) effectiveLevel += 1 if (applyAwaiting) effectiveLevel += awaitingExtraIndent
val firstChar = trimmedStart.firstOrNull() val firstChar = trimmedStart.firstOrNull()
// While inside parentheses, continuation applies scaled by nesting level // While inside parentheses, continuation applies scaled by nesting level
@ -129,23 +159,43 @@ object LyngFormatter {
// New line (keep EOF semantics similar to input) // New line (keep EOF semantics similar to input)
if (i < lines.lastIndex) sb.append('\n') if (i < lines.lastIndex) sb.append('\n')
val oldBlockLevel = blockLevel
// Update balances using this line's code content // Update balances using this line's code content
for (part in parts) { for (part in parts) {
if (part.type == PartType.Code) { if (part.type == PartType.Code) {
for (ch in part.text) updateBalances(ch) 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 inBlockComment = nextInBlockComment
// Update awaitingSingleIndent based on current line // Update awaitingExtraIndent based on current line
if (applyAwaiting && trimmedStart.isNotEmpty()) { if (applyAwaiting && trimmedStart.isNotEmpty()) {
// we have just consumed the body line // we have just applied it.
awaitingSingleIndent = false 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 { } else {
// start awaiting if current line is a control header without '{' // start awaiting if current line is a control header without '{'
val endsWithBrace = code.trimEnd().endsWith("{") val endsWithBrace = code.trimEnd().endsWith("{")
if (!endsWithBrace && isControlHeaderNoBrace(code)) { if (!endsWithBrace && isControlHeaderNoBrace(code)) {
awaitingSingleIndent = true awaitingExtraIndent = 1
} }
} }
@ -196,9 +246,6 @@ object LyngFormatter {
return applyControlledWrapping(spacedText, config) 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. * 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 * 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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", "and", "or", "not",
// declarations & modifiers // declarations & modifiers
"fun", "fn", "class", "enum", "val", "var", "import", "package", "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 // control flow and misc
"if", "else", "when", "while", "do", "for", "try", "catch", "finally", "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]. */ /** Maps lexer token type (and sometimes value) to a [HighlightKind]. */

View File

@ -730,4 +730,76 @@ class LyngFormatterTest {
assertEquals(expected, formatted) 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.HighlightKind
import net.sergeych.lyng.highlight.SimpleLyngHighlighter import net.sergeych.lyng.highlight.SimpleLyngHighlighter
import net.sergeych.lyng.highlight.offsetOf import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.miniast.MiniAstBuilder import net.sergeych.lyng.miniast.*
import net.sergeych.lyng.miniast.MiniClassDecl
import net.sergeych.lyng.miniast.MiniFunDecl
import net.sergeych.lyng.miniast.MiniTypeName
import org.w3c.dom.HTMLStyleElement import org.w3c.dom.HTMLStyleElement
fun ensureBootstrapCodeBlocks(html: String): String { 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 MiniFunDecl -> putName(d.nameStart, d.name, "hl-fn")
is MiniClassDecl -> putName(d.nameStart, d.name, "hl-class") 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 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 // 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 net.sergeych.lyng.miniast.MiniValDecl -> addTypeSegments(d.type)
is MiniClassDecl -> {} 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", "package", "import", "fun", "fn", "class", "enum", "val", "var",
"if", "else", "while", "do", "for", "when", "try", "catch", "finally", "if", "else", "while", "do", "for", "when", "try", "catch", "finally",
"throw", "return", "break", "continue", "in", "is", "as", "as?", "not", "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 { fun skipWs(idx0: Int): Int {
var idx = idx0 var idx = idx0