Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions pkg/ghexpr/builder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package ghexpr

import (
"strings"

"github.com/github/gh-aw/pkg/logger"
)

var builderLog = logger.New("ghexpr:builder")

// BuildConditionTree creates a condition tree from an existing condition string and a
// new draft condition string. When existingCondition is empty, the draft is returned
// as a standalone [ExpressionNode]; otherwise the two are combined with AND.
func BuildConditionTree(existingCondition string, draftCondition string) ConditionNode {
builderLog.Printf("Building condition tree: existing=%q, draft=%q", existingCondition, draftCondition)
draftNode := &ExpressionNode{Expression: draftCondition}
if existingCondition == "" {
return draftNode
}
existingNode := &ExpressionNode{Expression: existingCondition}
return &AndNode{Left: existingNode, Right: draftNode}
}

// BuildOr creates an OR node combining two conditions.
func BuildOr(left ConditionNode, right ConditionNode) ConditionNode {
return &OrNode{Left: left, Right: right}
}

// BuildAnd creates an AND node combining two conditions.
func BuildAnd(left ConditionNode, right ConditionNode) ConditionNode {
builderLog.Print("Building AND condition node")
return &AndNode{Left: left, Right: right}
}

// BuildPropertyAccess creates a property-access node for a dotted path.
// Example: BuildPropertyAccess("github.event.action")
func BuildPropertyAccess(path string) *PropertyAccessNode {
return &PropertyAccessNode{PropertyPath: path}
}

// BuildStringLiteral creates a single-quoted string literal node.
func BuildStringLiteral(value string) *StringLiteralNode {
return &StringLiteralNode{Value: value}
}

// BuildBooleanLiteral creates a boolean literal node (true or false).
func BuildBooleanLiteral(value bool) *BooleanLiteralNode {
return &BooleanLiteralNode{Value: value}
}

// BuildNullLiteral creates a null literal node.
func BuildNullLiteral() *ExpressionNode {
return &ExpressionNode{Expression: "null"}
}

// BuildComparison creates a comparison node with an explicit operator string
// such as "==", "!=", "<", ">", "<=", or ">=".
func BuildComparison(left ConditionNode, operator string, right ConditionNode) *ComparisonNode {
return &ComparisonNode{Left: left, Operator: operator, Right: right}
}

// BuildEquals creates an equality comparison node (==).
func BuildEquals(left ConditionNode, right ConditionNode) *ComparisonNode {
return BuildComparison(left, "==", right)
}

// BuildNotEquals creates an inequality comparison node (!=).
func BuildNotEquals(left ConditionNode, right ConditionNode) *ComparisonNode {
return BuildComparison(left, "!=", right)
}

// BuildFunctionCall creates a function-call node.
// Example: BuildFunctionCall("contains", BuildPropertyAccess("github.event_name"), BuildStringLiteral("push"))
func BuildFunctionCall(functionName string, args ...ConditionNode) *FunctionCallNode {
return &FunctionCallNode{FunctionName: functionName, Arguments: args}
}

// BuildDisjunction creates a multi-term OR node.
// When multiline is true, [DisjunctionNode.RenderMultiline] is used.
func BuildDisjunction(multiline bool, terms ...ConditionNode) *DisjunctionNode {
return &DisjunctionNode{Terms: terms, Multiline: multiline}
}

// RenderCondition runs [OptimizeExpression] on node and returns the rendered string.
func RenderCondition(node ConditionNode) string {
return OptimizeExpression(node).Render()
}

// RenderConditionAsIf writes a YAML `if:` block scalar entry to yaml using the given
// indentation prefix for each line of the rendered condition. The condition is first
// optimized with [OptimizeExpression].
func RenderConditionAsIf(yaml *strings.Builder, condition ConditionNode, indent string) {
yaml.WriteString(" if: |\n")
conditionStr := RenderCondition(condition)
for line := range strings.SplitSeq(conditionStr, "\n") {
yaml.WriteString(indent + line + "\n")
}
}
79 changes: 79 additions & 0 deletions pkg/ghexpr/builder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//go:build !integration

package ghexpr_test

import (
"testing"

"github.com/github/gh-aw/pkg/ghexpr"
"github.com/stretchr/testify/assert"
)

// TestBuildBasicNodes verifies that all primitive builders produce the expected Render output.
func TestBuildBasicNodes(t *testing.T) {
assert.Equal(t, "a.b.c", ghexpr.BuildPropertyAccess("a.b.c").Render())
assert.Equal(t, "'hello'", ghexpr.BuildStringLiteral("hello").Render())
assert.Equal(t, "'it''s'", ghexpr.BuildStringLiteral("it's").Render(), "embedded single quote must be doubled")
assert.Equal(t, "true", ghexpr.BuildBooleanLiteral(true).Render())
assert.Equal(t, "false", ghexpr.BuildBooleanLiteral(false).Render())
assert.Equal(t, "null", ghexpr.BuildNullLiteral().Render())
}

// TestBuildComparison verifies comparison builders.
func TestBuildComparison(t *testing.T) {
left := ghexpr.BuildPropertyAccess("github.event_name")
right := ghexpr.BuildStringLiteral("push")

assert.Equal(t, "github.event_name == 'push'", ghexpr.BuildEquals(left, right).Render())
assert.Equal(t, "github.event_name != 'push'", ghexpr.BuildNotEquals(left, right).Render())
assert.Equal(t, "github.event_name < 'push'", ghexpr.BuildComparison(left, "<", right).Render())
}

