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:
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! { [¶ms]
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! { [¶ms]
+ 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);
+ }
+}