Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaec78fcc9 | ||
|
|
71cadfa0cd | ||
|
|
b9a31bb848 | ||
|
|
8f0d96ca63 | ||
|
|
a578ac98a0 | ||
|
|
eeb1069cfc | ||
|
|
754a9b4b7c | ||
|
|
0fc0826435 | ||
|
|
10d7f3340d | ||
|
|
75bc285386 | ||
|
|
ded72c2339 | ||
|
|
1b1c3e3abb | ||
|
|
90fae14818 | ||
|
|
1f8283236f |
+201
-33
@@ -1,8 +1,8 @@
|
|||||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
|
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
|
||||||
|
mod raw_tokens;
|
||||||
#[path = "language/semantic_source.rs"]
|
#[path = "language/semantic_source.rs"]
|
||||||
mod semantic_source;
|
mod semantic_source;
|
||||||
mod source3_bridge;
|
mod source3_bridge;
|
||||||
mod raw_tokens;
|
|
||||||
// Staged syntax foundation only. Public source8 admission waits for complete
|
// Staged syntax foundation only. Public source8 admission waits for complete
|
||||||
// declaration lowering, source updates and the root-owned resource gate.
|
// declaration lowering, source updates and the root-owned resource gate.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -480,6 +480,8 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
value_depth: 0,
|
value_depth: 0,
|
||||||
limits,
|
limits,
|
||||||
|
#[cfg(test)]
|
||||||
|
mixed: None,
|
||||||
}
|
}
|
||||||
.document()
|
.document()
|
||||||
.map_err(|e| vec![diagnostic(source, e)])
|
.map_err(|e| vec![diagnostic(source, e)])
|
||||||
@@ -914,6 +916,8 @@ pub(crate) fn parse_unit(
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
value_depth: 0,
|
value_depth: 0,
|
||||||
limits,
|
limits,
|
||||||
|
#[cfg(test)]
|
||||||
|
mixed: None,
|
||||||
}
|
}
|
||||||
.unit(entry, true)
|
.unit(entry, true)
|
||||||
.map_err(|e| Box::new(diagnostic(source, e)))
|
.map_err(|e| Box::new(diagnostic(source, e)))
|
||||||
@@ -1005,31 +1009,52 @@ fn edit_positions(
|
|||||||
unit: SourceUnit,
|
unit: SourceUnit,
|
||||||
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
|
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
|
||||||
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
|
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
|
||||||
let mut ends: Vec<_> = tokens.iter().map(|token| token.span.end).collect();
|
edit_positions_from_tokens(source, &unit, |at| tokens[at], tokens.len(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_positions_from_tokens(
|
||||||
|
source: &str,
|
||||||
|
unit: &SourceUnit,
|
||||||
|
token: impl Fn(usize) -> Lexeme,
|
||||||
|
count: usize,
|
||||||
|
value_ends: Option<&BTreeMap<usize, usize>>,
|
||||||
|
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
|
||||||
|
let mut ends: Vec<_> = (0..count).map(|at| token(at).span.end).collect();
|
||||||
let mut stack = Vec::new();
|
let mut stack = Vec::new();
|
||||||
for (index, token) in tokens.iter().enumerate() {
|
for index in 0..count {
|
||||||
|
let token = token(index);
|
||||||
match token.text(source) {
|
match token.text(source) {
|
||||||
"{" | "[" => stack.push(index),
|
"{" | "[" => stack.push(index),
|
||||||
"}" | "]" => ends[stack.pop().expect("parser checked delimiters")] = token.span.end,
|
"}" | "]" => ends[stack.pop().expect("parser checked delimiters")] = token.span.end,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
struct Positions<'a> {
|
struct Positions<'a, F> {
|
||||||
tokens: &'a [Lexeme],
|
token_at: F,
|
||||||
ends: Vec<usize>,
|
ends: Vec<usize>,
|
||||||
|
value_ends: Option<&'a BTreeMap<usize, usize>>,
|
||||||
}
|
}
|
||||||
impl Positions<'_> {
|
impl<F: Fn(usize) -> Lexeme> Positions<'_, F> {
|
||||||
fn token(&self, start: usize) -> usize {
|
fn token(&self, start: usize) -> usize {
|
||||||
self.tokens
|
let mut low = 0;
|
||||||
.partition_point(|token| token.span.start < start)
|
let mut high = self.ends.len();
|
||||||
|
while low < high {
|
||||||
|
let middle = low + (high - low) / 2;
|
||||||
|
if (self.token_at)(middle).span.start < start {
|
||||||
|
low = middle + 1;
|
||||||
|
} else {
|
||||||
|
high = middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
low
|
||||||
}
|
}
|
||||||
fn fields(&self, fields: &BTreeMap<String, Ast>) -> BTreeMap<String, EditField> {
|
fn fields(&self, fields: &BTreeMap<String, Ast>) -> BTreeMap<String, EditField> {
|
||||||
fields
|
fields
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(key, ast)| {
|
.map(|(key, ast)| {
|
||||||
let value = self.value(ast);
|
let value = self.value(ast);
|
||||||
let start = self.tokens[self.token(value.span.start) - 1].span.start;
|
let start = (self.token_at)(self.token(value.span.start) - 1).span.start;
|
||||||
let end = self.tokens[self.token(value.span.end)].span.end;
|
let end = (self.token_at)(self.token(value.span.end)).span.end;
|
||||||
(
|
(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
EditField {
|
EditField {
|
||||||
@@ -1044,10 +1069,15 @@ fn edit_positions(
|
|||||||
let index = self.token(ast.offset());
|
let index = self.token(ast.offset());
|
||||||
let span = Span {
|
let span = Span {
|
||||||
start: ast.offset(),
|
start: ast.offset(),
|
||||||
end: match ast {
|
end: self
|
||||||
Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end,
|
.value_ends
|
||||||
_ => self.ends[index],
|
.and_then(|ends| ends.get(&ast.offset()).copied())
|
||||||
},
|
.unwrap_or_else(|| match ast {
|
||||||
|
Ast::Reference(..) | Ast::HistoricalUuid(..) => {
|
||||||
|
(self.token_at)(index + 1).span.end
|
||||||
|
}
|
||||||
|
_ => self.ends[index],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
let kind = match ast {
|
let kind = match ast {
|
||||||
Ast::Object(fields, _) => EditKind::Record(self.fields(fields)),
|
Ast::Object(fields, _) => EditKind::Record(self.fields(fields)),
|
||||||
@@ -1062,11 +1092,12 @@ fn edit_positions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let positions = Positions {
|
let positions = Positions {
|
||||||
tokens: &tokens,
|
token_at: &token,
|
||||||
ends,
|
ends,
|
||||||
|
value_ends,
|
||||||
};
|
};
|
||||||
unit.declarations
|
unit.declarations
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|declaration| {
|
.map(|declaration| {
|
||||||
if declaration.id_insertion.is_some() {
|
if declaration.id_insertion.is_some() {
|
||||||
return Err(Box::new(diagnostic(
|
return Err(Box::new(diagnostic(
|
||||||
@@ -1086,8 +1117,8 @@ fn edit_positions(
|
|||||||
let start = positions.token(declaration.span.start);
|
let start = positions.token(declaration.span.start);
|
||||||
let header = roles
|
let header = roles
|
||||||
.iter()
|
.iter()
|
||||||
.zip(&tokens[start..])
|
.zip(start..count)
|
||||||
.map(|(role, token)| (*role, token.span))
|
.map(|(role, at)| (*role, token(at).span))
|
||||||
.collect();
|
.collect();
|
||||||
Ok((
|
Ok((
|
||||||
declaration.id,
|
declaration.id,
|
||||||
@@ -1114,10 +1145,35 @@ struct Parser<'a> {
|
|||||||
cursor: usize,
|
cursor: usize,
|
||||||
value_depth: usize,
|
value_depth: usize,
|
||||||
limits: ParseLimits,
|
limits: ParseLimits,
|
||||||
|
#[cfg(test)]
|
||||||
|
mixed: Option<analysis_source::mixed::State<'a>>,
|
||||||
}
|
}
|
||||||
impl Parser<'_> {
|
impl Parser<'_> {
|
||||||
|
fn token(&self, index: usize) -> Option<Lexeme> {
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(mixed) = &self.mixed {
|
||||||
|
return mixed.token(index);
|
||||||
|
}
|
||||||
|
self.tokens.get(index).copied()
|
||||||
|
}
|
||||||
fn current(&self) -> Lexeme {
|
fn current(&self) -> Lexeme {
|
||||||
self.tokens[self.cursor.min(self.tokens.len() - 1)]
|
self.token(self.cursor).unwrap_or(Lexeme {
|
||||||
|
class: TokenClass::Punctuation,
|
||||||
|
span: Span {
|
||||||
|
start: self.source.len(),
|
||||||
|
end: self.source.len(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn syntax_entry(&mut self) -> Result<(), Error> {
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
let span = self.current().span;
|
||||||
|
if let Some(mixed) = &mut self.mixed {
|
||||||
|
mixed.entry(span)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
fn text(&self) -> &str {
|
fn text(&self) -> &str {
|
||||||
self.current().text(self.source)
|
self.current().text(self.source)
|
||||||
@@ -1154,6 +1210,10 @@ impl Parser<'_> {
|
|||||||
}
|
}
|
||||||
fn optional_id(&mut self) -> Result<Id, Error> {
|
fn optional_id(&mut self) -> Result<Id, Error> {
|
||||||
if self.text() != "id" {
|
if self.text() != "id" {
|
||||||
|
#[cfg(test)]
|
||||||
|
if self.mixed.is_some() {
|
||||||
|
return Err(self.error("Private mixed syntax requires explicit IDs."));
|
||||||
|
}
|
||||||
return Ok(Id::new_v4());
|
return Ok(Id::new_v4());
|
||||||
}
|
}
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
@@ -1194,6 +1254,7 @@ impl Parser<'_> {
|
|||||||
Ok(fields)
|
Ok(fields)
|
||||||
}
|
}
|
||||||
fn field(&mut self) -> Result<(String, Ast, Span), Error> {
|
fn field(&mut self) -> Result<(String, Ast, Span), Error> {
|
||||||
|
self.syntax_entry()?;
|
||||||
let key = self.text().to_owned();
|
let key = self.text().to_owned();
|
||||||
if !matches!(
|
if !matches!(
|
||||||
self.current().class,
|
self.current().class,
|
||||||
@@ -1204,15 +1265,19 @@ impl Parser<'_> {
|
|||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
let start = self.current().span.start;
|
let start = self.current().span.start;
|
||||||
let value = self.value()?;
|
let value = self.value()?;
|
||||||
let end = self.tokens[self.cursor - 1].span.end;
|
let end = self
|
||||||
|
.token(self.cursor - 1)
|
||||||
|
.expect("consumed token")
|
||||||
|
.span
|
||||||
|
.end;
|
||||||
self.take(";")?;
|
self.take(";")?;
|
||||||
Ok((key, value, Span { start, end }))
|
Ok((key, value, Span { start, end }))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nested_header_kind(&self) -> Option<DeclKind> {
|
fn nested_header_kind(&self) -> Option<DeclKind> {
|
||||||
let kind = declaration_kind(self.text())?;
|
let kind = declaration_kind(self.text())?;
|
||||||
let alias = self.tokens.get(self.cursor + 1)?;
|
let alias = self.token(self.cursor + 1)?;
|
||||||
let following = self.tokens.get(self.cursor + 2)?;
|
let following = self.token(self.cursor + 2)?;
|
||||||
// A node's ordinary `diagram @view;` field is not a declaration.
|
// A node's ordinary `diagram @view;` field is not a declaration.
|
||||||
if !crate::valid_alias(alias.text(self.source)) {
|
if !crate::valid_alias(alias.text(self.source)) {
|
||||||
return None;
|
return None;
|
||||||
@@ -1227,14 +1292,33 @@ impl Parser<'_> {
|
|||||||
if self.value_depth >= 64 {
|
if self.value_depth >= 64 {
|
||||||
return Err(self.error("Typed values may not nest more than 64 levels."));
|
return Err(self.error("Typed values may not nest more than 64 levels."));
|
||||||
}
|
}
|
||||||
|
self.syntax_entry()?;
|
||||||
self.value_depth += 1;
|
self.value_depth += 1;
|
||||||
let result = self.value_inner();
|
let result = self.value_inner();
|
||||||
self.value_depth -= 1;
|
self.value_depth -= 1;
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Ok(ast) = &result {
|
||||||
|
let end = self
|
||||||
|
.token(self.cursor - 1)
|
||||||
|
.expect("consumed value")
|
||||||
|
.span
|
||||||
|
.end;
|
||||||
|
if let Some(mixed) = &mut self.mixed {
|
||||||
|
mixed.value_ends.insert(ast.offset(), end);
|
||||||
|
}
|
||||||
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
fn value_inner(&mut self) -> Result<Ast, Error> {
|
fn value_inner(&mut self) -> Result<Ast, Error> {
|
||||||
let token = self.current();
|
let token = self.current();
|
||||||
let offset = token.span.start;
|
let offset = token.span.start;
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(mixed) = &self.mixed
|
||||||
|
&& (token.class == TokenClass::Number || matches!(self.text(), "-" | "+"))
|
||||||
|
{
|
||||||
|
let value = mixed.ordinary_number(&mut self.cursor)?;
|
||||||
|
return Ok(Ast::Literal(value, offset));
|
||||||
|
}
|
||||||
match self.text() {
|
match self.text() {
|
||||||
"uuid" => {
|
"uuid" => {
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
@@ -1332,16 +1416,31 @@ impl Parser<'_> {
|
|||||||
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
||||||
let mut header = if entry {
|
let mut header = if entry {
|
||||||
self.take("arcadia")?;
|
self.take("arcadia")?;
|
||||||
if !matches!(self.text(), "3" | "4" | "5" | "6" | "7") {
|
#[cfg(test)]
|
||||||
return Err(
|
let mixed_mode = self.mixed.is_some();
|
||||||
self.error("Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7.")
|
#[cfg(not(test))]
|
||||||
);
|
let mixed_mode = false;
|
||||||
|
if if mixed_mode {
|
||||||
|
self.text() != "8"
|
||||||
|
} else {
|
||||||
|
!matches!(self.text(), "3" | "4" | "5" | "6" | "7")
|
||||||
|
} {
|
||||||
|
return Err(self.error(if mixed_mode {
|
||||||
|
"Private mixed syntax requires arcadia 8."
|
||||||
|
} else {
|
||||||
|
"Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7."
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
let schema_version = self.text().parse().expect("checked source version");
|
let schema_version = self.text().parse().expect("checked source version");
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
self.take(";")?;
|
self.take(";")?;
|
||||||
let start = self.current().span.start;
|
let start = self.current().span.start;
|
||||||
self.take("project")?;
|
self.take("project")?;
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(mixed) = &mut self.mixed {
|
||||||
|
mixed.record(false, Span { start, end: start })?;
|
||||||
|
}
|
||||||
|
self.syntax_entry()?;
|
||||||
let alias = self.identifier()?;
|
let alias = self.identifier()?;
|
||||||
let name = self.quoted()?;
|
let name = self.quoted()?;
|
||||||
let id_insertion = (self.text() != "id").then_some(self.current().span.start);
|
let id_insertion = (self.text() != "id").then_some(self.current().span.start);
|
||||||
@@ -1382,6 +1481,14 @@ impl Parser<'_> {
|
|||||||
let mut open: Vec<usize> = Vec::new();
|
let mut open: Vec<usize> = Vec::new();
|
||||||
loop {
|
loop {
|
||||||
let offset = self.current().span.start;
|
let offset = self.current().span.start;
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(mixed) = &mut self.mixed
|
||||||
|
&& mixed.typed_header(self.cursor, open.is_empty())
|
||||||
|
{
|
||||||
|
let owner = open.last().map(|&at| declarations[at].id);
|
||||||
|
mixed.parse_typed(&mut self.cursor, owner)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(&owner) = open.last() {
|
if let Some(&owner) = open.last() {
|
||||||
if self.text() == "}" {
|
if self.text() == "}" {
|
||||||
declarations[owner].body.end = offset;
|
declarations[owner].body.end = offset;
|
||||||
@@ -1425,6 +1532,14 @@ impl Parser<'_> {
|
|||||||
if !includes_allowed {
|
if !includes_allowed {
|
||||||
return Err(self.error("An include requires a source bundle; single-string parsing never reads files."));
|
return Err(self.error("An include requires a source bundle; single-string parsing never reads files."));
|
||||||
}
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(mixed) = &mut self.mixed {
|
||||||
|
mixed.include(Span {
|
||||||
|
start: offset,
|
||||||
|
end: offset,
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
self.syntax_entry()?;
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
let path = self.quoted()?;
|
let path = self.quoted()?;
|
||||||
self.take(";")?;
|
self.take(";")?;
|
||||||
@@ -1432,7 +1547,11 @@ impl Parser<'_> {
|
|||||||
path,
|
path,
|
||||||
Span {
|
Span {
|
||||||
start: offset,
|
start: offset,
|
||||||
end: self.tokens[self.cursor - 1].span.end,
|
end: self
|
||||||
|
.token(self.cursor - 1)
|
||||||
|
.expect("consumed token")
|
||||||
|
.span
|
||||||
|
.end,
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
@@ -1444,20 +1563,40 @@ impl Parser<'_> {
|
|||||||
if entry_header.description.is_some() {
|
if entry_header.description.is_some() {
|
||||||
return Err(self.error("Duplicate project description."));
|
return Err(self.error("Duplicate project description."));
|
||||||
}
|
}
|
||||||
|
self.syntax_entry()?;
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
let description = self.quoted()?;
|
let description = self.quoted()?;
|
||||||
self.take(";")?;
|
self.take(";")?;
|
||||||
entry_header.project.description = description;
|
entry_header.project.description = description;
|
||||||
entry_header.description = Some(Span {
|
entry_header.description = Some(Span {
|
||||||
start: offset,
|
start: offset,
|
||||||
end: self.tokens[self.cursor - 1].span.end,
|
end: self
|
||||||
|
.token(self.cursor - 1)
|
||||||
|
.expect("consumed token")
|
||||||
|
.span
|
||||||
|
.end,
|
||||||
});
|
});
|
||||||
header = Some(entry_header);
|
header = Some(entry_header);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
if self.mixed.is_some() && analysis_source::mixed::long_analysis_word(self.text()) {
|
||||||
|
return Err(self.error("Use the short source8 analysis declaration word."));
|
||||||
|
}
|
||||||
|
self.syntax_entry()?;
|
||||||
let word = self.text().to_owned();
|
let word = self.text().to_owned();
|
||||||
let kind = declaration_kind(&word)
|
let kind = declaration_kind(&word)
|
||||||
.ok_or_else(|| self.error(format!("Unknown declaration '{word}'.")))?;
|
.ok_or_else(|| self.error(format!("Unknown declaration '{word}'.")))?;
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(mixed) = &mut self.mixed {
|
||||||
|
mixed.record(
|
||||||
|
matches!(kind, DeclKind::Element(..) | DeclKind::Relation(..)),
|
||||||
|
Span {
|
||||||
|
start: offset,
|
||||||
|
end: offset,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
}
|
||||||
graph_count += usize::from(matches!(
|
graph_count += usize::from(matches!(
|
||||||
kind,
|
kind,
|
||||||
DeclKind::Element(..) | DeclKind::Relation(..)
|
DeclKind::Element(..) | DeclKind::Relation(..)
|
||||||
@@ -1485,6 +1624,10 @@ impl Parser<'_> {
|
|||||||
let body_start = self.current().span.end;
|
let body_start = self.current().span.end;
|
||||||
self.take("{")?;
|
self.take("{")?;
|
||||||
let lexical_owner = open.last().map(|&owner| declarations[owner].id);
|
let lexical_owner = open.last().map(|&owner| declarations[owner].id);
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(mixed) = &mut self.mixed {
|
||||||
|
mixed.lexical_owners.push(open.last().copied());
|
||||||
|
}
|
||||||
declarations.push(Declaration {
|
declarations.push(Declaration {
|
||||||
kind,
|
kind,
|
||||||
alias,
|
alias,
|
||||||
@@ -1521,17 +1664,25 @@ impl Parser<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn resolve_declarations(
|
// The construction phase is shared with private mixed resolution. Migration and
|
||||||
|
// complete-model admission remain exclusively in each caller's finishing tail.
|
||||||
|
struct ConstructedDeclarations {
|
||||||
|
project: Project,
|
||||||
|
legacy_elements: serde_json::Map<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn construct_declarations<'a>(
|
||||||
mut project: Project,
|
mut project: Project,
|
||||||
declarations: &[Declaration],
|
declarations: impl IntoIterator<Item = &'a Declaration>,
|
||||||
aliases: &BTreeMap<String, Id>,
|
aliases: &BTreeMap<String, Id>,
|
||||||
limits: ParseLimits,
|
mut before_fields: impl FnMut(&Declaration) -> Result<(), Error>,
|
||||||
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
|
) -> Result<ConstructedDeclarations, (usize, Error)> {
|
||||||
let legacy = project.schema_version == 3;
|
let legacy = project.schema_version == 3;
|
||||||
let mut legacy_elements = serde_json::Map::new();
|
let mut legacy_elements = serde_json::Map::new();
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
for (index, declaration) in declarations.iter().enumerate() {
|
for (index, declaration) in declarations.into_iter().enumerate() {
|
||||||
(|| -> Result<(), Error> {
|
(|| -> Result<(), Error> {
|
||||||
|
before_fields(declaration)?;
|
||||||
let mut fields = declaration
|
let mut fields = declaration
|
||||||
.fields
|
.fields
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1756,6 +1907,23 @@ pub(crate) fn resolve_declarations(
|
|||||||
.map_err(|error| (index, error))?;
|
.map_err(|error| (index, error))?;
|
||||||
target.nodes.insert(node.id, node);
|
target.nodes.insert(node.id, node);
|
||||||
}
|
}
|
||||||
|
Ok(ConstructedDeclarations {
|
||||||
|
project,
|
||||||
|
legacy_elements,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_declarations(
|
||||||
|
project: Project,
|
||||||
|
declarations: &[Declaration],
|
||||||
|
aliases: &BTreeMap<String, Id>,
|
||||||
|
limits: ParseLimits,
|
||||||
|
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
|
||||||
|
let legacy = project.schema_version == 3;
|
||||||
|
let ConstructedDeclarations {
|
||||||
|
project,
|
||||||
|
legacy_elements,
|
||||||
|
} = construct_declarations(project, declarations, aliases, |_| Ok(()))?;
|
||||||
if legacy {
|
if legacy {
|
||||||
let migrated = source3_bridge::from_scratch(project, legacy_elements)
|
let migrated = source3_bridge::from_scratch(project, legacy_elements)
|
||||||
.and_then(|old| crate::persistence::v3::migrate(old, limits))
|
.and_then(|old| crate::persistence::v3::migrate(old, limits))
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
mod budget;
|
mod budget;
|
||||||
mod cursor;
|
mod cursor;
|
||||||
mod declarations;
|
mod declarations;
|
||||||
|
mod index;
|
||||||
mod lex;
|
mod lex;
|
||||||
mod lower;
|
mod lower;
|
||||||
|
pub(super) mod mixed;
|
||||||
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,
|
||||||
@@ -124,7 +124,7 @@ struct Symbol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Tables {
|
pub(super) struct Tables {
|
||||||
aliases: BTreeMap<String, Symbol>,
|
aliases: BTreeMap<String, Symbol>,
|
||||||
ids: BTreeSet<Id>,
|
ids: BTreeSet<Id>,
|
||||||
parameters: BTreeMap<Id, BTreeMap<String, a::ParameterId>>,
|
parameters: BTreeMap<Id, BTreeMap<String, a::ParameterId>>,
|
||||||
@@ -157,7 +157,7 @@ impl Tables {
|
|||||||
self.entries += 1;
|
self.entries += 1;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn insert(
|
pub(super) fn insert(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: Id,
|
id: Id,
|
||||||
alias: &str,
|
alias: &str,
|
||||||
@@ -233,7 +233,11 @@ impl Tables {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn typed(&mut self, declaration: &Declaration, budget: &Budget) -> Result<(), Error> {
|
pub(super) fn typed(
|
||||||
|
&mut self,
|
||||||
|
declaration: &Declaration,
|
||||||
|
budget: &Budget,
|
||||||
|
) -> Result<(), Error> {
|
||||||
let header = &declaration.header;
|
let header = &declaration.header;
|
||||||
let id = identity(&header.identity, header.alias.span)?;
|
let id = identity(&header.identity, header.alias.span)?;
|
||||||
self.insert(
|
self.insert(
|
||||||
@@ -299,31 +303,49 @@ pub(super) fn lower(
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lower_bodies(
|
||||||
|
&tables,
|
||||||
|
units
|
||||||
|
.iter()
|
||||||
|
.flat_map(|unit| unit.declarations.iter().map(move |d| (unit.file, d))),
|
||||||
|
budget,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catalog and borrowed declarations must be from the same private witness.
|
||||||
|
/// The mixed caller reserves aggregate live storage before entering this body;
|
||||||
|
/// the legacy wrapper retains its original individual phase checks.
|
||||||
|
pub(super) fn lower_bodies<'a>(
|
||||||
|
tables: &Tables,
|
||||||
|
declarations: impl IntoIterator<Item = (usize, &'a Declaration)>,
|
||||||
|
budget: &Budget,
|
||||||
|
) -> Result<Lowered, Failure> {
|
||||||
|
budget
|
||||||
|
.active()
|
||||||
|
.map_err(|error| Failure { file: None, error })?;
|
||||||
let mut output = Lowered {
|
let mut output = Lowered {
|
||||||
definitions: BTreeMap::new(),
|
definitions: BTreeMap::new(),
|
||||||
elements: BTreeMap::new(),
|
elements: BTreeMap::new(),
|
||||||
occurrences: Vec::new(),
|
occurrences: Vec::new(),
|
||||||
expressions: Vec::new(),
|
expressions: Vec::new(),
|
||||||
};
|
};
|
||||||
for unit in units {
|
for (file, declaration) in declarations {
|
||||||
for declaration in &unit.declarations {
|
let owner = declaration
|
||||||
let owner = declaration
|
.header
|
||||||
.header
|
.identity
|
||||||
.identity
|
.value
|
||||||
.value
|
.expect("collected stable identity");
|
||||||
.expect("collected stable identity");
|
let mut resolver = Resolver {
|
||||||
let mut resolver = Resolver {
|
tables,
|
||||||
tables: &tables,
|
output: &mut output,
|
||||||
output: &mut output,
|
budget,
|
||||||
budget,
|
file,
|
||||||
file: unit.file,
|
owner,
|
||||||
owner,
|
};
|
||||||
};
|
resolver.declaration(declaration).map_err(|error| Failure {
|
||||||
resolver.declaration(declaration).map_err(|error| Failure {
|
file: Some(file),
|
||||||
file: Some(unit.file),
|
error,
|
||||||
error,
|
})?;
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
//! Private syntax-only complete-bundle witness. No model admission or edit authority.
|
||||||
|
//!
|
||||||
|
//! One scan charges original bytes and raw tokens (including trivia). Typed
|
||||||
|
//! syntax keeps its existing entry/reference/numeric charges. Ordinary syntax
|
||||||
|
//! charges one entry per project/declaration/include/description, field, and
|
||||||
|
//! value before allocation; array values already account for their list slots.
|
||||||
|
//! These private storage ceilings are independent of caller model record counts.
|
||||||
|
//! Ordinary references consume value storage, not ordered-model-step allowance.
|
||||||
|
//! Typed reference occurrences retain the caller max_step_references ceiling.
|
||||||
|
//! Only declaration graph/record counts are known here; semantic nested-model
|
||||||
|
//! records and ordinary ordered steps still require the later complete admission.
|
||||||
|
//! Metadata phase slots aggregate across all files: retained token-end indices,
|
||||||
|
//! value-end entries, projected value/field/header/declaration positions, lexical
|
||||||
|
//! owner slots and the file table. Checks do not recharge syntax or latch errors;
|
||||||
|
//! every failure must return immediately. Tokens themselves use the token bound.
|
||||||
|
//! This deliberately bounded staging policy is not calibrated public source8.
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
budget::{Budget, Limits},
|
||||||
|
cursor::Cursor,
|
||||||
|
declarations, lex,
|
||||||
|
};
|
||||||
|
use crate::Id;
|
||||||
|
use crate::language::{
|
||||||
|
self, Ast, EditDeclaration, Error, Lexeme, ParseLimits, Parser, SourceUnit, Span, TokenClass,
|
||||||
|
};
|
||||||
|
use crate::source_project::{ProjectLimits, SourceBundle, SourceDiagnostic, traversal::Traversal};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
struct ModelCounts {
|
||||||
|
limits: ProjectLimits,
|
||||||
|
graph: usize,
|
||||||
|
records: usize,
|
||||||
|
includes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct ParsedMixedSyntax<'s> {
|
||||||
|
bundle: &'s SourceBundle,
|
||||||
|
// Preserve caller policy for the later semantic pass. Syntax storage uses
|
||||||
|
// separate ceilings and cannot stand in for these graph/record/step limits.
|
||||||
|
limits: ProjectLimits,
|
||||||
|
files: Vec<FileSyntax<'s>>,
|
||||||
|
budget: Budget,
|
||||||
|
// Includes the conservative dropped value-end subtotal. The semantic
|
||||||
|
// phase adds syntax entries to this once, not tokens/positions again.
|
||||||
|
metadata: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FileSyntax<'s> {
|
||||||
|
path: &'s str,
|
||||||
|
source: &'s str,
|
||||||
|
tokens: Vec<lex::Token>,
|
||||||
|
ordinary: SourceUnit,
|
||||||
|
typed: Vec<declarations::Declaration>,
|
||||||
|
positions: Vec<(Id, EditDeclaration)>,
|
||||||
|
// A lexical owner is an occurrence index, not only a possibly repeated UUID.
|
||||||
|
lexical_owners: Vec<Option<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only Parser borrows this state; the stored witness has no self-reference.
|
||||||
|
pub(in crate::language) struct State<'a> {
|
||||||
|
source: &'a str,
|
||||||
|
tokens: &'a [lex::Token],
|
||||||
|
budget: &'a mut Budget,
|
||||||
|
model: &'a mut ModelCounts,
|
||||||
|
typed: Vec<declarations::Declaration>,
|
||||||
|
pub(in crate::language) value_ends: BTreeMap<usize, usize>,
|
||||||
|
pub(in crate::language) lexical_owners: Vec<Option<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ordinary_error(error: super::Error) -> Error {
|
||||||
|
Error {
|
||||||
|
code: "invalid_source",
|
||||||
|
offset: error.span.start,
|
||||||
|
message: error.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lexeme(token: lex::Token) -> Lexeme {
|
||||||
|
Lexeme {
|
||||||
|
class: match token.class {
|
||||||
|
lex::Class::Word => TokenClass::Identifier,
|
||||||
|
lex::Class::Number => TokenClass::Number,
|
||||||
|
lex::Class::String => TokenClass::String,
|
||||||
|
lex::Class::Punctuation => TokenClass::Punctuation,
|
||||||
|
},
|
||||||
|
span: token.span,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State<'_> {
|
||||||
|
pub(in crate::language) fn token(&self, at: usize) -> Option<Lexeme> {
|
||||||
|
self.tokens.get(at).copied().map(lexeme)
|
||||||
|
}
|
||||||
|
pub(in crate::language) fn entry(&mut self, at: Span) -> Result<(), Error> {
|
||||||
|
self.budget.entry(at).map_err(ordinary_error)
|
||||||
|
}
|
||||||
|
pub(in crate::language) fn record(&mut self, graph: bool, at: Span) -> Result<(), Error> {
|
||||||
|
let records = self.model.records.checked_add(1);
|
||||||
|
let graph = self.model.graph.checked_add(usize::from(graph));
|
||||||
|
let (Some(records), Some(graph)) = (records, graph) else {
|
||||||
|
return Err(Error {
|
||||||
|
code: "model_limit",
|
||||||
|
offset: at.start,
|
||||||
|
message: "Declaration count overflow.".into(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
self.model
|
||||||
|
.limits
|
||||||
|
.model
|
||||||
|
.check_counts(graph, records, 0)
|
||||||
|
.map_err(|message| Error {
|
||||||
|
code: "model_limit",
|
||||||
|
offset: at.start,
|
||||||
|
message,
|
||||||
|
})?;
|
||||||
|
self.model.records = records;
|
||||||
|
self.model.graph = graph;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub(in crate::language) fn include(&mut self, at: Span) -> Result<(), Error> {
|
||||||
|
if self.model.includes >= self.model.limits.max_include_edges {
|
||||||
|
return Err(Error {
|
||||||
|
code: "project_limit",
|
||||||
|
offset: at.start,
|
||||||
|
message: format!(
|
||||||
|
"Include graph exceeds {} edges.",
|
||||||
|
self.model.limits.max_include_edges
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.model.includes += 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub(in crate::language) fn ordinary_number(
|
||||||
|
&self,
|
||||||
|
at: &mut usize,
|
||||||
|
) -> Result<serde_json::Value, Error> {
|
||||||
|
let first = self.tokens[*at];
|
||||||
|
let mut end = first.span.end;
|
||||||
|
let mut after = *at + 1;
|
||||||
|
// Match the old greedy numeric alphabet only across byte-adjacent raw
|
||||||
|
// tokens. This is consumption of the shared stream, not another scan.
|
||||||
|
while let Some(next) = self.tokens.get(after) {
|
||||||
|
if next.span.start != end
|
||||||
|
|| !self.source[next.span.start..next.span.end]
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || b"_.+-".contains(&byte))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
end = next.span.end;
|
||||||
|
after += 1;
|
||||||
|
}
|
||||||
|
let value: serde_json::Number = serde_json::from_str(&self.source[first.span.start..end])
|
||||||
|
.map_err(|_| Error {
|
||||||
|
code: "invalid_source",
|
||||||
|
offset: first.span.start,
|
||||||
|
message: "Expected a byte-adjacent finite JSON decimal number.".into(),
|
||||||
|
})?;
|
||||||
|
*at = after;
|
||||||
|
Ok(serde_json::Value::Number(value))
|
||||||
|
}
|
||||||
|
pub(in crate::language) fn typed_header(&self, at: usize, root: bool) -> bool {
|
||||||
|
let Some(token) = self.tokens.get(at) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if declarations::declaration_kind(&self.source[token.span.start..token.span.end]).is_none()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A generic field named `unit` remains an ordinary field. A declaration
|
||||||
|
// header has an alias and display-name token; only that shape is nested.
|
||||||
|
root || (self
|
||||||
|
.tokens
|
||||||
|
.get(at + 1)
|
||||||
|
.is_some_and(|t| t.class == lex::Class::Word)
|
||||||
|
&& self
|
||||||
|
.tokens
|
||||||
|
.get(at + 2)
|
||||||
|
.is_some_and(|t| t.class == lex::Class::String))
|
||||||
|
}
|
||||||
|
pub(in crate::language) fn parse_typed(
|
||||||
|
&mut self,
|
||||||
|
at: &mut usize,
|
||||||
|
owner: Option<Id>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
// Transfer the one active position into the typed consumer and back;
|
||||||
|
// neither consumer scans, clones a stream, nor runs concurrently.
|
||||||
|
self.record(true, self.tokens[*at].span)?;
|
||||||
|
let mut cursor = Cursor::new(self.source, self.tokens, self.budget);
|
||||||
|
cursor.cursor = *at;
|
||||||
|
let result = declarations::parse(&mut cursor, 8, owner);
|
||||||
|
*at = cursor.cursor;
|
||||||
|
let declaration = result.map_err(ordinary_error)?;
|
||||||
|
let missing = declaration.header.identity.value.is_none()
|
||||||
|
|| matches!(&declaration.body, declarations::Body::Calculation { parameters, .. }
|
||||||
|
if parameters.iter().any(|p| p.identity.value.is_none()));
|
||||||
|
if missing {
|
||||||
|
return Err(Error {
|
||||||
|
code: "invalid_source",
|
||||||
|
offset: declaration.span.start,
|
||||||
|
message: "Private mixed syntax requires explicit IDs.".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.typed.push(declaration);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::language) fn long_analysis_word(word: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
word,
|
||||||
|
"oa_engineering_property"
|
||||||
|
| "sa_engineering_property"
|
||||||
|
| "oa_analysis_check"
|
||||||
|
| "sa_analysis_check"
|
||||||
|
| "oa_analysis_case"
|
||||||
|
| "sa_analysis_case"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse(
|
||||||
|
bundle: &SourceBundle,
|
||||||
|
limits: ProjectLimits,
|
||||||
|
) -> Result<ParsedMixedSyntax<'_>, Vec<SourceDiagnostic>> {
|
||||||
|
let profile = Limits::source8(ParseLimits {
|
||||||
|
// Storage is not the caller's final model record/ordered-step accounting.
|
||||||
|
max_source_bytes: limits.model.max_source_bytes,
|
||||||
|
max_step_references: limits.model.max_step_references,
|
||||||
|
..ParseLimits::UNLIMITED
|
||||||
|
});
|
||||||
|
parse_with_profile(bundle, limits, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn located(path: &str, source: &str, error: Error) -> Vec<SourceDiagnostic> {
|
||||||
|
vec![SourceDiagnostic {
|
||||||
|
path: path.into(),
|
||||||
|
diagnostic: language::diagnostic(source, error),
|
||||||
|
related: Vec::new(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_with_profile(
|
||||||
|
bundle: &SourceBundle,
|
||||||
|
limits: ProjectLimits,
|
||||||
|
profile: Limits,
|
||||||
|
) -> Result<ParsedMixedSyntax<'_>, Vec<SourceDiagnostic>> {
|
||||||
|
let entry_source = bundle.files.get(&bundle.entry).map_or("", String::as_str);
|
||||||
|
let at_entry = |error| located(&bundle.entry, entry_source, ordinary_error(error));
|
||||||
|
let maximum = Limits::source8(ParseLimits {
|
||||||
|
max_source_bytes: limits.model.max_source_bytes,
|
||||||
|
max_step_references: limits.model.max_step_references,
|
||||||
|
..ParseLimits::UNLIMITED
|
||||||
|
});
|
||||||
|
if profile.bytes > maximum.bytes
|
||||||
|
|| profile.tokens > maximum.tokens
|
||||||
|
|| profile.entries > maximum.entries
|
||||||
|
|| profile.references > maximum.references
|
||||||
|
|| profile.frames > maximum.frames
|
||||||
|
|| profile.numeric_units > maximum.numeric_units
|
||||||
|
{
|
||||||
|
return Err(at_entry(super::error(
|
||||||
|
Span::default(),
|
||||||
|
"Mixed test profile may only tighten inherited limits.",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut budget = Budget::new(profile).map_err(at_entry)?;
|
||||||
|
let bytes = bundle
|
||||||
|
.files
|
||||||
|
.values()
|
||||||
|
.try_fold(0usize, |n, s| n.checked_add(s.len()))
|
||||||
|
.ok_or_else(|| at_entry(super::error(Span::default(), "Source size overflow.")))?;
|
||||||
|
budget
|
||||||
|
.index_bytes(bytes, Span::default())
|
||||||
|
.map_err(at_entry)?;
|
||||||
|
let mut metadata = bundle.files.len();
|
||||||
|
budget
|
||||||
|
.lowering_entries(metadata, Span::default())
|
||||||
|
.map_err(at_entry)?;
|
||||||
|
// Tighter private byte/file-slot limits precede folded-path table and
|
||||||
|
// traversal allocations. Public Traversal's own validation order is intact.
|
||||||
|
let mut traversal = Traversal::new(bundle, limits)?;
|
||||||
|
let mut parsed = BTreeMap::new();
|
||||||
|
let mut model = ModelCounts {
|
||||||
|
limits,
|
||||||
|
graph: 0,
|
||||||
|
records: 0,
|
||||||
|
includes: 0,
|
||||||
|
};
|
||||||
|
while let Some(path) = traversal.next()? {
|
||||||
|
let (path, source) = bundle
|
||||||
|
.files
|
||||||
|
.get_key_value(&path)
|
||||||
|
.expect("traversal checked source");
|
||||||
|
let failure = |error| located(path, source, ordinary_error(error));
|
||||||
|
let tokens = lex::tokens(source, &mut budget).map_err(failure)?;
|
||||||
|
let mut parser = Parser {
|
||||||
|
source,
|
||||||
|
tokens: Vec::new(),
|
||||||
|
cursor: 0,
|
||||||
|
value_depth: 0,
|
||||||
|
limits: limits.model,
|
||||||
|
mixed: Some(State {
|
||||||
|
source,
|
||||||
|
tokens: &tokens,
|
||||||
|
budget: &mut budget,
|
||||||
|
model: &mut model,
|
||||||
|
typed: Vec::new(),
|
||||||
|
value_ends: BTreeMap::new(),
|
||||||
|
lexical_owners: Vec::new(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let ordinary = parser
|
||||||
|
.unit(path == &bundle.entry, true)
|
||||||
|
.map_err(|e| located(path, source, e))?;
|
||||||
|
let state = parser.mixed.take().expect("private parser state");
|
||||||
|
drop(parser);
|
||||||
|
// Projected ordinary AST nodes/fields are bounded before constructing a
|
||||||
|
// second position representation. Count references as slots, not syntax.
|
||||||
|
let projection_slots = ordinary_position_slots(&ordinary);
|
||||||
|
metadata = metadata
|
||||||
|
.checked_add(tokens.len())
|
||||||
|
.and_then(|n| n.checked_add(state.value_ends.len()))
|
||||||
|
.and_then(|n| n.checked_add(projection_slots))
|
||||||
|
.and_then(|n| n.checked_add(state.lexical_owners.len()))
|
||||||
|
.ok_or_else(|| failure(super::error(Span::default(), "Metadata size overflow.")))?;
|
||||||
|
state
|
||||||
|
.budget
|
||||||
|
.lowering_entries(metadata, Span::default())
|
||||||
|
.map_err(failure)?;
|
||||||
|
let positions = language::edit_positions_from_tokens(
|
||||||
|
source,
|
||||||
|
&ordinary,
|
||||||
|
|at| lexeme(tokens[at]),
|
||||||
|
tokens.len(),
|
||||||
|
Some(&state.value_ends),
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
vec![SourceDiagnostic {
|
||||||
|
path: path.clone(),
|
||||||
|
diagnostic: *e,
|
||||||
|
related: Vec::new(),
|
||||||
|
}]
|
||||||
|
})?;
|
||||||
|
for (raw, span) in &ordinary.includes {
|
||||||
|
traversal.include(path, raw, *span)?;
|
||||||
|
}
|
||||||
|
traversal.finish_file(path.clone());
|
||||||
|
let State {
|
||||||
|
typed,
|
||||||
|
lexical_owners,
|
||||||
|
..
|
||||||
|
} = state;
|
||||||
|
parsed.insert(
|
||||||
|
path.as_str(),
|
||||||
|
FileSyntax {
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
tokens,
|
||||||
|
ordinary,
|
||||||
|
typed,
|
||||||
|
positions,
|
||||||
|
lexical_owners,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
traversal.finish()?;
|
||||||
|
budget.active().map_err(at_entry)?;
|
||||||
|
Ok(ParsedMixedSyntax {
|
||||||
|
bundle,
|
||||||
|
limits,
|
||||||
|
files: parsed.into_values().collect(),
|
||||||
|
budget,
|
||||||
|
metadata,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ordinary_position_slots(unit: &SourceUnit) -> usize {
|
||||||
|
// Iterative accounting avoids adding a second recursive walk before the
|
||||||
|
// already depth-bounded position projection. Each reference is one value.
|
||||||
|
let mut slots = 0usize;
|
||||||
|
let mut stack = Vec::new();
|
||||||
|
for declaration in &unit.declarations {
|
||||||
|
slots = slots.saturating_add(7); // declaration/body + at most five header roles
|
||||||
|
slots = slots.saturating_add(declaration.fields.len());
|
||||||
|
stack.extend(declaration.fields.values());
|
||||||
|
}
|
||||||
|
while let Some(value) = stack.pop() {
|
||||||
|
slots = slots.saturating_add(1);
|
||||||
|
match value {
|
||||||
|
Ast::Array(items, _) => stack.extend(items),
|
||||||
|
Ast::Object(fields, _) => {
|
||||||
|
slots = slots.saturating_add(fields.len());
|
||||||
|
stack.extend(fields.values());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slots
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
pub(super) mod project;
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
//! Private complete-Project admission from the actual complete syntax witness.
|
||||||
|
//! No Index, update authority, public source8 dispatch or replacement policy.
|
||||||
|
use super::super::{
|
||||||
|
Binding, Budget, Error as TypedError, Expression, ExpressionKind, Reference, Span,
|
||||||
|
declarations as d, error, lower,
|
||||||
|
};
|
||||||
|
use super::{FileSyntax, ParsedMixedSyntax};
|
||||||
|
use crate::language::{Ast, DeclKind};
|
||||||
|
use crate::source_project::{SourceDiagnostic, SourceLocation};
|
||||||
|
use crate::{Id, Kind, Project, RelationKind, analysis, language, validation};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
mod accounting;
|
||||||
|
use accounting::{Ledger, Slots, add};
|
||||||
|
|
||||||
|
/// Read-only authority: every component originates in the consumed syntax.
|
||||||
|
pub(in crate::language::analysis_source) struct ResolvedMixedProject<'s> {
|
||||||
|
syntax: ParsedMixedSyntax<'s>,
|
||||||
|
project: Project,
|
||||||
|
warnings: Vec<SourceDiagnostic>,
|
||||||
|
static_limits: analysis::AdmissionLimits,
|
||||||
|
static_usage: analysis::AdmissionUsage,
|
||||||
|
expressions: Vec<lower::ExpressionSite>,
|
||||||
|
occurrences: Vec<lower::Occurrence>,
|
||||||
|
storage: Ledger,
|
||||||
|
}
|
||||||
|
impl ResolvedMixedProject<'_> {
|
||||||
|
pub fn project(&self) -> &Project {
|
||||||
|
&self.project
|
||||||
|
}
|
||||||
|
pub fn warnings(&self) -> &[SourceDiagnostic] {
|
||||||
|
&self.warnings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(in crate::language::analysis_source) struct Failure {
|
||||||
|
pub diagnostics: Vec<SourceDiagnostic>,
|
||||||
|
}
|
||||||
|
impl Failure {
|
||||||
|
// Rejection transport is not retained in a successful witness. In particular
|
||||||
|
// a storage refusal needs a small diagnostic even when its allowance is zero.
|
||||||
|
fn typed(syntax: &ParsedMixedSyntax<'_>, file: Option<usize>, e: TypedError) -> Self {
|
||||||
|
let mut failure = Self::ordinary(
|
||||||
|
syntax,
|
||||||
|
file,
|
||||||
|
language::Error {
|
||||||
|
code: "invalid_source",
|
||||||
|
offset: e.span.start,
|
||||||
|
message: e.message,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
failure.diagnostics[0].diagnostic.span = e.span;
|
||||||
|
failure
|
||||||
|
}
|
||||||
|
fn ordinary(syntax: &ParsedMixedSyntax<'_>, file: Option<usize>, e: language::Error) -> Self {
|
||||||
|
let file = file.unwrap_or_else(|| entry(syntax));
|
||||||
|
let unit = &syntax.files[file];
|
||||||
|
Self {
|
||||||
|
diagnostics: super::located(unit.path, unit.source, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum OwnerKind {
|
||||||
|
Project,
|
||||||
|
Ordinary(usize),
|
||||||
|
Typed(usize),
|
||||||
|
}
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Owner {
|
||||||
|
file: usize,
|
||||||
|
kind: OwnerKind,
|
||||||
|
span: Span,
|
||||||
|
}
|
||||||
|
struct Catalog {
|
||||||
|
tables: lower::Tables,
|
||||||
|
aliases: BTreeMap<String, Id>,
|
||||||
|
owners: BTreeMap<Id, Owner>,
|
||||||
|
tables_storage: Slots,
|
||||||
|
aliases_storage: Slots,
|
||||||
|
owners_storage: Slots,
|
||||||
|
}
|
||||||
|
fn entry(syntax: &ParsedMixedSyntax<'_>) -> usize {
|
||||||
|
syntax
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.position(|f| f.path == syntax.bundle.entry)
|
||||||
|
.expect("complete entry")
|
||||||
|
}
|
||||||
|
fn category(kind: DeclKind) -> lower::Category {
|
||||||
|
match kind {
|
||||||
|
DeclKind::Element(kind, layer) => lower::Category::Element(kind, layer),
|
||||||
|
DeclKind::Relation(_) => lower::Category::Relation,
|
||||||
|
DeclKind::Diagram => lower::Category::Diagram,
|
||||||
|
DeclKind::Node => lower::Category::Node,
|
||||||
|
DeclKind::Artifact => lower::Category::Artifact,
|
||||||
|
DeclKind::Trace => lower::Category::Trace,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Catalog {
|
||||||
|
fn insert(
|
||||||
|
&mut self,
|
||||||
|
syntax: &ParsedMixedSyntax<'_>,
|
||||||
|
ledger: &mut Ledger,
|
||||||
|
id: Id,
|
||||||
|
alias: &str,
|
||||||
|
owner: Owner,
|
||||||
|
) -> Result<(), Failure> {
|
||||||
|
// Alias first, even when both aliases and IDs collide. Owner is an
|
||||||
|
// occurrence position, never a lookup through the rejected UUID.
|
||||||
|
let prior = self
|
||||||
|
.aliases
|
||||||
|
.get(alias)
|
||||||
|
.map(|id| ("alias", self.owners[id]))
|
||||||
|
.or_else(|| self.owners.get(&id).map(|o| ("identity", *o)));
|
||||||
|
if let Some((domain, prior)) = prior {
|
||||||
|
let mut failure = Failure::typed(
|
||||||
|
syntax,
|
||||||
|
Some(owner.file),
|
||||||
|
error(owner.span, format!("Duplicate global {domain}.")),
|
||||||
|
);
|
||||||
|
failure.diagnostics[0].related.push(SourceLocation {
|
||||||
|
path: syntax.files[prior.file].path.into(),
|
||||||
|
span: prior.span,
|
||||||
|
});
|
||||||
|
return Err(failure);
|
||||||
|
}
|
||||||
|
let alias_slots = Slots {
|
||||||
|
entries: 1,
|
||||||
|
text: alias.len(),
|
||||||
|
..Slots::default()
|
||||||
|
};
|
||||||
|
ledger
|
||||||
|
.reserve(
|
||||||
|
&syntax.budget,
|
||||||
|
alias_slots
|
||||||
|
.add(Slots::entries(1), owner.span)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(owner.file), e))?,
|
||||||
|
owner.span,
|
||||||
|
)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(owner.file), e))?;
|
||||||
|
self.aliases_storage = self
|
||||||
|
.aliases_storage
|
||||||
|
.add(alias_slots, owner.span)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(owner.file), e))?;
|
||||||
|
self.owners_storage = self
|
||||||
|
.owners_storage
|
||||||
|
.add(Slots::entries(1), owner.span)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(owner.file), e))?;
|
||||||
|
self.aliases.insert(alias.into(), id);
|
||||||
|
self.owners.insert(id, owner);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn reserve_tables(
|
||||||
|
&mut self,
|
||||||
|
syntax: &ParsedMixedSyntax<'_>,
|
||||||
|
ledger: &mut Ledger,
|
||||||
|
slots: Slots,
|
||||||
|
owner: Owner,
|
||||||
|
) -> Result<(), Failure> {
|
||||||
|
ledger
|
||||||
|
.reserve(&syntax.budget, slots, owner.span)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(owner.file), e))?;
|
||||||
|
self.tables_storage = self
|
||||||
|
.tables_storage
|
||||||
|
.add(slots, owner.span)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(owner.file), e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn catalog(syntax: &ParsedMixedSyntax<'_>, ledger: &mut Ledger) -> Result<Catalog, Failure> {
|
||||||
|
let mut catalog = Catalog {
|
||||||
|
tables: lower::Tables::default(),
|
||||||
|
aliases: BTreeMap::new(),
|
||||||
|
owners: BTreeMap::new(),
|
||||||
|
tables_storage: Slots::default(),
|
||||||
|
aliases_storage: Slots::default(),
|
||||||
|
owners_storage: Slots::default(),
|
||||||
|
};
|
||||||
|
let file = entry(syntax);
|
||||||
|
let header = syntax.files[file]
|
||||||
|
.ordinary
|
||||||
|
.header
|
||||||
|
.as_ref()
|
||||||
|
.expect("entry header");
|
||||||
|
let owner = Owner {
|
||||||
|
file,
|
||||||
|
kind: OwnerKind::Project,
|
||||||
|
span: header.span,
|
||||||
|
};
|
||||||
|
catalog.insert(
|
||||||
|
syntax,
|
||||||
|
ledger,
|
||||||
|
header.project.id,
|
||||||
|
&header.project.alias,
|
||||||
|
owner,
|
||||||
|
)?;
|
||||||
|
catalog.reserve_tables(
|
||||||
|
syntax,
|
||||||
|
ledger,
|
||||||
|
Slots {
|
||||||
|
entries: 2,
|
||||||
|
text: header.project.alias.len(),
|
||||||
|
..Slots::default()
|
||||||
|
},
|
||||||
|
owner,
|
||||||
|
)?;
|
||||||
|
catalog
|
||||||
|
.tables
|
||||||
|
.insert(
|
||||||
|
header.project.id,
|
||||||
|
&header.project.alias,
|
||||||
|
lower::Category::Project,
|
||||||
|
header.span,
|
||||||
|
&syntax.budget,
|
||||||
|
)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||||
|
for (file, unit) in syntax.files.iter().enumerate() {
|
||||||
|
let mut ordinary = unit.ordinary.declarations.iter().enumerate().peekable();
|
||||||
|
let mut typed = unit.typed.iter().enumerate().peekable();
|
||||||
|
while ordinary.peek().is_some() || typed.peek().is_some() {
|
||||||
|
let use_ordinary = match (ordinary.peek(), typed.peek()) {
|
||||||
|
(Some((_, a)), Some((_, b))) => a.span.start < b.span.start,
|
||||||
|
(Some(_), None) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if use_ordinary {
|
||||||
|
let (index, d) = ordinary.next().expect("ordinary head");
|
||||||
|
let owner = Owner {
|
||||||
|
file,
|
||||||
|
kind: OwnerKind::Ordinary(index),
|
||||||
|
span: d.span,
|
||||||
|
};
|
||||||
|
catalog.insert(syntax, ledger, d.id, &d.alias, owner)?;
|
||||||
|
catalog.reserve_tables(
|
||||||
|
syntax,
|
||||||
|
ledger,
|
||||||
|
Slots {
|
||||||
|
entries: 2,
|
||||||
|
text: d.alias.len(),
|
||||||
|
..Slots::default()
|
||||||
|
},
|
||||||
|
owner,
|
||||||
|
)?;
|
||||||
|
catalog
|
||||||
|
.tables
|
||||||
|
.insert(d.id, &d.alias, category(d.kind), d.span, &syntax.budget)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||||
|
} else {
|
||||||
|
let (index, d) = typed.next().expect("typed head");
|
||||||
|
let owner = Owner {
|
||||||
|
file,
|
||||||
|
kind: OwnerKind::Typed(index),
|
||||||
|
span: d.span,
|
||||||
|
};
|
||||||
|
catalog.insert(
|
||||||
|
syntax,
|
||||||
|
ledger,
|
||||||
|
d.header.identity.value.expect("syntax explicit ID"),
|
||||||
|
&d.header.alias.name,
|
||||||
|
owner,
|
||||||
|
)?;
|
||||||
|
let mut slots = Slots {
|
||||||
|
entries: 2,
|
||||||
|
text: d.header.alias.name.len(),
|
||||||
|
..Slots::default()
|
||||||
|
};
|
||||||
|
let mut scratch = 0;
|
||||||
|
if let d::Body::Calculation { parameters, .. } = &d.body {
|
||||||
|
slots.entries = add(
|
||||||
|
slots.entries,
|
||||||
|
add(1, parameters.len(), d.span)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(file), e))?,
|
||||||
|
d.span,
|
||||||
|
)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||||
|
scratch = parameters.len(); // per-signature duplicate-ID set
|
||||||
|
for p in parameters {
|
||||||
|
slots.text = add(slots.text, p.name.name.len(), p.span)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catalog.reserve_tables(syntax, ledger, slots, owner)?;
|
||||||
|
ledger
|
||||||
|
.reserve(&syntax.budget, Slots::entries(scratch), d.span)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||||
|
catalog
|
||||||
|
.tables
|
||||||
|
.typed(d, &syntax.budget)
|
||||||
|
.map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||||
|
ledger.release(Slots::entries(scratch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(catalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object<'a>(value: Option<&'a Ast>, key: &str) -> Option<&'a Ast> {
|
||||||
|
match value {
|
||||||
|
Some(Ast::Object(fields, _)) => fields.get(key),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Selector only, not a decoder. This executes before ordinary fields clone.
|
||||||
|
fn active_references(
|
||||||
|
declaration: &language::Declaration,
|
||||||
|
budget: &mut Budget,
|
||||||
|
) -> Result<(), language::Error> {
|
||||||
|
let field = |key: &str| declaration.fields.get(key);
|
||||||
|
let arrays = match declaration.kind {
|
||||||
|
DeclKind::Element(Kind::Process | Kind::FunctionalChain | Kind::Scenario, _) => {
|
||||||
|
[field("steps"), None]
|
||||||
|
}
|
||||||
|
DeclKind::Element(Kind::Occurrence, _) => {
|
||||||
|
[object(object(field("body"), "Branch"), "occurrences"), None]
|
||||||
|
}
|
||||||
|
DeclKind::Relation(RelationKind::ContextualLink) => [
|
||||||
|
object(field("source"), "references"),
|
||||||
|
object(field("target"), "references"),
|
||||||
|
],
|
||||||
|
_ => [None, None],
|
||||||
|
};
|
||||||
|
for array in arrays.into_iter().flatten() {
|
||||||
|
if let Ast::Array(items, _) = array {
|
||||||
|
for item in items {
|
||||||
|
let start = item.offset();
|
||||||
|
budget
|
||||||
|
.reference(Span { start, end: start })
|
||||||
|
.map_err(super::ordinary_error)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::language::analysis_source) fn resolve(
|
||||||
|
mut syntax: ParsedMixedSyntax<'_>,
|
||||||
|
) -> Result<ResolvedMixedProject<'_>, Failure> {
|
||||||
|
let mut storage = Ledger::default();
|
||||||
|
let lower_storage = accounting::syntax(&syntax, &mut storage)?;
|
||||||
|
let catalog = catalog(&syntax, &mut storage)?;
|
||||||
|
let entry = entry(&syntax);
|
||||||
|
// Only the empty model header is copied: retain its original syntax/header
|
||||||
|
// ownership and construct the final model through the shared decoder once.
|
||||||
|
let project = syntax.files[entry]
|
||||||
|
.ordinary
|
||||||
|
.header
|
||||||
|
.as_ref()
|
||||||
|
.expect("entry header")
|
||||||
|
.project
|
||||||
|
.clone();
|
||||||
|
let count = syntax
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.try_fold(0usize, |n, f| {
|
||||||
|
add(n, f.ordinary.declarations.len(), Span::default())
|
||||||
|
})
|
||||||
|
.map_err(|e| Failure::typed(&syntax, None, e))?;
|
||||||
|
storage
|
||||||
|
.reserve(&syntax.budget, Slots::entries(count), Span::default())
|
||||||
|
.map_err(|e| Failure::typed(&syntax, None, e))?;
|
||||||
|
let declarations: Vec<_> = syntax
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.flat_map(|(file, unit)| unit.ordinary.declarations.iter().map(move |d| (file, d)))
|
||||||
|
.collect();
|
||||||
|
let mut nodes = 0;
|
||||||
|
let constructed = language::construct_declarations(
|
||||||
|
project,
|
||||||
|
declarations.iter().map(|(_, d)| *d),
|
||||||
|
&catalog.aliases,
|
||||||
|
|d| {
|
||||||
|
active_references(d, &mut syntax.budget)?;
|
||||||
|
if matches!(d.kind, DeclKind::Node) {
|
||||||
|
storage
|
||||||
|
.reserve(&syntax.budget, Slots::entries(1), d.span)
|
||||||
|
.map_err(super::ordinary_error)?;
|
||||||
|
nodes += 1;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|(index, error)| {
|
||||||
|
Failure::ordinary(
|
||||||
|
&syntax,
|
||||||
|
declarations.get(index).map(|(file, _)| *file),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let language::ConstructedDeclarations {
|
||||||
|
mut project,
|
||||||
|
legacy_elements,
|
||||||
|
} = constructed;
|
||||||
|
debug_assert!(legacy_elements.is_empty()); // mixed syntax only admits source8
|
||||||
|
drop(legacy_elements);
|
||||||
|
drop(declarations);
|
||||||
|
storage.release(Slots::entries(count));
|
||||||
|
storage.release(Slots::entries(nodes));
|
||||||
|
drop(catalog.aliases);
|
||||||
|
storage.release(catalog.aliases_storage);
|
||||||
|
|
||||||
|
let all_lower = lower_storage
|
||||||
|
.all()
|
||||||
|
.map_err(|e| Failure::typed(&syntax, None, e))?;
|
||||||
|
storage
|
||||||
|
.reserve(&syntax.budget, all_lower, Span::default())
|
||||||
|
.map_err(|e| Failure::typed(&syntax, None, e))?;
|
||||||
|
let lower::Lowered {
|
||||||
|
definitions,
|
||||||
|
elements,
|
||||||
|
occurrences,
|
||||||
|
expressions,
|
||||||
|
} = lower::lower_bodies(
|
||||||
|
&catalog.tables,
|
||||||
|
syntax
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.flat_map(|(file, unit)| unit.typed.iter().map(move |d| (file, d))),
|
||||||
|
&syntax.budget,
|
||||||
|
)
|
||||||
|
.map_err(|e| Failure::typed(&syntax, e.file, e.error))?;
|
||||||
|
// One global arena transfers verbatim; only now are all Resolver temporaries
|
||||||
|
// dead. Output counts must fit the independently counted reservation.
|
||||||
|
storage.release(Slots {
|
||||||
|
entries: all_lower
|
||||||
|
.entries
|
||||||
|
.checked_sub(expressions.len())
|
||||||
|
.expect("expression bound"),
|
||||||
|
occurrences: all_lower
|
||||||
|
.occurrences
|
||||||
|
.checked_sub(occurrences.len())
|
||||||
|
.expect("occurrence bound"),
|
||||||
|
text: 0,
|
||||||
|
});
|
||||||
|
drop(catalog.tables);
|
||||||
|
storage.release(catalog.tables_storage);
|
||||||
|
for (id, value) in definitions {
|
||||||
|
if project.definitions.contains_key(&id) {
|
||||||
|
return Err(Failure::typed(
|
||||||
|
&syntax,
|
||||||
|
None,
|
||||||
|
error(Span::default(), "Non-disjoint definition merge."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
project.definitions.insert(id, value);
|
||||||
|
}
|
||||||
|
for (id, value) in elements {
|
||||||
|
if project.elements.contains_key(&id) {
|
||||||
|
return Err(Failure::typed(
|
||||||
|
&syntax,
|
||||||
|
None,
|
||||||
|
error(Span::default(), "Non-disjoint element merge."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
project.elements.insert(id, value);
|
||||||
|
}
|
||||||
|
syntax
|
||||||
|
.limits
|
||||||
|
.model
|
||||||
|
.check_model(&project)
|
||||||
|
.map_err(|message| {
|
||||||
|
Failure::ordinary(
|
||||||
|
&syntax,
|
||||||
|
None,
|
||||||
|
language::Error {
|
||||||
|
code: "model_limit",
|
||||||
|
offset: 0,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let (issues, admission) = validation::validate_with_usage(&project);
|
||||||
|
// Raw architecture Issues are excluded. Each transferred string, copied path
|
||||||
|
// and destination slot is admitted before mapping/retention; never truncate.
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
let mut rejected = false;
|
||||||
|
for issue in issues {
|
||||||
|
let owner = issue
|
||||||
|
.element
|
||||||
|
.and_then(|id| catalog.owners.get(&id))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(catalog.owners[&project.id]);
|
||||||
|
let span = field_span(&syntax.files[owner.file], owner, issue.field.as_deref());
|
||||||
|
let unit = &syntax.files[owner.file];
|
||||||
|
let text = [
|
||||||
|
unit.path.len(),
|
||||||
|
issue.code.len(),
|
||||||
|
issue.field.as_ref().map_or(0, String::len),
|
||||||
|
issue.message.len(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.try_fold(0, |n, k| add(n, k, span))
|
||||||
|
.map_err(|e| Failure::typed(&syntax, Some(owner.file), e))?;
|
||||||
|
storage
|
||||||
|
.reserve(
|
||||||
|
&syntax.budget,
|
||||||
|
Slots {
|
||||||
|
entries: 1,
|
||||||
|
occurrences: 0,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
span,
|
||||||
|
)
|
||||||
|
.map_err(|e| Failure::typed(&syntax, Some(owner.file), e))?;
|
||||||
|
rejected |= issue.severity == validation::Severity::Error;
|
||||||
|
let prefix = &unit.source[..span.start];
|
||||||
|
let line = prefix.bytes().filter(|b| *b == b'\n').count() + 1;
|
||||||
|
let column = prefix.rsplit('\n').next().expect("prefix").chars().count() + 1;
|
||||||
|
warnings.push(SourceDiagnostic {
|
||||||
|
path: unit.path.into(),
|
||||||
|
related: Vec::new(),
|
||||||
|
diagnostic: language::Diagnostic {
|
||||||
|
severity: issue.severity,
|
||||||
|
code: issue.code,
|
||||||
|
object_id: issue.element,
|
||||||
|
field: issue.field,
|
||||||
|
message: issue.message,
|
||||||
|
span,
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if rejected {
|
||||||
|
return Err(Failure {
|
||||||
|
diagnostics: warnings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let (static_limits, static_usage) = admission.expect("successful validation retains admission");
|
||||||
|
drop(catalog.owners);
|
||||||
|
storage.release(catalog.owners_storage);
|
||||||
|
Ok(ResolvedMixedProject {
|
||||||
|
syntax,
|
||||||
|
project,
|
||||||
|
warnings,
|
||||||
|
static_limits,
|
||||||
|
static_usage,
|
||||||
|
expressions,
|
||||||
|
occurrences,
|
||||||
|
storage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact known field or explicit whole-owner fallback. Never guess by offsets.
|
||||||
|
fn field_span(file: &FileSyntax<'_>, owner: Owner, field: Option<&str>) -> Span {
|
||||||
|
let Some(field) = field else {
|
||||||
|
return owner.span;
|
||||||
|
};
|
||||||
|
match owner.kind {
|
||||||
|
OwnerKind::Project => file
|
||||||
|
.ordinary
|
||||||
|
.header
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|h| (field == "description").then_some(h.description).flatten())
|
||||||
|
.unwrap_or(owner.span),
|
||||||
|
OwnerKind::Ordinary(index) => file.ordinary.declarations[index]
|
||||||
|
.field_spans
|
||||||
|
.get(field)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(owner.span),
|
||||||
|
OwnerKind::Typed(index) => typed_field(&file.typed[index], field).unwrap_or(owner.span),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn typed_field(d: &d::Declaration, field: &str) -> Option<Span> {
|
||||||
|
if field == "description" {
|
||||||
|
return d.description.as_ref().map(|f| f.value_span);
|
||||||
|
}
|
||||||
|
if field == "alias" {
|
||||||
|
return Some(d.header.alias.span);
|
||||||
|
}
|
||||||
|
if field == "name" {
|
||||||
|
return Some(d.header.name_span);
|
||||||
|
}
|
||||||
|
match &d.body {
|
||||||
|
d::Body::QuantityKind { dimension, algebra } => match field {
|
||||||
|
"dimension" => Some(dimension.value_span),
|
||||||
|
"algebra" => Some(algebra.value_span),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
d::Body::Unit {
|
||||||
|
kind,
|
||||||
|
scale,
|
||||||
|
offset,
|
||||||
|
symbol,
|
||||||
|
} => match field {
|
||||||
|
"kind" => Some(kind.value_span),
|
||||||
|
"scale" => Some(scale.value_span),
|
||||||
|
"offset" => Some(offset.value_span),
|
||||||
|
"symbol" => Some(symbol.value_span),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
d::Body::Calculation { result, body, .. } => match field {
|
||||||
|
"result" => Some(result.value_span),
|
||||||
|
"body" => Some(body.value_span),
|
||||||
|
// Current semantic admission reports aggregate "parameters", not a
|
||||||
|
// qualified parameter/type path. Do not manufacture precision from
|
||||||
|
// a UUID substring; lowerer failures already carry exact file/span.
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
d::Body::Property {
|
||||||
|
subject,
|
||||||
|
value_type,
|
||||||
|
base,
|
||||||
|
display_unit,
|
||||||
|
} => match field {
|
||||||
|
"subject" => Some(subject.value_span),
|
||||||
|
"type" | "value_type" => Some(value_type.value_span),
|
||||||
|
"base" => Some(base.value_span),
|
||||||
|
"display_unit" => display_unit.as_ref().map(|f| f.value_span),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
d::Body::Check {
|
||||||
|
subject,
|
||||||
|
documented_constraint,
|
||||||
|
predicate,
|
||||||
|
} => match field {
|
||||||
|
"subject" => subject.as_ref().map(|f| f.value_span),
|
||||||
|
"documented_constraint" => documented_constraint.as_ref().map(|f| f.value_span),
|
||||||
|
"predicate" => Some(predicate.value_span),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
d::Body::Case {
|
||||||
|
subject,
|
||||||
|
base,
|
||||||
|
overrides,
|
||||||
|
} => match field {
|
||||||
|
"subject" => subject.as_ref().map(|f| f.value_span),
|
||||||
|
"base" => Some(base.value_span),
|
||||||
|
"overrides" => Some(overrides.value_span),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
//! Logical auxiliary storage, separate from model/serde and raw validator heap.
|
||||||
|
use super::*;
|
||||||
|
use crate::language::{EditKind, EditValue};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub(super) struct Slots {
|
||||||
|
pub entries: usize,
|
||||||
|
pub occurrences: usize,
|
||||||
|
pub text: usize,
|
||||||
|
}
|
||||||
|
impl Slots {
|
||||||
|
pub fn entries(entries: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
entries,
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn text(text: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
text,
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn add(self, other: Self, at: Span) -> Result<Self, super::super::super::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
entries: add(self.entries, other.entries, at)?,
|
||||||
|
occurrences: add(self.occurrences, other.occurrences, at)?,
|
||||||
|
text: add(self.text, other.text, at)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(super) fn add(a: usize, b: usize, at: Span) -> Result<usize, super::super::super::Error> {
|
||||||
|
a.checked_add(b)
|
||||||
|
.ok_or_else(|| error(at, "Mixed auxiliary storage size overflow."))
|
||||||
|
}
|
||||||
|
pub(super) fn mul(a: usize, b: usize, at: Span) -> Result<usize, super::super::super::Error> {
|
||||||
|
a.checked_mul(b)
|
||||||
|
.ok_or_else(|| error(at, "Mixed auxiliary storage size overflow."))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(super) struct Ledger {
|
||||||
|
pub live: Slots,
|
||||||
|
}
|
||||||
|
impl Ledger {
|
||||||
|
pub fn reserve(&mut self, budget: &Budget, slots: Slots, at: Span) -> Result<(), TypedError> {
|
||||||
|
let next = self.live.add(slots, at)?;
|
||||||
|
budget.lowering_entries(next.entries, at)?;
|
||||||
|
budget.lowering_occurrences(next.occurrences, at)?;
|
||||||
|
budget.index_bytes(next.text, at)?;
|
||||||
|
self.live = next;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn release(&mut self, slots: Slots) {
|
||||||
|
self.live.entries = self
|
||||||
|
.live
|
||||||
|
.entries
|
||||||
|
.checked_sub(slots.entries)
|
||||||
|
.expect("entry reservation");
|
||||||
|
self.live.occurrences = self
|
||||||
|
.live
|
||||||
|
.occurrences
|
||||||
|
.checked_sub(slots.occurrences)
|
||||||
|
.expect("occurrence reservation");
|
||||||
|
self.live.text = self
|
||||||
|
.live
|
||||||
|
.text
|
||||||
|
.checked_sub(slots.text)
|
||||||
|
.expect("text reservation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact output-site counts and conservative whole-lowering temporary bound.
|
||||||
|
/// N expression nodes: at most 2N Visit/Build slots, N borrowed child slots,
|
||||||
|
/// N value/split slots. A arguments: A signature slots plus A seen-set slots.
|
||||||
|
/// `Resolver::call` borrows the callee signature. Its parameter Vec/seen set
|
||||||
|
/// contain only supplied arguments, including on incomplete/invalid calls;
|
||||||
|
/// no allocation is proportional to the full signature during body lowering.
|
||||||
|
/// The model expressions/maps themselves are outside this auxiliary ledger.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct LowerStorage {
|
||||||
|
pub nodes: usize,
|
||||||
|
pub occurrences: usize,
|
||||||
|
pub arguments: usize,
|
||||||
|
}
|
||||||
|
impl LowerStorage {
|
||||||
|
pub fn all(&self) -> Result<Slots, TypedError> {
|
||||||
|
let at = Span::default();
|
||||||
|
Ok(Slots {
|
||||||
|
entries: add(mul(self.nodes, 5, at)?, mul(self.arguments, 2, at)?, at)?,
|
||||||
|
occurrences: self.occurrences,
|
||||||
|
text: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Item<'a> {
|
||||||
|
Ast(&'a Ast),
|
||||||
|
Edit(&'a EditValue),
|
||||||
|
Expr(&'a Expression),
|
||||||
|
}
|
||||||
|
struct Walk<'a, 'b> {
|
||||||
|
ledger: &'b mut Ledger,
|
||||||
|
budget: &'b Budget,
|
||||||
|
work: Vec<Item<'a>>,
|
||||||
|
lower: LowerStorage,
|
||||||
|
}
|
||||||
|
impl<'a> Walk<'a, '_> {
|
||||||
|
fn text(&mut self, text: &str, at: Span) -> Result<(), TypedError> {
|
||||||
|
self.ledger
|
||||||
|
.reserve(self.budget, Slots::text(text.len()), at)
|
||||||
|
}
|
||||||
|
fn push(&mut self, item: Item<'a>) -> Result<(), TypedError> {
|
||||||
|
self.ledger
|
||||||
|
.reserve(self.budget, Slots::entries(1), Span::default())?;
|
||||||
|
self.work.push(item);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn reference(&mut self, r: &Reference) -> Result<(), TypedError> {
|
||||||
|
self.text(&r.name, r.span)?;
|
||||||
|
self.lower.occurrences = add(self.lower.occurrences, 1, r.span)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn optional(&mut self, field: &Option<d::Field<Option<Reference>>>) -> Result<(), TypedError> {
|
||||||
|
if let Some(r) = field.as_ref().and_then(|f| f.value.as_ref()) {
|
||||||
|
self.reference(r)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn ty(&mut self, ty: &d::TypeSyntax) -> Result<(), TypedError> {
|
||||||
|
if let d::ScalarKind::Quantity(r) = &ty.scalar.kind {
|
||||||
|
self.reference(r)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn binding(&mut self, binding: &'a Binding) -> Result<(), TypedError> {
|
||||||
|
if let Binding::Expression(e) = binding {
|
||||||
|
self.push(Item::Expr(e))?;
|
||||||
|
}
|
||||||
|
self.drain()
|
||||||
|
}
|
||||||
|
fn drain(&mut self) -> Result<(), TypedError> {
|
||||||
|
while let Some(item) = self.work.pop() {
|
||||||
|
self.ledger.release(Slots::entries(1));
|
||||||
|
match item {
|
||||||
|
Item::Ast(ast) => match ast {
|
||||||
|
Ast::Literal(serde_json::Value::String(s), _)
|
||||||
|
| Ast::Word(s, _)
|
||||||
|
| Ast::Reference(s, _) => self.text(s, Span::default())?,
|
||||||
|
Ast::Array(items, _) => {
|
||||||
|
for child in items {
|
||||||
|
self.push(Item::Ast(child))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ast::Object(fields, _) => {
|
||||||
|
for (key, child) in fields {
|
||||||
|
self.text(key, Span::default())?;
|
||||||
|
self.push(Item::Ast(child))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Item::Edit(value) => match &value.kind {
|
||||||
|
EditKind::Reference(s) => self.text(s, value.span)?,
|
||||||
|
EditKind::Record(fields) => {
|
||||||
|
for (key, field) in fields {
|
||||||
|
self.text(key, field.span)?;
|
||||||
|
self.push(Item::Edit(&field.value))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditKind::List(items) => {
|
||||||
|
for child in items {
|
||||||
|
self.push(Item::Edit(child))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Item::Expr(e) => {
|
||||||
|
self.lower.nodes = add(self.lower.nodes, 1, e.span)?;
|
||||||
|
use ExpressionKind as E;
|
||||||
|
match &e.kind {
|
||||||
|
E::Quantity { unit, .. } | E::Property(unit) | E::Parameter(unit) => {
|
||||||
|
self.reference(unit)?
|
||||||
|
}
|
||||||
|
E::List(items) => {
|
||||||
|
for child in items {
|
||||||
|
self.push(Item::Expr(child))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
E::Unary { operand, .. } => self.push(Item::Expr(operand))?,
|
||||||
|
E::Binary { left, right, .. } => {
|
||||||
|
self.push(Item::Expr(left))?;
|
||||||
|
self.push(Item::Expr(right))?;
|
||||||
|
}
|
||||||
|
E::If {
|
||||||
|
condition,
|
||||||
|
when_true,
|
||||||
|
when_false,
|
||||||
|
} => {
|
||||||
|
self.push(Item::Expr(condition))?;
|
||||||
|
self.push(Item::Expr(when_true))?;
|
||||||
|
self.push(Item::Expr(when_false))?;
|
||||||
|
}
|
||||||
|
E::Call {
|
||||||
|
definition,
|
||||||
|
arguments,
|
||||||
|
} => {
|
||||||
|
self.reference(definition)?;
|
||||||
|
self.lower.arguments =
|
||||||
|
add(self.lower.arguments, arguments.len(), e.span)?;
|
||||||
|
for argument in arguments {
|
||||||
|
self.reference(&argument.parameter)?;
|
||||||
|
self.push(Item::Expr(&argument.value))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
E::Sum { values, initial } => {
|
||||||
|
self.push(Item::Expr(values))?;
|
||||||
|
self.push(Item::Expr(initial))?;
|
||||||
|
}
|
||||||
|
E::Power { base, .. } => self.push(Item::Expr(base))?,
|
||||||
|
E::AsKind { kind, value } => {
|
||||||
|
self.reference(kind)?;
|
||||||
|
self.push(Item::Expr(value))?;
|
||||||
|
}
|
||||||
|
E::Boolean(_) | E::Number(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn syntax(
|
||||||
|
syntax: &ParsedMixedSyntax<'_>,
|
||||||
|
ledger: &mut Ledger,
|
||||||
|
) -> Result<LowerStorage, Failure> {
|
||||||
|
let at = Span::default();
|
||||||
|
ledger
|
||||||
|
.reserve(
|
||||||
|
&syntax.budget,
|
||||||
|
Slots::entries(
|
||||||
|
add(syntax.metadata, syntax.budget.usage.entries, at)
|
||||||
|
.map_err(|e| Failure::typed(syntax, None, e))?,
|
||||||
|
),
|
||||||
|
at,
|
||||||
|
)
|
||||||
|
.map_err(|e| Failure::typed(syntax, None, e))?;
|
||||||
|
let mut walk = Walk {
|
||||||
|
ledger,
|
||||||
|
budget: &syntax.budget,
|
||||||
|
work: Vec::new(),
|
||||||
|
lower: LowerStorage::default(),
|
||||||
|
};
|
||||||
|
for (file, unit) in syntax.files.iter().enumerate() {
|
||||||
|
let mut count = || -> Result<(), TypedError> {
|
||||||
|
if let Some(header) = &unit.ordinary.header {
|
||||||
|
for s in [
|
||||||
|
&header.project.alias,
|
||||||
|
&header.project.name,
|
||||||
|
&header.project.description,
|
||||||
|
] {
|
||||||
|
walk.text(s, header.span)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (path, span) in &unit.ordinary.includes {
|
||||||
|
walk.text(path, *span)?;
|
||||||
|
}
|
||||||
|
for declaration in &unit.ordinary.declarations {
|
||||||
|
walk.text(&declaration.alias, declaration.span)?;
|
||||||
|
walk.text(&declaration.name, declaration.span)?;
|
||||||
|
for key in declaration.field_spans.keys() {
|
||||||
|
walk.text(key, declaration.span)?;
|
||||||
|
}
|
||||||
|
for (key, value) in &declaration.fields {
|
||||||
|
walk.text(key, declaration.span)?;
|
||||||
|
walk.push(Item::Ast(value))?;
|
||||||
|
walk.drain()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (_, position) in &unit.positions {
|
||||||
|
walk.push(Item::Edit(&position.fields))?;
|
||||||
|
walk.drain()?;
|
||||||
|
}
|
||||||
|
for declaration in &unit.typed {
|
||||||
|
walk.text(
|
||||||
|
&declaration.header.alias.name,
|
||||||
|
declaration.header.alias.span,
|
||||||
|
)?;
|
||||||
|
walk.text(&declaration.header.name, declaration.header.name_span)?;
|
||||||
|
if let Some(f) = &declaration.description {
|
||||||
|
walk.text(&f.value, f.span)?;
|
||||||
|
}
|
||||||
|
match &declaration.body {
|
||||||
|
d::Body::QuantityKind { algebra, .. } => {
|
||||||
|
if let d::AlgebraSyntax::Affine(r) = &algebra.value {
|
||||||
|
walk.reference(r)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d::Body::Unit { kind, symbol, .. } => {
|
||||||
|
walk.reference(&kind.value)?;
|
||||||
|
walk.text(&symbol.value, symbol.span)?;
|
||||||
|
}
|
||||||
|
d::Body::Calculation {
|
||||||
|
parameters,
|
||||||
|
result,
|
||||||
|
body,
|
||||||
|
} => {
|
||||||
|
for p in parameters {
|
||||||
|
walk.reference(&p.name)?;
|
||||||
|
walk.ty(&p.value_type)?;
|
||||||
|
}
|
||||||
|
walk.ty(&result.value)?;
|
||||||
|
walk.binding(&body.value)?;
|
||||||
|
}
|
||||||
|
d::Body::Property {
|
||||||
|
subject,
|
||||||
|
value_type,
|
||||||
|
base,
|
||||||
|
display_unit,
|
||||||
|
} => {
|
||||||
|
walk.reference(&subject.value)?;
|
||||||
|
walk.ty(&value_type.value)?;
|
||||||
|
walk.binding(&base.value)?;
|
||||||
|
walk.optional(display_unit)?;
|
||||||
|
}
|
||||||
|
d::Body::Check {
|
||||||
|
subject,
|
||||||
|
documented_constraint,
|
||||||
|
predicate,
|
||||||
|
} => {
|
||||||
|
walk.optional(subject)?;
|
||||||
|
walk.optional(documented_constraint)?;
|
||||||
|
walk.binding(&predicate.value)?;
|
||||||
|
}
|
||||||
|
d::Body::Case {
|
||||||
|
subject, overrides, ..
|
||||||
|
} => {
|
||||||
|
walk.optional(subject)?;
|
||||||
|
for entry in &overrides.value {
|
||||||
|
walk.reference(&entry.target)?;
|
||||||
|
walk.binding(&entry.binding)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
count().map_err(|e| Failure::typed(syntax, Some(file), e))?;
|
||||||
|
}
|
||||||
|
Ok(walk.lower)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,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."
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -194,6 +194,8 @@ pub(super) fn row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
value_depth: 0,
|
value_depth: 0,
|
||||||
limits: ParseLimits::UNLIMITED,
|
limits: ParseLimits::UNLIMITED,
|
||||||
|
#[cfg(test)]
|
||||||
|
mixed: None,
|
||||||
};
|
};
|
||||||
let ast = parser.value().ok()?;
|
let ast = parser.value().ok()?;
|
||||||
let facet = serde_json::from_value(raw(&ast).ok()?).ok()?;
|
let facet = serde_json::from_value(raw(&ast).ok()?).ok()?;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||||||
mod source_migration;
|
mod source_migration;
|
||||||
#[path = "source_project/source_structure.rs"]
|
#[path = "source_project/source_structure.rs"]
|
||||||
mod source_structure;
|
mod source_structure;
|
||||||
|
pub(crate) mod traversal;
|
||||||
|
|
||||||
/// A portable slash-separated path, checked at every bundle boundary.
|
/// A portable slash-separated path, checked at every bundle boundary.
|
||||||
pub type LogicalPath = String;
|
pub type LogicalPath = String;
|
||||||
@@ -304,71 +305,7 @@ fn compile_once(
|
|||||||
bundle: &SourceBundle,
|
bundle: &SourceBundle,
|
||||||
limits: ProjectLimits,
|
limits: ProjectLimits,
|
||||||
) -> Result<(CompiledSourceProject, Edits), Vec<SourceDiagnostic>> {
|
) -> Result<(CompiledSourceProject, Edits), Vec<SourceDiagnostic>> {
|
||||||
let fail = |code, message| {
|
let mut traversal = traversal::Traversal::new(bundle, limits)?;
|
||||||
vec![problem(
|
|
||||||
bundle,
|
|
||||||
&bundle.entry,
|
|
||||||
Span::default(),
|
|
||||||
code,
|
|
||||||
message,
|
|
||||||
)]
|
|
||||||
};
|
|
||||||
if bundle.entry.contains('/') {
|
|
||||||
return Err(fail(
|
|
||||||
"invalid_path",
|
|
||||||
"The entry file must be at the source project root.".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if bundle.files.len() > limits.max_files {
|
|
||||||
return Err(fail(
|
|
||||||
"project_limit",
|
|
||||||
format!("Source project exceeds {} files.", limits.max_files),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let bytes = bundle
|
|
||||||
.files
|
|
||||||
.values()
|
|
||||||
.fold(0usize, |sum, text| sum.saturating_add(text.len()));
|
|
||||||
if bytes > limits.model.max_source_bytes {
|
|
||||||
return Err(fail(
|
|
||||||
"source_limit",
|
|
||||||
format!(
|
|
||||||
"Combined source exceeds {} bytes.",
|
|
||||||
limits.model.max_source_bytes
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut folded = BTreeMap::new();
|
|
||||||
for path in std::iter::once(&bundle.entry).chain(bundle.files.keys()) {
|
|
||||||
if resolve_include("", path).as_ref() != Ok(path) {
|
|
||||||
return Err(fail(
|
|
||||||
"invalid_path",
|
|
||||||
format!("Non-canonical source path '{path}'."),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(previous) = folded.insert(path.to_lowercase(), path)
|
|
||||||
&& previous != path
|
|
||||||
{
|
|
||||||
return Err(fail(
|
|
||||||
"duplicate_path",
|
|
||||||
format!("Case-colliding source paths '{previous}' and '{path}'."),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !bundle.files.contains_key(&bundle.entry) {
|
|
||||||
return Err(fail(
|
|
||||||
"missing_include",
|
|
||||||
"The entry file is missing from the source bundle.".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Visit {
|
|
||||||
Enter(LogicalPath, usize, Option<SourceLocation>),
|
|
||||||
Leave,
|
|
||||||
}
|
|
||||||
let mut stack = vec![Visit::Enter(bundle.entry.clone(), 0, None)];
|
|
||||||
let mut active: Vec<LogicalPath> = Vec::new();
|
|
||||||
let mut visited = BTreeSet::new();
|
|
||||||
let mut declarations = Vec::new();
|
let mut declarations = Vec::new();
|
||||||
let mut declaration_paths = Vec::new();
|
let mut declaration_paths = Vec::new();
|
||||||
let mut aliases = BTreeMap::new();
|
let mut aliases = BTreeMap::new();
|
||||||
@@ -381,51 +318,8 @@ fn compile_once(
|
|||||||
let mut includes = Vec::new();
|
let mut includes = Vec::new();
|
||||||
let mut insertions: Edits = BTreeMap::new();
|
let mut insertions: Edits = BTreeMap::new();
|
||||||
let mut graph_count = 0usize;
|
let mut graph_count = 0usize;
|
||||||
while let Some(visit) = stack.pop() {
|
while let Some(path) = traversal.next()? {
|
||||||
let Visit::Enter(path, depth, origin) = visit else {
|
let source = &bundle.files[&path];
|
||||||
active.pop();
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let at = origin.unwrap_or(SourceLocation {
|
|
||||||
path: path.clone(),
|
|
||||||
span: Span::default(),
|
|
||||||
});
|
|
||||||
if let Some(start) = active.iter().position(|file| file == &path) {
|
|
||||||
let chain = active[start..]
|
|
||||||
.iter()
|
|
||||||
.chain(std::iter::once(&path))
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" -> ");
|
|
||||||
return Err(vec![problem(
|
|
||||||
bundle,
|
|
||||||
&at.path,
|
|
||||||
at.span,
|
|
||||||
"include_cycle",
|
|
||||||
format!("Include cycle: {chain}."),
|
|
||||||
)]);
|
|
||||||
}
|
|
||||||
if depth > limits.max_include_depth {
|
|
||||||
return Err(vec![problem(
|
|
||||||
bundle,
|
|
||||||
&at.path,
|
|
||||||
at.span,
|
|
||||||
"project_limit",
|
|
||||||
format!("Include depth exceeds {}.", limits.max_include_depth),
|
|
||||||
)]);
|
|
||||||
}
|
|
||||||
if !visited.insert(path.clone()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(source) = bundle.files.get(&path) else {
|
|
||||||
return Err(vec![problem(
|
|
||||||
bundle,
|
|
||||||
&at.path,
|
|
||||||
at.span,
|
|
||||||
"missing_include",
|
|
||||||
format!("Included file '{path}' is missing."),
|
|
||||||
)]);
|
|
||||||
};
|
|
||||||
let mut unit = language::parse_unit(source, path == bundle.entry, limits.model).map_err(
|
let mut unit = language::parse_unit(source, path == bundle.entry, limits.model).map_err(
|
||||||
|diagnostic| {
|
|diagnostic| {
|
||||||
vec![SourceDiagnostic {
|
vec![SourceDiagnostic {
|
||||||
@@ -513,46 +407,12 @@ fn compile_once(
|
|||||||
declarations.push(declaration);
|
declarations.push(declaration);
|
||||||
declaration_paths.push(path.clone());
|
declaration_paths.push(path.clone());
|
||||||
}
|
}
|
||||||
let mut children = Vec::new();
|
|
||||||
for (raw, span) in unit.includes {
|
for (raw, span) in unit.includes {
|
||||||
let target = resolve_include(&path, &raw)
|
includes.push(traversal.include(&path, &raw, span)?);
|
||||||
.map_err(|message| vec![problem(bundle, &path, span, "invalid_path", message)])?;
|
|
||||||
let location = SourceLocation {
|
|
||||||
path: path.clone(),
|
|
||||||
span,
|
|
||||||
};
|
|
||||||
includes.push(IncludeLocation {
|
|
||||||
location: location.clone(),
|
|
||||||
target: target.clone(),
|
|
||||||
});
|
|
||||||
if includes.len() > limits.max_include_edges {
|
|
||||||
return Err(vec![problem(
|
|
||||||
bundle,
|
|
||||||
&path,
|
|
||||||
span,
|
|
||||||
"project_limit",
|
|
||||||
format!("Include graph exceeds {} edges.", limits.max_include_edges),
|
|
||||||
)]);
|
|
||||||
}
|
|
||||||
children.push(Visit::Enter(
|
|
||||||
target,
|
|
||||||
depth.saturating_add(1),
|
|
||||||
Some(location),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
active.push(path);
|
traversal.finish_file(path);
|
||||||
stack.push(Visit::Leave);
|
|
||||||
stack.extend(children.into_iter().rev());
|
|
||||||
}
|
|
||||||
if let Some(path) = bundle.files.keys().find(|path| !visited.contains(*path)) {
|
|
||||||
return Err(vec![problem(
|
|
||||||
bundle,
|
|
||||||
path,
|
|
||||||
Span::default(),
|
|
||||||
"unreachable_file",
|
|
||||||
"Supplied source file is not reachable from the entry file.",
|
|
||||||
)]);
|
|
||||||
}
|
}
|
||||||
|
traversal.finish()?;
|
||||||
let header = project_header.expect("validated entry header");
|
let header = project_header.expect("validated entry header");
|
||||||
let index = SourceIndex {
|
let index = SourceIndex {
|
||||||
declarations: declaration_locations,
|
declarations: declaration_locations,
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
//! Shared include traversal policy for ordinary compilation and private syntax.
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
enum Visit {
|
||||||
|
Enter(LogicalPath, usize, Option<SourceLocation>),
|
||||||
|
Leave,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Traversal<'a> {
|
||||||
|
bundle: &'a SourceBundle,
|
||||||
|
limits: ProjectLimits,
|
||||||
|
stack: Vec<Visit>,
|
||||||
|
active: Vec<LogicalPath>,
|
||||||
|
visited: BTreeSet<LogicalPath>,
|
||||||
|
children: Vec<Visit>,
|
||||||
|
depth: usize,
|
||||||
|
edges: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Traversal<'a> {
|
||||||
|
pub(crate) fn new(
|
||||||
|
bundle: &'a SourceBundle,
|
||||||
|
limits: ProjectLimits,
|
||||||
|
) -> Result<Self, Vec<SourceDiagnostic>> {
|
||||||
|
let fail = |code, message| {
|
||||||
|
vec![problem(
|
||||||
|
bundle,
|
||||||
|
&bundle.entry,
|
||||||
|
Span::default(),
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
)]
|
||||||
|
};
|
||||||
|
if bundle.entry.contains('/') {
|
||||||
|
return Err(fail(
|
||||||
|
"invalid_path",
|
||||||
|
"The entry file must be at the source project root.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if bundle.files.len() > limits.max_files {
|
||||||
|
return Err(fail(
|
||||||
|
"project_limit",
|
||||||
|
format!("Source project exceeds {} files.", limits.max_files),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let bytes = bundle
|
||||||
|
.files
|
||||||
|
.values()
|
||||||
|
.fold(0usize, |sum, text| sum.saturating_add(text.len()));
|
||||||
|
if bytes > limits.model.max_source_bytes {
|
||||||
|
return Err(fail(
|
||||||
|
"source_limit",
|
||||||
|
format!(
|
||||||
|
"Combined source exceeds {} bytes.",
|
||||||
|
limits.model.max_source_bytes
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut folded = BTreeMap::new();
|
||||||
|
for path in std::iter::once(&bundle.entry).chain(bundle.files.keys()) {
|
||||||
|
if resolve_include("", path).as_ref() != Ok(path) {
|
||||||
|
return Err(fail(
|
||||||
|
"invalid_path",
|
||||||
|
format!("Non-canonical source path '{path}'."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(previous) = folded.insert(path.to_lowercase(), path)
|
||||||
|
&& previous != path
|
||||||
|
{
|
||||||
|
return Err(fail(
|
||||||
|
"duplicate_path",
|
||||||
|
format!("Case-colliding source paths '{previous}' and '{path}'."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !bundle.files.contains_key(&bundle.entry) {
|
||||||
|
return Err(fail(
|
||||||
|
"missing_include",
|
||||||
|
"The entry file is missing from the source bundle.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
bundle,
|
||||||
|
limits,
|
||||||
|
stack: vec![Visit::Enter(bundle.entry.clone(), 0, None)],
|
||||||
|
active: Vec::new(),
|
||||||
|
visited: BTreeSet::new(),
|
||||||
|
children: Vec::new(),
|
||||||
|
depth: 0,
|
||||||
|
edges: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn next(&mut self) -> Result<Option<LogicalPath>, Vec<SourceDiagnostic>> {
|
||||||
|
while let Some(visit) = self.stack.pop() {
|
||||||
|
let Visit::Enter(path, depth, origin) = visit else {
|
||||||
|
self.active.pop();
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let at = origin.unwrap_or(SourceLocation {
|
||||||
|
path: path.clone(),
|
||||||
|
span: Span::default(),
|
||||||
|
});
|
||||||
|
if let Some(start) = self.active.iter().position(|file| file == &path) {
|
||||||
|
let chain = self.active[start..]
|
||||||
|
.iter()
|
||||||
|
.chain(std::iter::once(&path))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" -> ");
|
||||||
|
return Err(vec![problem(
|
||||||
|
self.bundle,
|
||||||
|
&at.path,
|
||||||
|
at.span,
|
||||||
|
"include_cycle",
|
||||||
|
format!("Include cycle: {chain}."),
|
||||||
|
)]);
|
||||||
|
}
|
||||||
|
if depth > self.limits.max_include_depth {
|
||||||
|
return Err(vec![problem(
|
||||||
|
self.bundle,
|
||||||
|
&at.path,
|
||||||
|
at.span,
|
||||||
|
"project_limit",
|
||||||
|
format!("Include depth exceeds {}.", self.limits.max_include_depth),
|
||||||
|
)]);
|
||||||
|
}
|
||||||
|
if !self.visited.insert(path.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !self.bundle.files.contains_key(&path) {
|
||||||
|
return Err(vec![problem(
|
||||||
|
self.bundle,
|
||||||
|
&at.path,
|
||||||
|
at.span,
|
||||||
|
"missing_include",
|
||||||
|
format!("Included file '{path}' is missing."),
|
||||||
|
)]);
|
||||||
|
}
|
||||||
|
self.depth = depth;
|
||||||
|
return Ok(Some(path));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn include(
|
||||||
|
&mut self,
|
||||||
|
path: &str,
|
||||||
|
raw: &str,
|
||||||
|
span: Span,
|
||||||
|
) -> Result<IncludeLocation, Vec<SourceDiagnostic>> {
|
||||||
|
let target = resolve_include(path, raw)
|
||||||
|
.map_err(|message| vec![problem(self.bundle, path, span, "invalid_path", message)])?;
|
||||||
|
let location = SourceLocation {
|
||||||
|
path: path.into(),
|
||||||
|
span,
|
||||||
|
};
|
||||||
|
if self.edges >= self.limits.max_include_edges {
|
||||||
|
return Err(vec![problem(
|
||||||
|
self.bundle,
|
||||||
|
path,
|
||||||
|
span,
|
||||||
|
"project_limit",
|
||||||
|
format!(
|
||||||
|
"Include graph exceeds {} edges.",
|
||||||
|
self.limits.max_include_edges
|
||||||
|
),
|
||||||
|
)]);
|
||||||
|
}
|
||||||
|
self.edges += 1;
|
||||||
|
self.children.push(Visit::Enter(
|
||||||
|
target.clone(),
|
||||||
|
self.depth.saturating_add(1),
|
||||||
|
Some(location.clone()),
|
||||||
|
));
|
||||||
|
Ok(IncludeLocation { location, target })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn finish_file(&mut self, path: LogicalPath) {
|
||||||
|
self.active.push(path);
|
||||||
|
self.stack.push(Visit::Leave);
|
||||||
|
self.stack.extend(self.children.drain(..).rev());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn finish(self) -> Result<(), Vec<SourceDiagnostic>> {
|
||||||
|
if let Some(path) = self
|
||||||
|
.bundle
|
||||||
|
.files
|
||||||
|
.keys()
|
||||||
|
.find(|path| !self.visited.contains(*path))
|
||||||
|
{
|
||||||
|
return Err(vec![problem(
|
||||||
|
self.bundle,
|
||||||
|
path,
|
||||||
|
Span::default(),
|
||||||
|
"unreachable_file",
|
||||||
|
"Supplied source file is not reachable from the entry file.",
|
||||||
|
)]);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_compile_once_keeps_include_order_and_cycle_source_location() {
|
||||||
|
let mut bundle = SourceBundle {
|
||||||
|
entry: "main.arcadia".into(),
|
||||||
|
files: BTreeMap::from([
|
||||||
|
("main.arcadia".into(), "arcadia 7; project p \"P\" id \"00000000-0000-0000-0000-000000000001\" { include \"dir/left.arcadia\"; include \"dir/right.arcadia\"; }".into()),
|
||||||
|
("dir/left.arcadia".into(), "include \"../shared.arcadia\";".into()),
|
||||||
|
("dir/right.arcadia".into(), "include \"../shared.arcadia\";".into()),
|
||||||
|
("shared.arcadia".into(), "// one shared ordinary fragment\n".into()),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
let original = bundle.clone();
|
||||||
|
// This is the existing initial ordinary pass, before schema migration's
|
||||||
|
// separate output/reparse. It makes no public source8 acceptance claim.
|
||||||
|
let (compiled, insertions) =
|
||||||
|
super::super::compile_once(&bundle, ProjectLimits::default()).unwrap();
|
||||||
|
assert!(insertions.is_empty());
|
||||||
|
assert_eq!(compiled.bundle, original);
|
||||||
|
assert_eq!(compiled.index.declarations.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
compiled
|
||||||
|
.index
|
||||||
|
.includes
|
||||||
|
.iter()
|
||||||
|
.map(|i| (i.location.path.as_str(), i.target.as_str()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
("main.arcadia", "dir/left.arcadia"),
|
||||||
|
("main.arcadia", "dir/right.arcadia"),
|
||||||
|
("dir/left.arcadia", "shared.arcadia"),
|
||||||
|
("dir/right.arcadia", "shared.arcadia"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let cycle = "// 雪\r\ninclude \"main.arcadia\";";
|
||||||
|
bundle.files.insert("shared.arcadia".into(), cycle.into());
|
||||||
|
let before = bundle.clone();
|
||||||
|
let errors = super::super::compile_once(&bundle, ProjectLimits::default()).unwrap_err();
|
||||||
|
assert_eq!(errors[0].diagnostic.code, "include_cycle");
|
||||||
|
assert_eq!(errors[0].path, "shared.arcadia");
|
||||||
|
assert_eq!(errors[0].diagnostic.line, 2);
|
||||||
|
assert_eq!(
|
||||||
|
&cycle[errors[0].diagnostic.span.start..errors[0].diagnostic.span.end],
|
||||||
|
"include \"main.arcadia\";"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
errors[0].diagnostic.message,
|
||||||
|
"Include cycle: main.arcadia -> dir/left.arcadia -> shared.arcadia -> main.arcadia."
|
||||||
|
);
|
||||||
|
assert_eq!(bundle, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,14 +180,96 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate(p: &Project) -> Vec<Issue> {
|
pub fn validate(p: &Project) -> Vec<Issue> {
|
||||||
|
validate_with_usage(p).0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the shared static/architectural route once and retains successful static
|
||||||
|
/// metadata without retaining a Project-borrowing environment. Architectural
|
||||||
|
/// issue production is outside the static meter and any caller auxiliary ledger;
|
||||||
|
/// callers must reserve separately before mapping or retaining these issues.
|
||||||
|
pub(crate) fn validate_with_usage(
|
||||||
|
p: &Project,
|
||||||
|
) -> (
|
||||||
|
Vec<Issue>,
|
||||||
|
Option<(
|
||||||
|
crate::analysis::AdmissionLimits,
|
||||||
|
crate::analysis::AdmissionUsage,
|
||||||
|
)>,
|
||||||
|
) {
|
||||||
// Bound analysis trees before architectural visitors can clone an element.
|
// Bound analysis trees before architectural visitors can clone an element.
|
||||||
// Admission itself never enters this public validator recursively.
|
// Admission itself never enters this public validator recursively.
|
||||||
if let Err(failure) = crate::analysis::admit_structure_checked(p, crate::analysis::AdmissionLimits::REFERENCE) {
|
let environment = match crate::analysis::admit_structure_checked(
|
||||||
let mut out = Vec::new();
|
p,
|
||||||
error(&mut out, failure.object, &failure.code, &failure.field, failure.message);
|
crate::analysis::AdmissionLimits::REFERENCE,
|
||||||
return out;
|
) {
|
||||||
|
Ok(environment) => environment,
|
||||||
|
Err(failure) => {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
error(
|
||||||
|
&mut out,
|
||||||
|
failure.object,
|
||||||
|
&failure.code,
|
||||||
|
&failure.field,
|
||||||
|
failure.message,
|
||||||
|
);
|
||||||
|
return (out, None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let metadata = (
|
||||||
|
environment.admission_limits(),
|
||||||
|
environment.admission_usage(),
|
||||||
|
);
|
||||||
|
drop(environment);
|
||||||
|
(validate_structure(p), Some(metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod shared_admission_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn successful_static_metadata_survives_architectural_warnings() {
|
||||||
|
let mut project = Project::empty();
|
||||||
|
project.name.clear();
|
||||||
|
let (issues, metadata) = validate_with_usage(&project);
|
||||||
|
let (limits, usage) = metadata.expect("static admission succeeds");
|
||||||
|
assert_eq!(limits, crate::analysis::AdmissionLimits::REFERENCE);
|
||||||
|
assert!(usage.static_events > 0);
|
||||||
|
assert_eq!(usage.stored_expression_nodes, 0);
|
||||||
|
assert!(issues.iter().any(|issue| issue.code == "unnamed"
|
||||||
|
&& issue.element == Some(project.id)
|
||||||
|
&& issue.severity == Severity::Warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn static_failure_returns_no_metadata_and_skips_architectural_warnings() {
|
||||||
|
use crate::analysis::{
|
||||||
|
AnalysisType, Binding, CalculationDefinition, Definition, DefinitionBody, ScalarType,
|
||||||
|
};
|
||||||
|
let mut project = Project::empty();
|
||||||
|
project.name.clear();
|
||||||
|
let key = Id::from_u128(10);
|
||||||
|
project.definitions.insert(
|
||||||
|
key,
|
||||||
|
Definition {
|
||||||
|
id: Id::from_u128(11),
|
||||||
|
alias: "f".into(),
|
||||||
|
name: "F".into(),
|
||||||
|
description: String::new(),
|
||||||
|
body: DefinitionBody::Calculation(CalculationDefinition {
|
||||||
|
parameters: Vec::new(),
|
||||||
|
result: AnalysisType::Scalar(ScalarType::Number),
|
||||||
|
body: Binding::Unbound,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (issues, metadata) = validate_with_usage(&project);
|
||||||
|
assert!(metadata.is_none());
|
||||||
|
assert_eq!(issues.len(), 1);
|
||||||
|
assert_eq!(issues[0].severity, Severity::Error);
|
||||||
|
assert_eq!(issues[0].element, Some(key));
|
||||||
|
assert_eq!(issues[0].code, "identity_mismatch");
|
||||||
}
|
}
|
||||||
validate_structure(p)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Architecture/header validation without recursively re-entering analysis admission.
|
/// Architecture/header validation without recursively re-entering analysis admission.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Private mixed Project implementation
|
||||||
|
|
||||||
|
Source-only checkpoint; compilation and runtime acceptance are pending. Base is `a578ac98a06772a60bec00ae287e65650680d119`. The accepted contract is `aecee2aff3f4410648a501a70cb2e6160c81adc5:harness/analysis-mixed-project-contract-20260923.md`; this note records implementation choices, not a replacement specification.
|
||||||
|
|
||||||
|
`mixed::project::resolve` consumes the actual complete syntax witness. The sealed result retains Project, warnings, static admission metadata, original syntax/Budget/ProjectLimits/bundle, unchanged expression/occurrence vectors and their canonical file keys. Only Project/warnings are exposed read-only. The empty Project header is copied into model ownership; the original header remains in syntax. No declarations, typed models, tokens or Exact values are reparsed to establish authority.
|
||||||
|
|
||||||
|
The catalog seeds the entry first and merges ordinary/typed headers by original declaration start within canonical file order. It checks alias before global ID, retaining both occurrence owners on collision. Existing typed-only catalog precedence remains unchanged. Parameter names/IDs remain calculation scoped. Ordinary construction is the shared existing loop with a pre-field hook; its legacy wrapper preserves source3 scratch and all migration/admission tails. Typed lowering shares the existing body resolver over borrowed declarations and one complete catalog. Moved model records must be disjoint before insertion.
|
||||||
|
|
||||||
|
## Live auxiliary storage
|
||||||
|
|
||||||
|
Every reservation uses checked arithmetic and tests the aggregate through the inherited Budget's noncharging entry, occurrence and owned-byte checks. No new Budget or reset occurs. Failure aborts the phase. Logical slots are not allocator capacity/RSS.
|
||||||
|
|
||||||
|
| Representation | Reservation and end of lifetime |
|
||||||
|
| --- | --- |
|
||||||
|
| Original syntax | Retained mixed metadata subtotal plus parsed syntax entries. Includes the existing conservative dropped value-end subtotal. Tokens/positions are not counted a second time. An iterative walk counts separately owned strings, with each counting-stack push reserved first. Source bytes stay separately charged once. Baseline remains live. |
|
||||||
|
| Complete catalog | Lower aliases+global ID set: two entries/global and owned alias bytes. Scoped parameter map: one entry/calculation plus one entry/name and copied name bytes. Per-signature temporary ID set reserves one entry/parameter and releases after `Tables::typed` returns. |
|
||||||
|
| Alias projection and diagnostic owners | One entry+copied alias bytes/global for projection; one entry/global for owner registry. Projection drops after construction, owner registry after diagnostic mapping. Both overlap the lower catalog initially. |
|
||||||
|
| Ordinary coordination | One borrowed `(file,declaration)` entry/ordinary occurrence. One deferred-node entry before that declaration's field cloning. Release after construction/attachment returns and the views drop. |
|
||||||
|
| Typed lowering | Bounded syntax walk counts N expressions, A supplied call arguments, O emitted occurrences. Reserve `5N+2A` entries and O occurrences before lowering. N entries are retained expression sites. Temporaries are bounded by 2N Visit/Build slots, N borrowed child slots, N value/split slots, A call-parameter slots and A seen-set slots. Hold this conservative whole-phase allowance until all Resolver temporaries die; then release only the difference from actual retained global sites. |
|
||||||
|
| Mapped diagnostics | Before retention, reserve one output slot, copied canonical path bytes and transferred Issue code/field/message String bytes. No line cache is allocated. Owner registry and syntax/sites remain live during mapping. Failure cannot produce truncated-warning success. |
|
||||||
|
|
||||||
|
The call signature is borrowed from the already accounted catalog; no lowerer allocation grows with unprovided callee parameters. Unknown/incomplete calls cannot exceed the supplied-argument bound. O includes parameter declarations, scalar-type references, non-null optional references, call targets/labels and expression reference forms; headers and display text count only as text. Failed lowering drops all partial output with its reservation.
|
||||||
|
|
||||||
|
Model/serde allocations and architectural validation's raw Issue vector/strings/indexes remain explicitly outside this preallocation ledger. Model limits are checked on the complete Project; static analysis retains its existing independent meter. Raw Issues are never retained uncharged in an accepted result. Rejection diagnostics are small error transport outside accepted-witness storage, so exhaustion itself remains reportable.
|
||||||
|
|
||||||
|
The ordinary hook selects exactly Process/FunctionalChain/Scenario steps, Occurrence Branch occurrences, and ContextualLink source then target reference arrays before field resolution/cloning. Repeated items count separately; scalar parent/owner, endpoint occurrence, unrelated fields and every Trace baseline snapshot are excluded. Recognizable malformed arrays are charged before the unchanged decoder reports their shape errors.
|
||||||
|
|
||||||
|
## Admission, mapping and verification
|
||||||
|
|
||||||
|
`validation::validate_with_usage` performs one static admission, copies owned usage/limits, drops the borrowing environment, then performs one architecture validation. `validate` uses the same route and returns its Issues. Static failure preserves the original short circuit. The mixed path maps all Issues and refuses any error. Known typed fields map precisely; unsupported semantic field paths, including the current aggregate `parameters`, explicitly fall back to their owner. Exact scoped-parameter lowerer failures retain their original file and full span; UUID substrings never manufacture precision.
|
||||||
|
|
||||||
|
Authored tests cover whole OA/SA models and exact warnings; a complete 15-expression/18-occurrence arena oracle with repeated/reordered calls, scoped UUID reuse and coincident file offsets; catalog collisions; ordinary reference selectors/cumulative limits; simultaneous storage and diagnostic-path copying; inherited nested records; legacy scratch/default/errors; static/architecture refusal; parameter diagnostic fallback; large-signature incomplete calls; and 128 KiB success/rejection cleanup. The full model/site oracle was independently designed before implementation. Tests are unexecuted at this checkpoint.
|
||||||
|
|
||||||
|
Required later gates: Windows listing, focused new Project tests, complete private typed tests (including lower/index/update and small-stack controls), existing public parser/traversal/migration regressions with preserved baseline disposition, and strict all-target core Clippy. The known inherited formatting/full-suite failures must remain explicit. Source formatting is a parsing check, not typechecking or runtime evidence.
|
||||||
|
|
||||||
|
Index construction/verification, ordinary occurrence indexing, old-updater access, public source8 activation, stabilization, mixed edit composition, output recompile/admission, intended-Project equality and root Scenario/editor reconciliation remain deferred. `index.rs` and `update.rs` are unchanged.
|
||||||
Reference in New Issue
Block a user