text.rs

11.5 kB · rust · 355 lines

1use mrlycore::errors::{value_error, Result};23#[derive(Clone, Debug, PartialEq, Eq)]4pub(crate) enum Atom {5    Number(String),6    Bool(bool),7    Word(String),8    List(Vec<Atom>),9}1011impl Atom {12    fn json(&self) -> String {13        match self {14            Atom::Number(digits) => digits.clone(),15            Atom::Bool(flag) => flag.to_string(),16            Atom::Word(word) => format!("\"{word}\""),17            Atom::List(items) => {18                let inner: Vec<String> = items.iter().map(Atom::json).collect();19                format!("[{}]", inner.join(","))20            }21        }22    }23    fn plain(&self) -> String {24        match self {25            Atom::Number(digits) => digits.clone(),26            Atom::Bool(flag) => flag.to_string(),27            Atom::Word(word) => word.clone(),28            Atom::List(items) => items.iter().map(Atom::plain).collect::<Vec<_>>().join(","),29        }30    }31    fn typed(token: &str) -> Result<Atom> {32        match token {33            "true" => Ok(Atom::Bool(true)),34            "false" => Ok(Atom::Bool(false)),35            _ if !token.is_empty() && token.bytes().all(|b| b.is_ascii_digit()) => {36                Ok(Atom::Number(token.to_string()))37            }38            _ if token.as_bytes().first().is_some_and(u8::is_ascii_lowercase)39                && token40                    .bytes()41                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') =>42            {43                Ok(Atom::Word(token.to_string()))44            }45            _ => value_error(format!(46                "value {token:?} strays outside a-z, 0-9 and underscore."47            )),48        }49    }50    fn list(text: &str) -> Result<Atom> {51        if text.is_empty() {52            return Ok(Atom::List(Vec::new()));53        }54        let items: Result<Vec<Atom>> = text.split(',').map(Atom::typed).collect();55        Ok(Atom::List(items?))56    }57}5859struct Reader<'a> {60    text: &'a str,61    at: usize,62}6364impl<'a> Reader<'a> {65    fn peek(&self) -> Option<u8> {66        self.text.as_bytes().get(self.at).copied()67    }68    fn take(&mut self, byte: u8) -> Result<()> {69        if self.peek() == Some(byte) {70            self.at += 1;71            return Ok(());72        }73        value_error(format!(74            "json {:?} wants {:?} at {}.",75            self.text, byte as char, self.at76        ))77    }78    fn until(&mut self, stop: impl Fn(u8) -> bool) -> &'a str {79        let start = self.at;80        while self.peek().is_some_and(|b| !stop(b)) {81            self.at += 1;82        }83        &self.text[start..self.at]84    }85    fn word(&mut self) -> Result<String> {86        self.take(b'"')?;87        let word = self.until(|b| b == b'"').to_string();88        self.take(b'"')?;89        Ok(word)90    }91    fn atom(&mut self) -> Result<Atom> {92        match self.peek() {93            Some(b'"') => Ok(Atom::Word(self.word()?)),94            Some(b'[') => {95                self.take(b'[')?;96                let mut items = Vec::new();97                while self.peek() != Some(b']') {98                    items.push(self.atom()?);99                    if self.peek() == Some(b',') {100                        self.at += 1;101                    }102                }103                self.take(b']')?;104                Ok(Atom::List(items))105            }106            Some(b) if b.is_ascii_digit() => Ok(Atom::Number(107                self.until(|b| !b.is_ascii_digit()).to_string(),108            )),109            Some(b't') | Some(b'f') => {110                let word = self.until(|b| !b.is_ascii_lowercase());111                Atom::typed(word)112            }113            _ => value_error(format!(114                "json {:?} holds no value at {}.",115                self.text, self.at116            )),117        }118    }119}120121pub(crate) fn pairs(json: &str) -> Result<Vec<(String, Atom)>> {122    let mut reader = Reader { text: json, at: 0 };123    reader.take(b'{')?;124    let mut out = Vec::new();125    while reader.peek() != Some(b'}') {126        let key = reader.word()?;127        reader.take(b':')?;128        out.push((key, reader.atom()?));129        if reader.peek() == Some(b',') {130            reader.at += 1;131        }132    }133    reader.take(b'}')?;134    if reader.at != json.len() {135        return value_error(format!("json {json:?} carries a tail."));136    }137    Ok(out)138}139140fn kinded(json: &str) -> (String, Vec<(String, Atom)>) {141    let mut list = pairs(json).expect("a canonical name reads back");142    let (key, kind) = list.remove(0);143    assert_eq!(key, "kind");144    let Atom::Word(kind) = kind else {145        panic!("the kind is a word");146    };147    (kind, list)148}149150pub(crate) fn json(kind: &str, fields: &[(String, Atom)]) -> String {151    let mut out = format!("{{\"kind\":\"{kind}\"");152    for (key, value) in fields {153        out.push_str(&format!(",\"{key}\":{}", value.json()));154    }155    out.push('}');156    out157}158159fn key(text: &str) -> Result<&str> {160    if !text.is_empty() && text.bytes().all(|b| b.is_ascii_lowercase()) {161        return Ok(text);162    }163    value_error(format!("key {text:?} strays outside a-z."))164}165166pub(crate) fn url(json: &str) -> String {167    let (kind, fields) = kinded(json);168    if fields.is_empty() {169        return format!("/{kind}");170    }171    let query: Vec<String> = fields172        .iter()173        .map(|(key, value)| format!("{key}={}", value.plain()))174        .collect();175    format!("/{kind}?{}", query.join("&"))176}177178pub(crate) fn url_to_json(text: &str, kind: &str, lists: &[&str]) -> Result<String> {179    let Some(rest) = text.strip_prefix('/') else {180        return value_error(format!("url {text:?} does not open with a slash."));181    };182    let (path, query) = rest.split_once('?').unwrap_or((rest, ""));183    if path != kind {184        return value_error(format!("url {text:?} is not a {kind}."));185    }186    let mut fields = Vec::new();187    for pair in query.split('&').filter(|pair| !pair.is_empty()) {188        let Some((name, value)) = pair.split_once('=') else {189            return value_error(format!("url field {pair:?} has no value."));190        };191        let listed = lists.contains(&name)192            && value193                .split(',')194                .all(|token| !matches!(Atom::typed(token), Ok(Atom::Word(_))));195        let atom = if value.contains(',') || listed {196            Atom::list(value)?197        } else {198            Atom::typed(value)?199        };200        fields.push((key(name)?.to_string(), atom));201    }202    Ok(json(kind, &fields))203}204205pub(crate) fn file(json: &str) -> String {206    let (kind, fields) = kinded(json);207    let mut out = kind;208    for (key, value) in &fields {209        let spelt = match value {210            Atom::List(_) => format!("[{}]", value.plain()),211            _ => value.plain(),212        };213        out.push_str(&format!("_{key}={spelt}"));214    }215    out216}217218fn opens_field(text: &str) -> bool {219    let letters = text.bytes().take_while(u8::is_ascii_lowercase).count();220    letters > 0 && text.as_bytes().get(letters) == Some(&b'=')221}222223pub(crate) fn file_to_json(text: &str, kind: &str) -> Result<String> {224    let mut cuts = vec![0];225    for (i, b) in text.bytes().enumerate() {226        if b == b'_' && opens_field(&text[i + 1..]) {227            cuts.push(i);228        }229    }230    cuts.push(text.len());231    let mut parts = cuts.windows(2).map(|w| &text[w[0]..w[1]]);232    let head = parts.next().unwrap_or("");233    if head != kind {234        return value_error(format!("file {text:?} is not a {kind}."));235    }236    let mut fields = Vec::new();237    for part in parts {238        let Some((name, value)) = part[1..].split_once('=') else {239            return value_error(format!("file field {part:?} has no value."));240        };241        let atom = match value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) {242            Some(inner) => Atom::list(inner)?,243            None => Atom::typed(value)?,244        };245        fields.push((key(name)?.to_string(), atom));246    }247    Ok(json(kind, &fields))248}249250pub(crate) fn mrly(json: &str, bare: &[&str]) -> String {251    let (kind, fields) = kinded(json);252    let mut parts = Vec::new();253    for (key, value) in &fields {254        parts.push(match value {255            Atom::Bool(true) => key.clone(),256            Atom::Word(word) if bare.contains(&key.as_str()) => word.clone(),257            Atom::List(items) => {258                let inner: Vec<String> = items.iter().map(Atom::plain).collect();259                format!("{key} [{}]", inner.join(" "))260            }261            other => format!("{key} {}", other.plain()),262        });263    }264    if parts.is_empty() {265        return kind;266    }267    format!("{kind} {}", parts.join(", "))268}269270pub(crate) fn longest<'a>(text: &'a str, names: &[String]) -> Option<(usize, &'a str)> {271    let mut best: Option<(usize, &str)> = None;272    for (i, name) in names.iter().enumerate() {273        let Some(rest) = text.strip_prefix(name.as_str()) else {274            continue;275        };276        if best.is_none_or(|(_, tail)| rest.len() < tail.len()) {277            best = Some((i, rest));278        }279    }280    best281}282283#[cfg(test)]284mod tests {285    use super::*;286287    const KOCH: &str =288        r#"{"kind":"bang","dim":2,"lattice":"hex","base":3,"code":39,"twist":[0,1,5,0]}"#;289290    #[test]291    fn the_canonical_text_reads_into_pairs_and_back() {292        let (kind, fields) = kinded(KOCH);293        assert_eq!(kind, "bang");294        assert_eq!(fields.len(), 5);295        assert_eq!(fields[1], ("lattice".into(), Atom::Word("hex".into())));296        assert_eq!(json(&kind, &fields), KOCH);297        assert!(pairs("{\"kind\":\"bang\"} ").is_err());298        assert!(pairs("[1]").is_err());299    }300301    #[test]302    fn the_views_hold_verbatim() {303        assert_eq!(304            url(KOCH),305            "/bang?dim=2&lattice=hex&base=3&code=39&twist=0,1,5,0"306        );307        assert_eq!(308            file(KOCH),309            "bang_dim=2_lattice=hex_base=3_code=39_twist=[0,1,5,0]"310        );311        assert_eq!(312            mrly(KOCH, &["lattice"]),313            "bang dim 2, hex, base 3, code 39, twist [0 1 5 0]"314        );315        assert_eq!(316            mrly(KOCH, &[]),317            "bang dim 2, lattice hex, base 3, code 39, twist [0 1 5 0]"318        );319    }320321    #[test]322    fn the_decodable_views_read_back() {323        assert_eq!(url_to_json(&url(KOCH), "bang", &["twist"]).unwrap(), KOCH);324        assert_eq!(file_to_json(&file(KOCH), "bang").unwrap(), KOCH);325        let rule = r#"{"kind":"rule","birth":[3],"survive":"grid_squares_ones","wrap":true}"#;326        assert_eq!(327            url_to_json(&url(rule), "rule", &["birth", "survive"]).unwrap(),328            rule329        );330        assert_eq!(file_to_json(&file(rule), "rule").unwrap(), rule);331        let empty = r#"{"kind":"rule","birth":[],"survive":[]}"#;332        assert_eq!(333            url_to_json(&url(empty), "rule", &["birth", "survive"]).unwrap(),334            empty335        );336        assert_eq!(file_to_json(&file(empty), "rule").unwrap(), empty);337        assert!(url_to_json("/rule?dim=2", "bang", &[]).is_err());338        assert!(url_to_json("bang?dim=2", "bang", &[]).is_err());339        assert!(url_to_json("/bang?dim", "bang", &[]).is_err());340        assert!(url_to_json("/bang?Dim=2", "bang", &[]).is_err());341        assert!(url_to_json("/bang?dim=2&lattice=Hex", "bang", &[]).is_err());342        assert!(file_to_json("rule_dim=2", "bang").is_err());343        assert!(file_to_json("bang_dim=2_x", "bang").is_err());344    }345346    #[test]347    fn a_bare_kind_prints_alone() {348        let plain = r#"{"kind":"rule"}"#;349        assert_eq!(url(plain), "/rule");350        assert_eq!(file(plain), "rule");351        assert_eq!(mrly(plain, &[]), "rule");352        assert_eq!(url_to_json("/rule", "rule", &[]).unwrap(), plain);353        assert_eq!(file_to_json("rule", "rule").unwrap(), plain);354    }355}