//! Map a role-proven alias occurrence through the exact prepared source change. //! This is cursor restoration, not target resolution or commit authority. use oa_core::language::{self, Outline, OutlineLimits, Span, TokenClass}; use std::fmt; const LIMITS: OutlineLimits = OutlineLimits { max_source_bytes: 64 * 1024 * 1024, max_tokens: 1_000_000, max_declarations: 250_000, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OriginError { Budget, InvalidSource, MissingOrigin, ChangedStructure, } impl fmt::Display for OriginError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::Budget => "The alias cursor preview exceeds its source scan budget. No rename was applied.", Self::InvalidSource => "The alias cursor could not be verified in this source. No rename was applied.", Self::MissingOrigin => "The original alias occurrence no longer matches. Preview again; no rename was applied.", Self::ChangedStructure => "The preview changed source outside the expected alias tokens. No rename was applied.", }) } } impl std::error::Error for OriginError {} fn word(class: TokenClass) -> bool { matches!(class, TokenClass::Identifier | TokenClass::Keyword) } /// The controller passes the exact alias span returned by the admitted symbol /// hit. `marker` is Some only for a reference (its separate @ span), including /// invocation with the caret on @. A declaration passes None. pub fn map( before: &str, after: &str, old_alias: &str, new_alias: &str, origin: Span, marker: Option, ) -> Result { let old = language::outline(before, LIMITS).map_err(|_| OriginError::Budget)?; let new = language::outline(after, LIMITS).map_err(|_| OriginError::Budget)?; if old.has_lexical_errors || new.has_lexical_errors { return Err(OriginError::InvalidSource); } if old.tokens.len() != new.tokens.len() { return Err(OriginError::ChangedStructure); } let roles_before = roles(&old); let roles_after = roles(&new); let mut mapped = None; for (index, (a, b)) in old.tokens.iter().zip(&new.tokens).enumerate() { let left = &before[a.span.start..a.span.end]; let right = &after[b.span.start..b.span.end]; let alias_pair = word(a.class) && word(b.class) && left == old_alias && right == new_alias && roles_before[index].is_some() && roles_after[index].is_some() && roles_before[index].map(|role| role.is_some()) == roles_after[index].map(|role| role.is_some()); if a.span == origin { if !alias_pair || roles_before[index] != Some(marker) { return Err(OriginError::MissingOrigin); } mapped = Some(b.span); } if !alias_pair && (left != right || a.class != b.class) { return Err(OriginError::ChangedStructure); } } mapped.ok_or(OriginError::MissingOrigin) } /// Outer None is not an alias role; Some(None) is a declaration alias; /// Some(Some(span)) is a live @ reference with its exact separate marker. /// Scan sorted declaration spans and tokens together, without repeated searches. fn roles(outline: &Outline<'_>) -> Vec>> { let mut result = vec![None; outline.tokens.len()]; let mut declarations = outline.declarations.iter().peekable(); let mut significant = None; for (index, token) in outline.tokens.iter().enumerate() { while declarations .peek() .is_some_and(|d| d.alias_span.start < token.span.start) { declarations.next(); } if word(token.class) { if declarations .peek() .is_some_and(|d| d.alias_span == token.span) { result[index] = Some(None); } else if let Some(previous) = significant { let span: Span = previous; if &outline.source[span.start..span.end] == "@" { result[index] = Some(Some(span)); } } } if !matches!(token.class, TokenClass::Whitespace | TokenClass::Comment) { significant = Some(token.span); } } result } #[cfg(test)] mod tests { use super::*; fn occurrence(source: &str, needle: &str, which: usize) -> Span { let start = source.match_indices(needle).nth(which).unwrap().0; Span { start, end: start + needle.len(), } } #[test] fn maps_last_repeated_reference_with_unicode_crlf_and_comment_separator() { let before = "oa_activity a \"救援\" {}\r\n// @a is prose\r\nsource @a; target @ // a\r\n a;\r\n"; let after = "oa_activity renamed \"救援\" {}\r\n// @a is prose\r\nsource @renamed; target @ // a\r\n renamed;\r\n"; let start = before.rfind(" a;").unwrap() + 1; let marker = before.rfind("@ //").unwrap(); let mapped = map( before, after, "a", "renamed", Span { start, end: start + 1, }, Some(Span { start: marker, end: marker + 1, }), ) .unwrap(); assert_eq!(&after[mapped.start..mapped.end], "renamed"); assert_eq!(mapped.start, after.rfind(" renamed;").unwrap() + 1); assert_eq!( before[..start].bytes().filter(|b| *b == b'\n').count(), after[..mapped.start] .bytes() .filter(|b| *b == b'\n') .count() ); } #[test] fn preserves_final_same_line_occurrence_not_first_or_prose() { let before = "oa_activity a \"a @a\" {} source @a; target @a;"; let after = "oa_activity longer \"a @a\" {} source @longer; target @longer;"; let start = before.rfind("@a").unwrap(); let result = map( before, after, "a", "longer", Span { start: start + 1, end: start + 2, }, Some(Span { start, end: start + 1, }), ) .unwrap(); assert_eq!(result.start, after.rfind("@longer").unwrap() + 1); let prose = occurrence(before, "a @a", 0); assert!( map( before, after, "a", "longer", Span { start: prose.start, end: prose.start + 1 }, None ) .is_err() ); } #[test] fn permits_identifier_keyword_transitions_in_alias_roles() { let before = "oa_activity a \"Name\" {} source @a;"; let alias = occurrence(before, "a \"Name", 0); let origin = Span { start: alias.start, end: alias.start + 1, }; for replacement in ["none", "include", "oa_activity"] { assert!(oa_core::valid_alias(replacement)); assert_eq!( language::tokenize(replacement)[0].class, TokenClass::Keyword ); let after = format!("oa_activity {replacement} \"Name\" {{}} source @{replacement};"); let mapped = map(before, &after, "a", replacement, origin, None).unwrap(); assert_eq!(&after[mapped.start..mapped.end], replacement); assert_eq!( map(&after, before, replacement, "a", mapped, None).unwrap(), origin ); } } #[test] fn rejects_changed_trivia_non_alias_words_and_wrong_marker() { let before = "oa_activity a \"Name\" {} source @a;"; let after = "oa_activity b \"Name\" {} source @b;"; let start = before.rfind("@a").unwrap(); let span = Span { start: start + 1, end: start + 2, }; let marker = Some(Span { start, end: start + 1, }); assert_eq!( map( before, &after.replace("Name", "Other"), "a", "b", span, marker ), Err(OriginError::ChangedStructure) ); assert_eq!( map( before, &after.replace(" source", " source"), "a", "b", span, marker ), Err(OriginError::ChangedStructure) ); assert_eq!( map(before, after, "a", "b", span, None), Err(OriginError::MissingOrigin) ); assert!( map( before, after, "a", "b", Span { start: 0, end: usize::MAX }, marker ) .is_err() ); } }