deps_core/lsp_helpers/git_ref.rs
1//! Shared git-tags-datasource parser scaffolding, extracted from
2//! `deps-github-actions`'s originally crate-private `parser.rs`/`formatter.rs` helpers so a
3//! second git-tags-shaped ecosystem (GitLab CI) can reuse the same hardened span/text
4//! plumbing instead of forking it. `deps-github-actions` now imports these instead of
5//! defining them locally.
6
7/// Length of a full, lowercase-or-not hex commit SHA (git's SHA-1 object id).
8const SHA_LEN: usize = 40;
9
10/// Whether `s` is a 40-character hex string — a git commit SHA shape, shared by every
11/// ecosystem resolving refs against a git-tags-datasource API (GitHub, GitLab).
12///
13/// # Examples
14///
15/// ```
16/// use deps_core::lsp_helpers::is_full_sha;
17///
18/// assert!(is_full_sha(&"a".repeat(40)));
19/// assert!(!is_full_sha(&"a".repeat(39)));
20/// assert!(!is_full_sha("not-a-sha"));
21/// ```
22#[must_use]
23pub fn is_full_sha(s: &str) -> bool {
24 s.len() == SHA_LEN && s.bytes().all(|b| b.is_ascii_hexdigit())
25}
26
27/// Whether `s` has the shape of a tag ref: an optional leading `v`/`V` followed by a digit.
28///
29/// Anything else (that isn't an [`is_full_sha`] SHA) is treated as a branch name — the
30/// "honest unknown" side, since a branch cannot be resolved to a concrete version without
31/// registry access.
32///
33/// # Examples
34///
35/// ```
36/// use deps_core::lsp_helpers::is_tag_shaped;
37///
38/// assert!(is_tag_shaped("v4"));
39/// assert!(is_tag_shaped("4.2.0"));
40/// assert!(!is_tag_shaped("main"));
41/// assert!(!is_tag_shaped(&"a".repeat(40)));
42/// ```
43#[must_use]
44pub fn is_tag_shaped(s: &str) -> bool {
45 if is_full_sha(s) {
46 return false;
47 }
48 let stripped = s.strip_prefix(['v', 'V']).unwrap_or(s);
49 stripped.starts_with(|c: char| c.is_ascii_digit())
50}
51
52/// Rewrites `tag` to match `current`'s leading `v`/`V` prefix style (or lack of one).
53///
54/// A repository/project can change its tagging convention over time (`4.0.0` -> `v5.0.0`);
55/// a formatted replacement should still read naturally against the user's existing pin
56/// style rather than silently flipping it.
57///
58/// # Examples
59///
60/// ```
61/// use deps_core::lsp_helpers::match_v_prefix_style;
62///
63/// assert_eq!(match_v_prefix_style("v4", "5.0.0"), "v5.0.0");
64/// assert_eq!(match_v_prefix_style("4", "v5.0.0"), "5.0.0");
65/// ```
66#[must_use]
67pub fn match_v_prefix_style(current: &str, tag: &str) -> String {
68 let current_has_v = current.starts_with(['v', 'V']);
69 let tag_has_v = tag.starts_with(['v', 'V']);
70 match (current_has_v, tag_has_v) {
71 (true, false) => format!("v{tag}"),
72 (false, true) => tag[1..].to_string(),
73 _ => tag.to_string(),
74 }
75}
76
77/// Maps a `yaml-rust2` char index to a byte offset in `content`, and a byte offset to the
78/// end of its containing line.
79///
80/// `yaml_rust2::scanner::Marker::index()` increments once per `char` consumed by the
81/// scanner (it is built over `Parser::new_from_str`'s `str::chars()` iterator) — despite
82/// its own doc comment claiming "in bytes", it is a **character** index, verified against
83/// `yaml-rust2` 0.12's `Scanner::skip_non_blank`/`skip_blank` (`self.mark.index += 1` per
84/// char, not per byte). For ASCII-only content the two coincide, but non-ASCII text
85/// upstream of a value would silently desync every downstream byte-offset computation
86/// without this table.
87pub struct CharOffsets {
88 byte_of_char: Vec<usize>,
89}
90
91impl CharOffsets {
92 /// Builds the table for `content`.
93 #[must_use]
94 pub fn new(content: &str) -> Self {
95 let mut byte_of_char: Vec<usize> = content.char_indices().map(|(b, _)| b).collect();
96 byte_of_char.push(content.len());
97 Self { byte_of_char }
98 }
99
100 /// Converts a `yaml-rust2` marker char index into a byte offset in the content this
101 /// table was built from.
102 #[must_use]
103 pub fn byte_offset(&self, char_index: usize) -> usize {
104 self.byte_of_char
105 .get(char_index)
106 .copied()
107 .unwrap_or(*self.byte_of_char.last().unwrap_or(&0))
108 }
109}
110
111/// Upper bound, in bytes past `search_from`, on how far [`locate_value_span`]'s fallback
112/// scan will search.
113///
114/// The fallback exists only to correct for `yaml-rust2`'s marker-vs-value quoting offset —
115/// a handful of bytes at most for any real manifest value. Leaving the scan unbounded made
116/// it an `O(line_length x value_length)` scan over the *rest of the line* regardless of how
117/// far away the real match could possibly be: a several-megabyte single-line manifest
118/// (comfortably under the crate's YAML expansion-size gate) could cost whole minutes of
119/// single-core CPU per `didOpen`/`didChange` (security S-2). Capping the window bounds the
120/// fallback's cost independent of line length; a value that genuinely cannot be located
121/// within this window is treated the same as any other unlocatable value — the candidate is
122/// silently skipped, not an error.
123pub const MAX_FALLBACK_SCAN_BYTES: usize = 1024;
124
125/// Finds the byte offset in `content` (searching only within the line starting at
126/// `search_from`) where the literal bytes of `value` occur.
127///
128/// The scanner-reported marker usually points exactly at the value's start for a plain
129/// scalar, but may point at the opening quote for a quoted one — rather than
130/// reverse-engineering `yaml-rust2`'s exact escaping/quoting byte accounting, this verifies
131/// the direct-offset guess first and falls back to a bounded same-line search (see
132/// [`MAX_FALLBACK_SCAN_BYTES`]), which is exact for the unescaped ASCII text most manifest
133/// values are.
134///
135/// # Examples
136///
137/// ```
138/// use deps_core::lsp_helpers::locate_value_span;
139///
140/// let content = "prefix xxxxx actions/checkout@v4 suffix";
141/// let (start, end) = locate_value_span(content, 0, "actions/checkout@v4").unwrap();
142/// assert_eq!(&content[start..end], "actions/checkout@v4");
143/// ```
144#[must_use]
145pub fn locate_value_span(content: &str, search_from: usize, value: &str) -> Option<(usize, usize)> {
146 if value.is_empty() {
147 return Some((search_from, search_from));
148 }
149 let bytes = content.as_bytes();
150 if search_from + value.len() <= bytes.len()
151 && &bytes[search_from..search_from + value.len()] == value.as_bytes()
152 {
153 return Some((search_from, search_from + value.len()));
154 }
155 let line_end = bytes[search_from..]
156 .iter()
157 .position(|&b| b == b'\n')
158 .map_or(bytes.len(), |p| search_from + p);
159 let scan_end = line_end.min(search_from.saturating_add(MAX_FALLBACK_SCAN_BYTES));
160 let haystack = &bytes[search_from..scan_end];
161 let needle = value.as_bytes();
162 haystack
163 .windows(needle.len())
164 .position(|w| w == needle)
165 .map(|rel| (search_from + rel, search_from + rel + needle.len()))
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn test_is_full_sha_accepts_and_rejects() {
174 assert!(is_full_sha(&"a".repeat(40)));
175 assert!(!is_full_sha(&"a".repeat(39)));
176 assert!(!is_full_sha(&"g".repeat(40)));
177 }
178
179 #[test]
180 fn test_is_tag_shaped() {
181 assert!(is_tag_shaped("v4"));
182 assert!(is_tag_shaped("4.2.0"));
183 assert!(!is_tag_shaped("main"));
184 assert!(!is_tag_shaped(&"a".repeat(40)));
185 }
186
187 #[test]
188 fn test_match_v_prefix_style() {
189 assert_eq!(match_v_prefix_style("v4", "5.0.0"), "v5.0.0");
190 assert_eq!(match_v_prefix_style("4", "v5.0.0"), "5.0.0");
191 assert_eq!(match_v_prefix_style("v4", "v5.0.0"), "v5.0.0");
192 assert_eq!(match_v_prefix_style("4", "5.0.0"), "5.0.0");
193 }
194
195 #[test]
196 fn test_locate_value_span_finds_value_within_fallback_bound() {
197 let content = "prefix xxxxx actions/checkout@v4 suffix";
198 let value = "actions/checkout@v4";
199 let (start, end) = locate_value_span(content, 0, value).unwrap();
200 assert_eq!(&content[start..end], value);
201 }
202
203 #[test]
204 fn test_locate_value_span_gives_up_beyond_fallback_bound_instead_of_hanging() {
205 let filler = "x".repeat(MAX_FALLBACK_SCAN_BYTES + 100);
206 let value = "actions/checkout@v4";
207 let content = format!("{filler}{value}");
208 assert_eq!(locate_value_span(&content, 0, value), None);
209 }
210
211 #[test]
212 fn test_locate_value_span_bounded_scan_stays_fast_on_a_huge_line() {
213 // Regression guard for the quadratic blowup itself (security S-2): a
214 // several-megabyte single-line haystack (well under the crate's YAML
215 // expansion-size gate) must resolve in milliseconds, not minutes, once the scan
216 // is bounded.
217 let filler = "y".repeat(6 * 1024 * 1024);
218 let value = "not-present-in-filler@v4";
219 let content = format!("{filler}\n");
220 let start = std::time::Instant::now();
221 let result = locate_value_span(&content, 0, value);
222 assert!(
223 start.elapsed() < std::time::Duration::from_secs(1),
224 "locate_value_span took {:?}, expected a bounded scan to finish in well under 1s",
225 start.elapsed()
226 );
227 assert_eq!(result, None);
228 }
229
230 #[test]
231 fn test_char_offsets_byte_offset_ascii() {
232 let offsets = CharOffsets::new("hello");
233 assert_eq!(offsets.byte_offset(0), 0);
234 assert_eq!(offsets.byte_offset(5), 5);
235 }
236
237 #[test]
238 fn test_char_offsets_byte_offset_multibyte() {
239 let content = "\u{3000}a";
240 let offsets = CharOffsets::new(content);
241 // U+3000 is 3 bytes; the second char ('a') starts at byte 3.
242 assert_eq!(offsets.byte_offset(1), 3);
243 }
244}