Files
ARCADIA-Studio/crates/oa-desktop/src/source_navigation/symbols.rs
T

2037 lines
67 KiB
Rust

//! Current-text symbol queries, with no model or editor mutation.
//!
//! The caller supplies one immutable snapshot of current text, full lexer tokens,
//! and syntax-only declaration role spans. This module does not lex, parse
//! Arcadia declarations, lint, compile, allocate model IDs, discover includes,
//! or read an accepted Project.
//! Its matches are authored lexical uses, not typed semantic dependency edges.
use oa_core::Id;
use oa_core::language::{Span, TokenClass, TokenSpan};
use std::collections::{BTreeMap, BTreeSet};
use std::ops::Range;
/// Fresh editor/workspace session identity plus a revision changed for EVERY
/// indexed input change, including membership, outline and validation evidence.
/// Reusing this key for different input violates the caller contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SnapshotKey {
pub workspace: Id,
pub revision: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Membership {
Included,
Detached,
Unverified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Evidence {
/// The caller verified these exact bytes in the current source scope.
Validated,
/// Current editor bytes; no successful semantic validation is claimed.
Draft,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyntaxCoverage {
/// The tolerant walk considered the entire file, even if its text is invalid.
FullFile,
/// A stopped/budget-limited outline cannot masquerade as a complete result.
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Defensive input contract exercised by index tests."
)
)]
Incomplete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PartialReason {
IncludesUnverified,
MissingFiles,
UnappliedWorkspace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
CompleteProject,
Partial(PartialReason),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Domain {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Tested included-only queries; editor presents all loaded candidates."
)
)]
Included,
Loaded,
}
impl Domain {
fn contains(self, membership: Membership) -> bool {
self == Self::Loaded || membership == Membership::Included
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthoredIdentity {
pub id: Id,
/// Whole quoted JSON string token in the declaration header, not a UUID
/// mentioned in a body, history record, comment or display name.
pub literal_span: Span,
}
/// These header roles must come from the coordinated core outline seam. The
/// existing public DeclarationSpan alone lacks alias/kind/ID literal spans.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeclarationInput {
pub span: Span,
pub kind_span: Span,
pub alias_span: Span,
pub identity: Option<AuthoredIdentity>,
}
#[derive(Debug, Clone, Copy)]
pub struct FileInput<'a> {
pub path: &'a str,
pub text: &'a str,
/// Exact lexer output for text, in order and covering the entire file.
pub tokens: &'a [TokenSpan],
/// Tolerant outline in alias-source order. Whole spans may nest.
pub declarations: &'a [DeclarationInput],
pub syntax: SyntaxCoverage,
pub membership: Membership,
pub evidence: Evidence,
}
/// Explicit experiment limits; production must select its own shared budgets.
/// Caller-owned text/tokens/outline must already have bounded extraction.
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub files: usize,
pub source_bytes: usize,
pub path_bytes: usize,
pub tokens: usize,
pub records: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resource {
Files,
SourceBytes,
PathBytes,
Tokens,
Records,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputProblem {
NonCanonicalPath,
DuplicateOrCaseCollidingPath,
InconsistentCompleteScope,
IncompleteSyntax,
TokenCoverage,
TokenBounds,
DeclarationBounds,
DeclarationOrder,
HeaderRoleToken,
InvalidAlias,
IdentityLiteral,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildError {
/// No partial Index is returned. This is not a zero-result query.
Limit {
resource: Resource,
limit: usize,
observed_at_least: usize,
},
Input {
file: usize,
record: Option<usize>,
problem: InputProblem,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Location {
pub file: usize,
pub span: Span,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DefinitionHandle {
snapshot: SnapshotKey,
index: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReferenceHandle {
snapshot: SnapshotKey,
index: usize,
}
/// Carries only an authored identity, never an identity generated by compilation.
/// It may be resolved across revisions of the same workspace. Resolution still
/// checks global loaded-file uniqueness every time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthoredAnchor {
workspace: Id,
id: Id,
}
impl AuthoredAnchor {
pub fn id(self) -> Id {
self.id
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Identity {
Missing,
Unique(AuthoredAnchor),
Collision { count: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Tested unique-reference and authored-anchor projections."
)
)]
pub enum Lookup {
Missing,
Unique(DefinitionHandle),
Ambiguous { count: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Hit {
Definition(DefinitionHandle),
Reference(ReferenceHandle),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryError {
StaleSnapshot,
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Authored-anchor projection rejects cross-workspace identity."
)
)]
WrongWorkspace,
InvalidHandle,
InvalidFile,
InvalidBytePosition,
#[cfg_attr(
not(test),
expect(dead_code, reason = "Tested included-only reference projection.")
)]
OutsideDomain,
#[cfg_attr(
not(test),
expect(dead_code, reason = "Tested unique-declaration reference projection.")
)]
AmbiguousAlias {
count: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Tested primitive scope; editor exposes adapter include inspection."
)
)]
pub struct QueryScope {
pub declared: Scope,
pub domain: Domain,
/// True only for the complete included project, not loaded detached files.
pub project_complete: bool,
pub all_selected_files_validated: bool,
pub selected_files: usize,
pub detached_files: usize,
pub unverified_files: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position {
pub line: usize,
/// Zero-based UTF-8 byte column. The editor adapter must convert explicitly
/// if it needs character/UTF-16 columns; CRLF bytes are never normalized.
pub byte_column: usize,
}
#[derive(Debug)]
pub struct Definition<'a> {
pub alias: &'a str,
pub kind: &'a str,
pub declaration: Location,
pub alias_location: Location,
pub authored_id: Option<Id>,
}
#[derive(Debug)]
pub struct Reference<'a> {
pub alias: &'a str,
pub at: Location,
pub alias_location: Location,
}
#[derive(Debug)]
pub struct SourceFile<'a> {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Retained source projection for independently tested index callers."
)
)]
pub path: &'a str,
pub text: &'a str,
pub membership: Membership,
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Included-only scope projection retains validation evidence."
)
)]
pub evidence: Evidence,
definitions: Range<usize>,
references: Range<usize>,
}
#[derive(Debug)]
pub struct Index<'a> {
snapshot: SnapshotKey,
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Tested domain scope projection; adapter owns the UI scope."
)
)]
scope: Scope,
files: Vec<SourceFile<'a>>,
definitions: Vec<Definition<'a>>,
references: Vec<Reference<'a>>,
aliases: BTreeMap<&'a str, Vec<usize>>,
uses: BTreeMap<&'a str, Vec<usize>>,
identities: BTreeMap<Id, Vec<usize>>,
}
#[derive(Debug, Clone, Copy)]
struct Counts {
definitions: usize,
references: usize,
}
fn charge(
total: &mut usize,
value: usize,
limit: usize,
resource: Resource,
) -> Result<(), BuildError> {
let next = total.checked_add(value).ok_or(BuildError::Limit {
resource,
limit,
observed_at_least: usize::MAX,
})?;
if next > limit {
return Err(BuildError::Limit {
resource,
limit,
observed_at_least: next,
});
}
*total = next;
Ok(())
}
fn input_error(file: usize, record: Option<usize>, problem: InputProblem) -> BuildError {
BuildError::Input {
file,
record,
problem,
}
}
fn valid_span(text: &str, span: Span) -> bool {
span.start < span.end
&& span.end <= text.len()
&& text.is_char_boundary(span.start)
&& text.is_char_boundary(span.end)
}
fn contains(outer: Span, inner: Span) -> bool {
outer.start <= inner.start && inner.end <= outer.end
}
fn token_at(tokens: &[TokenSpan], span: Span) -> Option<TokenSpan> {
let index = tokens
.binary_search_by_key(&span.start, |token| token.span.start)
.ok()?;
let token = tokens[index];
(token.span == span).then_some(token)
}
fn word(class: TokenClass) -> bool {
matches!(class, TokenClass::Identifier | TokenClass::Keyword)
}
/// Only a live punctuation @ followed by a legal alias outside trivia qualifies.
/// Trivia can separate @ and alias, but its bytes never become a clickable hit.
fn reference_spans<'a>(file: FileInput<'a>) -> impl Iterator<Item = (Span, Span)> + 'a {
file.tokens
.iter()
.enumerate()
.filter_map(move |(index, token)| {
if token.class != TokenClass::Punctuation
|| &file.text[token.span.start..token.span.end] != "@"
{
return None;
}
let next = file.tokens[index + 1..]
.iter()
.find(|next| !matches!(next.class, TokenClass::Whitespace | TokenClass::Comment))?;
let alias = &file.text[next.span.start..next.span.end];
(word(next.class) && oa_core::valid_alias(alias)).then_some((token.span, next.span))
})
}
/// Runs all aggregate limits and structural checks before allocating Index maps
/// or vectors. Path normalization/quoted-ID decoding may allocate bounded scratch
/// strings. Scratch storage is proportional to admitted input bytes and file
/// count, including Unicode expansion and collection overhead.
fn preflight(files: &[FileInput<'_>], scope: Scope, limits: Limits) -> Result<Counts, BuildError> {
let mut file_count = 0;
charge(&mut file_count, files.len(), limits.files, Resource::Files)?;
let mut bytes = 0;
let mut path_bytes = 0;
let mut tokens = 0;
let mut records = 0;
for file in files {
charge(
&mut bytes,
file.text.len(),
limits.source_bytes,
Resource::SourceBytes,
)?;
charge(
&mut path_bytes,
file.path.len(),
limits.path_bytes,
Resource::PathBytes,
)?;
charge(
&mut tokens,
file.tokens.len(),
limits.tokens,
Resource::Tokens,
)?;
charge(
&mut records,
file.declarations.len(),
limits.records,
Resource::Records,
)?;
}
let definitions = records;
if scope == Scope::CompleteProject
&& (!files
.iter()
.any(|file| file.membership == Membership::Included)
|| files
.iter()
.any(|file| file.membership == Membership::Unverified))
{
return Err(input_error(
0,
None,
InputProblem::InconsistentCompleteScope,
));
}
// Scratch storage is allocated only after aggregate file/path admission.
// Each admitted path is folded once, retaining core's Unicode policy.
let mut folded_paths = BTreeSet::new();
for (file_index, file) in files.iter().copied().enumerate() {
if file.syntax != SyntaxCoverage::FullFile {
return Err(input_error(
file_index,
None,
InputProblem::IncompleteSyntax,
));
}
if oa_core::source_project::resolve_include("", file.path).as_deref() != Ok(file.path) {
return Err(input_error(
file_index,
None,
InputProblem::NonCanonicalPath,
));
}
if !folded_paths.insert(file.path.to_lowercase()) {
return Err(input_error(
file_index,
None,
InputProblem::DuplicateOrCaseCollidingPath,
));
}
let mut end = 0;
for (token_index, token) in file.tokens.iter().enumerate() {
if !valid_span(file.text, token.span) {
return Err(input_error(
file_index,
Some(token_index),
InputProblem::TokenBounds,
));
}
if token.span.start != end {
return Err(input_error(
file_index,
Some(token_index),
InputProblem::TokenCoverage,
));
}
end = token.span.end;
}
if end != file.text.len() {
return Err(input_error(file_index, None, InputProblem::TokenCoverage));
}
let mut previous_alias_end = 0;
for (declaration_index, declaration) in file.declarations.iter().enumerate() {
let problem = |problem| input_error(file_index, Some(declaration_index), problem);
if !valid_span(file.text, declaration.span)
|| !valid_span(file.text, declaration.kind_span)
|| !valid_span(file.text, declaration.alias_span)
|| declaration.kind_span.start != declaration.span.start
|| !contains(declaration.span, declaration.kind_span)
|| !contains(declaration.span, declaration.alias_span)
|| declaration.alias_span.start < declaration.kind_span.end
{
return Err(problem(InputProblem::DeclarationBounds));
}
if declaration.alias_span.start < previous_alias_end {
return Err(problem(InputProblem::DeclarationOrder));
}
previous_alias_end = declaration.alias_span.end;
if !token_at(file.tokens, declaration.kind_span).is_some_and(|token| word(token.class))
|| !token_at(file.tokens, declaration.alias_span)
.is_some_and(|token| word(token.class))
{
return Err(problem(InputProblem::HeaderRoleToken));
}
let alias = &file.text[declaration.alias_span.start..declaration.alias_span.end];
if !oa_core::valid_alias(alias) {
return Err(problem(InputProblem::InvalidAlias));
}
if let Some(identity) = declaration.identity {
if !valid_span(file.text, identity.literal_span)
|| !contains(declaration.span, identity.literal_span)
|| identity.literal_span.start < declaration.alias_span.end
|| token_at(file.tokens, identity.literal_span).map(|token| token.class)
!= Some(TokenClass::String)
{
return Err(problem(InputProblem::IdentityLiteral));
}
let literal = &file.text[identity.literal_span.start..identity.literal_span.end];
let decoded = serde_json::from_str::<String>(literal)
.ok()
.filter(|value| oa_core::language::has_uuid_header_shape(value))
.and_then(|value| Id::parse_str(&value).ok());
if decoded != Some(identity.id) {
return Err(problem(InputProblem::IdentityLiteral));
}
}
}
// Token bounds and coverage are proven before reference_spans slices.
for _ in reference_spans(file) {
charge(&mut records, 1, limits.records, Resource::Records)?;
}
}
Ok(Counts {
definitions,
references: records - definitions,
})
}
impl<'a> Index<'a> {
pub fn build(
snapshot: SnapshotKey,
files: &[FileInput<'a>],
scope: Scope,
limits: Limits,
) -> Result<Self, BuildError> {
let counts = preflight(files, scope, limits)?;
let mut index = Self {
snapshot,
scope,
files: Vec::with_capacity(files.len()),
definitions: Vec::with_capacity(counts.definitions),
references: Vec::with_capacity(counts.references),
aliases: BTreeMap::new(),
uses: BTreeMap::new(),
identities: BTreeMap::new(),
};
for (file_index, file) in files.iter().copied().enumerate() {
let definition_start = index.definitions.len();
let reference_start = index.references.len();
for declaration in file.declarations {
let alias = &file.text[declaration.alias_span.start..declaration.alias_span.end];
let number = index.definitions.len();
index.aliases.entry(alias).or_default().push(number);
if let Some(identity) = declaration.identity {
index
.identities
.entry(identity.id)
.or_default()
.push(number);
}
index.definitions.push(Definition {
alias,
kind: &file.text[declaration.kind_span.start..declaration.kind_span.end],
declaration: Location {
file: file_index,
span: declaration.span,
},
alias_location: Location {
file: file_index,
span: declaration.alias_span,
},
authored_id: declaration.identity.map(|identity| identity.id),
});
}
for (at, alias_span) in reference_spans(file) {
let alias = &file.text[alias_span.start..alias_span.end];
index
.uses
.entry(alias)
.or_default()
.push(index.references.len());
index.references.push(Reference {
alias,
at: Location {
file: file_index,
span: at,
},
alias_location: Location {
file: file_index,
span: alias_span,
},
});
}
index.files.push(SourceFile {
path: file.path,
text: file.text,
membership: file.membership,
evidence: file.evidence,
definitions: definition_start..index.definitions.len(),
references: reference_start..index.references.len(),
});
}
Ok(index)
}
pub fn source(&self, file: usize) -> Result<&SourceFile<'a>, QueryError> {
self.files.get(file).ok_or(QueryError::InvalidFile)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Tested included/loaded scope projection; editor uses adapter scope."
)
)]
pub fn scope(&self, domain: Domain) -> QueryScope {
let selected_files = self
.files
.iter()
.filter(|file| domain.contains(file.membership))
.count();
QueryScope {
declared: self.scope,
domain,
project_complete: self.scope == Scope::CompleteProject && domain == Domain::Included,
all_selected_files_validated: selected_files != 0
&& self
.files
.iter()
.filter(|file| domain.contains(file.membership))
.all(|file| file.evidence == Evidence::Validated),
selected_files,
detached_files: self
.files
.iter()
.filter(|file| file.membership == Membership::Detached)
.count(),
unverified_files: self
.files
.iter()
.filter(|file| file.membership == Membership::Unverified)
.count(),
}
}
fn definition_handle(&self, index: usize) -> DefinitionHandle {
DefinitionHandle {
snapshot: self.snapshot,
index,
}
}
fn reference_handle(&self, index: usize) -> ReferenceHandle {
ReferenceHandle {
snapshot: self.snapshot,
index,
}
}
pub fn definition(&self, handle: DefinitionHandle) -> Result<&Definition<'a>, QueryError> {
if handle.snapshot != self.snapshot {
return Err(QueryError::StaleSnapshot);
}
self.definitions
.get(handle.index)
.ok_or(QueryError::InvalidHandle)
}
pub fn reference(&self, handle: ReferenceHandle) -> Result<&Reference<'a>, QueryError> {
if handle.snapshot != self.snapshot {
return Err(QueryError::StaleSnapshot);
}
self.references
.get(handle.index)
.ok_or(QueryError::InvalidHandle)
}
pub fn definitions_for_alias<'i>(&'i self, alias: &str, domain: Domain) -> Definitions<'i, 'a> {
Definitions {
index: self,
indices: self
.aliases
.get(alias)
.map_or(&[][..], Vec::as_slice)
.iter(),
domain,
}
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Tested authored-identity lookup; editor uses aliases and collision checks."
)
)]
pub fn definitions_for_id(&self, id: Id, domain: Domain) -> Definitions<'_, 'a> {
Definitions {
index: self,
indices: self
.identities
.get(&id)
.map_or(&[][..], Vec::as_slice)
.iter(),
domain,
}
}
pub fn references_for_alias<'i>(&'i self, alias: &str, domain: Domain) -> References<'i, 'a> {
References {
index: self,
indices: self.uses.get(alias).map_or(&[][..], Vec::as_slice).iter(),
domain,
}
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Tested unique-reference projection; UI shows bounded candidate rows."
)
)]
pub fn resolve_reference(
&self,
handle: ReferenceHandle,
domain: Domain,
) -> Result<Lookup, QueryError> {
let reference = self.reference(handle)?;
if !domain.contains(self.files[reference.at.file].membership) {
return Err(QueryError::OutsideDomain);
}
let mut definitions = self.definitions_for_alias(reference.alias, domain);
let Some(first) = definitions.next() else {
return Ok(Lookup::Missing);
};
let count = 1 + definitions.count();
Ok(if count == 1 {
Lookup::Unique(first)
} else {
Lookup::Ambiguous { count }
})
}
pub fn identity(&self, handle: DefinitionHandle) -> Result<Identity, QueryError> {
let Some(id) = self.definition(handle)?.authored_id else {
return Ok(Identity::Missing);
};
let count = self.identities[&id].len();
Ok(if count == 1 {
Identity::Unique(AuthoredAnchor {
workspace: self.snapshot.workspace,
id,
})
} else {
Identity::Collision { count }
})
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Tested identity-safe anchor API; editor Back uses buffer generations."
)
)]
pub fn resolve_anchor(
&self,
anchor: AuthoredAnchor,
domain: Domain,
) -> Result<Lookup, QueryError> {
if anchor.workspace != self.snapshot.workspace {
return Err(QueryError::WrongWorkspace);
}
let Some(candidates) = self.identities.get(&anchor.id) else {
return Ok(Lookup::Missing);
};
// A detached duplicate must not silently confer identity uniqueness.
if candidates.len() != 1 {
return Ok(Lookup::Ambiguous {
count: candidates.len(),
});
}
let candidate = candidates[0];
Ok(
if domain.contains(self.files[self.definitions[candidate].declaration.file].membership)
{
Lookup::Unique(self.definition_handle(candidate))
} else {
Lookup::Missing
},
)
}
/// Exact authored alias-use projection for a unique current declaration in
/// the requested domain. Duplicate aliases require candidate inspection;
/// this API never attributes all matching uses to the first declaration.
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Tested unique-target projection; UI labels lexical uses explicitly."
)
)]
pub fn references_to(
&self,
handle: DefinitionHandle,
domain: Domain,
) -> Result<References<'_, 'a>, QueryError> {
let definition = self.definition(handle)?;
if !domain.contains(self.files[definition.declaration.file].membership) {
return Err(QueryError::OutsideDomain);
}
let count = self.definitions_for_alias(definition.alias, domain).count();
if count != 1 {
return Err(QueryError::AmbiguousAlias { count });
}
Ok(self.references_for_alias(definition.alias, domain))
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Tested exact single-location projection; adapter batches projections."
)
)]
pub fn position(&self, file: usize, offset: usize) -> Result<Position, QueryError> {
let source = self.source(file)?;
if offset > source.text.len() || !source.text.is_char_boundary(offset) {
return Err(QueryError::InvalidBytePosition);
}
let prefix = &source.text[..offset];
Ok(Position {
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
byte_column: prefix
.rsplit_once('\n')
.map_or(prefix.len(), |(_, tail)| tail.len()),
})
}
pub fn hit(&self, file: usize, offset: usize) -> Result<Option<Hit>, QueryError> {
let source = self.source(file)?;
if offset > source.text.len() || !source.text.is_char_boundary(offset) {
return Err(QueryError::InvalidBytePosition);
}
let definitions = &self.definitions[source.definitions.clone()];
let before = definitions
.partition_point(|definition| definition.alias_location.span.start <= offset);
if before != 0 && offset < definitions[before - 1].alias_location.span.end {
return Ok(Some(Hit::Definition(
self.definition_handle(source.definitions.start + before - 1),
)));
}
let references = &self.references[source.references.clone()];
let before = references.partition_point(|reference| reference.at.span.start <= offset);
if before != 0 {
let reference = &references[before - 1];
if offset < reference.at.span.end
|| (reference.alias_location.span.start <= offset
&& offset < reference.alias_location.span.end)
{
return Ok(Some(Hit::Reference(
self.reference_handle(source.references.start + before - 1),
)));
}
}
Ok(None)
}
}
pub struct Definitions<'i, 'a> {
index: &'i Index<'a>,
indices: std::slice::Iter<'i, usize>,
domain: Domain,
}
impl Iterator for Definitions<'_, '_> {
type Item = DefinitionHandle;
fn next(&mut self) -> Option<Self::Item> {
for &number in self.indices.by_ref() {
let definition = &self.index.definitions[number];
if self
.domain
.contains(self.index.files[definition.declaration.file].membership)
{
return Some(self.index.definition_handle(number));
}
}
None
}
}
pub struct References<'i, 'a> {
index: &'i Index<'a>,
indices: std::slice::Iter<'i, usize>,
domain: Domain,
}
impl References<'_, '_> {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Tested primitive scope; editor displays adapter results scope."
)
)]
pub fn scope(&self) -> QueryScope {
self.index.scope(self.domain)
}
}
impl Iterator for References<'_, '_> {
type Item = ReferenceHandle;
fn next(&mut self) -> Option<Self::Item> {
for &number in self.indices.by_ref() {
let reference = &self.index.references[number];
if self
.domain
.contains(self.index.files[reference.at.file].membership)
{
return Some(self.index.reference_handle(number));
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
const A: Id = Id::from_u128(1);
const B: Id = Id::from_u128(2);
const C: Id = Id::from_u128(3);
struct Fixture {
path: String,
text: String,
tokens: Vec<TokenSpan>,
declarations: Vec<DeclarationInput>,
}
impl Fixture {
/// Test-only literal role proof. Production never searches declaration
/// headers: its caller supplies the coordinated syntax outline roles.
fn new(path: &str, text: String, roles: &[(&str, &str, Option<Id>)]) -> Self {
let tokens = oa_core::language::tokenize(&text);
let declarations = roles
.iter()
.map(|&(kind, alias, id)| {
let header = format!("{kind} {alias} ");
let start = text.find(&header).expect("authored fixture header");
let end = text[start..]
.find('}')
.map_or(text.len(), |end| start + end + 1);
let alias_start = start + kind.len() + 1;
DeclarationInput {
span: Span { start, end },
kind_span: Span {
start,
end: start + kind.len(),
},
alias_span: Span {
start: alias_start,
end: alias_start + alias.len(),
},
identity: id.map(|id| {
let literal = format!("\"{id}\"");
let literal_start = start
+ text[start..end]
.find(&literal)
.expect("authored fixture UUID");
AuthoredIdentity {
id,
literal_span: Span {
start: literal_start,
end: literal_start + literal.len(),
},
}
}),
}
})
.collect();
Self {
path: path.into(),
text,
tokens,
declarations,
}
}
fn input(&self, membership: Membership, evidence: Evidence) -> FileInput<'_> {
FileInput {
path: &self.path,
text: &self.text,
tokens: &self.tokens,
declarations: &self.declarations,
syntax: SyntaxCoverage::FullFile,
membership,
evidence,
}
}
fn included(&self) -> FileInput<'_> {
self.input(Membership::Included, Evidence::Draft)
}
}
fn snapshot(revision: u64) -> SnapshotKey {
SnapshotKey {
workspace: Id::from_u128(1000),
revision,
}
}
fn limits() -> Limits {
Limits {
files: 16,
source_bytes: 1024 * 1024,
path_bytes: 8192,
tokens: 200_000,
records: 100_000,
}
}
fn index<'a>(files: &[FileInput<'a>]) -> Index<'a> {
Index::build(snapshot(1), files, Scope::CompleteProject, limits()).unwrap()
}
fn definition(index: &Index<'_>, alias: &str) -> DefinitionHandle {
let mut found = index.definitions_for_alias(alias, Domain::Loaded);
let result = found.next().expect("fixture definition");
assert!(found.next().is_none(), "fixture expects unique alias");
result
}
fn anchor(index: &Index<'_>, handle: DefinitionHandle) -> AuthoredAnchor {
match index.identity(handle).unwrap() {
Identity::Unique(anchor) => anchor,
identity => panic!("fixture expects unique authored identity: {identity:?}"),
}
}
#[test]
fn repeated_live_uses_are_distinct_and_strings_comments_history_are_excluded() {
let fixture = Fixture::new(
"project.arcadia",
format!(
"oa_actor tank \"Same display\" id \"{A}\" {{}}\n\
oa_actor tank_extra \"Same display\" id \"{B}\" {{}}\n\
oa_interaction flow \"Flow\" id \"{C}\" {{ source @tank; target @tank; other @tank_extra; \
description \"@tank @tank_extra\"; historical uuid \"{A}\"; }}\n\
// @tank @tank_extra and UUID {A}\n"
),
&[
("oa_actor", "tank", Some(A)),
("oa_actor", "tank_extra", Some(B)),
("oa_interaction", "flow", Some(C)),
],
);
let original = (
fixture.text.clone(),
fixture.tokens.clone(),
fixture.declarations.clone(),
);
let index = index(&[fixture.included()]);
let tank = definition(&index, "tank");
let uses: Vec<_> = index
.references_to(tank, Domain::Included)
.unwrap()
.collect();
assert_eq!(uses.len(), 2);
assert_ne!(uses[0], uses[1]);
assert_eq!(
index
.references_for_alias("tank_extra", Domain::Loaded)
.count(),
1
);
for usage in uses {
let record = index.reference(usage).unwrap();
assert_eq!(&fixture.text[record.at.span.start..record.at.span.end], "@");
assert_eq!(
&fixture.text[record.alias_location.span.start..record.alias_location.span.end],
"tank"
);
assert_eq!(
index.resolve_reference(usage, Domain::Included),
Ok(Lookup::Unique(tank))
);
assert_eq!(
index.hit(0, record.alias_location.span.start),
Ok(Some(Hit::Reference(usage)))
);
}
for ignored in [
fixture.text.find("Same display").unwrap(),
fixture.text.find("// @tank").unwrap() + 3,
fixture.text.find("\"@tank @tank_extra\"").unwrap() + 1,
fixture.text.find("historical uuid").unwrap() + "historical uuid \"".len(),
] {
assert_eq!(index.hit(0, ignored), Ok(None));
}
assert_eq!(index.hit(0, fixture.text.len()), Ok(None));
assert_eq!(
original,
(
fixture.text.clone(),
fixture.tokens.clone(),
fixture.declarations.clone()
)
);
}
#[test]
fn comment_separated_reference_keeps_crlf_unicode_and_disjoint_hit_spans() {
let fixture = Fixture::new(
"parts/actors.arcadia",
format!(
"oa_actor a \"救助 🚒\" id \"{A}\" {{}}\r\nsource @ // 注释 @a\r\n a; target @a;\r\n"
),
&[("oa_actor", "a", Some(A))],
);
let index = index(&[fixture.included()]);
let uses: Vec<_> = index.references_for_alias("a", Domain::Loaded).collect();
assert_eq!(uses.len(), 2);
let first = index.reference(uses[0]).unwrap();
assert_eq!(
index.position(0, first.at.span.start),
Ok(Position {
line: 1,
byte_column: 7
})
);
assert_eq!(
index.position(0, first.alias_location.span.start),
Ok(Position {
line: 2,
byte_column: 2
})
);
assert_eq!(
index.hit(0, first.at.span.start),
Ok(Some(Hit::Reference(uses[0])))
);
assert_eq!(
index.hit(0, first.alias_location.span.start),
Ok(Some(Hit::Reference(uses[0])))
);
for byte in [
first.at.span.end,
fixture.text.find("注释").unwrap(),
fixture.text.find("// 注释 @a").unwrap() + "// 注释 ".len(),
first.alias_location.span.end,
] {
assert_eq!(index.hit(0, byte), Ok(None));
}
let unicode_inside = fixture.text.find('救').unwrap() + 1;
assert_eq!(
index.hit(0, unicode_inside),
Err(QueryError::InvalidBytePosition)
);
assert_eq!(
index.position(0, unicode_inside),
Err(QueryError::InvalidBytePosition)
);
assert_eq!(
index.hit(0, fixture.text.len() + 1),
Err(QueryError::InvalidBytePosition)
);
assert_eq!(index.hit(1, 0), Err(QueryError::InvalidFile));
assert!(index.source(0).unwrap().text.contains("\r\n"));
}
#[test]
fn legal_keyword_classified_alias_is_not_discarded() {
let fixture = Fixture::new(
"project.arcadia",
format!(
"oa_actor oa_activity \"Participant\" id \"{A}\" {{}}\nsource @oa_activity; source @parent;"
),
&[("oa_actor", "oa_activity", Some(A))],
);
assert!(oa_core::valid_alias("oa_activity"));
assert!(!oa_core::valid_alias("parent"));
assert_eq!(
token_at(&fixture.tokens, fixture.declarations[0].alias_span)
.unwrap()
.class,
TokenClass::Keyword
);
let index = index(&[fixture.included()]);
let found = definition(&index, "oa_activity");
let usage = index
.references_for_alias("oa_activity", Domain::Included)
.next()
.unwrap();
assert_eq!(
index.resolve_reference(usage, Domain::Included),
Ok(Lookup::Unique(found))
);
assert_eq!(
index.references_for_alias("parent", Domain::Loaded).count(),
0
);
assert_eq!(
index.hit(0, fixture.declarations[0].alias_span.start),
Ok(Some(Hit::Definition(found)))
);
}
#[test]
fn duplicate_aliases_are_candidates_without_false_reference_attribution() {
let first = Fixture::new(
"project.arcadia",
format!("oa_actor a \"Same display\" id \"{A}\" {{}}\nsource @a;"),
&[("oa_actor", "a", Some(A))],
);
let second = Fixture::new(
"parts/other.arcadia",
format!("oa_actor a \"Same display\" id \"{B}\" {{}}\nsource @a;"),
&[("oa_actor", "a", Some(B))],
);
let index = index(&[first.included(), second.included()]);
let candidates: Vec<_> = index.definitions_for_alias("a", Domain::Included).collect();
assert_eq!(candidates.len(), 2);
assert_eq!(index.definition(candidates[0]).unwrap().declaration.file, 0);
assert_eq!(index.definition(candidates[1]).unwrap().declaration.file, 1);
assert_ne!(anchor(&index, candidates[0]), anchor(&index, candidates[1]));
assert!(matches!(
index.references_to(candidates[0], Domain::Included),
Err(QueryError::AmbiguousAlias { count: 2 })
));
let uses: Vec<_> = index.references_for_alias("a", Domain::Included).collect();
assert_eq!(uses.len(), 2);
for usage in uses {
assert_eq!(
index.resolve_reference(usage, Domain::Included),
Ok(Lookup::Ambiguous { count: 2 })
);
}
}
#[test]
fn detached_uuid_clone_disables_anchor_even_for_included_query() {
let first = Fixture::new(
"project.arcadia",
format!("oa_actor a \"First\" id \"{A}\" {{}}"),
&[("oa_actor", "a", Some(A))],
);
let original = index(&[first.included()]);
let old_anchor = anchor(&original, definition(&original, "a"));
let detached = Fixture::new(
"retained.arcadia",
format!("oa_actor old \"Retained\" id \"{A}\" {{}}"),
&[("oa_actor", "old", Some(A))],
);
let current = Index::build(
snapshot(2),
&[
first.included(),
detached.input(Membership::Detached, Evidence::Draft),
],
Scope::CompleteProject,
limits(),
)
.unwrap();
assert_eq!(
current.identity(definition(&current, "a")),
Ok(Identity::Collision { count: 2 })
);
assert_eq!(
current.identity(definition(&current, "old")),
Ok(Identity::Collision { count: 2 })
);
assert_eq!(
current.resolve_anchor(old_anchor, Domain::Included),
Ok(Lookup::Ambiguous { count: 2 })
);
assert_eq!(
current.resolve_anchor(old_anchor, Domain::Loaded),
Ok(Lookup::Ambiguous { count: 2 })
);
assert_eq!(
current
.definitions_for_alias("old", Domain::Included)
.count(),
0
);
assert_eq!(current.definitions_for_id(A, Domain::Included).count(), 1);
assert_eq!(current.definitions_for_id(A, Domain::Loaded).count(), 2);
}
#[test]
fn reused_alias_cannot_steal_old_identity_and_old_locations_are_stale() {
let first = Fixture::new(
"project.arcadia",
format!("oa_actor a \"First\" id \"{A}\" {{}}\nsource @a;"),
&[("oa_actor", "a", Some(A))],
);
let original = index(&[first.included()]);
let old_definition = definition(&original, "a");
let old_reference = original
.references_for_alias("a", Domain::Included)
.next()
.unwrap();
let old_anchor = anchor(&original, old_definition);
let edited = Fixture::new(
"project.arcadia",
format!(
"oa_actor b \"First renamed\" id \"{A}\" {{}}\noa_actor a \"New actor\" id \"{B}\" {{}}\nsource @a; target @b;"
),
&[("oa_actor", "b", Some(A)), ("oa_actor", "a", Some(B))],
);
let current = Index::build(
snapshot(2),
&[edited.included()],
Scope::CompleteProject,
limits(),
)
.unwrap();
assert_eq!(
current
.definition(old_definition)
.map(|definition| definition.alias),
Err(QueryError::StaleSnapshot)
);
assert_eq!(
current
.reference(old_reference)
.map(|reference| reference.alias),
Err(QueryError::StaleSnapshot)
);
assert_eq!(
current.resolve_anchor(old_anchor, Domain::Included),
Ok(Lookup::Unique(definition(&current, "b")))
);
let new_use = current
.references_for_alias("a", Domain::Included)
.next()
.unwrap();
assert_eq!(
current.resolve_reference(new_use, Domain::Included),
Ok(Lookup::Unique(definition(&current, "a")))
);
assert_eq!(anchor(&current, definition(&current, "a")).id(), B);
let other_workspace = Index::build(
SnapshotKey {
workspace: Id::from_u128(2000),
revision: 2,
},
&[edited.included()],
Scope::CompleteProject,
limits(),
)
.unwrap();
assert_eq!(
other_workspace.resolve_anchor(old_anchor, Domain::Loaded),
Err(QueryError::WrongWorkspace)
);
assert_eq!(
other_workspace
.definition(definition(&current, "b"))
.map(|definition| definition.alias),
Err(QueryError::StaleSnapshot)
);
let deleted = Fixture::new(
"project.arcadia",
format!("oa_actor a \"New actor\" id \"{B}\" {{}}"),
&[("oa_actor", "a", Some(B))],
);
let after_delete = Index::build(
snapshot(3),
&[deleted.included()],
Scope::CompleteProject,
limits(),
)
.unwrap();
assert_eq!(
after_delete.resolve_anchor(old_anchor, Domain::Loaded),
Ok(Lookup::Missing)
);
}
#[test]
fn missing_ids_produce_only_current_snapshot_locations_without_stabilization() {
let fixture = Fixture::new(
"project.arcadia",
format!("oa_actor a \"No UUID\" {{ historical uuid \"{A}\"; }}\r\nsource @a;"),
&[("oa_actor", "a", None)],
);
let original = fixture.text.clone();
let index = index(&[fixture.included()]);
let declaration = definition(&index, "a");
assert_eq!(index.identity(declaration), Ok(Identity::Missing));
assert_eq!(index.definition(declaration).unwrap().authored_id, None);
let usage = index
.references_to(declaration, Domain::Included)
.unwrap()
.next()
.unwrap();
assert_eq!(
index.resolve_reference(usage, Domain::Included),
Ok(Lookup::Unique(declaration))
);
assert_eq!(fixture.text, original);
assert_eq!(
index.source(0).unwrap().text.as_bytes(),
original.as_bytes()
);
assert!(!index.source(0).unwrap().text.contains(" id "));
assert_eq!(
index.hit(0, fixture.text.find(&A.to_string()).unwrap()),
Ok(None)
);
}
#[test]
fn scope_provenance_and_detached_alias_collision_are_explicit() {
let included = Fixture::new(
"project.arcadia",
format!("oa_actor a \"Included\" id \"{A}\" {{}}\nsource @a;"),
&[("oa_actor", "a", Some(A))],
);
let detached = Fixture::new(
"old.arcadia",
format!("oa_actor a \"Detached\" id \"{B}\" {{}}\nsource @a;"),
&[("oa_actor", "a", Some(B))],
);
let unknown = Fixture::new("new.arcadia", "source @a;".into(), &[]);
let index = Index::build(
snapshot(1),
&[
included.input(Membership::Included, Evidence::Validated),
detached.input(Membership::Detached, Evidence::Draft),
unknown.input(Membership::Unverified, Evidence::Draft),
],
Scope::Partial(PartialReason::IncludesUnverified),
limits(),
)
.unwrap();
assert_eq!(index.references_for_alias("a", Domain::Included).count(), 1);
assert_eq!(index.references_for_alias("a", Domain::Loaded).count(), 3);
let scope = index.references_for_alias("a", Domain::Included).scope();
assert_eq!(
scope.declared,
Scope::Partial(PartialReason::IncludesUnverified)
);
assert!(!scope.project_complete);
assert!(scope.all_selected_files_validated);
assert_eq!(
(
scope.selected_files,
scope.detached_files,
scope.unverified_files
),
(1, 1, 1)
);
assert!(!index.scope(Domain::Loaded).all_selected_files_validated);
let uses: Vec<_> = index.references_for_alias("a", Domain::Loaded).collect();
assert!(matches!(
index.resolve_reference(uses[0], Domain::Included),
Ok(Lookup::Unique(_))
));
assert_eq!(
index.resolve_reference(uses[0], Domain::Loaded),
Ok(Lookup::Ambiguous { count: 2 })
);
assert_eq!(
index.resolve_reference(uses[1], Domain::Included),
Err(QueryError::OutsideDomain)
);
assert_eq!(
index.resolve_reference(uses[2], Domain::Included),
Err(QueryError::OutsideDomain)
);
assert_eq!(index.source(2).unwrap().membership, Membership::Unverified);
assert!(matches!(
Index::build(
snapshot(1),
&[
included.included(),
unknown.input(Membership::Unverified, Evidence::Draft)
],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::InconsistentCompleteScope,
..
})
));
}
#[test]
fn incomplete_draft_is_lexical_and_error_token_does_not_leak_references() {
let fixture = Fixture::new(
"project.arcadia",
"oa_actor a \"Draft\" {}\nsource @a; ? \"unfinished @a".into(),
&[("oa_actor", "a", None)],
);
assert!(
fixture
.tokens
.iter()
.any(|token| token.class == TokenClass::Error)
);
let index = Index::build(
snapshot(1),
&[fixture.included()],
Scope::Partial(PartialReason::UnappliedWorkspace),
limits(),
)
.unwrap();
let matches = index
.references_to(definition(&index, "a"), Domain::Included)
.unwrap();
assert!(!matches.scope().project_complete);
assert!(!matches.scope().all_selected_files_validated);
assert_eq!(matches.count(), 1);
assert_eq!(index.hit(0, fixture.text.rfind("@a").unwrap()), Ok(None));
}
#[test]
fn header_metadata_must_match_current_token_boundaries_and_authored_id() {
let mut fixture = Fixture::new(
"project.arcadia",
format!("oa_actor alpha \"救助\" id \"{A}\" {{}}"),
&[("oa_actor", "alpha", Some(A))],
);
let original = fixture.declarations[0];
fixture.declarations[0].alias_span.start += 1;
assert!(matches!(
Index::build(
snapshot(1),
&[fixture.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::HeaderRoleToken,
..
})
));
fixture.declarations[0] = original;
fixture.declarations[0].identity.as_mut().unwrap().id = B;
assert!(matches!(
Index::build(
snapshot(1),
&[fixture.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::IdentityLiteral,
..
})
));
fixture.declarations[0] = original;
fixture.declarations[0].alias_span = Span {
start: fixture.text.find('救').unwrap() + 1,
end: fixture.text.find('救').unwrap() + 3,
};
assert!(matches!(
Index::build(
snapshot(1),
&[fixture.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::DeclarationBounds,
..
})
));
fixture.declarations[0] = original;
fixture.declarations[0].span.end = fixture.text.len() + 1;
assert!(matches!(
Index::build(
snapshot(1),
&[fixture.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::DeclarationBounds,
..
})
));
}
#[test]
fn truncated_or_gapped_tokens_refuse_even_when_project_scope_is_partial() {
let mut fixture = Fixture::new(
"project.arcadia",
"oa_actor a \"Text\" {}\nsource @a;".into(),
&[("oa_actor", "a", None)],
);
let final_token = fixture.tokens.pop().unwrap();
assert!(matches!(
Index::build(
snapshot(1),
&[fixture.included()],
Scope::Partial(PartialReason::MissingFiles),
limits()
),
Err(BuildError::Input {
problem: InputProblem::TokenCoverage,
..
})
));
fixture.tokens.push(final_token);
fixture.tokens.remove(1);
assert!(matches!(
Index::build(
snapshot(1),
&[fixture.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::TokenCoverage,
..
})
));
let mut unicode = Fixture::new("project.arcadia", "// 救助\n".into(), &[]);
unicode.tokens[0].span.end = unicode.text.find('救').unwrap() + 1;
assert!(matches!(
Index::build(
snapshot(1),
&[unicode.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::TokenBounds,
..
})
));
}
#[test]
fn stopped_outline_is_not_a_successful_empty_query() {
let fixture = Fixture::new(
"project.arcadia",
"oa_actor a \"Text\" {}\nsource @a;".into(),
&[("oa_actor", "a", None)],
);
let mut stopped = fixture.included();
stopped.declarations = &[];
stopped.syntax = SyntaxCoverage::Incomplete;
assert!(matches!(
Index::build(
snapshot(1),
&[stopped],
Scope::Partial(PartialReason::MissingFiles),
limits()
),
Err(BuildError::Input {
problem: InputProblem::IncompleteSyntax,
..
})
));
}
#[test]
fn nested_declaration_extents_do_not_turn_body_text_into_definition_hits() {
let mut fixture = Fixture::new(
"project.arcadia",
"oa_entity outer \"Outer\" {\n oa_actor inner \"Inner\" {}\n}\nsource @inner;".into(),
&[("oa_entity", "outer", None), ("oa_actor", "inner", None)],
);
// Supply the actual outer whole span; nesting does not change alias order.
fixture.declarations[0].span.end = fixture.text.rfind('}').unwrap() + 1;
let index = index(&[fixture.included()]);
let inner = definition(&index, "inner");
assert_eq!(
index.hit(0, fixture.declarations[1].alias_span.start),
Ok(Some(Hit::Definition(inner)))
);
assert_eq!(
index.hit(0, fixture.text.find("\"Inner\"").unwrap() + 1),
Ok(None)
);
let usage = index
.references_for_alias("inner", Domain::Included)
.next()
.unwrap();
assert_eq!(
index.resolve_reference(usage, Domain::Included),
Ok(Lookup::Unique(inner))
);
}
#[test]
fn path_identity_rejects_normalization_and_case_collisions() {
let mut first = Fixture::new("project.arcadia", "source @a;".into(), &[]);
let second = Fixture::new("PROJECT.arcadia", "source @b;".into(), &[]);
assert!(matches!(
Index::build(
snapshot(1),
&[first.included(), second.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::DuplicateOrCaseCollidingPath,
..
})
));
assert!(matches!(
Index::build(
snapshot(1),
&[first.included(), first.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::DuplicateOrCaseCollidingPath,
..
})
));
for invalid in [
"./project.arcadia",
"parts/../project.arcadia",
"../project.arcadia",
"C:/project.arcadia",
"parts\\project.arcadia",
] {
first.path = invalid.into();
assert!(
matches!(
Index::build(
snapshot(1),
&[first.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::NonCanonicalPath,
..
})
),
"{invalid}"
);
}
}
#[test]
fn unicode_path_collisions_match_core_lowercase_policy() {
for (first_path, second_path) in [
("Ä.arcadia", "ä.arcadia"),
("ΟΣ/model.arcadia", "ος/model.arcadia"),
] {
let first = Fixture::new(first_path, "source @a;".into(), &[]);
let second = Fixture::new(second_path, "source @b;".into(), &[]);
assert_eq!(first_path.to_lowercase(), second_path.to_lowercase());
assert!(!first_path.eq_ignore_ascii_case(second_path));
assert!(matches!(
Index::build(
snapshot(1),
&[first.included(), second.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::DuplicateOrCaseCollidingPath,
..
})
));
}
}
#[test]
fn noncanonical_header_identity_cannot_enter_through_query_metadata() {
for raw in [
"00000000000000000000000000000001",
"{00000000-0000-0000-0000-000000000001}",
"urn:uuid:00000000-0000-0000-0000-000000000001",
] {
let literal = format!("\"{raw}\"");
let source = format!("oa_actor a \"A\" id {literal} {{}}\nsource @a;");
let mut fixture = Fixture::new(
"project.arcadia",
source.clone(),
&[("oa_actor", "a", None)],
);
let start = source.find(&literal).unwrap();
// This fixture has one explicit empty body. A brace inside the
// quoted UUID must not become the test's declaration end.
let body_start = source.find("{}\n").unwrap();
fixture.declarations[0].span.end = body_start + 2;
assert!(start + literal.len() <= body_start);
// An adapter passes None from the corrected outline; lexical
// definition/reference navigation survives without an ID anchor.
{
let admitted = index(&[fixture.included()]);
let d = definition(&admitted, "a");
assert_eq!(admitted.identity(d), Ok(Identity::Missing));
assert_eq!(
admitted.references_to(d, Domain::Included).unwrap().count(),
1
);
assert_eq!(admitted.source(0).unwrap().text, source);
}
// A forged/stale caller cannot put the UUID crate's permissive
// interpretation back into an authored anchor via the raw API.
fixture.declarations[0].identity = Some(AuthoredIdentity {
id: A,
literal_span: Span {
start,
end: start + literal.len(),
},
});
assert!(matches!(
Index::build(
snapshot(1),
&[fixture.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::IdentityLiteral,
..
})
));
}
}
#[test]
fn query_header_admission_preserves_uppercase_and_json_escape_bytes() {
let expected = Id::from_u128(0xabcdef);
let uppercase = expected.to_string().to_uppercase();
for raw in [uppercase.clone(), format!("\\u0030{}", &uppercase[1..])] {
let literal = format!("\"{raw}\"");
let source = format!("oa_actor a \"A\" id {literal} {{}}\nsource @a;");
let mut fixture = Fixture::new(
"project.arcadia",
source.clone(),
&[("oa_actor", "a", None)],
);
let start = source.find(&literal).unwrap();
fixture.declarations[0].identity = Some(AuthoredIdentity {
id: expected,
literal_span: Span {
start,
end: start + literal.len(),
},
});
let admitted = index(&[fixture.included()]);
assert_eq!(anchor(&admitted, definition(&admitted, "a")).id(), expected);
assert_eq!(
admitted.source(0).unwrap().text.as_bytes(),
source.as_bytes()
);
}
}
#[test]
fn admitted_unicode_path_budget_keeps_original_policy_after_single_fold() {
let fixture = Fixture::new("İ.arcadia", String::new(), &[]);
assert!(fixture.path.to_lowercase().len() > fixture.path.len());
let exact = Limits {
files: 1,
source_bytes: 0,
path_bytes: fixture.path.len(),
tokens: 0,
records: 0,
};
let admitted = Index::build(
snapshot(1),
&[fixture.included()],
Scope::CompleteProject,
exact,
)
.unwrap();
assert_eq!(admitted.source(0).unwrap().path, "İ.arcadia");
// PathBytes continues to count authored input. One lowercase key's
// Unicode expansion is bounded scratch, not a new path rejection rule.
}
#[test]
fn every_budget_admits_boundary_and_returns_no_index_one_below() {
let fixture = Fixture::new(
"project.arcadia",
"oa_actor a \"Text\" {}\nsource @a; target @a;".into(),
&[("oa_actor", "a", None)],
);
let exact = Limits {
files: 1,
source_bytes: fixture.text.len(),
path_bytes: fixture.path.len(),
tokens: fixture.tokens.len(),
records: 3,
};
assert!(
Index::build(
snapshot(1),
&[fixture.included()],
Scope::CompleteProject,
exact
)
.is_ok()
);
for resource in [
Resource::Files,
Resource::SourceBytes,
Resource::PathBytes,
Resource::Tokens,
Resource::Records,
] {
let mut reduced = exact;
match resource {
Resource::Files => reduced.files -= 1,
Resource::SourceBytes => reduced.source_bytes -= 1,
Resource::PathBytes => reduced.path_bytes -= 1,
Resource::Tokens => reduced.tokens -= 1,
Resource::Records => reduced.records -= 1,
}
assert!(
matches!(Index::build(snapshot(1), &[fixture.included()], Scope::CompleteProject, reduced), Err(BuildError::Limit { resource: actual, .. }) if actual == resource)
);
}
// Aggregate bytes/tokens/records, not a per-file budget reset.
let second = Fixture::new("other.arcadia", "source @a;".into(), &[]);
let mut aggregate = limits();
aggregate.records = 3;
assert!(matches!(
Index::build(
snapshot(1),
&[fixture.included(), second.included()],
Scope::CompleteProject,
aggregate
),
Err(BuildError::Limit {
resource: Resource::Records,
observed_at_least: 4,
..
})
));
let mut maximum = usize::MAX;
assert!(matches!(
charge(&mut maximum, 1, usize::MAX, Resource::SourceBytes),
Err(BuildError::Limit {
resource: Resource::SourceBytes,
..
})
));
assert_eq!(maximum, usize::MAX);
}
#[test]
fn declaration_order_and_duplicate_roles_refuse_instead_of_corrupting_hit_search() {
let mut fixture = Fixture::new(
"project.arcadia",
"oa_actor a \"First\" {}\noa_actor b \"Second\" {}".into(),
&[("oa_actor", "a", None), ("oa_actor", "b", None)],
);
fixture.declarations.reverse();
assert!(matches!(
Index::build(
snapshot(1),
&[fixture.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::DeclarationOrder,
..
})
));
fixture.declarations.reverse();
fixture.declarations.insert(1, fixture.declarations[0]);
assert!(matches!(
Index::build(
snapshot(1),
&[fixture.included()],
Scope::CompleteProject,
limits()
),
Err(BuildError::Input {
problem: InputProblem::DeclarationOrder,
..
})
));
}
}