Files
ARCADIA-Studio/crates/oa-desktop/src/document.rs
T

649 lines
23 KiB
Rust

//! Accepted graphical and source state, committed and restored together.
//! Editor drafts and filesystem receipts belong to their respective adapters.
use oa_core::{
Command, Id, ModelError, Project, language, prepare_command,
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic},
validation::Severity,
};
use std::{fmt, sync::Arc};
const HISTORY_LIMIT: usize = 100;
const HISTORY_BYTES: usize = 32 * 1024 * 1024;
struct HistoryEntry {
snapshot: Arc<CompiledSourceProject>,
bytes: usize,
}
fn estimated_bytes(compiled: &CompiledSourceProject) -> usize {
compiled
.bundle
.files
.values()
.fold(0usize, |sum, text| sum.saturating_add(text.len()))
.saturating_add(
serde_json::to_vec(&compiled.decoded.project).map_or(usize::MAX, |bytes| bytes.len()),
)
}
#[derive(Debug)]
pub enum DocumentError {
Model(ModelError),
Source(Vec<SourceDiagnostic>),
}
impl DocumentError {
pub fn diagnostics(&self) -> &[SourceDiagnostic] {
match self {
Self::Model(_) => &[],
Self::Source(diagnostics) => diagnostics,
}
}
}
impl fmt::Display for DocumentError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Model(error) => error.fmt(formatter),
Self::Source(diagnostics) => {
if let Some(first) = diagnostics.first() {
write!(
formatter,
"{}:{}:{}: {}",
first.path,
first.diagnostic.line,
first.diagnostic.column,
first.diagnostic.message
)
} else {
formatter.write_str("Source validation failed.")
}
}
}
}
}
impl std::error::Error for DocumentError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Model(error) => Some(error),
Self::Source(_) => None,
}
}
}
impl From<ModelError> for DocumentError {
fn from(error: ModelError) -> Self {
Self::Model(error)
}
}
impl From<Vec<SourceDiagnostic>> for DocumentError {
fn from(diagnostics: Vec<SourceDiagnostic>) -> Self {
Self::Source(diagnostics)
}
}
pub struct Document {
current: Arc<CompiledSourceProject>,
past: Vec<HistoryEntry>,
future: Vec<HistoryEntry>,
current_bytes: usize,
saved: Arc<SourceBundle>,
revision: u64,
limits: ProjectLimits,
}
impl Document {
/// A valid generated workspace starts clean, like a new semantic Session.
pub fn from_project(project: Project, limits: ProjectLimits) -> Result<Self, DocumentError> {
oa_core::validate_candidate(&project)?;
let document = Self::from_bundle(source_project::format_bundle(&project), limits)?;
if document.project() != &project {
return Err(DocumentError::Model(ModelError(
"Generated source does not reproduce the supplied project.".into(),
)));
}
Ok(document)
}
/// Retain the exact input as the saved baseline. If compilation inserts
/// missing IDs, those accepted source changes still need saving.
pub fn from_bundle(bundle: SourceBundle, limits: ProjectLimits) -> Result<Self, DocumentError> {
let current = Arc::new(source_project::compile_bundle(&bundle, limits)?);
let current_bytes = estimated_bytes(&current);
Ok(Self {
current,
current_bytes,
past: Vec::new(),
future: Vec::new(),
saved: Arc::new(bundle),
revision: 0,
limits,
})
}
pub fn project(&self) -> &Project {
&self.current.decoded.project
}
pub fn bundle(&self) -> &SourceBundle {
&self.current.bundle
}
pub fn compiled(&self) -> &CompiledSourceProject {
&self.current
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn dirty(&self) -> bool {
self.bundle() != self.saved.as_ref()
}
pub fn can_undo(&self) -> bool {
!self.past.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.future.is_empty()
}
/// Capture this before asynchronous saving. Completion must return the same
/// snapshot, rather than marking whatever is current at completion as saved.
pub fn snapshot(&self) -> Arc<CompiledSourceProject> {
self.current.clone()
}
/// Records the bytes actually saved, even when later edits remain current.
/// The I/O adapter owns document identity and save completion ordering.
pub fn mark_saved(&mut self, snapshot: Arc<CompiledSourceProject>) {
self.saved = Arc::new(snapshot.bundle.clone());
}
pub fn execute(&mut self, command: Command) -> Result<Option<Id>, DocumentError> {
let candidate = prepare_command(self.project(), &command)?;
// Equal model values can still carry different record provenance, such
// as deleting and recreating an identical record with a reused UUID.
// Only the resulting model/source pair determines whether this is a no-op.
let compiled = source_project::update_bundle_with_identity_map(
&self.current,
&candidate.project,
&candidate.identity_map,
self.limits,
)?;
self.commit(compiled);
Ok(candidate.selected)
}
pub fn apply_bundle(&mut self, bundle: SourceBundle) -> Result<bool, DocumentError> {
let compiled = source_project::compile_bundle(&bundle, self.limits)?;
Ok(self.commit(compiled))
}
/// Public compiled fields are convenient data, not a validation capability.
/// Rebuild diagnostics/index and reject a stale or altered model/index pair.
pub fn apply_compiled(
&mut self,
compiled: CompiledSourceProject,
) -> Result<bool, DocumentError> {
let checked = source_project::compile_bundle(&compiled.bundle, self.limits)?;
if checked.bundle != compiled.bundle
|| checked.decoded.project != compiled.decoded.project
|| checked.index != compiled.index
{
return Err(DocumentError::Source(vec![SourceDiagnostic {
path: compiled.bundle.entry,
diagnostic: language::Diagnostic {
severity: Severity::Error,
code: "stale_source_index".into(),
object_id: None,
field: None,
line: 1,
column: 1,
message: "Compile the current documents before applying this source snapshot."
.into(),
span: language::Span::default(),
},
related: Vec::new(),
}]));
}
Ok(self.commit(checked))
}
pub fn undo(&mut self) -> bool {
let Some(previous) = self.past.pop() else {
return false;
};
self.future.push(HistoryEntry {
snapshot: std::mem::replace(&mut self.current, previous.snapshot),
bytes: std::mem::replace(&mut self.current_bytes, previous.bytes),
});
self.revision = self.revision.saturating_add(1);
true
}
pub fn redo(&mut self) -> bool {
let Some(next) = self.future.pop() else {
return false;
};
self.past.push(HistoryEntry {
snapshot: std::mem::replace(&mut self.current, next.snapshot),
bytes: std::mem::replace(&mut self.current_bytes, next.bytes),
});
self.revision = self.revision.saturating_add(1);
true
}
fn commit(&mut self, compiled: CompiledSourceProject) -> bool {
if compiled.bundle == self.current.bundle {
return false;
}
let bytes = estimated_bytes(&compiled);
self.past.push(HistoryEntry {
snapshot: std::mem::replace(&mut self.current, Arc::new(compiled)),
bytes: std::mem::replace(&mut self.current_bytes, bytes),
});
self.future.clear();
let mut retained = self.past.iter().fold(self.current_bytes, |sum, entry| {
sum.saturating_add(entry.bytes)
});
while !self.past.is_empty() && (self.past.len() > HISTORY_LIMIT || retained > HISTORY_BYTES)
{
retained = retained.saturating_sub(self.past.remove(0).bytes);
}
self.revision = self.revision.saturating_add(1);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use oa_core::{Kind, Layer};
fn document() -> Document {
Document::from_project(Project::empty(), ProjectLimits::default()).unwrap()
}
fn comment(bundle: &SourceBundle, text: &str) -> SourceBundle {
let mut changed = bundle.clone();
changed
.files
.get_mut(&changed.entry)
.unwrap()
.push_str(text);
changed
}
#[test]
fn creation_selects_the_single_uuid_persisted_in_source() {
let mut document = document();
let id = document
.execute(Command::AddElement {
kind: Kind::Activity,
layer: Layer::Oa,
name: "Assess incident".into(),
parent: None,
})
.unwrap()
.unwrap();
assert_eq!(document.project().elements.len(), 1);
assert_eq!(document.project().elements[&id].name, "Assess incident");
let location = &document.compiled().index.declarations[&id];
let declaration =
&document.bundle().files[&location.path][location.span.start..location.span.end];
assert!(declaration.contains(&id.to_string()));
let reopened =
source_project::compile_bundle(document.bundle(), ProjectLimits::default()).unwrap();
assert_eq!(reopened.decoded.project, *document.project());
assert_eq!(document.revision(), 1);
}
#[test]
fn rejected_compile_command_and_writeback_preserve_state_and_redo() {
let bundle = source_project::format_bundle(&Project::empty());
let bytes = bundle.files.values().map(String::len).sum::<usize>();
let mut limits = ProjectLimits::default();
limits.model.max_source_bytes = bytes + 100;
let mut document = Document::from_bundle(bundle, limits).unwrap();
document
.apply_bundle(comment(document.bundle(), "// preserved future\n"))
.unwrap();
assert!(document.undo());
let before = document.snapshot();
let revision = document.revision();
let history = (document.past.len(), document.future.len());
let malformed = comment(document.bundle(), "?");
assert!(document.apply_bundle(malformed).is_err());
assert!(document.execute(Command::Delete(Id::new_v4())).is_err());
let error = document
.execute(Command::Rename(document.project().id, "x".repeat(200)))
.unwrap_err();
assert!(!error.diagnostics().is_empty());
assert!(error.to_string().contains(&document.bundle().entry));
assert!(Arc::ptr_eq(&before, &document.snapshot()));
assert_eq!(document.revision(), revision);
assert_eq!((document.past.len(), document.future.len()), history);
assert!(!document.dirty());
assert!(document.redo());
}
#[test]
fn comment_and_semantic_history_restore_exact_bytes_and_branch_together() {
let mut document = document();
let initial = document.snapshot();
let annotated = comment(
document.bundle(),
"\r\n// Project rationale: preserve me.\r\n",
);
assert!(document.apply_bundle(annotated.clone()).unwrap());
assert_eq!(document.project(), &initial.decoded.project);
assert_eq!(document.revision(), 1);
document
.execute(Command::Rename(
document.project().id,
"Rescue workspace".into(),
))
.unwrap();
let renamed = document.snapshot();
assert!(document.undo());
assert_eq!(document.bundle(), &annotated);
assert!(document.undo());
assert_eq!(document.bundle(), &initial.bundle);
assert!(!document.dirty());
assert!(document.redo());
assert_eq!(document.bundle(), &annotated);
assert!(document.redo());
assert_eq!(document.bundle(), &renamed.bundle);
assert!(document.undo());
let branch = comment(document.bundle(), "// A different source-only decision.\n");
assert!(document.apply_bundle(branch.clone()).unwrap());
assert!(!document.can_redo());
assert_eq!(document.project(), &initial.decoded.project);
assert!(document.undo());
assert_eq!(document.bundle(), &annotated);
assert!(document.redo());
assert_eq!(document.bundle(), &branch);
}
#[test]
fn nested_cross_file_reparent_and_source_edit_share_exact_history() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: [
(
"project.arcadia".into(),
"arcadia 3; project rescue \"Rescue\" { include \"oa/source.arcadia\"; include \"oa/target.arcadia\"; }\n".into(),
),
(
"oa/source.arcadia".into(),
"// Source module\r\noa_activity respond \"Respond\" {\r\n // Assessment rationale follows this activity.\r\n oa_activity assess \"Assess\" {}\r\n}\r\n".into(),
),
(
"oa/target.arcadia".into(),
"// Destination module\noa_activity coordinate \"Coordinate\" {}\n".into(),
),
].into(),
};
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
document.mark_saved(document.snapshot());
let original = document.snapshot();
let id = |alias: &str| {
original
.decoded
.project
.elements
.values()
.find(|element| element.alias == alias)
.unwrap()
.id
};
let child = id("assess");
let destination = id("coordinate");
document
.execute(Command::Reparent {
element: child,
parent: Some(destination),
})
.unwrap();
let moved = document.snapshot();
assert_eq!(
document.project().elements[&child].parent,
Some(destination)
);
assert_eq!(
document.compiled().index.declarations[&child].path,
"oa/target.arcadia"
);
assert!(
document.bundle().files["oa/target.arcadia"]
.contains("// Assessment rationale follows this activity.")
);
assert!(
!document.bundle().files["oa/source.arcadia"]
.contains("// Assessment rationale follows this activity.")
);
let edited = comment(document.bundle(), "// Reviewed containment decision.\n");
assert!(document.apply_bundle(edited.clone()).unwrap());
assert_eq!(document.project(), &moved.decoded.project);
assert!(document.undo());
assert_eq!(document.bundle(), &moved.bundle);
assert!(document.undo());
assert_eq!(document.bundle(), &original.bundle);
assert_eq!(document.project(), &original.decoded.project);
assert!(!document.dirty());
assert!(document.redo());
assert_eq!(document.bundle(), &moved.bundle);
assert!(document.redo());
assert_eq!(document.bundle(), &edited);
let accepted = document.snapshot();
let invalid = comment(document.bundle(), "invalid_declaration;");
assert!(document.apply_bundle(invalid).is_err());
assert!(Arc::ptr_eq(&accepted, &document.snapshot()));
let reopened =
Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), document.project());
assert_eq!(reopened.bundle(), document.bundle());
assert!(!reopened.dirty());
}
#[test]
fn recreated_equal_record_does_not_inherit_deleted_source_ownership() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: [
(
"project.arcadia".into(),
"arcadia 3; project rescue \"Rescue\" { include \"authored.arcadia\"; }\n"
.into(),
),
(
"authored.arcadia".into(),
"// Rationale for the original record.\noa_activity assess \"Assess\" {}\n"
.into(),
),
]
.into(),
};
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
document.mark_saved(document.snapshot());
let before = document.snapshot();
let element = document.project().elements.values().next().unwrap().clone();
let id = element.id;
document
.execute(Command::Batch(vec![
Command::Delete(id),
Command::CreateElement(element),
]))
.unwrap();
assert_eq!(document.project(), &before.decoded.project);
assert_ne!(
document.compiled().index.declarations[&id].path,
"authored.arcadia"
);
assert!(
document.bundle().files["authored.arcadia"]
.contains("// Rationale for the original record.")
);
assert!(!document.bundle().files["authored.arcadia"].contains("oa_activity"));
assert!(document.dirty());
assert_eq!(document.revision(), 1);
let recreated = document.snapshot();
assert!(document.undo());
assert_eq!(document.bundle(), &before.bundle);
assert!(!document.dirty());
assert!(document.redo());
assert_eq!(document.bundle(), &recreated.bundle);
}
#[test]
fn asynchronous_save_marks_its_snapshot_without_cleaning_later_edits() {
let mut document = document();
document
.apply_bundle(comment(document.bundle(), "// Saved rationale.\n"))
.unwrap();
let saving = document.snapshot();
document
.apply_bundle(comment(document.bundle(), "// Later unsaved rationale.\n"))
.unwrap();
document.mark_saved(saving);
assert!(document.dirty());
assert!(document.undo());
assert!(!document.dirty());
assert!(document.redo());
assert!(document.dirty());
}
#[test]
fn no_op_command_or_source_apply_preserves_history_revision_and_redo() {
let mut document = document();
assert!(!document.undo());
assert!(!document.redo());
document
.apply_bundle(comment(document.bundle(), "// Future edit.\n"))
.unwrap();
assert!(document.undo());
let before = document.snapshot();
let revision = document.revision();
document
.execute(Command::Rename(
document.project().id,
document.project().name.clone(),
))
.unwrap();
assert!(!document.apply_bundle(document.bundle().clone()).unwrap());
assert!(
!document
.apply_compiled(document.compiled().clone())
.unwrap()
);
assert!(Arc::ptr_eq(&before, &document.snapshot()));
assert_eq!(document.revision(), revision);
assert!(document.can_redo());
assert!(!document.can_undo());
}
#[test]
fn altered_compiled_model_or_index_is_rejected_without_mutation() {
let mut document = document();
let before = document.snapshot();
let mut forged = document.compiled().clone();
forged.decoded.project.name = "Not in the source".into();
assert!(document.apply_compiled(forged).is_err());
let mut forged = document.compiled().clone();
forged.index.declarations.clear();
let error = document.apply_compiled(forged).unwrap_err();
assert_eq!(error.diagnostics()[0].diagnostic.code, "stale_source_index");
assert!(Arc::ptr_eq(&before, &document.snapshot()));
assert_eq!(document.revision(), 0);
assert!(!document.can_undo());
}
#[test]
fn omitted_id_stabilization_remains_dirty_until_exact_source_is_saved() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: [(
"project.arcadia".into(),
"arcadia 3; project rescue \"Rescue\" {}\n".into(),
)]
.into(),
};
let mut document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap();
assert!(document.dirty());
assert_ne!(document.bundle(), &bundle);
let id = document.project().id;
assert!(!document.apply_bundle(document.bundle().clone()).unwrap());
assert_eq!(document.project().id, id);
document.mark_saved(document.snapshot());
assert!(!document.dirty());
}
#[test]
fn accepted_history_keeps_the_latest_hundred_complete_snapshots() {
let mut document = document();
for change in 0..105 {
document
.apply_bundle(comment(
document.bundle(),
&format!("// Change {change}.\n"),
))
.unwrap();
}
assert_eq!(document.past.len(), HISTORY_LIMIT);
let mut undone = 0;
while document.undo() {
undone += 1;
}
assert_eq!(undone, HISTORY_LIMIT);
assert_eq!(
document.bundle().files[&document.bundle().entry]
.matches("// Change")
.count(),
5
);
while document.redo() {}
assert_eq!(document.past.len(), HISTORY_LIMIT);
assert_eq!(document.revision(), 305);
}
#[test]
fn invalid_project_is_rejected_before_format_can_normalize_it() {
let mut project = Project::empty();
project.schema_version = 17;
assert!(Document::from_project(project, ProjectLimits::default()).is_err());
}
#[test]
fn byte_budget_trims_oldest_history_without_discarding_a_large_current() {
let mut document = document();
let rationale = format!("// {}\n", "x".repeat(11 * 1024 * 1024));
document
.apply_bundle(comment(document.bundle(), &rationale))
.unwrap();
document
.apply_bundle(comment(document.bundle(), "// Second version\n"))
.unwrap();
document
.apply_bundle(comment(document.bundle(), "// Third version\n"))
.unwrap();
assert_eq!(document.past.len(), 1);
let before = document.snapshot();
assert!(document.undo());
assert!(!document.can_undo());
assert!(document.redo());
assert!(Arc::ptr_eq(&before, &document.snapshot()));
let large = format!("// {}\n", "y".repeat(22 * 1024 * 1024));
document
.apply_bundle(comment(document.bundle(), &large))
.unwrap();
assert!(document.current_bytes > HISTORY_BYTES);
assert!(!document.can_undo());
assert!(document.bundle().files[&document.bundle().entry].ends_with(&large));
}
}