human-string-filler version 1.0.0 master
authorChris Morgan <me@chrismorgan.info>
committerChris Morgan <me@chrismorgan.info>
As you may imagine, there was various experimental work before this, and
in fact I changed syntaxes a few times (e.g. escaping with backslashes),
but I decided to throw away that history when publishing it.
And then I didn’t publish it for another year or so, and came back and
realised there was more I wanted to add, and so moved from String to <W:
fmt::Write> to get no-alloc support, and toned down my colon
recommendation and added split_propertied and blah blah blah and bumped
what had been going to be 0.1.0 to 1.0.0 because why not anyway. Then I
read the commit message I had made which had the subsequent paragraphs
in full, and snorted as I contemplated the fact that in the last few
days I’ve published two other crates with the BlueOak-1.0.0 option, when
clearly I had intended this to be my first. Eh, c’est la vie, as I so
often seem to end up saying. Well, on with the rest:

At some point someone may ask why no license text is included for MIT
(which requires it) or Apache-2.0 (which requires at least a notice of
similar length to the full MIT license, even if not the full text of the
license; concerning putting such a header in every source file, lawyers
tend to say “do it”, but I don’t believe the license actually requires
that). This is a deliberate decision on my part; BlueOak-1.0.0 doesn’t
require it, deeming a link sufficient in this era, where it wasn’t when
MIT and Apache-2.0 were made. So I’m applying common sense and clear
intent, both of which have stronger value than legalese. The whole thing
is a gentleman’s agreement anyway; my conscience would not allow me to
take legal action on any violations (see 1 Corinthians 6 for the general
theme of reasoning, even though it’s not the same situation and thus not
directly applicable).

As for the inclusion of BlueOak-1.0.0 as an alternative to MIT and
Apache-2.0? I prefer its comparative simplicity and completeness.

