gastownhall/beads · error

expected ')' at position %d, got %s

Error message

expected ')' at position %d, got %s

What it means

parsePrimary (internal/query/parser.go:222) parsed the contents of a '(' group via parseOr but the next token was not ')'. It reports the current token's position and type (e.g. EOF, IDENT, AND). Raised through parseNot <- parseAnd <- parseOr whenever a group is left unclosed.

Source

Thrown at internal/query/parser.go:222

		}
		return &NotNode{Operand: operand}, nil
	}

	return p.parsePrimary()
}

// parsePrimary parses primary expressions (comparisons and parenthesized expressions).
func (p *Parser) parsePrimary() (Node, error) {
	if p.current.Type == TokenLParen {
		if err := p.advance(); err != nil {
			return nil, err
		}
		node, err := p.parseOr()
		if err != nil {
			return nil, err
		}
		if p.current.Type != TokenRParen {
			return nil, fmt.Errorf("expected ')' at position %d, got %s", p.current.Pos, p.current.Type.String())
		}
		if err := p.advance(); err != nil {
			return nil, err
		}
		return node, nil
	}

	return p.parseComparison()
}

// parseComparison parses a field comparison.
func (p *Parser) parseComparison() (Node, error) {
	if p.current.Type != TokenIdent {
		return nil, fmt.Errorf("expected field name at position %d, got %s", p.current.Pos, p.current.Type.String())
	}

	// Field names are case-insensitive, but the key suffix of a
	// metadata.<key> field keeps its original case: metadata keys are

View on GitHub (pinned to 71377f2769)

Solutions

  1. Add the missing ')' for the group opened at the position reported by the preceding '(' — balance every '(' with a ')'.
  2. Quote the entire query at the shell level (`bd list "(a=1 OR b=2)"`) so the shell does not consume the parentheses.
  3. Count parens programmatically before calling Parse (ignoring quoted sections).
  4. Rebuild the query from smaller pieces, testing each group parses before combining.

Example fix

// before
query.Parse("(status=open OR status=blocked")
// after
query.Parse("(status=open OR status=blocked)")
Defensive patterns

Strategy: validation

Validate before calling

// Check parenthesis balance outside quoted strings
func parensBalanced(q string) bool {
    depth := 0
    var inQuote rune
    for i := 0; i < len(q); i++ {
        c := q[i]
        if inQuote != 0 {
            if c == '\\' { i++ } else if rune(c) == inQuote { inQuote = 0 }
            continue
        }
        switch c {
        case '\'', '"':
            inQuote = rune(c)
        case '(':
            depth++
        case ')':
            depth--
        }
        if depth < 0 { return false }
    }
    return depth == 0
}

Try / catch

if _, err := query.Parse(q); err != nil && strings.Contains(err.Error(), "expected ')'") {
    return fmt.Errorf("unclosed group in query; balance your parentheses: %w", err)
}

Prevention

When it happens

Trigger: query.Parse with unbalanced parentheses: `(status=open OR status=blocked` (missing final ')'), `(status=open AND priority>1 updated>7d` , or a nested group missing its closer: `((a=1 OR b=2) AND c=3`.

Common situations: Hand-edited queries losing a paren during modification; deeply nested filters where count of opens/closes diverges; shell interpretations stripping parens (unquoted `(` and `)` are shell syntax and get eaten before reaching bd — the query then arrives unbalanced).

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/1d21cf5104b2ad84. Report an issue: GitHub.