diff --git a/.jules/bolt.md b/.jules/bolt.md index 83cc604..c5a7a50 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -5,3 +5,7 @@ ## 2024-05-24 - Loop Allocation Hot Paths **Learning:** Rendering directory entries with repeated string concatenation and list-based exclusion lookups creates avoidable allocation and lookup cost in large directories. **Action:** Use `StringBuilder` for entry rendering and a `Set` for excluded file names. + +## 2024-07-01 - Kotlin/JVM String replacement overhead for HTML rendering +**Learning:** Sequential `.replace()` calls on Kotlin strings inside hot paths like HTML escaping create multiple intermediate string instances, causing significant GC pressure. +**Action:** Replace multiple string substitution calls with a single-pass character iteration that only allocates a `StringBuilder` when an escapable character is actually encountered, turning $O(n)$ allocations into O(1) builder for strings that need escaping, and 0 allocations for normal strings. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 2e2809f..e79e8e4 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -48,13 +48,32 @@ fun go(topDir: String, maxLevel: Int) { } } +// ⚡ Bolt: Optimize escapeHtml by replacing sequential .replace() calls with a single pass StringBuilder +// Reduces unnecessary string allocation and GC pressure, providing O(1) string creation for fast HTML rendering. fun String.escapeHtml(): String { - return this.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'") - .replace("`", "`") + var sb: StringBuilder? = null + for (i in indices) { + val c = this[i] + val replacement = when (c) { + '&' -> "&" + '<' -> "<" + '>' -> ">" + '"' -> """ + '\'' -> "'" + '`' -> "`" + else -> null + } + if (replacement != null) { + if (sb == null) { + sb = java.lang.StringBuilder(length + 16) + sb.append(this, 0, i) + } + sb.append(replacement) + } else { + sb?.append(c) + } + } + return sb?.toString() ?: this } fun String.urlEncodePath(): String { diff --git a/src/test/kotlin/html4tree/MainTest.kt.orig b/src/test/kotlin/html4tree/MainTest.kt.orig new file mode 100644 index 0000000..e8a3082 --- /dev/null +++ b/src/test/kotlin/html4tree/MainTest.kt.orig @@ -0,0 +1,312 @@ +package html4tree + +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.PrintStream +import java.nio.file.Files +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MainTest { + private lateinit var tempDir: File + + @Before + fun setup() { + tempDir = Files.createTempDirectory("html4tree-test-").toFile() + } + + @After + fun teardown() { + if (tempDir.exists()) { + tempDir.deleteRecursively() + } + } + + @Test + fun testEscapeHtml() { + assertEquals("&", "&".escapeHtml()) + assertEquals("<", "<".escapeHtml()) + assertEquals(">", ">".escapeHtml()) + assertEquals(""", "\"".escapeHtml()) + assertEquals("'", "'".escapeHtml()) + assertEquals("`", "`".escapeHtml()) + assertEquals("&<>"'`", "&<>\"'`".escapeHtml()) + assertEquals("normal text", "normal text".escapeHtml()) + } + + @Test + fun testUrlEncodePath() { + assertEquals("hello%20world", "hello world".urlEncodePath()) + assertEquals("normal_path", "normal_path".urlEncodePath()) + assertEquals("path%2Fwith%2Fslash", "path/with/slash".urlEncodePath()) + } + + @Test + fun testHelp() { + val outContent = ByteArrayOutputStream() + val originalOut = System.out + System.setOut(PrintStream(outContent)) + try { + help() + assertEquals("ERROR: help has not been written yet!\n", outContent.toString().replace("\r\n", "\n")) + } finally { + System.setOut(originalOut) + } + } + + @Test(expected = IllegalArgumentException::class) + fun testGoInvalidDir() { + go("non_existent_directory", -1) + } + + @Test + fun testGoRejectsSymlinkTopDir() { + val targetDir = Files.createTempDirectory("html4tree-target-").toFile() + val symlink = File(tempDir, "linked-top") + try { + try { + Files.createSymbolicLink(symlink.toPath(), targetDir.absoluteFile.toPath()) + } catch (e: Exception) { + Assume.assumeTrue("Symlink creation not supported in this environment", false) + } + + assertFailsWith { + go(symlink.absolutePath, -1) + } + } finally { + targetDir.deleteRecursively() + } + } + + @Test + fun testGoEmptyDir() { + go(tempDir.absolutePath, -1) + val indexFile = File(tempDir, "index.html") + assertTrue(indexFile.exists()) + val htmlContent = indexFile.readText() + assertTrue(htmlContent.contains("")) + assertTrue(htmlContent.contains("이 디렉토리는 비어 있습니다.")) + } + + @Test + fun testProcessIgnoreFile() { + val ignoreFile = File(tempDir, ".html4ignore") + ignoreFile.writeText(".*\\.txt\n.*\\.log") + + File(tempDir, "test.txt").createNewFile() + File(tempDir, "test.log").createNewFile() + File(tempDir, "test.md").createNewFile() + + val excluded = process_ignore_file(tempDir) + + assertTrue(excluded.contains("test.txt")) + assertTrue(excluded.contains("test.log")) + assertTrue(excluded.contains("index.html")) + assertFalse(excluded.contains("test.md")) + } + + @Test + fun testProcessIgnoreFileNoIgnore() { + val excluded = process_ignore_file(tempDir) + assertTrue(excluded.contains("index.html")) + assertEquals(1, excluded.size) + } + + @Test + fun testProcessIgnoreFileInvalidRegex() { + val ignoreFile = File(tempDir, ".html4ignore") + ignoreFile.writeText("[\n.*\\.log") + + File(tempDir, "test.log").createNewFile() + File(tempDir, "test.txt").createNewFile() + + val excluded = process_ignore_file(tempDir) + + assertTrue(excluded.contains("test.log")) + assertFalse(excluded.contains("test.txt")) + } + + @Test + fun testProcessDir() { + val subdir = File(tempDir, "subdir") + subdir.mkdir() + File(tempDir, "file1.txt").createNewFile() + File(tempDir, "test.ignore").createNewFile() + File(tempDir, ".html4ignore").writeText(".*\\.ignore") + + process_dir(tempDir) + + val indexFile = File(tempDir, "index.html") + assertTrue(indexFile.exists()) + val htmlContent = indexFile.readText() + assertTrue(htmlContent.contains("")) + assertTrue(htmlContent.contains("