package cc.unitmesh.xuiper.parser import cc.unitmesh.xuiper.action.BodyField import cc.unitmesh.xuiper.action.HttpMethod import cc.unitmesh.xuiper.action.MutationOp import cc.unitmesh.xuiper.action.NanoAction import cc.unitmesh.xuiper.ast.Binding import cc.unitmesh.xuiper.ast.NanoNode import cc.unitmesh.xuiper.spec.NanoSpec import cc.unitmesh.xuiper.spec.v1.NanoSpecV1 /** * IndentParser - Python-style indentation-based parser * * This is the default parser implementation for NanoDSL. * Uses indentation to determine hierarchy (like Python). * * Example input: * ``` * component GreetingCard: * Card: * padding: "md" * content: * VStack(spacing="sm"): * Text("Hello!", style="h2") * ``` * * To modify parsing behavior for new LLM capabilities: * 1. Override methods in this class * 2. Or create a new parser implementing NanoParser interface */ class IndentParser( override val spec: NanoSpec = NanoSpecV1 ) : NanoParser { companion object { private val COMPONENT_REGEX = Regex("""^component\s+(\w+)(?:\((.*?)\))?:\s*$""") private val STATE_BLOCK_REGEX = Regex("""^\s*state:\s*$""") private val STATE_VAR_REGEX = Regex("""^\s*(\w+):\s*(\w+)\s*=\s*(.+)$""") private val COMPONENT_CALL_REGEX = Regex("""^(\w+)(?:\((.*?)\))?:\s*$""") private val COMPONENT_INLINE_REGEX = Regex("""^(\w+)(?:\((.*?)\))$""") private val COMPONENT_MULTILINE_ARGS_START_REGEX = Regex("""^(\w+)\(\s*$""") private val COMPONENT_PARTIAL_INLINE_START_REGEX = Regex("""^(\w+)\(.*$""") private val PROP_REGEX = Regex("""^(\w+):\s*(.*)$""") private val IF_REGEX = Regex("""^if\s+(.+?):\s*$""") private val FOR_REGEX = Regex("""^for\s+(\w+)\s+in\s+(.+?):\s*$""") private val FOR_MULTILINE_LIST_START_REGEX = Regex("""^for\s+(\w+)\s+in\s+\[\s*$""") private val ON_CLICK_REGEX = Regex("""^on_click:\s*(.+)$""") private val ON_CLICK_BLOCK_REGEX = Regex("""^on_click:\s*$""") private val ON_EVENT_REGEX = Regex("""^on_([a-zA-Z_][\w_]*):\s*(.+)$""") private val ON_EVENT_BLOCK_REGEX = Regex("""^on_([a-zA-Z_][\w_]*):\s*$""") } /** * Parse NanoDSL source code into AST (NanoParser interface) */ override fun parse(source: String): ParseResult { return try { val lines = source.lines() // Check if source starts with 'component' keyword val firstNonBlank = lines.indexOfFirst { it.isNotBlank() } if (firstNonBlank >= 0) { val firstLine = lines[firstNonBlank].trim() // If it doesn't start with 'component', wrap it in an anonymous component if (!COMPONENT_REGEX.matches(firstLine)) { // Prepend "component AnonymousComponent:" to the source val wrappedSource = "component AnonymousComponent:\n" + source.lines().joinToString("\n") { if (it.isNotBlank()) " $it" else it } val wrappedLines = wrappedSource.lines() val (component, _) = parseComponent(wrappedLines, 0) ParseResult.Success(component) } else { val (component, _) = parseComponent(lines, 0) ParseResult.Success(component) } } else { ParseResult.Failure(listOf(ParseError("Empty source", 0))) } } catch (e: Exception) { ParseResult.Failure(listOf(ParseError(e.message ?: "Unknown error", 0))) } } /** * Validate source without full parsing */ override fun validate(source: String): ValidationResult { val errors = mutableListOf() val warnings = mutableListOf() val lines = source.lines() // Check basic structure if (lines.isEmpty() || lines.all { it.isBlank() }) { errors.add(ParseError("Empty source", 0)) return ValidationResult(false, errors, warnings) } // Check component definition val firstNonBlank = lines.indexOfFirst { it.isNotBlank() } if (firstNonBlank >= 0) { val firstLine = lines[firstNonBlank].trim() if (!COMPONENT_REGEX.matches(firstLine)) { // Check if it's a bare component without 'component' keyword if (COMPONENT_CALL_REGEX.matches(firstLine)) { // This is valid - bare components are now supported // No warning needed } else { errors.add(ParseError("Invalid component definition", firstNonBlank + 1)) } } } // Check indentation consistency var prevIndent = 0 lines.forEachIndexed { index, line -> if (line.isNotBlank()) { val indent = line.takeWhile { it == ' ' }.length if (indent % 4 != 0) { warnings.add(ParseWarning( "Inconsistent indentation (expected multiple of 4)", index + 1, "Use 4 spaces per indentation level" )) } prevIndent = indent } } return ValidationResult(errors.isEmpty(), errors, warnings) } /** * Legacy parse method for backward compatibility */ fun parseToAst(source: String): NanoNode.Component { val lines = source.lines() val (component, _) = parseComponent(lines, 0) return component } private fun parseComponent(lines: List, startIndex: Int): Pair { val firstLine = lines[startIndex].trim() val match = COMPONENT_REGEX.matchEntire(firstLine) ?: throw ParseException("Invalid component definition at line ${startIndex + 1}: $firstLine") val name = match.groupValues[1] val paramsStr = match.groupValues[2] val params = parseComponentParams(paramsStr) val baseIndent = getIndent(lines[startIndex]) var index = startIndex + 1 var state: NanoNode.StateBlock? = null val children = mutableListOf() while (index < lines.size) { val line = lines[index] if (line.isBlank()) { index++ continue } val currentIndent = getIndent(line) if (currentIndent <= baseIndent) break val trimmed = line.trim() // Check for state block if (STATE_BLOCK_REGEX.matches(trimmed)) { val (stateBlock, newIndex) = parseStateBlock(lines, index) state = stateBlock index = newIndex continue } // Parse child nodes val (node, newIndex) = parseNode(lines, index, currentIndent) if (node != null) { children.add(node) } index = newIndex } return NanoNode.Component(name, params, state, children) to index } private fun parseComponentParams(paramsStr: String): List { if (paramsStr.isBlank()) return emptyList() return paramsStr.split(",").map { param -> val parts = param.trim().split(":") if (parts.size == 2) { NanoNode.ComponentParam(parts[0].trim(), parts[1].trim()) } else { NanoNode.ComponentParam(parts[0].trim()) } } } private fun parseStateBlock(lines: List, startIndex: Int): Pair { val baseIndent = getIndent(lines[startIndex]) var index = startIndex + 1 val variables = mutableListOf() while (index < lines.size) { val line = lines[index] if (line.isBlank()) { index++ continue } val currentIndent = getIndent(line) if (currentIndent <= baseIndent) break val match = STATE_VAR_REGEX.matchEntire(line) if (match != null) { val name = match.groupValues[1] val type = match.groupValues[2] val rawValue = match.groupValues[3].trim() val (defaultValue, nextIndex) = collectStateDefaultValue(lines, index, baseIndent, rawValue) variables.add( NanoNode.StateVariable( name = name, type = type, defaultValue = defaultValue ) ) index = nextIndex continue } index++ } return NanoNode.StateBlock(variables) to index } private fun collectStateDefaultValue( lines: List, startIndex: Int, stateIndent: Int, rawValue: String ): Pair { if (rawValue.isBlank()) return null to (startIndex + 1) // Strip quotes from simple string values (e.g., "" -> empty string, "hello" -> hello) // For JSON-like values (arrays/objects), keep raw text as-is so it can be parsed later. if (rawValue.startsWith("\"") && rawValue.endsWith("\"") && rawValue.length >= 2) { return rawValue.substring(1, rawValue.length - 1) to (startIndex + 1) } val trimmed = rawValue.trimStart() val open = when (trimmed.firstOrNull()) { '[' -> '[' '{' -> '{' else -> return rawValue to (startIndex + 1) } val close = if (open == '[') ']' else '}' val builder = StringBuilder() builder.append(rawValue.trim()) var depth = 0 var inString = false var escaped = false fun scanChunk(chunk: String) { for (c in chunk) { if (inString) { if (escaped) { escaped = false } else if (c == '\\') { escaped = true } else if (c == '"') { inString = false } continue } when (c) { '"' -> inString = true open -> depth++ close -> if (depth > 0) depth-- } } } scanChunk(builder.toString()) var index = startIndex while (depth > 0) { index++ if (index >= lines.size) break val line = lines[index] if (line.isBlank()) continue val indent = getIndent(line) // Stop collecting if we exit the state block. if (indent <= stateIndent) { return builder.toString() to index } val chunk = line.trim() builder.append('\n').append(chunk) scanChunk(chunk) } return builder.toString() to (index + 1) } private fun parseNode(lines: List, startIndex: Int, expectedIndent: Int): Pair { if (startIndex >= lines.size) return null to startIndex val line = lines[startIndex] if (line.isBlank()) return null to startIndex + 1 val trimmed = line.trim() // Handle if statement IF_REGEX.matchEntire(trimmed)?.let { match -> return parseConditional(lines, startIndex, match.groupValues[1]) } // Handle for loop FOR_REGEX.matchEntire(trimmed)?.let { match -> return parseForLoop(lines, startIndex, match.groupValues[1], match.groupValues[2]) } // Handle multi-line list literal for loop, e.g.: // for item in [ // {"name": "A"}, // ]: FOR_MULTILINE_LIST_START_REGEX.matchEntire(trimmed)?.let { match -> return parseForLoopWithMultilineIterable(lines, startIndex, match.groupValues[1]) } // Handle Divider if (trimmed == "Divider") { return NanoNode.Divider to startIndex + 1 } // Handle multi-line inline component call with args already present on the first line, e.g.: // RadioGroup(value := state.transport, options=[ // {"value": "train", "label": "Train"}, // ], name="transport") // This is common when args contain multi-line bracket literals. if (COMPONENT_PARTIAL_INLINE_START_REGEX.matches(trimmed) && trimmed.contains('(') && !trimmed.contains(')') && !COMPONENT_CALL_REGEX.matches(trimmed) && !COMPONENT_INLINE_REGEX.matches(trimmed) && !trimmed.endsWith(":") ) { val componentName = trimmed.substringBefore('(').trim() return parseMultilineInlineComponentCallFromPartialStart(lines, startIndex, componentName) } // Handle multi-line inline component call, e.g.: // DataTable( // columns="...", // data=state.users // ) COMPONENT_MULTILINE_ARGS_START_REGEX.matchEntire(trimmed)?.let { match -> return parseMultilineInlineComponentCall(lines, startIndex, match.groupValues[1]) } // Handle component with block COMPONENT_CALL_REGEX.matchEntire(trimmed)?.let { match -> return parseComponentCall(lines, startIndex, match.groupValues[1], match.groupValues[2]) } // Handle inline component COMPONENT_INLINE_REGEX.matchEntire(trimmed)?.let { match -> val node = createNode(match.groupValues[1], match.groupValues[2], emptyList()) return node to startIndex + 1 } return null to startIndex + 1 } private fun parseChildrenAfterHeader( lines: List, headerEndIndex: Int, baseIndent: Int ): Pair, Int> { var index = headerEndIndex + 1 val children = mutableListOf() while (index < lines.size) { val line = lines[index] if (line.isBlank()) { index++ continue } val currentIndent = getIndent(line) if (currentIndent <= baseIndent) break val (node, newIndex) = parseNode(lines, index, currentIndent) if (node != null) { children.add(node) } index = newIndex } return children to index } private fun parseForLoopWithMultilineIterable( lines: List, startIndex: Int, variable: String ): Pair { val baseIndent = getIndent(lines[startIndex]) // Collect iterable expression across lines until we hit a top-level closing ']' followed by ':' // We keep only trimmed lines to produce valid JSON for the runtime resolver. val parts = mutableListOf() var index = startIndex var inString = false var quoteChar = '\u0000' var escaped = false var bracketDepth = 0 var seenBracket = false var headerEndIndex = startIndex while (index < lines.size) { val line = lines[index] if (line.isBlank()) { index++ continue } val trimmedLine = line.trim() val lineExpr = if (index == startIndex) { // first line is: for in [ "[" } else { trimmedLine } parts += lineExpr for (c in lineExpr) { if (inString) { if (escaped) { escaped = false continue } when (c) { '\\' -> escaped = true '\'', '"' -> if (c == quoteChar) { inString = false quoteChar = '\u0000' } } continue } when (c) { '\'', '"' -> { inString = true quoteChar = c } '[' -> { bracketDepth++ seenBracket = true } ']' -> { if (bracketDepth > 0) bracketDepth-- } } } // Header ends when we close the top-level list and the line ends with ':' (e.g. "]:" if (seenBracket && bracketDepth == 0 && trimmedLine.endsWith(':')) { headerEndIndex = index break } index++ } var iterable = parts.joinToString("\n").trim() if (iterable.endsWith(':')) iterable = iterable.dropLast(1).trim() val (body, endIndex) = parseChildrenAfterHeader(lines, headerEndIndex, baseIndent) return NanoNode.ForLoop(variable, iterable, body) to endIndex } private fun parseMultilineInlineComponentCallFromPartialStart( lines: List, startIndex: Int, componentName: String ): Pair { val startLine = lines[startIndex] val baseIndent = getIndent(startLine) var parenDepth = 0 var inString = false var quoteChar = '\u0000' var escaped = false var collecting = false val argsBuilder = StringBuilder() var index = startIndex while (index < lines.size) { val line = lines[index] if (index != startIndex && line.isNotBlank()) { val currentIndent = getIndent(line) if (parenDepth > 0 && currentIndent < baseIndent) { break } } val text = if (index == startIndex) line.trim() else line for (c in text) { if (inString) { if (escaped) { escaped = false if (collecting) argsBuilder.append(c) continue } when (c) { '\\' -> { escaped = true if (collecting) argsBuilder.append(c) } '\'', '"' -> { if (c == quoteChar) { inString = false quoteChar = '\u0000' } if (collecting) argsBuilder.append(c) } else -> if (collecting) argsBuilder.append(c) } continue } when (c) { '\'', '"' -> { inString = true quoteChar = c if (collecting) argsBuilder.append(c) } '(' -> { parenDepth++ if (parenDepth == 1) { collecting = true } else if (collecting) { argsBuilder.append(c) } } ')' -> { if (parenDepth > 0) parenDepth-- if (parenDepth == 0 && collecting) { collecting = false val argsStr = argsBuilder.toString().trim() val node = createNode(componentName, argsStr, emptyList()) return node to (index + 1) } if (collecting) argsBuilder.append(c) } else -> if (collecting) argsBuilder.append(c) } } if (collecting) argsBuilder.append("\n") index++ } return null to (startIndex + 1) } private fun parseMultilineInlineComponentCall( lines: List, startIndex: Int, componentName: String ): Pair { val startLine = lines[startIndex] val baseIndent = getIndent(startLine) var parenDepth = 0 var inString = false var escaped = false var collecting = false val argsBuilder = StringBuilder() var index = startIndex while (index < lines.size) { val line = lines[index] // For multi-line calls we expect the closing ')' to align with the opening indent. // Still, we scan characters to be robust if there are nested parentheses. if (index != startIndex && line.isNotBlank()) { val currentIndent = getIndent(line) if (parenDepth > 0 && currentIndent < baseIndent) { break } } val text = if (index == startIndex) line.trim() else line for (c in text) { if (inString) { if (escaped) { escaped = false if (collecting) argsBuilder.append(c) continue } when (c) { '\\' -> { escaped = true if (collecting) argsBuilder.append(c) } '"' -> { inString = false if (collecting) argsBuilder.append(c) } else -> if (collecting) argsBuilder.append(c) } continue } when (c) { '"' -> { inString = true if (collecting) argsBuilder.append(c) } '(' -> { parenDepth++ if (parenDepth == 1) { collecting = true } else if (collecting) { argsBuilder.append(c) } } ')' -> { if (parenDepth > 0) parenDepth-- if (parenDepth == 0 && collecting) { collecting = false val argsStr = argsBuilder.toString().trim() val node = createNode(componentName, argsStr, emptyList()) return node to (index + 1) } if (collecting) argsBuilder.append(c) } else -> if (collecting) argsBuilder.append(c) } } if (collecting) argsBuilder.append("\n") index++ } // If we didn't find a closing ')', treat it as an unparseable node. return null to (startIndex + 1) } private fun parseComponentCall( lines: List, startIndex: Int, componentName: String, argsStr: String ): Pair { val baseIndent = getIndent(lines[startIndex]) var index = startIndex + 1 val children = mutableListOf() val props = mutableMapOf() val actions = mutableMapOf() while (index < lines.size) { val line = lines[index] if (line.isBlank()) { index++ continue } val currentIndent = getIndent(line) if (currentIndent <= baseIndent) break val trimmed = line.trim() // Check for on_* block (multi-line): on_click/on_change/on_row_click/on_close/on_submit ON_EVENT_BLOCK_REGEX.matchEntire(trimmed)?.let { match -> val event = match.groupValues[1] val (action, newIndex) = parseActionBlock(lines, index) actions[event] = action index = newIndex continue } // Check for on_* action (single-line) ON_EVENT_REGEX.matchEntire(trimmed)?.let { match -> val event = match.groupValues[1] actions[event] = parseAction(match.groupValues[2]) index++ continue } // Check for on_click block (multi-line) if (ON_CLICK_BLOCK_REGEX.matches(trimmed)) { val (action, newIndex) = parseActionBlock(lines, index) actions["click"] = action index = newIndex continue } // Check for on_click action (single-line) ON_CLICK_REGEX.matchEntire(trimmed)?.let { match -> actions["click"] = parseAction(match.groupValues[1]) index++ continue } // Check if this is a component call (e.g., "VStack:" or "Button(...):") BEFORE checking property // This prevents "VStack:" from being matched as an empty property if (COMPONENT_CALL_REGEX.matches(trimmed) || COMPONENT_INLINE_REGEX.matches(trimmed)) { val (node, newIndex) = parseNode(lines, index, currentIndent) if (node != null) { children.add(node) } index = newIndex continue } // Check for property PROP_REGEX.matchEntire(trimmed)?.let { match -> val propName = match.groupValues[1] val propValue = match.groupValues[2].trim() // "content:" is a special case for nested children // This handles the pattern: // Card: // padding: "lg" // content: // VStack(...): if (propName == "content") { // If content is empty or just whitespace, parse children from content block if (propValue.isEmpty() || propValue.isBlank()) { val (contentChildren, newIndex) = parseContentBlock(lines, index) children.addAll(contentChildren) index = newIndex } else { // Content has an inline value, treat as property props[propName] = propValue.removeSurrounding("\"") index++ } continue } // Skip empty property values if (propValue.isEmpty()) { index++ continue } // Strip quotes from property value props[propName] = propValue.removeSurrounding("\"") index++ continue } // Parse as child node val (node, newIndex) = parseNode(lines, index, currentIndent) if (node != null) { children.add(node) } index = newIndex } val node = createNode(componentName, argsStr, children, props, actions) return node to index } private fun parseChildren(lines: List, startIndex: Int): Pair, Int> { val baseIndent = getIndent(lines[startIndex]) var index = startIndex + 1 val children = mutableListOf() while (index < lines.size) { val line = lines[index] if (line.isBlank()) { index++ continue } val currentIndent = getIndent(line) if (currentIndent <= baseIndent) break val (node, newIndex) = parseNode(lines, index, currentIndent) if (node != null) { children.add(node) } index = newIndex } return children to index } /** * Parse content block specifically for the "content:" property. * This handles the pattern: * ``` * Card: * padding: "lg" * content: * VStack(...): * ... * ``` * The baseIndent is the "content:" line, and children are indented below it. */ private fun parseContentBlock(lines: List, startIndex: Int): Pair, Int> { val contentLineIndent = getIndent(lines[startIndex]) var index = startIndex + 1 val children = mutableListOf() while (index < lines.size) { val line = lines[index] if (line.isBlank()) { index++ continue } val currentIndent = getIndent(line) // Content block ends when we encounter a line with same or less indent than "content:" line if (currentIndent <= contentLineIndent) break val (node, newIndex) = parseNode(lines, index, currentIndent) if (node != null) { children.add(node) } index = newIndex } return children to index } private fun parseConditional( lines: List, startIndex: Int, condition: String ): Pair { val (thenBranch, index) = parseChildren(lines, startIndex) return NanoNode.Conditional(condition, thenBranch) to index } private fun parseForLoop( lines: List, startIndex: Int, variable: String, iterable: String ): Pair { val (body, index) = parseChildren(lines, startIndex) return NanoNode.ForLoop(variable, iterable, body) to index } /** * Parse a multi-line action block (on_click: with indented content) * Supports: * - State mutations * - Multi-line Fetch with on_success/on_error callbacks * - Sequence of actions */ private fun parseActionBlock(lines: List, startIndex: Int): Pair { val baseIndent = getIndent(lines[startIndex]) var index = startIndex + 1 val actions = mutableListOf() val fetchBuilder = FetchActionBuilder() var inFetchBlock = false while (index < lines.size) { val line = lines[index] if (line.isBlank()) { index++ continue } val currentIndent = getIndent(line) if (currentIndent <= baseIndent) break val trimmed = line.trim() // Handle Fetch block start (multi-line) if (trimmed.startsWith("Fetch(") && !trimmed.endsWith(")")) { inFetchBlock = true parseFetchStart(trimmed, fetchBuilder) index++ continue } // Inside Fetch block if (inFetchBlock) { if (trimmed == ")") { // End of Fetch block actions.add(fetchBuilder.build()) inFetchBlock = false fetchBuilder.reset() } else { parseFetchLine(trimmed, fetchBuilder, lines, index) } index++ continue } // Single-line Fetch if (trimmed.startsWith("Fetch(") && trimmed.endsWith(")")) { actions.add(parseFetchAction(trimmed)) index++ continue } // Other actions (state mutation, Navigate, ShowToast) actions.add(parseAction(trimmed)) index++ } val resultAction = when (actions.size) { 0 -> NanoAction.StateMutation("unknown", MutationOp.SET, "") 1 -> actions[0] else -> NanoAction.Sequence(actions) } return resultAction to index } /** * Helper class to build Fetch action from multi-line input */ private class FetchActionBuilder { var url: String = "" var method: HttpMethod = HttpMethod.GET var body: MutableMap? = null var headers: MutableMap? = null var params: MutableMap? = null var onSuccess: NanoAction? = null var onError: NanoAction? = null var loadingState: String? = null fun build(): NanoAction.Fetch = NanoAction.Fetch( url = url, method = method, body = body, headers = headers, params = params, onSuccess = onSuccess, onError = onError, loadingState = loadingState ) fun reset() { url = "" method = HttpMethod.GET body = null headers = null params = null onSuccess = null onError = null loadingState = null } } private fun parseFetchStart(line: String, builder: FetchActionBuilder) { val urlMatch = Regex("""url\s*=\s*"([^"]+)"""").find(line) builder.url = urlMatch?.groupValues?.get(1) ?: "" val methodMatch = Regex("""method\s*=\s*"([^"]+)"""").find(line) methodMatch?.groupValues?.get(1)?.let { builder.method = try { HttpMethod.valueOf(it.uppercase()) } catch (e: Exception) { HttpMethod.GET } } } private fun parseFetchLine(line: String, builder: FetchActionBuilder, lines: List, currentIndex: Int) { val trimmed = line.trim().removeSuffix(",") // Parse url Regex("""url\s*=\s*"([^"]+)"""").find(trimmed)?.let { builder.url = it.groupValues[1] } // Parse method Regex("""method\s*=\s*"([^"]+)"""").find(trimmed)?.let { builder.method = try { HttpMethod.valueOf(it.groupValues[1].uppercase()) } catch (e: Exception) { HttpMethod.GET } } // Parse body if (trimmed.startsWith("body=") || trimmed.startsWith("body =")) { val bodyContent = trimmed.substringAfter("body").removePrefix("=").trim() if (bodyContent.startsWith("{") && bodyContent.endsWith("}")) { builder.body = parseFetchBodyContent(bodyContent.removeSurrounding("{", "}")) } } // Parse headers if (trimmed.startsWith("headers=") || trimmed.startsWith("headers =")) { val headersContent = trimmed.substringAfter("headers").removePrefix("=").trim() if (headersContent.startsWith("{") && headersContent.endsWith("}")) { builder.headers = parseFetchHeadersContent(headersContent.removeSurrounding("{", "}")) } } // Parse on_success callback if (trimmed.startsWith("on_success:")) { val actionStr = trimmed.removePrefix("on_success:").trim() if (actionStr.isNotEmpty()) { builder.onSuccess = parseAction(actionStr) } } // Parse on_error callback if (trimmed.startsWith("on_error:")) { val actionStr = trimmed.removePrefix("on_error:").trim() if (actionStr.isNotEmpty()) { builder.onError = parseAction(actionStr) } } } private fun parseFetchBodyContent(content: String): MutableMap { val result = mutableMapOf() val fieldRegex = Regex(""""(\w+)"\s*:\s*(state\.[\w.]+|"[^"]*")""") fieldRegex.findAll(content).forEach { match -> val key = match.groupValues[1] val value = match.groupValues[2] result[key] = BodyField.parse(value) } return result } private fun parseFetchHeadersContent(content: String): MutableMap { val result = mutableMapOf() val headerRegex = Regex(""""([^"]+)"\s*:\s*"([^"]+)"""") headerRegex.findAll(content).forEach { match -> result[match.groupValues[1]] = match.groupValues[2] } return result } private fun parseAction(actionStr: String): NanoAction { val trimmed = actionStr.trim() // Navigate action (enhanced with params, query, replace) if (trimmed.startsWith("Navigate(")) { return parseNavigateAction(trimmed) } // ShowToast action if (trimmed.startsWith("ShowToast(")) { val msgMatch = Regex("""ShowToast\("(.+?)"\)""").find(trimmed) if (msgMatch != null) { return NanoAction.ShowToast(msgMatch.groupValues[1]) } } // Fetch action - simple pattern if (trimmed.startsWith("Fetch(")) { return parseFetchAction(trimmed) } // State mutation val mutationMatch = Regex("""state\.(\w+)\s*([+\-]?)=\s*(.+)""").find(trimmed) if (mutationMatch != null) { val path = mutationMatch.groupValues[1] val op = when (mutationMatch.groupValues[2]) { "+" -> MutationOp.ADD "-" -> MutationOp.SUBTRACT else -> MutationOp.SET } val value = mutationMatch.groupValues[3] return NanoAction.StateMutation(path, op, value) } return NanoAction.StateMutation("unknown", MutationOp.SET, trimmed) } /** * Parse Navigate action with enhanced routing support * Examples: * - `Navigate(to="/home")` * - `Navigate(to="/user/{id}", params={"id": state.userId})` * - `Navigate(to="/search", query={"q": state.query})` * - `Navigate(to="/login", replace=true)` */ private fun parseNavigateAction(actionStr: String): NanoAction.Navigate { val paramsStr = actionStr.removePrefix("Navigate(").removeSuffix(")") // Parse 'to' (required) val toMatch = Regex("""to\s*=\s*"([^"]+)"""").find(paramsStr) val to = toMatch?.groupValues?.get(1) ?: "/" // Parse 'replace' boolean val replaceMatch = Regex("""replace\s*=\s*(true|false)""").find(paramsStr) val replace = replaceMatch?.groupValues?.get(1)?.toBoolean() ?: false // Parse 'params' map: params={"id": state.userId} or params={"id": "123"} val routeParams = parseNavigateMap(paramsStr, "params") // Parse 'query' map: query={"q": state.query} val queryParams = parseNavigateMap(paramsStr, "query") return NanoAction.Navigate( to = to, params = routeParams, query = queryParams, replace = replace ) } /** * Parse a map parameter from Navigate action * Supports: {"key": "value"} or {"key": state.path} */ private fun parseNavigateMap(paramsStr: String, mapName: String): Map? { val mapMatch = Regex("""$mapName\s*=\s*\{([^}]*)\}""").find(paramsStr) ?: return null val mapContent = mapMatch.groupValues[1] val result = mutableMapOf() // Match patterns like "id": "123" or "id": state.userId val fieldRegex = Regex(""""(\w+)"\s*:\s*("([^"]*)"|state\.[\w.]+)""") fieldRegex.findAll(mapContent).forEach { match -> val key = match.groupValues[1] val value = match.groupValues[2] // Remove quotes if it's a literal string result[key] = if (value.startsWith("\"")) { value.trim('"') } else { value // Keep state.xxx as-is for runtime binding } } return result.takeIf { it.isNotEmpty() } } /** * Parse Fetch action with parameters * Examples: * - `Fetch(url="/api/users")` * - `Fetch(url="/api/login", method="POST")` * - `Fetch(url="/api/login", method="POST", body={"email": state.email})` */ private fun parseFetchAction(actionStr: String): NanoAction.Fetch { // Extract parameters from Fetch(...) val paramsStr = actionStr.removePrefix("Fetch(").removeSuffix(")") // Parse URL val urlMatch = Regex("""url\s*=\s*"([^"]+)"""").find(paramsStr) val url = urlMatch?.groupValues?.get(1) ?: "/api/unknown" // Parse method val methodMatch = Regex("""method\s*=\s*"([^"]+)"""").find(paramsStr) val method = methodMatch?.groupValues?.get(1)?.uppercase()?.let { try { HttpMethod.valueOf(it) } catch (e: Exception) { HttpMethod.GET } } ?: HttpMethod.GET // Parse body (simplified: extract key-value pairs) val body = parseFetchBody(paramsStr) // Parse headers (simplified) val headers = parseFetchHeaders(paramsStr) return NanoAction.Fetch( url = url, method = method, body = body, headers = headers ) } /** * Parse body from Fetch params: body={"key": value, ...} */ private fun parseFetchBody(paramsStr: String): Map? { val bodyMatch = Regex("""body\s*=\s*\{([^}]*)\}""").find(paramsStr) ?: return null val bodyContent = bodyMatch.groupValues[1] val result = mutableMapOf() // Match patterns like "email": state.email or "name": "literal" val fieldRegex = Regex(""""(\w+)"\s*:\s*(state\.[\w.]+|"[^"]*")""") fieldRegex.findAll(bodyContent).forEach { match -> val key = match.groupValues[1] val value = match.groupValues[2] result[key] = BodyField.parse(value) } return result.takeIf { it.isNotEmpty() } } /** * Parse headers from Fetch params: headers={"Key": "value", ...} */ private fun parseFetchHeaders(paramsStr: String): Map? { val headersMatch = Regex("""headers\s*=\s*\{([^}]*)\}""").find(paramsStr) ?: return null val headersContent = headersMatch.groupValues[1] val result = mutableMapOf() val headerRegex = Regex(""""([^"]+)"\s*:\s*"([^"]+)"""") headerRegex.findAll(headersContent).forEach { match -> result[match.groupValues[1]] = match.groupValues[2] } return result.takeIf { it.isNotEmpty() } } private fun createNode( name: String, argsStr: String, children: List, props: Map = emptyMap(), actions: Map = emptyMap() ): NanoNode? { val args = parseArgs(argsStr) val onClick = actions["click"] val onChange = actions["change"] ?: onClick val onRowClick = actions["row_click"] ?: onClick val onClose = actions["close"] ?: onClick val onSubmitAction = actions["submit"] return when (name) { "VStack" -> NanoNode.VStack( spacing = args["spacing"] ?: props["spacing"], align = args["align"] ?: props["align"], flex = (args["flex"] ?: props["flex"])?.toFloatOrNull(), children = children ) "HStack" -> NanoNode.HStack( spacing = args["spacing"] ?: props["spacing"], align = args["align"] ?: props["align"], justify = args["justify"] ?: props["justify"], wrap = (args["wrap"] ?: props["wrap"])?.let { raw -> when (raw.trim().lowercase()) { "wrap" -> true "true" -> true "false" -> false else -> null } }, flex = (args["flex"] ?: props["flex"])?.toFloatOrNull(), children = children ) "Card" -> NanoNode.Card( padding = args["padding"] ?: props["padding"], shadow = args["shadow"] ?: props["shadow"], flex = (args["flex"] ?: props["flex"])?.toFloatOrNull(), children = children ) "Text" -> { val contentArg = args["content"] val content = if (contentArg != null) { // If content is a binding, extract the expression if (contentArg.startsWith("<<") || contentArg.startsWith(":=")) { "" // Content will come from binding } else { contentArg } } else { extractFirstArg(argsStr) ?: "" } val bindingFromNamed = contentArg?.let { Binding.parse(it) }?.takeIf { it !is Binding.Static } val bindingFromPositional = if (bindingFromNamed == null && extractFirstArg(argsStr) == null) { extractFirstRawArg(argsStr) ?.takeIf { it.isNotBlank() } ?.let { Binding.Subscribe(it) } } else { null } val binding = bindingFromNamed ?: bindingFromPositional NanoNode.Text( content = content, style = args["style"] ?: props["style"], binding = binding ) } "Button" -> { val label = extractFirstArg(argsStr) ?: "" NanoNode.Button( label = label, intent = args["intent"] ?: props["intent"], icon = args["icon"] ?: props["icon"], disabledIf = args["disabled_if"] ?: props["disabled_if"], onClick = onClick ) } "Image" -> NanoNode.Image( src = args["src"] ?: "", aspect = args["aspect"], radius = args["radius"], width = args["width"]?.toIntOrNull() ) "Badge" -> { val text = extractFirstArg(argsStr) ?: "" NanoNode.Badge(text = text, color = args["color"]) } "Icon" -> { val name = extractFirstArg(argsStr) ?: "" NanoNode.Icon( name = name, size = args["size"], color = args["color"] ) } "Input" -> { val valueArg = args["value"] NanoNode.Input( value = valueArg?.let { Binding.parse(it) }, placeholder = args["placeholder"], type = args["type"] ) } "Checkbox" -> { val checkedArg = args["checked"] NanoNode.Checkbox( checked = checkedArg?.let { Binding.parse(it) }, label = args["label"] ?: props["label"], onChange = onChange ) } "TextArea" -> { val valueArg = args["value"] NanoNode.TextArea( value = valueArg?.let { Binding.parse(it) }, placeholder = args["placeholder"], rows = args["rows"]?.toIntOrNull() ) } "Select" -> { val valueArg = args["value"] NanoNode.Select( value = valueArg?.let { Binding.parse(it) }, options = args["options"], placeholder = args["placeholder"] ) } "Form" -> { NanoNode.Form( onSubmit = args["onSubmit"], onSubmitAction = onSubmitAction, flex = (args["flex"] ?: props["flex"])?.toFloatOrNull(), children = children ) } // ============ P0: Core Form Input Components ============ "DatePicker" -> { val valueArg = args["value"] ?: props["value"] ?: props["bind"] NanoNode.DatePicker( value = valueArg?.let { Binding.parse(it) }, format = args["format"] ?: props["format"], minDate = args["minDate"] ?: props["minDate"], maxDate = args["maxDate"] ?: props["maxDate"], placeholder = args["placeholder"] ?: props["placeholder"], onChange = onChange ) } "Radio" -> { val valueArg = args["value"] ?: props["value"] NanoNode.Radio( value = valueArg?.let { Binding.parse(it) }, option = args["option"] ?: props["option"], label = args["label"] ?: props["label"], name = args["name"] ?: props["name"] ) } "RadioGroup" -> { val valueArg = args["value"] ?: props["value"] NanoNode.RadioGroup( value = valueArg?.let { Binding.parse(it) }, options = args["options"] ?: props["options"], name = args["name"] ?: props["name"], children = children ) } "Switch" -> { val checkedArg = args["checked"] ?: props["checked"] NanoNode.Switch( checked = checkedArg?.let { Binding.parse(it) }, label = args["label"] ?: props["label"], size = args["size"] ?: props["size"], onChange = onChange ) } "NumberInput" -> { val valueArg = args["value"] ?: props["value"] ?: props["bind"] NanoNode.NumberInput( value = valueArg?.let { Binding.parse(it) }, min = args["min"]?.toFloatOrNull() ?: props["min"]?.toFloatOrNull(), max = args["max"]?.toFloatOrNull() ?: props["max"]?.toFloatOrNull(), step = args["step"]?.toFloatOrNull() ?: props["step"]?.toFloatOrNull(), precision = args["precision"]?.toIntOrNull() ?: props["precision"]?.toIntOrNull(), placeholder = args["placeholder"] ?: props["placeholder"], onChange = onChange ) } // ============ P0: Feedback Components ============ "Modal" -> { val openArg = args["open"] ?: props["open"] NanoNode.Modal( open = openArg?.let { Binding.parse(it) }, title = args["title"] ?: props["title"], size = args["size"] ?: props["size"], closable = args["closable"]?.toBoolean() ?: props["closable"]?.toBoolean(), onClose = onClose, children = children ) } "Alert" -> { NanoNode.Alert( type = args["type"] ?: props["type"], message = args["message"] ?: props["message"], closable = args["closable"]?.toBoolean() ?: props["closable"]?.toBoolean(), icon = args["icon"] ?: props["icon"], onClose = onClose, children = children ) } "Progress" -> { NanoNode.Progress( value = args["value"] ?: props["value"], max = args["max"] ?: props["max"], showText = args["showText"]?.toBoolean() ?: props["showText"]?.toBoolean(), status = args["status"] ?: props["status"] ) } "Spinner" -> { NanoNode.Spinner( size = args["size"] ?: props["size"], color = args["color"] ?: props["color"], text = args["text"] ?: props["text"] ) } // ============ Tier 1-3: GenUI Components (already added) ============ "SplitView" -> { NanoNode.SplitView( ratio = args["ratio"]?.toFloatOrNull() ?: props["ratio"]?.toFloatOrNull(), flex = (args["flex"] ?: props["flex"])?.toFloatOrNull(), children = children ) } "GenCanvas" -> { val bindArg = args["bind"] ?: props["bind"] ?: args["value"] ?: props["value"] NanoNode.GenCanvas( bind = bindArg?.let { Binding.parse(it) }, flex = (args["flex"] ?: props["flex"])?.toFloatOrNull() ) } "SmartTextField" -> { val nameArg = extractFirstArg(argsStr) val bindArg = args["bind"] ?: props["bind"] ?: args["value"] ?: props["value"] NanoNode.SmartTextField( name = nameArg ?: props["name"], label = args["label"] ?: props["label"], bind = bindArg?.let { Binding.parse(it) }, validation = args["validation"] ?: props["validation"], placeholder = args["placeholder"] ?: props["placeholder"] ) } "Slider" -> { val nameArg = extractFirstArg(argsStr) val bindArg = args["bind"] ?: props["bind"] ?: args["value"] ?: props["value"] NanoNode.Slider( name = nameArg ?: props["name"], label = args["label"] ?: props["label"], bind = bindArg?.let { Binding.parse(it) }, min = args["min"]?.toFloatOrNull() ?: props["min"]?.toFloatOrNull(), max = args["max"]?.toFloatOrNull() ?: props["max"]?.toFloatOrNull(), step = args["step"]?.toFloatOrNull() ?: props["step"]?.toFloatOrNull(), onChange = onChange ) } "DateRangePicker" -> { val nameArg = extractFirstArg(argsStr) val bindArg = args["bind"] ?: props["bind"] ?: args["value"] ?: props["value"] NanoNode.DateRangePicker( name = nameArg ?: props["name"], bind = bindArg?.let { Binding.parse(it) }, onChange = onChange ) } "DataChart" -> { val nameArg = extractFirstArg(argsStr) NanoNode.DataChart( name = nameArg ?: props["name"], type = args["type"] ?: props["type"], data = args["data"] ?: props["data"], xAxis = args["x_axis"] ?: props["x_axis"], yAxis = args["y_axis"] ?: props["y_axis"], color = args["color"] ?: props["color"] ) } "DataTable" -> { val nameArg = extractFirstArg(argsStr) NanoNode.DataTable( name = nameArg ?: props["name"], columns = args["columns"] ?: props["columns"], data = args["data"] ?: props["data"], onRowClick = onRowClick ) } else -> null } } private fun parseArgs(argsStr: String): Map { if (argsStr.isBlank()) return emptyMap() val result = mutableMapOf() fun extractBracketLiteral(key: String): String? { // Match: key = [ ... ] or key: [ ... ] val m = Regex("""\b${key}\s*(?::=|=|:)\s*""").find(argsStr) ?: return null var i = m.range.last + 1 while (i < argsStr.length && argsStr[i].isWhitespace()) i++ if (i >= argsStr.length) return null val open = argsStr[i] val close = when (open) { '[' -> ']' '{' -> '}' else -> return null } var depth = 0 var inString = false var escaped = false val start = i while (i < argsStr.length) { val c = argsStr[i] if (inString) { if (escaped) { escaped = false } else if (c == '\\') { escaped = true } else if (c == '"') { inString = false } } else { when (c) { '"' -> inString = true open -> depth++ close -> { depth-- if (depth == 0) { return argsStr.substring(start, i + 1).trim() } } } } i++ } return null } // Capture complex literals that include commas/braces so regex parsing doesn't drop them. extractBracketLiteral("columns")?.let { result["columns"] = it } extractBracketLiteral("options")?.let { result["options"] = it } // First, handle << (subscribe binding) pattern at top-level args. // Supports expressions like: value << (flightBudget + (accommodationBudget * 5)) run { var i = 0 var inString = false var quoteChar = '\u0000' var escaped = false var depthParen = 0 var depthBracket = 0 var depthBrace = 0 fun isIdentStart(c: Char): Boolean = c == '_' || c.isLetter() fun isIdentPart(c: Char): Boolean = c == '_' || c.isLetterOrDigit() while (i < argsStr.length) { // Skip whitespace and commas while (i < argsStr.length && (argsStr[i].isWhitespace() || argsStr[i] == ',')) i++ if (i >= argsStr.length) break // Parse key identifier if (!isIdentStart(argsStr[i])) { i++ continue } val keyStart = i i++ while (i < argsStr.length && isIdentPart(argsStr[i])) i++ val key = argsStr.substring(keyStart, i) // Skip whitespace while (i < argsStr.length && argsStr[i].isWhitespace()) i++ // Expect << if (i + 1 >= argsStr.length || argsStr[i] != '<' || argsStr[i + 1] != '<') { continue } i += 2 // Skip whitespace while (i < argsStr.length && argsStr[i].isWhitespace()) i++ // Capture expression until next top-level comma val exprStart = i inString = false quoteChar = '\u0000' escaped = false depthParen = 0 depthBracket = 0 depthBrace = 0 while (i < argsStr.length) { val c = argsStr[i] if (inString) { if (escaped) { escaped = false } else if (c == '\\') { escaped = true } else if (c == quoteChar) { inString = false quoteChar = '\u0000' } i++ continue } when (c) { '\'', '"' -> { inString = true quoteChar = c } '(' -> depthParen++ ')' -> if (depthParen > 0) depthParen-- '[' -> depthBracket++ ']' -> if (depthBracket > 0) depthBracket-- '{' -> depthBrace++ '}' -> if (depthBrace > 0) depthBrace-- ',' -> { if (depthParen == 0 && depthBracket == 0 && depthBrace == 0) { break } } } i++ } val expr = argsStr.substring(exprStart, i).trim() if (expr.isNotBlank()) { result[key] = "<< $expr" } // If we stopped at a comma, skip it if (i < argsStr.length && argsStr[i] == ',') i++ } } // Then handle := (two-way binding) and = (assignment) patterns // Match patterns like: name="value", name=value, name := state.path, name: "value" val argRegex = Regex("""(\w+)\s*(?::=|=|:)\s*(?:"([^"]*)"|([\w.]+))""") argRegex.findAll(argsStr).forEach { match -> val key = match.groupValues[1] // Skip if already handled by subscribe binding if (key in result) return@forEach val rawValue = match.groupValues[2].ifEmpty { match.groupValues[3] } // Preserve the binding operator for value parsing val operatorMatch = Regex("""(\w+)\s*(:=)""").find(argsStr) val hasTwoWayBinding = operatorMatch?.groupValues?.get(1) == key result[key] = if (hasTwoWayBinding) ":= $rawValue" else rawValue } return result } private fun extractFirstArg(argsStr: String): String? { if (argsStr.isBlank()) return null val match = Regex("""^"([^"]*)".*$""").find(argsStr.trim()) return match?.groupValues?.get(1) } private fun extractFirstRawArg(argsStr: String): String? { val trimmed = argsStr.trim() if (trimmed.isBlank()) return null var i = 0 var inString = false var quoteChar = '\u0000' var escaped = false var depthParen = 0 var depthBracket = 0 var depthBrace = 0 while (i < trimmed.length) { val c = trimmed[i] if (inString) { if (escaped) { escaped = false } else if (c == '\\') { escaped = true } else if (c == quoteChar) { inString = false quoteChar = '\u0000' } i++ continue } when (c) { '\'', '"' -> { inString = true quoteChar = c } '(' -> depthParen++ ')' -> if (depthParen > 0) depthParen-- '[' -> depthBracket++ ']' -> if (depthBracket > 0) depthBracket-- '{' -> depthBrace++ '}' -> if (depthBrace > 0) depthBrace-- ',' -> { if (depthParen == 0 && depthBracket == 0 && depthBrace == 0) { break } } } i++ } val first = trimmed.substring(0, i).trim() // Ignore named-only first segments (e.g., style="h2"). if (first.contains('=') || first.contains(":=")) return null return first } private fun getIndent(line: String): Int { return line.takeWhile { it == ' ' || it == '\t' }.length } }