///|
/// Immutable in-memory KV1 workspace. Operations return a new snapshot; hosts
/// decide how to present or save it. No multi-file filesystem atomicity is claimed.
struct Workspace {
documents : Map[String, SourceDocument]
options : ParseOptions
}
///|
fn workspace_path(name : String) -> String raise {
if name.is_empty() ||
name.has_prefix("/") ||
name.contains("\\") ||
name.contains(":") ||
name.contains("\u0000") ||
normalize_reference(name) != name {
raise InvalidReference("Workspace paths must be normalized relative names")
}
name
}
///|
pub fn new_workspace(
files : Map[String, String],
options? : ParseOptions = ParseOptions::default(),
) -> Workspace raise {
if files.is_empty() || files.length() > 64 {
raise InvalidReference("Workspace requires 1..64 files")
}
let documents : Map[String, SourceDocument] = Map([])
let mut total = 0
for name, text in files {
workspace_path(name) |> ignore
total += text.char_length()
if total > 16777216 {
raise InvalidReference("Workspace exceeds 16 million characters")
}
documents[name] = parse_preserving(text, options~)
}
{ documents, options, }
}
///|
pub fn Workspace::files(self : Workspace) -> Map[String, String] {
let files : Map[String, String] = Map([])
for name, document in self.documents {
files[name] = document.text()
}
files
}
///|
fn relative_reference(source : String, target : String) -> String {
let parent = source.split("/").map(s => s.to_owned()).collect()
parent.pop() |> ignore
let parts = target.split("/").map(s => s.to_owned()).collect()
let mut common = 0
while common < parent.length() &&
common < parts.length() &&
parent[common] == parts[common] {
common += 1
}
let result : Array[String] = []
for _ in common.. SourceDocument raise {
let changes : Array[Change] = []
let occurrences : Map[String, Int] = Map([])
for entry in document.doc.entries {
let occurrence = occurrences.get(entry.key).unwrap_or(0)
occurrences[entry.key] = occurrence + 1
if !entry.key.equal_ignore_ascii_case("#include") &&
!entry.key.equal_ignore_ascii_case("#base") {
continue
}
guard entry.value is Text(reference) else {
raise InvalidReference("Reference must have a text path")
}
let original_target = child_reference(old_name, reference)
let target = if moved_from == Some(original_target) {
moved_to.unwrap()
} else {
original_target
}
if target != original_target || old_name != new_name {
let path = relative_reference(new_name, target)
if path != reference {
changes.push({
path: [{ key: entry.key, occurrence, }],
operation: Replace(Text(path)),
})
}
}
}
if changes.is_empty() {
document
} else {
document.apply_snapshot(changes)
}
}
///|
/// Rename incoming references, including inactive branches, and rebase outgoing
/// references when the moved file changes directory.
pub fn Workspace::rename_file(
self : Workspace,
from : String,
to : String,
) -> Workspace raise {
workspace_path(from) |> ignore
workspace_path(to) |> ignore
if !self.documents.contains(from) {
raise InvalidReference("Source file does not exist")
}
if from == to {
return self
}
if self.documents.contains(to) {
raise InvalidReference("Destination already exists")
}
let files : Map[String, String] = Map([])
for name, document in self.documents {
let renamed = if name == from { to } else { name }
files[renamed] = rewritten_references(
document,
name,
renamed,
Some(from),
Some(to),
).text()
}
new_workspace(files, options=self.options)
}
///|
pub fn Workspace::copy_file(
self : Workspace,
from : String,
to : String,
) -> Workspace raise {
workspace_path(to) |> ignore
guard self.documents.get(from) is Some(document) else {
raise InvalidReference("Source file does not exist")
}
if self.documents.contains(to) {
raise InvalidReference("Destination already exists")
}
let files = self.files()
files[to] = rewritten_references(document, from, to, None, None).text()
new_workspace(files, options=self.options)
}
///|
pub fn Workspace::add_file(
self : Workspace,
name : String,
text : String,
) -> Workspace raise {
workspace_path(name) |> ignore
if self.documents.contains(name) {
raise InvalidReference("File already exists")
}
let files = self.files()
files[name] = text
new_workspace(files, options=self.options)
}
///|
pub fn Workspace::remove_file(
self : Workspace,
name : String,
) -> Workspace raise {
if !self.documents.contains(name) {
raise InvalidReference("File does not exist")
}
let files : Map[String, String] = Map([])
for source, document in self.documents {
if source == name {
continue
}
for entry in document.doc.entries {
if entry.key.equal_ignore_ascii_case("#include") ||
entry.key.equal_ignore_ascii_case("#base") {
guard entry.value is Text(path) else {
raise InvalidReference("Invalid file directive")
}
if child_reference(source, path) == name {
raise InvalidReference("File is still referenced by " + source)
}
}
}
files[source] = document.text()
}
new_workspace(files, options=self.options)
}
///|
pub fn Workspace::apply(self : Workspace, plan : Json) -> Workspace raise {
guard plan is Array(changes) && changes.length() <= 1000 else {
raise InvalidReference("Expected at most 1000 workspace operations")
}
let mut result = self
for change in changes {
guard change is Object(row) && row.get("op") is Some(String(op)) else {
raise InvalidReference("Invalid workspace operation")
}
result = match op {
"rename" | "copy" if row.length() == 3 &&
row.get("from") is Some(String(from)) &&
row.get("to") is Some(String(to)) =>
if op == "rename" {
result.rename_file(from, to)
} else {
result.copy_file(from, to)
}
"add" if row.length() == 3 &&
row.get("name") is Some(String(name)) &&
row.get("text") is Some(String(text)) => result.add_file(name, text)
"remove" if row.length() == 2 && row.get("name") is Some(String(name)) =>
result.remove_file(name)
_ => raise InvalidReference("Unknown workspace operation or fields")
}
}
result
}
///|
pub fn workspace_from_json(
json : Json,
options? : ParseOptions = ParseOptions::default(),
) -> Workspace raise {
guard json is Object(rows) else {
raise InvalidReference("Expected a workspace file map")
}
let files : Map[String, String] = Map([])
for name, value in rows {
guard value is String(text) else {
raise InvalidReference("Workspace file contents must be text")
}
files[name] = text
}
new_workspace(files, options~)
}
///|
pub fn Workspace::to_json(self : Workspace) -> Json {
let rows : Map[String, Json] = Map([])
for name, document in self.documents {
rows[name] = Json::string(document.text())
}
Json::object(rows)
}
///|
pub fn edit_workspace_snapshot(
snapshot : Json,
plan : Json,
options? : ParseOptions = ParseOptions::default(),
) -> Json raise {
guard snapshot is Object(fields) &&
fields.get("format") == Some(Json::string("moonvalve-project-v1")) &&
fields.get("source") is Some(String(source)) &&
fields.get("files") is Some(files) else {
raise InvalidReference("Expected MoonValve project snapshot")
}
guard plan is Array(changes) && changes.length() <= 1000 else {
raise InvalidReference("Expected bounded workspace plan")
}
let mut workspace = workspace_from_json(files, options~)
let mut entry = source
if !workspace.documents.contains(entry) {
raise InvalidReference("Snapshot entry is missing")
}
for change in changes {
workspace = workspace.apply(Json::array([change]))
if change is Object(row) &&
row.get("op") == Some(Json::string("rename")) &&
row.get("from") == Some(Json::string(entry)) &&
row.get("to") is Some(String(to)) {
entry = to
}
if !workspace.documents.contains(entry) {
let names = workspace.documents.keys().collect()
names.sort()
entry = names[0]
}
}
Json::object({
"format": Json::string("moonvalve-project-v1"),
"source": Json::string(entry),
"files": workspace.to_json(),
})
}