fxrank_lang_python/source.rs
1/// Parse-position utilities for the Python frontend.
2///
3/// # BOM invariant
4///
5/// The frontend strips any leading UTF-8 BOM (`\u{feff}`) exactly once via
6/// `strip_bom` at the entry point, then uses that single stripped `&str` for
7/// all three consumers: `parse_module`, `tokenize`/`lambda_anchors`, and the
8/// pointer-arithmetic in `anchor_of_subslice`. Passing the same buffer to all
9/// three keeps every byte offset consistent — libcst's `byte_idx()` values,
10/// the token-stream positions, and `SpanIndex` line lookups all agree.
11use libcst_native::tokenize;
12
13/// Precomputed line-start byte offsets. The line is found in O(log n) (binary
14/// search over the line starts); the 1-based **char** column is then O(line length)
15/// (`chars().count()` over the line prefix, so multi-byte chars count as one).
16pub struct SpanIndex<'a> {
17 src: &'a str,
18 line_starts: Vec<usize>, // byte offset of the start of each line (line 1 = index 0)
19}
20
21impl<'a> SpanIndex<'a> {
22 pub fn new(src: &'a str) -> Self {
23 let mut line_starts = vec![0usize];
24 for (i, b) in src.bytes().enumerate() {
25 if b == b'\n' {
26 line_starts.push(i + 1);
27 }
28 }
29 SpanIndex { src, line_starts }
30 }
31
32 /// The source buffer this index was built from (for `anchor_of_subslice`).
33 pub fn src(&self) -> &'a str {
34 self.src
35 }
36
37 /// 1-based line, 1-based **char** column for a byte offset (`usize`, matching core).
38 pub fn line_col(&self, byte_off: usize) -> (usize, usize) {
39 let line_idx = match self.line_starts.binary_search(&byte_off) {
40 Ok(i) => i,
41 Err(i) => i - 1,
42 };
43 let line_start = self.line_starts[line_idx];
44 let col_chars = self.src[line_start..byte_off].chars().count();
45 (line_idx + 1, col_chars + 1)
46 }
47}
48
49/// Byte offset of a `&str` that is a subslice of `src` (pointer arithmetic).
50///
51/// **Precondition:** `sub` MUST point into `src`'s buffer (e.g. a libcst node's
52/// borrowed `&str` taken from the same parsed source). Passing an unrelated `&str`
53/// yields a meaningless offset. `pub(crate)` so this can't be misused from outside.
54pub(crate) fn anchor_of_subslice(src: &str, sub: &str) -> usize {
55 sub.as_ptr() as usize - src.as_ptr() as usize
56}
57
58/// (line, 1-based char col) of each `lambda` keyword token, in source order.
59///
60/// Returns `Some(anchors)` on success and `None` if tokenization fails.
61///
62/// # Precondition
63/// `src` MUST be the **same** (BOM-stripped) buffer that `parse_module` already
64/// accepted. Tokenizing is a strict subset of parsing, so any `src` that parsed
65/// also tokenizes; in practice this function therefore always returns `Some(…)`.
66/// The `None` branch exists to close the silent-drop hole that the old
67/// `unwrap_or_default()` created: if tokenization ever fails we now propagate the
68/// failure to the caller (`PythonFrontend::analyze`) so it can emit a `Diagnostic`
69/// and skip the file rather than silently emitting zero anchors and misattributing
70/// (or omitting) every lambda.
71///
72/// # Double-tokenize elimination
73/// The caller passes the returned `&[(usize, usize)]` slice directly into
74/// `functions::collect`, so tokenization happens **exactly once per file** — the
75/// old design called `lambda_anchors` a second time in `PythonFrontend::analyze`
76/// for the mismatch guard, producing two tokenizer passes per file.
77pub fn lambda_anchors(src: &str) -> Option<Vec<(usize, usize)>> {
78 tokenize(src)
79 .map(|toks| {
80 toks.iter()
81 .filter(|t| t.string == "lambda")
82 .map(|t| {
83 (
84 t.start_pos.line_number(),
85 t.start_pos.char_column_number() + 1,
86 )
87 })
88 .collect()
89 })
90 .ok()
91}
92
93/// Strip a leading UTF-8 BOM (`\u{feff}`) from `src`, if present.
94///
95/// Pass the result consistently to `parse_module`, `anchor_of_subslice`, and
96/// `SpanIndex::new` so that all byte offsets are relative to the same buffer.
97pub fn strip_bom(src: &str) -> &str {
98 src.strip_prefix('\u{feff}').unwrap_or(src)
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
106 fn line_col_counts_chars_not_bytes() {
107 let src = "x = 'é'\ndef f():\n pass\n"; // 'é' is 2 bytes, 1 char
108 let idx = SpanIndex::new(src);
109 let byte_off = src.find("def").unwrap();
110 assert_eq!(idx.line_col(byte_off), (2, 1)); // line 2, char col 1
111 }
112
113 #[test]
114 fn anchor_of_subslice_is_exact() {
115 let src = "def greet():\n pass\n";
116 let name = &src[4..9]; // "greet"
117 assert_eq!(anchor_of_subslice(src, name), 4);
118 }
119
120 #[test]
121 fn lambda_anchors_in_source_order() {
122 let src = "a = lambda: 1\nb = lambda y: y\n";
123 let anchors = lambda_anchors(src).expect("tokenize must succeed on valid Python");
124 assert_eq!(anchors, vec![(1, 5), (2, 5)]); // both `lambda` at char col 5
125 }
126
127 #[test]
128 fn strip_bom_removes_bom() {
129 let with_bom = "\u{feff}def f(): pass\n";
130 assert_eq!(strip_bom(with_bom), "def f(): pass\n");
131 }
132
133 #[test]
134 fn strip_bom_noop_without_bom() {
135 let src = "def f(): pass\n";
136 assert_eq!(strip_bom(src), src);
137 }
138}