feat(snippets): implement dynamic placeholders and modifiers

This commit implements a system for dynamic snippet placeholders, including support for date/time manipulation, clipboard history, and nested snippets. It also adds a flexible modifier system (`uppercase`, `trim`, `percent-encode`, `json-stringify`) that can be chained and applied to any placeholder.
This commit is contained in:
ByteAtATime 2025-06-26 15:34:33 -07:00
parent e2316cf949
commit 2437ad7d61
No known key found for this signature in database
8 changed files with 509 additions and 111 deletions

View file

@ -1,18 +1,44 @@
use crate::clipboard_history::manager::{
ClipboardHistoryManager, MANAGER as CLIPBOARD_MANAGER_STATIC,
};
use crate::error::AppError;
use crate::snippets::input_manager::{InputEvent, InputManager};
use crate::snippets::manager::SnippetManager;
use arboard::Clipboard;
use chrono::Local;
use chrono::{DateTime, Duration, Local, Months};
use enigo::Key as EnigoKey;
use once_cell::sync::Lazy;
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use regex::Regex;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
use uuid::Uuid;
const BUFFER_SIZE: usize = 30;
const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
static PLACEHOLDER_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"\{(?P<name>\w+)(?P<attributes>(?:\s+\w+=(?:"[^"]*"|\S+))*)?(?P<modifiers>(?:\s*\|\s*[\w%-]+)*)\}"#).unwrap()
});
static ATTRIBUTE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"\s*(?P<key>\w+)=(?:"(?P<q_value>[^"]*)"|(?P<uq_value>\S+))"#).unwrap()
});
static OFFSET_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?P<sign>[+-])(?P<num>\d+)(?P<unit>[ymhMd])").unwrap());
pub struct ResolvedSnippet {
pub content: String,
pub cursor_pos: Option<usize>,
}
#[derive(Debug)]
struct ParsedPlaceholder<'a> {
name: &'a str,
attributes: HashMap<&'a str, &'a str>,
modifiers: Vec<&'a str>,
}
pub struct ExpansionEngine {
buffer: Arc<Mutex<String>>,
snippet_manager: Arc<SnippetManager>,
@ -67,9 +93,15 @@ impl ExpansionEngine {
if let Ok(snippets) = self.snippet_manager.list_snippets(None) {
for snippet in snippets {
if buffer.ends_with(&snippet.keyword) {
let (keyword, content) = (snippet.keyword.clone(), snippet.content.clone());
let (keyword, content, id) =
(snippet.keyword.clone(), snippet.content.clone(), snippet.id);
let manager = self.snippet_manager.clone();
drop(buffer);
self.expand_snippet(&keyword, &content);
// run in a separate thread to not block input
thread::spawn(move || {
let _ = manager.snippet_was_used(id);
});
break;
}
}
@ -82,7 +114,24 @@ impl ExpansionEngine {
backspaces.push('\u{8}');
}
let resolved = parse_and_resolve_placeholders(content);
let clipboard_manager_lock = CLIPBOARD_MANAGER_STATIC.lock().unwrap();
let resolved_result = parse_and_resolve_placeholders(
content,
&self.snippet_manager,
clipboard_manager_lock.as_ref(),
);
let resolved = match resolved_result {
Ok(res) => res,
Err(e) => {
eprintln!("[ExpansionEngine] Error resolving placeholders: {}", e);
ResolvedSnippet {
content: content.to_string(),
cursor_pos: None,
}
}
};
let content_to_paste = resolved.content;
let chars_to_move_left = if let Some(pos) = resolved.cursor_pos {
@ -117,45 +166,267 @@ impl ExpansionEngine {
}
}
pub fn parse_and_resolve_placeholders(raw_content: &str) -> ResolvedSnippet {
fn parse_attributes(attr_str: &str) -> HashMap<&str, &str> {
ATTRIBUTE_REGEX
.captures_iter(attr_str)
.filter_map(|cap| {
let key = cap.name("key")?.as_str();
let value = cap
.name("q_value")
.or_else(|| cap.name("uq_value"))?
.as_str();
Some((key, value))
})
.collect()
}
fn parse_modifiers(mod_str: &str) -> Vec<&str> {
mod_str
.split('|')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect()
}
fn apply_modifiers(mut value: String, modifiers: &[&str]) -> String {
for &modifier in modifiers {
value = match modifier {
"uppercase" => value.to_uppercase(),
"lowercase" => value.to_lowercase(),
"trim" => value.trim().to_string(),
"percent-encode" => utf8_percent_encode(&value, FRAGMENT).to_string(),
"json-stringify" => serde_json::to_string(&value).unwrap_or(value),
_ => value,
};
}
value
}
fn translate_date_format(format_str: &str) -> String {
let mut result = String::with_capacity(format_str.len());
let mut in_literal = false;
let mut chars = format_str.chars().peekable();
while let Some(c) = chars.next() {
if c == '\'' {
in_literal = !in_literal;
continue;
}
if in_literal {
result.push(c);
continue;
}
let mut count = 1;
while chars.peek() == Some(&c) {
chars.next();
count += 1;
}
let format_specifier = match (c, count) {
// year
('y', 4) => "%Y",
('y', 2) => "%y",
// month
('M', 4) => "%B",
('M', 3) => "%b",
('M', 2) => "%m",
('M', 1) => "%-m",
// day of week
('E', 4) => "%A",
('E', 1..=3) => "%a",
// day of month
('d', 2) => "%d",
('d', 1) => "%-d",
// hour (0-23)
('H', 2) => "%H",
('H', 1) => "%-H",
// hour (1-12)
('h', 2) => "%I",
('h', 1) => "%-I",
// minute
('m', 2) => "%M",
('m', 1) => "%-M",
// second
('s', 2) => "%S",
('s', 1) => "%-S",
// fractional second
('S', 3) => "%f",
// am/pm
('a', 1) => "%p",
// timezone
('Z', 1) => "%z",
(other_char, num_chars) => {
// treat as literal
for _ in 0..num_chars {
result.push(other_char);
}
""
}
};
result.push_str(format_specifier);
}
result
}
fn resolve_value<'a>(
placeholder: &ParsedPlaceholder,
snippet_manager: &SnippetManager,
clipboard_manager: Option<&ClipboardHistoryManager>,
) -> Result<String, AppError> {
let now = Local::now();
match placeholder.name {
"cursor" => Ok(String::new()),
"uuid" => Ok(Uuid::new_v4().to_string().to_uppercase()),
"clipboard" => {
let offset: u32 = placeholder
.attributes
.get("offset")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
if offset > 0 {
if let Some(cm) = clipboard_manager {
if let Some(content) = cm.get_content_by_offset(offset)? {
return Ok(content);
}
}
return Ok(String::new());
}
Ok(Clipboard::new()
.ok()
.and_then(|mut c| c.get_text().ok())
.unwrap_or_default())
}
"snippet" => {
if let Some(name) = placeholder.attributes.get("name") {
if let Some(snippet) = snippet_manager.find_snippet_by_name(name)? {
if PLACEHOLDER_REGEX.is_match(&snippet.content) {
return Ok(String::new()); // prevent recursion
}
return Ok(snippet.content);
}
}
Ok(String::new())
}
"date" | "time" | "datetime" | "day" => {
let mut date_time: DateTime<Local> = now;
if let Some(offset_str) = placeholder.attributes.get("offset") {
for cap in OFFSET_REGEX.captures_iter(offset_str) {
let sign = cap.name("sign").unwrap().as_str();
let num: i64 = cap.name("num").unwrap().as_str().parse().unwrap_or(0);
let val = if sign == "-" { -num } else { num };
match cap.name("unit").unwrap().as_str() {
"y" => {
let months_to_add = val * 12;
if months_to_add > 0 {
date_time = date_time
.checked_add_months(Months::new(months_to_add as u32))
.unwrap_or(date_time);
} else if months_to_add < 0 {
date_time = date_time
.checked_sub_months(Months::new(-months_to_add as u32))
.unwrap_or(date_time);
}
}
"M" => {
if val > 0 {
date_time = date_time
.checked_add_months(Months::new(val as u32))
.unwrap_or(date_time);
} else if val < 0 {
date_time = date_time
.checked_sub_months(Months::new(-val as u32))
.unwrap_or(date_time);
}
}
"d" => {
date_time = date_time
.checked_add_signed(Duration::days(val))
.unwrap_or(date_time)
}
"h" => {
date_time = date_time
.checked_add_signed(Duration::hours(val))
.unwrap_or(date_time)
}
"m" => {
date_time = date_time
.checked_add_signed(Duration::minutes(val))
.unwrap_or(date_time)
}
_ => {}
}
}
}
let format_str = if let Some(fmt) = placeholder.attributes.get("format") {
translate_date_format(fmt)
} else {
match placeholder.name {
"date" => "%-d %b %Y".to_string(),
"time" => "%-I:%M %p".to_string(),
"datetime" => "%-d %b %Y at %-I:%M %p".to_string(),
"day" => "%A".to_string(),
_ => "".to_string(),
}
};
Ok(date_time
.format(&format_str)
.to_string()
.replace("am", "AM")
.replace("pm", "PM"))
}
_ => Ok(String::new()),
}
}
pub fn parse_and_resolve_placeholders(
raw_content: &str,
snippet_manager: &SnippetManager,
clipboard_manager: Option<&ClipboardHistoryManager>,
) -> Result<ResolvedSnippet, AppError> {
let mut resolved_content = String::with_capacity(raw_content.len());
let mut cursor_pos: Option<usize> = None;
let mut last_end = 0;
for (start, _) in raw_content.match_indices('{') {
if start < last_end {
continue;
}
if let Some(end) = raw_content[start..].find('}') {
let placeholder = &raw_content[start + 1..start + end];
for cap in PLACEHOLDER_REGEX.captures_iter(raw_content) {
let full_match = cap.get(0).unwrap();
resolved_content.push_str(&raw_content[last_end..full_match.start()]);
resolved_content.push_str(&raw_content[last_end..start]);
let name = cap.name("name").unwrap().as_str();
let attributes = parse_attributes(cap.name("attributes").map_or("", |m| m.as_str()));
let modifiers = parse_modifiers(cap.name("modifiers").map_or("", |m| m.as_str()));
let replacement = match placeholder {
"cursor" => {
if cursor_pos.is_none() {
cursor_pos = Some(resolved_content.chars().count());
}
String::new()
}
"clipboard" => Clipboard::new()
.ok()
.and_then(|mut c| c.get_text().ok())
.unwrap_or_default(),
"date" => Local::now().format("%d %b %Y").to_string(),
"time" => Local::now().format("%H:%M").to_string(),
"datetime" => Local::now().format("%d %b %Y at %H:%M").to_string(),
"day" => Local::now().format("%A").to_string(),
_ => raw_content[start..start + end + 1].to_string(),
};
resolved_content.push_str(&replacement);
last_end = start + end + 1;
let placeholder = ParsedPlaceholder {
name,
attributes,
modifiers,
};
if placeholder.name == "cursor" {
if cursor_pos.is_none() {
cursor_pos = Some(resolved_content.chars().count());
}
} else {
let value = resolve_value(&placeholder, snippet_manager, clipboard_manager)?;
let modified_value = apply_modifiers(value, &placeholder.modifiers);
resolved_content.push_str(&modified_value);
}
last_end = full_match.end();
}
resolved_content.push_str(&raw_content[last_end..]);
ResolvedSnippet {
Ok(ResolvedSnippet {
content: resolved_content,
cursor_pos,
}
}
})
}

View file

@ -169,4 +169,30 @@ impl SnippetManager {
Ok(None)
}
}
}
pub fn find_snippet_by_name(&self, name: &str) -> Result<Option<Snippet>, AppError> {
let db = self.db.lock().unwrap();
let mut stmt = db.prepare("SELECT id, name, keyword, content, created_at, updated_at, times_used, last_used_at FROM snippets WHERE name = ?1 ORDER BY updated_at DESC LIMIT 1")?;
let mut rows = stmt.query_map(params![name], |row| {
let created_at_ts: i64 = row.get(4)?;
let updated_at_ts: i64 = row.get(5)?;
let last_used_at_ts: i64 = row.get(7)?;
Ok(Snippet {
id: row.get(0)?,
name: row.get(1)?,
keyword: row.get(2)?,
content: row.get(3)?,
created_at: DateTime::from_timestamp(created_at_ts, 0).unwrap_or_default(),
updated_at: DateTime::from_timestamp(updated_at_ts, 0).unwrap_or_default(),
times_used: row.get(6)?,
last_used_at: DateTime::from_timestamp(last_used_at_ts, 0).unwrap_or_default(),
})
})?;
if let Some(row) = rows.next() {
Ok(Some(row?))
} else {
Ok(None)
}
}
}

View file

@ -3,6 +3,7 @@ pub mod input_manager;
pub mod manager;
pub mod types;
use crate::clipboard_history;
use crate::error::AppError;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@ -72,12 +73,20 @@ pub fn snippet_was_used(app: AppHandle, id: i64) -> Result<(), String> {
#[tauri::command]
pub fn paste_snippet_content(app: AppHandle, content: String) -> Result<(), String> {
let snippet_manager = app.state::<manager::SnippetManager>().inner();
let clipboard_manager = clipboard_history::manager::MANAGER.lock().unwrap();
let input_manager = app
.state::<Arc<dyn input_manager::InputManager>>()
.inner()
.clone();
let resolved = engine::parse_and_resolve_placeholders(&content);
let resolved = engine::parse_and_resolve_placeholders(
&content,
snippet_manager,
clipboard_manager.as_ref(),
)
.map_err(|e| e.to_string())?;
let content_to_paste = resolved.content;
let chars_to_move_left = if let Some(pos) = resolved.cursor_pos {
@ -131,4 +140,4 @@ pub fn import_snippets(app: AppHandle, json_content: String) -> Result<ImportRes
snippets_added,
duplicates_skipped,
})
}
}