diff --git a/.codespellrc b/.codespellrc new file mode 100644 index 0000000..66eacc0 --- /dev/null +++ b/.codespellrc @@ -0,0 +1,3 @@ +[codespell] +ignore-words-list = crate +skip = .git,*.lock diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 0000000..ca60d4f --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,19 @@ +--- +name: Codespell + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + codespell: + name: Check for spelling errors + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Codespell + uses: codespell-project/actions-codespell@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e50fe5..b9bb868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- The `QueryString::simple` function was added to construct the new `QueryStringSimple` type. + This type reduces string allocations, defers rendering and can keep references + but at the cost of a complex type signature slightly more rigid handling. + +### Changed + +- The `QueryString::new` function was renamed to `QueryString::dynamic`. + ## [0.5.1] - 2024-05-24 [0.5.1]: https://github.com/sunsided/query-string-builder/releases/tag/v0.5.1 diff --git a/Cargo.toml b/Cargo.toml index 42fc64a..452d12b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,7 @@ criterion = "0.5.1" [[bench]] name = "bench" harness = false + +[[bench]] +name = "bench_slim" +harness = false diff --git a/benches/bench.rs b/benches/bench.rs index efee663..3a82b6a 100644 --- a/benches/bench.rs +++ b/benches/bench.rs @@ -6,7 +6,7 @@ pub fn criterion_benchmark(c: &mut Criterion) { // `with_value` method benchmark c.bench_function("with_value", |b| { b.iter(|| { - let qs = QueryString::new() + let qs = QueryString::dynamic() .with_value("q", "apple???") .with_value("category", "fruits and vegetables"); format!("{qs}") @@ -16,7 +16,7 @@ pub fn criterion_benchmark(c: &mut Criterion) { // `with_opt_value` method benchmark c.bench_function("with_opt_value", |b| { b.iter(|| { - let qs = QueryString::new() + let qs = QueryString::dynamic() .with_value("q", "celery") .with_opt_value("taste", None::) .with_opt_value("category", Some("fruits and vegetables")) @@ -29,12 +29,12 @@ pub fn criterion_benchmark(c: &mut Criterion) { // Full test including creating, pushing and appending c.bench_function("push_opt_and_append", |b| { b.iter(|| { - let mut qs = QueryString::new(); + let mut qs = QueryString::dynamic(); qs.push("a", "apple"); qs.push_opt("b", None::); qs.push_opt("c", Some("🍎 apple")); - let more = QueryString::new().with_value("q", "pear"); + let more = QueryString::dynamic().with_value("q", "pear"); let qs = qs.append_into(more); format!("{qs}") diff --git a/benches/bench_slim.rs b/benches/bench_slim.rs new file mode 100644 index 0000000..bb6d7e0 --- /dev/null +++ b/benches/bench_slim.rs @@ -0,0 +1,31 @@ +use criterion::{criterion_group, criterion_main, Criterion}; + +use query_string_builder::QueryString; + +pub fn criterion_benchmark(c: &mut Criterion) { + // `with_value` method benchmark + c.bench_function("with_value (slim)", |b| { + b.iter(|| { + let qs = QueryString::simple() + .with_value("q", "apple???") + .with_value("category", "fruits and vegetables"); + format!("{qs}") + }) + }); + + // `with_opt_value` method benchmark + c.bench_function("with_opt_value (slim)", |b| { + b.iter(|| { + let qs = QueryString::simple() + .with_value("q", "celery") + .with_opt_value("taste", None::) + .with_opt_value("category", Some("fruits and vegetables")) + .with_opt_value("tasty", Some(true)) + .with_opt_value("weight", Some(99.9)); + format!("{qs}") + }) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/src/lib.rs b/src/lib.rs index a206fb4..a06eba8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,7 @@ //! ``` //! use query_string_builder::QueryString; //! -//! let qs = QueryString::new() +//! let qs = QueryString::dynamic() //! .with_value("q", "🍎 apple") //! .with_value("tasty", true) //! .with_opt_value("color", None::) @@ -22,12 +22,15 @@ #![deny(unsafe_code)] -use std::fmt::{Debug, Display, Formatter, Write}; +mod slim; use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; +use std::fmt::{Debug, Display, Formatter, Write}; + +pub use slim::{QueryStringSimple, WrappedQueryString}; /// https://url.spec.whatwg.org/#query-percent-encode-set -const QUERY: &AsciiSet = &CONTROLS +pub(crate) const QUERY: &AsciiSet = &CONTROLS .add(b' ') .add(b'"') .add(b'#') @@ -50,7 +53,7 @@ const QUERY: &AsciiSet = &CONTROLS /// ``` /// use query_string_builder::QueryString; /// -/// let qs = QueryString::new() +/// let qs = QueryString::dynamic() /// .with_value("q", "apple") /// .with_value("category", "fruits and vegetables"); /// @@ -59,14 +62,38 @@ const QUERY: &AsciiSet = &CONTROLS /// "https://example.com/?q=apple&category=fruits%20and%20vegetables" /// ); /// ``` -#[derive(Debug, Default, Clone)] +#[derive(Debug, Clone)] pub struct QueryString { pairs: Vec, } impl QueryString { /// Creates a new, empty query string builder. - pub fn new() -> Self { + /// + /// ## Example + /// + /// ``` + /// use query_string_builder::QueryString; + /// + /// let weight: &f32 = &99.9; + /// + /// let qs = QueryString::simple() + /// .with_value("q", "apple") + /// .with_value("category", "fruits and vegetables") + /// .with_opt_value("weight", Some(weight)); + /// + /// assert_eq!( + /// format!("https://example.com/{qs}"), + /// "https://example.com/?q=apple&category=fruits%20and%20vegetables&weight=99.9" + /// ); + /// ``` + #[allow(clippy::new_ret_no_self)] + pub fn simple() -> QueryStringSimple { + QueryStringSimple::default() + } + + /// Creates a new, empty query string builder. + pub fn dynamic() -> Self { Self { pairs: Vec::default(), } @@ -79,7 +106,7 @@ impl QueryString { /// ``` /// use query_string_builder::QueryString; /// - /// let qs = QueryString::new() + /// let qs = QueryString::dynamic() /// .with_value("q", "🍎 apple") /// .with_value("category", "fruits and vegetables") /// .with_value("answer", 42); @@ -104,7 +131,7 @@ impl QueryString { /// ``` /// use query_string_builder::QueryString; /// - /// let qs = QueryString::new() + /// let qs = QueryString::dynamic() /// .with_opt_value("q", Some("🍎 apple")) /// .with_opt_value("f", None::) /// .with_opt_value("category", Some("fruits and vegetables")) @@ -130,7 +157,7 @@ impl QueryString { /// ``` /// use query_string_builder::QueryString; /// - /// let mut qs = QueryString::new(); + /// let mut qs = QueryString::dynamic(); /// qs.push("q", "apple"); /// qs.push("category", "fruits and vegetables"); /// @@ -154,7 +181,7 @@ impl QueryString { /// ``` /// use query_string_builder::QueryString; /// - /// let mut qs = QueryString::new(); + /// let mut qs = QueryString::dynamic(); /// qs.push_opt("q", None::); /// qs.push_opt("q", Some("🍎 apple")); /// @@ -188,8 +215,8 @@ impl QueryString { /// ``` /// use query_string_builder::QueryString; /// - /// let mut qs = QueryString::new().with_value("q", "apple"); - /// let more = QueryString::new().with_value("q", "pear"); + /// let mut qs = QueryString::dynamic().with_value("q", "apple"); + /// let more = QueryString::dynamic().with_value("q", "pear"); /// /// qs.append(more); /// @@ -209,8 +236,8 @@ impl QueryString { /// ``` /// use query_string_builder::QueryString; /// - /// let qs = QueryString::new().with_value("q", "apple"); - /// let more = QueryString::new().with_value("q", "pear"); + /// let qs = QueryString::dynamic().with_value("q", "apple"); + /// let more = QueryString::dynamic().with_value("q", "pear"); /// /// let qs = qs.append_into(more); /// @@ -257,7 +284,7 @@ mod tests { #[test] fn test_empty() { - let qs = QueryString::new(); + let qs = QueryStringSimple::default(); assert_eq!(qs.to_string(), ""); assert_eq!(qs.len(), 0); assert!(qs.is_empty()); @@ -265,7 +292,7 @@ mod tests { #[test] fn test_simple() { - let qs = QueryString::new() + let qs = QueryString::dynamic() .with_value("q", "apple???") .with_value("category", "fruits and vegetables") .with_value("tasty", true) @@ -280,7 +307,7 @@ mod tests { #[test] fn test_encoding() { - let qs = QueryString::new() + let qs = QueryString::dynamic() .with_value("q", "Grünkohl") .with_value("category", "Gemüse"); assert_eq!(qs.to_string(), "?q=Gr%C3%BCnkohl&category=Gem%C3%BCse"); @@ -288,7 +315,7 @@ mod tests { #[test] fn test_emoji() { - let qs = QueryString::new() + let qs = QueryString::dynamic() .with_value("q", "🥦") .with_value("🍽️", "🍔🍕"); assert_eq!( @@ -299,7 +326,7 @@ mod tests { #[test] fn test_optional() { - let qs = QueryString::new() + let qs = QueryString::dynamic() .with_value("q", "celery") .with_opt_value("taste", None::) .with_opt_value("category", Some("fruits and vegetables")) @@ -314,7 +341,7 @@ mod tests { #[test] fn test_push_optional() { - let mut qs = QueryString::new(); + let mut qs = QueryString::dynamic(); qs.push("a", "apple"); qs.push_opt("b", None::); qs.push_opt("c", Some("🍎 apple")); @@ -327,11 +354,11 @@ mod tests { #[test] fn test_append() { - let qs = QueryString::new().with_value("q", "apple"); - let more = QueryString::new().with_value("q", "pear"); + let qs = QueryString::dynamic().with_value("q", "apple"); + let more = QueryString::dynamic().with_value("q", "pear"); let mut qs = qs.append_into(more); - qs.append(QueryString::new().with_value("answer", "42")); + qs.append(QueryString::dynamic().with_value("answer", "42")); assert_eq!( format!("https://example.com/{qs}"), @@ -371,7 +398,7 @@ mod tests { ("right_curly", "}", "}"), ]; - let mut qs = QueryString::new(); + let mut qs = QueryString::dynamic(); for (key, value, _) in &tests { qs.push(key.to_string(), value.to_string()); } diff --git a/src/slim.rs b/src/slim.rs new file mode 100644 index 0000000..154ddb5 --- /dev/null +++ b/src/slim.rs @@ -0,0 +1,487 @@ +use std::fmt; +use std::fmt::{Debug, Display, Formatter, Write}; + +use crate::QUERY; +use percent_encoding::utf8_percent_encode; + +/// A type alias for the [`WrappedQueryString`] root. +pub type QueryStringSimple = WrappedQueryString; + +/// A query string builder for percent encoding key-value pairs. +/// This variant reduces string allocations as much as possible, defers them to the +/// time of actual rendering, and is capable of storing references. +/// +/// ## Example +/// +/// ``` +/// use query_string_builder::QueryString; +/// +/// let weight: &f32 = &99.9; +/// +/// let qs = QueryString::simple() +/// .with_value("q", "apple") +/// .with_value("category", "fruits and vegetables") +/// .with_opt_value("weight", Some(weight)); +/// +/// assert_eq!( +/// format!("https://example.com/{qs}"), +/// "https://example.com/?q=apple&category=fruits%20and%20vegetables&weight=99.9" +/// ); +/// ``` +pub struct WrappedQueryString +where + B: ConditionalDisplay + Identifiable, + T: Display, +{ + base: BaseOption, + value: KvpOption, +} + +impl Default for QueryStringSimple { + fn default() -> Self { + QueryStringSimple::new() + } +} + +/// A helper type to track the values of [`WrappedQueryString`]. +pub struct Kvp +where + K: Display, + V: Display, +{ + key: K, + value: V, +} + +enum BaseOption { + Some(B), + None, +} + +enum KvpOption { + Some(T), + None, +} + +/// This type serves as a root marker for the builder. It has no public constructor, +/// thus can only be created within this crate. +pub struct RootMarker(()); + +/// This type serves as an empty value marker for the builder. It has no public constructor, +/// thus can only be created within this crate. +pub struct EmptyValue(()); + +impl WrappedQueryString +where + B: ConditionalDisplay + Identifiable, + T: Display, +{ + /// Creates a new, empty query string builder. + pub(crate) fn new() -> WrappedQueryString { + WrappedQueryString { + base: BaseOption::None, + value: KvpOption::None, + } + } + + /// Appends a key-value pair to the query string. + /// + /// ## Example + /// + /// ``` + /// use query_string_builder::QueryString; + /// + /// let qs = QueryString::dynamic() + /// .with_value("q", "🍎 apple") + /// .with_value("category", "fruits and vegetables") + /// .with_value("answer", 42); + /// + /// assert_eq!( + /// format!("https://example.com/{qs}"), + /// "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables&answer=42" + /// ); + /// ``` + pub fn with_value( + self, + key: K, + value: V, + ) -> WrappedQueryString> { + WrappedQueryString { + base: BaseOption::Some(self), + value: KvpOption::Some(Kvp { key, value }), + } + } + + /// Appends a key-value pair to the query string if the value exists. + /// + /// ## Example + /// + /// ``` + /// use query_string_builder::QueryString; + /// + /// let qs = QueryString::dynamic() + /// .with_opt_value("q", Some("🍎 apple")) + /// .with_opt_value("f", None::) + /// .with_opt_value("category", Some("fruits and vegetables")) + /// .with_opt_value("works", Some(true)); + /// + /// assert_eq!( + /// format!("https://example.com/{qs}"), + /// "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables&works=true" + /// ); + /// ``` + pub fn with_opt_value( + self, + key: K, + value: Option, + ) -> WrappedQueryString> { + if let Some(value) = value { + WrappedQueryString { + base: BaseOption::Some(self), + value: KvpOption::Some(Kvp { key, value }), + } + } else { + WrappedQueryString { + base: BaseOption::Some(self), + value: KvpOption::None, + } + } + } + + /// Determines the number of key-value pairs currently in the builder. + pub fn len(&self) -> usize { + if self.is_empty() { + return 0; + } + + 1 + self.base.len() + } + + /// Determines if the builder is currently empty. + pub fn is_empty(&self) -> bool { + // If this is the root node, and we don't have a value, we're empty. + if self.is_root() && self.value.is_empty() { + return true; + } + + // If we're not the root node we need to check if all values are empty. + if !self.value.is_empty() { + return false; + } + + self.base.is_empty() + } +} + +pub trait Identifiable { + fn is_root(&self) -> bool; + fn is_empty(&self) -> bool; + fn len(&self) -> usize; +} + +pub trait ConditionalDisplay { + fn cond_fmt(&self, should_display: bool, f: &mut Formatter<'_>) -> Result; +} + +impl Identifiable for RootMarker { + fn is_root(&self) -> bool { + unreachable!() + } + + fn is_empty(&self) -> bool { + unreachable!() + } + + fn len(&self) -> usize { + unreachable!() + } +} + +impl ConditionalDisplay for RootMarker { + fn cond_fmt(&self, _should_display: bool, _f: &mut Formatter<'_>) -> Result { + unreachable!() + } +} + +impl Display for RootMarker { + fn fmt(&self, _f: &mut Formatter<'_>) -> fmt::Result { + unreachable!() + } +} + +impl ConditionalDisplay for BaseOption +where + B: ConditionalDisplay, +{ + fn cond_fmt(&self, should_display: bool, f: &mut Formatter<'_>) -> Result { + match self { + BaseOption::Some(base) => Ok(base.cond_fmt(should_display, f)?), + BaseOption::None => { + // Reached the root marker. + if should_display { + f.write_char('?')?; + } + Ok(0) + } + } + } +} + +impl ConditionalDisplay for WrappedQueryString +where + B: ConditionalDisplay + Identifiable, + T: Display, +{ + fn cond_fmt(&self, should_display: bool, f: &mut Formatter<'_>) -> Result { + let depth = if !should_display { + // Our caller had nothing to display. If we have nothing to display either, + // we move on to our parent. + if self.value.is_empty() { + return self.base.cond_fmt(false, f); + } + + // We do have things to display - render the parent! + self.base.cond_fmt(true, f)? + } else { + // The caller has things to display - go ahead regardless. + self.base.cond_fmt(true, f)? + }; + + // If we have nothing to render, return the known depth. + if self.value.is_empty() { + return Ok(depth); + } + + // Display and increase the depth. + self.value.fmt(f)?; + + // If our parent indicated content was displayable, add the combinator. + if should_display { + f.write_char('&')?; + } + + Ok(depth + 1) + } +} + +impl BaseOption +where + B: Identifiable + ConditionalDisplay, +{ + fn is_empty(&self) -> bool { + match self { + BaseOption::Some(value) => value.is_empty(), + BaseOption::None => true, + } + } + + fn len(&self) -> usize { + match self { + BaseOption::Some(value) => value.len(), + BaseOption::None => 0, + } + } +} + +impl Identifiable for WrappedQueryString +where + B: ConditionalDisplay + Identifiable, + T: Display, +{ + fn is_root(&self) -> bool { + match self.base { + BaseOption::Some(_) => false, + BaseOption::None => true, + } + } + + fn is_empty(&self) -> bool { + match self.value { + KvpOption::Some(_) => false, + KvpOption::None => self.base.is_empty(), + } + } + + fn len(&self) -> usize { + match self.value { + KvpOption::Some(_) => 1 + self.base.len(), + KvpOption::None => self.base.len(), + } + } +} + +impl KvpOption { + fn is_empty(&self) -> bool { + match self { + KvpOption::Some(_) => false, + KvpOption::None => true, + } + } +} + +impl Display for EmptyValue { + fn fmt(&self, _f: &mut Formatter<'_>) -> fmt::Result { + Ok(()) + } +} + +impl Display for BaseOption +where + T: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + BaseOption::Some(d) => Display::fmt(d, f), + BaseOption::None => Ok(()), + } + } +} + +impl Display for KvpOption +where + T: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + KvpOption::Some(d) => Display::fmt(d, f), + KvpOption::None => Ok(()), + } + } +} + +impl Display for Kvp +where + K: Display, + V: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&utf8_percent_encode(&self.key.to_string(), QUERY), f)?; + f.write_char('=')?; + Display::fmt(&utf8_percent_encode(&self.value.to_string(), QUERY), f) + } +} + +impl Display for WrappedQueryString +where + B: ConditionalDisplay + Identifiable, + T: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let should_display = !self.value.is_empty(); + + self.base.cond_fmt(should_display, f)?; + if should_display { + Display::fmt(&self.value, f)?; + } + + Ok(()) + } +} + +impl Debug for WrappedQueryString +where + B: ConditionalDisplay + Identifiable, + T: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(self, f) + } +} + +#[cfg(test)] +mod tests { + use crate::slim::{BaseOption, EmptyValue, KvpOption}; + use crate::QueryString; + + #[test] + fn test_empty() { + let qs = QueryString::simple(); + + assert!(qs.is_empty()); + assert_eq!(qs.len(), 0); + + assert_eq!(qs.to_string(), ""); + } + + #[test] + fn test_empty_complex() { + let qs = QueryString::simple().with_opt_value("key", None::<&str>); + + assert!(qs.is_empty()); + assert_eq!(qs.len(), 0); + + assert_eq!(qs.to_string(), ""); + } + + #[test] + fn test_simple() { + let apple = "apple???"; + + let qs = QueryString::simple() + .with_value("q", &apple) + .with_value("category", "fruits and vegetables") + .with_value("tasty", true) + .with_value("weight", 99.9); + + assert!(!qs.is_empty()); + assert_eq!(qs.len(), 4); + + assert_eq!( + format!("{qs}"), + "?q=apple???&category=fruits%20and%20vegetables&tasty=true&weight=99.9" + ); + } + + #[test] + fn test_encoding() { + let qs = QueryString::simple() + .with_value("q", "Grünkohl") + .with_value("category", "Gemüse"); + + assert!(!qs.is_empty()); + assert_eq!(qs.len(), 2); + + assert_eq!(qs.to_string(), "?q=Gr%C3%BCnkohl&category=Gem%C3%BCse"); + } + + #[test] + fn test_emoji() { + let qs = QueryString::simple() + .with_value("q", "🥦") + .with_value("🍽️", "🍔🍕"); + + assert!(!qs.is_empty()); + assert_eq!(qs.len(), 2); + + assert_eq!( + format!("{qs:?}"), + "?q=%F0%9F%A5%A6&%F0%9F%8D%BD%EF%B8%8F=%F0%9F%8D%94%F0%9F%8D%95" + ); + } + + #[test] + fn test_optional() { + let qs = QueryString::simple() + .with_value("q", "celery") + .with_opt_value("taste", None::) + .with_opt_value("category", Some("fruits and vegetables")) + .with_opt_value("tasty", Some(true)) + .with_opt_value("weight", Some(99.9)); + + assert!(!qs.is_empty()); + assert_eq!(qs.len(), 4); + + assert_eq!( + qs.to_string(), + "?q=celery&category=fruits%20and%20vegetables&tasty=true&weight=99.9" + ); + assert_eq!(qs.len(), 4); // not five! + } + + #[test] + fn test_display() { + assert_eq!(format!("{}", KvpOption::::None), ""); + assert_eq!(format!("{}", BaseOption::::None), ""); + assert_eq!(format!("{}", EmptyValue(())), ""); + } +}