diff --git a/.jules/bolt.md b/.jules/bolt.md index 83cc604..e9f24f3 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-02 - String Escape Optimization +**Learning:** Multiple passes of `.replace()` on strings cause significant performance degradation due to intermediate string allocations per replacement call. +**Action:** Use a single-pass `StringBuilder` loop to avoid redundant string allocations when escaping HTML or doing multiple character replacements, after checking if an escape is even needed. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 2e2809f..6296a14 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -23,8 +23,9 @@ fun main(args: Array) = Html4tree().main(args) fun go(topDir: String, maxLevel: Int) { require(topDir.isNotBlank()) - val top_dir = File(topDir).canonicalFile - require(Files.isDirectory(top_dir.toPath(), LinkOption.NOFOLLOW_LINKS)) { "Top directory must be an existing non-symlink directory" } + val original_dir = File(topDir).absoluteFile + require(Files.isDirectory(original_dir.toPath(), LinkOption.NOFOLLOW_LINKS)) { "Top directory must be an existing non-symlink directory" } + val top_dir = original_dir.canonicalFile val ll = LinkedList() @@ -49,12 +50,30 @@ fun go(topDir: String, maxLevel: Int) { } fun String.escapeHtml(): String { - return this.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'") - .replace("`", "`") + var sb: java.lang.StringBuilder? = null + for (i in 0 until this.length) { + val c = this[i] + val escaped = when (c) { + '&' -> "&" + '<' -> "<" + '>' -> ">" + '"' -> """ + '\'' -> "'" + '`' -> "`" + else -> null + } + + if (escaped != null) { + if (sb == null) { + sb = java.lang.StringBuilder(this.length + 16) + sb.append(this, 0, i) + } + sb.append(escaped) + } else { + sb?.append(c) + } + } + return sb?.toString() ?: this } fun String.urlEncodePath(): String { diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index e8a3082..c36f7c8 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -39,6 +39,7 @@ class MainTest { assertEquals("`", "`".escapeHtml()) assertEquals("&<>"'`", "&<>\"'`".escapeHtml()) assertEquals("normal text", "normal text".escapeHtml()) + assertEquals("mixed & and < text > here", "mixed & and < text > here".escapeHtml()) } @Test @@ -85,6 +86,15 @@ class MainTest { } } + @Test + fun testGoRejectsNonExistentDir() { + val nonExistentDir = File(tempDir, "non_existent_dir_${System.currentTimeMillis()}") + assertFalse(nonExistentDir.exists(), "Test directory should not exist initially") + assertFailsWith { + go(nonExistentDir.absolutePath, -1) + } + } + @Test fun testGoEmptyDir() { go(tempDir.absolutePath, -1)