// TestBuildFunctionCall verifies function-call builder.
func TestBuildFunctionCall(t *testing.T) {
node := ghexpr.BuildFunctionCall("contains",
ghexpr.BuildPropertyAccess("github.event.label.name"),
ghexpr.BuildStringLiteral("deploy"),
)
assert.Equal(t, "contains(github.event.label.name, 'deploy')", node.Render())
}

// TestBuildLogical verifies AND / OR builders.
func TestBuildLogical(t *testing.T) {
a := ghexpr.BuildPropertyAccess("a")
b := ghexpr.BuildPropertyAccess("b")

// PropertyAccessNode operands of AND don't need parentheses.
assert.Equal(t, "a && b", ghexpr.BuildAnd(a, b).Render())
assert.Equal(t, "a || b", ghexpr.BuildOr(a, b).Render())
}

// TestBuildConditionTree verifies condition tree composition.
func TestBuildConditionTree(t *testing.T) {
assert.Equal(t, "draft",
ghexpr.BuildConditionTree("", "draft").Render(),
"empty existing condition returns draft-only node")

tree := ghexpr.BuildConditionTree("existing", "draft")
assert.Equal(t, "(existing) && (draft)", tree.Render())
}

// TestBuildDisjunction verifies multi-term OR builder.
func TestBuildDisjunction(t *testing.T) {
node := ghexpr.BuildDisjunction(false,
ghexpr.BuildPropertyAccess("a"),
ghexpr.BuildPropertyAccess("b"),
ghexpr.BuildPropertyAccess("c"),
)
assert.Equal(t, "a || b || c", node.Render())
}

// TestRenderCondition verifies RenderCondition optimises and renders.
func TestRenderCondition(t *testing.T) {
// A && true should optimise to just A.
node := ghexpr.BuildAnd(
ghexpr.BuildPropertyAccess("github.event_name"),
ghexpr.BuildBooleanLiteral(true),
)
assert.Equal(t, "github.event_name", ghexpr.RenderCondition(node))
}
73 changes: 73 additions & 0 deletions pkg/ghexpr/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Package ghexpr implements a parser, optimizer, and builder for GitHub Actions
// expression syntax (${{ ... }}).
//
// # Grammar
//
// The following grammar describes the subset of GitHub Actions expression
// syntax handled by this package (adapted from the official spec at
// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions):
//
// expression = or_expr
// or_expr = and_expr ( "||" and_expr )*
// and_expr = unary_expr ( "&&" unary_expr )*
// unary_expr = "!" unary_expr | primary_expr
// primary_expr = "(" expression ")"
// | function_call
// | literal
// | property_access
// function_call = IDENT "(" [ expression ( "," expression )* ] ")"
// property_access= IDENT ( "." IDENT )*
// | IDENT "[" expression "]"
// literal = STRING | NUMBER | "true" | "false" | "null"
// STRING = "'" [^']* "'" | '"' [^"]* '"'
// NUMBER = "-"? DIGIT+ ( "." DIGIT+ )?
// IDENT = [a-zA-Z_] [a-zA-Z0-9_-]*
//
// Note: GitHub Actions expressions are always wrapped in ${{ and }} markers
// when embedded in YAML values. This package operates on the inner content
// (without the ${{ }} wrappers) unless otherwise documented.
//
// # AST
//
// Parsed expressions are represented as a tree of [ConditionNode] values:
//
// - [ExpressionNode] – an opaque leaf expression
// - [AndNode] – left && right
// - [OrNode] – left || right
// - [NotNode] – !child
// - [ComparisonNode] – left op right (==, !=, <, >, <=, >=)
// - [FunctionCallNode] – func(arg, …)
// - [PropertyAccessNode]– a.b.c property path
// - [StringLiteralNode] – 'value'
// - [BooleanLiteralNode]– true / false
// - [DisjunctionNode] – multi-term OR, for rendering as GitHub's multi-line
//
// # Optimizer
//
// [OptimizeExpression] applies boolean-algebra simplifications (idempotent law,
// absorption, De Morgan, etc.) to produce a shorter equivalent expression.
// Status-function calls (always, success, failure, cancelled) are treated as
// opaque so the optimizer never removes them.
//
// # Builders
//
// Composable [Build…] functions construct condition trees without touching raw
// strings. [RenderCondition] renders a tree to its GitHub Actions syntax.
//
// # Patterns
//
// Pre-compiled [regexp.Regexp] values are exposed for common matching tasks:
//
// - [ExpressionPattern] – matches ${{ … }}
// - [ExpressionPatternDotAll] – same with dot-all flag
// - [StringLiteralPattern] – matches 'x', "x", or `x`
// - [NumberLiteralPattern] – matches numeric literals
// - [OrPattern] – splits left || right
// - [ComparisonExtractionPattern] – extracts the left side of comparisons
// - [InlineExpressionPattern] – matches inline ${{ … }}
// - [TemplateIfPattern] – matches {{#if … }}
// - [TemplateElseIfPattern] – matches {{#elseif … }}
// - [RangePattern] – matches numeric ranges (e.g. "1-10")
//
// Predicate helpers: [HasExpressionMarker], [ContainsExpression], [IsExpression].
package ghexpr
Loading