diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6d6ee..21f4067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `--- ---` 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. diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt index 705e54f..0cbcd58 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt @@ -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 diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt index 805abe3..ad465c8 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt @@ -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() // 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 diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt index 1338ab7..70e0a42 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt @@ -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]. */ diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt index 7969a63..10e3fcf 100644 --- a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt @@ -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) + } } diff --git a/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Highlight.kt b/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Highlight.kt index b6d11a0..3b8905a 100644 --- a/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Highlight.kt +++ b/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Highlight.kt @@ -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 "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