use super::LineOffsetTable;
use crate::position::Range;
use yaml_rust2::parser::Tag;
use yaml_rust2::scanner::{Marker, TScalarStyle};
#[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 is_full_sha(s: &str) -> bool {
s.len() == SHA_LEN && s.bytes().all(|b| b.is_ascii_hexdigit())
}
#[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(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedShaPin {
pub display_name: String,
pub version_range: Range,
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")]
#[expect(
clippy::string_slice,
reason = "sha.get(..7) already guards non-ASCII input; 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_resolved_line(markdown: &str, resolved_tag: &str, sha: &str) -> String {
let short_sha = sha.get(..7).unwrap_or(sha);
let line = format!(
"**Resolved**: {} ({})\n\n",
markdown_code_span(resolved_tag),
markdown_code_span(&format!("{short_sha}…"))
);
for anchor in ["**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());
out.push_str(&markdown[..insert_at]);
out.push_str(&line);
out.push_str(&markdown[insert_at..]);
return out;
}
}
format!("{markdown}{line}")
}
#[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_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());
}
#[cfg(feature = "lsp-responses")]
use crate::lsp_helpers::test_support::MockFormatter;
#[cfg(feature = "lsp-responses")]
use crate::position::Position as CorePosition;
#[cfg(feature = "lsp-responses")]
use crate::{PackageName, VersionReq};
#[cfg(feature = "lsp-responses")]
impl ShaPinning for MockFormatter {
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),
})
}
}
#[cfg(feature = "lsp-responses")]
#[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,
}
}
#[cfg(feature = "lsp-responses")]
#[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,
&MockFormatter,
"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));
}
#[cfg(feature = "lsp-responses")]
#[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,
&MockFormatter,
"TEST_DIAGNOSTIC_CODE",
)
.is_none()
);
}
#[cfg(feature = "lsp-responses")]
#[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,
&MockFormatter,
"TEST_DIAGNOSTIC_CODE",
)
.is_none()
);
}
#[cfg(feature = "lsp-responses")]
#[test]
fn test_sha_pin_text_edit_resolves() {
let dep = sha_pin_test_dep("resolvable");
let edit =
sha_pin_text_edit(&MockFormatter, &dep).expect("resolvable dependency must resolve");
assert_eq!(edit.new_text, "a".repeat(40));
assert_eq!(edit.range, dep.version_range.into());
}
#[cfg(feature = "lsp-responses")]
#[test]
fn test_sha_pin_text_edit_none_when_pinning_declines() {
let dep = sha_pin_test_dep("not-resolvable");
assert!(sha_pin_text_edit(&MockFormatter, &dep).is_none());
}
}