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).
94pub fn register_source(file: Option<&Path>, text: &str) {
95    let Some(file) = file else { return };
96    SOURCE_TEXTS.with(|s| {
97        let mut s = s.borrow_mut();
98        // Only store the text the first time a path is seen (identical on
99        // re-parse; avoids re-allocating the `Rc<str>` on cache-cold imports).
100        s.entry(file.to_path_buf())
101            .or_insert_with(|| Rc::from(text));
102    });
103}
104
105/// Clear the source-text registry. Called between independent top-level
106/// evaluations (alongside the ident-cache clear) so a stale path→text entry
107/// from a prior pass doesn't persist.
108pub fn clear_sources() {
109    SOURCE_TEXTS.with(|s| s.borrow_mut().clear());
110}
111
112/// Fetch a registered file's source text (an `Rc<str>` clone), if any.
113fn text_for(file: &Path) -> Option<Rc<str>> {
114    SOURCE_TEXTS.with(|s| s.borrow().get(file).cloned())
115}
116
117/// A resolved source position: the file (store-source-lifted) + 1-based
118/// line and column — the shape `unsafeGetAttrPos` returns.
119pub struct ResolvedPos {
120    pub file: String,
121    pub line: u64,
122    pub column: u64,
123}
124
125/// Resolve `(file, offset)` to a [`ResolvedPos`] — the file is lifted from a
126/// fetcher-cache path to its `/nix/store/<h>-source` store path via
127/// [`crate::path::dematerialize`]. Returns `None` when `file` is `None` (a
128/// `<string>` eval, no position) or the file was never parsed (no source
129/// text registered — the attrset can't have originated in a real file).
130///
131/// LINE/COLUMN are resolved against the file's text, 1-based, with BYTE
132/// columns.
133///
134/// This used to return `line = 1, column = byte_offset + 1` unconditionally,
135/// documented as CppNix's "observed" behaviour and "verified against `nix
136/// eval`" with the fixture below. It was not verified — the cited numbers are
137/// this function's OWN output, recorded as if they were the oracle's, and two
138/// unit tests pinned them green. Re-measured against nix 2.31.5 on exactly
139/// that fixture (`{\n  aaaaa = 1;\n  bbbbb = 2;\n  ccccc = 3;\n}`):
140///
141/// ```text
142///           nix        sui (before)
143///   aaaaa   2:3        1:5
144///   bbbbb   3:3        1:18
145///   ccccc   4:3        1:31
146/// ```
147///
148/// Columns count BYTES, not characters — measured: with a 2-byte `é` earlier
149/// on the line, CppNix's column advances by 2. A tab advances by 1; `\r` is
150/// not special.
151///
152/// The recorded OFFSETS were always correct: normalising both engines back to
153/// `base_of_line(line) + column - 1` agreed on 30/30 non-null rows (quoted,
154/// escaped, unicode and tab-indented keys; keys after comments; cross-file and
155/// `toFile` store-path sets). Only this mapping step was missing.
156#[must_use]
157pub fn resolve(file: Option<&Path>, offset: u32) -> Option<ResolvedPos> {
158    // CppNix has no position for a `<string>`-eval'd expression (no file);
159    // such an attrset yields `null` from `unsafeGetAttrPos`.
160    let file_path = file?;
161    // Existence check only: an attrset with a position table originated in a
162    // parsed file, so its text is registered. A missing entry means the
163    // position can't be trusted → `null` (matches CppNix's unknown-pos).
164    let text = text_for(file_path)?;
165    let file = crate::path::dematerialize(file_path)
166        .to_string_lossy()
167        .into_owned();
168    let (line, column) = line_col(&text, offset);
169    Some(ResolvedPos { file, line, column })
170}
171
172/// Map a byte offset to CppNix's 1-based (line, BYTE column).
173///
174/// Linear scan: `unsafeGetAttrPos` is rare enough that this never showed up in
175/// a profile. If it ever does, memoise a per-file line-start table beside
176/// `SOURCE_TEXTS` and binary-search it — do NOT go back to a constant.
177fn line_col(text: &str, offset: u32) -> (u64, u64) {
178    let off = (offset as usize).min(text.len());
179    let head = &text.as_bytes()[..off];
180    let line = 1 + head.iter().filter(|b| **b == b'\n').count();
181    let bol = head.iter().rposition(|b| *b == b'\n').map_or(0, |i| i + 1);
182    (line as u64, (off - bol) as u64 + 1)
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    /// Re-baselined against `nix eval` 2.31.5 — the previous expectations
190    /// (line 1, column offset+1) were this function's own output recorded as
191    /// the oracle's, which is why they never went red.
192    #[test]
193    fn line_col_matches_cppnix() {
194        // The fixture the old doc comment cited. ACTUAL nix answers:
195        //   aaaaa 2:3   bbbbb 3:3   ccccc 4:3
196        let t = "{\n  aaaaa = 1;\n  bbbbb = 2;\n  ccccc = 3;\n}\n";
197        assert_eq!(line_col(t, t.find("aaaaa").unwrap() as u32), (2, 3));
198        assert_eq!(line_col(t, t.find("bbbbb").unwrap() as u32), (3, 3));
199        assert_eq!(line_col(t, t.find("ccccc").unwrap() as u32), (4, 3));
200        assert_eq!(line_col(t, 0), (1, 1));
201    }
202
203    /// Columns count BYTES, not chars — measured against nix: a 2-byte `é`
204    /// earlier on the line advances the reported column by 2.
205    #[test]
206    fn line_col_columns_are_bytes_not_chars() {
207        let t = "{ \"é\" = 1; b = 2; }";
208        let b = t.find(" b =").unwrap() as u32 + 1;
209        assert_eq!(line_col(t, b), (1, u64::from(b) + 1));
210        assert!(t.chars().count() < t.len(), "fixture must be multi-byte");
211    }
212
213    /// An out-of-range offset clamps instead of panicking.
214    #[test]
215    fn line_col_clamps_past_end() {
216        assert_eq!(line_col("ab\ncd", 9_999), (2, 3));
217    }
218
219    #[test]
220    fn resolve_none_for_unregistered_file() {
221        clear_sources();
222        assert!(resolve(Some(Path::new("/nowhere/x.nix")), 0).is_none());
223    }
224
225    #[test]
226    fn resolve_none_when_no_file() {
227        clear_sources();
228        // A `<string>`-eval'd source (no file) has no position.
229        register_source(None, "x = 1;");
230        assert!(resolve(None, 0).is_none());
231    }
232
233    #[test]
234    fn resolve_reports_file_and_cppnix_offset_pos() {
235        clear_sources();
236        let f = PathBuf::from("/nix/store/deadbeef-source/foo.nix");
237        register_source(Some(&f), "a = 1;\nbcd = 2;");
238        // offset 7 is `bcd`, which is on line 2 at column 1. The old
239        // expectation here was line 1 / column 8 — the offset+1 rule, not
240        // CppNix's answer.
241        let p = resolve(Some(&f), 7).unwrap();
242        assert_eq!(p.file, "/nix/store/deadbeef-source/foo.nix");
243        assert_eq!(p.line, 2);
244        assert_eq!(p.column, 1);
245    }
246}