cheers 0.1.0-alpha.1

Fullstack hypermedia framework for Rust.
#[inline]
fn validate_signal_path_segment(segment: &str) -> Result<(), String> {
    if segment == "__proto__" {
        Err("signal path segment `__proto__` is not supported".to_owned())
    } else {
        Ok(())
    }
}

#[inline]
pub(crate) fn is_bare_signal_path_segment(segment: &str) -> bool {
    let mut chars = segment.chars();
    let Some(first) = chars.next() else {
        return false;
    };

    if !(first.is_ascii_alphanumeric() || first == '_') {
        return false;
    }

    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
}

#[inline]
fn expect_char(
    chars: &[char],
    index: usize,
    want: char,
    path: &str,
    ctx: &str,
) -> Result<(), String> {
    if chars.get(index).copied() == Some(want) {
        Ok(())
    } else {
        Err(format!(
            "invalid signal path `{path}`: expected `{want}` {ctx}"
        ))
    }
}

#[inline]
fn is_root_start(ch: char) -> bool {
    ch.is_ascii_alphanumeric() || ch == '_'
}

#[inline]
fn is_root_continue(ch: char) -> bool {
    ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
}

fn parse_quoted_segment(
    chars: &[char],
    mut index: usize,
    path: &str,
) -> Result<(String, usize), String> {
    index += 1;
    let mut value = String::new();

    while let Some(&ch) = chars.get(index) {
        if ch == '\\' {
            index += 1;
            let Some(escaped) = chars.get(index).copied() else {
                return Err(format!(
                    "invalid signal path `{path}`: unterminated escape sequence"
                ));
            };

            match escaped {
                '\\' | '\'' => value.push(escaped),
                'n' => value.push('\n'),
                'r' => value.push('\r'),
                't' => value.push('\t'),
                'u' => {
                    let mut hex = String::with_capacity(4);
                    for offset in 1..=4 {
                        let Some(digit) = chars.get(index + offset).copied() else {
                            return Err(format!(
                                "invalid signal path `{path}`: incomplete \\u escape"
                            ));
                        };
                        if !digit.is_ascii_hexdigit() {
                            return Err(format!(
                                "invalid signal path `{path}`: expected four hex digits after \\u"
                            ));
                        }
                        hex.push(digit);
                    }
                    let code = u32::from_str_radix(&hex, 16)
                        .expect("validated hex digits should always parse");
                    let Some(ch) = char::from_u32(code) else {
                        return Err(format!("invalid signal path `{path}`: invalid \\u escape"));
                    };
                    value.push(ch);
                    index += 4;
                }
                other => {
                    return Err(format!(
                        "invalid signal path `{path}`: unsupported escape `\\{other}`"
                    ));
                }
            }

            index += 1;
            continue;
        }

        if ch == '\'' {
            return Ok((value, index + 1));
        }

        value.push(ch);
        index += 1;
    }

    Err(format!(
        "invalid signal path `{path}`: unterminated quoted segment"
    ))
}

pub(crate) fn try_parse_signal_path(path: &str) -> Result<Vec<String>, String> {
    let trimmed = path.trim();
    if trimmed.is_empty() {
        return Ok(Vec::new());
    }

    let chars = trimmed.chars().collect::<Vec<_>>();
    let Some(&first) = chars.first() else {
        return Ok(Vec::new());
    };
    if !is_root_start(first) {
        return Err(format!(
            "invalid signal path `{trimmed}`: expected a root segment"
        ));
    }

    let mut index = 1;
    while matches!(chars.get(index), Some(&ch) if is_root_continue(ch)) {
        index += 1;
    }

    let root = chars[..index].iter().collect::<String>();
    validate_signal_path_segment(&root)?;
    let mut segments = vec![root];

    while let Some(&ch) = chars.get(index) {
        if ch != '[' {
            return Err(format!(
                "invalid signal path `{trimmed}`: expected `['segment']`"
            ));
        }

        expect_char(&chars, index + 1, '\'', trimmed, "to start quoted segment")?;

        let (segment, next_index) = parse_quoted_segment(&chars, index + 1, trimmed)?;
        expect_char(&chars, next_index, ']', trimmed, "after quoted segment")?;

        validate_signal_path_segment(&segment)?;
        segments.push(segment);
        index = next_index + 1;
    }

    Ok(segments)
}

pub(crate) fn parse_signal_path(path: &str) -> Vec<String> {
    try_parse_signal_path(path).unwrap_or_else(|error| panic!("{error}"))
}

#[cfg(test)]
mod tests {
    use super::parse_signal_path;
    use crate::__internal::{__push_signal_path_dynamic_segment, __push_signal_path_segment};

    #[test]
    fn serializes_bracket_segments_when_needed() {
        let mut path = String::new();
        __push_signal_path_segment(&mut path, "project");
        __push_signal_path_segment(&mut path, "user.123");
        __push_signal_path_segment(&mut path, "name");

        assert_eq!(path, "project['user.123']['name']");
    }

    #[test]
    fn encodes_unsupported_dynamic_path_segment() {
        let mut path = String::new();
        __push_signal_path_segment(&mut path, "project");
        __push_signal_path_dynamic_segment(&mut path, "__proto__");
        __push_signal_path_segment(&mut path, "name");

        assert_eq!(path, "project['$cheers$5f5f70726f746f5f5f']['name']");
        assert_eq!(
            parse_signal_path(&path),
            vec![
                "project".to_owned(),
                "$cheers$5f5f70726f746f5f5f".to_owned(),
                "name".to_owned()
            ]
        );
    }

    #[test]
    fn escapes_dynamic_encoding_prefix_to_avoid_collisions() {
        let mut encoded_proto = String::new();
        __push_signal_path_segment(&mut encoded_proto, "project");
        __push_signal_path_dynamic_segment(&mut encoded_proto, "__proto__");

        let mut escaped_prefixed_id = String::new();
        __push_signal_path_segment(&mut escaped_prefixed_id, "project");
        __push_signal_path_dynamic_segment(&mut escaped_prefixed_id, "$cheers$5f5f70726f746f5f5f");

        assert_ne!(encoded_proto, escaped_prefixed_id);
        assert_eq!(
            escaped_prefixed_id,
            "project['$cheers$2463686565727324356635663730373236663734366635663566']"
        );
    }

    #[test]
    fn parses_canonical_bracket_segments() {
        assert_eq!(
            parse_signal_path("project['user.123']['name']"),
            vec![
                "project".to_owned(),
                "user.123".to_owned(),
                "name".to_owned()
            ]
        );
    }

    #[test]
    fn parses_escaped_bracket_segments() {
        assert_eq!(
            parse_signal_path("project['O\\'Reilly']"),
            vec!["project".to_owned(), "O'Reilly".to_owned()]
        );
    }

    #[test]
    #[should_panic(expected = "__proto__")]
    fn rejects_proto_path_segment() {
        let _ = parse_signal_path("project['__proto__']['polluted']");
    }

    #[test]
    fn serializes_constructor_path_segment_as_data() {
        let mut path = String::new();
        __push_signal_path_segment(&mut path, "project");
        __push_signal_path_segment(&mut path, "constructor");
        __push_signal_path_segment(&mut path, "prototype");

        assert_eq!(path, "project['constructor']['prototype']");
    }

    #[test]
    #[should_panic(expected = "expected `['segment']`")]
    fn rejects_legacy_dotted_suffixes() {
        let _ = parse_signal_path("project.name");
    }

    #[test]
    #[should_panic(expected = "expected `'` to start quoted segment")]
    fn rejects_non_canonical_bracket_segments() {
        let _ = parse_signal_path("project[name]");
    }
}