seam

Symbolic-Expressions As Markup.
git clone git://git.knutsen.co/seam
Log | Files | Refs | README | LICENSE

commit 29677f1fea13fe474c0e43c960b2d25e533920f1
parent 0151a0d356cc211f1fa7e055f9efadb87255d1a0
Author: Demonstrandum <samuel@knutsen.co>
Date:   Mon,  9 Dec 2024 16:54:15 +0000

%glob has sorting option and added %sort macro.

%glob can be passed a `:order` keyword to sort files by:
    name, type, creation time, modified time.

%sort macro will sort a given list alphanumerically using a provided
`:key` lambda.

Diffstat:
MREADME.md | 29+++++++++++++++++------------
Mcrates/seam/src/lib.rs | 4++++
Mcrates/seam/src/parse/expander.rs | 209++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/seam/src/parse/parser.rs | 2+-
Acrates/seam/src/tests.rs | 35+++++++++++++++++++++++++++++++++++
5 files changed, 259 insertions(+), 20 deletions(-)

diff --git a/README.md b/README.md @@ -6,15 +6,15 @@ Because all markup is terrible, especially XML/SGML and derivatives. -But mainly, for easier static markup code generation, such as with -macros, code includes and such. +But mainly, for easier static markup code generation, such as by +macros and code includes and such. ## Try it out -This may be used as a library, such as from within a server, +This may be used as a Rust library, such as from within a server, generating HTML (or any other supported markup) before it is served to the -client. Personally, I am currently just using the `seam` binary to statically -generate some personal and project websites. +client. Personally, I just use the `seam` binary to statically +generate my personal websites through a Makefile. Read the [USAGE.md](USAGE.md) file for code examples and documentation. @@ -28,7 +28,7 @@ Read the [USAGE.md](USAGE.md) file for code examples and documentation. ### Installation -You may clone the repo, then build and install +You may clone the repo, then build and install by ```sh git clone git://git.knutsen.co/seam cd seam @@ -46,7 +46,7 @@ with it, comes `cargo`. ### Using The Binary -You may use it by doing +You may use it by passing in a file and piping from STDOUT. ```sh seam test.sex --html > test.html ``` @@ -54,13 +54,13 @@ seam test.sex --html > test.html `test.sex` contains your symbolic-expressions, which is used to generate HTML, saved in `test.html`. -Likewise, you may read from `STDIN` +Likewise, you may read from STDIN ```sh seam --html < example.sex > example.html -# Which is the same as +# ... same as cat example.sex | seam --html > example.html ``` -You may also very well use here-strings and here-docs, if your shell +You may also use here-strings or here-docs, if your shell supports it. ```sh seam --html <<< "(p Hello World)" @@ -92,6 +92,11 @@ seam --sexp <<< '(hello (%define subject world) %subject)' ## Checklist - [ ] User `(%error msg)` macro for aborting compilation. + - [ ] List reverse macro `(%reverse (...))`. + - [x] Sorting macro `(%sort (...))` which sorts alphanumerically on literals. + Allow providing a `:key` to sort "by field": e.g. sort by title name `(%sort :key (%lambda ((:title _ &&_)) %title) %posts)` + - [ ] Extend the strftime-style `(%date)` to be able to read UNIX numeric timestamps and display relative to timezones. + Add complementary strptime-style utility `(%timestamp)` to convert date-strings to timestamps (relative to a timezone). - [x] Pattern-matching `(%match expr (pat1 ...) (pat2 ...))` macro. Pattern matching is already implemented for `%define` internally. - [x] The trailing keyword-matching operator. `&&rest` matches excess keyword. @@ -118,8 +123,8 @@ seam --sexp <<< '(hello (%define subject world) %subject)' used by applying `%apply`, e.g. `(%apply (%lambda (a b) b a) x y)` becomes `y x` - [x] `(%string ...)`, `(%join ...)`, `(%map ...)`, `(%filter ...)` macros. - [x] `(%concat ...)` which is just `(%join "" ...)`. - - [ ] `(%basename )`, `(%dirname)` and `(%extension)` macro for paths. - - [ ] Add options to `%glob` for sorting by type, date(s), name, etc. + - [ ] `(%basename)`, `(%dirname)` and `(%extension)` macros for paths. + - [x] Add options to `%glob` for sorting by type, date(s), name, etc. - [x] `(%format "{}")` macro with Rust's `format` syntax. e.g. `(%format "Hello {}, age {age:0>2}" "Sam" :age 9)` - [x] Add `(%raw ...)` macro which takes a string and leaves it unchanged in the final output. - [ ] `(%formatter/text ...)` can take any seam (sexp) source code, for which it just embeds the expanded code (plain-text formatter). diff --git a/crates/seam/src/lib.rs b/crates/seam/src/lib.rs @@ -81,3 +81,7 @@ pub fn tree_builder_stream(stream: &mut impl io::Read) stream.read_to_string(&mut contents)?; Ok(tree_builder(Option::<&Path>::None, contents)) } + +#[cfg(test)] +#[path = "./tests.rs"] +mod tests; diff --git a/crates/seam/src/parse/expander.rs b/crates/seam/src/parse/expander.rs @@ -1067,18 +1067,34 @@ impl<'a> Expander<'a> { let params = self.expand_nodes(params)?; // Eager. let (_parser, args) = arguments! { [&params] mandatory(1): literal, - optional("type"): literal["file", "directory", "any"] + optional("type"): literal["file", "directory", "any"], + optional("sort"): literal["modified", "created", "name", "type", "none"], + optional("order"): literal["ascending", "descending"], }?; - #[derive(Clone, Copy, PartialEq, Eq)] + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PathTypes { File, Dir, Any } + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum SortBy { Modified, Created, Name, Type, None } + // Default to both files and dirs. let path_types = args.r#type.map(|typ| match typ.value.as_ref() { "file" => PathTypes::File, "directory" => PathTypes::Dir, "any" => PathTypes::Any, _ => unreachable!(), }).unwrap_or(PathTypes::Any); + // Default to no ordering. + let sortby = args.sort.map(|typ| match typ.value.as_ref() { + "modified" => SortBy::Modified, + "created" => SortBy::Created, + "name" => SortBy::Name, + "type" => SortBy::Type, + "none" => SortBy::None, + _ => unreachable!(), + }).unwrap_or(SortBy::None); + // Default to ascending order. + let is_ascending_order = args.order.map_or(true, |node| node.value == "ascending"); let pattern: &str = args.number.1.value.as_ref(); let paths = match glob(pattern) { @@ -1089,7 +1105,81 @@ impl<'a> Expander<'a> { )), }; - let mut expanded = vec![]; + struct GlobPath<'a> { + node: ParseNode<'a>, + meta: std::fs::Metadata, + path: PathBuf, + sortby: SortBy, + } + impl<'a> PartialEq for GlobPath<'a> { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } + } + impl<'a> Eq for GlobPath<'a> { } + impl<'a> PartialOrd for GlobPath<'a> { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + if self.sortby != other.sortby { return None }; + let sortby = self.sortby; + match sortby { + SortBy::Created => { + let Ok(d0) = self.meta.created() else { return Some(std::cmp::Ordering::Less) }; + let Ok(d1) = other.meta.created() else { return Some(std::cmp::Ordering::Less) }; + if d0 == d1 { // If created at the same time, default to alphabetical. + self.path.partial_cmp(&other.path) + } else { + // "ascending" order should be in terms of age. + d0.partial_cmp(&d1).map(|ord| ord.reverse()) // New < Old + } + }, + SortBy::Modified => { + let Ok(d0) = self.meta.modified() else { return Some(std::cmp::Ordering::Less) }; + let Ok(d1) = other.meta.modified() else { return Some(std::cmp::Ordering::Less) }; + if d0 == d1 { // If modified at the same time, default to alphabetical. + self.path.partial_cmp(&other.path) + } else { + // "ascending" order should be in terms of recentness. + d0.partial_cmp(&d1).map(|ord| ord.reverse()) // New < Old + } + }, + SortBy::Name => self.path.partial_cmp(&other.path), + SortBy::Type => { + // First sort by file vs. dir. + if self.meta.is_dir() && other.meta.is_file() { + Some(std::cmp::Ordering::Less) // Folder < File + } else if self.meta.is_file() && other.meta.is_dir() { + Some(std::cmp::Ordering::Greater) // File > Folder + } else if let Some(ext) = self.path.extension() { + if let Some(other_ext) = other.path.extension() { + if ext == other_ext { + self.path.partial_cmp(&other.path) // Sort by name when extension are the same. + } else { + ext.partial_cmp(other_ext) // Sort by different file extensions before name. + } + } else { + Some(std::cmp::Ordering::Greater) // With ext. > No ext. + } + } else { + if let Some(_) = other.path.extension() { + Some(std::cmp::Ordering::Less) // No ext. < With ext. + } else { + self.path.partial_cmp(&other.path) // Sort by name when neither have extensions. + } + } + } + SortBy::None => Some(std::cmp::Ordering::Less), + } + } + } + impl<'a> Ord for GlobPath<'a> { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + assert_eq!(self.sortby, other.sortby); + self.partial_cmp(other).unwrap() + } + } + + // Collect paths. + let mut collection: std::collections::BTreeSet<GlobPath> = Default::default(); for path in paths { let path = match path { Ok(path) => path, @@ -1104,13 +1194,22 @@ impl<'a> Expander<'a> { PathTypes::Dir if !meta.is_dir() => continue, _ => {}, } - expanded.push(ParseNode::String(Node { + let node = ParseNode::String(Node { value: path.to_string_lossy().to_string(), site: args.number.1.site.to_owned(), - leading_whitespace: args.number.1.leading_whitespace.to_owned(), - })); + leading_whitespace: String::from(" "), + }); + collection.insert(GlobPath { node, meta, path, sortby }); } - Ok(expanded.into_boxed_slice()) + let mut expanded = if is_ascending_order { + collection.into_iter().map(|path| path.node).collect::<ParseTree>() + } else { + collection.into_iter().rev().map(|path| path.node).collect::<ParseTree>() + }; + if let Some(first) = expanded.first_mut() { + first.set_leading_whitespace(String::new()); + } + Ok(expanded) } fn expand_raw_macro(&self, node: &ParseNode<'a>, params: ParseTree<'a>) @@ -1157,6 +1256,101 @@ impl<'a> Expander<'a> { ])) } + fn expand_sort_macro(&self, node: &ParseNode<'a>, params: ParseTree<'a>) + -> Result<ParseTree<'a>, ExpansionError<'a>> { + let params = self.expand_nodes(params)?; // Eager. + let (_parser, args) = arguments! { [&params] + mandatory(1): any, + optional("order"): literal["ascending", "descending"], + optional("key"): symbol, + }?; + + let call_node = node; + let key_fn_site = args.key.clone().map_or(call_node.owned_site(), |key_fn| key_fn.site); + let key_fn = args.key.map_or(String::from("do"), |key| key.value); + let ascending = args.order.map_or(true, |order| order.value == "ascending"); + + /// Keeps track of nodes and the key by which to sort them by. + struct SortItem<'b> { + node: ParseNode<'b>, + key: String, + } + + let ParseNode::List { nodes, site, end_token, .. } = args.number.1 else { + return Err(ExpansionError( + format!("`%sort` expects a list, was given {}.", args.number.1.node_type()), + args.number.1.owned_site(), + )) + }; + + let mut items = Vec::with_capacity(nodes.len()); + let mut whitespace = Vec::with_capacity(nodes.len()); // Preserve whitespace order. + for node in nodes { + let key = match self.expand_invocation(&key_fn, call_node, Box::new([node.clone()])) { + Ok(key) => key, + Err(err) => return Err(ExpansionError( + format!("Error evaluating `:key` macro:\n{}", err), + key_fn_site, + )) + }; + if key.len() > 1 { + return Err(ExpansionError( + format!("`:key` macro expanded to more than one value (namely {} values).", key.len()), + key_fn_site, + )); + } else if key.len() != 1 { + return Err(ExpansionError::new( + "`:key` macro did not yield any value to compare against.", + &key_fn_site, + )); + } + + let key = key[0].clone(); + let key_type = key.node_type(); + let key_site = key.owned_site(); + let Some(key) = key.into_atomic() else { + return Err(ExpansionError( + format!("List items in `%sort` must resolve to literals under `:key`; got {} instead.", key_type), + key_site, + )); + }; + let key = key.value; + whitespace.push(node.leading_whitespace().to_owned()); + items.push(SortItem { node, key }); + } + // Whitespace is reversed so .pop() removes the first item. + whitespace.reverse(); + // Sort by the evaluated .key string. + items.sort_by(|item0, item1| { + let (item0, item1) = if ascending { (item0, item1) } else { (item1, item0) }; + // First try to compare as integers, failing that, as floats, and then strings. + if let (Ok(n0), Ok(n1)) = (item0.key.parse::<i64>(), item1.key.parse::<i64>()) { + return n0.cmp(&n1) + } + if let (Ok(f0), Ok(f1)) = (item0.key.parse::<f64>(), item1.key.parse::<f64>()) { + if let Some(ord) = f0.partial_cmp(&f1) { + return ord; + } + } + item0.key.cmp(&item1.key) + }); + // Extract nodes and amend whitespace. + let nodes = items.into_iter().map(|item| { + let mut node = item.node; + node.set_leading_whitespace(whitespace.pop().unwrap()); + node + }).collect(); + + Ok(Box::new([ + ParseNode::List { + nodes, + site, + end_token, + leading_whitespace: node.leading_whitespace().to_owned(), + } + ])) + } + fn expand_join_macro(&self, node: &ParseNode<'a>, params: ParseTree<'a>) -> Result<ParseTree<'a>, ExpansionError<'a>> { let params = self.expand_nodes(params)?; // Eager. @@ -1408,6 +1602,7 @@ impl<'a> Expander<'a> { "toml" => self.expand_toml_macro(node, params), "glob" => self.expand_glob_macro(node, params), "for" => self.expand_for_macro(node, params), + "sort" => self.expand_sort_macro(node, params), "date" => self.expand_date_macro(node, params), "join" => self.expand_join_macro(node, params), "concat" => self.expand_concat_macro(node, params), diff --git a/crates/seam/src/parse/parser.rs b/crates/seam/src/parse/parser.rs @@ -450,7 +450,7 @@ impl<'a> Parser { // Check we are able to consume next expression for keyword's value. { let no_expr_error = ParseError( - format!("Keyword `:{}' expects an expression follwing it.", token.value), + format!("Keyword `:{}' expects an expression following it.", token.value), token.site.to_owned()); if self.lexer.eof() { Err(no_expr_error.clone())? ;} match self.lexer.peek()? { diff --git a/crates/seam/src/tests.rs b/crates/seam/src/tests.rs @@ -0,0 +1,35 @@ +use super::*; + +fn expand<'a>(source: String) -> Result<String, Box<dyn 'a + std::error::Error>> { + let expander = tree_builder(Some("<test>"), source); + let expander = Box::leak(Box::new(expander)); + let tree = expander.expand()?; + let mut ret = String::new(); + for node in tree { + ret += &format!("{}", node); + } + Ok(ret) +} + +mod tests { + use super::*; + + #[test] + fn test_sort_macro_alphanumeric() { + let source = "(%sort (13 d 8 e 14 f 9 10 g 11 12 a b c 0 0.1 1.5 0.2 1 2 -1 -10 -2 -1.4 -0.3) :order ascending)"; + let source = String::from(source); + let output = expand(source).unwrap(); + let expect = "(-10 -2 -1.4 -1 -0.3 0 0.1 0.2 1 1.5 2 8 9 10 11 12 13 14 a b c d e f g)"; + assert_eq!(output, expect); + } + + #[test] + fn test_sort_macro_key() { + // sort by the second element in 3-tuples. + let source = "(%sort :key (%lambda ((x y z)) %y) ((x 3 b) (z 1 a) (y 2 c)))"; + let source = String::from(source); + let output = expand(source).unwrap(); + let expect = "((z 1 a) (y 2 c) (x 3 b))"; + assert_eq!(output, expect); + } +}