1use std::collections::HashMap;
33
34#[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 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 pub extra: HashMap<String, String>,
61 pub fixed_now: Option<TimestampParts>,
64 pub fixed_random: Option<u64>,
66}
67
68#[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 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 "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 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 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 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
168pub fn builtin_variables() -> VariableContext {
173 VariableContext::default()
174}
175
176fn compute_timestamp_parts(unix_secs: u64) -> TimestampParts {
177 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 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 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
301fn 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 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 assert_eq!(ctx.resolve("RANDOM"), Some("456789".into()));
389 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 assert_eq!(id.chars().nth(14), Some('4'));
409 }
410
411 #[test]
412 fn days_to_ymd_round_trips_known_dates() {
413 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 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}