use crate::value::PathSegment;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(tag = "seg", rename_all = "lowercase")]
pub enum DocSeg {
Main,
Card { kind: Option<String>, index: usize },
Field { name: String },
Index { index: usize },
Body,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct DocPath {
segs: Vec<DocSeg>,
}
impl DocPath {
pub fn new() -> Self {
Self::default()
}
pub fn main() -> Self {
Self {
segs: vec![DocSeg::Main],
}
}
pub fn main_body() -> Self {
Self {
segs: vec![DocSeg::Main, DocSeg::Body],
}
}
pub fn card(kind: Option<&str>, index: usize) -> Self {
Self {
segs: vec![DocSeg::Card {
kind: kind.map(str::to_owned),
index,
}],
}
}
pub fn field(&self, name: &str) -> Self {
self.pushing(DocSeg::Field {
name: name.to_owned(),
})
}
pub fn index(&self, index: usize) -> Self {
self.pushing(DocSeg::Index { index })
}
pub fn body(&self) -> Self {
self.pushing(DocSeg::Body)
}
pub fn segment(&self, seg: &PathSegment) -> Self {
match seg {
PathSegment::Key(k) => self.field(k),
PathSegment::Index(i) => self.index(*i),
}
}
pub fn segs(&self) -> &[DocSeg] {
&self.segs
}
fn pushing(&self, seg: DocSeg) -> Self {
let mut segs = self.segs.clone();
segs.push(seg);
Self { segs }
}
}
impl fmt::Display for DocPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, seg) in self.segs.iter().enumerate() {
match seg {
DocSeg::Main => f.write_str("main")?,
DocSeg::Card { kind: Some(k), index } => write!(f, "cards.{k}[{index}]")?,
DocSeg::Card { kind: None, index } => write!(f, "cards[{index}]")?,
DocSeg::Field { name } => {
if i != 0 {
f.write_str(".")?;
}
f.write_str(name)?;
}
DocSeg::Index { index } => write!(f, "[{index}]")?,
DocSeg::Body => f.write_str(".body")?,
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocPathParseError {
pub input: String,
pub reason: &'static str,
}
impl fmt::Display for DocPathParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid document path '{}': {}", self.input, self.reason)
}
}
impl std::error::Error for DocPathParseError {}
impl FromStr for DocPath {
type Err = DocPathParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let err = |reason: &'static str| DocPathParseError {
input: s.to_owned(),
reason,
};
if s.is_empty() {
return Err(err("empty path"));
}
let segs = scan(s).map_err(err)?;
if matches!(segs.first(), Some(DocSeg::Field { name }) if name == "main") {
let rest = &segs[1..];
if matches!(rest, [DocSeg::Field { name }] if name == "body") {
return Ok(DocPath::main_body());
}
let mut out = vec![DocSeg::Main];
out.extend_from_slice(rest);
return Ok(DocPath { segs: out });
}
if matches!(segs.first(), Some(DocSeg::Field { name }) if name == "cards") {
if let Some((card, rest)) = parse_card_root(&segs) {
let mut segs = vec![card];
segs.extend(tail_segs(rest));
return Ok(DocPath { segs });
}
}
Ok(DocPath { segs })
}
}
fn scan(s: &str) -> Result<Vec<DocSeg>, &'static str> {
let mut segs = Vec::new();
let bytes = s.as_bytes();
let mut i = 0;
if bytes[0] == b'.' || bytes[0] == b'[' {
return Err("path must start with a name");
}
while i < bytes.len() {
match bytes[i] {
b'[' => {
let end = s[i..].find(']').map(|o| i + o).ok_or("unclosed '['")?;
let digits = &s[i + 1..end];
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return Err("index is not a number");
}
let index = digits.parse().map_err(|_| "index out of range")?;
segs.push(DocSeg::Index { index });
i = end + 1;
}
b'.' => {
let start = i + 1;
i = word_end(bytes, start);
if i == start {
return Err("empty segment after '.'");
}
segs.push(DocSeg::Field { name: s[start..i].to_owned() });
}
_ => {
let start = i;
i = word_end(bytes, start);
segs.push(DocSeg::Field { name: s[start..i].to_owned() });
}
}
}
Ok(segs)
}
fn word_end(bytes: &[u8], start: usize) -> usize {
let mut i = start;
while i < bytes.len() && bytes[i] != b'.' && bytes[i] != b'[' {
i += 1;
}
i
}
fn parse_card_root(segs: &[DocSeg]) -> Option<(DocSeg, &[DocSeg])> {
match segs {
[DocSeg::Field { .. }, DocSeg::Index { index }, rest @ ..] => {
Some((DocSeg::Card { kind: None, index: *index }, rest))
}
[DocSeg::Field { .. }, DocSeg::Field { name: kind }, DocSeg::Index { index }, rest @ ..] => {
Some((
DocSeg::Card {
kind: Some(kind.clone()),
index: *index,
},
rest,
))
}
_ => None,
}
}
fn tail_segs(rest: &[DocSeg]) -> Vec<DocSeg> {
match rest {
[DocSeg::Field { name }] if name == "body" => vec![DocSeg::Body],
_ => rest.to_vec(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn round_trip(path: DocPath, rendered: &str) {
assert_eq!(path.to_string(), rendered, "serialize");
assert_eq!(
rendered.parse::<DocPath>().expect("parse"),
path,
"parse back"
);
}
#[test]
fn main_field_and_nested() {
round_trip(DocPath::main(), "main");
round_trip(DocPath::main().field("title"), "main.title");
round_trip(
DocPath::main().field("recipients").index(0).field("name"),
"main.recipients[0].name",
);
}
#[test]
fn main_body() {
round_trip(DocPath::main_body(), "main.body");
}
#[test]
fn card_roots() {
round_trip(DocPath::card(Some("indorsement"), 0), "cards.indorsement[0]");
round_trip(DocPath::card(None, 3), "cards[3]");
}
#[test]
fn card_field_and_body() {
round_trip(
DocPath::card(Some("indorsement"), 0).field("signature_block"),
"cards.indorsement[0].signature_block",
);
round_trip(
DocPath::card(Some("skills"), 2).body(),
"cards.skills[2].body",
);
round_trip(
DocPath::card(Some("indorsement"), 0)
.field("recipients")
.index(1)
.field("name"),
"cards.indorsement[0].recipients[1].name",
);
}
#[test]
fn body_is_reserved_only_as_a_root_terminal() {
round_trip(
DocPath::card(Some("k"), 0).field("body").field("x"),
"cards.k[0].body.x",
);
round_trip(DocPath::main().field("x"), "main.x");
}
#[test]
fn main_field_named_for_a_root_no_longer_collides() {
round_trip(DocPath::main().field("cards"), "main.cards");
round_trip(DocPath::main().field("main"), "main.main");
round_trip(DocPath::new().field("cards").field("foo"), "cards.foo");
}
#[test]
fn config_space_anchor_is_the_unrooted_form() {
round_trip(
DocPath::new()
.field("$seed")
.field("indorsement")
.field("author"),
"$seed.indorsement.author",
);
}
#[test]
fn segment_bridge() {
let base = DocPath::card(Some("k"), 0);
assert_eq!(
base.segment(&PathSegment::Key("addr".into()))
.segment(&PathSegment::Index(2))
.to_string(),
"cards.k[0].addr[2]",
);
}
#[test]
fn parse_rejects_malformed() {
for bad in ["", ".foo", "[0]", "foo[", "foo[a]", "foo[]", "a..b", "a."] {
assert!(bad.parse::<DocPath>().is_err(), "expected error for {bad:?}");
}
}
#[test]
fn serde_round_trips_as_tagged_array() {
let path = DocPath::card(Some("indorsement"), 0).field("sig");
let json = serde_json::to_string(&path).unwrap();
assert_eq!(
json,
r#"[{"seg":"card","kind":"indorsement","index":0},{"seg":"field","name":"sig"}]"#
);
assert_eq!(serde_json::from_str::<DocPath>(&json).unwrap(), path);
}
}