Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d7f3340d | ||
|
|
75bc285386 | ||
|
|
ded72c2339 | ||
|
|
1b1c3e3abb | ||
|
|
90fae14818 | ||
|
|
1f8283236f |
@@ -3,9 +3,11 @@
|
||||
mod budget;
|
||||
mod cursor;
|
||||
mod declarations;
|
||||
mod index;
|
||||
mod lex;
|
||||
mod lower;
|
||||
mod syntax;
|
||||
mod update;
|
||||
mod write;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
//! 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 must retain the same opaque parse/lower provenance. Occurrence
|
||||
//! completeness trusts that lowerer; the private builder does not prove the
|
||||
//! presence of every syntax reference in a caller-fabricated Lowered value.
|
||||
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::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
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.
|
||||
/// Comparison and Debug walk leaf-to-root without allocating or recursing;
|
||||
/// this private total order need not be root-first lexicographic order.
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct Path(Option<Arc<Step>>);
|
||||
struct Step {
|
||||
parent: Path,
|
||||
edge: Child,
|
||||
}
|
||||
impl PartialEq for Path {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.cmp(other) == Ordering::Equal
|
||||
}
|
||||
}
|
||||
impl Eq for Path {}
|
||||
impl PartialOrd for Path {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
impl Ord for Path {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
let (mut left, mut right) = (self, other);
|
||||
loop {
|
||||
match (left.0.as_deref(), right.0.as_deref()) {
|
||||
(Some(a), Some(b)) => {
|
||||
let order = a.edge.cmp(&b.edge);
|
||||
if order != Ordering::Equal {
|
||||
return order;
|
||||
}
|
||||
(left, right) = (&a.parent, &b.parent);
|
||||
}
|
||||
(None, None) => return Ordering::Equal,
|
||||
(None, Some(_)) => return Ordering::Less,
|
||||
(Some(_), None) => return Ordering::Greater,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl fmt::Debug for Path {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Debug intentionally shows the same leaf-to-root order as comparison.
|
||||
let mut list = formatter.debug_list();
|
||||
let mut path = self;
|
||||
while let Some(step) = &path.0 {
|
||||
list.entry(&step.edge);
|
||||
path = &step.parent;
|
||||
}
|
||||
list.finish()
|
||||
}
|
||||
}
|
||||
impl Drop for Path {
|
||||
fn drop(&mut self) {
|
||||
let mut next = self.0.take();
|
||||
while let Some(link) = next {
|
||||
// Shared ancestors remain owned elsewhere. The final owner peels
|
||||
// unique links here, taking the parent before Step is dropped.
|
||||
let Some(mut step) = Arc::into_inner(link) else {
|
||||
break;
|
||||
};
|
||||
next = step.parent.0.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Path {
|
||||
pub fn edges(&self) -> Vec<Child> {
|
||||
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<Field>,
|
||||
/// None is outside an expression; Some(empty) is its root call site.
|
||||
pub path: Option<Path>,
|
||||
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<usize, String>,
|
||||
names: BTreeMap<Target, String>,
|
||||
occurrences: BTreeMap<OccurrenceKey, Location>,
|
||||
expressions: BTreeMap<ExpressionKey, Location>,
|
||||
}
|
||||
impl<'s> Index<'s> {
|
||||
pub fn sources(&self) -> &BTreeMap<usize, String> {
|
||||
self.sources
|
||||
}
|
||||
pub fn names(&self) -> &BTreeMap<Target, String> {
|
||||
&self.names
|
||||
}
|
||||
pub fn occurrences(&self) -> &BTreeMap<OccurrenceKey, Location> {
|
||||
&self.occurrences
|
||||
}
|
||||
pub fn expressions(&self) -> &BTreeMap<ExpressionKey, Location> {
|
||||
&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<usize, String>,
|
||||
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))
|
||||
}
|
||||
|
||||
// Same-parse/lower internal seam only. Checks below reject damaged supplied
|
||||
// sites but occurrence completeness is an invariant of our own lowerer.
|
||||
// Do not expose a constructor accepting independently fabricated Lowered.
|
||||
fn build(
|
||||
ordinary: &Project,
|
||||
sources: &'s BTreeMap<usize, String>,
|
||||
units: &[Unit],
|
||||
witnesses: &BTreeMap<usize, Vec<lex::Token>>,
|
||||
lowered: &Lowered,
|
||||
budget: &mut Budget,
|
||||
) -> Result<Self, Error> {
|
||||
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<usize, Vec<lex::Token>>,
|
||||
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<usize, String>,
|
||||
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;
|
||||
@@ -0,0 +1,630 @@
|
||||
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<usize, String> {
|
||||
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<usize, String>,
|
||||
budget: &mut Budget,
|
||||
) -> (Vec<Unit>, BTreeMap<usize, Vec<lex::Token>>, 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::<Vec<_>>(),
|
||||
right.occurrences.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
left.expressions.keys().collect::<Vec<_>>(),
|
||||
right.expressions.keys().collect::<Vec<_>>()
|
||||
);
|
||||
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::<BTreeSet<_>>()
|
||||
.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 mut sources = sources();
|
||||
// The other callee is fully valid and reuses both parameter identities
|
||||
// and spellings. Catalog/token validation must not explain rejection.
|
||||
sources.insert(
|
||||
31,
|
||||
r#"calculation other "Other" id "00000000-0000-0000-0000-00000000cafe" {
|
||||
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
|
||||
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
|
||||
result Quantity(@duration); body $a + $b;
|
||||
}"#
|
||||
.into(),
|
||||
);
|
||||
let mut work = budget();
|
||||
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
|
||||
let valid = Index::build(
|
||||
&ordinary(),
|
||||
&sources,
|
||||
&units,
|
||||
&witnesses,
|
||||
&lowered,
|
||||
&mut work,
|
||||
)
|
||||
.unwrap();
|
||||
let mut expected_mismatch_span = None;
|
||||
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();
|
||||
let original = label.target;
|
||||
let Target::Parameter {
|
||||
calculation,
|
||||
parameter,
|
||||
} = &mut label.target
|
||||
else {
|
||||
panic!("argument label must target a parameter");
|
||||
};
|
||||
let other = Target::Parameter {
|
||||
calculation: Id::from_u128(0xcafe),
|
||||
parameter: *parameter,
|
||||
};
|
||||
assert_eq!(valid.names[&original], valid.names[&other]);
|
||||
*calculation = Id::from_u128(0xcafe);
|
||||
expected_mismatch_span = Some(label.span);
|
||||
}
|
||||
_ => {
|
||||
let row = lowered
|
||||
.occurrences
|
||||
.iter_mut()
|
||||
.find(|row| row.role == Role::OverrideTarget)
|
||||
.unwrap();
|
||||
row.field = Field::Override(ordinary().id);
|
||||
}
|
||||
}
|
||||
let failure = Index::build(
|
||||
&ordinary(),
|
||||
&sources,
|
||||
&units,
|
||||
&witnesses,
|
||||
&lowered,
|
||||
&mut work,
|
||||
)
|
||||
.unwrap_err();
|
||||
if let Some(span) = expected_mismatch_span {
|
||||
assert_eq!(
|
||||
failure.message,
|
||||
"Argument label crosses its called calculation."
|
||||
);
|
||||
assert_eq!(failure.span, span);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
fn on_small_stack(work: impl FnOnce() + Send + 'static) {
|
||||
// Match the established core language_nesting_tests worker-stack request.
|
||||
// This is a requested platform stack size, not a measured peak-byte claim.
|
||||
std::thread::Builder::new()
|
||||
.name("analysis-index-128k".into())
|
||||
.stack_size(128 * 1024)
|
||||
.spawn(work)
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn chain(first: Child, edges: usize) -> Path {
|
||||
let mut path = Path::default();
|
||||
for position in 0..edges {
|
||||
path = Path(Some(Arc::new(Step {
|
||||
parent: path,
|
||||
edge: if position == 0 { first } else { Child::Operand },
|
||||
})));
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_index_paths_compare_lookup_debug_and_drop_on_128k_stack() {
|
||||
on_small_stack(|| {
|
||||
let frames = super::super::budget::Limits::source8(ParseLimits::UNLIMITED).frames;
|
||||
assert_eq!(frames, 256);
|
||||
let edges = frames - 1; // root has frame depth one
|
||||
let mut work = budget();
|
||||
work.frame(frames, Span::default()).unwrap();
|
||||
let left = chain(Child::Left, edges);
|
||||
let equal = chain(Child::Left, edges); // independent allocations
|
||||
let right = chain(Child::Right, edges);
|
||||
assert!(!Arc::ptr_eq(
|
||||
left.0.as_ref().unwrap(),
|
||||
equal.0.as_ref().unwrap()
|
||||
));
|
||||
assert_eq!(left, equal);
|
||||
assert_eq!(left.partial_cmp(&equal), Some(Ordering::Equal));
|
||||
// The difference is at the root-most edge: leaf-first walks every link.
|
||||
assert_eq!(left.cmp(&right), Child::Left.cmp(&Child::Right));
|
||||
assert_eq!(right.cmp(&left), left.cmp(&right).reverse());
|
||||
assert!(Path::default() < left);
|
||||
assert!(chain(Child::Operand, edges - 1) < chain(Child::Operand, edges));
|
||||
let shared = left.clone();
|
||||
assert!(Arc::ptr_eq(
|
||||
left.0.as_ref().unwrap(),
|
||||
shared.0.as_ref().unwrap()
|
||||
));
|
||||
let leaf = Arc::downgrade(left.0.as_ref().unwrap());
|
||||
let key = |path| ExpressionKey {
|
||||
owner: Id::from_u128(1),
|
||||
field: Field::Body,
|
||||
path,
|
||||
};
|
||||
let mut expressions = BTreeMap::new();
|
||||
assert_eq!(expressions.insert(key(left.clone()), 1), None);
|
||||
assert_eq!(expressions.insert(key(equal.clone()), 2), Some(1));
|
||||
assert_eq!(expressions.insert(key(right.clone()), 3), None);
|
||||
assert_eq!(expressions.get(&key(equal.clone())), Some(&2));
|
||||
assert_eq!(expressions.get(&key(chain(Child::Right, edges))), Some(&3));
|
||||
assert_eq!(expressions.len(), 2);
|
||||
let occurrence = |path| OccurrenceKey {
|
||||
owner: Id::from_u128(1),
|
||||
field: Some(Field::Body),
|
||||
path: Some(path),
|
||||
role: TokenRole::Use(Role::ParameterUse),
|
||||
target: Target::Parameter {
|
||||
calculation: Id::from_u128(1),
|
||||
parameter: a::ParameterId(Id::from_u128(2)),
|
||||
},
|
||||
};
|
||||
let mut occurrences = BTreeSet::new();
|
||||
assert!(occurrences.insert(occurrence(left.clone())));
|
||||
assert!(!occurrences.insert(occurrence(equal.clone())));
|
||||
assert!(occurrences.contains(&occurrence(chain(Child::Left, edges))));
|
||||
let debug = format!("{left:?}");
|
||||
assert_eq!(debug.matches("Operand").count(), edges - 1);
|
||||
assert!(debug.ends_with("Left]"));
|
||||
assert!(!format!("{expressions:?} {occurrences:?}").is_empty());
|
||||
drop(expressions);
|
||||
drop(occurrences);
|
||||
drop(shared);
|
||||
drop(left);
|
||||
assert!(leaf.upgrade().is_none(), "maps/clones release their chain");
|
||||
drop(equal);
|
||||
drop(right);
|
||||
// Dropping a child stops at a shared ancestor, which remains usable.
|
||||
let parent = chain(Child::Left, edges - 1);
|
||||
let ancestor = Arc::downgrade(parent.0.as_ref().unwrap());
|
||||
let child = Path(Some(Arc::new(Step {
|
||||
parent: parent.clone(),
|
||||
edge: Child::Right,
|
||||
})));
|
||||
drop(child);
|
||||
assert_eq!(parent.edges().len(), edges - 1);
|
||||
drop(parent);
|
||||
assert!(ancestor.upgrade().is_none());
|
||||
// No other strong owners: final destruction must peel the full chain.
|
||||
let unique = chain(Child::Left, edges);
|
||||
let final_leaf = Arc::downgrade(unique.0.as_ref().unwrap());
|
||||
drop(unique);
|
||||
assert!(final_leaf.upgrade().is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_parse_index_plan_and_failed_build_cleanup_on_128k_stack() {
|
||||
on_small_stack(|| {
|
||||
use super::super::update::Plan;
|
||||
let deep = format!("{}$a{}", "abs(".repeat(127), ")".repeat(127));
|
||||
let source = format!(
|
||||
r#"calculation f "Deep 雪" id "00000000-0000-0000-0000-000000000081" {{
|
||||
input a id "00000000-0000-0000-0000-000000000083" : Number;
|
||||
result Number; body {deep};
|
||||
}}
|
||||
calculation caller "Caller" id "00000000-0000-0000-0000-000000000082" {{
|
||||
result Number; body @f(a: 1);
|
||||
}}
|
||||
"#
|
||||
);
|
||||
let sources = BTreeMap::from([(47, source.clone())]);
|
||||
let skeleton = ordinary();
|
||||
let mut work = budget();
|
||||
let (index, lowered) = Index::parse(&skeleton, &sources, &mut work).unwrap();
|
||||
assert_eq!(work.usage.bytes, source.len());
|
||||
assert_eq!(index.expressions.len(), 130); // 128 unary/leaf + call/argument
|
||||
assert_eq!(
|
||||
index.expressions.keys().map(|k| k.path.edges().len()).max(),
|
||||
Some(127)
|
||||
);
|
||||
let renames = BTreeMap::from([
|
||||
(Target::Global(Id::from_u128(0x81)), "renamed_deep".into()),
|
||||
(
|
||||
Target::Parameter {
|
||||
calculation: Id::from_u128(0x81),
|
||||
parameter: a::ParameterId(Id::from_u128(0x83)),
|
||||
},
|
||||
"renamed_arg".into(),
|
||||
),
|
||||
]);
|
||||
let usage = work.usage;
|
||||
let plan = Plan::renames(&index, &renames, &work).unwrap();
|
||||
assert_eq!(plan.edit_count(), 5); // declaration/call + input/use/label
|
||||
let output = plan.apply(&sources, &work).unwrap();
|
||||
assert_eq!(work.usage, usage);
|
||||
// Independent complete original-token oracle; no index-derived offsets.
|
||||
let expected_source = source
|
||||
.replace("calculation f ", "calculation renamed_deep ")
|
||||
.replace("input a id", "input renamed_arg id")
|
||||
.replace("$a", "$renamed_arg")
|
||||
.replace("@f(a: 1)", "@renamed_deep(renamed_arg: 1)");
|
||||
assert_eq!(output, BTreeMap::from([(47, expected_source)]));
|
||||
let (renamed_index, renamed) = Index::parse(&skeleton, &output, &mut budget()).unwrap();
|
||||
assert_eq!(
|
||||
index.occurrences.keys().collect::<Vec<_>>(),
|
||||
renamed_index.occurrences.keys().collect::<Vec<_>>()
|
||||
);
|
||||
// Compare complete independently constructed models, including deepest
|
||||
// parameter identity and the call's parameter-keyed argument map.
|
||||
for (lowered, renamed) in [(lowered, false), (renamed, true)] {
|
||||
let mut expected = skeleton.clone();
|
||||
let parameter = a::ParameterId(Id::from_u128(0x83));
|
||||
let mut expression = a::Expr::Parameter(parameter);
|
||||
for _ in 0..127 {
|
||||
expression = a::Expr::Unary {
|
||||
op: a::UnaryOp::Absolute,
|
||||
operand: Box::new(expression),
|
||||
};
|
||||
}
|
||||
for (id, alias, name, parameters, expression) in [
|
||||
(
|
||||
0x81,
|
||||
if renamed { "renamed_deep" } else { "f" },
|
||||
"Deep 雪",
|
||||
vec![a::Parameter {
|
||||
id: parameter,
|
||||
name: if renamed { "renamed_arg" } else { "a" }.into(),
|
||||
value_type: a::AnalysisType::Scalar(a::ScalarType::Number),
|
||||
}],
|
||||
expression,
|
||||
),
|
||||
(
|
||||
0x82,
|
||||
"caller",
|
||||
"Caller",
|
||||
Vec::new(),
|
||||
a::Expr::Call {
|
||||
definition: a::DefinitionRef::Local(Id::from_u128(0x81)),
|
||||
arguments: BTreeMap::from([(
|
||||
parameter,
|
||||
a::Expr::Number(crate::analysis_numeric::Exact::from_integer(1)),
|
||||
)]),
|
||||
},
|
||||
),
|
||||
] {
|
||||
let id = Id::from_u128(id);
|
||||
expected.definitions.insert(
|
||||
id,
|
||||
a::Definition {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
body: a::DefinitionBody::Calculation(a::CalculationDefinition {
|
||||
parameters,
|
||||
result: a::AnalysisType::Scalar(a::ScalarType::Number),
|
||||
body: a::Binding::Expression(expression),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut actual = skeleton.clone();
|
||||
actual.definitions.extend(lowered.definitions);
|
||||
actual.elements.extend(lowered.elements);
|
||||
assert_eq!(actual, expected);
|
||||
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
|
||||
drop(actual);
|
||||
drop(expected);
|
||||
}
|
||||
drop(plan);
|
||||
drop(renamed_index);
|
||||
drop(index);
|
||||
// Failure after the deepest keys have been allocated must clean up on
|
||||
// the same stack; no modified Lowered escapes this private test seam.
|
||||
let mut work = budget();
|
||||
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
|
||||
let deepest = lowered
|
||||
.expressions
|
||||
.iter()
|
||||
.find(|site| &sources[&site.file][site.span.start..site.span.end] == "$a")
|
||||
.unwrap();
|
||||
lowered.expressions.push(lower::ExpressionSite {
|
||||
file: deepest.file,
|
||||
owner: deepest.owner,
|
||||
field: deepest.field,
|
||||
parent: deepest.parent,
|
||||
span: deepest.span,
|
||||
});
|
||||
let failure =
|
||||
Index::build(&skeleton, &sources, &units, &witnesses, &lowered, &mut work).unwrap_err();
|
||||
assert_eq!(failure.message, "Duplicate qualified expression path.");
|
||||
lowered.expressions.pop();
|
||||
let mut limits = super::super::budget::Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.frames = 127;
|
||||
let mut bounded = Budget::new(limits).unwrap();
|
||||
let failure = Index::build(
|
||||
&skeleton,
|
||||
&sources,
|
||||
&units,
|
||||
&witnesses,
|
||||
&lowered,
|
||||
&mut bounded,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(failure.message.contains("syntax frames"));
|
||||
assert!(bounded.active().is_err());
|
||||
drop(lowered);
|
||||
drop(witnesses);
|
||||
drop(units);
|
||||
let rejected = BTreeMap::from([(47, source.replace(&deep, &format!("abs({deep})")))]);
|
||||
let failure = Index::parse(&skeleton, &rejected, &mut budget()).unwrap_err();
|
||||
assert!(failure.error.message.contains("logical depth"));
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -349,6 +349,11 @@ impl Cursor<'_, '_> {
|
||||
_ => 0,
|
||||
};
|
||||
if child_depth >= crate::analysis::MAX_EXPRESSION_DEPTH {
|
||||
// The rejected parent still owns accepted, potentially depth-128
|
||||
// children. Recursive derived cleanup here runs beneath the parser
|
||||
// frames and can exhaust the bounded native stack before returning
|
||||
// the depth error. Consume those children with an explicit worklist.
|
||||
drop_rejected_kind(kind);
|
||||
return Err(error(span, "Expression logical depth exceeds 128."));
|
||||
}
|
||||
Ok(Expression {
|
||||
@@ -399,6 +404,42 @@ impl Cursor<'_, '_> {
|
||||
self.node(kind, span)
|
||||
}
|
||||
}
|
||||
fn drop_rejected_kind(kind: ExpressionKind) {
|
||||
let mut pending = vec![kind];
|
||||
while let Some(kind) = pending.pop() {
|
||||
match kind {
|
||||
ExpressionKind::Unary { operand, .. } => pending.push(operand.kind),
|
||||
ExpressionKind::Binary { left, right, .. } => {
|
||||
pending.push(left.kind);
|
||||
pending.push(right.kind);
|
||||
}
|
||||
ExpressionKind::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
pending.push(condition.kind);
|
||||
pending.push(when_true.kind);
|
||||
pending.push(when_false.kind);
|
||||
}
|
||||
ExpressionKind::List(values) => pending.extend(values.into_iter().map(|v| v.kind)),
|
||||
ExpressionKind::Call { arguments, .. } => {
|
||||
pending.extend(arguments.into_iter().map(|a| a.value.kind));
|
||||
}
|
||||
ExpressionKind::Sum { values, initial } => {
|
||||
pending.push(values.kind);
|
||||
pending.push(initial.kind);
|
||||
}
|
||||
ExpressionKind::Power { base, .. } => pending.push(base.kind),
|
||||
ExpressionKind::AsKind { value, .. } => pending.push(value.kind),
|
||||
ExpressionKind::Boolean(_)
|
||||
| ExpressionKind::Number(_)
|
||||
| ExpressionKind::Quantity { .. }
|
||||
| ExpressionKind::Property(_)
|
||||
| ExpressionKind::Parameter(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn comparison(op: BinaryOp) -> bool {
|
||||
matches!(
|
||||
op,
|
||||
|
||||
@@ -108,6 +108,49 @@ fn flat_binary_depth_and_malformed_constructor_retain_bounded_completed_children
|
||||
assert_eq!(number(&expression("1")), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_parent_shape_rejects_depth129_and_cleans_children_on_128k_stack() {
|
||||
std::thread::Builder::new()
|
||||
.name("analysis-syntax-rejection-128k".into())
|
||||
.stack_size(128 * 1024)
|
||||
.spawn(|| {
|
||||
// Rotate through every recursive syntax shape, so the rejected
|
||||
// parent's complete child tree also exercises every cleanup arm.
|
||||
let wrap = |shape, child: &str| match shape {
|
||||
0 => format!("abs({child})"),
|
||||
1 => format!("({child}) + 1"),
|
||||
2 => format!("if(true, {child}, 0)"),
|
||||
3 => format!("[{child}]"),
|
||||
4 => format!("@f(a: {child})"),
|
||||
5 => format!("sum({child}, 0)"),
|
||||
6 => format!("power({child}, 1)"),
|
||||
7 => format!("as_kind(@k, {child})"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let mut deep = "1".to_owned();
|
||||
for layer in 0..127 {
|
||||
deep = wrap(layer % 8, &deep);
|
||||
}
|
||||
assert_eq!(expression(&deep).depth, 128);
|
||||
for shape in 0..8 {
|
||||
let source = wrap(shape, &deep);
|
||||
let failure = parse(&source, &mut budget()).unwrap_err();
|
||||
assert_eq!(failure.message, "Expression logical depth exceeds 128.");
|
||||
assert_eq!(
|
||||
failure.span,
|
||||
Span {
|
||||
start: 0,
|
||||
end: source.len()
|
||||
}
|
||||
);
|
||||
assert_eq!(number(&expression("1")), "1");
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn significant_ratio_and_quantity_trivia_preserve_original_spans() {
|
||||
let source = "ratio // explanation café\n ( - // sign\n 3, // denominator\n 4 ) // measurement\n [@millisecond]";
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! 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<Target, String>,
|
||||
edits: Vec<Edit>,
|
||||
}
|
||||
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<Target, String>,
|
||||
budget: &Budget,
|
||||
) -> Result<Self, Error> {
|
||||
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<Target, String>,
|
||||
budget: &Budget,
|
||||
) -> Result<(), Error> {
|
||||
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.",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
index: &'i Index<'s>,
|
||||
renames: &BTreeMap<Target, String>,
|
||||
budget: &Budget,
|
||||
) -> Result<Self, Error> {
|
||||
// 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
|
||||
.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,
|
||||
)
|
||||
});
|
||||
Ok(Self {
|
||||
index,
|
||||
names: renames.clone(),
|
||||
edits,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn edit_count(&self) -> usize {
|
||||
self.edits.len()
|
||||
}
|
||||
|
||||
/// 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<usize, String>, 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 previous: Option<Location> = 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."));
|
||||
}
|
||||
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);
|
||||
}
|
||||
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.",
|
||||
));
|
||||
}
|
||||
}
|
||||
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<usize, String>,
|
||||
budget: &Budget,
|
||||
) -> Result<BTreeMap<usize, usize>, 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
|
||||
.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<usize, String>,
|
||||
budget: &Budget,
|
||||
) -> Result<BTreeMap<usize, String>, Error> {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Target, String>,
|
||||
inherited: &'b Budget,
|
||||
) -> Result<Self, Error> {
|
||||
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<usize, String>,
|
||||
) -> Result<OriginalEdits<'_, 'i, 's>, Error> {
|
||||
Plan::validate_names(self.plan.index, &self.plan.names, self.budget)?;
|
||||
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<Item = OriginalEdit<'_>> {
|
||||
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;
|
||||
@@ -0,0 +1,744 @@
|
||||
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<usize, String> {
|
||||
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<Target, String> {
|
||||
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::<Vec<_>>(),
|
||||
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<usize, String>,
|
||||
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());
|
||||
}
|
||||
|
||||
#[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<Field>, Option<Vec<Child>>, 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<Row> = 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<Row> = 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."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_noop_apply_keeps_tighter_file_only_budget_and_output_boundary() {
|
||||
let sources = sources();
|
||||
let mut inherited = budget();
|
||||
let (index, _) = Index::parse(&skeleton(), &sources, &mut inherited).unwrap();
|
||||
let plan = Plan::renames(
|
||||
&index,
|
||||
&BTreeMap::from([(global(0x100), "f".into())]),
|
||||
&inherited,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(plan.edit_count(), 0);
|
||||
assert!(index.names().len() > sources.len());
|
||||
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
|
||||
limits.entries = sources.len();
|
||||
limits.references = 0;
|
||||
limits.bytes = sources.values().map(String::len).sum();
|
||||
// Standalone apply checks file/edit records, not the already validated
|
||||
// catalog/name-storage allocation from construction. Rechecking the catalog
|
||||
// under this intentionally tighter probe budget would reject this no-op.
|
||||
assert_eq!(
|
||||
plan.apply(&sources, &Budget::new(limits).unwrap()).unwrap(),
|
||||
sources
|
||||
);
|
||||
limits.bytes -= 1;
|
||||
assert_eq!(
|
||||
plan.apply(&sources, &Budget::new(limits).unwrap())
|
||||
.unwrap_err()
|
||||
.message,
|
||||
"Source8 index/output byte limit exceeded."
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user