///|
/// Immutable mixed KV1/text KV3 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]
kv3_documents : Map[String, KV3SourceDocument]
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 kv3_documents : Map[String, KV3SourceDocument] = 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")
}
if lsp_is_kv3(name, text) {
kv3_documents[name] = parse_kv3_preserving(text, options~)
} else {
documents[name] = parse_preserving(text, options~)
}
}
{ documents, kv3_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()
}
for name, document in self.kv3_documents {
files[name] = document.text()
}
files
}
///|
fn Workspace::has_file(self : Workspace, name : String) -> Bool {
self.documents.contains(name) || self.kv3_documents.contains(name)
}
///|
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, Int32] = 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.has_file(from) {
raise InvalidReference("Source file does not exist")
}
if from == to {
return self
}
if self.has_file(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()
}
for name, document in self.kv3_documents {
files[if name == from { to } else { name }] = document.text()
}
new_workspace(files, options=self.options)
}
///|
pub fn Workspace::copy_file(
self : Workspace,
from : String,
to : String,
) -> Workspace raise {
workspace_path(to) |> ignore
if !self.has_file(from) {
raise InvalidReference("Source file does not exist")
}
if self.has_file(to) {
raise InvalidReference("Destination already exists")
}
let files = self.files()
files[to] = match self.documents.get(from) {
Some(document) =>
rewritten_references(document, from, to, None, None).text()
None => self.kv3_documents[from].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.has_file(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.has_file(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()
}
for source, document in self.kv3_documents {
if source != name {
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 {
"kv3-query" if row.length() == 4 &&
row.get("expression") is Some(String(expression)) &&
row.get("operation") is Some(operation) &&
row.get("files") is Some(files) =>
result.edit_query_kv3(
expression,
kv3_query_edit_from_json(operation),
files=workspace_selection(files),
)
"query" if row.length() == 4 &&
row.get("expression") is Some(String(expression)) &&
row.get("operation") is Some(operation) &&
row.get("files") is Some(files) =>
result.edit_query(
expression,
query_edit_from_json(operation),
files=workspace_selection(files),
)
"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, text in self.files() {
rows[name] = Json::string(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.has_file(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.has_file(entry) {
let names = workspace.files().keys().collect()
names.sort()
entry = names[0]
}
}
Json::object({
"format": Json::string("moonvalve-project-v1"),
"source": Json::string(entry),
"files": workspace.to_json(),
})
}
///|
fn workspace_selection(json : Json) -> Array[String] raise {
guard json is Array(values) && values.length() <= 64 else {
raise InvalidQuery("Expected at most 64 selected files")
}
values.map(value => {
guard value is String(name) else {
raise InvalidQuery("Expected selected file names")
}
name
})
}
///|
fn Workspace::selected_names(
self : Workspace,
files : Array[String],
) -> Array[String] raise {
let selected : Map[String, Bool] = Map([])
for name in files {
if !self.has_file(name) || selected.contains(name) {
raise InvalidQuery("Missing or duplicate selected file")
}
selected[name] = true
}
let names = if files.is_empty() {
self.files().keys().collect()
} else {
files.map(s => s)
}
names.sort_by((a, b) => a.lexical_compare(b))
names
}
///|
/// Empty file selection means all files. Results are ordered by file then source.
pub fn Workspace::select(
self : Workspace,
expression : String,
files~ : Array[String] = [],
) -> Json raise {
let query = compile_query(expression)
let rows : Array[Json] = []
let mut total = 0
for name in self.selected_names(files) {
if !self.documents.contains(name) {
if !files.is_empty() {
raise InvalidQuery("Selected file is KV3; use typed workspace queries")
}
continue
}
let matches = query.select(self.documents[name].doc)
total += matches.length()
if total > 10000 {
raise InvalidQuery("Workspace exceeds 10000 query matches")
}
if !matches.is_empty() {
rows.push(
Json::object({
"file": Json::string(name),
"matches": located_values_to_json(matches),
}),
)
}
}
Json::array(rows)
}
///|
/// Every edit addresses original file contents. Failure returns no new workspace.
pub fn Workspace::edit_query(
self : Workspace,
expression : String,
operation : QueryEdit,
files~ : Array[String] = [],
) -> Workspace raise {
self.select(expression, files~) |> ignore
let output = self.files()
for name in self.selected_names(files) {
if !self.documents.contains(name) {
continue
}
output[name] = self.documents[name].edit_query(expression, operation).text()
}
new_workspace(output, options=self.options)
}
///|
pub fn query_workspace_snapshot(
snapshot : Json,
expression : String,
files~ : Array[String] = [],
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(contents) else {
raise InvalidReference("Expected MoonValve project snapshot")
}
let workspace = workspace_from_json(contents, options~)
if !workspace.has_file(source) {
raise InvalidReference("Snapshot entry is missing")
}
workspace.select(expression, files~)
}