feat(source): add bounded read-only mixed filesystem discovery

This commit is contained in:
2026-09-24 21:57:16 -07:00
parent f83f3bf45b
commit 3fd87bd8be
8 changed files with 739 additions and 31 deletions
+1
View File
@@ -5,6 +5,7 @@ mod semantic_source;
mod source3_bridge;
// Mixed source8 is exposed only through the separate borrowed read-only checker.
mod analysis_source;
pub(crate) use analysis_source::MixedDiscovery;
pub use analysis_source::{CheckedMixed, check_mixed_bundle};
#[path = "migration_v2/source.rs"]
mod v2_source;
@@ -118,3 +118,5 @@ pub fn parse(source: &str, budget: &mut Budget) -> Result<Binding, Error> {
#[cfg(test)]
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 tokens = lex::tokens(source, &mut budget).map_err(failure)?;
let metadata_before = budget.producer_entries();
let mut parser = Parser {
let (ordinary, state) = parse_unit(
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(),
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);
&tokens,
&mut budget,
&mut model,
limits.model,
path == &bundle.entry,
)
.map_err(|e| located(path, source, e))?;
// Projected ordinary AST nodes/fields are bounded before constructing a
// second position representation. Count references as slots, not syntax.
let projection_slots = if entry_producers {
@@ -564,3 +549,40 @@ fn ordinary_position_slots(unit: &SourceUnit) -> usize {
mod tests;
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);
}
+82 -8
View File
@@ -379,6 +379,59 @@ pub fn expect_new_file(
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.
/// Existing drafts (including detached ones) and their disk baselines survive.
pub fn discover_missing(
@@ -390,6 +443,14 @@ pub fn discover_missing(
result.bundle.files.extend(overlays.clone());
result.reachable.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)?;
let mut stack = vec![(result.bundle.entry.clone(), 0usize, None::<SourceLocation>)];
let mut edges = 0usize;
@@ -492,12 +553,20 @@ pub fn discover_missing(
}
}
}
match inspect_includes(
&path,
&result.bundle.files[&path],
path == result.bundle.entry,
limits,
) {
let includes = match &mut mode {
DiscoveryMode::Ordinary => inspect_includes(
&path,
&result.bundle.files[&path],
path == result.bundle.entry,
limits,
),
DiscoveryMode::Mixed(discovery) => discovery.inspect(
&path,
&result.bundle.files[&path],
path == result.bundle.entry,
),
};
match includes {
Ok(includes) => {
edges = edges.saturating_add(includes.len());
if edges > limits.max_include_edges {
@@ -517,7 +586,12 @@ pub fn discover_missing(
.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)?;
@@ -525,7 +599,7 @@ pub fn discover_missing(
&result.root,
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) {
Ok(compiled) => result.diagnostics = compiled.diagnostics,
Err(diagnostics) => result.diagnostics = diagnostics,