From b20c65cbce5cb681b12da4e62c861bc406482d89 Mon Sep 17 00:00:00 2001 From: Matt Mckenna Date: Sat, 6 Jun 2026 10:26:35 -0400 Subject: [PATCH 1/2] Optimize parser hot paths --- .../commonMain/kotlin/halogen/SchemaParser.kt | 15 +++- .../kotlin/halogen/SchemaParserTest.kt | 7 ++ .../kotlin/halogen/engine/HintExtractor.kt | 77 ++++++++++++++----- .../halogen/engine/HintExtractorTest.kt | 25 ++++++ 4 files changed, 101 insertions(+), 23 deletions(-) diff --git a/halogen-core/src/commonMain/kotlin/halogen/SchemaParser.kt b/halogen-core/src/commonMain/kotlin/halogen/SchemaParser.kt index 4c15627..67fedcd 100644 --- a/halogen-core/src/commonMain/kotlin/halogen/SchemaParser.kt +++ b/halogen-core/src/commonMain/kotlin/halogen/SchemaParser.kt @@ -15,8 +15,6 @@ public object SchemaParser { isLenient = true } - private val HEX_COLOR_REGEX: Regex = Regex("^#[0-9A-Fa-f]{6}$") - /** * Parse a JSON string (potentially wrapped in markdown code fences) into * a validated [HalogenThemeSpec]. @@ -67,7 +65,7 @@ public object SchemaParser { ) for ((name, value) in colorFields) { - if (!HEX_COLOR_REGEX.matches(value)) { + if (!isValidHexColor(value)) { return Result.failure( IllegalArgumentException( "Invalid hex color for $name: \"$value\". Expected format: #RRGGBB", @@ -85,4 +83,15 @@ public object SchemaParser { return Result.success(clamped) } + + private fun isValidHexColor(value: String): Boolean { + if (value.length != 7 || value[0] != '#') return false + + for (index in 1 until value.length) { + val char = value[index] + if (char !in '0'..'9' && char !in 'a'..'f' && char !in 'A'..'F') return false + } + + return true + } } diff --git a/halogen-core/src/commonTest/kotlin/halogen/SchemaParserTest.kt b/halogen-core/src/commonTest/kotlin/halogen/SchemaParserTest.kt index 1e78da9..d5313b2 100644 --- a/halogen-core/src/commonTest/kotlin/halogen/SchemaParserTest.kt +++ b/halogen-core/src/commonTest/kotlin/halogen/SchemaParserTest.kt @@ -139,6 +139,13 @@ class SchemaParserTest { assertTrue(result.isFailure, "Should fail on short hex color") } + @Test + fun parse_hexColorWithoutHash_fails() { + val badColor = validJson.replace("#6750A4", "6750A4") + val result = SchemaParser.parse(badColor) + assertTrue(result.isFailure, "Should fail when hex color is missing leading #") + } + // ---- Clamping: weights ---- @Test diff --git a/halogen-engine/src/commonMain/kotlin/halogen/engine/HintExtractor.kt b/halogen-engine/src/commonMain/kotlin/halogen/engine/HintExtractor.kt index da90f01..6ce40d0 100644 --- a/halogen-engine/src/commonMain/kotlin/halogen/engine/HintExtractor.kt +++ b/halogen-engine/src/commonMain/kotlin/halogen/engine/HintExtractor.kt @@ -10,42 +10,79 @@ package halogen.engine */ internal object HintExtractor { - private val PREFIX_PATTERN = Regex("""^(?:/r/|/category/|/topic/|/|#)""") - private val CAMEL_SPLIT = Regex("""(?<=[a-z])(?=[A-Z])""") - private val ID_PATTERN = Regex("""^[0-9a-f]{8,}$""", RegexOption.IGNORE_CASE) - private val NUMERIC_ONLY = Regex("""^\d+$""") - private val WHITESPACE_PATTERN = Regex("""\s+""") - fun extract(key: String): String? { if (key.isBlank()) return null - // Strip common prefixes - var cleaned = PREFIX_PATTERN.replace(key.trim(), "") + val trimmed = key.trim() + var start = when { + trimmed.startsWith("/r/") -> 3 + trimmed.startsWith("/category/") -> 10 + trimmed.startsWith("/topic/") -> 7 + trimmed.startsWith("/") -> 1 + trimmed.startsWith("#") -> 1 + else -> 0 + } // Remove leading/trailing slashes - cleaned = cleaned.trim('/') + var end = trimmed.length + while (start < end && trimmed[start] == '/') start++ + while (end > start && trimmed[end - 1] == '/') end-- + if (start >= end) return null // Take the last meaningful segment if it looks like a path - if ('/' in cleaned) { - cleaned = cleaned.substringAfterLast('/') + val lastSlash = trimmed.lastIndexOf('/', end - 1) + if (lastSlash >= start) { + start = lastSlash + 1 } - // Split camelCase - cleaned = CAMEL_SPLIT.replace(cleaned, " ") + val builder = StringBuilder(end - start) + var previousOutput = ' ' + var previousOriginal = ' ' + + for (index in start until end) { + val char = trimmed[index] - // Split snake_case and kebab-case - cleaned = cleaned.replace('_', ' ').replace('-', ' ') + if (char == '_' || char == '-' || char.isWhitespace()) { + if (previousOutput != ' ') { + builder.append(' ') + previousOutput = ' ' + } + } else { + if (previousOriginal in 'a'..'z' && char in 'A'..'Z' && previousOutput != ' ') { + builder.append(' ') + } + builder.append(char) + previousOutput = char + } - // Normalize whitespace - cleaned = cleaned.trim().replace(WHITESPACE_PATTERN, " ") + previousOriginal = char + } + + val cleaned = builder.toString().trim() if (cleaned.isBlank()) return null // Reject things that look like IDs - val noSpaces = cleaned.replace(" ", "") - if (ID_PATTERN.matches(noSpaces)) return null - if (NUMERIC_ONLY.matches(noSpaces)) return null + if (looksLikeId(cleaned)) return null return cleaned.lowercase() } + + private fun looksLikeId(value: String): Boolean { + var length = 0 + var allNumeric = true + var allHex = true + + for (char in value) { + if (char == ' ') continue + + length++ + if (char !in '0'..'9') allNumeric = false + if (char !in '0'..'9' && char !in 'a'..'f' && char !in 'A'..'F') allHex = false + + if (!allNumeric && !allHex) return false + } + + return length > 0 && (allNumeric || (length >= 8 && allHex)) + } } diff --git a/halogen-engine/src/commonTest/kotlin/halogen/engine/HintExtractorTest.kt b/halogen-engine/src/commonTest/kotlin/halogen/engine/HintExtractorTest.kt index 6827ab5..6e14c98 100644 --- a/halogen-engine/src/commonTest/kotlin/halogen/engine/HintExtractorTest.kt +++ b/halogen-engine/src/commonTest/kotlin/halogen/engine/HintExtractorTest.kt @@ -41,4 +41,29 @@ class HintExtractorTest { fun extract_pathTakesLastSegment() { assertEquals("programming", HintExtractor.extract("/category/tech/programming")) } + + @Test + fun extract_topicPrefix_strips() { + assertEquals("material you", HintExtractor.extract("/topic/material-you")) + } + + @Test + fun extract_hashPrefix_strips() { + assertEquals("dark mode", HintExtractor.extract("#darkMode")) + } + + @Test + fun extract_repeatedSeparators_normalizesWhitespace() { + assertEquals("dark mode", HintExtractor.extract(" dark__-- \t mode ")) + } + + @Test + fun extract_numericOnly_returnsNull() { + assertNull(HintExtractor.extract("123456")) + } + + @Test + fun extract_hexIdWithSeparators_returnsNull() { + assertNull(HintExtractor.extract("a1b2-c3d4")) + } } From 2d7ac0251e5cfe0662a3b695f11102a1605ff3e9 Mon Sep 17 00:00:00 2001 From: Matt Mckenna Date: Sat, 6 Jun 2026 10:41:47 -0400 Subject: [PATCH 2/2] Stabilize compose theme tests --- .../halogen/compose/HalogenThemeTest.kt | 47 ++++++++----------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/halogen-compose/src/commonTest/kotlin/halogen/compose/HalogenThemeTest.kt b/halogen-compose/src/commonTest/kotlin/halogen/compose/HalogenThemeTest.kt index 145b570..ab99547 100644 --- a/halogen-compose/src/commonTest/kotlin/halogen/compose/HalogenThemeTest.kt +++ b/halogen-compose/src/commonTest/kotlin/halogen/compose/HalogenThemeTest.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.runComposeUiTest import halogen.HalogenConfig @@ -27,6 +28,17 @@ class HalogenThemeTest { """{"pri":"#9A6ACD","sec":"#4A8A8A","ter":"#B06B7D","neuL":"#F3F0F6","neuD":"#151018","err":"#93000A","font":"mono","hw":700,"bw":400,"ls":true,"cs":"sharp","cx":0.5}""", ) + private fun ComposeUiTest.waitUntilPrimaryChangesFrom( + previousPrimary: Color, + currentPrimary: () -> Color, + ) { + waitUntil(timeoutMillis = 5_000) { + val primary = currentPrimary() + primary != Color.Unspecified && primary != previousPrimary + } + waitForIdle() + } + // ── Config flag tests ─────────────────────────────────────────────── @Test @@ -45,7 +57,7 @@ class HalogenThemeTest { } } - waitForIdle() + waitUntilPrimaryChangesFrom(defaultPrimary) { capturedPrimary } assertNotEquals(defaultPrimary, capturedPrimary, "LLM colors should differ from M3 defaults") } @@ -103,6 +115,7 @@ class HalogenThemeTest { } } + waitUntil(timeoutMillis = 5_000) { capturedMediumShape != defaultMediumShape } waitForIdle() assertNotEquals(defaultMediumShape, capturedMediumShape, "LLM shapes should differ from M3 defaults") } @@ -114,8 +127,6 @@ class HalogenThemeTest { var spec by mutableStateOf(oceanSpec) var capturedPrimary = Color.Unspecified - mainClock.autoAdvance = false - setContent { HalogenTheme( spec = spec, @@ -127,26 +138,13 @@ class HalogenThemeTest { } } - // Let initial theme expand and snap - mainClock.advanceTimeBy(1000) + waitUntilPrimaryChangesFrom(lightColorScheme().primary) { capturedPrimary } val oceanPrimary = capturedPrimary - // Switch to neon spec spec = neonSpec - mainClock.advanceTimeBy(50) // let expansion start - mainClock.advanceTimeBy(50) // expansion should complete - - // mid-animation: color should have started changing but not reached target - mainClock.advanceTimeBy(100) - val midPrimary = capturedPrimary - - // complete animation - mainClock.advanceTimeBy(500) + waitUntilPrimaryChangesFrom(oceanPrimary) { capturedPrimary } val finalPrimary = capturedPrimary - // mid-animation should differ from both start and end - // (unless expansion is slow, in which case mid == ocean still) - // Final should differ from ocean assertNotEquals(oceanPrimary, finalPrimary, "Final primary should differ from ocean primary after spec change") } @@ -155,8 +153,6 @@ class HalogenThemeTest { var spec by mutableStateOf(oceanSpec) var capturedPrimary = Color.Unspecified - mainClock.autoAdvance = false - setContent { HalogenTheme( spec = spec, @@ -168,13 +164,11 @@ class HalogenThemeTest { } } - // Let initial theme expand - mainClock.advanceTimeBy(1000) + waitUntilPrimaryChangesFrom(lightColorScheme().primary) { capturedPrimary } val oceanPrimary = capturedPrimary - // Switch to neon spec spec = neonSpec - mainClock.advanceTimeBy(1000) // give expansion time + waitUntilPrimaryChangesFrom(oceanPrimary) { capturedPrimary } val afterSnap = capturedPrimary @@ -187,8 +181,6 @@ class HalogenThemeTest { var capturedPrimary = Color.Unspecified val defaultPrimary = lightColorScheme().primary - mainClock.autoAdvance = false - setContent { HalogenTheme( spec = oceanSpec, @@ -200,8 +192,7 @@ class HalogenThemeTest { } } - // Advance past expansion time — first theme should snap, not animate - mainClock.advanceTimeBy(1000) + waitUntilPrimaryChangesFrom(defaultPrimary) { capturedPrimary } val firstPrimary = capturedPrimary // If it animated, we'd still see the default color at frame 0.