Skip to main content

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}