279 lines
9.1 KiB
Rust
279 lines
9.1 KiB
Rust
//! 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<Span>,
|
|
) -> Result<Span, OriginError> {
|
|
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<Option<Option<Span>>> {
|
|
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()
|
|
);
|
|
}
|
|
}
|