Combine Text alias rename with guarded diagram navigation
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
//! 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user