diff --git a/crates/oa-core/src/language/analysis_source/update.rs b/crates/oa-core/src/language/analysis_source/update.rs index bab14f41..4c02c91f 100644 --- a/crates/oa-core/src/language/analysis_source/update.rs +++ b/crates/oa-core/src/language/analysis_source/update.rs @@ -25,6 +25,17 @@ impl<'i, 's> Plan<'i, 's> { renames: &BTreeMap, budget: &Budget, ) -> Result { + let plan = Self::prepare(index, renames, budget)?; + plan.validate(index.sources(), budget)?; + plan.output_lengths(index.sources(), budget)?; + Ok(plan) + } + + fn validate_names( + index: &Index<'_>, + renames: &BTreeMap, + budget: &Budget, + ) -> Result<(), Error> { budget.active()?; budget.lowering_entries(renames.len(), Span::default())?; let mut bytes = 0usize; @@ -61,6 +72,16 @@ impl<'i, 's> Plan<'i, 's> { )); } } + Ok(()) + } + + fn prepare( + index: &'i Index<'s>, + renames: &BTreeMap, + budget: &Budget, + ) -> Result { + // Validate record/name storage before copying any owned final names. + Self::validate_names(index, renames, budget)?; let mut edits = Vec::new(); for (key, location) in index.occurrences() { if renames @@ -81,26 +102,20 @@ impl<'i, 's> Plan<'i, 's> { edit.location.span.end, ) }); - let plan = Self { + Ok(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> { + /// Recheck the complete original witness and complete rename intent. + /// A changed unrelated file is stale too. This does not construct output. + fn validate(&self, current: &BTreeMap, budget: &Budget) -> Result<(), Error> { budget.active()?; if current != self.index.sources() { return Err(error( @@ -108,12 +123,9 @@ impl<'i, 's> Plan<'i, 's> { "Source index is stale: original file set or bytes changed.", )); } + Self::validate_names(self.index, &self.names, budget)?; 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 { @@ -130,8 +142,7 @@ impl<'i, 's> Plan<'i, 's> { { return Err(error(at.span, "Stale original token spelling.")); } - let replacement = self - .names + self.names .get(&edit.key.target) .ok_or_else(|| error(at.span, "Edit has no final target name."))?; if previous.is_some_and(|prior| { @@ -140,13 +151,6 @@ impl<'i, 's> Plan<'i, 's> { 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 @@ -160,6 +164,28 @@ impl<'i, 's> Plan<'i, 's> { )); } } + Ok(()) + } + + /// Standalone output preflight, after ownership validation. Composition + /// defers this hypothetical typed-only size to its future combined emitter. + fn output_lengths( + &self, + current: &BTreeMap, + budget: &Budget, + ) -> Result, Error> { + let mut lengths: BTreeMap<_, _> = current + .iter() + .map(|(&file, source)| (file, source.len())) + .collect(); + for edit in &self.edits { + let at = edit.location; + let length = lengths.get_mut(&at.file).expect("validated edit file"); + *length = length + .checked_sub(at.span.end - at.span.start) + .and_then(|size| size.checked_add(self.names[&edit.key.target].len())) + .ok_or_else(|| error(at.span, "Updated source size overflow."))?; + } let mut total = 0usize; for length in lengths.values() { total = total @@ -175,7 +201,8 @@ impl<'i, 's> Plan<'i, 's> { current: &BTreeMap, budget: &Budget, ) -> Result, Error> { - let lengths = self.validate(current, budget)?; + self.validate(current, budget)?; + let lengths = self.output_lengths(current, budget)?; let mut result = BTreeMap::new(); let mut edits = self.edits.iter().peekable(); for (&file, source) in current { @@ -194,5 +221,96 @@ impl<'i, 's> Plan<'i, 's> { } } +/// Private typed-only composition planning. The caller must supply its inherited +/// parse Budget; this type keeps that same immutable budget through export. +/// It does not establish the future complete bundle/file-table/parse-session +/// witness. In particular, an Index currently does not store its parse profile. +pub(super) struct Composition<'b, 'i, 's> { + plan: Plan<'i, 's>, + budget: &'b Budget, +} +impl std::fmt::Debug for Composition<'_, '_, '_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Composition") + .field("plan", &self.plan) + .finish_non_exhaustive() + } +} +impl<'b, 'i, 's> Composition<'b, 'i, 's> { + pub fn renames( + index: &'i Index<'s>, + renames: &BTreeMap, + inherited: &'b Budget, + ) -> Result { + let plan = Plan::prepare(index, renames, inherited)?; + plan.validate(index.sources(), inherited)?; + Ok(Self { + plan, + budget: inherited, + }) + } + + /// Export the entire validated plan against exact original numeric file keys + /// and bytes. There is no output allocation, application or subset-accept API. + /// A future collector must check this actual index witness and the complete + /// combined output bounds before applying either producer's edits once. + pub fn original_edits( + &self, + current: &BTreeMap, + ) -> Result, Error> { + self.plan.validate(current, self.budget)?; + Ok(OriginalEdits { plan: &self.plan }) + } +} + +#[derive(Debug)] +pub(super) struct OriginalEdits<'p, 'i, 's> { + plan: &'p Plan<'i, 's>, +} +impl<'s> OriginalEdits<'_, '_, 's> { + pub fn index(&self) -> &Index<'s> { + self.plan.index + } + pub fn len(&self) -> usize { + self.plan.edits.len() + } + pub fn is_empty(&self) -> bool { + self.plan.edits.is_empty() + } + pub fn iter(&self) -> impl ExactSizeIterator> { + self.plan.edits.iter().map(|edit| OriginalEdit { + key: &edit.key, + location: edit.location, + original: &self.plan.index.sources()[&edit.location.file] + [edit.location.span.start..edit.location.span.end], + replacement: &self.plan.names[&edit.key.target], + }) + } +} + +/// A borrowed inspection record, never independent edit authority. Only an +/// intact OriginalEdits batch can carry this producer's complete rename intent. +#[derive(Debug)] +pub(super) struct OriginalEdit<'p> { + key: &'p OccurrenceKey, + location: Location, + original: &'p str, + replacement: &'p str, +} +impl OriginalEdit<'_> { + pub fn key(&self) -> &OccurrenceKey { + self.key + } + pub fn location(&self) -> Location { + self.location + } + pub fn original(&self) -> &str { + self.original + } + pub fn replacement(&self) -> &str { + self.replacement + } +} + #[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 index a9e9b4e5..2a956897 100644 --- a/crates/oa-core/src/language/analysis_source/update/tests.rs +++ b/crates/oa-core/src/language/analysis_source/update/tests.rs @@ -322,3 +322,390 @@ fn output_and_plan_limits_are_aggregate_and_do_not_reset_parse_work() { limits.entries = 0; assert!(Plan::renames(&index, &rename_map(), &Budget::new(limits).unwrap()).is_err()); } + +#[test] +fn composition_exports_complete_borrowed_swap_witnesses_and_empty_noops() { + use super::super::index::TokenRole; + use super::super::lower::{Child, Field, Role}; + type Key = (Id, Option, Option>, TokenRole, Target); + type Row = (Location, String, String, Key); + + 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 composition = Composition::renames(&index, &rename_map(), &work).unwrap(); + assert!(std::ptr::eq(composition.budget, &work)); + // A byte-identical snapshot is valid; records still borrow the actual index. + let batch = composition.original_edits(&sources.clone()).unwrap(); + assert!(std::ptr::eq(batch.index(), &index)); + assert_eq!(batch.len(), 13); + assert!(!batch.is_empty()); + assert_eq!(batch.iter().len(), 13); + + // Independently authored occurrence identities and full source contexts. + // No index offset/path or generated output defines this oracle. + let mut expected: Vec = Vec::new(); + let mut add = |file, context: &str, prefix: &str, old: &str, new: &str, key: Key| { + let start = sources[&file].find(context).unwrap() + prefix.len(); + assert_eq!(&sources[&file][start..start + old.len()], old); + expected.push(( + Location { + file, + span: Span { + start, + end: start + old.len(), + }, + }, + old.into(), + new.into(), + key, + )); + }; + for (id, old, new, context) in [ + (0x100, "f", "g", "calculation f "), + (0x200, "g", "f", "calculation g "), + ] { + add( + 11, + context, + "calculation ", + old, + new, + ( + Id::from_u128(id), + None, + None, + TokenRole::Declaration, + global(id), + ), + ); + } + for (id, old, new, context, edge, prefix) in [ + (0x200, "a", "b", "input a id", Child::Left, "body $"), + (0x201, "b", "a", "input b id", Child::Right, "body $a + $"), + ] { + let target = parameter(0x100, id); + add( + 11, + context, + "input ", + old, + new, + ( + Id::from_u128(0x100), + Some(Field::Input(a::ParameterId(Id::from_u128(id)))), + None, + TokenRole::Use(Role::ParameterDeclaration), + target, + ), + ); + add( + 11, + "body $a + $b;", + prefix, + old, + new, + ( + Id::from_u128(0x100), + Some(Field::Body), + Some(vec![edge]), + TokenRole::Use(Role::ParameterUse), + target, + ), + ); + } + for (context, path) in [ + ("@f(b: 2, a: 1)", vec![Child::Left, Child::Left]), + ("@f(a: 3, b: 4)", vec![Child::Left, Child::Right]), + ] { + add( + 23, + context, + "@", + "f", + "g", + ( + Id::from_u128(0x300), + Some(Field::Body), + Some(path.clone()), + TokenRole::Use(Role::CallTarget), + global(0x100), + ), + ); + for (id, old, new) in [(0x200, "a", "b"), (0x201, "b", "a")] { + let offset = context.find(&format!("{old}:")).unwrap(); + add( + 23, + context, + &context[..offset], + old, + new, + ( + Id::from_u128(0x300), + Some(Field::Body), + Some(path.clone()), + TokenRole::Use(Role::ArgumentLabel), + parameter(0x100, id), + ), + ); + } + } + add( + 23, + "@g(a: 5, b: 6)", + "@", + "g", + "f", + ( + Id::from_u128(0x300), + Some(Field::Body), + Some(vec![Child::Right]), + TokenRole::Use(Role::CallTarget), + global(0x200), + ), + ); + expected.sort_by_key(|row| (row.0.file, row.0.span.start, row.0.span.end)); + let actual: Vec = batch + .iter() + .map(|edit| { + let key = edit.key(); + ( + edit.location(), + edit.original().into(), + edit.replacement().into(), + ( + key.owner, + key.field, + key.path.as_ref().map(|path| path.edges()), + key.role, + key.target, + ), + ) + }) + .collect(); + assert_eq!(actual, expected); + + let noop = Composition::renames( + &index, + &BTreeMap::from([ + (global(0x100), "f".into()), + (parameter(0x200, 0x200), "a".into()), + ]), + &work, + ) + .unwrap(); + let empty = noop.original_edits(&sources).unwrap(); + assert!(empty.is_empty()); + assert_eq!(empty.len(), 0); + assert_eq!(empty.iter().count(), 0); + assert_eq!(work.usage, usage); + assert_eq!(work.numeric_units(), numeric); +} + +#[test] +fn composition_rejects_stale_or_damaged_complete_batches_before_export() { + let mut sources = sources(); + sources.insert(99, "// unrelated 雪\r\n".into()); + let original = sources.clone(); + let mut work = budget(); + let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap(); + for damage in 0..10 { + let mut current = sources.clone(); + let mut composition = Composition::renames(&index, &rename_map(), &work).unwrap(); + match damage { + 0 => current.get_mut(&99).unwrap().push_str("changed"), + 1 => { + current.remove(&23); + } + 2 => { + current.insert(101, String::new()); + } + 3 => composition.plan.edits[0].location.span.start += 1, + 4 => composition + .plan + .edits + .insert(1, composition.plan.edits[0].clone()), + 5 => { + composition.plan.edits.pop(); + } + 6 => composition.plan.edits.swap(0, 1), + 7 => { + composition + .plan + .names + .insert(global(0x100), "bad name".into()); + } + 8 => { + composition.plan.names.insert(global(0x100), "f".into()); + } + _ => { + composition.plan.names.insert(global(999), "missing".into()); + } + } + let before = current.clone(); + assert!( + composition.original_edits(¤t).is_err(), + "damage {damage}" + ); + assert_eq!(current, before); + } + assert_eq!(sources, original); + let failure = work.frame(257, Span::default()).unwrap_err(); + assert_eq!( + Composition::renames(&index, &rename_map(), &work).unwrap_err(), + failure + ); +} + +#[test] +fn composition_defers_only_typed_output_size_with_independent_shrink_arithmetic() { + let sources = sources(); + let cap = sources.values().map(String::len).sum(); + let mut limits = Limits::source8(ParseLimits::UNLIMITED); + limits.bytes = cap; + let mut work = Budget::new(limits).unwrap(); + let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap(); + assert_eq!(work.usage.bytes, cap); + let usage = work.usage; + let numeric = work.numeric_units(); + let names = BTreeMap::from([(global(0x100), "ff".into())]); + assert_eq!( + Plan::renames(&index, &names, &work).unwrap_err().message, + "Source8 index/output byte limit exceeded." + ); + let composition = Composition::renames(&index, &names, &work).unwrap(); + let batch = composition.original_edits(&sources).unwrap(); + assert_eq!(batch.len(), 3); // declaration and two distinct call targets + let growth: usize = batch + .iter() + .map(|edit| edit.replacement().len() - edit.original().len()) + .sum(); + assert_eq!(growth, 3); + assert_eq!( + composition.plan.apply(&sources, &work).unwrap_err().message, + "Source8 index/output byte limit exceeded." + ); + + // Independent future ordinary/scalar producer witness on untouched display + // name bytes. This is size/offset arithmetic only: this typed-only harness + // has no ordinary collector, mixed ownership proof or accepted output. + let start = DEFINITIONS.find("\"f a b\"").unwrap() + 1; + let shrink = Location { + file: 11, + span: Span { + start, + end: start + "f a b".len(), + }, + }; + assert_eq!( + &sources[&shrink.file][shrink.span.start..shrink.span.end], + "f a b" + ); + for edit in batch.iter() { + let at = edit.location(); + assert!( + at.file != shrink.file + || at.span.end <= shrink.span.start + || shrink.span.end <= at.span.start + ); + } + let original_len = shrink.span.end - shrink.span.start; + assert_eq!(cap + growth - original_len + "fa".len(), cap); + assert_eq!(cap + growth - original_len + "fab".len(), cap + 1); + assert_eq!(work.usage, usage); + assert_eq!(work.numeric_units(), numeric); +} + +#[test] +fn composition_keeps_name_storage_and_rename_record_limits_before_allocation() { + let sources = sources(); + let cap = sources.values().map(String::len).sum(); + let mut limits = Limits::source8(ParseLimits::UNLIMITED); + limits.bytes = cap; + let mut work = Budget::new(limits).unwrap(); + let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap(); + let exact = BTreeMap::from([(global(0x100), "x".repeat(cap))]); + assert_eq!( + Composition::renames(&index, &exact, &work) + .unwrap() + .original_edits(&sources) + .unwrap() + .len(), + 3 + ); + let over = BTreeMap::from([(global(0x100), "x".repeat(cap + 1))]); + assert_eq!( + Composition::renames(&index, &over, &work) + .unwrap_err() + .message, + "Source8 index/output byte limit exceeded." + ); + for invalid in [ + BTreeMap::from([(global(999), "unknown".into())]), + BTreeMap::from([(global(0x100), "bad name".into())]), + BTreeMap::from([(global(0x100), "g".into())]), + BTreeMap::from([(parameter(0x100, 0x200), "b".into())]), + ] { + assert!(Composition::renames(&index, &invalid, &work).is_err()); + } + + // A typed-empty fixture isolates the rename-map record bound without + // syntax entry usage masking it. No ordinary edits are authorized here. + let ordinary = skeleton(); + let count = ordinary.identities().len(); + let files = BTreeMap::from([(1, "// empty typed unit\n".into())]); + let mut limits = Limits::source8(ParseLimits::UNLIMITED); + limits.entries = count; + limits.references = 0; + let mut inherited = Budget::new(limits).unwrap(); + let (index, _) = Index::parse(&ordinary, &files, &mut inherited).unwrap(); + let mut names: BTreeMap<_, _> = index + .names() + .keys() + .enumerate() + .map(|(i, target)| (*target, format!("renamed_{i}"))) + .collect(); + assert_eq!(names.len(), count); + assert!( + Composition::renames(&index, &names, &inherited) + .unwrap() + .original_edits(&files) + .unwrap() + .is_empty() + ); + names.insert(global(999), "additional".into()); + assert_eq!( + Composition::renames(&index, &names, &inherited) + .unwrap_err() + .message, + "Source8 lowering entry limit exceeded." + ); +} + +#[test] +fn composition_checks_edit_record_ceiling_before_duplicate_membership() { + let sources = sources(); + let mut limits = Limits::source8(ParseLimits::UNLIMITED); + limits.entries = 128; + limits.references = 32; + let ceiling = limits.entries + limits.references; + let mut work = Budget::new(limits).unwrap(); + let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap(); + let mut composition = Composition::renames(&index, &rename_map(), &work).unwrap(); + assert_eq!(composition.original_edits(&sources).unwrap().len(), 13); + let duplicate = composition.plan.edits[0].clone(); + composition.plan.edits = vec![duplicate.clone(); ceiling]; + // At the exact count bound validation proceeds to the independent ownership + // error. The damaged plan never becomes a successful/authoritative batch. + assert_eq!( + composition.original_edits(&sources).unwrap_err().message, + "Forged or duplicate edit occurrence." + ); + composition.plan.edits.push(duplicate); + assert_eq!( + composition.original_edits(&sources).unwrap_err().message, + "Source8 lowering occurrence limit exceeded." + ); +}