Search by

chez14 / lucene-ast

chez14

Zero-runtime Lucene syntax parser to AST.

v0.0.1 2026-10-04 17:41 UTC

This package is auto-updated.

Last update: 2026-10-04 10:51:48 UTC


README

Parse Lucene-style query strings into a typed AST. Zero dependencies.

Installation

composer require chez14/lucene-ast

Usage

use CHEZ14\LuceneAst\Parser;
use CHEZ14\LuceneAst\Printer;

$ast = (new Parser())->parse('title:"hello world" AND -(status:draft OR status:archived) date:[2020 TO *}');

parse() returns a tree of immutable nodes (null for an empty query):

Or
├─ And
│  ├─ Field(title) → Phrase("hello world")
│  └─ Not
│     └─ Or
│        ├─ Field(status) → Term("draft")
│        └─ Field(status) → Term("archived")
└─ Field(date) → Range(lower: "2020", upper: null, includeLower: true, includeUpper: false)

Every node is JsonSerializable:

echo json_encode($ast);
// {"type":"or","children":[{"type":"and","children":[{"type":"field","field":"title","value":{"type":"phrase","value":"hello world"}},{"type":"not","child":{"type":"or","children":[...]}}]},{"type":"field","field":"date","value":{"type":"range","lower":"2020","upper":null,"includeLower":true,"includeUpper":false}}]}

Clauses without an operator (a b) are joined with an OR by default. To join them with AND instead:

use CHEZ14\LuceneAst\BooleanOperator;

$parser = new Parser(defaultOperator: BooleanOperator::And);

Printer turns an AST back into a canonical query string. Parsing the output gives the same AST:

echo (new Printer())->print($ast);
// (title:"hello world" AND -(status:draft OR status:archived)) OR date:[2020 TO *}

Supported syntax

SyntaxMeaningAST
hellotermTermNode('hello')
"hello world"phrasePhraseNode('hello world')
hello\ world, chez\:14term with escaped charactersTermNode('hello world'), TermNode('chez:14')
foo*, te?twildcardWildcardNode('foo*')
/jo(h)?n/regular expressionRegexpNode('jo(h)?n')
[a TO b], {a TO b}, [a TO b}range: [ ] inclusive, { } exclusiveRangeNode('a', 'b', true, true)
[* TO 100]open-ended rangeRangeNode(null, '100', true, true)
title:hellofieldFieldNode('title', TermNode('hello'))
title:(a OR b)field applied to a groupFieldNode('title', OrNode(a, b))
a AND b, a && bconjunctionAndNode(a, b)
a OR b, a \|\| bdisjunctionOrNode(a, b)
a bimplicit operatorOrNode(a, b) by default, AndNode(a, b) with defaultOperator: And
-a, NOT a, !anegationNotNode(a)
(a OR b) AND cgroupingAndNode(OrNode(a, b), c): parentheses shape the tree but leave no node

The full grammar, escaping rules and error list are in docs/syntax.md.

Differences from Lucene

  • Boolean precedence. Lucene's classic parser has no precedence: it flags each clause as optional, required or prohibited. Here the operators are plain boolean logic, NOT binds tighter than AND, and AND binds tighter than OR. So a b -c is a OR b OR NOT c (or a AND b AND NOT c with defaultOperator: And), not Lucene's "(a or b) and not c". See ADR 0001.
  • + is rejected. +a +b would silently turn into an OR of a and b, so it throws instead. Write a AND b, or escape the character (\+a). ^ (boost) and ~ (fuzzy) are rejected too. See ADR 0002.

Security

The parser is hardened against hostile input: lexing and parsing are linear, nesting is capped by maxDepth (default 64), and error messages never contain query text. What you do with the AST is up to you:

  • Cap the query length before parsing. Memory grows linearly with the input.
  • Whitelist field names. Never put them into SQL or column names.
  • Bind values as parameters.
  • Never pass RegexpNode::$pattern or WildcardNode::$pattern straight to preg_* or LIKE. They are Lucene syntax, not PCRE or SQL, and user-controlled patterns can cause catastrophic backtracking.
  • Keep maxDepth modest. Each level costs about five PHP frames; raising it far above the default can hit Xdebug's xdebug.max_nesting_level (512 by default) or PHP's own stack limit.

More detail in docs/syntax.md.

Errors

Syntax errors throw CHEZ14\LuceneAst\Exception\ParseException (an InvalidArgumentException) with a reason and the byte position of the problem:

use CHEZ14\LuceneAst\Exception\ParseException;

try {
    (new Parser())->parse('title:(foo OR');
} catch (ParseException $e) {
    echo $e->reason;   // Unexpected end of query
    echo $e->position; // 13
}

Development

composer install
composer test       # PHPUnit
composer lint       # PHP-CS-Fixer (check) then phpcs
composer lint:fix   # PHP-CS-Fixer, apply formatting fixes
composer check      # lint + test, same as CI

Formatting is done by PHP-CS-Fixer (@PER-CS); phpcs enforces the chez14/phpcs rules, including documented functions and no ternary expressions.

License

Released under the MIT License.