(Fun aside: when you can choose between two licenses, we call it
dual-licensing. When you can choose between three licenses, the
equivalent term would be trial-licensing. That term is… inapt.)
.gitignore [new file with mode: 0644]
COPYRIGHT [new file with mode: 0644]
Cargo.toml [new file with mode: 0644]
README.md [new file with mode: 0644]
design-discussion.md [new file with mode: 0644]
src/lib.rs [new file with mode: 0644]
test [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..e9e2199
--- /dev/null
@@ -0,0 +1,2 @@
+/target/
+/Cargo.lock
diff --git a/COPYRIGHT b/COPYRIGHT
new file mode 100644 (file)
index 0000000..2cced42
--- /dev/null
+++ b/COPYRIGHT
@@ -0,0 +1,17 @@
+Copyright © 2022 Chris Morgan
+
+This project is distributed under the terms of three different licenses,
+at your choice:
+
+- Blue Oak Model License 1.0.0: https://blueoakcouncil.org/license/1.0.0
+- MIT License: https://opensource.org/licenses/MIT
+- Apache License, Version 2.0: https://www.apache.org/licenses/LICENSE-2.0
+
+If you do not have particular cause to select the MIT or the Apache-2.0
+license, Chris Morgan recommends that you select BlueOak-1.0.0, which is
+better and simpler than both MIT and Apache-2.0, which are only offered
+due to their greater recognition and their conventional use in the Rust
+ecosystem. (BlueOak-1.0.0 was only published in March 2019.)
+
+When using this code, ensure you comply with the terms of at least one of
+these licenses.
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644 (file)
index 0000000..038f115
--- /dev/null
@@ -0,0 +1,20 @@
+[package]
+name = "human-string-filler"
+description = "A tiny template language for human-friendly string substitutions"
+authors = ["Chris Morgan <rust@chrismorgan.info>"]
+license = "BlueOak-1.0.0 OR MIT OR Apache-2.0"
+version = "1.0.0"
+edition = "2021"
+keywords = ["template", "format"]
+categories = ["no-std", "template-engine", "text-processing"]
+repository = "https://gitlab.com/chris-morgan/human-string-filler"
+exclude = ["design-discussion.md"]
+
+[package.metadata.docs.rs]
+all-features = true
+rustdoc-args = ["--cfg", "docsrs"]
+
+[features]
+default = ["std"]
+std = ["alloc"]
+alloc = []
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..7dff6a0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,170 @@
+# human-string-filler
+
+A tiny template language for human-friendly string substitutions.
+
+This crate is intended for situations where you need the user to be able to write simple
+templated strings, and conveniently evaluate them. It’s deliberately simple so that there are
+no surprises in its performance or functionality, and so that it’s not accidentally tied to
+Rust (e.g. you can readily implement it in a JavaScript-powered web app), which would happen
+if things like number formatting specifiers were included out of the box—instead, if you want
+that sort of thing, you’ll have to implement it yourself (don’t worry, it won’t be hard).
+
+No logic is provided in this template language, only simple string formatting: `{…}` template
+regions get replaced in whatever way you decide, curly braces get escaped by doubling them
+(`{{` and `}}`), and *that’s it*.
+
+## Sample usage
+
+The **lowest-level** handling looks like this:
+
+```rust
+use human_string_filler::{StrExt, SimpleFillerError};
+
+let mut output = String::new();
+"Hello, {name}!".fill_into(&mut output, |output: &mut String, key: &str| {
+    match key {
+        "name" => output.push_str("world"),
+        _ => return Err(SimpleFillerError::NoSuchKey),
+    }
+    Ok(())
+}).unwrap();
+
+assert_eq!(output, "Hello, world!");
+```
+
+`template.fill_into(output, filler)` (provided by `StrExt`) can also be spelled
+`fill(template, filler, output)` if you prefer a function to a method
+(I reckon the method syntax is clearer, but opinions will differ so I provided both).
+
+The filler function appends to the string directly for efficiency in case of computed values,
+and returns `Result<(), E>`; any error will become `Err(Error::BadReplacement { error, .. })`
+on the fill call. (In this example I’ve used `SimpleFillerError::NoSuchKey`, but `()` would
+work almost as well, or you can write your own error type altogether.)
+
+This example showed a closure that took `&mut String` and used `.push_str(…)`, but this crate
+is not tied to `String` in any way: for greater generality you would use a function generic
+over a type that implements `std::fmt::Write`, and use `.write_str(…)?` inside (`?` works there
+because `SimpleFillerError` implements `From<std::fmt::Error>`).
+
+At a **higher level**, you can use a string-string map as a filler, and you can also fill
+directly to a `String` with `.fill_to_string()` (also available as a standalone function
+`fill_to_string`):
+
+```rust
+use std::collections::HashMap;
+use human_string_filler::StrExt;
+
+let mut map = HashMap::new();
+map.insert("name", "world");
+
+let s = "Hello, {name}!".fill_to_string(&map);
+
+assert_eq!(s.unwrap(), "Hello, world!");
+```
+
+Or you can implement the `Filler` trait for some other type of your own if you like.
+
+## Cargo features
+
+- **std** (enabled by default): remove for `#![no_std]` operation. Implies *alloc*.
+  - Implementation of `std::error::Error` for `Error`;
+  - Implementation of `Filler` for `&HashMap`.
+
+- **alloc** (enabled by default via *std*):
+  - Implementation of `Filler` for `&BTreeMap`.
+  - `fill_to_string` and `StrExt::fill_to_string`.
+
+## The template language
+
+This is the grammar of the template language in [ABNF](https://tools.ietf.org/html/rfc5234):
+
+```abnf
+unescaped-normal-char = %x00-7A / %x7C / %x7E-D7FF / %xE000-10FFFF
+                      ; any Unicode scalar value except for "{" and "}"
+
+normal-char           = unescaped-normal-char / "{{" / "}}"
+
+template-region       = "{" *unescaped-normal-char "}"
+
+template-string       = *( normal-char / template-region )
+```
+
+This regular expression will validate a template string:
+
+```text
+^([^{}]|\{\{|\}\}|\{[^{}]*\})*$
+```
+
+Sample legal template strings:
+
+- The empty string
+- `Hello, {name}!`: one template region with key "name".
+- `Today is {date:short}`: one template region with key "date:short". (Although there’s no
+  format specification like with the `format!()` macro, a colon convention is one reasonable
+  option—see the next section.)
+- `Hello, {}!`: one template region with an empty key, not recommended but allowed.
+- `Escaped {{ braces {and replacements} for {fun}!`: string "Escaped { braces ", followed by a
+  template region with key "and replacements", followed by string " for ", followed by a
+  template region with key "fun", followed by string "!".
+
+Sample illegal template strings:
+
+- `hello, {world}foo}`: opening and closing curlies must match; any others (specifically, the
+  last character of the string) must be escaped by doubling.
+- `{{thing}`: the `{{` is an escaped opening curly, so the `}` is unmatched.
+- `{thi{{n}}g}`: no curlies of any form inside template region keys. (It’s possible that a
+  future version may make it possible to escape curlies inside template regions, if it proves
+  to be useful in something like format specifiers; but not at this time.)
+
+## Conventions on key semantics
+
+The key is an arbitrary string (except that it can’t contain `{` or `}`) with explicitly no
+defined semantics, but here are some suggestions, including helper functions:
+
+1. If it makes sense to have a format specifier (e.g. to specify a date format to use, or
+   whether to pad numbers with leading zeroes, *&c.*), split once on a character like `:`.
+   To do this most conveniently, a function `split_on` is provided.
+
+2. For more advanced formatting where you have multiple properties you could wish to set,
+   `split_propertied` offers some sound and similarly simple semantics for such strings as
+   `{key prop1 prop2=val2}` and `{key:prop1,prop2=val2}`.
+
+3. If it makes sense to have nested property access, split on `.` with the `key.split('.')`
+   iterator. (If you’re using `split_on` or `split_propertied` as mentioned above, you
+   probably want to apply them first to separate out the key part.)
+
+4. Only use [UAX #31 identifiers](https://www.unicode.org/reports/tr31/) for the key
+   (or keys, if supporting nested property access). Most of the time, empty strings and
+   numbers are probably not a good idea.
+
+With these suggestions, you might end up with the key `foo.bar:baz` being interpreted as
+retrieving the “bar” property from the “foo” object, and formatting it according to “baz”; or
+`aleph.beth.gimmel|alpha beta=5` as retrieving “gimmel” from “beth” of “aleph”, and formatting
+it with properties “alpha” set to true and “beta” set to 5. What those things actually *mean*
+is up to you to decide. *I* certainly haven’t a clue.
+
+## Author
+
+[Chris Morgan](https://chrismorgan.info/)
+([chris-morgan](https://gitlab.com/chris-morgan))
+is the author and maintainer of human-string-filler.
+
+## License
+
+Copyright © 2022 Chris Morgan
+
+This project is distributed under the terms of three different licenses,
+at your choice:
+
+- Blue Oak Model License 1.0.0: https://blueoakcouncil.org/license/1.0.0
+- MIT License: https://opensource.org/licenses/MIT
+- Apache License, Version 2.0: https://www.apache.org/licenses/LICENSE-2.0
+
+If you do not have particular cause to select the MIT or the Apache-2.0
+license, Chris Morgan recommends that you select BlueOak-1.0.0, which is
+better and simpler than both MIT and Apache-2.0, which are only offered
+due to their greater recognition and their conventional use in the Rust
+ecosystem. (BlueOak-1.0.0 was only published in March 2019.)
+
+When using this code, ensure you comply with the terms of at least one of
+these licenses.
diff --git a/design-discussion.md b/design-discussion.md
new file mode 100644 (file)
index 0000000..71e33a2
--- /dev/null
@@ -0,0 +1,137 @@
+# A discussion of parts of the design and trade-offs in this library
+
+## Terminology and semantics
+
+- `Hello, {thing}!` is a template string.
+- In that, `{thing}` is a template region.
+- In that, `thing` is a key.
+- `{{` is an escaped opening curly brace.
+- `}}` is an escaped closing curly brace.
+
+Beyond that, these are *suggestions* for template region semantics:
+
+- In the `{post.date:short}` template region:
+  - `post.date` is a path (the `date` field within the `post` object);
+  - `short` is a format specifier, which we might here imagine to mean “use
+    localised date formatting, short form”.
+
+- In the `{post.date time hour=24}` and `{post.date:time,hour=24}`
+  template regions (I’m not suggesting particular characters):
+  - `post.date` is a path (the `date` field within the `post` object);
+  - `time` is a boolean property, and `hour` a property with value `24`,
+    which we might here imagine to mean “show the time, in 24-hour mode”.
+
+## Explanation of escaping
+
+It is necessary that literal `{` and `}` be able to be expressed, even if
+they’re rare; there are three common techniques for achieving this:
+
+1. **Reserve certain template region keys to signify the delimiters,** e.g.
+   `{open_curly}` and `{close_curly}` to expand to `{` and `}`. Downsides: may
+   clash with real template region keys when arbitrary user keys are supported;
+   and it’s generally less memorable.
+2. **Double the delimiter:** `{{` expands to `{` and `}}` to `}`. Seen in
+   string delimiters in a few languages, so `"foo""bar"` means “foo"bar”.
+   Downside: makes including the delimiters inside the key a difficult
+   proposition (see below).
+3. **Escape by prefixing with another selected character:** select an escape
+   character which is also rare, most commonly a backslash due to its
+   convention in programming languages. Problems with using backslash: if you
+   need to represent Windows paths, literal backslashes aren’t so rare after
+   all, and you need to escape them all; and if you need to encode the template
+   string in a string literal in another language that uses the same escape
+   character (e.g. a TOML config file), all backslashes must now be escaped
+   again, e.g. string literals `"C:\\\\dir"` or `"\\{ ← literal curly"`.
+
+(A fourth technique that can be employed is delimiting the replacement regions
+with values that will not appear in the desired text, as with the ␜, ␝, ␞ and ␟
+separator control characters in ASCII—and that more or less failed because it
+was roughly machine-writable only, and so file types like CSV prospered due to
+being human-writable, despite the acconpanying escaping woes. This language
+requires that the delimiters be easy for the user to type, so this fourth
+technique is deemed impossible—all values that are easy to type may occur in
+regular text.)
+
+One interesting consideration is whether you can express the delimiters inside
+template regions. For simplicity, I’ve currently banned curlies inside template
+regions, but there are things to consider if it becomes desirable to express it
+later (e.g. in a format specifier). Here’s how each of the three techniques
+previously mentioned handles it:
+
+1. Solvable in two ways, both of which require that curlies be paired inside
+   template regions:
+   (a) Replace only `{open_curly}` and `{close_curly}` literally inside
+       template regions; or
+   (b) Perform recursive template resolution!
+
+2. Well, should `{foo}}}` mean “template region with key ‘foo’ followed by
+   literal ‘}’”, or “template region with key ‘foo}’”? If you wish both to be
+   expressible, you need to provide some means of disambiguation. The most
+   straightforward is to ban a certain character at the start and end of
+   template regions, most probably HORIZONTAL SPACE (that is: declare “no
+   leading or trailing whitespace”), and then repurpose that to behave
+   essentially the same as `#` on Rust’s raw string literals, where you can
+   write `r"…"`, `r#"…"`, `r##"…"##`, *&c.* With this, `{foo}}}` would mean
+   “template region with key ‘foo’ followed by literal ‘}’”, and `{ foo} }`
+   would mean “template region with key ‘foo}’”. (Note how escaping curlies
+   inside template regions is not required. Decide for yourself whether that’s
+   good or bad.)
+
+3. Allow escapes inside the template region, e.g. `\{{\{\}}\}` would mean
+   “literal ‘{’ followed by template region with key ‘{}’ followed by literal
+   ‘}’”. Straightforward.
+
+This then leads to three possibilities:
+(a) full recursive template rendering;
+(b) arbitrary keys, or *almost* arbitrary if leading and trailing whitespace is
+    disallowed or ignored (depending on the approach); or
+(c) no curlies in keys please.
+
+I initially implemented the third technique (escape with backslash, and allow
+escapes in template regions), but the downsides of using backslashes became too
+severe for my liking, and I didn’t like any alternative characters on the
+keyboard as much as just doubling the delimiter, so I went with the second
+technique in the end. I don’t expect its downside to bite me, but we’ll see.
+
+## Other considered syntaxes
+
+I settled on using `{…}` for template regions. I also contemplated `$…`, but
+ruled it for the combination of these reasons:
+
+- it requires embedding UAX #31 identifier parsing which is heavier and less
+  *definitely obvious* for real users than I’d like;
+
+- for things like nested property access and format specifiers you’d need a
+  variant with a closing delimiter anyway, such as `${…}`, so now you’d have
+  *two* formats;
+
+- all this makes it harder to explain all the features of the language to
+  users.
+
+So `$…` was eliminated as too complex and insufficient.
+
+So it became more a toss-up between `$…$`, `%…%`, `${…}` and `{…}`.
+
+- `$…$` was eliminated because it’s not using any paired delimiting characters,
+  and because more programmery types would be more likely to expect `$…` than
+  `$…$`.
+
+- `%…%` has precedent in Windows’ environment variables, but again the lack of
+  *paired* delimiting characters makes it not so great. Also it just feels
+  uglier in the standard sort of case I have in mind. I’m picturing something
+  like `%ALBUM%/%TRACK% - %TITLE%.mp3`. (It doesn’t *have* to be uppercase, but
+  I find that using percent makes me think uppercase.)
+
+- Given my intended purpose (*small* strings rather than big multi-line
+  strings), the `$` in `${…}` didn’t feel necessary: consider for example
+  `{album}/{track} - {title}.mp3`, which feels about right, compared with
+  `${album}/${track} - ${title}.mp3`, which *works*, but feels… I dunno,
+  programmery rather than usery.
+
+So `{…}` won.
+
+Taken with the `{{` and `}}` escaping, this makes it match Rust’s format
+strings; this is purely coincidental.
+
+I’m pretty sure I considered `[…]` at some point too, but decided I didn’t like
+it as much as `{…}`.
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644 (file)
index 0000000..6d98850
--- /dev/null
@@ -0,0 +1,853 @@
+#![cfg_attr(not(feature = "std"), no_std)]
+#![deny(missing_docs)]
+#![cfg_attr(docsrs, feature(doc_cfg))]
+//! A tiny template language for human-friendly string substitutions.
+//!
+//! This crate is intended for situations where you need the user to be able to write simple
+//! templated strings, and conveniently evaluate them. It’s deliberately simple so that there are
+//! no surprises in its performance or functionality, and so that it’s not accidentally tied to
+//! Rust (e.g. you can readily implement it in a JavaScript-powered web app), which would happen
+//! if things like number formatting specifiers were included out of the box—instead, if you want
+//! that sort of thing, you’ll have to implement it yourself (don’t worry, it won’t be hard).
+//!
+//! No logic is provided in this template language, only simple string formatting: `{…}` template
+//! regions get replaced in whatever way you decide, curly braces get escaped by doubling them
+//! (`{{` and `}}`), and *that’s it*.
+//!
+//! ## Sample usage
+//!
+//! The **lowest-level** handling looks like this:
+//!
+//! ```rust
+//! use human_string_filler::{StrExt, SimpleFillerError};
+//!
+//! let mut output = String::new();
+//! "Hello, {name}!".fill_into(&mut output, |output: &mut String, key: &str| {
+//!     match key {
+//!         "name" => output.push_str("world"),
+//!         _ => return Err(SimpleFillerError::NoSuchKey),
+//!     }
+//!     Ok(())
+//! }).unwrap();
+//!
+//! assert_eq!(output, "Hello, world!");
+//! ```
+//!
+//! `template.fill_into(output, filler)` (provided by `StrExt`) can also be spelled
+//! `fill(template, filler, output)` if you prefer a function to a method
+//! (I reckon the method syntax is clearer, but opinions will differ so I provided both).
+//!
+//! The filler function appends to the string directly for efficiency in case of computed values,
+//! and returns `Result<(), E>`; any error will become `Err(Error::BadReplacement { error, .. })`
+//! on the fill call. (In this example I’ve used `SimpleFillerError::NoSuchKey`, but `()` would
+//! work almost as well, or you can write your own error type altogether.)
+//!
+//! This example showed a closure that took `&mut String` and used `.push_str(…)`, but this crate
+//! is not tied to `String` in any way: for greater generality you would use a function generic
+//! over a type that implements `std::fmt::Write`, and use `.write_str(…)?` inside (`?` works there
+//! because `SimpleFillerError` implements `From<std::fmt::Error>`).
+//!
+//! At a **higher level**, you can use a string-string map as a filler, and you can also fill
+//! directly to a `String` with `.fill_to_string()` (also available as a standalone function
+//! `fill_to_string`):
+//!
+//! ```rust
+//! # #[cfg(feature = "std")] {
+//! use std::collections::HashMap;
+//! use human_string_filler::StrExt;
+//!
+//! let mut map = HashMap::new();
+//! map.insert("name", "world");
+//!
+//! let s = "Hello, {name}!".fill_to_string(&map);
+//!
+//! assert_eq!(s.unwrap(), "Hello, world!");
+//! # }
+//! ```
+//!
+//! Or you can implement the [`Filler`] trait for some other type of your own if you like.
+//!
+//! ## Cargo features
+//!
+#![cfg_attr(
+    feature = "std",
+    doc = " \
+    - **std** (enabled by default, enabled in this build): remove for `#![no_std]` operation. \
+      Implies *alloc*.\
+"
+)]
+#![cfg_attr(
+    not(feature = "std"),
+    doc = " \
+    - **std** (enabled by default, *disabled* in this build): remove for `#![no_std]` operation. \
+      Implies *alloc*.\
+"
+)]
+//!     - Implementation of `std::error::Error` for `Error`;
+//!     - Implementation of `Filler` for `&HashMap`.
+//!
+#![cfg_attr(
+    feature = "alloc",
+    doc = " \
+    - **alloc** (enabled by default via *std*, enabled in this build):\
+"
+)]
+#![cfg_attr(
+    not(feature = "alloc"),
+    doc = " \
+    - **alloc** (enabled by default via *std*, disabled in this build):\
+"
+)]
+//!     - Implementation of `Filler` for `&BTreeMap`.
+//!     - `fill_to_string` and `StrExt::fill_to_string`.
+//!
+//! ## The template language
+//!
+//! This is the grammar of the template language in [ABNF](https://tools.ietf.org/html/rfc5234):
+//!
+//! ```abnf
+//! unescaped-normal-char = %x00-7A / %x7C / %x7E-D7FF / %xE000-10FFFF
+//!                       ; any Unicode scalar value except for "{" and "}"
+//!
+//! normal-char           = unescaped-normal-char / "{{" / "}}"
+//!
+//! template-region       = "{" *unescaped-normal-char "}"
+//!
+//! template-string       = *( normal-char / template-region )
+//! ```
+//!
+//! This regular expression will validate a template string:
+//!
+//! ```text
+//! ^([^{}]|\{\{|\}\}|\{[^{}]*\})*$
+//! ```
+//!
+//! Sample legal template strings:
+//!
+//! - The empty string
+//! - `Hello, {name}!`: one template region with key "name".
+//! - `Today is {date:short}`: one template region with key "date:short". (Although there’s no
+//!   format specification like with the `format!()` macro, a colon convention is one reasonable
+//!   option—see the next section.)
+//! - `Hello, {}!`: one template region with an empty key, not recommended but allowed.
+//! - `Escaped {{ braces {and replacements} for {fun}!`: string "Escaped { braces ", followed by a
+//!   template region with key "and replacements", followed by string " for ", followed by a
+//!   template region with key "fun", followed by string "!".
+//!
+//! Sample illegal template strings:
+//!
+//! - `hello, {world}foo}`: opening and closing curlies must match; any others (specifically, the
+//!   last character of the string) must be escaped by doubling.
+//! - `{{thing}`: the `{{` is an escaped opening curly, so the `}` is unmatched.
+//! - `{thi{{n}}g}`: no curlies of any form inside template region keys. (It’s possible that a
+//!   future version may make it possible to escape curlies inside template regions, if it proves
+//!   to be useful in something like format specifiers; but not at this time.)
+//!
+//! ## Conventions on key semantics
+//!
+//! The key is an arbitrary string (except that it can’t contain `{` or `}`) with explicitly no
+//! defined semantics, but here are some suggestions, including helper functions:
+//!
+//! 1. If it makes sense to have a format specifier (e.g. to specify a date format to use, or
+//!    whether to pad numbers with leading zeroes, *&c.*), split once on a character like `:`.
+//!    To do this most conveniently, a function [`split_on`] is provided.
+//!
+//! 2. For more advanced formatting where you have multiple properties you could wish to set,
+//!    [`split_propertied`] offers some sound and similarly simple semantics for such strings as
+//!    `{key prop1 prop2=val2}` and `{key:prop1,prop2=val2}`.
+//!
+//! 3. If it makes sense to have nested property access, split on `.` with the `key.split('.')`
+//!    iterator. (If you’re using `split_on` or `split_propertied` as mentioned above, you
+//!    probably want to apply them first to separate out the key part.)
+//!
+//! 4. Only use [UAX #31 identifiers](https://www.unicode.org/reports/tr31/) for the key
+//!    (or keys, if supporting nested property access). Most of the time, empty strings and
+//!    numbers are probably not a good idea.
+//!
+//! With these suggestions, you might end up with the key `foo.bar:baz` being interpreted as
+//! retrieving the “bar” property from the “foo” object, and formatting it according to “baz”; or
+//! `aleph.beth.gimmel|alpha beta=5` as retrieving “gimmel” from “beth” of “aleph”, and formatting
+//! it with properties “alpha” set to true and “beta” set to 5. What those things actually *mean*
+//! is up to you to decide. *I* certainly haven’t a clue.
+
+use core::fmt;
+use core::iter::FusedIterator;
+use core::ops::Range;
+
+#[cfg(feature = "alloc")]
+extern crate alloc;
+
+#[cfg(feature = "alloc")]
+use alloc::string::String;
+
+#[cfg(feature = "alloc")]
+use alloc::collections::BTreeMap;
+#[cfg(feature = "alloc")]
+use core::borrow::Borrow;
+#[cfg(feature = "std")]
+use std::collections::HashMap;
+#[cfg(feature = "std")]
+use std::hash::Hash;
+
+/// Any error that occurs when filling a template string.
+///
+/// Template parsing and filling is all done in a single pass; so a failed replacement due to an
+/// unknown key will shadow a syntax error later in the string.
+#[derive(Debug, PartialEq, Eq)]
+pub enum Error<'a, E> {
+    /// A template region was not closed.
+    /// That is, an opening curly brace (`{`) with no matching closing curly brace (`}`).
+    ///
+    /// Example:
+    ///
+    /// ```rust
+    /// # #[cfg(feature = "alloc")] {
+    /// # use human_string_filler::{StrExt, Error};
+    /// # assert_eq!(
+    /// "Hello, {thing"
+    /// # .fill_to_string(|_: &mut String, _: &str| Result::<(), ()>::Ok(())),
+    /// # Err(Error::UnclosedRegion { source: "{thing", range: 7..13 }),
+    /// # );
+    /// # }
+    /// ```
+    UnclosedRegion {
+        /// The text of the unclosed region, which will start with `{` and contain no other curly
+        /// braces.
+        source: &'a str,
+        /// The indexes of `source` within the template string.
+        range: Range<usize>,
+    },
+
+    /// An unescaped closing curly brace (`}`) was found, outside a template region.
+    ///
+    /// Examples:
+    ///
+    /// ```rust
+    /// # #[cfg(feature = "alloc")] {
+    /// # use human_string_filler::{StrExt, Error};
+    /// # assert_eq!(
+    /// "Hello, thing}!"
+    /// # .fill_to_string(|_: &mut String, _: &str| Result::<(), ()>::Ok(())),
+    /// # Err(Error::UnexpectedClosingBrace { index: 12 }),
+    /// # );
+    /// # assert_eq!(
+    /// "Hello, {name}, look at my magnificent moustache: (}-:"
+    /// # .fill_to_string(|_: &mut String, _: &str| Result::<(), ()>::Ok(())),
+    /// # Err(Error::UnexpectedClosingBrace { index: 50 }),
+    /// # );
+    /// # assert_eq!(
+    /// "Hello, {name}}!"
+    /// # .fill_to_string(|_: &mut String, _: &str| Result::<(), ()>::Ok(())),
+    /// # Err(Error::UnexpectedClosingBrace { index: 13 }),
+    /// # );
+    /// # }
+    /// ```
+    UnexpectedClosingBrace {
+        /// The index of the closing brace within the template string.
+        index: usize,
+    },
+
+    /// An opening curly brace (`{`) was found within a template region.
+    ///
+    /// Examples:
+    ///
+    /// ```rust
+    /// # #[cfg(feature = "alloc")] {
+    /// # use human_string_filler::{StrExt, Error};
+    /// # assert_eq!(
+    /// "Hello, {thing{{sadness}}}"
+    /// # .fill_to_string(|_: &mut String, _: &str| Result::<(), ()>::Ok(())),
+    /// # Err(Error::UnexpectedOpeningBrace { index: 13 }),
+    /// # );
+    /// # }
+    /// ```
+    UnexpectedOpeningBrace {
+        /// The index of the opening brace within the template string.
+        index: usize,
+    },
+
+    /// The filler returned an error for the specified key.
+    BadReplacement {
+        /// The key on which the filler failed. Curly braces not included.
+        key: &'a str,
+        /// The indexes of `key` within the template string.
+        range: Range<usize>,
+        /// The error value returned by the filler.
+        error: E,
+    },
+
+    /// Writing to the output failed.
+    WriteFailed(fmt::Error),
+}
+
+impl<'a, E> From<fmt::Error> for Error<'a, E> {
+    fn from(e: fmt::Error) -> Self {
+        Error::WriteFailed(e)
+    }
+}
+
+impl<'a, E> fmt::Display for Error<'a, E>
+where
+    E: fmt::Display,
+{
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Error::UnclosedRegion { source, .. } => {
+                write!(f, "Unclosed template region at \"{}\"", source)
+            }
+
+            Error::UnexpectedClosingBrace { index } => {
+                write!(f, "Unexpected closing brace at index {}", index)
+            }
+
+            Error::UnexpectedOpeningBrace { index } => {
+                write!(
+                    f,
+                    "Unexpected curly brace within template region at index {}",
+                    index
+                )
+            }
+
+            Error::BadReplacement { key, error, .. } => {
+                write!(f, "Error in template string at \"{{{}}}\": {}", key, error)
+            }
+
+            Error::WriteFailed(fmt::Error) => f.write_str("Error in writing output"),
+        }
+    }
+}
+
+#[cfg(feature = "std")]
+#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
+impl<'a, E> std::error::Error for Error<'a, E>
+where
+    E: std::error::Error + 'static,
+{
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        match self {
+            Error::BadReplacement { error, .. } => Some(error),
+            Error::WriteFailed(error) => Some(error),
+            _ => None,
+        }
+    }
+}
+
+/// Implementers of this trait have the ability to fill template strings.
+///
+/// It is extremely strongly recommended that fillers only push to the output, and do not perform
+/// any other modifications of it.
+///
+/// I mean, if you implement `Filler<String, _>`, you get a `&mut String` and it’s *possible* to do
+/// other things with it, but that’s a terrible idea. I’m almost ashamed of ideas like making `{␡}`
+/// pop the last character, and `{←rot13}` ROT-13-encode what precedes it in the string.
+pub trait Filler<W, E>
+where
+    W: fmt::Write,
+{
+    /// Fill the value for the given key into the output string.
+    fn fill(&mut self, output: &mut W, key: &str) -> Result<(), E>;
+}
+
+impl<F, W, E> Filler<W, E> for F
+where
+    F: FnMut(&mut W, &str) -> Result<(), E>,
+    W: fmt::Write,
+{
+    fn fill(&mut self, output: &mut W, key: &str) -> Result<(), E> {
+        self(output, key)
+    }
+}
+
+#[cfg_attr(not(feature = "std"), allow(rustdoc::broken_intra_doc_links))]
+/// A convenient error type for fillers; you might even like to use it yourself.
+///
+/// You could also use `()`, but this gives you
+/// <code>[From](core::convert::From)&lt;[core::fmt::Error]></code> so that you can use
+/// `write!(out, …)?`, and sane [`core::fmt::Display`] and [`std::error::Error`] implementations.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum SimpleFillerError {
+    /// The map didn’t contain the requested key.
+    NoSuchKey,
+    /// Some fmt::Write operation returned an error.
+    WriteFailed(fmt::Error),
+}
+
+impl From<fmt::Error> for SimpleFillerError {
+    fn from(e: fmt::Error) -> Self {
+        SimpleFillerError::WriteFailed(e)
+    }
+}
+
+impl fmt::Display for SimpleFillerError {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            SimpleFillerError::NoSuchKey => f.write_str("no such key"),
+            SimpleFillerError::WriteFailed(fmt::Error) => f.write_str("write failed"),
+        }
+    }
+}
+
+#[cfg(feature = "std")]
+#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
+impl std::error::Error for SimpleFillerError {
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        match self {
+            SimpleFillerError::WriteFailed(error) => Some(error),
+            _ => None,
+        }
+    }
+}
+
+#[cfg(feature = "std")]
+#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
+impl<K, V, W> Filler<W, SimpleFillerError> for &HashMap<K, V>
+where
+    K: Borrow<str> + Eq + Hash,
+    V: AsRef<str>,
+    W: fmt::Write,
+{
+    fn fill(&mut self, output: &mut W, key: &str) -> Result<(), SimpleFillerError> {
+        self.get(key)
+            .ok_or(SimpleFillerError::NoSuchKey)
+            .and_then(|value| output.write_str(value.as_ref()).map_err(Into::into))
+    }
+}
+
+#[cfg(feature = "alloc")]
+#[cfg_attr(docsrs, doc(cfg(feature = "alloc")))]
+impl<K, V, W> Filler<W, SimpleFillerError> for &BTreeMap<K, V>
+where
+    K: Borrow<str> + Ord,
+    V: AsRef<str>,
+    W: fmt::Write,
+{
+    fn fill(&mut self, output: &mut W, key: &str) -> Result<(), SimpleFillerError> {
+        self.get(key)
+            .ok_or(SimpleFillerError::NoSuchKey)
+            .and_then(|value| output.write_str(value.as_ref()).map_err(Into::into))
+    }
+}
+
+/// String extension methods for the template string.
+///
+/// This is generally how I recommend using this library, because I find that the method receiver
+/// makes code clearer: that `template.fill_into(output, filler)` is easier to understand than
+/// `fill(template, filler, output)`.
+pub trait StrExt {
+    /// Fill this template, producing a new string.
+    ///
+    /// This is a convenience method for ergonomics in the case where you aren’t fussed about
+    /// allocations and are using the standard `String` type.
+    ///
+    #[cfg_attr(feature = "std", doc = " Example, using a hash map:")]
+    #[cfg_attr(
+        not(feature = "std"),
+        doc = " Example, using a hash map (requires the *std* feature):"
+    )]
+    ///
+    /// ```rust
+    /// # #[cfg(feature = "std")] {
+    /// # use human_string_filler::StrExt;
+    /// # use std::collections::HashMap;
+    /// let map = [("name", "world")].into_iter().collect::<HashMap<_, _>>();
+    /// assert_eq!(
+    ///     "Hello, {name}!".fill_to_string(&map).unwrap(),
+    ///     "Hello, world!",
+    /// );
+    /// # }
+    /// ```
+    #[cfg(feature = "alloc")]
+    #[cfg_attr(docsrs, doc(cfg(feature = "alloc")))]
+    fn fill_to_string<F, E>(&self, filler: F) -> Result<String, Error<E>>
+    where
+        F: Filler<String, E>,
+    {
+        let mut out = String::new();
+        self.fill_into(&mut out, filler).map(|()| out)
+    }
+
+    /// Fill this template string into the provided string, with the provided filler.
+    ///
+    /// Uses an existing string, which is more efficient if you want to push to an existing string
+    /// or can reuse a string allocation.
+    ///
+    /// Example, using a closure:
+    ///
+    /// ```rust
+    /// # use human_string_filler::StrExt;
+    /// let filler = |output: &mut String, key: &str| {
+    ///     match key {
+    ///         "name" => output.push_str("world"),
+    ///         _ => return Err(()),
+    ///     }
+    ///     Ok(())
+    /// };
+    /// let mut string = String::new();
+    /// assert!("Hello, {name}!".fill_into(&mut string, filler).is_ok());
+    /// assert_eq!(string, "Hello, world!");
+    /// ```
+    fn fill_into<F, W, E>(&self, output: &mut W, filler: F) -> Result<(), Error<E>>
+    where
+        F: Filler<W, E>,
+        W: fmt::Write;
+}
+
+impl StrExt for str {
+    #[inline]
+    fn fill_into<F, W, E>(&self, output: &mut W, filler: F) -> Result<(), Error<E>>
+    where
+        F: Filler<W, E>,
+        W: fmt::Write,
+    {
+        fill(self, filler, output)
+    }
+}
+
+/// The lowest-level form, as a function: fill the template string, into a provided writer.
+///
+/// This is the most efficient form. It splits a string by `{…}` sections, adding anything outside
+/// them to the output string (with escaped curlies dedoubled) and passing template regions through
+/// the filler, which handles pushing to the output string itself.
+///
+/// See also [`StrExt::fill_into`] which respells `fill(template, filler, output)` as
+/// `template.fill_into(output, filler)`.
+pub fn fill<'a, F, W, E>(
+    mut template: &'a str,
+    mut filler: F,
+    output: &mut W,
+) -> Result<(), Error<'a, E>>
+where
+    F: Filler<W, E>,
+    W: fmt::Write,
+{
+    let mut index = 0;
+    loop {
+        if let Some(i) = template.find(|c| c == '{' || c == '}') {
+            #[allow(clippy::wildcard_in_or_patterns)]
+            match template.as_bytes()[i] {
+                c @ b'}' | c @ b'{' if template.as_bytes().get(i + 1) == Some(&c) => {
+                    output.write_str(&template[0..i + 1])?;
+                    template = &template[i + 2..];
+                    index += i + 2;
+                }
+                b'}' => return Err(Error::UnexpectedClosingBrace { index: index + i }),
+                b'{' | _ => {
+                    // (_ here just to lazily skip an unreachable!().)
+                    output.write_str(&template[0..i])?;
+                    template = &template[i..];
+                    index += i;
+                    if let Some(i) = template[1..].find(|c| c == '{' || c == '}') {
+                        match template.as_bytes()[i + 1] {
+                            b'}' => {
+                                if let Err(e) = filler.fill(output, &template[1..i + 1]) {
+                                    return Err(Error::BadReplacement {
+                                        key: &template[1..i + 1],
+                                        range: (index + 1)..(index + i + 1),
+                                        error: e,
+                                    });
+                                }
+                                template = &template[i + 2..];
+                                index += i + 2;
+                            }
+                            // (Again, _ is unreachable.)
+                            b'{' | _ => {
+                                return Err(Error::UnexpectedOpeningBrace {
+                                    index: index + i + 1,
+                                })
+                            }
+                        }
+                    } else {
+                        return Err(Error::UnclosedRegion {
+                            source: template,
+                            range: index..(index + template.len()),
+                        });
+                    }
+                }
+            }
+        } else {
+            output.write_str(template)?;
+            break;
+        }
+    }
+
+    Ok(())
+}
+
+/// Fill a template, producing a new string.
+///
+/// This is a convenience function for ergonomics in the case where you aren’t fussed about
+/// allocations and are using the standard `String` type.
+///
+/// See also [`StrExt::fill_to_string`], which respells `fill_to_string(template, filler)` as
+/// `template.fill_to_string(filler)`.
+#[cfg(feature = "alloc")]
+#[cfg_attr(docsrs, doc(cfg(feature = "alloc")))]
+pub fn fill_to_string<F, E>(template: &str, filler: F) -> Result<String, Error<E>>
+where
+    F: Filler<String, E>,
+{
+    let mut out = String::new();
+    fill(template, filler, &mut out).map(|()| out)
+}
+
+/// A convenience function to split a string on a character.
+///
+/// This is nicer than using `string.split(c, 2)` because it gives you the two values up-front.
+///
+/// # Returns
+///
+/// A two-tuple of:
+///
+/// 1. What comes before the split character, or the entire string if there was none; and
+/// 2. The remainder after the split character, if there was one (even if it’s empty).
+///
+/// ```
+/// # use human_string_filler::split_on;
+/// assert_eq!(split_on("The quick brown fox", ':'), ("The quick brown fox", None));
+/// assert_eq!(split_on("/", '/'), ("", Some("")));
+/// assert_eq!(split_on("harum = scarum", '='), ("harum ", Some(" scarum")));
+/// assert_eq!(split_on("diæresis:tréma:umlaut", ':'), ("diæresis", Some("tréma:umlaut")));
+/// ```
+pub fn split_on(string: &str, c: char) -> (&str, Option<&str>) {
+    match string.find(c) {
+        Some(i) => (&string[..i], Some(&string[i + c.len_utf8()..])),
+        None => (string, None),
+    }
+}
+
+/// The separators to use in [`split_propertied`].
+///
+/// A couple of sets of plausible-looking values (but if you want a concrete recommendation, like
+/// Gallio of old I refuse to be a judge of these things):
+///
+/// - `(' ', ' ', '=')` looks like `Hello, {name first formal=false case=lower}!`.
+/// - `('|', ',', ':')` looks like `Hello, {name|first,formal:false,case:lower}!`.
+#[derive(Clone, Copy, Debug)]
+pub struct Separators {
+    /// What character indicates the end of the key and the start of the properties.
+    pub between_key_and_properties: char,
+
+    /// What character indicates the end of one property’s name or value and the start of the next
+    /// property’s name.
+    pub between_properties: char,
+
+    /// What character indicates the end of a property’s name and the start of its value.
+    /// Remember that properties aren’t required to have values, but can be booleanyish.
+    // “booleanyish” sounded better than “booleanishy”. That’s my story and I’m sticking with it.
+    /// For that matter, if you want *all* properties to be boolean, set this to the same value as
+    /// `between_properties`, because `between_properties` is greedier.
+    pub between_property_name_and_value: char,
+}
+
+/// A convenience function to split a key that is followed by properties.
+///
+/// In keeping with this library in general, this is deliberately very simple and consequently not
+/// able to express all possible values; for example, if you use space as the separator between
+/// properties, you can’t use space in property values; and this doesn’t guard against empty keys
+/// or property names in any way.
+///
+/// ```
+/// use human_string_filler::{Separators, split_propertied};
+///
+/// let (key, properties) = split_propertied("key:prop1,prop2=value2,prop3=4+5=9", Separators {
+///     between_key_and_properties: ':',
+///     between_properties: ',',
+///     between_property_name_and_value: '=',
+/// });
+///
+/// assert_eq!(key, "key");
+/// assert_eq!(properties.collect::<Vec<_>>(),
+///            vec![("prop1", None), ("prop2", Some("value2")), ("prop3", Some("4+5=9"))]);
+/// ```
+///
+/// This method consumes exactly one character for the separators; if space is your
+/// between-properties separator, for example, multiple spaces will not be combined, but
+/// you’ll get `("", None)` properties instead. As I say, this is deliberately simple.
+pub fn split_propertied(
+    s: &str,
+    separators: Separators,
+) -> (
+    &str,
+    impl Iterator<Item = (&str, Option<&str>)>
+        + DoubleEndedIterator
+        + FusedIterator
+        + Clone
+        + fmt::Debug,
+) {
+    let (key, properties) = split_on(s, separators.between_key_and_properties);
+    let properties = properties
+        .map(|properties| properties.split(separators.between_properties))
+        .unwrap_or_else(|| {
+            // We need an iterator of the same type that will yield None, but Split yields an empty
+            // string first. Nice and easy: consume that, then continue on our way.
+            let mut dummy = "".split(' ');
+            dummy.next();
+            dummy
+        })
+        .map(move |word| split_on(word, separators.between_property_name_and_value));
+    (key, properties)
+}
+
+#[cfg(test)]
+mod tests {
+    #[allow(unused_imports)]
+    use super::*;
+
+    #[cfg(feature = "alloc")]
+    macro_rules! test {
+        ($name:ident, $filler:expr) => {
+            #[test]
+            fn $name() {
+                let filler = $filler;
+
+                assert_eq!(
+                    "Hello, {}!".fill_to_string(&filler).as_ref().map(|s| &**s),
+                    Ok("Hello, (this space intentionally left blank)!"),
+                );
+                assert_eq!(
+                    "Hello, {name}!"
+                        .fill_to_string(&filler)
+                        .as_ref()
+                        .map(|s| &**s),
+                    Ok("Hello, world!"),
+                );
+                assert_eq!(
+                    "Hello, {you}!".fill_to_string(&filler),
+                    Err(Error::BadReplacement {
+                        key: "you",
+                        range: 8..11,
+                        error: SimpleFillerError::NoSuchKey,
+                    }),
+                );
+                assert_eq!(
+                    "I like {keys with SPACES!? 😱}"
+                        .fill_to_string(&filler)
+                        .as_ref()
+                        .map(|s| &**s),
+                    Ok("I like identifier-only keys 👌"),
+                );
+            }
+        };
+    }
+
+    #[cfg(feature = "alloc")]
+    test!(closure_filler, |out: &mut String, key: &str| {
+        use core::fmt::Write;
+        out.write_str(match key {
+            "" => "(this space intentionally left blank)",
+            "name" => "world",
+            "keys with SPACES!? 😱" => "identifier-only keys 👌",
+            _ => return Err(SimpleFillerError::NoSuchKey),
+        })
+        .map_err(Into::into)
+    });
+
+    #[cfg(feature = "std")]
+    test!(hash_map_fillter, {
+        [
+            ("", "(this space intentionally left blank)"),
+            ("name", "world"),
+            ("keys with SPACES!? 😱", "identifier-only keys 👌"),
+        ]
+        .into_iter()
+        .collect::<HashMap<_, _>>()
+    });
+
+    #[cfg(feature = "alloc")]
+    test!(btree_map_fillter, {
+        [
+            ("", "(this space intentionally left blank)"),
+            ("name", "world"),
+            ("keys with SPACES!? 😱", "identifier-only keys 👌"),
+        ]
+        .into_iter()
+        .collect::<BTreeMap<_, _>>()
+    });
+
+    #[test]
+    #[cfg(feature = "alloc")]
+    fn fill_errors() {
+        let c = |_: &mut String, _: &str| -> Result<(), ()> { Ok(()) };
+
+        assert_eq!(
+            fill_to_string("Hello, {thing", c),
+            Err(Error::UnclosedRegion {
+                source: "{thing",
+                range: 7..13
+            })
+        );
+        assert_eq!(
+            fill_to_string("{}/{x}/{xx}/{xxx}/{{/}}/{thing", c),
+            Err(Error::UnclosedRegion {
+                source: "{thing",
+                range: 24..30
+            })
+        );
+
+        assert_eq!(
+            fill_to_string("Hello, }thing", c),
+            Err(Error::UnexpectedClosingBrace { index: 7 })
+        );
+        assert_eq!(
+            fill_to_string("{}/{x}/{xx}/{xxx}/{{/}}/}thing", c),
+            Err(Error::UnexpectedClosingBrace { index: 24 })
+        );
+
+        assert_eq!(
+            fill_to_string("Hello, {thi{{ng}", c),
+            Err(Error::UnexpectedOpeningBrace { index: 11 })
+        );
+        assert_eq!(
+            fill_to_string("{}/{x}/{xx}/{xxx}/{{/}}/{x{", c),
+            Err(Error::UnexpectedOpeningBrace { index: 26 })
+        );
+
+        assert_eq!(
+            fill_to_string("Hello, {thi}}ng}", c),
+            Err(Error::UnexpectedClosingBrace { index: 12 })
+        );
+        assert_eq!(
+            fill_to_string("{}/{x}/{xx}/{xxx}/{{/}}/}", c),
+            Err(Error::UnexpectedClosingBrace { index: 24 })
+        );
+    }
+
+    // This is almost enough to make me only expose a dyn fmt::Writer.
+    #[test]
+    #[cfg(feature = "alloc")]
+    fn do_not_do_this_at_home_kids() {
+        // Whatever possessed me!?
+        let s = "Don’t{␡}{\b}{^H} do this at home, {who}!".fill_to_string(
+            |output: &mut String, key: &str| {
+                match key {
+                    "␡" | "\b" | "^H" => {
+                        output.pop();
+                    }
+                    "who" => {
+                        output.push_str("kids");
+                    }
+                    _ => return Err(()),
+                }
+                Ok(())
+            },
+        );
+        assert_eq!(s.unwrap(), "Do do this at home, kids!");
+
+        // I haven’t yet decided whether this is better or worse than the previous one.
+        let s = "Don’t yell at {who}!{←make ASCII uppercase} (Please.)".fill_to_string(
+            |output: &mut String, key: &str| {
+                match key {
+                    "←make ASCII uppercase" => {
+                        output.make_ascii_uppercase();
+                    }
+                    "who" => {
+                        output.push_str("me");
+                    }
+                    _ => return Err(()),
+                }
+                Ok(())
+            },
+        );
+        assert_eq!(s.unwrap(), "DON’T YELL AT ME! (Please.)");
+    }
+}
diff --git a/test b/test
new file mode 100755 (executable)
index 0000000..6e523e6
--- /dev/null
+++ b/test
@@ -0,0 +1,13 @@
+#!/bin/sh
+set -e
+export RUSTFLAGS="-D warnings"
+export RUSTDOCFLAGS="-D warnings"
+for release in "" "--release"; do
+       for subcommand in clippy test doc; do
+               cargo $subcommand $release
+               cargo $subcommand $release --no-default-features
+               cargo $subcommand $release --no-default-features --features alloc
+       done
+done
+
+RUSTDOCFLAGS="-D warnings --cfg docsrs" cargo +nightly doc --all-features --no-deps