use super::LineOffsetTable;
use tower_lsp_server::ls_types::Range;
use yaml_rust2::scanner::{Marker, TScalarStyle};
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())
}
#[allow(clippy::string_slice)]
#[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()))
}
#[allow(clippy::string_slice)]
#[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 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) -> 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() {
return Some((search_from, search_from));
}
#[allow(clippy::indexing_slicing)]
{
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()));
}
}
#[allow(clippy::indexing_slicing)]
let window_end = bytes
.len()
.min(search_from.saturating_add(MAX_FALLBACK_SCAN_BYTES));
#[allow(clippy::indexing_slicing)]
let scan_end = bytes[search_from..window_end]
.iter()
.position(|&b| b == b'\n')
.map_or(window_end, |p| search_from + p);
#[allow(clippy::indexing_slicing)]
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 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)
}
#[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(test)]
#[allow(clippy::string_slice)]
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).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, ""), None);
assert_eq!(locate_value_span(content, content.len() + 1, ""), None);
}
#[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), 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);
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);
}
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());
}
}