Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b1c3e3abb | ||
|
|
90fae14818 | ||
|
|
1f8283236f |
@@ -3,9 +3,11 @@
|
|||||||
mod budget;
|
mod budget;
|
||||||
mod cursor;
|
mod cursor;
|
||||||
mod declarations;
|
mod declarations;
|
||||||
|
mod index;
|
||||||
mod lex;
|
mod lex;
|
||||||
mod lower;
|
mod lower;
|
||||||
mod syntax;
|
mod syntax;
|
||||||
|
mod update;
|
||||||
mod write;
|
mod write;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ impl Budget {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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> {
|
pub fn lowering_occurrences(&self, count: usize, span: Span) -> Result<(), Error> {
|
||||||
self.active()?;
|
self.active()?;
|
||||||
// Parameter declarations are syntax entries, not reference uses.
|
// 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,
|
Calculation,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub(super) enum Target {
|
pub(super) enum Target {
|
||||||
Global(Id),
|
Global(Id),
|
||||||
Parameter {
|
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 {
|
pub(super) enum Field {
|
||||||
Algebra,
|
Algebra,
|
||||||
Kind,
|
Kind,
|
||||||
@@ -59,7 +59,7 @@ pub(super) enum Field {
|
|||||||
Override(Id),
|
Override(Id),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub(super) enum Role {
|
pub(super) enum Role {
|
||||||
Reference,
|
Reference,
|
||||||
ParameterDeclaration,
|
ParameterDeclaration,
|
||||||
@@ -70,7 +70,7 @@ pub(super) enum Role {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stable structural edges, independent of alias spelling and argument order.
|
/// 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 {
|
pub(super) enum Child {
|
||||||
Item(usize),
|
Item(usize),
|
||||||
Operand,
|
Operand,
|
||||||
|
|||||||
@@ -349,6 +349,11 @@ impl Cursor<'_, '_> {
|
|||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
if child_depth >= crate::analysis::MAX_EXPRESSION_DEPTH {
|
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."));
|
return Err(error(span, "Expression logical depth exceeds 128."));
|
||||||
}
|
}
|
||||||
Ok(Expression {
|
Ok(Expression {
|
||||||
@@ -399,6 +404,42 @@ impl Cursor<'_, '_> {
|
|||||||
self.node(kind, span)
|
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 {
|
fn comparison(op: BinaryOp) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
op,
|
op,
|
||||||
|
|||||||
@@ -108,6 +108,49 @@ fn flat_binary_depth_and_malformed_constructor_retain_bounded_completed_children
|
|||||||
assert_eq!(number(&expression("1")), "1");
|
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]
|
#[test]
|
||||||
fn significant_ratio_and_quantity_trivia_preserve_original_spans() {
|
fn significant_ratio_and_quantity_trivia_preserve_original_spans() {
|
||||||
let source = "ratio // explanation café\n ( - // sign\n 3, // denominator\n 4 ) // measurement\n [@millisecond]";
|
let source = "ratio // explanation café\n ( - // sign\n 3, // denominator\n 4 ) // measurement\n [@millisecond]";
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//! Simultaneous typed token edits. This is a staged plan, not source acceptance:
|
||||||
|
//! the bundle owner must merge ordinary edits, reparse/recompile the exact bytes,
|
||||||
|
//! admit once and compare the complete intended Project before installation.
|
||||||
|
use super::index::{Index, Location, OccurrenceKey, checked_source};
|
||||||
|
use super::lower::Target;
|
||||||
|
use super::{Budget, Error, Span, error};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct Edit {
|
||||||
|
key: OccurrenceKey,
|
||||||
|
location: Location,
|
||||||
|
}
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct Plan<'i, 's> {
|
||||||
|
index: &'i Index<'s>,
|
||||||
|
names: BTreeMap<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> {
|
||||||
|
budget.active()?;
|
||||||
|
budget.lowering_entries(renames.len(), Span::default())?;
|
||||||
|
let mut bytes = 0usize;
|
||||||
|
for (target, name) in renames {
|
||||||
|
if !index.names().contains_key(target) {
|
||||||
|
return Err(error(
|
||||||
|
Span::default(),
|
||||||
|
"Rename has no qualified source target.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !crate::valid_alias(name) {
|
||||||
|
return Err(error(
|
||||||
|
Span::default(),
|
||||||
|
"Invalid alias or scoped parameter name.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
bytes = bytes
|
||||||
|
.checked_add(name.len())
|
||||||
|
.ok_or_else(|| error(Span::default(), "Rename size overflow."))?;
|
||||||
|
budget.index_bytes(bytes, Span::default())?;
|
||||||
|
}
|
||||||
|
let mut final_names = BTreeSet::new();
|
||||||
|
for (target, original) in index.names() {
|
||||||
|
let name = renames.get(target).unwrap_or(original);
|
||||||
|
let scope = match target {
|
||||||
|
Target::Global(_) => None,
|
||||||
|
Target::Parameter { calculation, .. } => Some(*calculation),
|
||||||
|
};
|
||||||
|
budget.lowering_entries(final_names.len() + 1, Span::default())?;
|
||||||
|
if !final_names.insert((scope, name.as_str())) {
|
||||||
|
return Err(error(
|
||||||
|
Span::default(),
|
||||||
|
"Rename creates a duplicate name in its namespace.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut edits = Vec::new();
|
||||||
|
for (key, location) in index.occurrences() {
|
||||||
|
if renames
|
||||||
|
.get(&key.target)
|
||||||
|
.is_some_and(|name| index.names().get(&key.target) != Some(name))
|
||||||
|
{
|
||||||
|
budget.lowering_occurrences(edits.len() + 1, location.span)?;
|
||||||
|
edits.push(Edit {
|
||||||
|
key: key.clone(),
|
||||||
|
location: *location,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edits.sort_by_key(|edit| {
|
||||||
|
(
|
||||||
|
edit.location.file,
|
||||||
|
edit.location.span.start,
|
||||||
|
edit.location.span.end,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let plan = Self {
|
||||||
|
index,
|
||||||
|
names: renames.clone(),
|
||||||
|
edits,
|
||||||
|
};
|
||||||
|
plan.validate(index.sources(), budget)?;
|
||||||
|
Ok(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_count(&self) -> usize {
|
||||||
|
self.edits.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recheck all files, all edit ownership and all final sizes before any
|
||||||
|
/// output String is allocated. A changed unrelated file is stale too.
|
||||||
|
fn validate(
|
||||||
|
&self,
|
||||||
|
current: &BTreeMap<usize, String>,
|
||||||
|
budget: &Budget,
|
||||||
|
) -> Result<BTreeMap<usize, usize>, Error> {
|
||||||
|
budget.active()?;
|
||||||
|
if current != self.index.sources() {
|
||||||
|
return Err(error(
|
||||||
|
Span::default(),
|
||||||
|
"Source index is stale: original file set or bytes changed.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
budget.lowering_entries(current.len(), Span::default())?;
|
||||||
|
budget.lowering_occurrences(self.edits.len(), Span::default())?;
|
||||||
|
let mut lengths: BTreeMap<_, _> = current
|
||||||
|
.iter()
|
||||||
|
.map(|(&file, source)| (file, source.len()))
|
||||||
|
.collect();
|
||||||
|
let mut previous: Option<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."));
|
||||||
|
}
|
||||||
|
let replacement = self
|
||||||
|
.names
|
||||||
|
.get(&edit.key.target)
|
||||||
|
.ok_or_else(|| error(at.span, "Edit has no final target name."))?;
|
||||||
|
if previous.is_some_and(|prior| {
|
||||||
|
prior.file > at.file || (prior.file == at.file && prior.span.end > at.span.start)
|
||||||
|
}) {
|
||||||
|
return Err(error(at.span, "Overlapping or unordered source edits."));
|
||||||
|
}
|
||||||
|
previous = Some(at);
|
||||||
|
let length = lengths
|
||||||
|
.get_mut(&at.file)
|
||||||
|
.ok_or_else(|| error(at.span, "Unknown edit file."))?;
|
||||||
|
*length = length
|
||||||
|
.checked_sub(old.len())
|
||||||
|
.and_then(|size| size.checked_add(replacement.len()))
|
||||||
|
.ok_or_else(|| error(at.span, "Updated source size overflow."))?;
|
||||||
|
}
|
||||||
|
for (key, at) in self.index.occurrences() {
|
||||||
|
let required = self
|
||||||
|
.names
|
||||||
|
.get(&key.target)
|
||||||
|
.is_some_and(|name| self.index.names().get(&key.target) != Some(name));
|
||||||
|
if required != seen.contains(key) {
|
||||||
|
return Err(error(
|
||||||
|
at.span,
|
||||||
|
"Edit set differs from the complete qualified rename plan.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut total = 0usize;
|
||||||
|
for length in lengths.values() {
|
||||||
|
total = total
|
||||||
|
.checked_add(*length)
|
||||||
|
.ok_or_else(|| error(Span::default(), "Updated bundle size overflow."))?;
|
||||||
|
budget.index_bytes(total, Span::default())?;
|
||||||
|
}
|
||||||
|
Ok(lengths)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(
|
||||||
|
&self,
|
||||||
|
current: &BTreeMap<usize, String>,
|
||||||
|
budget: &Budget,
|
||||||
|
) -> Result<BTreeMap<usize, String>, Error> {
|
||||||
|
let lengths = self.validate(current, budget)?;
|
||||||
|
let mut result = BTreeMap::new();
|
||||||
|
let mut edits = self.edits.iter().peekable();
|
||||||
|
for (&file, source) in current {
|
||||||
|
let mut output = String::with_capacity(lengths[&file]);
|
||||||
|
let mut start = 0;
|
||||||
|
while edits.peek().is_some_and(|edit| edit.location.file == file) {
|
||||||
|
let edit = edits.next().expect("peeked edit");
|
||||||
|
output.push_str(&source[start..edit.location.span.start]);
|
||||||
|
output.push_str(&self.names[&edit.key.target]);
|
||||||
|
start = edit.location.span.end;
|
||||||
|
}
|
||||||
|
output.push_str(&source[start..]);
|
||||||
|
result.insert(file, output);
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
use super::super::budget::Limits;
|
||||||
|
use super::*;
|
||||||
|
use crate::analysis as a;
|
||||||
|
use crate::analysis_numeric::Exact;
|
||||||
|
use crate::language::ParseLimits;
|
||||||
|
use crate::{Id, Layer, Project};
|
||||||
|
|
||||||
|
const DEFINITIONS: &str = "// 雪 @f(a:) $a: comment spellings stay exact\r\n\
|
||||||
|
calculation f \"f a b\" id \"00000000-0000-0000-0000-000000000100\" {\r\n\
|
||||||
|
input a id \"00000000-0000-0000-0000-000000000200\" : Number;\r\n\
|
||||||
|
input b id \"00000000-0000-0000-0000-000000000201\" : Number;\r\n\
|
||||||
|
result Number; body $a + $b;\r\n}\r\n\
|
||||||
|
calculation g \"g a b\" id \"00000000-0000-0000-0000-000000000200\" {\r\n\
|
||||||
|
input a id \"00000000-0000-0000-0000-000000000200\" : Number;\r\n\
|
||||||
|
input b id \"00000000-0000-0000-0000-000000000201\" : Number;\r\n\
|
||||||
|
result Number; body $a + $b;\r\n}\r\n";
|
||||||
|
const CALLER: &str = "calculation caller \"Caller\" id \"00000000-0000-0000-0000-000000000300\" {\n\
|
||||||
|
result Number; body @f(b: 2, a: 1) // first 雪 @f(a:)\n\
|
||||||
|
+ @f(a: 3, b: 4) // second\n\
|
||||||
|
+ @g(a: 5, b: 6);\n}\n";
|
||||||
|
fn budget() -> Budget {
|
||||||
|
Budget::new(Limits::source8(ParseLimits::UNLIMITED)).unwrap()
|
||||||
|
}
|
||||||
|
fn global(id: u128) -> Target {
|
||||||
|
Target::Global(Id::from_u128(id))
|
||||||
|
}
|
||||||
|
fn parameter(calculation: u128, parameter: u128) -> Target {
|
||||||
|
Target::Parameter {
|
||||||
|
calculation: Id::from_u128(calculation),
|
||||||
|
parameter: a::ParameterId(Id::from_u128(parameter)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn sources() -> BTreeMap<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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user