sui_eval/pos.rs
1//! Source positions for `builtins.unsafeGetAttrPos` (and `__curPos`).
2//!
3//! CppNix records, for every attribute-set binding, the source position of
4//! its KEY — file + line + column. `builtins.unsafeGetAttrPos name set`
5//! returns `{ file; line; column; }` for the key `name` in `set` (or `null`
6//! when the key/position is unknown). nixpkgs `lib/types.nix`'s `attrTag`
7//! computes each tag's `declarations` from `[ pos.file ]`, so a stub that
8//! returns `null` makes every `attrTag` sub-option's `declarations` empty —
9//! the `options.json` dock-declarations byte-parity divergence (the six
10//! `system.defaults.dock.persistent-{apps,others}.*` fields).
11//!
12//! This module gives each attrset built from a LITERAL (`eval_attrset`) an
13//! optional [`AttrPositions`] table — its static keys' byte offsets keyed by
14//! interned `Symbol`, plus the `source_id` of the parse tree it came from —
15//! and a per-`source_id` [`SourceInfo`] registry (file path + full text) so
16//! an offset resolves to a 1-based line/column.
17//!
18//! The reported `.file` is passed through [`crate::path::dematerialize`] so a
19//! fetched flake input's cache-dir path is lifted to its
20//! `/nix/store/<h>-source` store path — the reverse of the read-time
21//! `materialize` redirect. That is what makes nix-darwin's `doc/manual`
22//! `hasPrefix <nix-darwin>.outPath decl` rewrite fire (`decl` must carry the
23//! store prefix), producing the `<nix-darwin/…>` declaration entries.
24//!
25//! Everything here only produces a REPORTED value on an explicit
26//! `unsafeGetAttrPos` call; no value the evaluator observes elsewhere is
27//! mutated — the byte-parity invariant.
28
29use std::cell::RefCell;
30use std::path::{Path, PathBuf};
31use std::rc::Rc;
32
33use rustc_hash::FxHashMap;
34use sui_intern::Symbol;
35
36/// The static keys of one attrset literal, keyed by interned `Symbol`, with
37/// each key's byte offset into the source text of `file`.
38///
39/// Attached (as `Option<Rc<AttrPositions>>`) only to attrsets built by
40/// `eval_attrset` from a literal with static (`Ident`/`Str`) keys — `None`
41/// for the vast majority of attrsets (merges, overlays, builtin-built,
42/// dynamic-key). Shared behind `Rc` so cloning an attrset is a refcount
43/// bump, never a map copy.
44///
45/// `file` is the file the literal was built in — captured at
46/// `eval_attrset`-force time from the evaluator's eval-file stack (which a
47/// thunk restores to its captured file when it forces), NOT the current
48/// parse tree. A lazily-forced attrset literal from `dock.nix` therefore
49/// records `dock.nix`, not whatever file happened to be top-of-stack.
50#[derive(Debug, Default)]
51pub struct AttrPositions {
52 /// File the literal was built in (store-path-prefixed for imported
53 /// inputs), or `None` for a `<string>`-eval'd literal.
54 pub file: Option<PathBuf>,
55 /// Key symbol → byte offset of the key token in the source text.
56 pub keys: FxHashMap<Symbol, u32>,
57}
58
59impl AttrPositions {
60 /// Start an empty table for a literal built in `file`.
61 #[must_use]
62 pub fn new(file: Option<PathBuf>) -> Self {
63 Self {
64 file,
65 keys: FxHashMap::default(),
66 }
67 }
68
69 /// Record a static key's byte offset.
70 pub fn insert(&mut self, key: Symbol, offset: u32) {
71 self.keys.insert(key, offset);
72 }
73
74 /// Whether any key positions were recorded (a set of only dynamic/dotted
75 /// keys records nothing).
76 #[must_use]
77 pub fn is_empty(&self) -> bool {
78 self.keys.is_empty()
79 }
80}
81
82thread_local! {
83 /// Canonicalized file path → its full source text, registered once per
84 /// `eval_with_file` (each file is parsed once and cached by
85 /// `IMPORT_CACHE`, so one text per path). Used to resolve a key's byte
86 /// offset to a 1-based line/column.
87 static SOURCE_TEXTS: RefCell<FxHashMap<PathBuf, Rc<str>>> =
88 RefCell::new(FxHashMap::default());
89}
90
91/// Register a file's source text so a key offset in that file resolves to a
92/// line/column. Called by `eval_with_file`. A `None` file (a `<string>`
93/// eval) registers nothing (it has no reportable position).
94/// `(file count, total source bytes)` currently retained by `SOURCE_TEXTS`.
95///
96/// The registry keeps every parsed file's FULL TEXT for the process lifetime so
97/// `unsafeGetAttrPos` can resolve an offset to a line/column. That is a real
98/// retainer nothing counted, and it is a proxy for a much bigger one: the rowan
99/// GREEN TREE parsed from each of those files, held by `IMPORT_CACHE` and by
100/// every unforced `Suspended { expr, .. }` thunk. `rnix::ast::Expr` measures
101/// 16 B only because it is a handle into that tree.
102///
103/// Backed by GLOBAL atomics, not by reading `SOURCE_TEXTS` directly: that map is
104/// a `thread_local`, and the census's exit dump runs on the periodic-dump
105/// THREAD, where it is empty. Reading it there reported `src_files=0` for an
106/// evaluation that had parsed thousands of files — a cross-thread read of
107/// thread-local state, indistinguishable from "nothing was registered".
108pub(crate) static SRC_FILES: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
109pub(crate) static SRC_BYTES: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
110
111#[must_use]
112pub fn source_text_census() -> (usize, usize) {
113 use std::sync::atomic::Ordering::Relaxed;
114 (
115 SRC_FILES.load(Relaxed).max(0) as usize,
116 SRC_BYTES.load(Relaxed).max(0) as usize,
117 )
118}
119
120pub fn register_source(file: Option<&Path>, text: &str) {
121 let Some(file) = file else { return };
122 SOURCE_TEXTS.with(|s| {
123 let mut s = s.borrow_mut();
124 // Only store the text the first time a path is seen (identical on
125 // re-parse; avoids re-allocating the `Rc<str>` on cache-cold imports).
126 use std::sync::atomic::Ordering::Relaxed;
127 if !s.contains_key(file) {
128 SRC_FILES.fetch_add(1, Relaxed);
129 SRC_BYTES.fetch_add(text.len() as i64, Relaxed);
130 }
131 s.entry(file.to_path_buf())
132 .or_insert_with(|| Rc::from(text));
133 });
134}
135
136/// Clear the source-text registry. Called between independent top-level
137/// evaluations (alongside the ident-cache clear) so a stale path→text entry
138/// from a prior pass doesn't persist.
139pub fn clear_sources() {
140 SOURCE_TEXTS.with(|s| s.borrow_mut().clear());
141}
142
143/// Fetch a registered file's source text (an `Rc<str>` clone), if any.
144fn text_for(file: &Path) -> Option<Rc<str>> {
145 SOURCE_TEXTS.with(|s| s.borrow().get(file).cloned())
146}
147
148/// A resolved source position: the file (store-source-lifted) + 1-based
149/// line and column — the shape `unsafeGetAttrPos` returns.
150pub struct ResolvedPos {
151 pub file: String,
152 pub line: u64,
153 pub column: u64,
154}
155
156/// Resolve `(file, offset)` to a [`ResolvedPos`] — the file is lifted from a
157/// fetcher-cache path to its `/nix/store/<h>-source` store path via
158/// [`crate::path::dematerialize`]. Returns `None` when `file` is `None` (a
159/// `<string>` eval, no position) or the file was never parsed (no source
160/// text registered — the attrset can't have originated in a real file).
161///
162/// LINE/COLUMN are resolved against the file's text, 1-based, with BYTE
163/// columns.
164///
165/// This used to return `line = 1, column = byte_offset + 1` unconditionally,
166/// documented as CppNix's "observed" behaviour and "verified against `nix
167/// eval`" with the fixture below. It was not verified — the cited numbers are
168/// this function's OWN output, recorded as if they were the oracle's, and two
169/// unit tests pinned them green. Re-measured against nix 2.31.5 on exactly
170/// that fixture (`{\n aaaaa = 1;\n bbbbb = 2;\n ccccc = 3;\n}`):
171///
172/// ```text
173/// nix sui (before)
174/// aaaaa 2:3 1:5
175/// bbbbb 3:3 1:18
176/// ccccc 4:3 1:31
177/// ```
178///
179/// Columns count BYTES, not characters — measured: with a 2-byte `é` earlier
180/// on the line, CppNix's column advances by 2. A tab advances by 1; `\r` is
181/// not special.
182///
183/// The recorded OFFSETS were always correct: normalising both engines back to
184/// `base_of_line(line) + column - 1` agreed on 30/30 non-null rows (quoted,
185/// escaped, unicode and tab-indented keys; keys after comments; cross-file and
186/// `toFile` store-path sets). Only this mapping step was missing.
187#[must_use]
188pub fn resolve(file: Option<&Path>, offset: u32) -> Option<ResolvedPos> {
189 // CppNix has no position for a `<string>`-eval'd expression (no file);
190 // such an attrset yields `null` from `unsafeGetAttrPos`.
191 let file_path = file?;
192 // Existence check only: an attrset with a position table originated in a
193 // parsed file, so its text is registered. A missing entry means the
194 // position can't be trusted → `null` (matches CppNix's unknown-pos).
195 let text = text_for(file_path)?;
196 let file = crate::path::dematerialize(file_path)
197 .to_string_lossy()
198 .into_owned();
199 let (line, column) = line_col(&text, offset);
200 Some(ResolvedPos { file, line, column })
201}
202
203/// Map a byte offset to CppNix's 1-based (line, BYTE column).
204///
205/// Linear scan: `unsafeGetAttrPos` is rare enough that this never showed up in
206/// a profile. If it ever does, memoise a per-file line-start table beside
207/// `SOURCE_TEXTS` and binary-search it — do NOT go back to a constant.
208fn line_col(text: &str, offset: u32) -> (u64, u64) {
209 let off = (offset as usize).min(text.len());
210 let head = &text.as_bytes()[..off];
211 let line = 1 + head.iter().filter(|b| **b == b'\n').count();
212 let bol = head.iter().rposition(|b| *b == b'\n').map_or(0, |i| i + 1);
213 (line as u64, (off - bol) as u64 + 1)
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 /// Re-baselined against `nix eval` 2.31.5 — the previous expectations
221 /// (line 1, column offset+1) were this function's own output recorded as
222 /// the oracle's, which is why they never went red.
223 #[test]
224 fn line_col_matches_cppnix() {
225 // The fixture the old doc comment cited. ACTUAL nix answers:
226 // aaaaa 2:3 bbbbb 3:3 ccccc 4:3
227 let t = "{\n aaaaa = 1;\n bbbbb = 2;\n ccccc = 3;\n}\n";
228 assert_eq!(line_col(t, t.find("aaaaa").unwrap() as u32), (2, 3));
229 assert_eq!(line_col(t, t.find("bbbbb").unwrap() as u32), (3, 3));
230 assert_eq!(line_col(t, t.find("ccccc").unwrap() as u32), (4, 3));
231 assert_eq!(line_col(t, 0), (1, 1));
232 }
233
234 /// Columns count BYTES, not chars — measured against nix: a 2-byte `é`
235 /// earlier on the line advances the reported column by 2.
236 #[test]
237 fn line_col_columns_are_bytes_not_chars() {
238 let t = "{ \"é\" = 1; b = 2; }";
239 let b = t.find(" b =").unwrap() as u32 + 1;
240 assert_eq!(line_col(t, b), (1, u64::from(b) + 1));
241 assert!(t.chars().count() < t.len(), "fixture must be multi-byte");
242 }
243
244 /// An out-of-range offset clamps instead of panicking.
245 #[test]
246 fn line_col_clamps_past_end() {
247 assert_eq!(line_col("ab\ncd", 9_999), (2, 3));
248 }
249
250 #[test]
251 fn resolve_none_for_unregistered_file() {
252 clear_sources();
253 assert!(resolve(Some(Path::new("/nowhere/x.nix")), 0).is_none());
254 }
255
256 #[test]
257 fn resolve_none_when_no_file() {
258 clear_sources();
259 // A `<string>`-eval'd source (no file) has no position.
260 register_source(None, "x = 1;");
261 assert!(resolve(None, 0).is_none());
262 }
263
264 #[test]
265 fn resolve_reports_file_and_cppnix_offset_pos() {
266 clear_sources();
267 let f = PathBuf::from("/nix/store/deadbeef-source/foo.nix");
268 register_source(Some(&f), "a = 1;\nbcd = 2;");
269 // offset 7 is `bcd`, which is on line 2 at column 1. The old
270 // expectation here was line 1 / column 8 — the offset+1 rule, not
271 // CppNix's answer.
272 let p = resolve(Some(&f), 7).unwrap();
273 assert_eq!(p.file, "/nix/store/deadbeef-source/foo.nix");
274 assert_eq!(p.line, 2);
275 assert_eq!(p.column, 1);
276 }
277}