Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Kotlin Multiplatform Compose JSON viewer and editor component for Android, iOS,

## Features

- **JSON Viewer** — Read-only, syntax-highlighted, foldable JSON tree with virtualized rendering (virtually no size limit for valid JSON; invalid JSON truncated at 100 KB)
- **JSON Viewer** — Read-only, syntax-highlighted, foldable JSON tree with virtualized rendering (virtually no size limit)
- **JSON Editor** — Editable JSON with real-time validation, formatting, and sorting (50 KB write limit)
- **Search** — Highlight matching text across the JSON document
- **Multiple Themes** — Dark, Light, Monokai, Dracula, Solarized Dark (+ custom themes)
Expand Down
2 changes: 1 addition & 1 deletion docs/api/jsoncmp.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Two separate composable entry points for viewing and editing JSON.

## JsonViewerCMP

Read-only JSON viewer with virtualized rendering. Virtually no size limit for valid JSON; invalid JSON is truncated at 100 KB with a warning.
Read-only JSON viewer with virtualized rendering. Virtually no size limit.

```kotlin
@ExperimentalJsonCmpApi
Expand Down
3 changes: 1 addition & 2 deletions docs/features/viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@

## Size Limits

- **Valid JSON** — Virtually no size limit. The viewer uses virtualized rendering to handle large documents efficiently.
- **Invalid JSON** — Truncated at 100 KB. A warning is shown indicating the original size and that the preview is truncated.
- Virtually no size limit. The viewer uses virtualized rendering to handle large documents efficiently.

## Syntax Highlighting

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

package dev.skymansandy.jsoncmp.domain.line

import androidx.compose.runtime.Immutable
import dev.skymansandy.jsoncmp.domain.model.FoldType
import dev.skymansandy.jsoncmp.domain.model.JsonPath

Expand All @@ -24,6 +25,7 @@ import dev.skymansandy.jsoncmp.domain.model.JsonPath
* straight to `allLines[childEndIndex]` instead of iterating through every hidden child.
* Set to -1 for non-foldable lines (values, closing brackets).
*/
@Immutable
internal data class JsonLine(
val lineNumber: Int,
val depth: Int,
Expand All @@ -35,4 +37,7 @@ internal data class JsonLine(
val path: JsonPath = emptyList(),
val isClosingBracket: Boolean = false,
val childEndIndex: Int = -1,
)
) {
/** Cached concatenation of all parts' text — avoids repeated joinToString in hot paths. */
val text: String = parts.joinToString("") { it.text }
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ internal class JsonLineBuilder {
private var lineNum = 0
private var nextFoldId = 0

/** Pre-computed indent parts to avoid repeated string allocation. */
private val indentCache = ArrayList<List<JsonPart>>()

/**
* Tracks the index in [out] where each foldable header line was emitted.
* After the full tree walk, a post-pass uses these to compute [JsonLine.childEndIndex]
Expand Down Expand Up @@ -77,9 +80,8 @@ internal class JsonLineBuilder {
parentFoldIds: List<Int>,
path: JsonPath,
) {
// Common parts shared by all node types
val indent: List<JsonPart> =
if (depth > 0) listOf(JsonPart.Indent(" ".repeat(depth))) else emptyList()
// Common parts shared by all node types — indent is cached to avoid repeated allocation
val indent: List<JsonPart> = indentForDepth(depth)
val keyParts: List<JsonPart> = if (key != null) {
listOf(JsonPart.Key("\"$key\""), JsonPart.Punct(": "))
} else emptyList()
Expand Down Expand Up @@ -190,6 +192,15 @@ internal class JsonLineBuilder {
)
}
}

private fun indentForDepth(depth: Int): List<JsonPart> {
if (depth == 0) return emptyList()
// Grow cache on demand
while (indentCache.size < depth) {
indentCache.add(listOf(JsonPart.Indent(" ".repeat(indentCache.size + 1))))
}
return indentCache[depth - 1]
}
}

/** Convenience entry point — creates a builder, walks the tree, and returns the flat line list. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,40 +34,23 @@ internal suspend fun parseAndBuildLines(
val trimmed = raw.trim()
if (trimmed.isEmpty()) return@withContext ParseResult.Empty

val (node, err) = parseJsonResult(trimmed)
if (node != null) {
ParseResult.Success(node, buildDisplayLines(node))
} else {
ParseResult.Failure(err)
}
}

/** Parses [input] into a [JsonNode] tree, returning (node, null) on success or (null, error) on failure. */
private suspend fun parseJsonResult(
input: String,
): Pair<JsonNode?, JsonError?> = withContext(Dispatchers.Default) {
try {
val trimmed = input.trim()
if (trimmed.isEmpty()) {
null to JsonError("Empty input")
} else {
val element = json.parseToJsonElement(trimmed)
element.toJsonNode() to null
}
val element = json.parseToJsonElement(trimmed)
val node = element.toJsonNode()
ParseResult.Success(node, buildDisplayLines(node))
} catch (e: Exception) {
null to JsonError(e.message ?: "Invalid JSON")
ParseResult.Failure(JsonError(e.message ?: "Invalid JSON"))
}
}

private suspend fun JsonElement.toJsonNode(): JsonNode = withContext(Dispatchers.Default) {
when (this@toJsonNode) {
is JsonObject -> JsonNode.JObject(entries.map { (key, value) -> key to value.toJsonNode() })
is JsonArray -> JsonNode.JArray(map { it.toJsonNode() })
JsonNull -> JsonNode.JNull
is JsonPrimitive -> when {
isString -> JsonNode.JString(content)
booleanOrNull != null -> JsonNode.JBoolean(boolean)
else -> JsonNode.JNumber(content)
}
/** Converts a kotlinx.serialization [JsonElement] tree to our [JsonNode] tree — plain function, no dispatch overhead. */
private fun JsonElement.toJsonNode(): JsonNode = when (this) {
is JsonObject -> JsonNode.JObject(entries.map { (key, value) -> key to value.toJsonNode() })
is JsonArray -> JsonNode.JArray(map { it.toJsonNode() })
JsonNull -> JsonNode.JNull
is JsonPrimitive -> when {
isString -> JsonNode.JString(content)
booleanOrNull != null -> JsonNode.JBoolean(boolean)
else -> JsonNode.JNumber(content)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import dev.skymansandy.jsoncmp.domain.model.JsonNode
*/
internal fun JsonNode.toJsonString(indent: Int = 2, compact: Boolean = false): String {
val sb = StringBuilder()
writeNode(sb = sb, node = this, currentIndent = 0, step = indent, compact = compact)
val indentCache = HashMap<Int, String>()
writeNode(sb = sb, node = this, currentIndent = 0, step = indent, compact = compact, indentCache = indentCache)
return sb.toString()
}

Expand Down Expand Up @@ -59,11 +60,12 @@ private fun writeNode(
currentIndent: Int,
step: Int,
compact: Boolean,
indentCache: MutableMap<Int, String>,
) {
val nl = if (compact) "" else "\n"
val childIndent = currentIndent + step
val indentStr = if (compact) "" else " ".repeat(childIndent)
val closingIndentStr = if (compact) "" else " ".repeat(currentIndent)
val indentStr = if (compact) "" else indentCache.getOrPut(childIndent) { " ".repeat(childIndent) }
val closingIndentStr = if (compact) "" else indentCache.getOrPut(currentIndent) { " ".repeat(currentIndent) }
val colonSep = if (compact) ":" else ": "

when (node) {
Expand All @@ -74,9 +76,11 @@ private fun writeNode(
sb.append("{").append(nl)
node.fields.forEachIndexed { i, (key, value) ->
sb.append(indentStr)
sb.append('"').append(escapeJsonString(key)).append('"')
sb.append('"')
escapeJsonStringTo(sb, key)
sb.append('"')
sb.append(colonSep)
writeNode(sb, value, childIndent, step, compact)
writeNode(sb, value, childIndent, step, compact, indentCache)
if (i < node.fields.lastIndex) sb.append(",")
sb.append(nl)
}
Expand All @@ -91,7 +95,7 @@ private fun writeNode(
sb.append("[").append(nl)
node.elements.forEachIndexed { i, element ->
sb.append(indentStr)
writeNode(sb, element, childIndent, step, compact)
writeNode(sb, element, childIndent, step, compact, indentCache)
if (i < node.elements.lastIndex) sb.append(",")
sb.append(nl)
}
Expand All @@ -100,7 +104,9 @@ private fun writeNode(
}

is JsonNode.JString -> {
sb.append('"').append(escapeJsonString(node.value)).append('"')
sb.append('"')
escapeJsonStringTo(sb, node.value)
sb.append('"')
}

is JsonNode.JNumber -> sb.append(node.value)
Expand All @@ -109,22 +115,22 @@ private fun writeNode(
}
}

/** Escapes special characters (quotes, backslashes, control chars) for safe JSON string output. */
private fun escapeJsonString(s: String): String = buildString(s.length) {
/** Escapes special characters directly into [sb], avoiding intermediate string allocation. */
private fun escapeJsonStringTo(sb: StringBuilder, s: String) {
for (c in s) {
when (c) {
'"' -> append("\\\"")
'\\' -> append("\\\\")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
'\b' -> append("\\b")
'\u000C' -> append("\\f")
'"' -> sb.append("\\\"")
'\\' -> sb.append("\\\\")
'\n' -> sb.append("\\n")
'\r' -> sb.append("\\r")
'\t' -> sb.append("\\t")
'\b' -> sb.append("\\b")
'\u000C' -> sb.append("\\f")
else -> {
if (c.code < 0x20) {
append("\\u${c.code.toString(16).padStart(4, '0')}")
sb.append("\\u${c.code.toString(16).padStart(4, '0')}")
} else {
append(c)
sb.append(c)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,12 @@ internal class JsonHolderImpl(
}

is JsonAction.CollapseAll -> {
_state.update { current ->
val allFoldIds = current.allLines.mapNotNull { it.foldId }
current.copy(foldState = allFoldIds.associateWith { true })
scope.launch {
val current = _state.value
val collapsedFolds = current.allLines
.mapNotNull { it.foldId }
.associateWith { true }
_state.update { it.copy(foldState = collapsedFolds) }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,36 +47,39 @@ internal class JsonHolderState(

fun computeFoldedContent(line: JsonLine): String {
if (line.foldId == null || line.childEndIndex < 0) return ""
val startIdx = allLines.indexOf(line)
if (startIdx < 0) return ""
val startIdx = line.lineNumber - 1
if (startIdx < 0 || startIdx >= allLines.size) return ""
val endIdx = line.childEndIndex.coerceAtMost(allLines.size)
return allLines.subList(startIdx + 1, endIdx)
.joinToString(" ") { l -> l.parts.joinToString("") { it.text }.trim() }
return buildString {
for (i in (startIdx + 1) until endIdx) {
if (isNotEmpty()) append(' ')
append(allLines[i].text.trim())
}
}
}

fun hasFoldedMatch(line: JsonLine, searchQuery: String): Boolean {
if (line.foldId == null || line.childEndIndex < 0 || searchQuery.isBlank()) return false
val startIdx = allLines.indexOf(line)
if (startIdx < 0) return false
val startIdx = line.lineNumber - 1
if (startIdx < 0 || startIdx >= allLines.size) return false
val endIdx = line.childEndIndex.coerceAtMost(allLines.size)
val queryLower = searchQuery.lowercase()
for (i in (startIdx + 1) until endIdx) {
val lineText = allLines[i].parts.joinToString("") { it.text }
if (lineText.lowercase().contains(queryLower)) return true
if (allLines[i].text.lowercase().contains(queryLower)) return true
}
return false
}

fun countFoldedMatches(line: JsonLine, searchQuery: String): Int {
if (line.foldId == null || line.childEndIndex < 0 || searchQuery.isBlank()) return 0
if (foldState[line.foldId] != true) return 0
val startIdx = allLines.indexOf(line)
if (startIdx < 0) return 0
val startIdx = line.lineNumber - 1
if (startIdx < 0 || startIdx >= allLines.size) return 0
val endIdx = line.childEndIndex.coerceAtMost(allLines.size)
val queryLower = searchQuery.lowercase()
var count = 0
for (i in (startIdx + 1) until endIdx) {
val lineText = allLines[i].parts.joinToString("") { it.text }.lowercase()
val lineText = allLines[i].text.lowercase()
var idx = lineText.indexOf(queryLower)
while (idx >= 0) {
count++
Expand All @@ -89,24 +92,25 @@ internal class JsonHolderState(
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is JsonHolderState) return false
return raw == other.raw &&
parsedJson == other.parsedJson &&
error == other.error &&
isParsing == other.isParsing &&
return isParsing == other.isParsing &&
isCompact == other.isCompact &&
isEditing == other.isEditing &&
error == other.error &&
foldState == other.foldState &&
raw == other.raw &&
allLines == other.allLines &&
foldState == other.foldState
parsedJson == other.parsedJson
}

override fun hashCode(): Int {
var result = raw.hashCode()
// Use raw.length instead of raw.hashCode() to avoid O(n) hash on large strings
var result = raw.length
result = 31 * result + (parsedJson?.hashCode() ?: 0)
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + isParsing.hashCode()
result = 31 * result + isCompact.hashCode()
result = 31 * result + isEditing.hashCode()
result = 31 * result + allLines.hashCode()
result = 31 * result + allLines.size
result = 31 * result + foldState.hashCode()
return result
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,9 @@ internal fun JsonLineView(
}
} else {
// Expanded: render all parts with syntax colors and optional search highlights
val lineText = remember(line) {
buildString { line.parts.forEach { append(it.text) } }
}

val styledText = remember(line, searchQuery, colors) {
buildAnnotatedString {
append(lineText)
append(line.text)
var cursor = 0
line.parts.forEach { part ->
addStyle(
Expand All @@ -175,7 +171,7 @@ internal fun JsonLineView(
cursor += part.text.length
}
if (searchQuery.isNotBlank()) {
val lower = lineText.lowercase()
val lower = line.text.lowercase()
val queryLower = searchQuery.lowercase()
var idx = lower.indexOf(queryLower)
while (idx >= 0) {
Expand Down
Loading
Loading