haematite-cli 0.21.1

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! A labelled node or fixed chain of directed hops, with byte locations for every name.

use super::{Input, Refused, Source, Span, Spanned, Stage, bare, first, spans};

#[derive(Debug, PartialEq, Eq)]
pub struct Node {
    pub binding: Spanned<String>,
    pub label: Spanned<String>,
    pub span: Span,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
    Outgoing,
    Incoming,
}

#[derive(Debug, PartialEq, Eq)]
pub struct Relation {
    pub kind: Spanned<String>,
    pub direction: Direction,
    pub span: Span,
}

/// Read only the outer stage; a following pipe belongs to the pipeline.
pub(super) fn read(text: &str, start: usize) -> Result<Spanned<Stage>, Refused> {
    let rest = text.trim_start();
    let span = spans::stage(rest, start + text.len() - rest.len());
    let text = rest.split_at(span.end - span.start).0;
    let mut input = Input { text, rest: text };
    if input.word() != "match" {
        return Err(refuse(&input, span.start, "the `match` keyword"));
    }
    input.advance(5);
    input.trim();
    if !input.rest.starts_with('(') {
        return Err(Refused::NotYet(
            "a `match` stage without a labelled node",
            Span {
                start: span.start,
                end: span.start + 5,
            },
        ));
    }
    let mut nodes = vec![node(&mut input, span.start)?];
    input.trim();
    let mut relations = Vec::new();
    while !input.rest.is_empty() {
        suffix(&input, span.start)?;
        let relation = relation(&mut input, span.start)?;
        if relations.len() == 7 {
            return Err(Refused::NotYet(
                "more than seven hops in `match`",
                relation.span,
            ));
        }
        relations.push(relation);
        nodes.push(node(&mut input, span.start)?);
        input.trim();
    }
    Ok(Spanned {
        value: Stage::Match { nodes, relations },
        span,
    })
}

/// A match takes records only from its own label and precedes other stages.
pub(super) fn position(
    stage: &Stage,
    source: &Source,
    preceding: usize,
    span: Span,
) -> Result<(), Refused> {
    let Stage::Match { nodes, .. } = stage else {
        return Ok(());
    };
    if matches!(source, Source::Shaped(roots) if roots.len() > 1) {
        return Err(Refused::NotYet(
            "`match` after a shape with several roots",
            span,
        ));
    }
    if preceding != 0 {
        return Err(Refused::NotYet(
            "`match` anywhere but first or immediately after its collection",
            span,
        ));
    }
    let Some(node) = nodes.first() else {
        return Err(Refused::NotYet("a `match` node with no label", span));
    };
    if let Source::Collection { name, .. } = source
        && *name != node.label.value
    {
        return Err(Refused::NotYet(
            "`match` after a collection other than its first node's label",
            node.label.span,
        ));
    }
    Ok(())
}

fn node(input: &mut Input<'_>, start: usize) -> Result<Node, Refused> {
    input.trim();
    let begin = start + input.position();
    if !input.take("(") {
        return Err(refuse(input, start, "a `match` node pattern"));
    }
    input.trim();
    let binding = binding(input, start)?;
    input.trim();
    if input.rest.starts_with('{') {
        return Err(refuse(input, start, "a `match` property map"));
    }
    if !input.take(":") {
        return Err(refuse(input, start, "a `match` node with no label"));
    }
    input.trim();
    let label = name(input, start, "a `match` node with no label")?;
    input.trim();
    if input.rest.starts_with('{') {
        return Err(refuse(input, start, "a `match` property map"));
    }
    if input.rest.starts_with(':') {
        return Err(refuse(input, start, "multiple labels on a `match` node"));
    }
    if !input.take(")") {
        return Err(refuse(input, start, "an unclosed node in `match`"));
    }
    Ok(Node {
        binding,
        label,
        span: Span {
            start: begin,
            end: start + input.position(),
        },
    })
}

