substitute/
lib.rs

1//! trivial string templates based on [the FORTH word of the same name][1].
2//!
3//! the primary advantage over `str::replace` is that it can perform multiple replacements while only allocating one new string.
4//!
5//! a template is a string that contains several *substitutions*.
6//!
7//! a substitution starts with '%', then has any number of of charachers in its name, then ends with '%'.
8//! substitution names are case-sensitive.
9//!
10//! a Substituter maps a substitution name to its replacement
11//!
12//! the substitution `%%` always has the replacment `%`.  this is to allow escaping of literal percentage signs.
13//!
14//! has `no_std` support, and can support `str` and `[u8]` templates.
15//!
16//! [1]: <https://forth-standard.org/standard/string/SUBSTITUTE>
17
18#![forbid(unsafe_code)]
19#![deny(clippy::implicit_hasher)]
20#![warn(clippy::pedantic)]
21#![no_std]
22
23#[cfg(feature = "alloc")]
24extern crate alloc;
25#[cfg(feature = "std")]
26extern crate std;
27
28#[cfg(feature = "alloc")]
29use alloc::string::{String, ToString};
30#[cfg(feature = "std")]
31use std::collections::HashMap;
32#[cfg(feature = "std")]
33use core::borrow::Borrow;
34#[cfg(feature = "std")]
35use core::hash::Hash;
36use core::iter::Iterator;
37use core::fmt;
38use core::convert::Infallible;
39use core::ops::{Index, Range};
40
41// TODO: nightly feature to support BorrowedCursor as Output
42
43#[derive(Debug, Clone)]
44struct ErrorContext{
45	absolute_offset: usize,
46	relative_offset: usize,
47    #[cfg(feature = "alloc")]
48	filename: Option<String>,
49	/// 1-indexed line number within the template string
50	lineno: u32,
51	// TODO: "column" field that holds the offset within a given line
52	/// length of the highlighted range
53	length: usize,
54    #[cfg(feature = "alloc")]
55	nearby: String,
56}
57
58impl ErrorContext {
59    #[cold]
60	fn new(src: &[u8], offset: usize, mut length: usize) -> Self {
61		let mut start = offset.saturating_sub(15);
62		let isctrl = |c: &u8| c.is_ascii_control();
63		if let Some(lidx) = src[start..offset].iter().rposition(isctrl) {
64			start += lidx;
65		}
66		let mut end = (offset.saturating_add(length.clamp(15, 100))).min(src.len());
67		if let Some(lidx) = src[offset..end].iter().position(isctrl) {
68			end = offset + lidx;
69		}
70		let lineno = src[..offset].split(|&b| b == b'\n').count().try_into().unwrap_or(u32::MAX);
71		length = length.min(end-offset);
72		// TODO: strip whitespace, count line numbers.
73		ErrorContext{
74			absolute_offset: offset,
75			relative_offset: offset-start,
76            #[cfg(feature = "alloc")]
77			nearby: String::from_utf8_lossy(&src[start..end]).to_string(),
78            #[cfg(feature = "alloc")]
79			filename: None,
80			length,
81			lineno,
82		}
83	}
84
85    #[cfg(feature = "alloc")]
86    pub fn filename(&self) -> Option<&str> {
87        self.filename.as_ref().map(|x| x.as_str())
88    }
89
90    #[cfg(not(feature = "alloc"))]
91    #[allow(clippy::unused_self)]
92    pub fn filename(&self) -> Option<&str> {
93        None
94    }
95}
96
97impl fmt::Display for ErrorContext {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99		write!(f, "at ")?;
100		if let Some(name) = &self.filename() {
101			write!(f, "{name}:")?;
102		} else {
103			write!(f, "line ")?;
104		}
105
106		write!(f, "{}", self.lineno)?;
107		if f.alternate() {
108			writeln!(f, " (byte offset {})", self.absolute_offset)?;
109		} else {
110			writeln!(f)?;
111		}
112        #[cfg(feature = "alloc")]
113        {
114            writeln!(f, "{}", self.nearby)?;
115        }
116		for _ in 0..self.relative_offset {
117			write!(f, " ")?;
118		}
119		for _ in 0..self.length {
120			write!(f, "^")?;
121		}
122		writeln!(f)?;
123		Ok(())
124    }
125}
126
127#[non_exhaustive]
128#[derive(Debug, Clone, PartialEq)]
129pub enum ErrorKind<E = Infallible> {
130	/// the template string has an odd number of '%' bytes
131	UnmatchedPercent,
132	/// the template string contains a substitution name that is not known
133	/// to the substituter.
134	UnknownSubstitution,
135    /// an error occurred writing to the output
136    Output(E),
137}
138
139impl<E: fmt::Display + fmt::Debug> fmt::Display for ErrorKind<E> {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141		write!(f, "{}", match self {
142			ErrorKind::UnmatchedPercent => "unmatched percent sign",
143			ErrorKind::UnknownSubstitution => "unknown substitution name",
144            ErrorKind::Output(err) => return write!(f, "error writing to output: {err:?}"),
145		})
146	}
147}
148
149/// an error that occured during substitution
150#[derive(Debug, Clone)]
151pub struct Error<E = Infallible> {
152	kind: ErrorKind<E>,
153	cx: ErrorContext,
154}
155
156/// format the error nicely across multiple lines.
157///
158/// use the `{:#}` (alternate) flag to also display the byte offset within the
159/// template the error occurred at.
160///
161/// the exact format is semver exempt, and should not be parsed.
162impl fmt::Display for Error {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164		write!(f, "error expanding template: {} ", self.kind)?;
165		// use Display::fmt here instead of write! in order to preserve the
166		// formatting flags.
167		self.cx.fmt(f)
168	}
169}
170
171impl Error {
172	#[must_use = "pure function with no side effects"]
173	pub fn kind(&self) -> &ErrorKind {
174		&self.kind
175	}
176
177	/// attach filename and line number position info to an error message.
178	///
179	/// usually used with [`Result::map_err`].
180	///
181	/// ```rust
182	/// use substitute::substitute;
183	/// let r = substitute("%notfound%", &()).map_err(|e| e.with_pos(file!(), line!()));
184	///
185	/// assert_eq!(r.unwrap_err().to_string(), "\
186	/// error expanding template: unknown substitution name at src/lib.rs:6
187	/// %notfound%
188	///  ^^^^^^^^
189	/// ");
190	/// ```
191	#[must_use = "pure function with no side effects"]
192    #[cfg(feature = "alloc")]
193	pub fn with_pos(self, filename: &str, lineno: u32) -> Self {
194		Self {
195			cx: ErrorContext{
196				filename: Some(filename.to_string()),
197				// offset the line number, taking into account the fact it
198				// is 1-indexed.
199				lineno: self.cx.lineno - 1 + lineno,
200				.. self.cx
201			},
202			.. self
203		}
204	}
205}
206
207
208/// the core substitution function.
209///
210/// for each `%replacement%`, it calls `sub.lookup_replacement`.
211pub fn substitute_into<'a, T, O, S>(template: &T, sub: &'a S, out: &mut O) -> Result<(), Error<O::Error>> where
212    T: AsRef<[u8]> + 'a + ?Sized,
213    T: Index<Range<usize>, Output = T>,
214    S: Substituter<'a, T> + ?Sized,
215    O: Output<T>,
216    str: AsRef<T>,
217{
218	// position after the last '%' (the start of the current chunk)
219	let mut start_pos = 0;
220	let mut in_name = false;
221	// note that using .chars().enumerate() here would be incorrect due to mixing char and byte indexes.
222	for (i, &b) in template.as_ref().iter().enumerate() {
223        let err_cx = || ErrorContext::new(template.as_ref(), start_pos, i-start_pos);
224        let out_err = |err| Error{
225            kind: ErrorKind::Output(err),
226            cx: err_cx(),
227        };
228		if b == b'%' {
229            let slice = &template[start_pos..i];
230			if in_name {
231                if start_pos == i {
232                    out.append_to_output("%".as_ref()).map_err(out_err)?;
233				} else if let Some(rep) = sub.lookup_replacement(slice) {
234                    out.append_to_output(rep).map_err(out_err)?;
235                } else {
236					return Err(Error{
237						kind: ErrorKind::UnknownSubstitution,
238						cx: err_cx(),
239					});
240				}
241			} else {
242				out.append_to_output(slice).map_err(out_err)?;
243			}
244			start_pos = i + 1;
245			in_name = !in_name;
246		}
247	}
248	if in_name {
249		Err(Error{
250			kind: ErrorKind::UnmatchedPercent,
251			cx: ErrorContext::new(template.as_ref(), start_pos-1, 1),
252		})
253	} else {
254        let len = template.as_ref().len();
255		out.append_to_output(&template[start_pos..len]).map_err(|err| Error{
256            kind: ErrorKind::Output(err),
257            cx: ErrorContext::new(template.as_ref(), start_pos, len),
258        })?;
259		Ok(())
260	}
261}
262
263/// given a template string, it replaces every substitution with
264/// the replacement given by the substituter.
265#[cfg(feature = "alloc")]
266pub fn substitute_string<'a, S: Substituter<'a> + ?Sized>(template: &str, sub: &'a S) -> Result<String, Error> {
267    let mut out = String::with_capacity(template.len());
268	substitute_into(template, sub, &mut out)?;
269    Ok(out)
270}
271
272
273#[cfg(feature = "alloc")]
274pub use crate::substitute_string as substitute;
275
276pub trait Substituter<'a, T: ?Sized = str> {
277	/// map a substitution name to its replacement.
278	///
279	/// returns None when given an unknown substitution name.
280	///
281	/// note that replacements are not cached, if a lookup corrosponds
282	/// to an expensive operation like i/o, it should be cached internally
283	/// for optimal performance.
284	fn lookup_replacement(&'a self, name: &T) -> Option<&'a T>;
285}
286
287pub trait Output<T: ?Sized = str> {
288    type Error;
289
290    fn append_to_output(&mut self, section: &T) -> Result<(), Self::Error>;
291}
292
293#[cfg(feature = "std")]
294impl<'a, K, V, S> Substituter<'a> for HashMap<K, V, S> where
295	K: Borrow<str> + Eq + Hash + 'a,
296	V: AsRef<str> + 'a,
297	S: std::hash::BuildHasher,
298{
299	fn lookup_replacement(&'a self, name: &str) -> Option<&'a str> {
300		self.get(name).map(std::convert::AsRef::as_ref)
301	}
302}
303
304/// a Substituter that interprets a slice of pairs as a key-value map.
305///
306/// this may be faster than using a `HashMap` when you have a small number of
307/// substitutions, as it skips a heap allocation.
308impl<'a, S: AsRef<str> + 'a> Substituter<'a> for [(S, S)] {
309	fn lookup_replacement(&'a self, name: &str) -> Option<&'a str> {
310		for (k, v) in self {
311			if k.as_ref() == name {
312				return Some(v.as_ref())
313			}
314		}
315		None
316	}
317}
318
319impl<'a, S: AsRef<str> + 'a, const N: usize> Substituter<'a> for [(S, S); N] {
320	fn lookup_replacement(&'a self, name: &str) -> Option<&'a str> {
321		self[..].lookup_replacement(name)
322	}
323}
324
325/// empty substituter
326impl<'a, T: ?Sized> Substituter<'a, T> for () {
327	fn lookup_replacement(&'a self, _name: &T) -> Option<&'a T> {
328		None
329	}
330}
331
332#[cfg(feature = "alloc")]
333impl Output<str> for String {
334    type Error = Infallible;
335
336    fn append_to_output(&mut self, section: &str) -> Result<(), Infallible> {
337        *self += section;
338        Ok(())
339    }
340}
341
342#[cfg(feature = "alloc")]
343impl Output<[u8]> for alloc::vec::Vec<u8> {
344    type Error = Infallible;
345
346    fn append_to_output(&mut self, section: &[u8]) -> Result<(), Infallible> {
347        self.extend_from_slice(section);
348        Ok(())
349    }
350}
351
352
353// TODO: benchmark against a full templating library
354// TODO: substituter that caches the results of another substituter.
355// TODO: substituter that wraps around a Serialize struct? or use own derive macro?
356// TODO: Output that wraps io::Write
357
358#[cfg(all(test, feature = "alloc"))]
359mod tests {
360    use super::*;
361    use alloc::format;
362
363    #[test]
364    #[cfg(feature = "std")]
365    fn it_works() {
366        let templ1 = "Greetings, %name%, it is %weekday%, and I'm feeling 100%%";
367		let sub1 = HashMap::from([("name".to_string(), "Alice"), ("weekday".to_string(), "Monday")]);
368        assert_eq!(substitute(templ1, &sub1).unwrap(), "Greetings, Alice, it is Monday, and I'm feeling 100%");
369    }
370
371	#[test]
372	fn errors() {
373		let bad_templ1 = "Hi, %name";
374		let templ1 = "Hi, %name%";
375		let err1 = substitute(bad_templ1, &[("name", "Bob")][..]).unwrap_err();
376		assert_eq!(err1.kind(), &ErrorKind::UnmatchedPercent);
377		assert_eq!(err1.to_string(),
378				   r"error expanding template: unmatched percent sign at line 1
379Hi, %name
380    ^
381");
382		let err2 = substitute(templ1, &[("lastname", "Smith")]).unwrap_err();
383		assert_eq!(err2.kind(), &ErrorKind::UnknownSubstitution);
384		assert_eq!(format!("{err2:#}"),
385				   r"error expanding template: unknown substitution name at line 1 (byte offset 5)
386Hi, %name%
387     ^^^^
388");
389	}
390
391	// TODO: property based testing. (eg. if there are an even number of '%' signs, there should never be a an UnmatchedPercent error).
392}