FileManager.kt
src/jvmMain/kotlin/afield/document/workspace/FileManager.kt · 669 lines
package and imports — 55 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Data")
package afield.document.workspace
import afield.data.collections.invoke
import afield.data.collections.s
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.named
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.geometry.mesh.expanded
import afield.geometry.mesh.normalized
import afield.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.linalg.x
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.normalized
import afield.media.image.open
import afield.runtime.lifecycle.cached
import afield.runtime.lifecycle.once
import afield.simulation.automata.history
import afield.audio.synth.*
import afield.data.collections.get
import afield.document.properties.clear
import afield.geometry.mesh.contains
import afield.graphics.vulkan.buffers.add
import afield.document.graph.Box
import afield.app.server.SessionInformation
import afield.app.server.ReadOnlyDocuments
import afield.document.workspace.listWorkspaceProjects
import afield.document.workspace.projectsDir
import afield.app.server.thisSessionInfo
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.Collections
import java.util.WeakHashMap
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicBoolean
import java.util.stream.Stream
Path resolution for the running Field session: turns a short spec into a real File or Path.
The files / fileManager properties (and the String.file, String.path,
String.absolutePath extensions) hand you the FieldFileManager for the current box or
session, which resolves, in order: absolute and ~/ paths; %document/… and %project/…;
any name registered with FileManager.add or FileManager["name"] = dir; globs, by walking
the search roots and taking the first sorted match; and otherwise a plain relative name looked
up in the document's files/ directory, then in the files/ directory of every workspace
project the document belongs to, and then in every directory put on the search list with
FileManager.add(name, dir, search = true), in the order they were added; finally, an exact
filename registered with FileManager.addFile.
Resolutions are cached per session and dropped when a directory or file registration changes, and a child path that would escape its root is rejected. Every lookup is appended to the session root's FILE_MANAGER_ACCESS_HISTORY_PROPERTY property, trimmed to FileManager.maxAccessHistoryEntries — so these calls need an active session or Box context and will error without one.
const val FILE_MANAGER_ACCESS_HISTORY_PROPERTY = "fileManagerAccessHistory"
The property on the session root that accumulates every file lookup this session has made — request, resolved path, which root answered it, whether it existed, and which box asked.
Reset when the session's file manager is created and trimmed to FileManager.maxAccessHistoryEntries, so it is a recent-activity log rather than a complete one. Read it when a path resolved somewhere you did not expect and you want to see where it actually went.
class FieldFileManager internal constructor(private val session: SessionInformation)
One session's view of the filesystem: hand it a short spec and it gives you a real File or Path.
Reach for it as files rather than constructing one. Resolution is tried in a fixed order — absolute and ~/
paths; %document/… and %project/…; any name registered with FileManager.add; a glob, matched by walking the
search roots and taking the first sorted match; and otherwise a plain relative name looked for in the
document's files/ directory, then in each workspace project the document belongs to, and then in each directory
registered with search = true (FileManager.searchNames, in order), then exact filenames registered
with FileManager.addFile. A name found nowhere resolves into the
document's files/, never into a shared search directory.
Two things worth knowing. A resolution is cached only if the file exists, so a name you resolve before
creating the file is re-resolved next time rather than being stuck; and a spec that would climb out of its root
is rejected outright, so %document/../.. throws rather than escaping.
Every lookup is recorded to FILE_MANAGER_ACCESS_HISTORY_PROPERTY, and every call needs a live session — there is no way to use this outside one.
class FieldFileManager internal constructor(private val session: SessionInformation) {
private val resolved = ConcurrentHashMap<String, FileResolution>()
private val sequence = AtomicLong(0)
private val reportedReadOnlyDirectory = AtomicBoolean(false)
private var seenGlobalVersion = FileManager.globalVersion()
init
init {
resetSessionAccessHistory()
}
val documentDirectory: Path
The directory this document lives in — a directory, not a single file, since a Field document is a folder.
val documentDirectory: Path
get() = File(session.filename).absoluteFile.toPath().normalize()
val documentFilesDirectory: Path
This document's own files/ directory, the first place a relative name is looked for.
Created on read if it does not exist, except in a read-only session, which returns the path without creating it. Merely asking for it can therefore have a side effect on disk.
val documentFilesDirectory: Path
get() = documentDirectory.resolve("files").normalize().also {
if (!Files.isDirectory(it)) {
if (session.readOnly || ReadOnlyDocuments.global) {
if (reportedReadOnlyDirectory.compareAndSet(false, true)) {
ReadOnlyDocuments.refuseWrite("FileManager.documentFilesDirectory", it.toString())
}
} else {
Files.createDirectories(it)
}
}
}
val projectFilesDirectories: List<Path>
The files/ directory of every workspace project this document belongs to — where a name not found in the
document itself is looked for next. Empty when the document is in no project.
Reading this creates the project directories, which live outside the document.
val projectFilesDirectories: List<Path>
get() = currentProjectFilesDirectories(create = true)
fun file(spec: String): File = resolveAndRecord(spec, "file").toFile()
Resolves a spec to a File. The everyday call, usually written as "data.csv".file.
The file need not exist: for a plain relative name that is nowhere to be found, you get the path it would
have inside the document's files/, which is what you want for something you are about to write. A glob or a
%name/… that matches nothing throws instead, since there is no sensible path to invent.
@throws IllegalStateException on a blank spec, an unmatched glob, an unknown %name, or a %project spec in a
document that is in no project — or in more than one, ambiguously
fun path(spec: String): Path = resolveAndRecord(spec, "path")
The same resolution as file, as an NIO Path. Prefer it when you are going on to use java.nio.file.
fun absolutePath(spec: String): String
The same resolution as file, as an absolute normalised String — for handing a path to a subprocess.
fun absolutePath(spec: String): String = resolveAndRecord(spec, "absolutePath").toAbsolutePath().normalize().toString()
fun clearCache()
Forgets this session's cached resolutions. Rarely needed: the cache already drops a resolution whose file has gone, and is cleared wholesale whenever a directory or file registration changes.
body — 201 lines, from line 168
fun clearCache() {
resolved.clear()
}
private fun resolveAndRecord(spec: String, access: String): Path {
val resolution = resolve(spec)
recordAccess(spec, access, resolution)
return resolution.path
}
private fun resolve(spec: String): FileResolution {
refreshGlobalStateIfNeeded()
val cleanSpec = spec.takeIf { it.isNotBlank() } ?: error("FileManager cannot resolve a blank path")
resolved[cleanSpec]?.takeIf { Files.exists(it.path) && FileManager.fallbackFile(cleanSpec) == null }?.let { return it }
resolved.remove(cleanSpec)
val resolution = when {
isAbsoluteLike(cleanSpec) -> FileResolution(expandHome(cleanSpec).toAbsolutePath().normalize(), "absolute")
cleanSpec.startsWith("%") -> resolveMappedPath(cleanSpec)
hasGlob(cleanSpec) -> searchFirst(
roots = searchRoots(),
pattern = cleanSpec
) ?: error("No file matched '$cleanSpec' in ${searchRoots().joinToString { it.toString() }}")
else -> resolveRelative(cleanSpec)
}
if (Files.exists(resolution.path)) resolved[cleanSpec] = resolution
return resolution
}
private fun resolveMappedPath(spec: String): FileResolution {
val (name, rest) = splitMappedPath(spec)
return when (name) {
"document" -> resolveInRoot(documentFilesDirectory, rest, spec)
"project" -> resolveProjectPath(rest, spec)
else -> {
val root = FileManager.namedDirectory(name)
?: error("No FileManager directory named '%$name'")
resolveInRoot(root, rest, spec, source = "%$name")
}
}
}
private fun resolveRelative(spec: String): FileResolution {
val roots = searchRoots()
for (root in roots) {
val candidate = resolveChild(root.path, spec)
if (Files.exists(candidate)) return FileResolution(candidate, root.source)
}
FileManager.fallbackFile(spec)?.takeIf { Files.isRegularFile(it) }?.let {
return FileResolution(it, "file:$spec")
}
return FileResolution(resolveChild(documentFilesDirectory, spec), "document")
}
private fun resolveProjectPath(rest: String, originalSpec: String): FileResolution {
val roots = currentProjectFileRoots(create = false)
if (roots.isEmpty()) {
error("'$originalSpec' needs a workspace project, but document '${session.documentName}' is not in one")
}
if (hasGlob(rest)) {
return searchFirst(roots, rest)
?: error("No file matched '$originalSpec' in ${roots.joinToString { it.toString() }}")
}
for (root in roots) {
val candidate = resolveChild(root.path, rest)
if (Files.exists(candidate)) return FileResolution(candidate, root.source)
}
if (roots.size > 1) {
error("'$originalSpec' is ambiguous because document '${session.documentName}' is in multiple projects")
}
val root = roots.single().also { Files.createDirectories(it.path) }
return FileResolution(resolveChild(root.path, rest), root.source)
}
private fun resolveInRoot(
root: Path,
rest: String,
originalSpec: String,
source: String = "document"
): FileResolution {
if (rest.isBlank()) return FileResolution(root, source)
if (hasGlob(rest)) {
return searchFirst(listOf(FileSearchRoot(source, root)), rest)
?: error("No file matched '$originalSpec' under $root")
}
return FileResolution(resolveChild(root, rest), source)
}
private fun searchRoots(): List<FileSearchRoot> {
return listOf(FileSearchRoot("document", documentFilesDirectory)) +
currentProjectFileRoots(create = false) +
listOfNotNull(KitInstances.sharedResources(documentDirectory.toFile())?.let { FileSearchRoot("kit", it.toPath()) }) +
FileManager.searchDirectories().map { (name, path) -> FileSearchRoot("search:$name", path) }
}
private fun currentProjectFilesDirectories(create: Boolean): List<Path> {
return currentProjectFileRoots(create).map { it.path }
}
private fun currentProjectFileRoots(create: Boolean): List<FileSearchRoot> {
val workspace = documentDirectory.parent ?: return emptyList()
val projects = runCatching { listWorkspaceProjects(workspace.toString()) }.getOrDefault(emptyList())
return projects
.filter { project -> project.documents.contains(session.documentName) }
.map { project ->
FileSearchRoot(
source = "project:${project.name}",
path = projectsDir(workspace.toString()).toPath().resolve(project.name).resolve("files").normalize()
)
}
.also { roots ->
if (create) roots.forEach { Files.createDirectories(it.path) }
}
}
private fun searchFirst(roots: List<FileSearchRoot>, pattern: String): FileResolution? {
val normalizedPattern = pattern.replace('\\', '/')
val alternatePattern = normalizedPattern.takeIf { it.startsWith("**/") }?.removePrefix("**/")
for (root in roots.distinctBy { it.path }) {
if (!Files.isDirectory(root.path)) continue
val matcher = root.path.fileSystem.getPathMatcher("glob:$normalizedPattern")
val alternateMatcher = alternatePattern?.let { root.path.fileSystem.getPathMatcher("glob:$it") }
walk(root.path).use { stream ->
val match = stream
.filter { Files.isRegularFile(it) }
.sorted(compareBy<Path> { it.toString() })
.filter { path ->
val relative = root.path.relativize(path)
matcher.matches(relative) || alternateMatcher?.matches(relative) == true
}
.findFirst()
if (match.isPresent) return FileResolution(match.get().toAbsolutePath().normalize(), root.source)
}
}
return null
}
private fun walk(root: Path): Stream<Path> {
return runCatching { Files.walk(root) }
.getOrElse { error("Couldn't search $root: ${it.message}") }
}
private fun resolveChild(root: Path, child: String): Path {
val cleanChild = child.replace('\\', '/').trimStart('/')
val target = root.resolve(cleanChild).normalize()
require(target.startsWith(root.normalize())) {
"Path '$child' escapes FileManager root $root"
}
return target
}
private fun refreshGlobalStateIfNeeded() {
val current = FileManager.globalVersion()
if (current != seenGlobalVersion) {
resolved.clear()
seenGlobalVersion = current
}
}
private fun resetSessionAccessHistory() {
synchronized(session.root) {
session.root.properties[FILE_MANAGER_ACCESS_HISTORY_PROPERTY] = emptyList<Map<String, Any?>>()
}
}
private fun recordAccess(spec: String, access: String, resolution: FileResolution) {
val box = Box.context.get()
val entry = linkedMapOf<String, Any?>(
"index" to sequence.incrementAndGet(),
"time" to System.currentTimeMillis(),
"access" to access,
"request" to spec,
"resolvedPath" to resolution.path.toAbsolutePath().normalize().toString(),
"source" to resolution.source,
"exists" to Files.exists(resolution.path),
"box" to box?.id,
"boxName" to box?.name
)
synchronized(session.root) {
val existing = session.root.properties[FILE_MANAGER_ACCESS_HISTORY_PROPERTY] as? List<*>
val next = (existing.orEmpty().filterIsInstance<Map<String, Any?>>() + entry)
.takeLast(FileManager.maxAccessHistoryEntries)
session.root.setPropertyRaw(FILE_MANAGER_ACCESS_HISTORY_PROPERTY, next, doResync = false)
}
session.markSidecarPending(session.root.id, FILE_MANAGER_ACCESS_HISTORY_PROPERTY)
}
private data class FileSearchRoot(val source: String, val path: Path)
private data class FileResolution(val path: Path, val source: String)
}
object FileManager
File-related utilities for the active AField session.
Resolves document/project-relative paths, named %directory mappings, globs,
and absolute or home-relative paths while keeping a small per-session cache.
object FileManager {
private val managers = Collections.synchronizedMap(WeakHashMap<SessionInformation, FieldFileManager>())
private val namedDirectories = ConcurrentHashMap<String, Path>()
private val searchOrder = mutableListOf<String>()
private val version = AtomicLong(0)
private val fallbackFiles = ConcurrentHashMap<String, Path>()
init
init {
val location = runCatching {
Paths.get(FileManager::class.java.protectionDomain.codeSource.location.toURI())
}.getOrNull()
location?.let(::bundledSlugExecutable)?.let { addFile("field-slug", it) }
location?.let(::bundledDocsDirectory)?.let { add("docs", it) }
}
fun addFile(name: String, file: Path): Path
Registers an exact bare filename as a process-global fallback after document, project and search directories. Returns the absolute normalized target. Re-registering replaces it and invalidates session caches. The target may not exist yet (for an optional helper built later); only regular files answer lookups. Names may include extensions but cannot be paths, globs, or explicit root selectors. This does not build, copy or execute the file. Globs do not search these registrations.
fun addFile(name: String, file: Path): Path {
validateFileName(name)
val target = file.toAbsolutePath().normalize()
fallbackFiles[name] = target
version.incrementAndGet()
return target
}
fun addFile(name: String, file: File): Path = addFile(name, file.toPath())
addFile with a File target.
fun addFile(name: String, file: String): Path = addFile(name, expandHome(file))
addFile with a string target; expands a leading ~/.
fun removeFile(name: String): Boolean
Removes an exact-file fallback and invalidates caches; leaves the target on disk.
fun removeFile(name: String): Boolean {
validateFileName(name)
val removed = fallbackFiles.remove(name) != null
if (removed) version.incrementAndGet()
return removed
}
internal fun fallbackFile(name: String): Path? = fallbackFiles[name]
private fun validateFileName(name: String) {
require(name.isNotBlank() && name == name.trim() && name != "." && name != ".." &&
name.none { it == '/' || it == '\\' || it == ':' || it == '\u0000' } &&
!name.startsWith("%") && !name.startsWith("~") && !hasGlob(name)) {
"Invalid FileManager fallback filename '$name'"
}
}
var maxAccessHistoryEntries: Int = 1024
How many lookups FILE_MANAGER_ACCESS_HISTORY_PROPERTY keeps before dropping the oldest. Applied to the whole list on the next lookup, so lowering it does trim the history already recorded.
fun current(): FieldFileManager
The file manager for whatever session is in scope right now — the current box's, or the ambient session's.
One manager per session, kept weakly, so it goes away with the session. This is what files and the String
extensions call.
@throws IllegalStateException when there is neither an active Box context nor a session — which is what you get calling this from a plain unit test or a bare thread
fun current(): FieldFileManager {
val box = Box.context.get()
val session = when {
box != null && box.isInsideInitialized() -> box.inside
else -> thisSessionInfo.get()
} ?: error("FileManager needs an active Field session or Box context")
return synchronized(managers) {
managers.getOrPut(session) { FieldFileManager(session) }
}
}
fun forSession(session: SessionInformation): FieldFileManager
The manager for a named session rather than the ambient one — for code holding a session explicitly.
fun forSession(session: SessionInformation): FieldFileManager {
return synchronized(managers) {
managers.getOrPut(session) { FieldFileManager(session) }
}
}
operator fun set(name: String, directory: String)
FileManager["textures"] = "~/Pictures" — registers a named directory, so %textures/wall.png resolves.
Sugar for add with create = false, so the directory must already exist. Registration is process-global
and outlives the document, and re-registering a name silently replaces it.
operator fun set(name: String, directory: String) {
add(name, directory)
}
operator fun set(name: String, directory: File)
FileManager["name"] = someFile — as the String overload, taking a File.
operator fun set(name: String, directory: File) {
add(name, directory)
}
operator fun set(name: String, directory: Path)
FileManager["name"] = somePath — as the String overload, taking a Path.
operator fun set(name: String, directory: Path) {
add(name, directory)
}
operator fun get(name: String): Path? = namedDirectory(name)
The directory registered under this name, or null if nothing is registered under it. The % prefix is
optional — get("x") and get("%x") agree.
A name is validated, not merely looked up, so this throws rather than returning null for a malformed
one and for the two reserved names the resolver owns: FileManager["document"] and
FileManager["project"] are IllegalArgumentException, not null.
fun add(name: String, directory: String, create: Boolean = false, search: Boolean = false): Path
add from a string path, with ~ expanded first.
fun add(name: String, directory: String, create: Boolean = false, search: Boolean = false): Path =
add(name, expandHome(directory), create, search)
fun add(name: String, directory: File, create: Boolean = false, search: Boolean = false): Path
add from a File.
fun add(name: String, directory: File, create: Boolean = false, search: Boolean = false): Path =
add(name, directory.toPath(), create, search)
fun add(name: String, directory: Path, create: Boolean = false, search: Boolean = false): Path
Registers directory under name so %name/… resolves into it, returning the absolute path stored.
Process-global and shared by every open document, so a name registered by one workspace affects them all, and
registering an existing name replaces it without warning. Pass create = true to make the directory rather
than require it.
Pass search = true to also put the directory on the search list, so a plain name like "kick.wav".file
(and a glob) looks in it after the document's and the projects' files/. Search directories are tried in the
order they were added, and the access history names the one that answered as search:<name>. A file found in
none of them still resolves into the document's files/. Re-registering a name replaces its search membership
too, so FileManager["name"] = dir takes a directory off the search list.
The name must look like an identifier (letters, digits, _, -, not starting with a digit); a leading % or
$ is stripped for you. document and project are reserved, since they are the built-in roots.
@throws IllegalArgumentException on a malformed or reserved name, or when the path is not a directory
fun add(name: String, directory: Path, create: Boolean = false, search: Boolean = false): Path {
val cleanName = validateName(name)
val root = directory.toAbsolutePath().normalize()
if (create) Files.createDirectories(root)
require(Files.isDirectory(root)) { "FileManager directory '$cleanName' is not a directory: $root" }
synchronized(searchOrder) {
namedDirectories[cleanName] = root
searchOrder.remove(cleanName)
if (search) searchOrder.add(cleanName)
version.incrementAndGet()
}
return root
}
fun remove(name: String): Boolean
Unregisters a named directory, returning whether there was one. Nothing on disk is touched.
@throws IllegalArgumentException on a malformed or reserved name, the same as add
fun remove(name: String): Boolean {
val cleanName = validateName(name)
return synchronized(searchOrder) {
val removed = namedDirectories.remove(cleanName) != null
searchOrder.remove(cleanName)
if (removed) version.incrementAndGet()
removed
}
}
val searchNames: List<String>
The names on the search list — registered with search = true — in the order a plain name tries them, after
the document's and the projects' files/. A snapshot; change it with add and remove.
val searchNames: List<String>
get() = synchronized(searchOrder) { searchOrder.toList() }
fun clearCache()
Clears every session's resolution cache. Directory and exact-file registration changes already do this.
fun clearCache() {
synchronized(managers) {
managers.values.forEach { it.clearCache() }
}
}
internal fun namedDirectory(name: String): Path? = namedDirectories[validateName(name)]
internal fun globalVersion(): Long = version.get()
internal fun searchDirectories(): List<Pair<String, Path>>
internal fun searchDirectories(): List<Pair<String, Path>> = synchronized(searchOrder) {
searchOrder.mapNotNull { name -> namedDirectories[name]?.let { name to it } }
}
private fun validateName(name: String): String {
val clean = name.removePrefix("%").removePrefix("$").trim()
require(clean.matches(Regex("[A-Za-z_][A-Za-z0-9_-]*"))) {
"Invalid FileManager directory name '$name'"
}
require(clean != "document" && clean != "project") {
"'$clean' is reserved by FileManager"
}
return clean
}
}
val files: FieldFileManager
The current session's FieldFileManager — the way into all of this from a box: files.file("data.csv").
Resolved per read, so it always belongs to the session you are actually in.
val files: FieldFileManager
get() = FileManager.current()
val fileManager: FieldFileManager
Alias for files, for code that reads better spelled out.
val fileManager: FieldFileManager
get() = FileManager.current()
val String.file: File
"data.csv".file — the terse form of files.file(this), and the idiom worth learning first.
Understands everything FieldFileManager does — a home-relative path, a %document/… or %project/… spec, a
registered %name, or a recursive glob. Throws on an unmatched glob or an unknown name; see FieldFileManager.file.
val String.file: File
get() = FileManager.current().file(this)
val String.path: Path
"data.csv".path — the same resolution as String.file, as an NIO Path.
val String.path: Path
get() = FileManager.current().path(this)
val String.absolutePath: String
"data.csv".absolutePath — the same resolution, as an absolute normalised string.
val String.absolutePath: String
get() = FileManager.current().absolutePath(this)
val String.abslutePath: String
A misspelling of absolutePath that works anyway.
Kept so that box code which typed it once keeps running, and deprecated rather than removed for the same reason: a live-coded document is a file on someone's disk, not a call site that can be recompiled. Does nothing different.
@Deprecated(
"Misspelling of absolutePath, kept only so existing box code keeps running.",
ReplaceWith("absolutePath"),
DeprecationLevel.WARNING
)
val String.abslutePath: String
get() = absolutePath
private fun splitMappedPath(spec: String): Pair<String, String> {
val withoutPrefix = spec.removePrefix("%")
val separator = withoutPrefix.indexOfAny(charArrayOf('/', '\\'))
if (separator < 0) return withoutPrefix to ""
return withoutPrefix.substring(0, separator) to withoutPrefix.substring(separator + 1)
}
private fun hasGlob(spec: String): Boolean {
return spec.any { it == '*' || it == '?' || it == '[' || it == '{' }
}
private fun isAbsoluteLike(spec: String): Boolean {
return spec.startsWith("~/") || Paths.get(spec).isAbsolute
}
private fun expandHome(spec: String): Path {
return if (spec == "~" || spec.startsWith("~/")) {
Paths.get(System.getProperty("user.home")).resolve(spec.removePrefix("~/")).normalize()
} else {
Paths.get(spec)
}
}
internal fun bundledSlugExecutable(codeLocation: Path): Path?
Locate optional build output from the loaded classes/JAR, never from a document or the working directory.
internal fun bundledSlugExecutable(codeLocation: Path): Path? {
var root: Path? = codeLocation.toAbsolutePath().normalize()
while (root != null) {
val helper = root.resolve("native/field-slug")
if (Files.isRegularFile(helper.resolve("build.sh"))) return helper.resolve("build/field-slug")
root = root.parent
}
return null
}
internal fun bundledDocsDirectory(codeLocation: Path): Path?
Bundled documentation root, located from the loaded classes/JAR rather than the caller's cwd.
internal fun bundledDocsDirectory(codeLocation: Path): Path? {
var root: Path? = codeLocation.toAbsolutePath().normalize()
while (root != null) {
val docs = root.resolve("public/docs")
if (Files.isRegularFile(docs.resolve("index.md")) && Files.isDirectory(docs.resolve("assets"))) return docs
root = root.parent
}
return null
}