///|
/// Workspace anchor: the directory that relative paths in tool calls
/// resolve against. One anchor is built per host from the launch directory
/// and threaded into every tool; the absolute root never appears in
/// model-visible prompts.
pub struct WorkspaceAnchor {
/// Absolute or process-relative root directory, normalized.
root : String
} derive(Debug)
///|
pub extend WorkspaceAnchor with @moonbitlang/core/debug.Debug::{to_repr}
///|
/// Build an anchor. `""` normalizes to `"."`; a trailing `/` is dropped
/// (except on `/` itself) so joined paths never contain `//` at the seam.
///
/// @devkit.WorkspaceAnchor::WorkspaceAnchor("/tmp/proj/").root,
/// test {
/// inspect(
/// WorkspaceAnchor::WorkspaceAnchor("/tmp/proj/").root,
/// content="/tmp/proj",
/// )
/// }
/// ```
pub fn WorkspaceAnchor::WorkspaceAnchor(root : String) -> WorkspaceAnchor {
// Lexical normalization also drops any trailing "/" (except on "/" itself).
let root = if root == "" { "." } else { normalize_lexical(root) }
{ root, }
}
///|
/// Resolve `path` against the anchor. Absolute paths pass through
/// unchanged; relative paths are joined onto the root and lexically
/// normalized (`.` and `..` collapse, `//` deduplicates). `..` may rise
/// above the root — containment is a permission concern, not an anchoring
/// one.
/// let anchor = @devkit.WorkspaceAnchor::WorkspaceAnchor("/tmp/proj")
/// ```mbt check
/// test {
/// let anchor = @devkit.WorkspaceAnchor::WorkspaceAnchor("/tmp/proj")
/// inspect(anchor.resolve("src/a/../b"), content="/tmp/proj/src/b")
/// inspect(anchor.resolve("../sibling"), content="/tmp/sibling")
/// }
/// ```
pub fn WorkspaceAnchor::resolve(
self : WorkspaceAnchor,
path : String,
) -> String {
if path.has_prefix("/") {
return path
}
normalize_lexical("\{self.root}/\{path}")
}
///|
/// Lexically normalize a joined path: drop `.` and empty segments, apply
/// `..` by popping; `..` above a relative start is retained, above an
/// absolute root it is dropped.
fn normalize_lexical(joined : String) -> String {
let absolute = joined.has_prefix("/")
let segments : Array[String] = []
for seg in joined.split("/") {
if seg == "" || seg == "." {
continue
}
if seg == ".." {
if segments.length() > 0 && segments[segments.length() - 1] != ".." {
ignore(segments.pop())
} else if !absolute {
segments.push("..")
}
continue
}
segments.push(seg.to_owned())
}
let body = segments.join("/")
if absolute {
"/\{body}"
} else if body == "" {
"."
} else {
body
}
}