//! Owned, bounded queries over exact current source buffers. //! //! Includes are inspected from these bytes only. A previously validated bundle //! is an equality witness, never a source of missing text or navigation spans. //! The caller must advance the workspace key on every indexed-input change and //! reject asynchronous results whose key, query serial or byte readback changed. use super::symbols::{self, Index}; use oa_core::Id; use oa_core::language::{self, OutlineLimits, Span, TokenClass, TokenSpan}; use oa_core::source_project::{self, IncludeLocation, ProjectLimits}; use std::collections::BTreeMap; pub use super::symbols::{Evidence, Identity, Membership, Position, SnapshotKey}; #[derive(Debug, Clone, Copy)] pub struct DraftFile<'a> { pub path: &'a str, pub text: &'a str, } /// The caller asserts that this complete source bundle passed explicit semantic /// validation. Capture independently compares its entry, membership and bytes. #[derive(Debug, Clone, Copy)] pub struct ValidatedSources<'a> { pub entry: &'a str, pub files: &'a [DraftFile<'a>], } #[derive(Debug, Clone, Copy)] pub struct Input<'a> { pub key: SnapshotKey, pub entry: &'a str, pub files: &'a [DraftFile<'a>], pub applied: bool, pub validation: Option>, } #[derive(Debug, Clone, Copy)] pub struct Limits { pub index: symbols::Limits, pub include_depth: usize, pub include_edges: usize, pub result_rows: usize, /// Upper bound on copied result strings, including quoted name input before /// JSON decoding. Row/vector overhead is separately bounded by result_rows. pub result_bytes: usize, } impl Default for Limits { fn default() -> Self { Self { index: symbols::Limits { files: 256, source_bytes: 64 * 1024 * 1024, path_bytes: 64 * 1024, tokens: 1_000_000, records: 250_000, }, include_depth: 32, include_edges: 1024, result_rows: 200, result_bytes: 256 * 1024, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum AdmissionError { Index(symbols::BuildError), Outline { file: usize, error: language::OutlineLimit, }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScopeIssueKind { MissingEntry, InvalidIncludes, MissingInclude, IncludeCycle, IncludeDepth, IncludeBudget, } /// File indices refer to Snapshot::file. Include spans retain the original /// quoted include location; diagnostics do not copy unbounded source strings. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ScopeIssue { pub kind: ScopeIssueKind, pub file: Option, pub span: Span, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Scope { pub closure_complete: bool, pub applied: bool, pub validated: bool, pub included_files: usize, pub detached_files: usize, pub unverified_files: usize, pub issues: Vec, } impl Scope { pub fn partial(&self) -> bool { !self.closure_complete || !self.applied } fn index_scope(&self) -> symbols::Scope { if !self.applied { symbols::Scope::Partial(symbols::PartialReason::UnappliedWorkspace) } else if !self.closure_complete { let missing = self.issues.iter().any(|issue| { matches!( issue.kind, ScopeIssueKind::MissingEntry | ScopeIssueKind::MissingInclude ) }); symbols::Scope::Partial(if missing { symbols::PartialReason::MissingFiles } else { symbols::PartialReason::IncludesUnverified }) } else { symbols::Scope::CompleteProject } } } #[derive(Debug)] struct File { path: String, text: String, tokens: Vec, declarations: Vec, membership: Membership, evidence: Evidence, } /// Immutable and Send: callers can retain it in an Arc for asynchronous work. /// No Workspace, accepted Project, filesystem handle or mutation is retained. #[derive(Debug)] pub struct Snapshot { key: SnapshotKey, entry: String, files: Vec, scope: Scope, limits: Limits, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct QueryStamp { pub snapshot: SnapshotKey, pub serial: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { Definition, References, } #[derive(Debug, Clone, Copy)] pub struct Query<'a> { pub stamp: QueryStamp, pub path: &'a str, pub byte_offset: usize, pub action: Action, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Disposition { NoSymbol, NoDefinition, /// All candidates require an explicit choice, including a sole draft or /// detached candidate. Truncation can never imply unique resolution. Choices, Direct, LexicalReferences, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Declaration { pub alias: String, pub kind: String, pub name: Option, pub span: Span, pub authored_id: Option, pub identity: Identity, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct DefinitionRow { pub path: String, /// The alias token to select, rather than an enclosing declaration body. pub span: Span, pub position: Position, pub end_position: Position, pub declaration: Declaration, pub membership: Membership, pub evidence: Evidence, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReferenceRow { pub path: String, pub alias: String, pub span: Span, /// Separate live @ token: intervening comments are never selected as a hit. pub marker: Span, pub position: Position, pub end_position: Position, /// Innermost tolerant declaration containing this occurrence, if available. /// This describes its lexical location, not its semantic target or owner. pub context: Option, pub membership: Membership, pub evidence: Evidence, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Results { pub stamp: QueryStamp, pub action: Action, pub origin_path: String, pub origin_offset: usize, pub alias: Option, pub scope: Scope, pub disposition: Disposition, pub definitions: Vec, pub references: Vec, pub total_definitions: usize, pub total_references: usize, pub omitted_definitions: usize, pub omitted_references: usize, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum QueryError { Index(symbols::BuildError), Query(symbols::QueryError), MissingFile, ResultEnvelopeLimit, } impl From for QueryError { fn from(error: symbols::QueryError) -> Self { Self::Query(error) } } fn charge( total: &mut usize, value: usize, limit: usize, resource: symbols::Resource, ) -> Result<(), AdmissionError> { let next = total .checked_add(value) .ok_or(AdmissionError::Index(symbols::BuildError::Limit { resource, limit, observed_at_least: usize::MAX, }))?; if next > limit { return Err(AdmissionError::Index(symbols::BuildError::Limit { resource, limit, observed_at_least: next, })); } *total = next; Ok(()) } /// No path normalization, outline extraction, accepted-source copy or index /// allocation takes place until aggregate raw input admission succeeds. fn admit_raw(input: Input<'_>, limits: Limits) -> Result<(), AdmissionError> { charge( &mut 0, input.files.len(), limits.index.files, symbols::Resource::Files, )?; let mut bytes = 0; let mut paths = 0; charge( &mut paths, input.entry.len(), limits.index.path_bytes, symbols::Resource::PathBytes, )?; for file in input.files { charge( &mut bytes, file.text.len(), limits.index.source_bytes, symbols::Resource::SourceBytes, )?; charge( &mut paths, file.path.len(), limits.index.path_bytes, symbols::Resource::PathBytes, )?; } Ok(()) } struct Extracted { tokens: Vec, declarations: Vec, } #[derive(Debug)] struct OwnedFile { path: String, text: String, } #[derive(Debug, Default)] struct ValidationProof { admitted: bool, file_count: usize, matches: Vec, } impl ValidationProof { fn capture(input: Input<'_>, limits: Limits) -> Self { let Some(validation) = input.validation else { return Self::default(); }; if validation.entry != input.entry || admit_raw( Input { files: validation.files, entry: validation.entry, validation: None, ..input }, limits, ) .is_err() { return Self::default(); } let files: BTreeMap<_, _> = validation .files .iter() .map(|file| (file.path, file.text)) .collect(); if files.len() != validation.files.len() { return Self::default(); } Self { admitted: true, file_count: files.len(), matches: input .files .iter() .map(|file| files.get(file.path).is_some_and(|text| *text == file.text)) .collect(), } } fn matches(&self, memberships: &[Membership], scope: &Scope) -> bool { self.admitted && scope.closure_complete && scope.applied && self.file_count == scope.included_files && self.matches.len() == memberships.len() && memberships .iter() .zip(&self.matches) .all(|(membership, equal)| *membership != Membership::Included || *equal) } } /// Request-time data for spawn_blocking: raw admission plus one current-text /// clone, with no lexer/parser work. The private equality proof contains only /// booleans/counts, never a second accepted SourceBundle. These private fields /// prevent changing the bytes after their equality evidence was captured. #[derive(Debug)] pub struct OwnedInput { key: SnapshotKey, entry: String, files: Vec, applied: bool, validation: ValidationProof, limits: Limits, } pub fn admit_owned(input: Input<'_>, limits: Limits) -> Result { admit_raw(input, limits)?; let validation = ValidationProof::capture(input, limits); Ok(OwnedInput { key: input.key, entry: input.entry.to_owned(), files: input .files .iter() .map(|file| OwnedFile { path: file.path.to_owned(), text: file.text.to_owned(), }) .collect(), applied: input.applied, validation, limits, }) } impl OwnedInput { /// Run on the blocking worker. Token/declaration/reference budgets are /// enforced before extraction/index allocation, then strings are moved into /// Snapshot without another copy. A limit error never returns partial data. pub fn capture(self) -> Result { let files: Vec<_> = self .files .iter() .map(|file| DraftFile { path: &file.path, text: &file.text, }) .collect(); let input = Input { key: self.key, entry: &self.entry, files: &files, applied: self.applied, validation: None, }; let prepared = prepare(input, self.limits, Some(&self.validation))?; Ok(finish( self.key, self.entry, self.files, self.limits, prepared, )) } } struct Prepared { extracted: Vec, memberships: Vec, scope: Scope, } #[cfg(test)] pub fn capture(input: Input<'_>, limits: Limits) -> Result { let prepared = prepare(input, limits, None)?; let files = input .files .iter() .map(|file| OwnedFile { path: file.path.to_owned(), text: file.text.to_owned(), }) .collect(); Ok(finish( input.key, input.entry.to_owned(), files, limits, prepared, )) } fn prepare( input: Input<'_>, limits: Limits, proof: Option<&ValidationProof>, ) -> Result { admit_raw(input, limits)?; let mut extracted = Vec::with_capacity(input.files.len()); let mut tokens_left = limits.index.tokens; let mut records_left = limits.index.records; for (file, draft) in input.files.iter().enumerate() { // Remaining aggregate budgets are passed before this file is lexed. // A stopped outline returns an error, never a complete zero-result file. let outline = language::outline( draft.text, OutlineLimits { max_source_bytes: limits.index.source_bytes, max_tokens: tokens_left, max_declarations: records_left, }, ) .map_err(|error| AdmissionError::Outline { file, error })?; tokens_left -= outline.tokens.len(); records_left -= outline.declarations.len(); extracted.push(Extracted { tokens: outline.tokens, declarations: outline .declarations .into_iter() .map(|declaration| symbols::DeclarationInput { span: declaration.declaration.span, kind_span: declaration.kind_span, alias_span: declaration.alias_span, identity: declaration .declaration .id .zip(declaration.id_literal_span) .map(|(id, literal_span)| symbols::AuthoredIdentity { id, literal_span }), }) .collect(), }); } // Reuse the reviewed live-reference counter and structural admission, rather // than maintain a second lexer/reference recognizer. No text has been copied. let inputs: Vec<_> = input .files .iter() .zip(&extracted) .map(|(file, outline)| symbols::FileInput { path: file.path, text: file.text, tokens: &outline.tokens, declarations: &outline.declarations, syntax: symbols::SyntaxCoverage::FullFile, membership: Membership::Unverified, evidence: Evidence::Draft, }) .collect(); Index::build( input.key, &inputs, symbols::Scope::Partial(symbols::PartialReason::IncludesUnverified), limits.index, ) .map_err(AdmissionError::Index)?; let (memberships, mut scope) = inspect_closure(input, limits); scope.validated = proof.map_or_else( || validation_matches(input, &memberships, &scope, limits), |proof| proof.matches(&memberships, &scope), ); Ok(Prepared { extracted, memberships, scope, }) } fn finish( key: SnapshotKey, entry: String, files: Vec, limits: Limits, prepared: Prepared, ) -> Snapshot { let Prepared { extracted, memberships, scope, } = prepared; let files = files .into_iter() .zip(extracted) .zip(memberships) .map(|((draft, outline), membership)| File { path: draft.path, text: draft.text, tokens: outline.tokens, declarations: outline.declarations, membership, evidence: if scope.validated && membership == Membership::Included { Evidence::Validated } else { Evidence::Draft }, }) .collect(); Snapshot { key, entry, files, scope, limits, } } fn validation_matches( input: Input<'_>, memberships: &[Membership], scope: &Scope, limits: Limits, ) -> bool { if !scope.closure_complete || !input.applied { return false; } ValidationProof::capture(input, limits).matches(memberships, scope) } struct Frame { file: usize, depth: usize, includes: std::vec::IntoIter, } fn inspect_closure(input: Input<'_>, limits: Limits) -> (Vec, Scope) { let paths: BTreeMap<_, _> = input .files .iter() .enumerate() .map(|(index, file)| (file.path, index)) .collect(); let mut memberships = vec![Membership::Unverified; input.files.len()]; let mut states = vec![0_u8; input.files.len()]; let mut issues = Vec::new(); let mut stack: Vec = Vec::new(); let mut edges_left = limits.include_edges; let mut pending = paths.get(input.entry).copied().map(|file| (file, 0, None)); if pending.is_none() { issues.push(ScopeIssue { kind: ScopeIssueKind::MissingEntry, file: None, span: Span::default(), }); } loop { if let Some((file, depth, origin)) = pending.take() { memberships[file] = Membership::Included; let (at_file, at_span) = origin.unwrap_or((file, Span::default())); let problem = if states[file] == 1 { Some(ScopeIssueKind::IncludeCycle) } else if depth > limits.include_depth { Some(ScopeIssueKind::IncludeDepth) } else { None }; if let Some(kind) = problem { issues.push(ScopeIssue { kind, file: Some(at_file), span: at_span, }); } else if states[file] == 0 { states[file] = 1; let draft = input.files[file]; let project_limits = ProjectLimits { max_files: limits.index.files, max_include_depth: limits.include_depth, max_include_edges: edges_left, model: language::ParseLimits { max_source_bytes: limits.index.source_bytes, max_graph_objects: limits.index.records, max_records: limits.index.records, max_step_references: limits.index.records, }, }; match source_project::inspect_includes( draft.path, draft.text, draft.path == input.entry, project_limits, ) { Ok(includes) => { edges_left -= includes.len(); stack.push(Frame { file, depth, includes: includes.into_iter(), }); } Err(diagnostics) => { let first = diagnostics.first(); let kind = if first.is_some_and(|d| d.diagnostic.code == "project_limit") { ScopeIssueKind::IncludeBudget } else { ScopeIssueKind::InvalidIncludes }; issues.push(ScopeIssue { kind, file: Some(file), span: first.map_or(Span::default(), |d| d.diagnostic.span), }); states[file] = 2; } } } } let Some(frame) = stack.last_mut() else { break; }; if let Some(include) = frame.includes.next() { if let Some(&file) = paths.get(include.target.as_str()) { pending = Some(( file, frame.depth.saturating_add(1), Some((frame.file, include.location.span)), )); } else { issues.push(ScopeIssue { kind: ScopeIssueKind::MissingInclude, file: Some(frame.file), span: include.location.span, }); } } else { states[frame.file] = 2; stack.pop(); } } let complete = issues.is_empty(); if complete { for membership in &mut memberships { if *membership == Membership::Unverified { *membership = Membership::Detached; } } } let scope = Scope { closure_complete: complete, applied: input.applied, validated: false, included_files: memberships .iter() .filter(|m| **m == Membership::Included) .count(), detached_files: memberships .iter() .filter(|m| **m == Membership::Detached) .count(), unverified_files: memberships .iter() .filter(|m| **m == Membership::Unverified) .count(), issues, }; (memberships, scope) } impl Snapshot { pub fn key(&self) -> SnapshotKey { self.key } #[cfg(test)] pub fn scope(&self) -> &Scope { &self.scope } #[cfg(test)] pub fn file(&self, index: usize) -> Option> { self.files.get(index).map(|file| DraftFile { path: &file.path, text: &file.text, }) } /// Exact loaded file-set/byte comparison, independent of input order. The /// evidence outcome is rechecked too; irrelevant old witness text is not kept. /// The key check is mandatory even if bytes happen to match after Undo. pub fn matches(&self, input: Input<'_>) -> bool { if input.key != self.key || input.entry != self.entry || input.applied != self.scope.applied || input.files.len() != self.files.len() || admit_raw(input, self.limits).is_err() { return false; } let mut memberships = Vec::with_capacity(input.files.len()); for draft in input.files { let Some(file) = self .files .iter() .find(|file| file.path == draft.path && file.text == draft.text) else { return false; }; // Duplicate paths are not the same file set, even if their bytes match. if input .files .iter() .filter(|other| other.path == draft.path) .count() != 1 { return false; } memberships.push(file.membership); } validation_matches(input, &memberships, &self.scope, self.limits) == self.scope.validated } fn index(&self) -> Result, QueryError> { let inputs: Vec<_> = self .files .iter() .map(|file| symbols::FileInput { path: &file.path, text: &file.text, tokens: &file.tokens, declarations: &file.declarations, syntax: symbols::SyntaxCoverage::FullFile, membership: file.membership, evidence: file.evidence, }) .collect(); Index::build( self.key, &inputs, self.scope.index_scope(), self.limits.index, ) .map_err(QueryError::Index) } pub fn query(&self, query: Query<'_>) -> Result { if query.stamp.snapshot != self.key { return Err(symbols::QueryError::StaleSnapshot.into()); } let file = self .files .iter() .position(|file| file.path == query.path) .ok_or(QueryError::MissingFile)?; let index = self.index()?; let hit = index.hit(file, query.byte_offset)?; let alias = match hit { Some(symbols::Hit::Definition(handle)) => Some(index.definition(handle)?.alias), Some(symbols::Hit::Reference(handle)) => Some(index.reference(handle)?.alias), None => None, }; let mut budget = RowBudget { rows: self.limits.result_rows, bytes: self.limits.result_bytes, }; if !budget.strings(query.path.len().saturating_add(alias.map_or(0, str::len))) { return Err(QueryError::ResultEnvelopeLimit); } let mut result = Results { stamp: query.stamp, action: query.action, origin_path: query.path.to_owned(), origin_offset: query.byte_offset, alias: alias.map(str::to_owned), scope: self.scope.clone(), disposition: Disposition::NoSymbol, definitions: Vec::new(), references: Vec::new(), total_definitions: 0, total_references: 0, omitted_definitions: 0, omitted_references: 0, }; let Some(alias) = alias else { return Ok(result); }; result.total_definitions = index .definitions_for_alias(alias, symbols::Domain::Loaded) .count(); for handle in index.definitions_for_alias(alias, symbols::Domain::Loaded) { let definition = index.definition(handle)?; let source = &self.files[definition.declaration.file]; let display = self.display(&index, handle)?; if !budget.row(source.path.len().saturating_add(display.bytes())) { break; } result.definitions.push(DefinitionRow { path: source.path.clone(), span: definition.alias_location.span, position: Position { line: 0, byte_column: 0, }, end_position: Position { line: 0, byte_column: 0, }, declaration: display.owned(), membership: source.membership, evidence: source.evidence, }); } result.omitted_definitions = result.total_definitions - result.definitions.len(); result.disposition = if result.total_definitions == 0 { Disposition::NoDefinition } else { Disposition::Choices }; if query.action == Action::Definition && result.total_definitions == 1 && result.definitions.len() == 1 && self.scope.validated && self.files[file].membership == Membership::Included { let only = &result.definitions[0]; if only.membership == Membership::Included && only.evidence == Evidence::Validated && !matches!(only.declaration.identity, Identity::Collision { .. }) { result.disposition = Disposition::Direct; } } if query.action == Action::References { result.disposition = Disposition::LexicalReferences; result.total_references = index .references_for_alias(alias, symbols::Domain::Loaded) .count(); for handle in index.references_for_alias(alias, symbols::Domain::Loaded) { let reference = index.reference(handle)?; let source = &self.files[reference.alias_location.file]; let context = self.context(&index, reference.alias_location)?; let bytes = source .path .len() .saturating_add(alias.len()) .saturating_add(context.as_ref().map_or(0, Display::bytes)); if !budget.row(bytes) { break; } result.references.push(ReferenceRow { path: source.path.clone(), alias: alias.to_owned(), span: reference.alias_location.span, marker: reference.at.span, position: Position { line: 0, byte_column: 0, }, end_position: Position { line: 0, byte_column: 0, }, context: context.map(Display::owned), membership: source.membership, evidence: source.evidence, }); } result.omitted_references = result.total_references - result.references.len(); } self.locate_rows(&mut result); Ok(result) } fn locate_rows(&self, result: &mut Results) { // Index admission already proved every row span and UTF-8 boundary. // Scan each selected source prefix once instead of rescanning a large // buffer for every occurrence. Scratch has at most two entries per row. for file in &self.files { let mut offsets = Vec::new(); for (row, definition) in result.definitions.iter().enumerate() { if definition.path == file.path { offsets.push((definition.span.start, true, row, false)); offsets.push((definition.span.end, true, row, true)); } } for (row, reference) in result.references.iter().enumerate() { if reference.path == file.path { offsets.push((reference.span.start, false, row, false)); offsets.push((reference.span.end, false, row, true)); } } offsets.sort_unstable_by_key(|&(offset, _, _, _)| offset); let (mut scanned, mut line, mut line_start) = (0, 0, 0); for (offset, definition, row, end) in offsets { for (relative, byte) in file.text.as_bytes()[scanned..offset].iter().enumerate() { if *byte == b'\n' { line += 1; line_start = scanned + relative + 1; } } scanned = offset; let position = Position { line, byte_column: offset - line_start, }; match (definition, end) { (true, false) => result.definitions[row].position = position, (true, true) => result.definitions[row].end_position = position, (false, false) => result.references[row].position = position, (false, true) => result.references[row].end_position = position, } } } } fn display<'a>( &'a self, index: &Index<'a>, handle: symbols::DefinitionHandle, ) -> Result, QueryError> { let definition = index.definition(handle)?; let file = &self.files[definition.declaration.file]; let alias_end = definition.alias_location.span.end; let next = file .tokens .partition_point(|token| token.span.start < alias_end); // The coordinated outline has already proved the declaration and alias // roles. This optional decoration only decodes its immediate name token. let name = (definition.kind != "node") .then(|| { file.tokens[next..].iter().find(|token| { !matches!(token.class, TokenClass::Whitespace | TokenClass::Comment) }) }) .flatten() .filter(|token| { token.class == TokenClass::String && token.span.end <= definition.declaration.span.end }) .map(|token| &file.text[token.span.start..token.span.end]); Ok(Display { alias: definition.alias, kind: definition.kind, quoted_name: name, span: definition.declaration.span, authored_id: definition.authored_id, identity: index.identity(handle)?, }) } fn context<'a>( &'a self, index: &Index<'a>, location: symbols::Location, ) -> Result>, QueryError> { let file = &self.files[location.file]; let inner = file .declarations .iter() .filter(|declaration| { declaration.span.start <= location.span.start && location.span.end <= declaration.span.end }) .min_by_key(|declaration| declaration.span.end - declaration.span.start); let Some(inner) = inner else { return Ok(None); }; let Some(symbols::Hit::Definition(handle)) = index.hit(location.file, inner.alias_span.start)? else { return Ok(None); }; self.display(index, handle).map(Some) } } struct Display<'a> { alias: &'a str, kind: &'a str, quoted_name: Option<&'a str>, span: Span, authored_id: Option, identity: Identity, } impl Display<'_> { fn bytes(&self) -> usize { self.alias .len() .saturating_add(self.kind.len()) .saturating_add(self.quoted_name.map_or(0, str::len)) } fn owned(self) -> Declaration { Declaration { alias: self.alias.to_owned(), kind: self.kind.to_owned(), name: self .quoted_name .and_then(|name| serde_json::from_str(name).ok()), span: self.span, authored_id: self.authored_id, identity: self.identity, } } } struct RowBudget { rows: usize, bytes: usize, } impl RowBudget { fn strings(&mut self, bytes: usize) -> bool { if bytes > self.bytes { return false; } self.bytes -= bytes; true } fn row(&mut self, bytes: usize) -> bool { if self.rows == 0 || !self.strings(bytes) { return false; } self.rows -= 1; true } } #[cfg(test)] #[path = "query_tests.rs"] mod tests;