fn binding(input: &mut Input<'_>, start: usize) -> Result<Spanned<String>, Refused> {
    let length = input
        .rest
        .chars()
        .take_while(|character| {
            !character.is_whitespace() && !matches!(*character, ':' | ')' | '(' | '{' | '[' | ']')
        })
        .map(char::len_utf8)
        .sum();
    let value = input.rest.split_at(length).0;
    let valid = value
        .bytes()
        .next()
        .is_some_and(|byte| byte.is_ascii_lowercase() || byte == b'_')
        && value
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_');
    if !valid {
        let token = input.token_span();
        return Err(Refused::NotYet(
            "a `match` binding name outside [a-z_][a-z0-9_]*",
            Span {
                start: start + token.start,
                end: start + token.start + length.max(token.end - token.start),
            },
        ));
    }
    let span = Span {
        start: start + input.position(),
        end: start + input.position() + length,
    };
    let value = value.to_owned();
    input.advance(length);
    Ok(Spanned { value, span })
}

fn name(
    input: &mut Input<'_>,
    start: usize,
    missing: &'static str,
) -> Result<Spanned<String>, Refused> {
    let length = input
        .rest
        .chars()
        .take_while(|character| bare(*character))
        .map(char::len_utf8)
        .sum();
    let value = input.rest.split_at(length).0;
    if !value.starts_with(first) {
        return Err(refuse(input, start, missing));
    }
    let span = Span {
        start: start + input.position(),
        end: start + input.position() + length,
    };
    let value = value.to_owned();
    input.advance(length);
    Ok(Spanned { value, span })
}

fn relation(input: &mut Input<'_>, start: usize) -> Result<Relation, Refused> {
    let begin = start + input.position();
    let direction = if input.take("<-") {
        Direction::Incoming
    } else if input.take("-") {
        Direction::Outgoing
    } else {
        return Err(refuse(input, start, "an unsupported `match` suffix"));
    };
    input.trim();
    if !input.take("[") {
        return Err(refuse(input, start, "a `match` relation with no kind"));
    }
    input.trim();
    if !input.take(":") {
        let construct = if input.rest.starts_with(']') {
            "a `match` relation with no kind"
        } else {
            "a `match` relation binding"
        };
        return Err(refuse(input, start, construct));
    }
    input.trim();
    let kind = name(input, start, "a `match` relation with no kind")?;
    input.trim();
    if input.rest.starts_with('*') {
        return Err(refuse(input, start, "a variable-length `match` relation"));
    }
    if input.rest.starts_with('{') {
        return Err(refuse(input, start, "a `match` property map"));
    }
    if !input.take("]") {
        return Err(refuse(input, start, "an unclosed `match` relation kind"));
    }
    input.trim();
    let arrow = match direction {
        Direction::Outgoing => "->",
        Direction::Incoming => "-",
    };
    if direction == Direction::Incoming && input.rest.starts_with("->") {
        return Err(refuse(input, start, "a bidirectional `match` relation"));
    }
    if !input.take(arrow) {
        return Err(refuse(
            input,
            start,
            "an undirected or incomplete `match` arrow",
        ));
    }
    Ok(Relation {
        kind,
        direction,
        span: Span {
            start: begin,
            end: start + input.position(),
        },
    })
}

fn suffix(input: &Input<'_>, start: usize) -> Result<(), Refused> {
    let construct = if input.word().eq_ignore_ascii_case("where") {
        Some("a `match` WHERE clause")
    } else if input.rest.starts_with('{') {
        Some("a `match` property map")
    } else if input.rest.starts_with(',') {
        Some("multiple patterns in `match`")
    } else if input.rest.starts_with('(') {
        Some("two `match` nodes without a relation")
    } else {
        None
    };
    construct.map_or(Ok(()), |construct| Err(refuse(input, start, construct)))
}

fn refuse(input: &Input<'_>, start: usize, construct: &'static str) -> Refused {
    let span = input.token_span();
    Refused::NotYet(
        construct,
        Span {
            start: start + span.start,
            end: start + span.end,
        },
    )
}

#[cfg(test)]
#[path = "match_tests.rs"]
mod tests;

#[cfg(test)]
#[path = "match_contract_tests.rs"]
mod contract_tests;

#[cfg(test)]
#[path = "match_library_tests.rs"]
mod library_tests;

#[cfg(test)]
#[path = "match_chain_tests.rs"]
mod chain_tests;