Skip to main content

lattice_snippet/
variables.rs

1//! Variable resolution for snippet expansion. The host fills
2//! in a [`VariableContext`] with file / cursor / clipboard
3//! info; the renderer asks the context for each `$VAR` it
4//! encounters.
5//!
6//! v1 supports the TextMate / VS Code built-in variable set:
7//!
8//! | Variable | Resolves to |
9//! |---|---|
10//! | `TM_SELECTED_TEXT` | last visual selection |
11//! | `TM_CURRENT_LINE` | current line text |
12//! | `TM_CURRENT_WORD` | word under cursor |
13//! | `TM_LINE_INDEX` | 0-based current line |
14//! | `TM_LINE_NUMBER` | 1-based current line |
15//! | `TM_FILENAME` | current filename (basename + ext) |
16//! | `TM_FILENAME_BASE` | filename without extension |
17//! | `TM_DIRECTORY` | containing directory absolute path |
18//! | `TM_FILEPATH` | absolute path to current file |
19//! | `WORKSPACE_NAME` | workspace folder basename |
20//! | `WORKSPACE_FOLDER` | workspace folder absolute path |
21//! | `CLIPBOARD` | current `"+` register text |
22//! | `CURRENT_YEAR` / `CURRENT_MONTH` / `CURRENT_DATE` / `CURRENT_HOUR` / `CURRENT_MINUTE` / `CURRENT_SECOND` | timestamp components (zero-padded where conventional) |
23//! | `CURRENT_DAY_NAME` / `CURRENT_DAY_NAME_SHORT` / `CURRENT_MONTH_NAME` / `CURRENT_MONTH_NAME_SHORT` | named timestamp parts |
24//! | `RANDOM` | 6-digit random int |
25//! | `RANDOM_HEX` | 6-char random hex |
26//! | `UUID` | random UUID v4 |
27//! | `LINE_COMMENT` / `BLOCK_COMMENT_START` / `BLOCK_COMMENT_END` | per-major-mode comment strings (Phase 8) |
28//!
29//! Unknown variables resolve to `None` (the renderer emits the
30//! variable's `default` body or the empty string).
31
32use std::collections::HashMap;
33
34/// Editor-supplied context the renderer consults to resolve
35/// variable references. The host fills in the file / cursor
36/// fields; timestamp + random / UUID values are computed at
37/// expansion time inside [`VariableContext::resolve`].
38///
39/// `extra` is the plugin / user-defined variable slot --
40/// values the host wants to expose without subclassing.
41/// Names lookup falls through to `extra` when the built-in
42/// set doesn't recognise the name.
43#[derive(Debug, Clone, Default)]
44pub struct VariableContext {
45    pub selected_text: Option<String>,
46    pub current_line: Option<String>,
47    pub current_word: Option<String>,
48    /// 0-based.
49    pub line_index: Option<u32>,
50    pub filename: Option<String>,
51    pub directory: Option<String>,
52    pub filepath: Option<String>,
53    pub workspace_name: Option<String>,
54    pub workspace_folder: Option<String>,
55    pub clipboard: Option<String>,
56    pub line_comment: Option<String>,
57    pub block_comment_start: Option<String>,
58    pub block_comment_end: Option<String>,
59    /// User / plugin extras. Looked up after the built-in set.
60    pub extra: HashMap<String, String>,
61    /// Optional fixed timestamp (for tests). When None, the
62    /// renderer reads `SystemTime::now()`.
63    pub fixed_now: Option<TimestampParts>,
64    /// Optional fixed random seed (for tests).
65    pub fixed_random: Option<u64>,
66}
67
68/// Decomposed timestamp -- supplied by the host (typically
69/// derived from `chrono` / `time` if it's already a dep) or
70/// computed at variable-resolve time. Tests use `fixed_now` to
71/// pin output.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct TimestampParts {
74    pub year: u32,
75    pub month: u32,
76    pub day: u32,
77    pub hour: u32,
78    pub minute: u32,
79    pub second: u32,
80    pub day_name: &'static str,
81    pub day_name_short: &'static str,
82    pub month_name: &'static str,
83    pub month_name_short: &'static str,
84}
85
86impl VariableContext {
87    /// Resolve a variable name to its expansion text. Returns
88    /// `None` when neither the built-in set nor `extra` knows
89    /// the name -- the renderer then emits the snippet's
90    /// fallback (default body) or empty string.
91    pub fn resolve(&self, name: &str) -> Option<String> {
92        match name {
93            "TM_SELECTED_TEXT" => self.selected_text.clone(),
94            "TM_CURRENT_LINE" => self.current_line.clone(),
95            "TM_CURRENT_WORD" => self.current_word.clone(),
96            "TM_LINE_INDEX" => self.line_index.map(|n| n.to_string()),
97            "TM_LINE_NUMBER" => self.line_index.map(|n| (n + 1).to_string()),
98            "TM_FILENAME" => self.filename.clone(),
99            "TM_FILENAME_BASE" => self.filename.as_ref().map(|f| {
100                f.rsplit_once('.')
101                    .map(|(s, _)| s.to_string())
102                    .unwrap_or(f.clone())
103            }),
104            "TM_DIRECTORY" => self.directory.clone(),
105            "TM_FILEPATH" => self.filepath.clone(),
106            "WORKSPACE_NAME" => self.workspace_name.clone(),
107            "WORKSPACE_FOLDER" => self.workspace_folder.clone(),
108            "CLIPBOARD" => self.clipboard.clone(),
109            "LINE_COMMENT" => self.line_comment.clone(),
110            "BLOCK_COMMENT_START" => self.block_comment_start.clone(),
111            "BLOCK_COMMENT_END" => self.block_comment_end.clone(),
112            // Timestamps -- `fixed_now` for tests; otherwise
113            // SystemTime-derived. Date / Day equal the current
114            // day of month; the month's name is supplied in
115            // TimestampParts.
116            "CURRENT_YEAR" => Some(self.now().year.to_string()),
117            "CURRENT_YEAR_SHORT" => Some(format!("{:02}", self.now().year % 100)),
118            "CURRENT_MONTH" => Some(format!("{:02}", self.now().month)),
119            "CURRENT_DATE" => Some(format!("{:02}", self.now().day)),
120            "CURRENT_HOUR" => Some(format!("{:02}", self.now().hour)),
121            "CURRENT_MINUTE" => Some(format!("{:02}", self.now().minute)),
122            "CURRENT_SECOND" => Some(format!("{:02}", self.now().second)),
123            "CURRENT_DAY_NAME" => Some(self.now().day_name.to_string()),
124            "CURRENT_DAY_NAME_SHORT" => Some(self.now().day_name_short.to_string()),
125            "CURRENT_MONTH_NAME" => Some(self.now().month_name.to_string()),
126            "CURRENT_MONTH_NAME_SHORT" => Some(self.now().month_name_short.to_string()),
127            "RANDOM" => Some(format!("{:06}", self.random() % 1_000_000)),
128            "RANDOM_HEX" => Some(format!("{:06x}", self.random() & 0xff_ffff)),
129            "UUID" => Some(uuid_v4_str(self.random())),
130            other => self.extra.get(other).cloned(),
131        }
132    }
133
134    fn now(&self) -> TimestampParts {
135        if let Some(fx) = self.fixed_now.as_ref() {
136            return fx.clone();
137        }
138        // Real-clock path -- compute from SystemTime. Falls
139        // back to a fixed default on UNIX-time errors (clock
140        // before epoch -- shouldn't happen).
141        let now = std::time::SystemTime::now()
142            .duration_since(std::time::UNIX_EPOCH)
143            .map(|d| d.as_secs())
144            .unwrap_or(0);
145        compute_timestamp_parts(now)
146    }
147
148    fn random(&self) -> u64 {
149        if let Some(seed) = self.fixed_random {
150            return seed;
151        }
152        // Cheap pseudo-random from clock ns -- snippets don't
153        // need cryptographic randomness. Avoids pulling in
154        // `rand` as a dep just for this.
155        let nanos = std::time::SystemTime::now()
156            .duration_since(std::time::UNIX_EPOCH)
157            .map(|d| d.subsec_nanos() as u64)
158            .unwrap_or(0);
159        // xorshift step.
160        let mut x = nanos.wrapping_add(0x9e37_79b9_7f4a_7c15);
161        x ^= x << 13;
162        x ^= x >> 7;
163        x ^= x << 17;
164        x
165    }
166}
167
168/// Convenience: `VariableContext` populated with the builtin
169/// timestamp + random sources but no editor-supplied fields.
170/// Useful for "expand this snippet outside an editor" flows
171/// (tests, examples, snippet authoring tools).
172pub fn builtin_variables() -> VariableContext {
173    VariableContext::default()
174}
175
176fn compute_timestamp_parts(unix_secs: u64) -> TimestampParts {
177    // Approximate Gregorian date math. Good enough for snippet
178    // variables -- avoids the `chrono` dependency. Day-of-week
179    // computation uses Zeller's congruence. Month names mirror
180    // VS Code's variable values.
181    let days = unix_secs / 86_400;
182    let secs = unix_secs % 86_400;
183    let hour = (secs / 3600) as u32;
184    let minute = ((secs % 3600) / 60) as u32;
185    let second = (secs % 60) as u32;
186
187    let (year, month, day) = days_to_ymd(days);
188    let day_of_week = day_of_week_zeller(year, month, day);
189    let day_name = match day_of_week {
190        0 => "Saturday",
191        1 => "Sunday",
192        2 => "Monday",
193        3 => "Tuesday",
194        4 => "Wednesday",
195        5 => "Thursday",
196        6 => "Friday",
197        _ => "Sunday",
198    };
199    let day_name_short = match day_name {
200        "Saturday" => "Sat",
201        "Sunday" => "Sun",
202        "Monday" => "Mon",
203        "Tuesday" => "Tue",
204        "Wednesday" => "Wed",
205        "Thursday" => "Thu",
206        "Friday" => "Fri",
207        _ => "Sun",
208    };
209    let month_name = match month {
210        1 => "January",
211        2 => "February",
212        3 => "March",
213        4 => "April",
214        5 => "May",
215        6 => "June",
216        7 => "July",
217        8 => "August",
218        9 => "September",
219        10 => "October",
220        11 => "November",
221        12 => "December",
222        _ => "Unknown",
223    };
224    let month_name_short = match month {
225        1 => "Jan",
226        2 => "Feb",
227        3 => "Mar",
228        4 => "Apr",
229        5 => "May",
230        6 => "Jun",
231        7 => "Jul",
232        8 => "Aug",
233        9 => "Sep",
234        10 => "Oct",
235        11 => "Nov",
236        12 => "Dec",
237        _ => "Unk",
238    };
239    TimestampParts {
240        year,
241        month,
242        day,
243        hour,
244        minute,
245        second,
246        day_name,
247        day_name_short,
248        month_name,
249        month_name_short,
250    }
251}
252
253fn days_to_ymd(mut days: u64) -> (u32, u32, u32) {
254    // Days since 1970-01-01.
255    let mut year: u64 = 1970;
256    loop {
257        let year_days = if is_leap_year(year as u32) { 366 } else { 365 };
258        if days < year_days {
259            break;
260        }
261        days -= year_days;
262        year += 1;
263    }
264    let month_days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
265    let mut month: u32 = 1;
266    for (i, m_days) in month_days.iter().enumerate() {
267        let m_days = *m_days
268            + if i == 1 && is_leap_year(year as u32) {
269                1
270            } else {
271                0
272            };
273        if days < m_days {
274            month = i as u32 + 1;
275            break;
276        }
277        days -= m_days;
278    }
279    let day = days as u32 + 1;
280    (year as u32, month, day)
281}
282
283fn is_leap_year(y: u32) -> bool {
284    (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
285}
286
287fn day_of_week_zeller(year: u32, month: u32, day: u32) -> u32 {
288    // Zeller's congruence (Gregorian). Returns 0=Saturday,
289    // 1=Sunday, ..., 6=Friday.
290    let (m, y) = if month < 3 {
291        (month + 12, year - 1)
292    } else {
293        (month, year)
294    };
295    let k = y % 100;
296    let j = y / 100;
297
298    (day + (13 * (m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j) % 7
299}
300
301/// Cheap UUID v4 string from a 64-bit seed. Not RFC-cryptographic,
302/// just structurally valid. Snippet `$UUID` doesn't need
303/// security guarantees.
304fn uuid_v4_str(seed: u64) -> String {
305    let mut a = seed;
306    let mut b = seed.wrapping_mul(0x9e37_79b9_7f4a_7c15);
307    a ^= a << 13;
308    a ^= a >> 7;
309    a ^= a << 17;
310    b ^= b << 13;
311    b ^= b >> 7;
312    b ^= b << 17;
313    let lo = a;
314    let hi = b;
315    // Layout: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx (y = 8|9|a|b).
316    let p1 = (lo >> 32) as u32;
317    let p2 = (lo & 0xffff_ffff) as u16;
318    let mut p3 = ((hi >> 48) & 0x0fff) as u16;
319    p3 |= 0x4000;
320    let mut p4 = ((hi >> 32) & 0x3fff) as u16;
321    p4 |= 0x8000;
322    let p5 = hi & 0xffff_ffff_ffff;
323    format!("{p1:08x}-{p2:04x}-{p3:04x}-{p4:04x}-{p5:012x}")
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn tm_filename_resolves() {
332        let ctx = VariableContext {
333            filename: Some("foo.rs".into()),
334            ..Default::default()
335        };
336        assert_eq!(ctx.resolve("TM_FILENAME"), Some("foo.rs".into()));
337        assert_eq!(ctx.resolve("TM_FILENAME_BASE"), Some("foo".into()));
338    }
339
340    #[test]
341    fn tm_line_number_is_one_based() {
342        let ctx = VariableContext {
343            line_index: Some(42),
344            ..Default::default()
345        };
346        assert_eq!(ctx.resolve("TM_LINE_INDEX"), Some("42".into()));
347        assert_eq!(ctx.resolve("TM_LINE_NUMBER"), Some("43".into()));
348    }
349
350    #[test]
351    fn unknown_variable_falls_through_to_extras() {
352        let mut ctx = VariableContext::default();
353        ctx.extra.insert("MY_VAR".into(), "value".into());
354        assert_eq!(ctx.resolve("MY_VAR"), Some("value".into()));
355        assert_eq!(ctx.resolve("OTHER"), None);
356    }
357
358    #[test]
359    fn fixed_now_pins_timestamp_variables() {
360        let mut ctx = VariableContext::default();
361        ctx.fixed_now = Some(TimestampParts {
362            year: 2026,
363            month: 5,
364            day: 6,
365            hour: 14,
366            minute: 30,
367            second: 0,
368            day_name: "Tuesday",
369            day_name_short: "Tue",
370            month_name: "May",
371            month_name_short: "May",
372        });
373        assert_eq!(ctx.resolve("CURRENT_YEAR"), Some("2026".into()));
374        assert_eq!(ctx.resolve("CURRENT_MONTH"), Some("05".into()));
375        assert_eq!(ctx.resolve("CURRENT_DATE"), Some("06".into()));
376        assert_eq!(ctx.resolve("CURRENT_HOUR"), Some("14".into()));
377        assert_eq!(ctx.resolve("CURRENT_DAY_NAME"), Some("Tuesday".into()));
378        assert_eq!(ctx.resolve("CURRENT_MONTH_NAME"), Some("May".into()));
379    }
380
381    #[test]
382    fn fixed_random_pins_random_variables() {
383        let ctx = VariableContext {
384            fixed_random: Some(123_456_789),
385            ..Default::default()
386        };
387        // Modulo 1_000_000 then zero-padded to 6 digits.
388        assert_eq!(ctx.resolve("RANDOM"), Some("456789".into()));
389        // Hex variant -- low 6 hex digits.
390        assert!(
391            ctx.resolve("RANDOM_HEX")
392                .unwrap()
393                .chars()
394                .all(|c| { c.is_ascii_hexdigit() })
395        );
396    }
397
398    #[test]
399    fn uuid_resolves_to_36_char_dashed_string() {
400        let ctx = VariableContext {
401            fixed_random: Some(0x12345678),
402            ..Default::default()
403        };
404        let id = ctx.resolve("UUID").unwrap();
405        assert_eq!(id.len(), 36);
406        assert_eq!(id.matches('-').count(), 4);
407        // Version 4 marker at the right offset.
408        assert_eq!(id.chars().nth(14), Some('4'));
409    }
410
411    #[test]
412    fn days_to_ymd_round_trips_known_dates() {
413        // 2026-01-01 = 20454 days since 1970-01-01.
414        let secs = 20454u64 * 86_400;
415        let parts = compute_timestamp_parts(secs);
416        assert_eq!(parts.year, 2026);
417        assert_eq!(parts.month, 1);
418        assert_eq!(parts.day, 1);
419    }
420
421    #[test]
422    fn leap_year_february_handles_feb_29() {
423        // 2024 is a leap year. 2024-02-29 = 19782 days since
424        // epoch.
425        let secs = 19782u64 * 86_400;
426        let parts = compute_timestamp_parts(secs);
427        assert_eq!(parts.year, 2024);
428        assert_eq!(parts.month, 2);
429        assert_eq!(parts.day, 29);
430    }
431}