commit a9801d55e62952ed925510ec98eda85d55316cc8 from: Murilo Ijanc date: Sat Apr 18 12:06:59 2026 UTC Use vendored jackson library for request and response JSON. Drop the hand-rolled json_escape() and the substring-based response scanner in favor of jackson's json_struct! macro. PostMessage and PostResponse capture the wire format as plain Rust structs and the generated ToJson/FromJson impls handle (de)serialisation, including escapes and optional fields via Option. jackson lives under vendor/ as a single file and is built as a separate rlib linked via --extern, so sm.rs is still one rustc invocation away from a binary. vendor/VENDOR records the upstream commit so updates are a straightforward re-copy. commit - e678f0f29294a33d774f58298da8658035b1e6cb commit + a9801d55e62952ed925510ec98eda85d55316cc8 blob - 9d32ecd98d729027e4a3f7f2a8693ad58aae8d4d blob + f2c3c2ff2b75548c0e4cc41bf3255d4957d09767 --- .gitignore +++ .gitignore @@ -1,3 +1,2 @@ build -vendor channels blob - 7dd31e538016cf5c7d220ffe8a2e9628a79da650 blob + b58a2ff4c350c616efb5a4b616c1d751ce575bbc --- Makefile +++ Makefile @@ -23,6 +23,8 @@ MANDIR ?= $(PREFIX)/share/man BUILD = build BIN = $(BUILD)/sm MAIN = sm.rs +JACKSON = vendor/jackson.rs +JACKSON_LIB = $(BUILD)/libjackson.rlib CLIPPY ?= $(shell rustup which clippy-driver 2>/dev/null) RUSTFMT ?= $(shell rustup which rustfmt 2>/dev/null) @@ -31,11 +33,18 @@ RUSTFMT ?= $(shell rustup which rustfmt 2>/dev/null) all: $(BIN) -$(BIN): $(MAIN) +$(JACKSON_LIB): $(JACKSON) mkdir -p $(BUILD) + $(RUSTC) --edition 2024 \ + --crate-type rlib --crate-name jackson $(RUSTFLAGS) \ + -o $@ $< + +$(BIN): $(MAIN) $(JACKSON_LIB) + mkdir -p $(BUILD) SM_VERSION=$(VERSION) TMPDIR=/tmp $(RUSTC) --edition 2024 \ --crate-type bin --crate-name sm $(RUSTFLAGS) \ -C link-arg=-ltls \ + --extern jackson=$(JACKSON_LIB) \ -o $@ $< clean: @@ -49,10 +58,11 @@ install: $(BIN) fmt-check: $(RUSTFMT) --edition 2024 --check $(MAIN) -clippy: +clippy: $(JACKSON_LIB) SM_VERSION=$(VERSION) TMPDIR=/tmp $(CLIPPY) --edition 2024 \ --crate-type bin --crate-name sm \ -C link-arg=-ltls \ + --extern jackson=$(JACKSON_LIB) \ -W clippy::all -o /tmp/sm.clippy $(MAIN) @rm -f /tmp/sm.clippy blob - a33466e063fdc3fa28fece729888b6dae3b32507 blob + daa5961a41a66a88aebb2ff37c335b3473a9e282 --- sm.rs +++ sm.rs @@ -26,6 +26,24 @@ use std::{ time::Duration, }; +use jackson::{FromJson, ToJson, json_struct}; + +json_struct! { + struct PostMessage { + channel: String, + text: String, + } +} + +json_struct! { + struct PostResponse { + ok: bool, + error: Option, + needed: Option, + provided: Option, + } +} + const HOST: &str = "slack.com"; const PORT: u16 = 443; const PATH: &str = "/api/chat.postMessage"; @@ -84,12 +102,17 @@ fn run() -> i32 { } }; - let mut body = String::with_capacity(channel.len() + message.len() + 32); - body.push_str("{\"channel\":\""); - json_escape(&mut body, channel); - body.push_str("\",\"text\":\""); - json_escape(&mut body, &message); - body.push_str("\"}"); + let req = PostMessage { + channel: channel.clone(), + text: message, + }; + let body = match req.to_json().stringify() { + Ok(s) => s, + Err(e) => { + eprintln!("sm: encode body: {e}"); + return 2; + } + }; let resp = match post(&token, &body) { Ok(r) => r, @@ -147,28 +170,6 @@ fn validate_message(s: &str) -> Result<(), String> { } ////////////////////////////////////////////////////////////////////////////// -// JSON -////////////////////////////////////////////////////////////////////////////// - -fn json_escape(out: &mut String, s: &str) { - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - '\x08' => out.push_str("\\b"), - '\x0c' => out.push_str("\\f"), - c if (c as u32) < 0x20 => { - let _ = write!(out, "\\u{:04x}", c as u32); - } - c => out.push(c), - } - } -} - -////////////////////////////////////////////////////////////////////////////// // libtls FFI ////////////////////////////////////////////////////////////////////////////// @@ -399,22 +400,20 @@ fn post(token: &str, body: &str) -> Result, St fn parse_ok(resp: &[u8]) -> Result<(), String> { let sep = find(resp, b"\r\n\r\n").ok_or("no header terminator in response")?; - let body = &resp[sep + 4..]; - // TODO(ijanc): substring-based matching is enough for Slack's flat - // envelope but would misread nested strings containing "ok":true/false. - // Replace with a minimal JSON scanner when a second caller appears. - if find(body, b"\"ok\":true").is_some() { + let body = std::str::from_utf8(&resp[sep + 4..]) + .map_err(|e| format!("non-utf8 body: {e}"))?; + let v: jackson::Value = + body.parse().map_err(|e: jackson::Error| format!("json: {e}"))?; + let r = PostResponse::from_json(&v) + .map_err(|e| format!("decode body: {e}"))?; + if r.ok { return Ok(()); } - if find(body, b"\"ok\":false").is_none() { - return Err("malformed or truncated response".into()); - } - let mut msg = extract_str(body, b"\"error\":\"") - .unwrap_or_else(|| "unknown error".into()); - if let Some(needed) = extract_str(body, b"\"needed\":\"") { + let mut msg = r.error.unwrap_or_else(|| "unknown error".into()); + if let Some(needed) = r.needed { let _ = write!(msg, " (needed: {needed})"); } - if let Some(provided) = extract_str(body, b"\"provided\":\"") { + if let Some(provided) = r.provided { let _ = write!(msg, " (provided: {provided})"); } Err(msg) @@ -423,34 +422,3 @@ fn parse_ok(resp: &[u8]) -> Result<(), String> { fn find(hay: &[u8], needle: &[u8]) -> Option { hay.windows(needle.len()).position(|w| w == needle) } - -fn extract_str(body: &[u8], key: &[u8]) -> Option { - let start = find(body, key)? + key.len(); - let mut out = String::new(); - let mut i = start; - while i < body.len() { - let b = body[i]; - if b == b'"' { - return Some(out); - } - if b == b'\\' && i + 1 < body.len() { - match body[i + 1] { - b'n' => out.push('\n'), - b'r' => out.push('\r'), - b't' => out.push('\t'), - b'"' => out.push('"'), - b'\\' => out.push('\\'), - b'/' => out.push('/'), - o => { - out.push('\\'); - out.push(o as char); - } - } - i += 2; - } else { - out.push(b as char); - i += 1; - } - } - None -} blob - /dev/null blob + e876bc9ed124135ffec1f6d07ead838168f7c2b5 (mode 644) --- /dev/null +++ vendor/VENDOR @@ -0,0 +1,6 @@ +jackson.rs + origin: ssh://ijanc@ijanc.org/json/jackson + commit: a4e84f6d2f21d8a5f28d17a38ed9968251325714 + date: 2026-04-18 + +To update: copy jackson.rs from upstream, then update commit/date above. blob - /dev/null blob + 89d9655a02f2cd1a90920cd514f0428a57739453 (mode 644) --- /dev/null +++ vendor/jackson.rs @@ -0,0 +1,1171 @@ +// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et : +// +// Copyright (c) 2026 Murilo Ijanc' +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +//! Parse and generate JSON documents. + +use std::fmt; +use std::fmt::Write as _; +use std::str::FromStr; + +/// A JSON value. +pub enum Value { + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + Object(Vec<(String, Value)>), +} + +/// A parse or serialise error. +pub struct Error { + msg: &'static str, + pos: usize, +} + +impl Error { + pub const fn new(msg: &'static str, pos: usize) -> Self { + Self { msg, pos } + } + + /// Static error message. + pub const fn message(&self) -> &'static str { + self.msg + } + + /// Byte offset into the input where the error was detected. + pub const fn position(&self) -> usize { + self.pos + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} at byte {}", self.msg, self.pos) + } +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +impl std::error::Error for Error {} + +impl FromStr for Value { + type Err = Error; + + fn from_str(s: &str) -> Result { + Parser::new(s).parse_root() + } +} + +impl Value { + /// Serialise this value as a JSON document. + pub fn stringify(&self) -> Result { + let mut out = String::new(); + write_value(self, &mut out)?; + Ok(out) + } + + pub fn as_bool(&self) -> Option { + match self { + Value::Bool(b) => Some(*b), + _ => None, + } + } + + pub fn as_number(&self) -> Option { + match self { + Value::Number(n) => Some(*n), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Value::String(s) => Some(s.as_str()), + _ => None, + } + } + + pub fn as_array(&self) -> Option<&[Value]> { + match self { + Value::Array(a) => Some(a.as_slice()), + _ => None, + } + } + + pub fn as_object(&self) -> Option<&[(String, Value)]> { + match self { + Value::Object(o) => Some(o.as_slice()), + _ => None, + } + } +} + +impl From for Value { + fn from(b: bool) -> Self { + Value::Bool(b) + } +} + +impl From for Value { + fn from(n: f64) -> Self { + Value::Number(n) + } +} + +impl From<&str> for Value { + fn from(s: &str) -> Self { + Value::String(s.to_string()) + } +} + +impl From for Value { + fn from(s: String) -> Self { + Value::String(s) + } +} + +fn write_value(v: &Value, out: &mut String) -> Result<(), Error> { + match v { + Value::Null => out.push_str("null"), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Number(n) => { + if !n.is_finite() { + return Err(Error::new("non-finite number", 0)); + } + if *n != 0.0 && n.fract() == 0.0 && n.abs() < (1_i64 << 53) as f64 { + write!(out, "{}", *n as i64).unwrap(); + } else { + write!(out, "{n}").unwrap(); + } + } + Value::String(s) => write_string(s, out), + Value::Array(a) => { + out.push('['); + for (i, item) in a.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_value(item, out)?; + } + out.push(']'); + } + Value::Object(o) => { + out.push('{'); + for (i, (k, item)) in o.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_string(k, out); + out.push(':'); + write_value(item, out)?; + } + out.push('}'); + } + } + Ok(()) +} + +fn write_string(s: &str, out: &mut String) { + out.push('"'); + let bytes = s.as_bytes(); + let mut run_start = 0; + for (i, &b) in bytes.iter().enumerate() { + let esc: &str = match b { + b'"' => "\\\"", + b'\\' => "\\\\", + b'\n' => "\\n", + b'\r' => "\\r", + b'\t' => "\\t", + 0x08 => "\\b", + 0x0C => "\\f", + 0..=0x1F => { + out.push_str(&s[run_start..i]); + write!(out, "\\u{:04x}", b).unwrap(); + run_start = i + 1; + continue; + } + _ => continue, + }; + out.push_str(&s[run_start..i]); + out.push_str(esc); + run_start = i + 1; + } + out.push_str(&s[run_start..]); + out.push('"'); +} + +const MAX_DEPTH: usize = 128; + +struct Parser<'a> { + src: &'a [u8], + pos: usize, + depth: usize, +} + +impl<'a> Parser<'a> { + fn new(s: &'a str) -> Self { + Self { + src: s.as_bytes(), + pos: 0, + depth: 0, + } + } + + fn err(&self, msg: &'static str) -> Error { + Error::new(msg, self.pos) + } + + fn peek(&self) -> Option { + self.src.get(self.pos).copied() + } + + fn bump(&mut self) -> Option { + let b = self.peek()?; + self.pos += 1; + Some(b) + } + + fn skip_ws(&mut self) { + while let Some(b) = self.peek() { + if matches!(b, b' ' | b'\t' | b'\n' | b'\r') { + self.pos += 1; + } else { + break; + } + } + } + + fn expect(&mut self, b: u8, msg: &'static str) -> Result<(), Error> { + if self.peek() == Some(b) { + self.pos += 1; + Ok(()) + } else { + Err(self.err(msg)) + } + } + + fn expect_keyword(&mut self, kw: &[u8]) -> Result<(), Error> { + let end = self.pos + kw.len(); + if end > self.src.len() || &self.src[self.pos..end] != kw { + return Err(self.err("expected keyword")); + } + self.pos = end; + Ok(()) + } + + fn enter(&mut self) -> Result<(), Error> { + if self.depth >= MAX_DEPTH { + return Err(self.err("max nesting depth exceeded")); + } + self.depth += 1; + Ok(()) + } + + fn parse_root(&mut self) -> Result { + let v = self.parse_value()?; + self.skip_ws(); + if self.pos < self.src.len() { + return Err(self.err("trailing garbage")); + } + Ok(v) + } + + fn parse_value(&mut self) -> Result { + self.skip_ws(); + let b = self + .peek() + .ok_or_else(|| self.err("unexpected end of input"))?; + match b { + b'n' => { + self.expect_keyword(b"null")?; + Ok(Value::Null) + } + b't' => { + self.expect_keyword(b"true")?; + Ok(Value::Bool(true)) + } + b'f' => { + self.expect_keyword(b"false")?; + Ok(Value::Bool(false)) + } + b'"' => Ok(Value::String(self.parse_string()?)), + b'[' => self.parse_array(), + b'{' => self.parse_object(), + b'-' | b'0'..=b'9' => Ok(Value::Number(self.parse_number()?)), + _ => Err(self.err("unexpected character")), + } + } + + fn parse_string(&mut self) -> Result { + self.pos += 1; + let mut out = String::new(); + loop { + let run_start = self.pos; + while let Some(&b) = self.src.get(self.pos) { + if matches!(b, b'"' | b'\\') || b < 0x20 { + break; + } + self.pos += 1; + } + let run = &self.src[run_start..self.pos]; + // SAFETY: src is the byte view of a &str input; the scan + // only breaks on ASCII bytes ("", \\, < 0x20), so + // run_start..self.pos is always a valid UTF-8 substring. + let run_str = unsafe { std::str::from_utf8_unchecked(run) }; + out.push_str(run_str); + match self.peek() { + None => return Err(self.err("unterminated string")), + Some(b'"') => { + self.pos += 1; + return Ok(out); + } + Some(b'\\') => { + self.pos += 1; + let esc = + self.bump().ok_or_else(|| self.err("bad escape"))?; + match esc { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{08}'), + b'f' => out.push('\u{0C}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => out.push(self.parse_u_escape()?), + _ => return Err(self.err("invalid escape")), + } + } + Some(_) => { + return Err(self.err("control character in string")); + } + } + } + } + + fn parse_u_escape(&mut self) -> Result { + let hi = self.parse_hex4()?; + if (0xD800..=0xDBFF).contains(&hi) { + if self.bump() != Some(b'\\') || self.bump() != Some(b'u') { + return Err(self.err("expected low surrogate")); + } + let lo = self.parse_hex4()?; + if !(0xDC00..=0xDFFF).contains(&lo) { + return Err(self.err("invalid low surrogate")); + } + let code = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); + char::from_u32(code).ok_or_else(|| self.err("invalid codepoint")) + } else { + char::from_u32(hi).ok_or_else(|| self.err("invalid codepoint")) + } + } + + fn parse_hex4(&mut self) -> Result { + let mut v: u32 = 0; + for _ in 0..4 { + let b = + self.bump().ok_or_else(|| self.err("bad unicode escape"))?; + let d = match b { + b'0'..=b'9' => b - b'0', + b'a'..=b'f' => b - b'a' + 10, + b'A'..=b'F' => b - b'A' + 10, + _ => return Err(self.err("bad hex digit")), + }; + v = v * 16 + d as u32; + } + Ok(v) + } + + fn parse_number(&mut self) -> Result { + let start = self.pos; + if self.peek() == Some(b'-') { + self.pos += 1; + } + match self.peek() { + Some(b'0') => self.pos += 1, + Some(b'1'..=b'9') => { + self.pos += 1; + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.pos += 1; + } + } + _ => return Err(self.err("expected digit")), + } + if self.peek() == Some(b'.') { + self.pos += 1; + if !matches!(self.peek(), Some(b'0'..=b'9')) { + return Err(self.err("expected digit after decimal point")); + } + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.pos += 1; + } + } + if matches!(self.peek(), Some(b'e' | b'E')) { + self.pos += 1; + if matches!(self.peek(), Some(b'+' | b'-')) { + self.pos += 1; + } + if !matches!(self.peek(), Some(b'0'..=b'9')) { + return Err(self.err("expected digit in exponent")); + } + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.pos += 1; + } + } + let slice = &self.src[start..self.pos]; + let s = std::str::from_utf8(slice).unwrap(); + s.parse::() + .map_err(|_| Error::new("invalid number", start)) + } + + fn parse_array(&mut self) -> Result { + self.pos += 1; + self.enter()?; + let mut items = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b']') { + self.pos += 1; + self.depth -= 1; + return Ok(Value::Array(items)); + } + loop { + items.push(self.parse_value()?); + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b']') => { + self.pos += 1; + self.depth -= 1; + return Ok(Value::Array(items)); + } + _ => return Err(self.err("expected ',' or ']'")), + } + } + } + + fn parse_object(&mut self) -> Result { + self.pos += 1; + self.enter()?; + let mut items = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b'}') { + self.pos += 1; + self.depth -= 1; + return Ok(Value::Object(items)); + } + loop { + if self.peek() != Some(b'"') { + return Err(self.err("expected string key")); + } + let key = self.parse_string()?; + self.skip_ws(); + self.expect(b':', "expected ':'")?; + let v = self.parse_value()?; + items.push((key, v)); + self.skip_ws(); + match self.peek() { + Some(b',') => { + self.pos += 1; + self.skip_ws(); + } + Some(b'}') => { + self.pos += 1; + self.depth -= 1; + return Ok(Value::Object(items)); + } + _ => return Err(self.err("expected ',' or '}'")), + } + } + } +} + +/// Trait implemented by types that can be written to a [`Value`]. +pub trait ToJson { + fn to_json(&self) -> Value; +} + +/// Trait implemented by types that can be read from a [`Value`]. +pub trait FromJson: Sized { + fn from_json(v: &Value) -> Result; + + /// Produce a value when the corresponding field is absent from the + /// parent object. The default rejects; `Option` overrides to + /// return `None`. + fn missing_field() -> Result { + Err(Error::new("missing field", 0)) + } +} + +impl ToJson for bool { + fn to_json(&self) -> Value { + Value::Bool(*self) + } +} + +impl FromJson for bool { + fn from_json(v: &Value) -> Result { + v.as_bool().ok_or_else(|| Error::new("expected bool", 0)) + } +} + +impl ToJson for String { + fn to_json(&self) -> Value { + Value::String(self.clone()) + } +} + +impl FromJson for String { + fn from_json(v: &Value) -> Result { + v.as_str() + .map(str::to_string) + .ok_or_else(|| Error::new("expected string", 0)) + } +} + +impl ToJson for f64 { + fn to_json(&self) -> Value { + Value::Number(*self) + } +} + +impl FromJson for f64 { + fn from_json(v: &Value) -> Result { + v.as_number() + .ok_or_else(|| Error::new("expected number", 0)) + } +} + +impl ToJson for f32 { + fn to_json(&self) -> Value { + Value::Number(*self as f64) + } +} + +impl FromJson for f32 { + fn from_json(v: &Value) -> Result { + v.as_number() + .map(|n| n as f32) + .ok_or_else(|| Error::new("expected number", 0)) + } +} + +macro_rules! jackson_int_impls { + ($($t:ty),* $(,)?) => { $( + impl ToJson for $t { + fn to_json(&self) -> Value { + Value::Number(*self as f64) + } + } + + impl FromJson for $t { + fn from_json(v: &Value) -> Result { + let n = v + .as_number() + .ok_or_else(|| Error::new("expected number", 0))?; + if n.is_finite() + && n.fract() == 0.0 + && n >= <$t>::MIN as f64 + && n <= <$t>::MAX as f64 + { + Ok(n as $t) + } else { + Err(Error::new("integer out of range", 0)) + } + } + } + )* }; +} + +jackson_int_impls!(i8, i16, i32, i64, u8, u16, u32, u64); + +impl ToJson for Option { + fn to_json(&self) -> Value { + match self { + Some(v) => v.to_json(), + None => Value::Null, + } + } +} + +impl FromJson for Option { + fn from_json(v: &Value) -> Result { + match v { + Value::Null => Ok(None), + _ => T::from_json(v).map(Some), + } + } + + fn missing_field() -> Result { + Ok(None) + } +} + +impl ToJson for Vec { + fn to_json(&self) -> Value { + Value::Array(self.iter().map(T::to_json).collect()) + } +} + +impl FromJson for Vec { + fn from_json(v: &Value) -> Result { + let a = v + .as_array() + .ok_or_else(|| Error::new("expected array", 0))?; + a.iter().map(T::from_json).collect() + } +} + +/// Define a struct together with [`ToJson`] and [`FromJson`] impls. +#[macro_export] +macro_rules! json_struct { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident { + $( + $(#[$fmeta:meta])* + $fvis:vis $field:ident : $ty:ty + ),* $(,)? + } + ) => { + $(#[$meta])* + $vis struct $name { + $( + $(#[$fmeta])* + $fvis $field: $ty, + )* + } + + impl $crate::ToJson for $name { + fn to_json(&self) -> $crate::Value { + $crate::Value::Object(vec![ + $( + ( + stringify!($field).to_string(), + <$ty as $crate::ToJson>::to_json(&self.$field), + ), + )* + ]) + } + } + + impl $crate::FromJson for $name { + fn from_json( + v: &$crate::Value, + ) -> ::std::result::Result { + let obj = v.as_object().ok_or_else(|| { + $crate::Error::new("expected object", 0) + })?; + $( let mut $field: Option<&$crate::Value> = None; )* + for (k, val) in obj { + match k.as_str() { + $( stringify!($field) => $field = Some(val), )* + _ => {} + } + } + Ok(Self { + $( + $field: match $field { + Some(val) => { + <$ty as $crate::FromJson>::from_json(val)? + } + None => <$ty as $crate::FromJson>::missing_field() + .map_err(|_| $crate::Error::new( + concat!( + "missing field: ", + stringify!($field), + ), + 0, + ))?, + }, + )* + }) + } + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn value_accessors() { + assert_eq!(Value::Bool(true).as_bool(), Some(true)); + assert_eq!(Value::Null.as_bool(), None); + assert_eq!(Value::Number(1.5).as_number(), Some(1.5)); + assert_eq!(Value::Null.as_number(), None); + assert_eq!(Value::String("hi".into()).as_str(), Some("hi")); + assert_eq!(Value::Null.as_str(), None); + assert!(Value::Array(Vec::new()).as_array().is_some()); + assert!(Value::Null.as_array().is_none()); + assert!(Value::Object(Vec::new()).as_object().is_some()); + assert!(Value::Null.as_object().is_none()); + } + + #[test] + fn value_from_impls() { + assert!(matches!(Value::from(true), Value::Bool(true))); + assert!(matches!(Value::from(1.5), Value::Number(n) if n == 1.5)); + match Value::from("hello") { + Value::String(s) => assert_eq!(s, "hello"), + _ => panic!(), + } + match Value::from(String::from("world")) { + Value::String(s) => assert_eq!(s, "world"), + _ => panic!(), + } + } + + #[test] + fn error_accessors() { + let e = Error::new("boom", 42); + assert_eq!(e.message(), "boom"); + assert_eq!(e.position(), 42); + } + + #[test] + fn value_variants_construct() { + let _ = Value::Null; + let _ = Value::Bool(true); + let _ = Value::Number(0.0); + let _ = Value::String(String::new()); + let _ = Value::Array(Vec::new()); + let _ = Value::Object(Vec::new()); + } + + fn parse(s: &str) -> Value { + s.parse::().unwrap() + } + + fn parse_err(s: &str) { + assert!(s.parse::().is_err(), "expected error for {s:?}"); + } + + #[test] + fn parse_primitives() { + assert!(matches!(parse("null"), Value::Null)); + assert!(matches!(parse("true"), Value::Bool(true))); + assert!(matches!(parse("false"), Value::Bool(false))); + assert!(matches!(parse(" null "), Value::Null)); + } + + #[test] + fn parse_numbers() { + let cases: &[(&str, f64)] = &[ + ("0", 0.0), + ("-0", 0.0), + ("42", 42.0), + ("-42", -42.0), + ("3.14", 3.14), + ("-2.5", -2.5), + ("1e10", 1e10), + ("1E10", 1e10), + ("1.5e2", 150.0), + ("1.5e+2", 150.0), + ("2.5e-1", 0.25), + ]; + for (s, v) in cases { + match parse(s) { + Value::Number(n) => assert_eq!(n, *v, "{s}"), + _ => panic!("not a number: {s}"), + } + } + } + + #[test] + fn parse_number_errors() { + parse_err("01"); + parse_err("+1"); + parse_err(".5"); + parse_err("1."); + parse_err("1e"); + parse_err("1e+"); + parse_err("-"); + } + + #[test] + fn parse_strings() { + match parse(r#""hello""#) { + Value::String(s) => assert_eq!(s, "hello"), + _ => panic!(), + } + match parse(r#""a\"b\\c\/d""#) { + Value::String(s) => assert_eq!(s, "a\"b\\c/d"), + _ => panic!(), + } + match parse(r#""\n\r\t\b\f""#) { + Value::String(s) => assert_eq!(s, "\n\r\t\u{08}\u{0C}"), + _ => panic!(), + } + match parse(r#""\u0041""#) { + Value::String(s) => assert_eq!(s, "A"), + _ => panic!(), + } + match parse(r#""\uD834\uDD1E""#) { + Value::String(s) => assert_eq!(s, "\u{1D11E}"), + _ => panic!(), + } + match parse("\"é\"") { + Value::String(s) => assert_eq!(s, "é"), + _ => panic!(), + } + } + + #[test] + fn parse_string_errors() { + parse_err(r#""unterminated"#); + parse_err("\"embedded\nnewline\""); + parse_err(r#""\x""#); + parse_err(r#""\uD800""#); + parse_err(r#""\uDC00""#); + parse_err(r#""\uD800\u0041""#); + } + + #[test] + fn parse_arrays() { + assert!(matches!(parse("[]"), Value::Array(a) if a.is_empty())); + match parse("[1, 2, 3]") { + Value::Array(a) => { + assert_eq!(a.len(), 3); + assert!(matches!(a[0], Value::Number(n) if n == 1.0)); + } + _ => panic!(), + } + match parse("[[1], [2, 3]]") { + Value::Array(a) => assert_eq!(a.len(), 2), + _ => panic!(), + } + } + + #[test] + fn parse_array_errors() { + parse_err("["); + parse_err("[1,"); + parse_err("[1 2]"); + parse_err("[,]"); + } + + #[test] + fn parse_objects() { + assert!(matches!(parse("{}"), Value::Object(m) if m.is_empty())); + match parse(r#"{"a": 1, "b": true}"#) { + Value::Object(m) => { + assert_eq!(m.len(), 2); + assert_eq!(m[0].0, "a"); + assert!(matches!(m[0].1, Value::Number(n) if n == 1.0)); + assert_eq!(m[1].0, "b"); + assert!(matches!(m[1].1, Value::Bool(true))); + } + _ => panic!(), + } + } + + #[test] + fn parse_object_errors() { + parse_err("{"); + parse_err(r#"{"a""#); + parse_err(r#"{"a":}"#); + parse_err(r#"{"a":1"#); + parse_err(r#"{a:1}"#); + parse_err(r#"{"a":1,}"#); + } + + #[test] + fn parse_duplicate_keys_kept() { + match parse(r#"{"a":1,"a":2}"#) { + Value::Object(m) => { + assert_eq!(m.len(), 2); + assert_eq!(m[0].0, "a"); + assert!(matches!(m[0].1, Value::Number(n) if n == 1.0)); + assert_eq!(m[1].0, "a"); + assert!(matches!(m[1].1, Value::Number(n) if n == 2.0)); + } + _ => panic!(), + } + } + + #[test] + fn parse_trailing_garbage_fails() { + parse_err("null null"); + parse_err("1 2"); + parse_err("[] x"); + } + + #[test] + fn parse_empty_fails() { + parse_err(""); + parse_err(" "); + } + + #[test] + fn round_trip() { + let cases = &[ + "null", + "true", + "false", + "0", + "-1.5", + r#""hello""#, + "[]", + "[1,2,3]", + "{}", + r#"{"a":1,"b":[true,null]}"#, + ]; + for s in cases { + let v: Value = s.parse().unwrap(); + assert_eq!(&v.stringify().unwrap(), s, "round trip {s}"); + } + } + + #[test] + fn parse_rejects_deep_nesting() { + let s: String = "[".repeat(200); + parse_err(&s); + } + + #[test] + fn stringify_primitives() { + assert_eq!(Value::Null.stringify().unwrap(), "null"); + assert_eq!(Value::Bool(true).stringify().unwrap(), "true"); + assert_eq!(Value::Bool(false).stringify().unwrap(), "false"); + assert_eq!(Value::Number(0.0).stringify().unwrap(), "0"); + assert_eq!(Value::Number(-42.5).stringify().unwrap(), "-42.5"); + } + + #[test] + fn stringify_integer_values() { + assert_eq!(Value::Number(42.0).stringify().unwrap(), "42"); + assert_eq!(Value::Number(-1000.0).stringify().unwrap(), "-1000"); + assert_eq!(Value::Number(1.5).stringify().unwrap(), "1.5"); + // Beyond 2^53 falls back to the f64 formatter. + let big = (1_i64 << 54) as f64; + let s = Value::Number(big).stringify().unwrap(); + assert_eq!(s.parse::().unwrap(), big); + } + + #[test] + fn stringify_non_finite_errors() { + assert!(Value::Number(f64::NAN).stringify().is_err()); + assert!(Value::Number(f64::INFINITY).stringify().is_err()); + assert!(Value::Number(f64::NEG_INFINITY).stringify().is_err()); + } + + #[test] + fn stringify_string_escapes() { + assert_eq!(Value::String("hi".into()).stringify().unwrap(), "\"hi\""); + assert_eq!( + Value::String("a\"b\\c".into()).stringify().unwrap(), + "\"a\\\"b\\\\c\"" + ); + assert_eq!( + Value::String("\n\r\t\u{08}\u{0C}".into()) + .stringify() + .unwrap(), + "\"\\n\\r\\t\\b\\f\"" + ); + assert_eq!( + Value::String("\x01\x1f".into()).stringify().unwrap(), + "\"\\u0001\\u001f\"" + ); + assert_eq!(Value::String("é".into()).stringify().unwrap(), "\"é\""); + } + + #[test] + fn stringify_array() { + assert_eq!(Value::Array(Vec::new()).stringify().unwrap(), "[]"); + let a = Value::Array(vec![ + Value::Number(1.0), + Value::Null, + Value::Bool(true), + ]); + assert_eq!(a.stringify().unwrap(), "[1,null,true]"); + } + + #[test] + fn stringify_object_preserves_insertion_order() { + let o = vec![ + ("b".into(), Value::Number(2.0)), + ("a".into(), Value::Number(1.0)), + ]; + assert_eq!(Value::Object(o).stringify().unwrap(), r#"{"b":2,"a":1}"#); + } + + #[test] + fn stringify_nested() { + let inner = vec![("x".into(), Value::Array(vec![Value::Number(3.0)]))]; + let outer = vec![("obj".into(), Value::Object(inner))]; + assert_eq!( + Value::Object(outer).stringify().unwrap(), + r#"{"obj":{"x":[3]}}"# + ); + } + + #[test] + fn stringify_array_propagates_error() { + let a = Value::Array(vec![Value::Number(f64::NAN)]); + assert!(a.stringify().is_err()); + } + + json_struct! { + struct SimpleUser { + name: String, + age: i64, + active: bool, + } + } + + #[test] + fn derive_simple_round_trip() { + let u = SimpleUser { + name: "ada".into(), + age: 36, + active: true, + }; + let s = u.to_json().stringify().unwrap(); + let v: Value = s.parse().unwrap(); + let back = SimpleUser::from_json(&v).unwrap(); + assert_eq!(back.name, "ada"); + assert_eq!(back.age, 36); + assert!(back.active); + } + + json_struct! { + struct OptionalFields { + required: String, + maybe: Option, + } + } + + #[test] + fn derive_option_none_serialises_as_null() { + let u = OptionalFields { + required: "x".into(), + maybe: None, + }; + let s = u.to_json().stringify().unwrap(); + assert_eq!(s, r#"{"required":"x","maybe":null}"#); + let back = OptionalFields::from_json(&s.parse().unwrap()).unwrap(); + assert!(back.maybe.is_none()); + } + + #[test] + fn derive_absent_option_is_none() { + let v: Value = r#"{"required":"x"}"#.parse().unwrap(); + let back = OptionalFields::from_json(&v).unwrap(); + assert_eq!(back.required, "x"); + assert!(back.maybe.is_none()); + } + + #[test] + fn derive_option_some_round_trips() { + let u = OptionalFields { + required: "x".into(), + maybe: Some(42), + }; + let s = u.to_json().stringify().unwrap(); + let back = OptionalFields::from_json(&s.parse().unwrap()).unwrap(); + assert_eq!(back.maybe, Some(42)); + } + + json_struct! { + struct Container { + tags: Vec, + counts: Vec, + } + } + + #[test] + fn derive_vec_round_trip() { + let c = Container { + tags: vec!["a".into(), "b".into()], + counts: vec![1, 2, 3], + }; + let s = c.to_json().stringify().unwrap(); + let back = Container::from_json(&s.parse().unwrap()).unwrap(); + assert_eq!(back.tags, vec!["a".to_string(), "b".to_string()]); + assert_eq!(back.counts, vec![1, 2, 3]); + } + + json_struct! { + struct Inner { + value: i32, + } + } + + json_struct! { + struct Outer { + name: String, + inner: Inner, + } + } + + #[test] + fn derive_nested_struct() { + let o = Outer { + name: "n".into(), + inner: Inner { value: 7 }, + }; + let s = o.to_json().stringify().unwrap(); + assert_eq!(s, r#"{"name":"n","inner":{"value":7}}"#); + let back = Outer::from_json(&s.parse().unwrap()).unwrap(); + assert_eq!(back.name, "n"); + assert_eq!(back.inner.value, 7); + } + + #[test] + fn derive_missing_field_errors() { + let v: Value = r#"{"name":"ada","active":true}"#.parse().unwrap(); + let e = SimpleUser::from_json(&v).err().unwrap(); + assert_eq!(e.message(), "missing field: age"); + } + + #[test] + fn derive_wrong_type_errors() { + let v: Value = + r#"{"name":"ada","age":"old","active":true}"#.parse().unwrap(); + assert!(SimpleUser::from_json(&v).is_err()); + } + + #[test] + fn derive_expects_object() { + let v = Value::Array(Vec::new()); + assert!(SimpleUser::from_json(&v).is_err()); + } + + #[test] + fn derive_integer_out_of_range_errors() { + assert!(i32::from_json(&Value::Number(1e20)).is_err()); + } + + #[test] + fn derive_non_integer_errors() { + assert!(i64::from_json(&Value::Number(1.5)).is_err()); + } + + #[test] + fn derive_duplicate_keys_last_wins() { + let v: Value = + r#"{"name":"a","age":1,"active":true,"age":2}"#.parse().unwrap(); + let back = SimpleUser::from_json(&v).unwrap(); + assert_eq!(back.age, 2); + } +}