feat(source): add bounded read-only mixed filesystem discovery
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user