diff --git a/crates/oa-core/src/language/analysis_source.rs b/crates/oa-core/src/language/analysis_source.rs index 6db10341..dec41849 100644 --- a/crates/oa-core/src/language/analysis_source.rs +++ b/crates/oa-core/src/language/analysis_source.rs @@ -3,9 +3,11 @@ mod budget; mod cursor; mod declarations; +mod index; mod lex; mod lower; mod syntax; +mod update; mod write; #[cfg(test)] diff --git a/crates/oa-core/src/language/analysis_source/budget.rs b/crates/oa-core/src/language/analysis_source/budget.rs index 82c02d11..fef55596 100644 --- a/crates/oa-core/src/language/analysis_source/budget.rs +++ b/crates/oa-core/src/language/analysis_source/budget.rs @@ -143,6 +143,14 @@ impl Budget { } Ok(()) } + /// Aggregate owned text for an index/update phase, checked before copying. + pub fn index_bytes(&self, count: usize, span: Span) -> Result<(), Error> { + self.active()?; + if count > self.limits.bytes { + return Err(error(span, "Source8 index/output byte limit exceeded.")); + } + Ok(()) + } pub fn lowering_occurrences(&self, count: usize, span: Span) -> Result<(), Error> { self.active()?; // Parameter declarations are syntax entries, not reference uses. diff --git a/crates/oa-core/src/language/analysis_source/index.rs b/crates/oa-core/src/language/analysis_source/index.rs new file mode 100644 index 00000000..fcef16f3 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/index.rs @@ -0,0 +1,646 @@ +//! Temporary typed source witnesses. No arena index becomes a model identity. +//! The staged entry point parses each supplied typed file once. A future bundle +//! parser can pass its already parsed units/tokens to the private builder. +use super::cursor::Cursor; +use super::lower::{self, Child, Field, Lowered, Role, Target, Unit}; +use super::{Budget, Error, Span, declarations, error, lex}; +use crate::{Id, Project, analysis as a}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +/// Persistent structural path: cloning shares ancestors, and equality/order +/// compare the complete edge sequence, never allocation or arena identities. +/// Depth is checked against the inherited frame limit before allocation. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)] +pub(super) struct Path(Option>); +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +struct Step { + parent: Path, + edge: Child, +} +impl Path { + pub fn edges(&self) -> Vec { + let mut result = Vec::new(); + let mut path = self; + while let Some(step) = &path.0 { + result.push(step.edge); + path = &step.parent; + } + result.reverse(); + result + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct ExpressionKey { + pub owner: Id, + pub field: Field, + pub path: Path, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum TokenRole { + Declaration, + Use(Role), +} +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct OccurrenceKey { + pub owner: Id, + /// None is a global declaration header, not a binding field. + pub field: Option, + /// None is outside an expression; Some(empty) is its root call site. + pub path: Option, + pub role: TokenRole, + pub target: Target, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct Location { + pub file: usize, + pub span: Span, +} + +#[derive(Debug)] +pub(super) struct Index<'s> { + // Private provenance: callers can inspect locations, not supply replacements + // for the source witness, keys, catalog or occurrence spans. + sources: &'s BTreeMap, + names: BTreeMap, + occurrences: BTreeMap, + expressions: BTreeMap, +} +impl<'s> Index<'s> { + pub fn sources(&self) -> &BTreeMap { + self.sources + } + pub fn names(&self) -> &BTreeMap { + &self.names + } + pub fn occurrences(&self) -> &BTreeMap { + &self.occurrences + } + pub fn expressions(&self) -> &BTreeMap { + &self.expressions + } + + /// Typed-only staged adapter. `ordinary` has already been parsed by its + /// owner. Missing typed IDs fail in lowering; this never invents UUIDs. + /// The caller merges the returned additions and admits the complete model. + pub fn parse( + ordinary: &Project, + sources: &'s BTreeMap, + budget: &mut Budget, + ) -> Result<(Self, Lowered), lower::Failure> { + let mut units = Vec::new(); + let mut witnesses = BTreeMap::new(); + for (&file, source) in sources { + let failure = |error| lower::Failure { + file: Some(file), + error, + }; + budget + .lowering_entries(units.len() + 1, Span::default()) + .map_err(failure)?; + let tokens = lex::tokens(source, budget).map_err(failure)?; + let mut cursor = Cursor::new(source, &tokens, budget); + let mut parsed = Vec::new(); + while cursor.cursor < tokens.len() { + parsed.push(declarations::parse(&mut cursor, 8, None).map_err(failure)?); + } + units.push(Unit { + file, + declarations: parsed, + }); + witnesses.insert(file, tokens); + } + let lowered = lower::lower(ordinary, &units, budget)?; + // A build failure after our own parse/lower is a phase/invariant error, + // not an invented file assignment for an ambiguous numeric span. + let index = Self::build(ordinary, sources, &units, &witnesses, &lowered, budget) + .map_err(|error| lower::Failure { file: None, error })?; + Ok((index, lowered)) + } + + fn build( + ordinary: &Project, + sources: &'s BTreeMap, + units: &[Unit], + witnesses: &BTreeMap>, + lowered: &Lowered, + budget: &mut Budget, + ) -> Result { + budget.active()?; + budget.lowering_entries(lowered.expressions.len(), Span::default())?; + budget.lowering_occurrences(lowered.occurrences.len(), Span::default())?; + let mut names = BTreeMap::new(); + let mut name_bytes = 0usize; + let mut insert_name = |target, name: &str| -> Result<(), Error> { + budget.lowering_entries(names.len() + 1, Span::default())?; + name_bytes = name_bytes + .checked_add(name.len()) + .ok_or_else(|| error(Span::default(), "Index name size overflow."))?; + budget.index_bytes(name_bytes, Span::default())?; + if names.insert(target, name.to_owned()).is_some() { + return Err(error( + Span::default(), + "Duplicate qualified catalog identity.", + )); + } + Ok(()) + }; + // Lowering has already bounded and validated the complete ordinary + // identity catalog before this existing convenience visitor allocates. + for (id, alias) in ordinary.identities() { + insert_name(Target::Global(id), alias)?; + } + for definition in ordinary + .definitions + .values() + .chain(lowered.definitions.values()) + { + if lowered.definitions.contains_key(&definition.id) { + insert_name(Target::Global(definition.id), &definition.alias)?; + } + if let a::DefinitionBody::Calculation(calculation) = &definition.body { + for parameter in &calculation.parameters { + insert_name( + Target::Parameter { + calculation: definition.id, + parameter: parameter.id, + }, + ¶meter.name, + )?; + } + } + } + for element in lowered.elements.values() { + insert_name(Target::Global(element.id), &element.alias)?; + } + let mut index = Self { + sources, + names, + occurrences: BTreeMap::new(), + expressions: BTreeMap::new(), + }; + let mut owners = BTreeMap::new(); + let mut physical = BTreeSet::new(); + for unit in units { + for declaration in &unit.declarations { + let owner = declaration.header.identity.value.ok_or_else(|| { + error( + declaration.span, + "Missing ID requires source stabilization.", + ) + })?; + let location = Location { + file: unit.file, + span: declaration.span, + }; + checked_source(sources, location)?; + budget.lowering_entries(owners.len() + 1, declaration.span)?; + if owners.insert(owner, location).is_some() { + return Err(error(declaration.span, "Duplicate source owner.")); + } + let key = OccurrenceKey { + owner, + field: None, + path: None, + role: TokenRole::Declaration, + target: Target::Global(owner), + }; + index.insert( + key, + Location { + file: unit.file, + span: declaration.header.alias.span, + }, + witnesses, + &mut physical, + budget, + )?; + } + } + // Parent-before-child is part of the lowering contract. One forward + // pass rejects forward, cyclic, cross-file/owner/field and duplicate + // paths. Each node allocates one shared edge, not a copied path vector. + let mut paths: Vec<(ExpressionKey, usize)> = Vec::new(); + for (position, site) in lowered.expressions.iter().enumerate() { + if !matches!( + site.field, + Field::Body | Field::Base | Field::Predicate | Field::Override(_) + ) { + return Err(error( + site.span, + "Expression occurs outside a binding field.", + )); + } + let location = Location { + file: site.file, + span: site.span, + }; + checked_source(sources, location)?; + let owner = owners + .get(&site.owner) + .ok_or_else(|| error(site.span, "Unknown expression owner."))?; + contained(*owner, location)?; + let (path, depth) = if let Some((parent, edge)) = site.parent { + if parent >= position { + return Err(error( + site.span, + "Expression parent must precede its child.", + )); + } + let ancestor = &lowered.expressions[parent]; + if ancestor.owner != site.owner + || ancestor.field != site.field + || ancestor.file != site.file + { + return Err(error( + site.span, + "Expression parent crosses its owner, field or file.", + )); + } + contained( + Location { + file: ancestor.file, + span: ancestor.span, + }, + location, + )?; + if ancestor.span == site.span { + return Err(error( + site.span, + "Child expression must be a proper source subtree.", + )); + } + let depth = paths[parent].1 + 1; + budget.frame(depth, site.span)?; + ( + Path(Some(Arc::new(Step { + parent: paths[parent].0.path.clone(), + edge, + }))), + depth, + ) + } else { + budget.frame(1, site.span)?; + (Path::default(), 1) + }; + let key = ExpressionKey { + owner: site.owner, + field: site.field, + path, + }; + if index.expressions.insert(key.clone(), location).is_some() { + return Err(error(site.span, "Duplicate qualified expression path.")); + } + paths.push((key, depth)); + } + for occurrence in &lowered.occurrences { + let location = Location { + file: occurrence.file, + span: occurrence.span, + }; + let owner = owners + .get(&occurrence.owner) + .ok_or_else(|| error(occurrence.span, "Unknown occurrence owner."))?; + contained(*owner, location)?; + let path = match occurrence.expression { + Some(site) => { + let expression = lowered + .expressions + .get(site) + .ok_or_else(|| error(occurrence.span, "Unknown occurrence expression."))?; + if expression.owner != occurrence.owner + || expression.field != occurrence.field + || expression.file != occurrence.file + { + return Err(error( + occurrence.span, + "Occurrence crosses its expression owner, field or file.", + )); + } + contained( + Location { + file: expression.file, + span: expression.span, + }, + location, + )?; + Some(paths[site].0.path.clone()) + } + None => None, + }; + match (occurrence.role, occurrence.target) { + ( + Role::ParameterDeclaration, + Target::Parameter { + calculation, + parameter, + }, + ) if calculation == occurrence.owner + && occurrence.field == Field::Input(parameter) + && path.is_none() => {} + (Role::ParameterUse, Target::Parameter { calculation, .. }) + if calculation == occurrence.owner + && occurrence.field == Field::Body + && path.is_some() => {} + (Role::ArgumentLabel, Target::Parameter { .. }) if path.is_some() => {} + (Role::OverrideTarget, Target::Global(id)) + if occurrence.field == Field::Override(id) && path.is_none() => {} + (Role::CallTarget, Target::Global(_)) if path.is_some() => {} + (Role::Reference, Target::Global(_)) => {} + _ => return Err(error(occurrence.span, "Invalid qualified occurrence role.")), + } + let key = OccurrenceKey { + owner: occurrence.owner, + field: Some(occurrence.field), + path, + role: TokenRole::Use(occurrence.role), + target: occurrence.target, + }; + index.insert(key, location, witnesses, &mut physical, budget)?; + } + // Argument labels belong to this precise call site and its callee, not + // merely to a parameter UUID/name present somewhere in the catalog. + let mut calls = BTreeMap::new(); + for (key, at) in &index.occurrences { + if key.role == TokenRole::Use(Role::CallTarget) { + let site = (key.owner, key.field, key.path.clone()); + if calls.insert(site, key.target).is_some() { + return Err(error(at.span, "Multiple callees at one expression site.")); + } + } + } + for (key, at) in &index.occurrences { + if let (TokenRole::Use(Role::ArgumentLabel), Target::Parameter { calculation, .. }) = + (key.role, key.target) + && calls.get(&(key.owner, key.field, key.path.clone())) + != Some(&Target::Global(calculation)) + { + return Err(error( + at.span, + "Argument label crosses its called calculation.", + )); + } + } + for (position, site) in lowered.expressions.iter().enumerate() { + if let Some((parent, Child::Argument(parameter))) = site.parent { + let key = &paths[parent].0; + let Some(Target::Global(calculation)) = + calls.get(&(key.owner, Some(key.field), Some(key.path.clone()))) + else { + return Err(error(site.span, "Argument child has no owning call.")); + }; + let label = OccurrenceKey { + owner: site.owner, + field: Some(site.field), + path: Some(key.path.clone()), + role: TokenRole::Use(Role::ArgumentLabel), + target: Target::Parameter { + calculation: *calculation, + parameter, + }, + }; + if !index.occurrences.contains_key(&label) || paths[position].0.path == key.path { + return Err(error(site.span, "Argument child has no qualified label.")); + } + } + } + index.check_syntax_edges(units, lowered, budget)?; + Ok(index) + } + + /// Check structural edges against the already parsed syntax, not just the + /// arena's internally consistent claims. This is one iterative tree walk, + /// with indexed span lookup; it does not lex or copy source/path strings. + fn check_syntax_edges<'a>( + &self, + units: &'a [Unit], + lowered: &Lowered, + budget: &Budget, + ) -> Result<(), Error> { + let mut globals = BTreeMap::new(); + let mut parameters = BTreeMap::new(); + for (target, name) in &self.names { + match target { + Target::Global(id) => { + globals.insert(name.as_str(), *id); + } + Target::Parameter { + calculation, + parameter, + } => { + parameters.insert((*calculation, name.as_str()), *parameter); + } + } + } + let mut sites = BTreeMap::new(); + for (position, site) in lowered.expressions.iter().enumerate() { + if sites + .insert( + ( + site.file, + site.owner, + site.field, + site.span.start, + site.span.end, + ), + position, + ) + .is_some() + { + return Err(error(site.span, "Duplicate expression source ownership.")); + } + } + let mut work = Vec::new(); + let mut root = |file, owner, field, binding: &'a super::Binding| -> Result<(), Error> { + if let super::Binding::Expression(expression) = binding { + budget.lowering_entries(work.len() + 1, expression.span)?; + work.push((file, owner, field, expression, None)); + } + Ok(()) + }; + for unit in units { + for declaration in &unit.declarations { + let owner = declaration.header.identity.value.expect("validated owner"); + match &declaration.body { + declarations::Body::Calculation { body, .. } => { + root(unit.file, owner, Field::Body, &body.value)? + } + declarations::Body::Property { base, .. } => { + root(unit.file, owner, Field::Base, &base.value)? + } + declarations::Body::Check { predicate, .. } => { + root(unit.file, owner, Field::Predicate, &predicate.value)? + } + declarations::Body::Case { overrides, .. } => { + for entry in &overrides.value { + let target = + *globals.get(entry.target.name.as_str()).ok_or_else(|| { + error(entry.target.span, "Unknown syntax override target.") + })?; + root(unit.file, owner, Field::Override(target), &entry.binding)?; + } + } + _ => {} + } + } + } + let mut visited = 0usize; + while let Some((file, owner, field, expression, parent)) = work.pop() { + budget.lowering_entries(visited + 1, expression.span)?; + visited += 1; + let site = *sites + .get(&( + file, + owner, + field, + expression.span.start, + expression.span.end, + )) + .ok_or_else(|| { + error( + expression.span, + "Parsed expression is absent from the source arena.", + ) + })?; + if lowered.expressions[site].parent != parent { + return Err(error( + expression.span, + "Arena parent/child edge differs from original syntax.", + )); + } + let mut child = |value: &'a super::Expression, edge| -> Result<(), Error> { + budget.lowering_entries(work.len() + 1, value.span)?; + work.push((file, owner, field, value, Some((site, edge)))); + Ok(()) + }; + use super::ExpressionKind as E; + match &expression.kind { + E::List(items) => { + for (index, item) in items.iter().enumerate() { + child(item, Child::Item(index))?; + } + } + E::Unary { operand, .. } => child(operand, Child::Operand)?, + E::Binary { left, right, .. } => { + child(left, Child::Left)?; + child(right, Child::Right)?; + } + E::If { + condition, + when_true, + when_false, + } => { + child(condition, Child::Condition)?; + child(when_true, Child::WhenTrue)?; + child(when_false, Child::WhenFalse)?; + } + E::Call { + definition, + arguments, + } => { + let calculation = *globals + .get(definition.name.as_str()) + .ok_or_else(|| error(definition.span, "Unknown syntax call target."))?; + for argument in arguments { + let parameter = *parameters + .get(&(calculation, argument.parameter.name.as_str())) + .ok_or_else(|| { + error(argument.parameter.span, "Unknown syntax call parameter.") + })?; + child(&argument.value, Child::Argument(parameter))?; + } + } + E::Sum { values, initial } => { + child(values, Child::Values)?; + child(initial, Child::Initial)?; + } + E::Power { base, .. } => child(base, Child::Base)?, + E::AsKind { value, .. } => child(value, Child::Value)?, + _ => {} + } + } + if visited != lowered.expressions.len() { + return Err(error( + Span::default(), + "Arena contains expressions absent from parsed syntax.", + )); + } + Ok(()) + } + + fn insert( + &mut self, + key: OccurrenceKey, + location: Location, + witnesses: &BTreeMap>, + physical: &mut BTreeSet<(usize, usize, usize)>, + budget: &Budget, + ) -> Result<(), Error> { + budget.lowering_occurrences(self.occurrences.len() + 1, location.span)?; + let text = checked_source(self.sources, location)?; + if self.names.get(&key.target).is_none_or(|name| name != text) { + return Err(error( + location.span, + "Token differs from its qualified target name.", + )); + } + let tokens = witnesses + .get(&location.file) + .ok_or_else(|| error(location.span, "Missing lexical witness."))?; + let token = tokens + .binary_search_by_key(&location.span.start, |token| token.span.start) + .ok() + .map(|at| tokens[at]); + if token.is_none_or(|token| token.span != location.span || token.class != lex::Class::Word) + { + return Err(error( + location.span, + "Occurrence is not an original whole name token.", + )); + } + if self.occurrences.contains_key(&key) + || !physical.insert((location.file, location.span.start, location.span.end)) + { + return Err(error( + location.span, + "Duplicate qualified occurrence or token ownership.", + )); + } + self.occurrences.insert(key, location); + Ok(()) + } +} + +pub(super) fn checked_source( + sources: &BTreeMap, + location: Location, +) -> Result<&str, Error> { + if location.span.start >= location.span.end { + return Err(error(location.span, "Empty or reversed source span.")); + } + sources + .get(&location.file) + .and_then(|source| source.get(location.span.start..location.span.end)) + .ok_or_else(|| { + error( + location.span, + "Source span is outside its original UTF-8 file.", + ) + }) +} +fn contained(parent: Location, child: Location) -> Result<(), Error> { + if parent.file != child.file + || child.span.start < parent.span.start + || child.span.end > parent.span.end + { + return Err(error( + child.span, + "Source span crosses its containing syntax.", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-core/src/language/analysis_source/index/tests.rs b/crates/oa-core/src/language/analysis_source/index/tests.rs new file mode 100644 index 00000000..44f0f569 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/index/tests.rs @@ -0,0 +1,326 @@ +use super::*; +use crate::language::ParseLimits; + +const OA: &str = include_str!("../lower/fixtures/typed-oa.arcadia"); +const ORDINARY: &str = include_str!("../lower/fixtures/ordinary-oa.arcadia"); +fn budget() -> Budget { + Budget::new(super::super::budget::Limits::source8( + ParseLimits::UNLIMITED, + )) + .unwrap() +} +fn ordinary() -> Project { + crate::language::parse(ORDINARY).unwrap() +} +fn sources() -> BTreeMap { + let split = OA.find("calculation combine").unwrap(); + BTreeMap::from([ + (7, OA[..split].replace('\n', "\r\n")), + (19, OA[split..].to_owned()), + ]) +} +fn raw( + sources: &BTreeMap, + budget: &mut Budget, +) -> (Vec, BTreeMap>, Lowered) { + let mut units = Vec::new(); + let mut witnesses = BTreeMap::new(); + for (&file, source) in sources { + let tokens = lex::tokens(source, budget).unwrap(); + let mut cursor = Cursor::new(source, &tokens, budget); + let mut declarations = Vec::new(); + while cursor.cursor < tokens.len() { + declarations.push(declarations::parse(&mut cursor, 8, None).unwrap()); + } + units.push(Unit { file, declarations }); + witnesses.insert(file, tokens); + } + let lowered = lower::lower(&ordinary(), &units, budget).unwrap(); + (units, witnesses, lowered) +} + +#[test] +fn staged_index_preserves_complete_oa_sa_models_and_original_file_spans() { + for (layer, ordinary_source, typed) in [ + (crate::Layer::Oa, ORDINARY, OA), + ( + crate::Layer::Sa, + include_str!("../lower/fixtures/ordinary-sa.arcadia"), + include_str!("../lower/fixtures/typed-sa.arcadia"), + ), + ] { + let sources = BTreeMap::from([(37, typed.replace('\n', "\r\n"))]); + let skeleton = crate::language::parse(ordinary_source).unwrap(); + let mut work = budget(); + let (index, lowered) = Index::parse(&skeleton, &sources, &mut work).unwrap(); + let mut actual = skeleton; + actual.definitions.extend(lowered.definitions); + actual.elements.extend(lowered.elements); + assert_eq!(actual, super::super::model_oracle::project(layer)); + a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap(); + assert_eq!(work.usage.bytes, sources[&37].len(), "one lexical pass"); + for (key, at) in index.occurrences() { + assert_eq!(at.file, 37); + assert_eq!( + &sources[&37][at.span.start..at.span.end], + index.names[&key.target] + ); + } + assert!(!index.expressions().is_empty()); + } +} + +#[test] +fn repeated_reordered_calls_have_stable_full_paths_and_four_labels() { + let expression = "@combine(b: @dispatch_time, a: @receive_time) // first 雪\n + @combine(a: @receive_time, b: @dispatch_time)"; + let original = OA.replace("@combine(a: @receive_time, b: @dispatch_time)", expression); + let reordered = original.replace( + "@combine(b: @dispatch_time, a: @receive_time)", + "@combine(a: @receive_time, b: @dispatch_time)", + ); + let first = BTreeMap::from([(7, original)]); + let second = BTreeMap::from([(7, reordered)]); + let (left, _) = Index::parse(&ordinary(), &first, &mut budget()).unwrap(); + let (right, _) = Index::parse(&ordinary(), &second, &mut budget()).unwrap(); + assert_eq!( + left.occurrences.keys().collect::>(), + right.occurrences.keys().collect::>() + ); + assert_eq!( + left.expressions.keys().collect::>(), + right.expressions.keys().collect::>() + ); + let labels: Vec<_> = left + .occurrences + .iter() + .filter(|(key, _)| key.role == TokenRole::Use(Role::ArgumentLabel)) + .collect(); + assert_eq!(labels.len(), 4); + let paths: BTreeSet<_> = labels + .iter() + .map(|(key, _)| key.path.as_ref().unwrap().edges()) + .collect(); + assert_eq!( + paths, + BTreeSet::from([vec![Child::Left], vec![Child::Right]]) + ); + let spans: BTreeSet<_> = labels + .iter() + .map(|(_, at)| (at.span.start, at.span.end)) + .collect(); + assert_eq!(spans.len(), 4); +} + +#[test] +fn index_checks_every_expression_edge_and_keeps_override_fields_distinct() { + let source = OA + .replace("body $a + $b;", "body if(if(true, 1 < 2, false), sum([$a, abs(-$b)], 0 [@second]), as_kind(@duration, power(1 [@second], 2) / 1 [@second]));") + .replace("@receive_time = 1250 [@millisecond];", "@receive_time = @combine(a: 1 [@second], b: 2 [@second]); @dispatch_time = @combine(b: 2 [@second], a: 1 [@second]);"); + let sources = BTreeMap::from([(17, source)]); + let (index, lowered) = Index::parse(&ordinary(), &sources, &mut budget()).unwrap(); + let mut project = ordinary(); + project.definitions.extend(lowered.definitions); + project.elements.extend(lowered.elements); + a::admit(&project, a::AdmissionLimits::REFERENCE).unwrap(); + let edges: BTreeSet<_> = index + .expressions + .keys() + .flat_map(|key| key.path.edges()) + .collect(); + for edge in [ + Child::Item(0), + Child::Item(1), + Child::Operand, + Child::Left, + Child::Right, + Child::Condition, + Child::WhenTrue, + Child::WhenFalse, + Child::Values, + Child::Initial, + Child::Base, + Child::Value, + ] { + assert!(edges.contains(&edge), "missing {edge:?}"); + } + assert!(edges.iter().any(|edge| matches!(edge, Child::Argument(_)))); + let labels: Vec<_> = index + .occurrences + .keys() + .filter(|key| { + key.role == TokenRole::Use(Role::ArgumentLabel) + && matches!(key.field, Some(Field::Override(_))) + }) + .collect(); + assert_eq!(labels.len(), 4); + assert_eq!( + labels + .iter() + .map(|key| key.field) + .collect::>() + .len(), + 2 + ); + assert!( + labels + .iter() + .all(|key| key.path.as_ref().unwrap().edges().is_empty()) + ); +} + +#[test] +fn arena_rejects_forward_cycles_and_cross_owner_field_file_parents() { + for damage in 0..6 { + let sources = sources(); + let mut work = budget(); + let (units, witnesses, mut lowered) = raw(&sources, &mut work); + let child = lowered + .expressions + .iter() + .position(|site| site.parent.is_some()) + .unwrap(); + let parent = lowered.expressions[child].parent.unwrap().0; + match damage { + 0 => lowered.expressions[child].parent = Some((child, Child::Left)), + 1 => lowered.expressions[parent].parent = Some((child, Child::Right)), + 2 => lowered.expressions[child].owner = ordinary().id, + 3 => lowered.expressions[child].field = Field::Predicate, + 4 => lowered.expressions[child].file = 12345, + _ => lowered.expressions[child].parent = Some((parent, Child::Operand)), + } + assert!( + Index::build( + &ordinary(), + &sources, + &units, + &witnesses, + &lowered, + &mut work + ) + .is_err(), + "damage {damage}" + ); + } +} + +#[test] +fn arena_rejects_duplicate_sites_occurrences_and_wrong_callee_labels() { + for damage in 0..4 { + let sources = sources(); + let mut work = budget(); + let (units, witnesses, mut lowered) = raw(&sources, &mut work); + match damage { + 0 => { + let site = &lowered.expressions[0]; + lowered.expressions.push(lower::ExpressionSite { + file: site.file, + owner: site.owner, + field: site.field, + parent: site.parent, + span: site.span, + }); + } + 1 => { + let row = &lowered.occurrences[0]; + lowered.occurrences.push(lower::Occurrence { + file: row.file, + owner: row.owner, + field: row.field, + expression: row.expression, + role: row.role, + target: row.target, + span: row.span, + }); + } + 2 => { + let label = lowered + .occurrences + .iter_mut() + .find(|row| row.role == Role::ArgumentLabel) + .unwrap(); + if let Target::Parameter { calculation, .. } = &mut label.target { + *calculation = ordinary().id; + } + } + _ => { + let row = lowered + .occurrences + .iter_mut() + .find(|row| row.role == Role::OverrideTarget) + .unwrap(); + row.field = Field::Override(ordinary().id); + } + } + assert!( + Index::build( + &ordinary(), + &sources, + &units, + &witnesses, + &lowered, + &mut work + ) + .is_err(), + "damage {damage}" + ); + } +} + +#[test] +fn index_rejects_comment_partial_and_non_utf8_occurrence_spans() { + for damage in 0..3 { + let mut sources = sources(); + let with_comment = + sources[&7].replace("subject @receive;", "subject @receive; // duration 雪\r\n"); + sources.insert(7, with_comment); + let mut work = budget(); + let (units, witnesses, mut lowered) = raw(&sources, &mut work); + let occurrence = lowered + .occurrences + .iter_mut() + .find(|row| row.file == 7 && &sources[&7][row.span.start..row.span.end] == "duration") + .unwrap(); + match damage { + 0 => { + let start = sources[&7].find("// duration").unwrap() + 3; + occurrence.span = Span { + start, + end: start + 8, + }; + } + 1 => occurrence.span.end -= 1, + _ => { + let start = sources[&7].find('雪').unwrap() + 1; + occurrence.span = Span { + start, + end: start + 1, + }; + } + } + assert!( + Index::build( + &ordinary(), + &sources, + &units, + &witnesses, + &lowered, + &mut work + ) + .is_err() + ); + } +} + +#[test] +fn index_has_shared_budget_and_never_stabilizes_missing_ids() { + let source = "calculation f \"F\" { result Number; body unbound; }"; + let files = BTreeMap::from([(91, source.into())]); + let missing = Index::parse(&ordinary(), &files, &mut budget()).unwrap_err(); + assert_eq!(missing.file, Some(91)); + assert!(missing.error.message.contains("stabilization")); + let mut limits = super::super::budget::Limits::source8(ParseLimits::UNLIMITED); + limits.bytes = 1; + let mut work = Budget::new(limits).unwrap(); + assert!(Index::parse(&ordinary(), &sources(), &mut work).is_err()); + assert!(work.active().is_err()); +} diff --git a/crates/oa-core/src/language/analysis_source/lower.rs b/crates/oa-core/src/language/analysis_source/lower.rs index 3a3a0f67..1e2f02b3 100644 --- a/crates/oa-core/src/language/analysis_source/lower.rs +++ b/crates/oa-core/src/language/analysis_source/lower.rs @@ -34,7 +34,7 @@ pub(super) enum Category { Calculation, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(super) enum Target { Global(Id), Parameter { @@ -43,7 +43,7 @@ pub(super) enum Target { }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(super) enum Field { Algebra, Kind, @@ -59,7 +59,7 @@ pub(super) enum Field { Override(Id), } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(super) enum Role { Reference, ParameterDeclaration, @@ -70,7 +70,7 @@ pub(super) enum Role { } /// Stable structural edges, independent of alias spelling and argument order. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(super) enum Child { Item(usize), Operand, diff --git a/crates/oa-core/src/language/analysis_source/update.rs b/crates/oa-core/src/language/analysis_source/update.rs new file mode 100644 index 00000000..bab14f41 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/update.rs @@ -0,0 +1,198 @@ +//! Simultaneous typed token edits. This is a staged plan, not source acceptance: +//! the bundle owner must merge ordinary edits, reparse/recompile the exact bytes, +//! admit once and compare the complete intended Project before installation. +use super::index::{Index, Location, OccurrenceKey, checked_source}; +use super::lower::Target; +use super::{Budget, Error, Span, error}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Edit { + key: OccurrenceKey, + location: Location, +} +#[derive(Debug)] +pub(super) struct Plan<'i, 's> { + index: &'i Index<'s>, + names: BTreeMap, + edits: Vec, +} +impl<'i, 's> Plan<'i, 's> { + /// All names are the desired final names. Validate the final namespaces, + /// rather than applying one rename at a time: alias/parameter swaps are legal. + pub fn renames( + index: &'i Index<'s>, + renames: &BTreeMap, + budget: &Budget, + ) -> Result { + budget.active()?; + budget.lowering_entries(renames.len(), Span::default())?; + let mut bytes = 0usize; + for (target, name) in renames { + if !index.names().contains_key(target) { + return Err(error( + Span::default(), + "Rename has no qualified source target.", + )); + } + if !crate::valid_alias(name) { + return Err(error( + Span::default(), + "Invalid alias or scoped parameter name.", + )); + } + bytes = bytes + .checked_add(name.len()) + .ok_or_else(|| error(Span::default(), "Rename size overflow."))?; + budget.index_bytes(bytes, Span::default())?; + } + let mut final_names = BTreeSet::new(); + for (target, original) in index.names() { + let name = renames.get(target).unwrap_or(original); + let scope = match target { + Target::Global(_) => None, + Target::Parameter { calculation, .. } => Some(*calculation), + }; + budget.lowering_entries(final_names.len() + 1, Span::default())?; + if !final_names.insert((scope, name.as_str())) { + return Err(error( + Span::default(), + "Rename creates a duplicate name in its namespace.", + )); + } + } + let mut edits = Vec::new(); + for (key, location) in index.occurrences() { + if renames + .get(&key.target) + .is_some_and(|name| index.names().get(&key.target) != Some(name)) + { + budget.lowering_occurrences(edits.len() + 1, location.span)?; + edits.push(Edit { + key: key.clone(), + location: *location, + }); + } + } + edits.sort_by_key(|edit| { + ( + edit.location.file, + edit.location.span.start, + edit.location.span.end, + ) + }); + let plan = Self { + index, + names: renames.clone(), + edits, + }; + plan.validate(index.sources(), budget)?; + Ok(plan) + } + + pub fn edit_count(&self) -> usize { + self.edits.len() + } + + /// Recheck all files, all edit ownership and all final sizes before any + /// output String is allocated. A changed unrelated file is stale too. + fn validate( + &self, + current: &BTreeMap, + budget: &Budget, + ) -> Result, Error> { + budget.active()?; + if current != self.index.sources() { + return Err(error( + Span::default(), + "Source index is stale: original file set or bytes changed.", + )); + } + budget.lowering_entries(current.len(), Span::default())?; + budget.lowering_occurrences(self.edits.len(), Span::default())?; + let mut lengths: BTreeMap<_, _> = current + .iter() + .map(|(&file, source)| (file, source.len())) + .collect(); + let mut previous: Option = None; + let mut seen = BTreeSet::new(); + for edit in &self.edits { + let at = edit.location; + if self.index.occurrences().get(&edit.key) != Some(&at) || !seen.insert(&edit.key) { + return Err(error(at.span, "Forged or duplicate edit occurrence.")); + } + let old = checked_source(current, at)?; + if self + .index + .names() + .get(&edit.key.target) + .is_none_or(|name| name != old) + { + return Err(error(at.span, "Stale original token spelling.")); + } + let replacement = self + .names + .get(&edit.key.target) + .ok_or_else(|| error(at.span, "Edit has no final target name."))?; + if previous.is_some_and(|prior| { + prior.file > at.file || (prior.file == at.file && prior.span.end > at.span.start) + }) { + return Err(error(at.span, "Overlapping or unordered source edits.")); + } + previous = Some(at); + let length = lengths + .get_mut(&at.file) + .ok_or_else(|| error(at.span, "Unknown edit file."))?; + *length = length + .checked_sub(old.len()) + .and_then(|size| size.checked_add(replacement.len())) + .ok_or_else(|| error(at.span, "Updated source size overflow."))?; + } + for (key, at) in self.index.occurrences() { + let required = self + .names + .get(&key.target) + .is_some_and(|name| self.index.names().get(&key.target) != Some(name)); + if required != seen.contains(key) { + return Err(error( + at.span, + "Edit set differs from the complete qualified rename plan.", + )); + } + } + let mut total = 0usize; + for length in lengths.values() { + total = total + .checked_add(*length) + .ok_or_else(|| error(Span::default(), "Updated bundle size overflow."))?; + budget.index_bytes(total, Span::default())?; + } + Ok(lengths) + } + + pub fn apply( + &self, + current: &BTreeMap, + budget: &Budget, + ) -> Result, Error> { + let lengths = self.validate(current, budget)?; + let mut result = BTreeMap::new(); + let mut edits = self.edits.iter().peekable(); + for (&file, source) in current { + let mut output = String::with_capacity(lengths[&file]); + let mut start = 0; + while edits.peek().is_some_and(|edit| edit.location.file == file) { + let edit = edits.next().expect("peeked edit"); + output.push_str(&source[start..edit.location.span.start]); + output.push_str(&self.names[&edit.key.target]); + start = edit.location.span.end; + } + output.push_str(&source[start..]); + result.insert(file, output); + } + Ok(result) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-core/src/language/analysis_source/update/tests.rs b/crates/oa-core/src/language/analysis_source/update/tests.rs new file mode 100644 index 00000000..a9e9b4e5 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/update/tests.rs @@ -0,0 +1,324 @@ +use super::super::budget::Limits; +use super::*; +use crate::analysis as a; +use crate::analysis_numeric::Exact; +use crate::language::ParseLimits; +use crate::{Id, Layer, Project}; + +const DEFINITIONS: &str = "// 雪 @f(a:) $a: comment spellings stay exact\r\n\ +calculation f \"f a b\" id \"00000000-0000-0000-0000-000000000100\" {\r\n\ + input a id \"00000000-0000-0000-0000-000000000200\" : Number;\r\n\ + input b id \"00000000-0000-0000-0000-000000000201\" : Number;\r\n\ + result Number; body $a + $b;\r\n}\r\n\ +calculation g \"g a b\" id \"00000000-0000-0000-0000-000000000200\" {\r\n\ + input a id \"00000000-0000-0000-0000-000000000200\" : Number;\r\n\ + input b id \"00000000-0000-0000-0000-000000000201\" : Number;\r\n\ + result Number; body $a + $b;\r\n}\r\n"; +const CALLER: &str = "calculation caller \"Caller\" id \"00000000-0000-0000-0000-000000000300\" {\n\ + result Number; body @f(b: 2, a: 1) // first 雪 @f(a:)\n\ + + @f(a: 3, b: 4) // second\n\ + + @g(a: 5, b: 6);\n}\n"; +fn budget() -> Budget { + Budget::new(Limits::source8(ParseLimits::UNLIMITED)).unwrap() +} +fn global(id: u128) -> Target { + Target::Global(Id::from_u128(id)) +} +fn parameter(calculation: u128, parameter: u128) -> Target { + Target::Parameter { + calculation: Id::from_u128(calculation), + parameter: a::ParameterId(Id::from_u128(parameter)), + } +} +fn sources() -> BTreeMap { + BTreeMap::from([(11, DEFINITIONS.into()), (23, CALLER.into())]) +} +fn skeleton() -> Project { + let mut project = super::super::model_oracle::project(Layer::Oa); + project.definitions.clear(); + project + .elements + .retain(|_, element| !element.kind().is_analysis()); + project +} +fn binary(left: a::Expr, right: a::Expr) -> a::Expr { + a::Expr::Binary { + op: a::BinaryOp::Add, + left: Box::new(left), + right: Box::new(right), + } +} +fn expected(renamed: bool) -> Project { + // Independent model construction: no parsed AST, source index, writer or + // generated edit participates in this expected complete Project. + let mut project = skeleton(); + let p = |id| a::ParameterId(Id::from_u128(id)); + for (id, alias, name) in [ + (0x100, if renamed { "g" } else { "f" }, "f a b"), + (0x200, if renamed { "f" } else { "g" }, "g a b"), + ] { + let names = if renamed && id == 0x100 { + ["b", "a"] + } else { + ["a", "b"] + }; + let body = a::CalculationDefinition { + parameters: [(0x200, names[0]), (0x201, names[1])] + .into_iter() + .map(|(id, name)| a::Parameter { + id: p(id), + name: name.into(), + value_type: a::AnalysisType::Scalar(a::ScalarType::Number), + }) + .collect(), + result: a::AnalysisType::Scalar(a::ScalarType::Number), + body: a::Binding::Expression(binary( + a::Expr::Parameter(p(0x200)), + a::Expr::Parameter(p(0x201)), + )), + }; + let id = Id::from_u128(id); + project.definitions.insert( + id, + a::Definition { + id, + alias: alias.into(), + name: name.into(), + description: String::new(), + body: a::DefinitionBody::Calculation(body), + }, + ); + } + let call = |id, x, y| a::Expr::Call { + definition: a::DefinitionRef::Local(Id::from_u128(id)), + arguments: BTreeMap::from([ + (p(0x200), a::Expr::Number(Exact::from_integer(x))), + (p(0x201), a::Expr::Number(Exact::from_integer(y))), + ]), + }; + let id = Id::from_u128(0x300); + project.definitions.insert( + id, + a::Definition { + id, + alias: "caller".into(), + name: "Caller".into(), + description: String::new(), + body: a::DefinitionBody::Calculation(a::CalculationDefinition { + parameters: Vec::new(), + result: a::AnalysisType::Scalar(a::ScalarType::Number), + body: a::Binding::Expression(binary( + binary(call(0x100, 1, 2), call(0x100, 3, 4)), + call(0x200, 5, 6), + )), + }), + }, + ); + project +} +fn merge(mut project: Project, lowered: super::super::lower::Lowered) -> Project { + project.definitions.extend(lowered.definitions); + project.elements.extend(lowered.elements); + project +} +fn rename_map() -> BTreeMap { + BTreeMap::from([ + (global(0x100), "g".into()), + (global(0x200), "f".into()), + (parameter(0x100, 0x200), "b".into()), + (parameter(0x100, 0x201), "a".into()), + ]) +} + +#[test] +fn simultaneous_global_and_scoped_swaps_match_original_token_and_whole_model_oracles() { + let sources = sources(); + let mut work = budget(); + let (index, lowered) = Index::parse(&skeleton(), &sources, &mut work).unwrap(); + assert_eq!(merge(skeleton(), lowered), expected(false)); + let plan = Plan::renames(&index, &rename_map(), &work).unwrap(); + // List expected edits against the original authored bytes independently of + // the index. Full contexts deliberately exclude equal strings/comments. + let mut edits = Vec::new(); + let mut add = |file, context: &str, token: &str, replacement: &str| { + let source = &sources[&file]; + let context_start = source.find(context).unwrap(); + let start = context_start + context.find(token).unwrap(); + edits.push((file, start, start + token.len(), replacement.to_owned())); + }; + add(11, "calculation f ", "f", "g"); + add(11, "calculation g ", "g", "f"); + add(11, "input a id", "a", "b"); + add(11, "input b id", "b", "a"); + add(11, "body $a + $b;", "a", "b"); + add(11, "body $a + $b;", "b;", "a;"); + // The last oracle edit above includes ';' to disambiguate 'b' in 'body'; + // restrict it back to the exact name token for the span oracle. + edits.last_mut().unwrap().2 -= 1; + edits.last_mut().unwrap().3.pop(); + for call in ["@f(b: 2, a: 1)", "@f(a: 3, b: 4)"] { + add_call_edits(&sources, &mut edits, call); + } + let start = CALLER.find("@g(a: 5, b: 6)").unwrap() + 1; + edits.push((23, start, start + 1, "f".into())); + edits.sort_by_key(|edit| (edit.0, edit.1)); + assert_eq!(plan.edit_count(), 13); + assert_eq!( + plan.edits + .iter() + .map(|edit| ( + edit.location.file, + edit.location.span.start, + edit.location.span.end, + plan.names[&edit.key.target].clone() + )) + .collect::>(), + edits + ); + let mut exact = BTreeMap::new(); + for (&file, source) in &sources { + let mut output = source.clone(); + for (_, start, end, value) in edits.iter().filter(|edit| edit.0 == file).rev() { + output.replace_range(*start..*end, value); + } + exact.insert(file, output); + } + let output = plan.apply(&sources, &work).unwrap(); + assert_eq!(output, exact); + assert!(output[&11].contains("// 雪 @f(a:) $a: comment spellings stay exact\r\n")); + assert!(output[&23].contains("// first 雪 @f(a:)\n")); + assert!(output[&11].contains("\"f a b\"")); + let (_, lowered) = Index::parse(&skeleton(), &output, &mut budget()).unwrap(); + let actual = merge(skeleton(), lowered); + assert_eq!(actual, expected(true)); + a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap(); +} +fn add_call_edits( + sources: &BTreeMap, + edits: &mut Vec<(usize, usize, usize, String)>, + call: &str, +) { + let start = sources[&23].find(call).unwrap(); + for (offset, replacement) in [ + (1, "g"), + (call.find("a:").unwrap(), "b"), + (call.find("b:").unwrap(), "a"), + ] { + edits.push((23, start + offset, start + offset + 1, replacement.into())); + } +} + +#[test] +fn alias_edits_preserve_unit_type_override_tokens_and_unrelated_authored_bytes() { + let source = include_str!("../lower/fixtures/typed-oa.arcadia") + .replace("750 [@millisecond]", "750 // exact 雪\r\n [@millisecond]"); + let files = BTreeMap::from([(59, source.clone())]); + let mut work = budget(); + let (index, _) = Index::parse(&skeleton(), &files, &mut work).unwrap(); + let mut expected_project = super::super::model_oracle::project(Layer::Oa); + let id = |name: &str| { + index + .names() + .iter() + .find(|(target, alias)| matches!(target, Target::Global(_)) && alias.as_str() == name) + .map(|(target, _)| *target) + .unwrap() + }; + let renames = BTreeMap::from([ + (id("duration"), "time_kind".into()), + (id("millisecond"), "ms_unit".into()), + (id("receive_time"), "arrival".into()), + ]); + let plan = Plan::renames(&index, &renames, &work).unwrap(); + let output = plan.apply(&files, &work).unwrap(); + let expected_source = source + .replace("quantity_kind duration ", "quantity_kind time_kind ") + .replace("@duration", "@time_kind") + .replace("unit millisecond ", "unit ms_unit ") + .replace("@millisecond", "@ms_unit") + .replace("oa_property receive_time ", "oa_property arrival ") + .replace("@receive_time", "@arrival"); + assert_eq!(output[&59], expected_source); + for definition in expected_project.definitions.values_mut() { + if let Some(name) = renames.get(&Target::Global(definition.id)) { + definition.alias = name.clone(); + } + } + for element in expected_project.elements.values_mut() { + if let Some(name) = renames.get(&Target::Global(element.id)) { + element.alias = name.clone(); + } + } + let (_, lowered) = Index::parse(&skeleton(), &output, &mut budget()).unwrap(); + assert_eq!(merge(skeleton(), lowered), expected_project); +} + +#[test] +fn rename_rejects_unknown_invalid_and_colliding_final_names_but_allows_noop() { + let sources = sources(); + let work = &mut budget(); + let (index, _) = Index::parse(&skeleton(), &sources, work).unwrap(); + for renames in [ + BTreeMap::from([(global(999), "new".into())]), + BTreeMap::from([(global(0x100), "bad name".into())]), + BTreeMap::from([(global(0x100), "g".into())]), + BTreeMap::from([(parameter(0x100, 0x200), "b".into())]), + ] { + assert!(Plan::renames(&index, &renames, work).is_err()); + } + let plan = Plan::renames(&index, &BTreeMap::from([(global(0x100), "f".into())]), work).unwrap(); + assert_eq!(plan.edit_count(), 0); + assert_eq!(plan.apply(&sources, work).unwrap(), sources); +} + +#[test] +fn stale_file_sets_bytes_forged_spans_and_duplicate_edits_fail_before_output() { + let sources = sources(); + let mut work = budget(); + let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap(); + for damage in 0..6 { + let mut current = sources.clone(); + let mut plan = Plan::renames(&index, &rename_map(), &work).unwrap(); + match damage { + 0 => current + .get_mut(&23) + .unwrap() + .push_str("// unrelated change"), + 1 => { + current.remove(&23); + } + 2 => { + current.insert(99, String::new()); + } + 3 => plan.edits[0].location.span.start += 1, + 4 => plan.edits.insert(1, plan.edits[0].clone()), + _ => { + plan.edits.pop(); + } + } + assert!(plan.apply(¤t, &work).is_err(), "damage {damage}"); + } +} + +#[test] +fn output_and_plan_limits_are_aggregate_and_do_not_reset_parse_work() { + let sources = sources(); + let mut work = budget(); + let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap(); + let usage = work.usage; + let numeric = work.numeric_units(); + let plan = Plan::renames(&index, &rename_map(), &work).unwrap(); + let output = plan.apply(&sources, &work).unwrap(); + assert_eq!(work.usage, usage); + assert_eq!(work.numeric_units(), numeric); + let bytes = output.values().map(String::len).sum(); + let mut limits = Limits::source8(ParseLimits::UNLIMITED); + limits.bytes = bytes; + assert!(plan.apply(&sources, &Budget::new(limits).unwrap()).is_ok()); + limits.bytes -= 1; + assert!(plan.apply(&sources, &Budget::new(limits).unwrap()).is_err()); + limits = Limits::source8(ParseLimits::UNLIMITED); + limits.references = 0; + limits.entries = 0; + assert!(Plan::renames(&index, &rename_map(), &Budget::new(limits).unwrap()).is_err()); +}