gha-expression-proof 1.0.0

GitHub Actions expression evaluator and receipt generator for offline CI compatibility testing
Documentation
use crate::ast::{AccessSegment, BinaryOp, Expr, UnaryOp};
use crate::lexer::{Token, TokenKind, lex};
use anyhow::{Result, bail};

pub fn parse_expression(input: &str) -> Result<Expr> {
    let unwrapped = unwrap_expression(input);
    let tokens = lex(&unwrapped)?;
    let mut parser = Parser { tokens, pos: 0 };
    let expr = parser.parse_or()?;
    parser.expect_eof()?;
    Ok(expr)
}

pub fn unwrap_expression(input: &str) -> String {
    let trimmed = input.trim();
    if let Some(inner) = trimmed
        .strip_prefix("${{")
        .and_then(|value| value.strip_suffix("}}"))
    {
        inner.trim().to_owned()
    } else {
        trimmed.to_owned()
    }
}

struct Parser {
    tokens: Vec<Token>,
    pos: usize,
}

impl Parser {
    fn parse_or(&mut self) -> Result<Expr> {
        let mut expr = self.parse_and()?;
        while self.matches(&TokenKind::OrOr) {
            let right = self.parse_and()?;
            expr = Expr::Binary {
                op: BinaryOp::Or,
                left: Box::new(expr),
                right: Box::new(right),
            };
        }
        Ok(expr)
    }

    fn parse_and(&mut self) -> Result<Expr> {
        let mut expr = self.parse_equality()?;
        while self.matches(&TokenKind::AndAnd) {
            let right = self.parse_equality()?;
            expr = Expr::Binary {
                op: BinaryOp::And,
                left: Box::new(expr),
                right: Box::new(right),
            };
        }
        Ok(expr)
    }

    fn parse_equality(&mut self) -> Result<Expr> {
        let mut expr = self.parse_relational()?;
        loop {
            let op = if self.matches(&TokenKind::EqEq) {
                Some(BinaryOp::Eq)
            } else if self.matches(&TokenKind::Ne) {
                Some(BinaryOp::Ne)
            } else {
                None
            };
            let Some(op) = op else { break };
            let right = self.parse_relational()?;
            expr = Expr::Binary {
                op,
                left: Box::new(expr),
                right: Box::new(right),
            };
        }
        Ok(expr)
    }

    fn parse_relational(&mut self) -> Result<Expr> {
        let mut expr = self.parse_unary()?;
        loop {
            let op = if self.matches(&TokenKind::Lt) {
                Some(BinaryOp::Lt)
            } else if self.matches(&TokenKind::Le) {
                Some(BinaryOp::Le)
            } else if self.matches(&TokenKind::Gt) {
                Some(BinaryOp::Gt)
            } else if self.matches(&TokenKind::Ge) {
                Some(BinaryOp::Ge)
            } else {
                None
            };
            let Some(op) = op else { break };
            let right = self.parse_unary()?;
            expr = Expr::Binary {
                op,
                left: Box::new(expr),
                right: Box::new(right),
            };
        }
        Ok(expr)
    }

    fn parse_unary(&mut self) -> Result<Expr> {
        if self.matches(&TokenKind::Bang) {
            return Ok(Expr::Unary {
                op: UnaryOp::Not,
                expr: Box::new(self.parse_unary()?),
            });
        }
        self.parse_postfix()
    }

    fn parse_postfix(&mut self) -> Result<Expr> {
        let mut expr = self.parse_primary()?;
        loop {
            if self.matches(&TokenKind::Dot) {
                if self.matches(&TokenKind::Star) {
                    expr = Expr::Access {
                        base: Box::new(expr),
                        segment: AccessSegment::Wildcard,
                    };
                } else {
                    let name = self.expect_ident("expected property name after `.`")?;
                    expr = Expr::Access {
                        base: Box::new(expr),
                        segment: AccessSegment::Property(name),
                    };
                }
            } else if self.matches(&TokenKind::LBracket) {
                if self.matches(&TokenKind::Star) {
                    self.expect(&TokenKind::RBracket, "expected `]` after wildcard")?;
                    expr = Expr::Access {
                        base: Box::new(expr),
                        segment: AccessSegment::Wildcard,
                    };
                } else {
                    let index = self.parse_or()?;
                    self.expect(&TokenKind::RBracket, "expected `]` after index")?;
                    expr = Expr::Access {
                        base: Box::new(expr),
                        segment: AccessSegment::Index(Box::new(index)),
                    };
                }
            } else {
                break;
            }
        }
        Ok(expr)
    }

    fn parse_primary(&mut self) -> Result<Expr> {
        match self.advance().kind.clone() {
            TokenKind::Literal(value) => Ok(Expr::Literal(value)),
            TokenKind::Ident(name) => {
                if self.matches(&TokenKind::LParen) {
                    let mut args = Vec::new();
                    if !self.check(&TokenKind::RParen) {
                        loop {
                            args.push(self.parse_or()?);
                            if !self.matches(&TokenKind::Comma) {
                                break;
                            }
                        }
                    }
                    self.expect(&TokenKind::RParen, "expected `)` after function arguments")?;
                    Ok(Expr::Call { name, args })
                } else {
                    Ok(Expr::Variable(name))
                }
            }
            TokenKind::LParen => {
                let expr = self.parse_or()?;
                self.expect(&TokenKind::RParen, "expected `)` after grouped expression")?;
                Ok(expr)
            }
            other => bail!("expected expression, found {other:?}"),
        }
    }

    fn expect_eof(&mut self) -> Result<()> {
        if self.check(&TokenKind::Eof) {
            Ok(())
        } else {
            bail!("unexpected token {:?} after expression", self.peek().kind)
        }
    }

    fn expect_ident(&mut self, message: &str) -> Result<String> {
        match self.advance().kind.clone() {
            TokenKind::Ident(name) => Ok(name),
            _ => bail!("{message}"),
        }
    }

    fn expect(&mut self, kind: &TokenKind, message: &str) -> Result<()> {
        if self.matches(kind) {
            Ok(())
        } else {
            bail!("{message}")
        }
    }

    fn matches(&mut self, kind: &TokenKind) -> bool {
        if self.check(kind) {
            self.pos += 1;
            true
        } else {
            false
        }
    }

    fn check(&self, kind: &TokenKind) -> bool {
        std::mem::discriminant(&self.peek().kind) == std::mem::discriminant(kind)
    }

    fn advance(&mut self) -> &Token {
        let index = self.pos;
        self.pos += 1;
        &self.tokens[index]
    }

    fn peek(&self) -> &Token {
        &self.tokens[self.pos]
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_object_filter() {
        let expr = parse_expression("github.event.issue.labels.*.name").unwrap();
        let mut roots = Vec::new();
        expr.collect_roots(&mut roots);
        assert_eq!(roots, vec!["github"]);
    }

    #[test]
    fn rejects_double_quoted_strings() {
        assert!(parse_expression("github.ref == \"main\"").is_err());
    }
}