feat(source): add bounded read-only mixed filesystem discovery
This commit is contained in:
@@ -5,6 +5,7 @@ mod semantic_source;
|
|||||||
mod source3_bridge;
|
mod source3_bridge;
|
||||||
// Mixed source8 is exposed only through the separate borrowed read-only checker.
|
// Mixed source8 is exposed only through the separate borrowed read-only checker.
|
||||||
mod analysis_source;
|
mod analysis_source;
|
||||||
|
pub(crate) use analysis_source::MixedDiscovery;
|
||||||
pub use analysis_source::{CheckedMixed, check_mixed_bundle};
|
pub use analysis_source::{CheckedMixed, check_mixed_bundle};
|
||||||
#[path = "migration_v2/source.rs"]
|
#[path = "migration_v2/source.rs"]
|
||||||
mod v2_source;
|
mod v2_source;
|
||||||
|
|||||||
@@ -118,3 +118,5 @@ pub fn parse(source: &str, budget: &mut Budget) -> Result<Binding, Error> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
pub(crate) use mixed::discovery::MixedDiscovery;
|
||||||
|
|||||||
@@ -395,30 +395,15 @@ fn parse_impl<'s>(
|
|||||||
let failure = |error| located(path, source, ordinary_error(error));
|
let failure = |error| located(path, source, ordinary_error(error));
|
||||||
let tokens = lex::tokens(source, &mut budget).map_err(failure)?;
|
let tokens = lex::tokens(source, &mut budget).map_err(failure)?;
|
||||||
let metadata_before = budget.producer_entries();
|
let metadata_before = budget.producer_entries();
|
||||||
let mut parser = Parser {
|
let (ordinary, state) = parse_unit(
|
||||||
source,
|
source,
|
||||||
tokens: Vec::new(),
|
&tokens,
|
||||||
cursor: 0,
|
&mut budget,
|
||||||
value_depth: 0,
|
&mut model,
|
||||||
limits: limits.model,
|
limits.model,
|
||||||
mixed: Some(State {
|
path == &bundle.entry,
|
||||||
source,
|
)
|
||||||
tokens: &tokens,
|
.map_err(|e| located(path, source, e))?;
|
||||||
budget: &mut budget,
|
|
||||||
model: &mut model,
|
|
||||||
typed: Vec::new(),
|
|
||||||
value_ends: BTreeMap::new(),
|
|
||||||
lexical_owners: Vec::new(),
|
|
||||||
pending_projection: 0,
|
|
||||||
reserved_values: 0,
|
|
||||||
reserved_owners: 0,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
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
|
// Projected ordinary AST nodes/fields are bounded before constructing a
|
||||||
// second position representation. Count references as slots, not syntax.
|
// second position representation. Count references as slots, not syntax.
|
||||||
let projection_slots = if entry_producers {
|
let projection_slots = if entry_producers {
|
||||||
@@ -564,3 +549,40 @@ fn ordinary_position_slots(unit: &SourceUnit) -> usize {
|
|||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
pub(super) mod project;
|
pub(super) mod project;
|
||||||
|
|
||||||
|
/// Shared grammar setup only. Complete parsing still retains/projects its state;
|
||||||
|
/// discovery drops that state after moving includes without a projection.
|
||||||
|
fn parse_unit<'a>(
|
||||||
|
source: &'a str,
|
||||||
|
tokens: &'a [lex::Token],
|
||||||
|
budget: &'a mut Budget,
|
||||||
|
model: &'a mut ModelCounts,
|
||||||
|
limits: ParseLimits,
|
||||||
|
entry: bool,
|
||||||
|
) -> Result<(SourceUnit, State<'a>), Error> {
|
||||||
|
let mut parser = Parser {
|
||||||
|
source,
|
||||||
|
tokens: Vec::new(),
|
||||||
|
cursor: 0,
|
||||||
|
value_depth: 0,
|
||||||
|
limits,
|
||||||
|
mixed: Some(State {
|
||||||
|
source,
|
||||||
|
tokens,
|
||||||
|
budget,
|
||||||
|
model,
|
||||||
|
typed: Vec::new(),
|
||||||
|
value_ends: BTreeMap::new(),
|
||||||
|
lexical_owners: Vec::new(),
|
||||||
|
pending_projection: 0,
|
||||||
|
reserved_values: 0,
|
||||||
|
reserved_owners: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let ordinary = parser.unit(entry, true)?;
|
||||||
|
let state = parser.mixed.take().expect("mixed parser state");
|
||||||
|
drop(parser);
|
||||||
|
Ok((ordinary, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod discovery;
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//! A separate conservative discovery phase; no model admission or edit positions.
|
||||||
|
use super::*;
|
||||||
|
use crate::source_project::{IncludeLocation, SourceLocation, resolve_include};
|
||||||
|
|
||||||
|
pub(crate) struct MixedDiscovery {
|
||||||
|
budget: Budget,
|
||||||
|
model: ModelCounts,
|
||||||
|
failed: Option<Vec<SourceDiagnostic>>,
|
||||||
|
}
|
||||||
|
impl MixedDiscovery {
|
||||||
|
pub(crate) fn new(limits: ProjectLimits) -> Result<Self, String> {
|
||||||
|
let mut budget = Budget::new(source_profile(limits)).map_err(|e| e.message)?;
|
||||||
|
budget.enable_text_producers(0).map_err(|e| e.message)?;
|
||||||
|
budget.enable_entry_producers(0, 0).map_err(|e| e.message)?;
|
||||||
|
Ok(Self {
|
||||||
|
budget,
|
||||||
|
model: ModelCounts {
|
||||||
|
limits,
|
||||||
|
graph: 0,
|
||||||
|
records: 0,
|
||||||
|
includes: 0,
|
||||||
|
},
|
||||||
|
failed: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn inspect(
|
||||||
|
&mut self,
|
||||||
|
path: &str,
|
||||||
|
source: &str,
|
||||||
|
entry: bool,
|
||||||
|
) -> Result<Vec<IncludeLocation>, Vec<SourceDiagnostic>> {
|
||||||
|
if let Some(failed) = &self.failed {
|
||||||
|
return Err(failed.clone());
|
||||||
|
}
|
||||||
|
let result = self.inspect_unit(path, source, entry);
|
||||||
|
if let Err(failed) = &result {
|
||||||
|
self.failed = Some(failed.clone());
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_unit(
|
||||||
|
&mut self,
|
||||||
|
path: &str,
|
||||||
|
source: &str,
|
||||||
|
entry: bool,
|
||||||
|
) -> Result<Vec<IncludeLocation>, Vec<SourceDiagnostic>> {
|
||||||
|
let failure = |error| located(path, source, ordinary_error(error));
|
||||||
|
// Conservative per-inspected-file reservation, before its scanner/parser.
|
||||||
|
self.budget
|
||||||
|
.reserve_metadata(1, Span::default())
|
||||||
|
.map_err(failure)?;
|
||||||
|
let tokens = lex::tokens(source, &mut self.budget).map_err(failure)?;
|
||||||
|
let limits = self.model.limits.model;
|
||||||
|
let (ordinary, state) = parse_unit(
|
||||||
|
source,
|
||||||
|
&tokens,
|
||||||
|
&mut self.budget,
|
||||||
|
&mut self.model,
|
||||||
|
limits,
|
||||||
|
entry,
|
||||||
|
)
|
||||||
|
.map_err(|e| located(path, source, e))?;
|
||||||
|
// No unit-drop refunds: remaining reservations are cumulative phase work.
|
||||||
|
// In-parser quoted/UUID/key temporary release behavior is unchanged.
|
||||||
|
drop(state);
|
||||||
|
let mut includes = Vec::new();
|
||||||
|
for (raw, span) in ordinary.includes {
|
||||||
|
let target = resolve_include(path, &raw).map_err(|message| {
|
||||||
|
let mut diagnostic = language::diagnostic(
|
||||||
|
source,
|
||||||
|
Error {
|
||||||
|
code: "invalid_path",
|
||||||
|
offset: span.start,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
diagnostic.span = span;
|
||||||
|
vec![SourceDiagnostic {
|
||||||
|
path: path.into(),
|
||||||
|
diagnostic,
|
||||||
|
related: Vec::new(),
|
||||||
|
}]
|
||||||
|
})?;
|
||||||
|
includes.push(IncludeLocation {
|
||||||
|
location: SourceLocation {
|
||||||
|
path: path.into(),
|
||||||
|
span,
|
||||||
|
},
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(includes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
const MAIN: &str = "arcadia 8;\nproject p \"Project\" id \"00000000-0000-0000-0000-000000000001\" {\ninclude \"part.arcadia\";\n}\n";
|
||||||
|
const PART: &str = "include \"last.arcadia\";\r\n";
|
||||||
|
const LAST: &str = "//é\r\n";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_discovery_accumulates_work_and_reservations_after_unit_drop() {
|
||||||
|
let limits = ProjectLimits {
|
||||||
|
model: ParseLimits {
|
||||||
|
max_source_bytes: 4096,
|
||||||
|
max_records: 1,
|
||||||
|
max_graph_objects: 3,
|
||||||
|
max_step_references: 17,
|
||||||
|
},
|
||||||
|
max_files: 7,
|
||||||
|
max_include_depth: 9,
|
||||||
|
max_include_edges: 11,
|
||||||
|
};
|
||||||
|
let mut phase = MixedDiscovery::new(limits).unwrap();
|
||||||
|
let profile = phase.budget.limits();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
profile.bytes,
|
||||||
|
profile.tokens,
|
||||||
|
profile.entries,
|
||||||
|
profile.references,
|
||||||
|
profile.frames,
|
||||||
|
profile.numeric_units
|
||||||
|
),
|
||||||
|
(4096, 4096, 262144, 17, 256, 50_000_000)
|
||||||
|
);
|
||||||
|
assert_eq!(phase.budget.producer_text(), Some((0, 0)));
|
||||||
|
assert_eq!(phase.budget.producer_entries(), Some(0));
|
||||||
|
let includes = phase.inspect("main.arcadia", MAIN, true).unwrap();
|
||||||
|
assert_eq!(includes.len(), 1);
|
||||||
|
assert_eq!(includes[0].target, "part.arcadia");
|
||||||
|
drop(includes);
|
||||||
|
assert_eq!(phase.budget.producer_text(), Some((20, 0)));
|
||||||
|
assert_eq!(phase.budget.producer_entries(), Some(1));
|
||||||
|
assert_eq!(phase.budget.usage.entries, 2);
|
||||||
|
let includes = phase.inspect("part.arcadia", PART, false).unwrap();
|
||||||
|
assert_eq!(includes[0].target, "last.arcadia");
|
||||||
|
drop(includes);
|
||||||
|
assert_eq!(phase.budget.producer_text(), Some((32, 0)));
|
||||||
|
assert!(
|
||||||
|
phase
|
||||||
|
.inspect("last.arcadia", LAST, false)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
phase.budget.usage.bytes,
|
||||||
|
phase.budget.usage.tokens,
|
||||||
|
phase.budget.usage.entries,
|
||||||
|
phase.budget.usage.references
|
||||||
|
),
|
||||||
|
(132, 31, 3, 0)
|
||||||
|
);
|
||||||
|
assert_eq!(phase.budget.producer_entries(), Some(3));
|
||||||
|
assert_eq!(phase.budget.producer_text(), Some((32, 0)));
|
||||||
|
assert_eq!(phase.budget.numeric_units(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
(phase.model.records, phase.model.graph, phase.model.includes),
|
||||||
|
(1, 0, 2)
|
||||||
|
);
|
||||||
|
assert_eq!(phase.model.limits.max_files, 7);
|
||||||
|
assert_eq!(phase.model.limits.max_include_depth, 9);
|
||||||
|
assert_eq!(phase.model.limits.max_include_edges, 11);
|
||||||
|
assert_eq!(phase.model.limits.model.max_records, 1);
|
||||||
|
assert_eq!(phase.model.limits.model.max_graph_objects, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_discovery_cumulative_entry_boundary_and_terminal_original_failure() {
|
||||||
|
// Deliberate private constructor fixture: fresh policy, never a widened owner.
|
||||||
|
for cap in [6, 5] {
|
||||||
|
let mut phase = MixedDiscovery::new(ProjectLimits::default()).unwrap();
|
||||||
|
phase.budget = Budget::new(Limits {
|
||||||
|
entries: cap,
|
||||||
|
bytes: 4096,
|
||||||
|
..source_profile(ProjectLimits::default())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
phase.budget.enable_text_producers(0).unwrap();
|
||||||
|
phase.budget.enable_entry_producers(0, 0).unwrap();
|
||||||
|
phase.inspect("main.arcadia", MAIN, true).unwrap();
|
||||||
|
phase.inspect("part.arcadia", PART, false).unwrap();
|
||||||
|
let last = phase.inspect("last.arcadia", LAST, false);
|
||||||
|
if cap == 6 {
|
||||||
|
assert!(last.unwrap().is_empty());
|
||||||
|
} else {
|
||||||
|
let errors = last.unwrap_err();
|
||||||
|
assert_eq!(errors[0].path, "last.arcadia");
|
||||||
|
assert_eq!(errors[0].diagnostic.span, Span { start: 0, end: 1 });
|
||||||
|
assert!(errors[0].diagnostic.message.contains("entry limit"));
|
||||||
|
let usage = phase.budget.usage;
|
||||||
|
assert_eq!(
|
||||||
|
phase
|
||||||
|
.inspect("later.arcadia", "malformed", false)
|
||||||
|
.unwrap_err(),
|
||||||
|
errors
|
||||||
|
);
|
||||||
|
assert_eq!(phase.budget.usage, usage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut phase = MixedDiscovery::new(ProjectLimits::default()).unwrap();
|
||||||
|
let errors = phase
|
||||||
|
.inspect("broken.arcadia", "arcadia 8; project p \"P\" {}", true)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
errors[0].diagnostic.message,
|
||||||
|
"Mixed source8 requires explicit UUIDs."
|
||||||
|
);
|
||||||
|
let usage = phase.budget.usage;
|
||||||
|
assert_eq!(
|
||||||
|
phase.inspect("later.arcadia", LAST, false).unwrap_err(),
|
||||||
|
errors
|
||||||
|
);
|
||||||
|
assert_eq!(phase.budget.usage, usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_discovery_numeric_and_reference_work_is_not_restarted_per_unit() {
|
||||||
|
let mut phase = MixedDiscovery::new(ProjectLimits::default()).unwrap();
|
||||||
|
for (alias, number) in [("f", 2), ("g", 3)] {
|
||||||
|
let source = format!(
|
||||||
|
"calculation {alias} \"C\" id \"00000000-0000-0000-0000-{number:012}\" {{ input x id \"00000000-0000-0000-0000-000000000001\" : Number; result Number; body $x + 1; }}"
|
||||||
|
);
|
||||||
|
let before = phase.budget.numeric_units();
|
||||||
|
phase
|
||||||
|
.inspect(&format!("{alias}.arcadia"), &source, false)
|
||||||
|
.unwrap();
|
||||||
|
if number == 2 {
|
||||||
|
assert!(phase.budget.numeric_units() > 0);
|
||||||
|
} else {
|
||||||
|
assert_eq!(phase.budget.numeric_units(), 2 * before);
|
||||||
|
}
|
||||||
|
assert_eq!(phase.budget.usage.references, number - 1);
|
||||||
|
assert_eq!(phase.budget.producer_text().unwrap().1, 0);
|
||||||
|
}
|
||||||
|
assert_eq!(phase.model.records, 2);
|
||||||
|
}
|
||||||
@@ -379,6 +379,59 @@ pub fn expect_new_file(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Captured mixed source bytes and discovery diagnostics, without save authority.
|
||||||
|
/// Handle discovery errors before checking the complete bundle. This is not an
|
||||||
|
/// atomic snapshot against another process and never performs journal recovery.
|
||||||
|
pub struct MixedSourceSnapshot {
|
||||||
|
bundle: SourceBundle,
|
||||||
|
diagnostics: Vec<SourceDiagnostic>,
|
||||||
|
}
|
||||||
|
impl MixedSourceSnapshot {
|
||||||
|
pub fn bundle(&self) -> &SourceBundle {
|
||||||
|
&self.bundle
|
||||||
|
}
|
||||||
|
pub fn diagnostics(&self) -> &[SourceDiagnostic] {
|
||||||
|
&self.diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover explicit-ID mixed source8 through the shared grammar without writes.
|
||||||
|
/// Complete semantic checking remains `language::check_mixed_bundle`; discovery
|
||||||
|
/// alone does not admit a model. Source bytes are capped before disk reads.
|
||||||
|
pub fn load_mixed_source(
|
||||||
|
entry: &Path,
|
||||||
|
mut limits: ProjectLimits,
|
||||||
|
) -> Result<MixedSourceSnapshot, SourceFileError> {
|
||||||
|
limits.model.max_source_bytes = limits.model.max_source_bytes.min(64 * 1024 * 1024);
|
||||||
|
let (root, entry) = root_entry(entry)?;
|
||||||
|
let snapshot = DiskProject {
|
||||||
|
root,
|
||||||
|
bundle: SourceBundle {
|
||||||
|
entry,
|
||||||
|
files: BTreeMap::new(),
|
||||||
|
},
|
||||||
|
expectations: BTreeMap::new(),
|
||||||
|
reachable: BTreeSet::new(),
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
};
|
||||||
|
let mut discovery = language::MixedDiscovery::new(limits).map_err(SourceFileError::Limit)?;
|
||||||
|
let captured = discover(snapshot, limits, DiscoveryMode::Mixed(&mut discovery))?;
|
||||||
|
drop(discovery);
|
||||||
|
let DiskProject {
|
||||||
|
bundle,
|
||||||
|
diagnostics,
|
||||||
|
..
|
||||||
|
} = captured;
|
||||||
|
Ok(MixedSourceSnapshot {
|
||||||
|
bundle,
|
||||||
|
diagnostics,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DiscoveryMode<'a> {
|
||||||
|
Ordinary,
|
||||||
|
Mixed(&'a mut language::MixedDiscovery),
|
||||||
|
}
|
||||||
/// Merge editor overlays and discover only files absent from retained buffers.
|
/// Merge editor overlays and discover only files absent from retained buffers.
|
||||||
/// Existing drafts (including detached ones) and their disk baselines survive.
|
/// Existing drafts (including detached ones) and their disk baselines survive.
|
||||||
pub fn discover_missing(
|
pub fn discover_missing(
|
||||||
@@ -390,6 +443,14 @@ pub fn discover_missing(
|
|||||||
result.bundle.files.extend(overlays.clone());
|
result.bundle.files.extend(overlays.clone());
|
||||||
result.reachable.clear();
|
result.reachable.clear();
|
||||||
result.diagnostics.clear();
|
result.diagnostics.clear();
|
||||||
|
discover(result, limits, DiscoveryMode::Ordinary)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover(
|
||||||
|
mut result: DiskProject,
|
||||||
|
limits: ProjectLimits,
|
||||||
|
mut mode: DiscoveryMode<'_>,
|
||||||
|
) -> Result<DiskProject, SourceFileError> {
|
||||||
check_snapshot(&result, limits)?;
|
check_snapshot(&result, limits)?;
|
||||||
let mut stack = vec![(result.bundle.entry.clone(), 0usize, None::<SourceLocation>)];
|
let mut stack = vec![(result.bundle.entry.clone(), 0usize, None::<SourceLocation>)];
|
||||||
let mut edges = 0usize;
|
let mut edges = 0usize;
|
||||||
@@ -492,12 +553,20 @@ pub fn discover_missing(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match inspect_includes(
|
let includes = match &mut mode {
|
||||||
&path,
|
DiscoveryMode::Ordinary => inspect_includes(
|
||||||
&result.bundle.files[&path],
|
&path,
|
||||||
path == result.bundle.entry,
|
&result.bundle.files[&path],
|
||||||
limits,
|
path == result.bundle.entry,
|
||||||
) {
|
limits,
|
||||||
|
),
|
||||||
|
DiscoveryMode::Mixed(discovery) => discovery.inspect(
|
||||||
|
&path,
|
||||||
|
&result.bundle.files[&path],
|
||||||
|
path == result.bundle.entry,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
match includes {
|
||||||
Ok(includes) => {
|
Ok(includes) => {
|
||||||
edges = edges.saturating_add(includes.len());
|
edges = edges.saturating_add(includes.len());
|
||||||
if edges > limits.max_include_edges {
|
if edges > limits.max_include_edges {
|
||||||
@@ -517,7 +586,12 @@ pub fn discover_missing(
|
|||||||
.map(|include| (include.target, depth + 1, Some(include.location))),
|
.map(|include| (include.target, depth + 1, Some(include.location))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(diagnostics) => result.diagnostics.extend(diagnostics),
|
Err(diagnostics) => {
|
||||||
|
result.diagnostics.extend(diagnostics);
|
||||||
|
if matches!(mode, DiscoveryMode::Mixed(_)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
check_snapshot(&result, limits)?;
|
check_snapshot(&result, limits)?;
|
||||||
@@ -525,7 +599,7 @@ pub fn discover_missing(
|
|||||||
&result.root,
|
&result.root,
|
||||||
result.expectations.keys().chain(result.bundle.files.keys()),
|
result.expectations.keys().chain(result.bundle.files.keys()),
|
||||||
)?;
|
)?;
|
||||||
if result.diagnostics.is_empty() {
|
if result.diagnostics.is_empty() && matches!(mode, DiscoveryMode::Ordinary) {
|
||||||
match compile_bundle(&result.reachable_bundle(), limits) {
|
match compile_bundle(&result.reachable_bundle(), limits) {
|
||||||
Ok(compiled) => result.diagnostics = compiled.diagnostics,
|
Ok(compiled) => result.diagnostics = compiled.diagnostics,
|
||||||
Err(diagnostics) => result.diagnostics = diagnostics,
|
Err(diagnostics) => result.diagnostics = diagnostics,
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
//! Actual filesystem discovery followed by the public borrowed complete checker.
|
||||||
|
use oa_core::{analysis as a, language, source_files::*, source_project::*, *};
|
||||||
|
use std::{collections::BTreeMap, fs};
|
||||||
|
|
||||||
|
fn id(n: u128) -> Id {
|
||||||
|
Id::from_u128(n)
|
||||||
|
}
|
||||||
|
fn document(body: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"arcadia 8;\nproject p \"Project\" id \"{}\" {{\n{body}\n}}\n",
|
||||||
|
id(1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn write(files: &[(&str, String)]) -> tempfile::TempDir {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
for (path, text) in files {
|
||||||
|
fs::write(dir.path().join(path), text).unwrap();
|
||||||
|
}
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
fn unchanged(dir: &tempfile::TempDir, files: &[(&str, String)]) {
|
||||||
|
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), files.len());
|
||||||
|
for (path, text) in files {
|
||||||
|
assert_eq!(fs::read(dir.path().join(path)).unwrap(), text.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn calculation(alias: &str, n: u128, body: a::Expr) -> a::Definition {
|
||||||
|
a::Definition {
|
||||||
|
id: id(n),
|
||||||
|
alias: alias.into(),
|
||||||
|
name: "Calculation".into(),
|
||||||
|
description: String::new(),
|
||||||
|
body: a::DefinitionBody::Calculation(a::CalculationDefinition {
|
||||||
|
parameters: vec![a::Parameter {
|
||||||
|
id: a::ParameterId(id(1)),
|
||||||
|
name: "x".into(),
|
||||||
|
value_type: a::AnalysisType::Scalar(a::ScalarType::Number),
|
||||||
|
}],
|
||||||
|
result: a::AnalysisType::Scalar(a::ScalarType::Number),
|
||||||
|
body: a::Binding::Expression(body),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn calc(alias: &str, n: u128, body: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"calculation {alias} \"Calculation\" id \"{}\" {{ input x id \"{}\" : Number; result Number; body {body}; }}",
|
||||||
|
id(n),
|
||||||
|
id(1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_files_complete_model_nested_ordinary_scoped_cross_file_and_original_bytes() {
|
||||||
|
let files = [
|
||||||
|
(
|
||||||
|
"main.arcadia",
|
||||||
|
document(
|
||||||
|
"// include \"ignored.arcadia\"; 海\ninclude \"f.arcadia\"; include \"g.arcadia\";",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"f.arcadia",
|
||||||
|
format!(
|
||||||
|
"{}\noa_entity team \"Team\" id \"{}\" {{ oa_actor crew \"Crew\" id \"{}\" {{}} }}\r\n",
|
||||||
|
calc("f", 2, "@g(x: $x)"),
|
||||||
|
id(4),
|
||||||
|
id(5)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("g.arcadia", calc("g", 3, "$x")),
|
||||||
|
];
|
||||||
|
let dir = write(&files);
|
||||||
|
let snapshot =
|
||||||
|
load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap();
|
||||||
|
assert!(
|
||||||
|
snapshot.diagnostics().is_empty(),
|
||||||
|
"{:?}",
|
||||||
|
snapshot.diagnostics()
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.bundle().entry, "main.arcadia");
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.bundle().files,
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.map(|(p, t)| (p.to_string(), t.clone()))
|
||||||
|
.collect()
|
||||||
|
);
|
||||||
|
let mut expected = Project::empty();
|
||||||
|
expected.id = id(1);
|
||||||
|
expected.alias = "p".into();
|
||||||
|
expected.name = "Project".into();
|
||||||
|
expected.definitions.insert(
|
||||||
|
id(2),
|
||||||
|
calculation(
|
||||||
|
"f",
|
||||||
|
2,
|
||||||
|
a::Expr::Call {
|
||||||
|
definition: a::DefinitionRef::Local(id(3)),
|
||||||
|
arguments: BTreeMap::from([(
|
||||||
|
a::ParameterId(id(1)),
|
||||||
|
a::Expr::Parameter(a::ParameterId(id(1))),
|
||||||
|
)]),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expected.definitions.insert(
|
||||||
|
id(3),
|
||||||
|
calculation("g", 3, a::Expr::Parameter(a::ParameterId(id(1)))),
|
||||||
|
);
|
||||||
|
for (n, alias, name, kind, parent) in [
|
||||||
|
(4, "team", "Team", Kind::Entity, None),
|
||||||
|
(5, "crew", "Crew", Kind::Actor, Some(id(4))),
|
||||||
|
] {
|
||||||
|
expected.elements.insert(
|
||||||
|
id(n),
|
||||||
|
Element {
|
||||||
|
id: id(n),
|
||||||
|
alias: alias.into(),
|
||||||
|
name: name.into(),
|
||||||
|
description: String::new(),
|
||||||
|
parent,
|
||||||
|
body: ElementBody::new(kind, Layer::Oa).unwrap(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let checked =
|
||||||
|
language::check_mixed_bundle(snapshot.bundle(), ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(checked.project(), &expected);
|
||||||
|
assert!(checked.warnings().is_empty());
|
||||||
|
drop(checked);
|
||||||
|
drop(snapshot);
|
||||||
|
unchanged(&dir, &files);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_files_terminal_syntax_preserves_first_path_and_still_reports_journal() {
|
||||||
|
let files = [
|
||||||
|
(
|
||||||
|
"main.arcadia",
|
||||||
|
document("include \"bad.arcadia\"; include \"later.arcadia\";"),
|
||||||
|
),
|
||||||
|
("bad.arcadia", "oa_entity missing \"Missing\" {}".into()),
|
||||||
|
("later.arcadia", "must not parse".into()),
|
||||||
|
];
|
||||||
|
let dir = write(&files);
|
||||||
|
fs::write(
|
||||||
|
dir.path().join(".arcadia-save-main.arcadia.journal"),
|
||||||
|
b"unrecovered",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let snapshot =
|
||||||
|
load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(snapshot.diagnostics().len(), 2);
|
||||||
|
assert_eq!(snapshot.diagnostics()[0].path, "bad.arcadia");
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.diagnostics()[0].diagnostic.message,
|
||||||
|
"Mixed source8 requires explicit UUIDs."
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.diagnostics()[1].diagnostic.code, "pending_save");
|
||||||
|
assert!(!snapshot.bundle().files.contains_key("later.arcadia"));
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(dir.path().join("later.arcadia")).unwrap(),
|
||||||
|
b"must not parse"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(dir.path().join(".arcadia-save-main.arcadia.journal")).unwrap(),
|
||||||
|
b"unrecovered"
|
||||||
|
);
|
||||||
|
for (path, text) in &files {
|
||||||
|
assert_eq!(fs::read(dir.path().join(path)).unwrap(), text.as_bytes());
|
||||||
|
}
|
||||||
|
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_files_identity_classes_and_malformed_units_are_discovery_errors() {
|
||||||
|
let valid = calc("f", 2, "$x");
|
||||||
|
let cases = [
|
||||||
|
document("").replace(&format!(" id \"{}\"", id(1)), ""),
|
||||||
|
document("oa_entity e \"E\" {}"),
|
||||||
|
document(&valid.replace(&format!("id \"{}\"", id(2)), "")),
|
||||||
|
document(&valid.replace(&format!("input x id \"{}\"", id(1)), "input x")),
|
||||||
|
document("calculation malformed"),
|
||||||
|
];
|
||||||
|
for (i, text) in cases.into_iter().enumerate() {
|
||||||
|
let files = [("main.arcadia", text)];
|
||||||
|
let dir = write(&files);
|
||||||
|
let snapshot =
|
||||||
|
load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(snapshot.diagnostics().len(), 1);
|
||||||
|
assert_eq!(snapshot.diagnostics()[0].path, "main.arcadia");
|
||||||
|
if i < 4 {
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.diagnostics()[0].diagnostic.message,
|
||||||
|
"Mixed source8 requires explicit UUIDs."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(snapshot.diagnostics()[0].diagnostic.code, "invalid_source");
|
||||||
|
}
|
||||||
|
unchanged(&dir, &files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_files_include_failures_and_cycles_do_not_become_partial_admission() {
|
||||||
|
for (include, code) in [
|
||||||
|
("missing.arcadia", "missing_include"),
|
||||||
|
("../escape.arcadia", "invalid_path"),
|
||||||
|
] {
|
||||||
|
let files = [("main.arcadia", document(&format!("include \"{include}\";")))];
|
||||||
|
let dir = write(&files);
|
||||||
|
let snapshot =
|
||||||
|
load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap();
|
||||||
|
assert!(
|
||||||
|
snapshot
|
||||||
|
.diagnostics()
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.diagnostic.code == code)
|
||||||
|
);
|
||||||
|
unchanged(&dir, &files);
|
||||||
|
}
|
||||||
|
let files = [
|
||||||
|
("main.arcadia", document("include \"part.arcadia\";")),
|
||||||
|
("part.arcadia", "include \"part.arcadia\";".into()),
|
||||||
|
];
|
||||||
|
let dir = write(&files);
|
||||||
|
let snapshot =
|
||||||
|
load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap();
|
||||||
|
assert!(snapshot.diagnostics().is_empty());
|
||||||
|
let errors = match language::check_mixed_bundle(snapshot.bundle(), ProjectLimits::default()) {
|
||||||
|
Ok(_) => panic!("cycle admitted"),
|
||||||
|
Err(errors) => errors,
|
||||||
|
};
|
||||||
|
assert!(errors.iter().any(|d| d.diagnostic.code == "include_cycle"));
|
||||||
|
unchanged(&dir, &files);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_files_limits_encoding_nonregular_and_physical_aliases() {
|
||||||
|
let files = [
|
||||||
|
("main.arcadia", document("include \"part.arcadia\";")),
|
||||||
|
("part.arcadia", "include \"last.arcadia\";\r\n".into()),
|
||||||
|
("last.arcadia", "//é\r\n".into()),
|
||||||
|
];
|
||||||
|
let dir = write(&files);
|
||||||
|
let entry = dir.path().join("main.arcadia");
|
||||||
|
for (kind, cap) in [("files", 3), ("depth", 2), ("edges", 2), ("bytes", 132)] {
|
||||||
|
for allowed in [cap, cap - 1] {
|
||||||
|
let mut limits = ProjectLimits::default();
|
||||||
|
match kind {
|
||||||
|
"files" => limits.max_files = allowed,
|
||||||
|
"depth" => limits.max_include_depth = allowed,
|
||||||
|
"edges" => limits.max_include_edges = allowed,
|
||||||
|
_ => limits.model.max_source_bytes = allowed,
|
||||||
|
};
|
||||||
|
let result = load_mixed_source(&entry, limits);
|
||||||
|
if allowed == cap {
|
||||||
|
assert!(result.unwrap().diagnostics().is_empty(), "{kind}");
|
||||||
|
} else {
|
||||||
|
match result {
|
||||||
|
Err(_) => {}
|
||||||
|
Ok(snapshot) => assert!(!snapshot.diagnostics().is_empty(), "{kind}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unchanged(&dir, &files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs::write(dir.path().join("last.arcadia"), [0xff]).unwrap();
|
||||||
|
let snapshot = load_mixed_source(&entry, ProjectLimits::default()).unwrap();
|
||||||
|
assert!(
|
||||||
|
snapshot
|
||||||
|
.diagnostics()
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.diagnostic.code == "source_encoding")
|
||||||
|
);
|
||||||
|
fs::remove_file(dir.path().join("last.arcadia")).unwrap();
|
||||||
|
fs::create_dir(dir.path().join("last.arcadia")).unwrap();
|
||||||
|
let snapshot = load_mixed_source(&entry, ProjectLimits::default()).unwrap();
|
||||||
|
assert!(
|
||||||
|
snapshot
|
||||||
|
.diagnostics()
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.diagnostic.code == "invalid_path")
|
||||||
|
);
|
||||||
|
let collision = write(&[
|
||||||
|
(
|
||||||
|
"main.arcadia",
|
||||||
|
document("include \"part.arcadia\"; include \"PART.arcadia\";"),
|
||||||
|
),
|
||||||
|
("part.arcadia", "// case alias\n".into()),
|
||||||
|
]);
|
||||||
|
assert!(matches!(
|
||||||
|
load_mixed_source(
|
||||||
|
&collision.path().join("main.arcadia"),
|
||||||
|
ProjectLimits::default()
|
||||||
|
),
|
||||||
|
Err(SourceFileError::Path(_))
|
||||||
|
));
|
||||||
|
let aliases = write(&[
|
||||||
|
(
|
||||||
|
"main.arcadia",
|
||||||
|
document("include \"a.arcadia\"; include \"b.arcadia\";"),
|
||||||
|
),
|
||||||
|
("a.arcadia", "// same physical file\n".into()),
|
||||||
|
]);
|
||||||
|
fs::hard_link(
|
||||||
|
aliases.path().join("a.arcadia"),
|
||||||
|
aliases.path().join("b.arcadia"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
load_mixed_source(
|
||||||
|
&aliases.path().join("main.arcadia"),
|
||||||
|
ProjectLimits::default()
|
||||||
|
),
|
||||||
|
Err(SourceFileError::Path(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Mixed CLI core discovery implementation
|
||||||
|
|
||||||
|
Base bab6f96a; selected root contract f810fb40. This contribution owns core only.
|
||||||
|
source_files::load_mixed_source returns opaque MixedSourceSnapshot with immutable
|
||||||
|
bundle/diagnostics views. It consumes a fresh private DiskProject after final
|
||||||
|
checks and drops root/expectations/reachability; no reachable-bundle clone, model,
|
||||||
|
index, save or recovery authority escapes. The byte allowance is clamped to the
|
||||||
|
existing64MiB ceiling before disk reads; other caller fields are preserved.
|
||||||
|
|
||||||
|
The existing discovery loop has a private borrowed ordinary/mixed selector.
|
||||||
|
Ordinary overlays, aggregation, limits, final compilation and journal behavior
|
||||||
|
are preserved. Mixed selects a shared Budget/ModelCounts phase and skips ordinary
|
||||||
|
compilation. Syntax/budget failure records the original file diagnostic and stops
|
||||||
|
parsing later siblings, while final disk/physical/journal checks still execute.
|
||||||
|
Disk/encoding/path failures retain existing classifications and safeguards.
|
||||||
|
|
||||||
|
Both producer modes start once at zero. Each inspected file reserves its phase
|
||||||
|
slot before scanning. Bytes/tokens/references/numeric/syntax/model work accumulate
|
||||||
|
across units. Remaining text/value/owner reservations remain conservative after
|
||||||
|
unit destruction; in-parser temporary/UUID/key releases are unchanged. No edit
|
||||||
|
positions or complete model are constructed during discovery. Final public
|
||||||
|
semantic checking is a separate pass with the original caller policy.
|
||||||
|
|
||||||
|
A narrow shared parse_unit helper centralizes exactly the existing Parser setup,
|
||||||
|
unit(entry,true) call, state extraction and Parser drop. Complete mixed parsing
|
||||||
|
keeps all subsequent metadata/projection/traversal/accounting operations. Source
|
||||||
|
review finds no semantic blocker; helper-frame effects still require parent's
|
||||||
|
actual existing stack gates and prologue comparison, not a source-only claim.
|
||||||
|
|
||||||
|
Three internal discovery tests cover exact132-byte/31-token/3-event chain totals,
|
||||||
|
conservative text32/metadata3 after unit drops, all temporary balances, original
|
||||||
|
policy capture, cumulative6/5 entry boundary with no later-file remapping, syntax
|
||||||
|
failure terminality and positive cumulative numeric/reference work. The tight
|
||||||
|
entry profile is explicitly a fresh internal fixture, not a public policy knob.
|
||||||
|
|
||||||
|
Five external mixed_source_files tests exercise real filesystem capture and
|
||||||
|
independently authored complete model equality (nested ordinary participants,
|
||||||
|
cross-file calculations and scoped parameters), original bytes, four missing-ID
|
||||||
|
classes, malformed syntax, unparsed later sibling plus preserved pending journal,
|
||||||
|
missing/escaping/cyclic includes, file/byte/depth/edge boundaries, invalid UTF-8,
|
||||||
|
nonregular files, case collisions and hard-link aliases. CLI worker separately
|
||||||
|
owns OA/SA rich command fixtures/report/actual executable tests and documentation.
|
||||||
|
No old test body or expected outcome was changed.
|
||||||
|
|
||||||
|
Only owned Rust files were formatted directly with skip_children=true. No Cargo,
|
||||||
|
Rust compiler, rustdoc, CLI execution or remote operation ran in this contribution.
|
||||||
|
Parent owns all runtime, production ordinary-regression, stack, lint and combined
|
||||||
|
source acceptance. Existing depth/platform/accounting limitations remain intact.
|
||||||
Reference in New Issue
Block a user