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
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2024-06-21 - Regex Compilation in Loops
**Learning:** In Kotlin, compiling regular expressions (`.toRegex()`) inside a loop over files is a significant O(N * M) performance bottleneck when processing ignore files (N files * M rules).
**Action:** Always map string rules to compiled `Regex` objects outside of the file iteration loop (O(M) compilation) to avoid unnecessary regex re-compilations.

## 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.
46 changes: 37 additions & 9 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ class Html4tree : CliktCommand() {
fun main(args: Array<String>) = Html4tree().main(args)

fun go(topDir: String, maxLevel: Int) {
val top_dir = File(topDir)
require(topDir.isNotBlank())
val top_dir = File(topDir).canonicalFile
require(top_dir.exists() && top_dir.isDirectory())

val ll = LinkedList()
Expand Down Expand Up @@ -48,26 +49,52 @@ fun String.escapeHtml(): String {
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#x27;")
.replace("`", "&#x60;")
}

fun String.urlEncodePath(): String {
return java.net.URLEncoder.encode(this, "UTF-8").replace("+", "%20")
val encoded = StringBuilder()
this.toByteArray(Charsets.UTF_8).forEach {
val byte = it.toInt() and 0xff
val isUnreserved = (byte in 'A'.toInt()..'Z'.toInt()) ||
(byte in 'a'.toInt()..'z'.toInt()) ||
(byte in '0'.toInt()..'9'.toInt()) ||
byte == '-'.toInt() ||
byte == '.'.toInt() ||
byte == '_'.toInt() ||
byte == '~'.toInt()
if (isUnreserved) {
encoded.append(byte.toChar())
} else {
encoded.append('%')
encoded.append(byte.toString(16).padStart(2, '0').toUpperCase())
}
}
return encoded.toString()
}

fun process_ignore_file(curr_dir: File): List<String> {
fun process_ignore_file(curr_dir: File): Set<String> {

val ignore_filename = ".html4ignore"

val ignore_file_path = curr_dir.getAbsolutePath()+"/"+ignore_filename

val ignore_file = File(ignore_file_path)

val files_to_exclude = mutableListOf<String>()
val files_to_exclude = mutableSetOf<String>()

if(ignore_file.exists()){
val ignored_regexes = mutableListOf<Regex>()

ignore_file.forEachLine { ignored_regexes.add(("^"+it+"$").toRegex()) }
ignore_file.forEachLine {
val pattern = it.trim()
if (pattern.isNotEmpty()) {
try {
ignored_regexes.add(("^"+pattern+"$").toRegex())
} catch (_: IllegalArgumentException) {
}
}
}

curr_dir.list().sorted().forEach {
val current = it
Expand All @@ -87,7 +114,7 @@ fun process_ignore_file(curr_dir: File): List<String> {

fun process_dir(curr_dir: File){

val exclude: List<String> = process_ignore_file(curr_dir)
val exclude: Set<String> = process_ignore_file(curr_dir)

val css = """
<style>
Expand Down Expand Up @@ -126,7 +153,7 @@ fun process_dir(curr_dir: File){
"""

val index_middle = fun():String{
var l=""
val l = StringBuilder()

val dir_files: MutableList<File> = curr_dir.listFiles()?.toMutableList() ?: mutableListOf()
dir_files.sortWith(compareBy ({it.name}) )
Expand All @@ -136,11 +163,12 @@ fun process_dir(curr_dir: File){
val fileName = it.getName()
val encodedHref = if (isLinkedDirectory) { "./${fileName.urlEncodePath()}/" } else { "./${fileName.urlEncodePath()}" }
val ariaLabel = "${fileName} ${if (isLinkedDirectory) { "디렉토리" } else { "파일" }}".escapeHtml()
l += """ <li><a style="display:block; width:100%" href="${encodedHref}" aria-label="${ariaLabel}">${if (isLinkedDirectory) { "&#128193;" } else { "&rtrif;" }} ${fileName.escapeHtml()}</a></li>"""+"\n"
l.append(""" <li><a style="display:block; width:100%" href="${encodedHref}" aria-label="${ariaLabel}">${if (isLinkedDirectory) { "&#128193;" } else { "&rtrif;" }} ${fileName.escapeHtml()}</a></li>""")
l.append('\n')
}
}

return l;
return l.toString();
}

val index_bottom="""
Expand Down
19 changes: 17 additions & 2 deletions src/test/kotlin/html4tree/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ class MainTest {
assertEquals("&gt;", ">".escapeHtml())
assertEquals("&quot;", "\"".escapeHtml())
assertEquals("&#x27;", "'".escapeHtml())
assertEquals("&amp;&lt;&gt;&quot;&#x27;", "&<>\"'".escapeHtml())
assertEquals("&#x60;", "`".escapeHtml())
assertEquals("&amp;&lt;&gt;&quot;&#x27;&#x60;", "&<>\"'`".escapeHtml())
assertEquals("normal text", "normal text".escapeHtml())
}

Expand All @@ -53,7 +54,7 @@ class MainTest {
System.setOut(PrintStream(outContent))
try {
help()
assertEquals("ERROR: help has not been written yet!\n", outContent.toString())
assertEquals("ERROR: help has not been written yet!\n", outContent.toString().replace("\r\n", "\n"))
} finally {
System.setOut(originalOut)
}
Expand Down Expand Up @@ -96,6 +97,20 @@ class MainTest {
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")
Expand Down
Loading