use super::{BoundedVersionReq, LineOffsetTable, RequirementStatus};
use crate::pagination::ListCoverage;
use crate::position::Range;
use yaml_rust2::parser::Tag;
use yaml_rust2::scanner::{Marker, TScalarStyle};
#[cfg(feature = "lsp-responses")]
use super::diagnostics::MAX_VERSION_DIAGNOSTIC_CHARS;
#[cfg(feature = "lsp-responses")]
use super::{EcosystemFormatter, markdown_code_span, single_file_edit};
#[cfg(feature = "lsp-responses")]
use crate::{Dependency, ParseResult};
#[cfg(feature = "lsp-responses")]
use tower_lsp_server::ls_types::{CodeAction, CodeActionKind, Position, TextEdit, WorkspaceEdit};
const SHA_LEN: usize = 40;
#[must_use]
pub fn short_sha(sha: &str) -> &str {
sha.get(..7).unwrap_or(sha)
}
#[must_use]
pub fn is_full_sha(s: &str) -> bool {
s.len() == SHA_LEN && s.bytes().all(|b| b.is_ascii_hexdigit())
}
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub struct CommitSha(String);
impl CommitSha {
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
is_full_sha(s).then(|| Self(s.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for CommitSha {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::borrow::Borrow<str> for CommitSha {
fn borrow(&self) -> &str {
&self.0
}
}
#[non_exhaustive]
#[derive(Debug, Default)]
pub struct TagIndex {
pub tag_to_sha: std::collections::HashMap<String, CommitSha>,
sha_to_tag: std::collections::HashMap<CommitSha, ResolvedPin>,
canonical_repo_name: Option<crate::github::CanonicalRepoName>,
coverage: ListCoverage,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SiblingTags(Vec<crate::ConcreteVersion>);
impl SiblingTags {
pub(crate) fn from_candidates<'a>(
primary: &str,
names: impl IntoIterator<Item = &'a str>,
) -> Self {
let class = tag_specificity_rank(primary).0;
let (kind, _, is_release) = class;
if !is_release || !matches!(kind, FULL_SEMVER_KIND | PARTIAL_SEMVER_KIND) {
return Self::default();
}
let primary_normalized = crate::github::normalize_tag(primary);
let mut ranked: Vec<(TagRank<'a>, &'a str)> = names
.into_iter()
.map(|n| (tag_specificity_rank(n), n))
.collect();
let all: Vec<&str> = ranked.iter().map(|(_, n)| *n).collect();
ranked.retain(|(rank, n)| {
rank.0 == class
&& crate::github::normalize_tag(n) != primary_normalized
&& (kind == FULL_SEMVER_KIND
|| (tag_components(n).all(|c| c.parse::<u64>().is_ok())
&& !all.iter().any(|other| extends_tag(other, n))))
});
ranked.sort_by(|a, b| b.0.cmp(&a.0));
ranked.dedup_by(|a, b| {
crate::github::normalize_tag(a.1) == crate::github::normalize_tag(b.1)
});
Self(
ranked
.into_iter()
.map(|(_, n)| crate::ConcreteVersion::new(n))
.collect(),
)
}
pub fn iter(&self) -> std::slice::Iter<'_, crate::ConcreteVersion> {
self.0.iter()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub const fn len(&self) -> usize {
self.0.len()
}
}
impl<'a> IntoIterator for &'a SiblingTags {
type Item = &'a crate::ConcreteVersion;
type IntoIter = std::slice::Iter<'a, crate::ConcreteVersion>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedPin {
MostSpecific {
tag: crate::ConcreteVersion,
siblings: SiblingTags,
},
Alias {
tag: crate::ConcreteVersion,
siblings: SiblingTags,
},
}
impl ResolvedPin {
#[must_use]
pub fn most_specific(tag: crate::ConcreteVersion) -> Self {
Self::MostSpecific {
tag,
siblings: SiblingTags::default(),
}
}
#[must_use]
pub fn alias(tag: crate::ConcreteVersion) -> Self {
Self::Alias {
tag,
siblings: SiblingTags::default(),
}
}
#[must_use]
pub const fn version(&self) -> &crate::ConcreteVersion {
match self {
Self::MostSpecific { tag, .. } | Self::Alias { tag, .. } => tag,
}
}
#[must_use]
pub const fn siblings(&self) -> &SiblingTags {
match self {
Self::MostSpecific { siblings, .. } | Self::Alias { siblings, .. } => siblings,
}
}
}
const FULL_SEMVER_KIND: u8 = 2;
const PARTIAL_SEMVER_KIND: u8 = 1;
type TagRank<'a> = (
(u8, usize, bool),
std::cmp::Reverse<Option<semver::Version>>,
std::cmp::Reverse<Vec<u64>>,
std::cmp::Reverse<&'a str>,
);
fn tag_specificity_rank(name: &str) -> TagRank<'_> {
let normalized = crate::github::normalize_tag(name);
let is_release = !normalized.contains(['-', '+']);
let (class, semver, numeric) = if let Ok(v) = semver::Version::parse(normalized) {
((FULL_SEMVER_KIND, 0, is_release), Some(v), Vec::new())
} else if is_partial_semver_shaped(name) {
let numeric = normalized
.split(['-', '+'])
.next()
.unwrap_or_default()
.split('.')
.map(|c| c.parse().unwrap_or(u64::MAX))
.collect();
(
(
PARTIAL_SEMVER_KIND,
tag_components(name).count(),
is_release,
),
None,
numeric,
)
} else {
((0, 0, false), None, Vec::new())
};
(
class,
std::cmp::Reverse(semver),
std::cmp::Reverse(numeric),
std::cmp::Reverse(name),
)
}
fn tag_components(name: &str) -> impl Iterator<Item = &str> {
crate::github::normalize_tag(name).split('.')
}
#[must_use]
pub fn extends_tag(longer: &str, shorter: &str) -> bool {
let mut longer = tag_components(longer);
tag_components(shorter).all(|c| longer.next() == Some(c)) && longer.next().is_some()
}
impl TagIndex {
#[must_use]
pub fn from_tags<'a, I>(entries: I) -> Self
where
I: IntoIterator<Item = (&'a str, &'a CommitSha)>,
{
let mut index = Self::default();
let mut names_by_sha: std::collections::HashMap<&CommitSha, Vec<&str>> =
std::collections::HashMap::new();
for (name, sha) in entries {
names_by_sha.entry(sha).or_default().push(name);
index
.tag_to_sha
.entry(name.to_string())
.or_insert_with(|| sha.clone());
}
for (sha, names) in names_by_sha {
let Some(best) = names
.iter()
.copied()
.max_by_key(|n| tag_specificity_rank(n))
else {
continue;
};
let siblings = SiblingTags::from_candidates(best, names.iter().copied());
let tag = crate::ConcreteVersion::new(best);
let pin = if names.iter().any(|other| extends_tag(other, best)) {
ResolvedPin::Alias { tag, siblings }
} else {
ResolvedPin::MostSpecific { tag, siblings }
};
index.sha_to_tag.insert(sha.clone(), pin);
}
index
}
#[must_use]
pub fn resolved_exact_tag(&self, written: &str) -> Option<ResolvedPin> {
let sha = self.tag_to_sha.get(written)?;
let major = tag_components(written).next();
let names: Vec<&str> = self
.tag_to_sha
.iter()
.filter(|(name, other)| *other == sha && tag_components(name).next() == major)
.map(|(name, _)| name.as_str())
.collect();
let siblings = SiblingTags::from_candidates(written, names.iter().copied());
let tag = crate::ConcreteVersion::new(written);
Some(if names.iter().any(|other| extends_tag(other, written)) {
ResolvedPin::Alias { tag, siblings }
} else {
ResolvedPin::MostSpecific { tag, siblings }
})
}
#[must_use]
pub fn with_canonical_repo_name(
mut self,
name: Option<crate::github::CanonicalRepoName>,
) -> Self {
self.canonical_repo_name = name;
self
}
#[must_use]
pub const fn with_coverage(mut self, coverage: ListCoverage) -> Self {
self.coverage = coverage;
self
}
#[must_use]
pub const fn coverage(&self) -> ListCoverage {
self.coverage
}
#[must_use]
pub const fn canonical_repo_name(&self) -> Option<&crate::github::CanonicalRepoName> {
self.canonical_repo_name.as_ref()
}
pub fn insert_sha_pin(&mut self, sha: CommitSha, pin: ResolvedPin) {
self.sha_to_tag.insert(sha, pin);
}
#[must_use]
pub fn resolved_pin(&self, sha: &str) -> Option<ResolvedPin> {
self.pin_for_sha(sha).cloned()
}
#[must_use]
pub fn tag_for_sha(&self, sha: &str) -> Option<&str> {
self.tag_version_for_sha(sha)
.map(crate::ConcreteVersion::as_str)
}
fn pin_for_sha(&self, sha: &str) -> Option<&ResolvedPin> {
self.sha_to_tag
.get(sha)
.or_else(|| self.sha_to_tag.get(sha.to_ascii_lowercase().as_str()))
}
fn tag_version_for_sha(&self, sha: &str) -> Option<&crate::ConcreteVersion> {
self.pin_for_sha(sha).map(ResolvedPin::version)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.tag_to_sha.is_empty() && self.sha_to_tag.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShaPinLookup {
LatestCommit,
Indexed {
tag: crate::ConcreteVersion,
},
NotIndexed,
Unverifiable,
}
impl ShaPinLookup {
#[must_use]
pub fn resolve(
index: Option<&TagIndex>,
sha: &str,
latest: &crate::ConcreteVersion,
) -> Option<Self> {
if !is_full_sha(sha) {
return None;
}
let sha = sha.to_ascii_lowercase();
let Some(index) = index.filter(|index| !index.is_empty()) else {
return Some(Self::Unverifiable);
};
if index
.tag_to_sha
.get(latest.as_str())
.is_some_and(|commit| commit.as_str() == sha)
{
return Some(Self::LatestCommit);
}
Some(match (index.tag_version_for_sha(&sha), index.coverage()) {
(Some(tag), _) => Self::Indexed { tag: tag.clone() },
(None, ListCoverage::Complete) => Self::NotIndexed,
(None, ListCoverage::Truncated) => Self::Unverifiable,
})
}
#[must_use]
pub fn into_status(
self,
tag_is_up_to_date: impl FnOnce(BoundedVersionReq<'_>) -> bool,
) -> Option<RequirementStatus> {
match self {
Self::LatestCommit => Some(RequirementStatus::UpToDate),
Self::Indexed { tag } => {
let tag = crate::VersionReq::new(tag.as_str());
Some(
BoundedVersionReq::new(&tag).map_or(RequirementStatus::Unresolved, |tag| {
if is_tag_shaped(tag.as_str()) && tag_is_up_to_date(tag) {
RequirementStatus::UpToDate
} else {
RequirementStatus::Outdated
}
}),
)
}
Self::NotIndexed => Some(RequirementStatus::Outdated),
Self::Unverifiable => None,
}
}
}
#[must_use]
pub fn is_tag_shaped(s: &str) -> bool {
if is_full_sha(s) {
return false;
}
let stripped = s.strip_prefix(['v', 'V']).unwrap_or(s);
stripped.starts_with(|c: char| c.is_ascii_digit())
}
#[expect(
clippy::string_slice,
reason = "idx comes from str::find(['-', '+']), both ASCII bytes, so it is always a char \
boundary; strip_prefix(['v', 'V']) likewise only ever removes a single ASCII byte"
)]
#[must_use]
pub fn is_partial_semver_shaped(s: &str) -> bool {
let has_v_prefix = s.starts_with(['v', 'V']);
let stripped = s.strip_prefix(['v', 'V']).unwrap_or(s);
let core = match stripped.find(['-', '+']) {
Some(idx) => &stripped[..idx],
None => stripped,
};
let parts: Vec<&str> = core.split('.').collect();
if parts.len() > 3 || (!has_v_prefix && parts.len() < 2) {
return false;
}
parts
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
}
#[expect(
clippy::string_slice,
reason = "tag[1..] only runs when tag_has_v (an ASCII 'v'/'V' prefix check), so index 1 \
is always a char boundary"
)]
#[must_use]
pub fn match_v_prefix_style(current: &str, tag: &str) -> String {
let current_has_v = current.starts_with(['v', 'V']);
let tag_has_v = tag.starts_with(['v', 'V']);
match (current_has_v, tag_has_v) {
(true, false) => format!("v{tag}"),
(false, true) => tag[1..].to_string(),
_ => tag.to_string(),
}
}
#[must_use]
pub fn is_plain_null(style: TScalarStyle, tag: Option<&Tag>, value: &str) -> bool {
if style != TScalarStyle::Plain {
return false;
}
if value.is_empty() {
return true;
}
match tag {
None => matches!(value, "~" | "null" | "Null" | "NULL"),
Some(tag) => is_null_tag(tag) && matches!(value, "~" | "null" | "Null" | "NULL"),
}
}
#[must_use]
pub fn is_null_tag(tag: &Tag) -> bool {
(tag.handle == "tag:yaml.org,2002:" && tag.suffix == "null")
|| (tag.handle.is_empty() && tag.suffix == "tag:yaml.org,2002:null")
}
#[must_use]
pub fn marker_byte_offset(
content: &str,
table: &LineOffsetTable,
line: usize,
col: usize,
) -> usize {
debug_assert!(line >= 1, "yaml-rust2 marker line is 1-indexed, got 0");
let Some(line_start) = table.line_start(line.saturating_sub(1)) else {
return content.len();
};
let line_end = table.line_start(line).unwrap_or(content.len());
let line_text = content.get(line_start..line_end).unwrap_or_default();
let byte_in_line = if table
.line_is_ascii
.get(line.saturating_sub(1))
.copied()
.unwrap_or(false)
{
col.min(line_text.len())
} else {
table.non_ascii_char_byte_offset(line.saturating_sub(1), line_text, col)
};
line_start + byte_in_line
}
pub const MAX_FALLBACK_SCAN_BYTES: usize = 1024;
#[must_use]
pub fn locate_value_span(
content: &str,
search_from: usize,
value: &str,
is_quoted: bool,
) -> Option<(usize, usize)> {
let bytes = content.as_bytes();
if search_from > bytes.len() {
tracing::debug!(
search_from,
content_len = bytes.len(),
"locate_value_span: search_from past end of content"
);
return None;
}
if value.is_empty() {
let corrected = if is_quoted {
match bytes.get(search_from) {
Some(b'"' | b'\'') => search_from + 1,
_ => search_from,
}
} else {
search_from
};
return Some((corrected, corrected));
}
#[expect(
clippy::indexing_slicing,
reason = "every slice/index in this block is bounds-checked by the search_from <= \
bytes.len() guard above combined with each expression's own <=/.min(...) clamp"
)]
{
if search_from + value.len() <= bytes.len()
&& &bytes[search_from..search_from + value.len()] == value.as_bytes()
{
return Some((search_from, search_from + value.len()));
}
}
let window_end = bytes
.len()
.min(search_from.saturating_add(MAX_FALLBACK_SCAN_BYTES));
#[expect(
clippy::indexing_slicing,
reason = "window_end = bytes.len().min(...), and search_from <= bytes.len() from the \
guard above, so bytes[search_from..window_end] is always in bounds"
)]
let scan_end = bytes[search_from..window_end]
.iter()
.position(|&b| b == b'\n')
.map_or(window_end, |p| search_from + p);
#[expect(
clippy::indexing_slicing,
reason = "scan_end is derived from window_end or a position found within \
bytes[search_from..window_end], so it stays within [search_from, window_end]"
)]
let haystack = &bytes[search_from..scan_end];
let needle = value.as_bytes();
let found = haystack
.windows(needle.len())
.position(|w| w == needle)
.map(|rel| (search_from + rel, search_from + rel + needle.len()));
if found.is_none() {
tracing::debug!(
search_from,
value_len = needle.len(),
scan_bytes = scan_end - search_from,
"locate_value_span: value not found within bounded fallback scan"
);
}
found
}
#[must_use]
pub fn byte_span_to_range(
content: &str,
table: &LineOffsetTable,
start: usize,
end: usize,
) -> Range {
Range::new(
table.byte_offset_to_position(content, start),
table.byte_offset_to_position(content, end),
)
}
#[derive(Debug, Clone)]
pub struct MarkedScalar {
text: String,
style: TScalarStyle,
line: usize,
col: usize,
}
impl MarkedScalar {
#[must_use]
pub fn new(text: String, style: TScalarStyle, marker: &Marker) -> Self {
Self {
text,
style,
line: marker.line(),
col: marker.col(),
}
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub fn into_text(self) -> String {
self.text
}
#[must_use]
pub const fn style(&self) -> TScalarStyle {
self.style
}
#[must_use]
pub fn is_plain(&self) -> bool {
self.style == TScalarStyle::Plain
}
#[must_use]
pub fn is_quoted(&self) -> bool {
matches!(
self.style,
TScalarStyle::SingleQuoted | TScalarStyle::DoubleQuoted
)
}
#[must_use]
pub const fn line(&self) -> usize {
self.line
}
#[must_use]
pub const fn col(&self) -> usize {
self.col
}
#[must_use]
pub fn span(&self, content: &str, table: &LineOffsetTable) -> Option<(usize, usize)> {
let start = marker_byte_offset(content, table, self.line, self.col);
locate_value_span(content, start, &self.text, self.is_quoted())
}
#[must_use]
pub fn range(&self, content: &str, table: &LineOffsetTable) -> Option<Range> {
self.span(content, table)
.map(|(start, end)| byte_span_to_range(content, table, start, end))
}
}
#[cfg(feature = "lsp-responses")]
#[derive(Clone, PartialEq, Eq, deps_core::redact_debug::RedactingDebug)]
pub struct ResolvedShaPin {
#[redact(key)]
pub display_name: String,
#[raw]
pub version_range: Range,
#[raw]
pub replacement: String,
}
#[cfg(feature = "lsp-responses")]
pub trait ShaPinning: Send + Sync {
fn resolve_static_sha_pin(&self, dep: &dyn Dependency) -> Option<ResolvedShaPin>;
}
#[cfg(feature = "lsp-responses")]
const MAX_SHA_PIN_TITLE_NAME_CHARS: usize = 128;
#[cfg(feature = "lsp-responses")]
#[must_use]
pub fn build_sha_pin_action<F: EcosystemFormatter + ShaPinning>(
parse_result: &dyn ParseResult,
position: Position,
uri: &url::Url,
formatter: &F,
diagnostic_code: &'static str,
) -> Option<CodeAction> {
let dep = parse_result
.dependencies()
.into_iter()
.find(|d| formatter.is_position_on_dependency(*d, position.into()))?;
let resolved = formatter.resolve_static_sha_pin(dep)?;
let changes = single_file_edit(uri, resolved.version_range, resolved.replacement);
let display_name = super::diagnostics::sanitize_and_truncate_for_diagnostic(
&resolved.display_name,
MAX_SHA_PIN_TITLE_NAME_CHARS,
);
Some(CodeAction {
title: format!("Pin {display_name} to commit SHA"),
kind: Some(CodeActionKind::QUICKFIX),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
data: Some(serde_json::json!({
"diagnostic_codes": [diagnostic_code],
"diagnostic_range": tower_lsp_server::ls_types::Range::from(resolved.version_range),
})),
..Default::default()
})
}
#[cfg(feature = "lsp-responses")]
#[must_use]
pub fn sha_pin_text_edit(pinning: &impl ShaPinning, dep: &dyn Dependency) -> Option<TextEdit> {
let resolved = pinning.resolve_static_sha_pin(dep)?;
Some(TextEdit {
range: resolved.version_range.into(),
new_text: resolved.replacement,
})
}
#[cfg(feature = "lsp-responses")]
#[must_use]
pub fn splice_resolved_line(markdown: &str, resolved_tag: &str, sha: &str) -> String {
let short_sha = short_sha(sha);
let line = format!(
"**Resolved**: {} ({})",
markdown_code_span(&super::diagnostics::sanitize_and_truncate_for_diagnostic(
resolved_tag,
MAX_VERSION_DIAGNOSTIC_CHARS
)),
markdown_code_span(&format!("{short_sha}…"))
);
splice_hover_line(markdown, &line)
}
#[cfg(feature = "lsp-responses")]
#[expect(
clippy::string_slice,
reason = "pos/rel_end/insert_at derive only from find() of ASCII anchors, so every slice \
below is a char boundary"
)]
#[must_use]
pub fn splice_hover_line(markdown: &str, line: &str) -> String {
for anchor in ["**Resolved**: ", "**Current**: ", "**Requirement**: "] {
if let Some(pos) = markdown.find(anchor)
&& let Some(rel_end) = markdown[pos..].find("\n\n")
{
let insert_at = pos + rel_end + 2;
let mut out = String::with_capacity(markdown.len() + line.len() + 2);
out.push_str(&markdown[..insert_at]);
out.push_str(line);
out.push_str("\n\n");
out.push_str(&markdown[insert_at..]);
return out;
}
}
format!("{markdown}{line}\n\n")
}
#[cfg(test)]
#[expect(
clippy::string_slice,
reason = "fixtures are single-line ASCII literals with hand-computed byte offsets"
)]
mod tests {
use super::*;
#[test]
fn test_is_full_sha_accepts_and_rejects() {
assert!(is_full_sha(&"a".repeat(40)));
assert!(!is_full_sha(&"a".repeat(39)));
assert!(!is_full_sha(&"g".repeat(40)));
}
#[test]
fn test_commit_sha_parse_accepts_and_rejects() {
assert!(CommitSha::parse(&"a".repeat(40)).is_some());
assert!(CommitSha::parse("not-a-sha").is_none());
}
#[test]
fn test_commit_sha_parse_preserves_case() {
let sha = "A".repeat(40);
assert_eq!(CommitSha::parse(&sha).unwrap().as_str(), sha);
}
#[test]
fn test_commit_sha_borrow_str_enables_hashmap_lookup_by_str() {
let sha = CommitSha::parse(&"a".repeat(40)).unwrap();
let mut map = std::collections::HashMap::new();
map.insert(sha.clone(), "v1.0.0".to_string());
assert_eq!(map.get(sha.as_str()), Some(&"v1.0.0".to_string()));
}
#[test]
fn test_tag_index_from_tags_dedupes_first_wins_for_tag_to_sha() {
let sha_a = CommitSha::parse(&"a".repeat(40)).unwrap();
let sha_b = CommitSha::parse(&"b".repeat(40)).unwrap();
let index = TagIndex::from_tags([("v1.0.0", &sha_a), ("v1.0.0", &sha_b)]);
assert_eq!(index.tag_to_sha.get("v1.0.0"), Some(&sha_a));
}
#[test]
fn test_tag_index_from_tags_sha_to_tag_prefers_semver_over_bare_moving_tag() {
let sha = CommitSha::parse(&"a".repeat(40)).unwrap();
let index = TagIndex::from_tags([("v1", &sha), ("v0.1.15", &sha)]);
assert_eq!(index.tag_for_sha(sha.as_str()), Some("v0.1.15"));
assert_eq!(index.tag_to_sha.get("v1"), Some(&sha));
assert_eq!(index.tag_to_sha.get("v0.1.15"), Some(&sha));
}
fn sha_of(c: char) -> CommitSha {
CommitSha::parse(&c.to_string().repeat(40)).unwrap()
}
fn lookup(index: Option<&TagIndex>, sha: &str, latest: &str) -> Option<ShaPinLookup> {
ShaPinLookup::resolve(index, sha, &crate::ConcreteVersion::new(latest))
}
#[test]
fn test_sha_pin_lookup_rejects_non_full_sha() {
let index = TagIndex::from_tags([("v1.0.0", &sha_of('a'))]);
for bad in [
"a".repeat(39),
"a".repeat(41),
"g".repeat(40),
"v1.0.0".to_string(),
] {
assert_eq!(lookup(Some(&index), &bad, "v1.0.0"), None, "{bad}");
}
assert_eq!(
lookup(Some(&index), &"A".repeat(40), "v1.0.0"),
Some(ShaPinLookup::LatestCommit)
);
}
#[test]
fn test_sha_pin_lookup_variants() {
let index = TagIndex::from_tags([
("v1.0.0", &sha_of('a')),
("v1.1.0", &sha_of('b')),
("v1.1", &sha_of('b')),
]);
assert_eq!(
lookup(Some(&index), &"a".repeat(40), "v1.1.0"),
Some(ShaPinLookup::Indexed {
tag: crate::ConcreteVersion::new("v1.0.0")
})
);
assert_eq!(
lookup(Some(&index), &"c".repeat(40), "v1.1.0"),
Some(ShaPinLookup::NotIndexed)
);
assert_eq!(
lookup(Some(&TagIndex::default()), &"a".repeat(40), "v1.1.0"),
Some(ShaPinLookup::Unverifiable)
);
assert_eq!(
lookup(None, &"a".repeat(40), "v1.1.0"),
Some(ShaPinLookup::Unverifiable)
);
assert_eq!(
lookup(Some(&index), &"b".repeat(40), "v1.1.0"),
Some(ShaPinLookup::LatestCommit)
);
}
#[test]
fn test_sha_pin_lookup_truncated_index_only_proves_presence() {
let index = TagIndex::from_tags([("v1.0.0", &sha_of('a')), ("v1.1.0", &sha_of('b'))])
.with_coverage(ListCoverage::Truncated);
assert_eq!(
lookup(Some(&index), &"c".repeat(40), "v1.1.0"),
Some(ShaPinLookup::Unverifiable)
);
assert_eq!(
lookup(Some(&index), &"a".repeat(40), "v1.1.0"),
Some(ShaPinLookup::Indexed {
tag: crate::ConcreteVersion::new("v1.0.0")
})
);
assert_eq!(
lookup(Some(&index), &"b".repeat(40), "v1.1.0"),
Some(ShaPinLookup::LatestCommit)
);
}
#[test]
fn test_tag_index_lookups_are_case_insensitive_on_sha() {
let index = TagIndex::from_tags([("v1.0.0", &sha_of('a'))]);
let upper = "A".repeat(40);
assert_eq!(index.tag_for_sha(&upper), Some("v1.0.0"));
assert!(index.resolved_pin(&upper).is_some());
}
#[test]
fn test_sha_pin_lookup_into_status_indexed_branches() {
let indexed = |tag: &str| ShaPinLookup::Indexed {
tag: crate::ConcreteVersion::new(tag),
};
assert_eq!(
indexed("v1.0.0").into_status(|_| true),
Some(RequirementStatus::UpToDate)
);
assert_eq!(
indexed("v1.0.0").into_status(|_| false),
Some(RequirementStatus::Outdated)
);
assert_eq!(
indexed("cargo-deny").into_status(|_| true),
Some(RequirementStatus::Outdated)
);
let oversized = "v".repeat(super::super::MAX_REQUIREMENT_LEN + 1);
assert_eq!(
indexed(&oversized).into_status(|_| true),
Some(RequirementStatus::Unresolved)
);
assert_eq!(
ShaPinLookup::NotIndexed.into_status(|_| true),
Some(RequirementStatus::Outdated)
);
assert_eq!(ShaPinLookup::Unverifiable.into_status(|_| true), None);
}
fn resolved_pin_for(tags: &[&str]) -> Option<ResolvedPin> {
let sha = CommitSha::parse(&"a".repeat(40)).unwrap();
let index = TagIndex::from_tags(tags.iter().map(|t| (*t, &sha)));
index.resolved_pin(sha.as_str())
}
#[test]
fn test_tag_index_two_component_release_beats_moving_major_in_any_order() {
for tags in [["v2.9", "v2"], ["v2", "v2.9"]] {
assert_eq!(
resolved_pin_for(&tags),
Some(ResolvedPin::most_specific(crate::ConcreteVersion::new(
"v2.9"
))),
"{tags:?}"
);
}
}
#[test]
fn test_tag_index_full_semver_beats_two_component_and_major() {
assert_eq!(
resolved_pin_for(&["v2", "v2.9", "v2.9.1"]),
Some(ResolvedPin::most_specific(crate::ConcreteVersion::new(
"v2.9.1"
)))
);
}
#[test]
fn test_tag_index_lone_major_is_most_specific_but_a_prefix_of_an_unranked_tag_is_alias() {
assert_eq!(
resolved_pin_for(&["v2"]),
Some(ResolvedPin::most_specific(crate::ConcreteVersion::new(
"v2"
)))
);
assert_eq!(
resolved_pin_for(&["v2.9", "v2.9.1.4"]),
Some(ResolvedPin::alias(crate::ConcreteVersion::new("v2.9")))
);
}
fn most_specific(tag: &str) -> Option<ResolvedPin> {
Some(ResolvedPin::most_specific(crate::ConcreteVersion::new(tag)))
}
fn sibling_names(pin: &ResolvedPin) -> Vec<&str> {
pin.siblings().iter().map(|t| t.as_str()).collect()
}
#[test]
fn test_sibling_tags_collect_other_releases_in_any_order() {
for tags in [["v4.8.0", "v4.9.0"], ["v4.9.0", "v4.8.0"]] {
let pin = resolved_pin_for(&tags).unwrap();
assert_eq!(pin.version().as_str(), "v4.8.0", "{tags:?}");
assert_eq!(sibling_names(&pin), ["v4.9.0"], "{tags:?}");
}
}
#[test]
fn test_sibling_tags_are_sorted_lowest_first() {
let pin = resolved_pin_for(&["v4.10.0", "v4.8.0", "v4.9.0", "v4.11.0"]).unwrap();
assert_eq!(pin.version().as_str(), "v4.8.0");
assert_eq!(sibling_names(&pin), ["v4.9.0", "v4.10.0", "v4.11.0"]);
}
#[test]
fn test_sibling_tags_exclude_aliases_prereleases_and_duplicates() {
let pin = resolved_pin_for(&["v4", "v4.8", "v4.8.0", "v4.9.0-rc1", "4.8.0", "release-x"])
.unwrap();
assert_eq!(pin.version().as_str().trim_start_matches('v'), "4.8.0");
assert!(pin.siblings().is_empty(), "{:?}", pin.siblings());
}
#[test]
fn test_sibling_tags_dedupe_spelling_variants_among_siblings() {
let pin = resolved_pin_for(&["v4.8.0", "v4.9.0", "4.9.0"]).unwrap();
assert_eq!(pin.version().as_str(), "v4.8.0");
assert_eq!(pin.siblings().len(), 1);
}
#[test]
fn test_sibling_tags_two_component_releases() {
let pin = resolved_pin_for(&["v2.9", "v2.10"]).unwrap();
assert_eq!(sibling_names(&pin), ["v2.10"]);
let pin = resolved_pin_for(&["v2.9", "v2.10", "v2.10.0.1"]).unwrap();
assert_eq!(pin.version().as_str(), "v2.9");
assert!(pin.siblings().is_empty());
}
#[test]
fn test_sibling_tags_prerelease_primary_has_none() {
let pin = resolved_pin_for(&["v4.9.0-rc1", "v4.9.0-rc2"]).unwrap();
assert!(pin.siblings().is_empty());
}
#[test]
fn test_sibling_tags_alias_winner_keeps_siblings() {
let pin = resolved_pin_for(&["v1.2.3", "v1.2.4", "v1.2.3.1"]).unwrap();
assert!(matches!(pin, ResolvedPin::Alias { .. }));
assert_eq!(pin.version().as_str(), "v1.2.3");
assert_eq!(sibling_names(&pin), ["v1.2.4"]);
}
#[test]
fn test_resolved_exact_tag_keeps_written_primary_and_same_major_siblings() {
let sha = CommitSha::parse(&"a".repeat(40)).unwrap();
let other = CommitSha::parse(&"b".repeat(40)).unwrap();
let index = TagIndex::from_tags([
("v4.9.0", &sha),
("v4.8.0", &sha),
("v5.0.0", &sha),
("v4.7.0", &other),
]);
let pin = index.resolved_exact_tag("v4.9.0").unwrap();
assert_eq!(pin.version().as_str(), "v4.9.0");
assert_eq!(sibling_names(&pin), ["v4.8.0"]);
assert_eq!(index.resolved_exact_tag("v9.9.9"), None);
}
#[test]
fn test_resolved_exact_tag_alias_and_spelling_twin() {
let sha = CommitSha::parse(&"a".repeat(40)).unwrap();
let index = TagIndex::from_tags([("v4.8.0", &sha), ("v4.8.0.1", &sha), ("4.8.0", &sha)]);
let pin = index.resolved_exact_tag("v4.8.0").unwrap();
assert!(matches!(pin, ResolvedPin::Alias { .. }));
assert!(pin.siblings().is_empty());
}
#[test]
fn test_tag_index_release_beats_prerelease_in_any_order() {
for tags in [["v2.9-rc1", "v2.9"], ["v2.9", "v2.9-rc1"]] {
assert_eq!(resolved_pin_for(&tags), most_specific("v2.9"), "{tags:?}");
}
for tags in [["v2.9.1-rc1", "v2.9.1"], ["v2.9.1", "v2.9.1-rc1"]] {
assert_eq!(resolved_pin_for(&tags), most_specific("v2.9.1"), "{tags:?}");
}
}
#[test]
fn test_tag_index_lowest_semver_release_wins_in_any_order() {
for (tags, expected) in [
(["v4.9.0", "v4.10.0"], "v4.9.0"),
(["v4.10.0", "v4.9.0"], "v4.9.0"),
(["v2.9", "v2.10"], "v2.9"),
(["v2.10", "v2.9"], "v2.9"),
(["v4.8.0", "v4.9.0"], "v4.8.0"),
(["v4.9.0", "v4.8.0"], "v4.8.0"),
] {
let pin = resolved_pin_for(&tags).unwrap();
assert_eq!(pin.version().as_str(), expected, "{tags:?}");
assert!(matches!(pin, ResolvedPin::MostSpecific { .. }), "{tags:?}");
}
}
#[test]
fn test_tag_index_prerelease_ordering_and_release_precedence() {
for tags in [
["v1.0.0-rc.2", "v1.0.0-rc.10"],
["v1.0.0-rc.10", "v1.0.0-rc.2"],
] {
assert_eq!(
resolved_pin_for(&tags),
most_specific("v1.0.0-rc.2"),
"{tags:?}"
);
}
assert_eq!(
resolved_pin_for(&["v4.10.0-rc1", "v4.9.0"]),
most_specific("v4.9.0")
);
}
#[test]
fn test_tag_index_non_semver_and_overflowing_components_stay_deterministic() {
for tags in [["nightly", "edge"], ["edge", "nightly"]] {
assert_eq!(resolved_pin_for(&tags), most_specific("edge"), "{tags:?}");
}
let huge = "v99999999999999999999999.1";
for tags in [[huge, "v2.9"], ["v2.9", huge]] {
assert_eq!(resolved_pin_for(&tags), most_specific("v2.9"), "{tags:?}");
}
}
#[test]
fn test_tag_index_full_semver_prerelease_beats_two_component() {
assert_eq!(
resolved_pin_for(&["v2.9", "v2.9.0-beta"]),
most_specific("v2.9.0-beta")
);
}
#[test]
fn test_tag_index_unprefixed_tags_rank_like_prefixed() {
for tags in [["2.9", "2"], ["2", "2.9"]] {
assert_eq!(resolved_pin_for(&tags), most_specific("2.9"), "{tags:?}");
}
assert_eq!(resolved_pin_for(&["2"]), most_specific("2"));
}
#[test]
fn test_tag_index_numeric_tag_beats_non_numeric_and_lone_non_numeric_wins() {
assert_eq!(resolved_pin_for(&["cargo-deny", "v2"]), most_specific("v2"));
assert_eq!(
resolved_pin_for(&["nightly", "v2.9"]),
most_specific("v2.9")
);
assert_eq!(resolved_pin_for(&["nightly"]), most_specific("nightly"));
}
#[test]
fn test_tag_index_empty_input_yields_empty_index() {
let index = TagIndex::from_tags(std::iter::empty());
assert!(index.is_empty());
assert_eq!(index.resolved_pin(&"a".repeat(40)), None);
}
#[test]
fn test_tag_index_classifies_each_sha_independently() {
let a = CommitSha::parse(&"a".repeat(40)).unwrap();
let b = CommitSha::parse(&"b".repeat(40)).unwrap();
let index = TagIndex::from_tags([("v2", &a), ("v2.9", &a), ("v3", &b)]);
assert_eq!(index.resolved_pin(a.as_str()), most_specific("v2.9"));
assert_eq!(index.resolved_pin(b.as_str()), most_specific("v3"));
}
#[test]
fn test_tag_index_alias_is_classified_and_insert_sha_pin_overrides() {
let sha = CommitSha::parse(&"a".repeat(40)).unwrap();
let mut index = TagIndex::from_tags([("v2.9", &sha), ("v2.9.1.4", &sha)]);
assert_eq!(
index.resolved_pin(sha.as_str()),
Some(ResolvedPin::alias(crate::ConcreteVersion::new("v2.9")))
);
assert_eq!(index.tag_for_sha(sha.as_str()), Some("v2.9"));
index.insert_sha_pin(
sha.clone(),
ResolvedPin::most_specific(crate::ConcreteVersion::new("v3.0")),
);
assert_eq!(index.resolved_pin(sha.as_str()), most_specific("v3.0"));
}
#[test]
fn test_extends_tag() {
assert!(extends_tag("v2.9", "v2"));
assert!(extends_tag("2.9.1", "v2.9"));
assert!(!extends_tag("v2.10", "v2.1"));
assert!(!extends_tag("v2", "v2"));
assert!(!extends_tag("v2", "v2.9"));
assert!(!extends_tag("v3.9", "v2"));
}
#[test]
fn test_tag_index_resolved_pin_none_for_unknown_sha() {
assert_eq!(TagIndex::default().resolved_pin(&"b".repeat(40)), None);
}
#[test]
fn test_is_tag_shaped() {
assert!(is_tag_shaped("v4"));
assert!(is_tag_shaped("4.2.0"));
assert!(!is_tag_shaped("main"));
assert!(!is_tag_shaped(&"a".repeat(40)));
}
#[test]
fn test_is_partial_semver_shaped_accepts_v_prefixed_at_any_precision() {
assert!(is_partial_semver_shaped("v4"));
assert!(is_partial_semver_shaped("v2.9"));
assert!(is_partial_semver_shaped("v4.2.0"));
assert!(is_partial_semver_shaped("v4.2.0-beta.1"));
assert!(is_partial_semver_shaped("v4.2.0+build.5"));
assert!(is_partial_semver_shaped("V4"));
}
#[test]
fn test_is_partial_semver_shaped_accepts_dotted_without_v_prefix() {
assert!(is_partial_semver_shaped("2.9"));
assert!(is_partial_semver_shaped("4.2.0"));
}
#[test]
fn test_is_partial_semver_shaped_rejects_bare_unprefixed_integer() {
assert!(!is_partial_semver_shaped("1234"));
assert!(!is_partial_semver_shaped("20240501"));
assert!(!is_partial_semver_shaped("0"));
}
#[test]
fn test_is_partial_semver_shaped_rejects_dash_separated_date_and_branch_names() {
assert!(!is_partial_semver_shaped("2024-01-15"));
assert!(!is_partial_semver_shaped("main"));
assert!(!is_partial_semver_shaped(&"a".repeat(40)));
}
#[test]
fn test_is_partial_semver_shaped_rejects_too_many_components() {
assert!(!is_partial_semver_shaped("v1.2.3.4"));
}
#[test]
fn test_match_v_prefix_style() {
assert_eq!(match_v_prefix_style("v4", "5.0.0"), "v5.0.0");
assert_eq!(match_v_prefix_style("4", "v5.0.0"), "5.0.0");
assert_eq!(match_v_prefix_style("v4", "v5.0.0"), "v5.0.0");
assert_eq!(match_v_prefix_style("4", "5.0.0"), "5.0.0");
}
#[test]
fn test_locate_value_span_finds_value_within_fallback_bound() {
let content = "prefix xxxxx actions/checkout@v4 suffix";
let value = "actions/checkout@v4";
let (start, end) = locate_value_span(content, 0, value, false).unwrap();
assert_eq!(&content[start..end], value);
}
#[test]
fn test_locate_value_span_out_of_bounds_search_from_rejected_even_with_empty_value() {
let content = "short";
assert_eq!(locate_value_span(content, usize::MAX, "", false), None);
assert_eq!(
locate_value_span(content, content.len() + 1, "", false),
None
);
}
#[test]
fn test_locate_value_span_empty_value_corrects_past_opening_double_quote() {
let content = r#"pkg: """#;
let quote_offset = content.find('"').unwrap();
let (start, end) = locate_value_span(content, quote_offset, "", true).unwrap();
assert_eq!(start, quote_offset + 1);
assert_eq!(end, quote_offset + 1);
}
#[test]
fn test_locate_value_span_empty_value_corrects_past_opening_single_quote() {
let content = "ref: ''";
let quote_offset = content.find('\'').unwrap();
let (start, end) = locate_value_span(content, quote_offset, "", true).unwrap();
assert_eq!(start, quote_offset + 1);
assert_eq!(end, quote_offset + 1);
}
#[test]
fn test_locate_value_span_empty_value_at_non_quote_position_is_unchanged() {
let content = "ref: ";
let end_offset = content.len();
let (start, end) = locate_value_span(content, end_offset, "", false).unwrap();
assert_eq!(start, end_offset);
assert_eq!(end, end_offset);
}
#[test]
fn test_locate_value_span_plain_empty_value_never_shifts_past_a_next_token_quote() {
let content = "ref: \n\"next-token\"";
let marker_offset = content.find('\n').unwrap() + 1;
assert_eq!(content.as_bytes()[marker_offset], b'"');
let (start, end) = locate_value_span(content, marker_offset, "", false).unwrap();
assert_eq!(start, marker_offset);
assert_eq!(end, marker_offset);
}
#[test]
fn test_locate_value_span_literal_style_empty_value_never_shifts_past_a_next_token_quote() {
let content = "ref: |\n\"next-token\"";
let marker_offset = content.find('\n').unwrap() + 1;
assert_eq!(content.as_bytes()[marker_offset], b'"');
let (start, end) = locate_value_span(content, marker_offset, "", false).unwrap();
assert_eq!(start, marker_offset);
assert_eq!(end, marker_offset);
}
#[test]
fn test_marked_scalar_span_quoted_empty_value_anchors_after_opening_quote() {
use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser};
struct Scalars(Vec<(String, TScalarStyle, Marker)>);
impl MarkedEventReceiver for Scalars {
fn on_event(&mut self, event: Event, marker: Marker) {
if let Event::Scalar(value, style, ..) = event {
self.0.push((value, style, marker));
}
}
}
let content = "pkg: \"\"\n";
let mut receiver = Scalars(Vec::new());
Parser::new_from_str(content)
.load(&mut receiver, false)
.unwrap();
let (value, style, marker) = receiver.0[1].clone();
assert_eq!(value, "");
let scalar = MarkedScalar::new(value, style, &marker);
let table = LineOffsetTable::new(content);
let (start, end) = scalar.span(content, &table).unwrap();
let quote_offset = content.find("\"\"").unwrap();
assert_eq!(
(start, end),
(quote_offset + 1, quote_offset + 1),
"expected the empty value's span to anchor between the quotes, not on the \
opening quote"
);
}
#[test]
fn test_marked_scalar_is_quoted_distinguishes_literal_from_plain_and_quoted() {
use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser};
struct Scalars(Vec<(String, TScalarStyle, Marker)>);
impl MarkedEventReceiver for Scalars {
fn on_event(&mut self, event: Event, marker: Marker) {
if let Event::Scalar(value, style, ..) = event {
self.0.push((value, style, marker));
}
}
}
let content = "ref: |\n";
let mut receiver = Scalars(Vec::new());
Parser::new_from_str(content)
.load(&mut receiver, false)
.unwrap();
let (value, style, marker) = receiver.0[1].clone();
assert_eq!(style, TScalarStyle::Literal);
let scalar = MarkedScalar::new(value, style, &marker);
assert!(!scalar.is_plain());
assert!(
!scalar.is_quoted(),
"a Literal block scalar is neither plain nor quoted"
);
}
#[test]
fn test_locate_value_span_gives_up_beyond_fallback_bound_instead_of_hanging() {
let filler = "x".repeat(MAX_FALLBACK_SCAN_BYTES + 100);
let value = "actions/checkout@v4";
let content = format!("{filler}{value}");
assert_eq!(locate_value_span(&content, 0, value, false), None);
}
#[test]
fn test_locate_value_span_bounded_scan_stays_fast_on_a_huge_line() {
let filler = "y".repeat(6 * 1024 * 1024);
let value = "not-present-in-filler@v4";
let content = format!("{filler}\n");
let start = std::time::Instant::now();
let result = locate_value_span(&content, 0, value, false);
assert!(
start.elapsed() < std::time::Duration::from_secs(1),
"locate_value_span took {:?}, expected a bounded scan to finish in well under 1s",
start.elapsed()
);
assert_eq!(result, None);
}
#[test]
fn test_locate_value_span_many_fallback_scans_on_one_huge_line_stay_bounded() {
let filler_segment = "z".repeat(64);
let mut content = String::new();
let mut offsets = Vec::new();
for _ in 0..2000 {
offsets.push(content.len());
content.push_str(&filler_segment);
}
content.push_str(&"w".repeat(8 * 1024 * 1024));
let value = "actions/checkout@v4";
let start = std::time::Instant::now();
for &offset in &offsets {
let _ = locate_value_span(&content, offset, value, false);
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(1),
"{} locate_value_span fallback calls against an 8MB-tail single line took \
{elapsed:?}; expected the pre-bound window to make each call's cost \
independent of the trailing content size",
offsets.len()
);
}
#[test]
fn test_marker_byte_offset_ascii() {
let content = "hello\nworld";
let table = LineOffsetTable::new(content);
assert_eq!(marker_byte_offset(content, &table, 1, 0), 0);
assert_eq!(marker_byte_offset(content, &table, 2, 3), 9);
}
#[test]
fn test_marker_byte_offset_multibyte() {
let content = "\u{3000}a\nb";
let table = LineOffsetTable::new(content);
assert_eq!(marker_byte_offset(content, &table, 1, 1), 3);
assert_eq!(marker_byte_offset(content, &table, 2, 0), 5);
}
#[test]
fn test_marker_byte_offset_block_scalar_drift_regression() {
let content = "run: |\n echo \u{2014} hi\nuses: actions/checkout@v4\n";
let table = LineOffsetTable::new(content);
let expected = content.find("actions/checkout@v4").unwrap();
assert_eq!(marker_byte_offset(content, &table, 3, 6), expected);
}
#[test]
fn test_marker_byte_offset_line_past_end_returns_content_len() {
let content = "hello";
let table = LineOffsetTable::new(content);
assert_eq!(marker_byte_offset(content, &table, 5, 0), content.len());
}
#[test]
fn test_marker_byte_offset_multiple_multibyte_chars_before_target_col() {
let content = "\u{2014}\u{2014}\u{3000}target";
let table = LineOffsetTable::new(content);
assert_eq!(marker_byte_offset(content, &table, 1, 3), 9);
}
#[test]
fn test_marker_byte_offset_col_past_end_of_line_clamps_to_line_end() {
let content = "ab\ncd";
let table = LineOffsetTable::new(content);
assert_eq!(marker_byte_offset(content, &table, 1, 100), 3);
}
#[test]
fn test_marker_byte_offset_ascii_fast_path_matches_char_indices_result() {
fn slow_path(content: &str, table: &LineOffsetTable, line: usize, col: usize) -> usize {
let Some(line_start) = table.line_start(line.saturating_sub(1)) else {
return content.len();
};
let line_end = table.line_start(line).unwrap_or(content.len());
let line_text = content.get(line_start..line_end).unwrap_or_default();
let byte_in_line = line_text
.char_indices()
.nth(col)
.map_or(line_text.len(), |(b, _)| b);
line_start + byte_in_line
}
let content = "uses: actions/checkout@v4\nref: v1.0.0\n";
let table = LineOffsetTable::new(content);
for col in 0..=35 {
assert_eq!(
marker_byte_offset(content, &table, 1, col),
slow_path(content, &table, 1, col),
"ascii fast path desynced from the general char_indices() walk at col {col}"
);
}
}
#[test]
fn test_marker_byte_offset_ascii_fast_path_stays_linear_on_a_huge_single_line() {
let filler = "x".repeat(6 * 1024 * 1024);
let table = LineOffsetTable::new(&filler);
let start = std::time::Instant::now();
for col in (0..filler.len()).step_by(filler.len() / 5000) {
assert_eq!(marker_byte_offset(&filler, &table, 1, col), col);
}
assert!(
start.elapsed() < std::time::Duration::from_secs(1),
"marker_byte_offset took {:?} for 5000 lookups on a huge ASCII line, expected well \
under 1s",
start.elapsed()
);
}
#[test]
fn test_marker_byte_offset_non_ascii_line_cache_stays_fast_on_a_huge_single_line() {
let filler = "x".repeat(200 * 1024);
let content = format!("{filler}\u{2014}{filler}");
let table = LineOffsetTable::new(&content);
let char_count = content.chars().count();
let start = std::time::Instant::now();
for col in (0..char_count).step_by((char_count / 5000).max(1)) {
let _ = marker_byte_offset(&content, &table, 1, col);
}
assert!(
start.elapsed() < std::time::Duration::from_secs(1),
"marker_byte_offset took {:?} for 5000 lookups on a huge non-ASCII line, expected \
well under 1s",
start.elapsed()
);
}
#[test]
fn test_marker_byte_offset_non_ascii_cache_matches_char_indices_result() {
fn slow_path(content: &str, table: &LineOffsetTable, line: usize, col: usize) -> usize {
let Some(line_start) = table.line_start(line.saturating_sub(1)) else {
return content.len();
};
let line_end = table.line_start(line).unwrap_or(content.len());
let line_text = content.get(line_start..line_end).unwrap_or_default();
let byte_in_line = line_text
.char_indices()
.nth(col)
.map_or(line_text.len(), |(b, _)| b);
line_start + byte_in_line
}
let content = "\u{1f680}rocket \u{2014} emoji then em dash \u{3000} and more";
let table = LineOffsetTable::new(content);
for col in 0..=(content.chars().count() + 5) {
assert_eq!(
marker_byte_offset(content, &table, 1, col),
slow_path(content, &table, 1, col),
"cached non-ASCII path desynced from the general char_indices() walk at col \
{col}"
);
}
}
#[test]
fn test_marker_byte_offset_multiple_non_ascii_lines_cache_isolation() {
fn slow_path(content: &str, table: &LineOffsetTable, line: usize, col: usize) -> usize {
let Some(line_start) = table.line_start(line.saturating_sub(1)) else {
return content.len();
};
let line_end = table.line_start(line).unwrap_or(content.len());
let line_text = content.get(line_start..line_end).unwrap_or_default();
let byte_in_line = line_text
.char_indices()
.nth(col)
.map_or(line_text.len(), |(b, _)| b);
line_start + byte_in_line
}
let content = "ascii only\n\u{2014}em dash line\n\u{1f680}rocket \u{3000}ideographic line\nascii again";
let table = LineOffsetTable::new(content);
let cases: &[(usize, usize)] = &[(3, 5), (2, 2), (3, 0), (2, 5), (3, 8), (2, 0)];
for &(line, col) in cases {
assert_eq!(
marker_byte_offset(content, &table, line, col),
slow_path(content, &table, line, col),
"line {line} col {col} desynced after interleaved multi-line lookups"
);
}
}
#[test]
fn test_marker_byte_offset_non_ascii_line_still_correct_without_fast_path() {
let content = "\u{1f680}rocket \u{2014} emoji then em dash";
let table = LineOffsetTable::new(content);
let expected = content.char_indices().nth(3).unwrap().0;
assert_eq!(marker_byte_offset(content, &table, 1, 3), expected);
}
#[test]
fn test_marker_byte_offset_lone_cr_document_documented_limitation() {
let content = "on: push\rjobs:\r build:\r steps:\r - uses: actions/checkout@v4\r";
let table = LineOffsetTable::new(content);
assert_eq!(marker_byte_offset(content, &table, 5, 14), content.len());
}
#[test]
fn test_tag_index_coverage_defaults_to_complete() {
assert_eq!(TagIndex::default().coverage(), ListCoverage::Complete);
assert_eq!(
TagIndex::from_tags(std::iter::empty()).coverage(),
ListCoverage::Complete
);
}
#[test]
fn test_tag_index_with_coverage_records_truncation() {
let index = TagIndex::default().with_coverage(ListCoverage::Truncated);
assert_eq!(index.coverage(), ListCoverage::Truncated);
}
#[cfg(feature = "lsp-responses")]
mod lsp_tests {
use super::*;
#[test]
fn splice_hover_line_anchor_priority_resolved_then_current_then_requirement() {
let with_resolved = "**Current**: `a`\n\n**Resolved**: `b`\n\nBody.";
assert_eq!(
splice_hover_line(with_resolved, "W"),
"**Current**: `a`\n\n**Resolved**: `b`\n\nW\n\nBody."
);
assert_eq!(
splice_hover_line("**Requirement**: `a`\n\nBody.", "W"),
"**Requirement**: `a`\n\nW\n\nBody."
);
assert_eq!(splice_hover_line("Body.", "W"), "Body.W\n\n");
}
#[test]
fn splice_resolved_line_truncates_overlong_resolved_tag() {
let long_tag = "9".repeat(5000);
let sha = "a".repeat(40);
let out = splice_resolved_line("", &long_tag, &sha);
assert!(out.len() < long_tag.len(), "got: {out}");
assert!(out.contains('…'));
}
#[test]
fn splice_resolved_line_boundary_at_and_over_cap() {
let cap = MAX_VERSION_DIAGNOSTIC_CHARS;
let sha = "a".repeat(40);
let at_cap = "9".repeat(cap);
let out = splice_resolved_line("", &at_cap, &sha);
assert!(out.contains(&format!("`{at_cap}`")), "got: {out}");
let over_cap = "9".repeat(cap + 1);
let out = splice_resolved_line("", &over_cap, &sha);
assert!(
out.contains(&format!("`{}…`", "9".repeat(cap))),
"got: {out}"
);
}
#[test]
fn splice_resolved_line_strips_u0600_from_resolved_tag() {
let sha = "a".repeat(40);
let out = splice_resolved_line("", &format!("v1.0{}0", '\u{0600}'), &sha);
assert!(
!out.contains('\u{0600}'),
"U+0600 must be stripped from resolved_tag; got: {out}"
);
}
use crate::lsp_helpers::test_support::MOCK_FORMATTER;
use crate::position::Position as CorePosition;
use crate::test_util::StubFormatter;
use crate::{PackageName, VersionReq};
impl ShaPinning for StubFormatter {
fn resolve_static_sha_pin(&self, dep: &dyn Dependency) -> Option<ResolvedShaPin> {
if dep.name().as_str() != "resolvable" {
return None;
}
Some(ResolvedShaPin {
display_name: dep.name().as_str().to_string(),
version_range: dep.version_range()?,
replacement: "a".repeat(40),
})
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "fixed short ASCII test-fixture names never approach u32::MAX"
)]
fn sha_pin_test_dep(name: &str) -> crate::lsp_helpers::test_support::MockDep {
let range = Range::new(
CorePosition::new(0, 6),
CorePosition::new(0, 6 + name.len() as u32),
);
crate::lsp_helpers::test_support::MockDep {
name: PackageName::new(name),
version_req: VersionReq::new("v1"),
version_range: range,
name_range: range,
}
}
#[test]
fn test_build_sha_pin_action_resolves_at_position() {
let dep = sha_pin_test_dep("resolvable");
let uri = crate::test_util::test_uri("/repo/manifest.yml");
let parse_result = crate::lsp_helpers::test_support::MockParseResult {
deps: vec![dep],
uri: uri.clone(),
};
let position = Position {
line: 0,
character: 7,
};
let action = build_sha_pin_action(
&parse_result,
position,
&uri,
&MOCK_FORMATTER,
"TEST_DIAGNOSTIC_CODE",
)
.expect("resolvable dependency at position must produce a quickfix");
assert_eq!(action.title, "Pin resolvable to commit SHA");
let edits = action
.edit
.expect("quickfix must carry a WorkspaceEdit")
.changes
.expect("WorkspaceEdit must carry changes");
let text_edits = edits.values().next().expect("one file's edits");
assert_eq!(text_edits.len(), 1);
assert_eq!(text_edits[0].new_text, "a".repeat(40));
}
#[test]
fn test_build_sha_pin_action_none_when_pinning_declines() {
let dep = sha_pin_test_dep("not-resolvable");
let uri = crate::test_util::test_uri("/repo/manifest.yml");
let parse_result = crate::lsp_helpers::test_support::MockParseResult {
deps: vec![dep],
uri: uri.clone(),
};
let position = Position {
line: 0,
character: 7,
};
assert!(
build_sha_pin_action(
&parse_result,
position,
&uri,
&MOCK_FORMATTER,
"TEST_DIAGNOSTIC_CODE",
)
.is_none()
);
}
#[test]
fn test_build_sha_pin_action_none_when_position_off_dependency() {
let dep = sha_pin_test_dep("resolvable");
let uri = crate::test_util::test_uri("/repo/manifest.yml");
let parse_result = crate::lsp_helpers::test_support::MockParseResult {
deps: vec![dep],
uri: uri.clone(),
};
let position = Position {
line: 5,
character: 0,
};
assert!(
build_sha_pin_action(
&parse_result,
position,
&uri,
&MOCK_FORMATTER,
"TEST_DIAGNOSTIC_CODE",
)
.is_none()
);
}
#[test]
fn test_sha_pin_text_edit_resolves() {
let dep = sha_pin_test_dep("resolvable");
let edit = sha_pin_text_edit(&MOCK_FORMATTER, &dep)
.expect("resolvable dependency must resolve");
assert_eq!(edit.new_text, "a".repeat(40));
assert_eq!(edit.range, dep.version_range.into());
}
#[test]
fn test_sha_pin_text_edit_none_when_pinning_declines() {
let dep = sha_pin_test_dep("not-resolvable");
assert!(sha_pin_text_edit(&MOCK_FORMATTER, &dep).is_none());
}
crate::debug_redaction_conformance!(
test_resolved_sha_pin_debug_redacts_credentials,
1,
ResolvedShaPin {
display_name: crate::conformance::CREDENTIAL_PROBE_KEY.to_string(),
version_range: Range::default(),
replacement: "a".repeat(40),
},
);
}
}