Skip to main content

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}