CLI tool
gajus/liqe avatar
gajus/liqe

liqe: a Lucene-like query language that filters plain JavaScript objects

Lightweight and performant Lucene-like parser, serializer and search engine.

683 stars29 forksTypeScriptNOASSERTION

At a glance

What is it?
liqe is a TypeScript library that parses Lucene-like queries into an AST, serialises them back, and evaluates them in memory against JSON documents, with a grammar generated by nearley and no runtime dependencies beyond that. It suits a product that wants a familiar search box over its own objects, and it is not an index, so it does not scale the way a real search engine does.
Who is it for?
Adopt liqe if you want a Lucene-flavoured search box over documents that already live in memory, and you want the parsed query to be an object you can inspect, transform or evaluate yourself. Do not adopt it as a search engine for a large corpus, since nothing is indexed, scored or persisted, and the README does not claim Lucene compatibility beyond what the parser covers.
Can I use it commercially?
Check first. The repository uses a licence we do not classify automatically, so read its LICENSE file before any commercial use.
Is it still maintained?
Yes. The repository received new commits within the last day.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Born to filter log lines, now a general query language

liqe is a lightweight and performant Lucene-like parser, serializer and search engine, and its origin explains the design. It was originally built to enable Roarr log filtering through the Roarr command line tool. The author has since polished it as a hobby and intellectual exercise, and says he has seen it adopted by various command line and web applications that require advanced search.

The claim he makes for it is specific: to his knowledge it is currently the most complete Lucene-like syntax parser and serializer in JavaScript, as well as a compatible in-memory search engine.

The use cases are listed as three: parsing search queries, serialising parsed queries, and searching JSON documents using the Liqe query language, LQL. The third one is the one most people reach for, and it is what turns this from a parser into a component.

One decision makes the library more useful than a typical parser. The Liqe AST is treated as a public API, so somebody could implement their own search mechanism that uses the Liqe query language. In other words the parser and the evaluator are separable, and if the built in evaluation does not fit your data shape you can keep the syntax and replace the semantics.

That is the design that matters for an evaluation. liqe is not a search engine you deploy, it is a query front end you embed.

Four functions cover most of what an application needs

The public API is four functions, and the documentation's examples show how little code it takes to use them.

ts
import {
  filter,
  highlight,
  parse,
  test,
} from 'liqe';

parse turns a query string into tokens, which is the AST. filter applies a parsed query to a collection and returns the matching objects, so filter(parse('height:>170'), persons) returns the two people taller than 170 from the example array.

test answers a boolean question about a single object, which is the form you want when a record arrives and you need to know whether it matches: test(parse('name:John'), persons[0]) is true and test(parse('name:David'), persons[0]) is false.

highlight returns the matching fields and substrings, and it works in two different modes. For a text match it gives you the path and a regular expression that captures the matched text, so highlight(parse('name:john'), persons[0]) returns the path name and the pattern /(John)/. For a field comparison such as height:180 it returns just the path, with no pattern, because there is no substring to point at.

That last distinction matters for a user interface. Highlighting is how a search result shows the reader why a document matched, and the library gives you enough structure to build it rather than making you reconstruct the match yourself.

The query language covers more of Lucene than you would expect

LQL is heavily inspired by Lucene and extends it in various ways, and the cheat sheet in the documentation is the fastest way to see the scope.

A bare term searches any field and is case insensitive. A quoted term is case sensitive, and quoting also lets you target a field whose name contains a space, so 'full name':foo works. A dotted path addresses nested objects, so name.first:foo matches a document shaped as {name: {first: 'foo'}}.

There is a regular expression form, name:/foo/o with the usual flags, and a wildcard form where * stands for any sequence and ? for a single character, so name:foo*bar matches words that start with foo and end with bar. Boolean field values are supported with member:true and member:false, and member:null matches a null value.

Numbers get the full comparison set, height:=100, height:>100, height:>=100, height:<100 and height:<=100, plus range syntax in two flavours. height:[100 TO 200] is inclusive at both ends, and height:{100 TO 200} is exclusive at both ends, which is the kind of detail that usually gets missed in a hand rolled query language.

The boolean layer has AND, OR, the unary NOT and its symbol form, implicit AND between two terms, and grouping with parentheses, so name:foo AND (bio:bar OR bio:baz) parses the way you would expect.

Taken together, that is a query language a user can type without a manual, which is the point of the whole exercise.

Serialising the AST back into a query string

The serializer converts Liqe tokens back into the original search query, and it is what makes the AST practical to work with.

The documented example parses foo:bar and serialises it straight back to the string 'foo:bar'. The interesting part is what the tokens carry while they are in between. Each token in the AST has a location object with a start offset, and the field token carries a name, a path array, a quoted flag and a type of Field. The literal expression carries its value, a quoted flag and a type of LiteralExpression, and the operator token records the operator character and its own location.

Those offsets are the reason the AST can be treated as a public API. A location lets you point at the exact character in the user's query that produced a token, which is what you need to report a syntax error against the original string, and what you need to slice a match out of a field value.

The documentation also lists a Compatibility with Lucene section and a Recipes section with two named entries, handling syntax errors and highlighting matches. Those are the two things people actually build with a query parser, and it is a good sign that the project treats them as first class rather than as afterthoughts.

The AST is also a stable contract to build on, in the sense that the type names are exported: BooleanOperatorToken, ComparisonOperatorToken, EmptyExpression, FieldToken, ImplicitBooleanOperatorToken, ImplicitFieldToken and LiteralExpressionToken are all importable types, so a consumer can write a function that takes liqe tokens and does something specific with them.

The grammar is generated from nearley, so the parser is not hand written

The build story is worth knowing, because it explains how a Lucene-like syntax gets parsed at all.

The runtime dependencies are two: nearley at ^2.20.1, which is the parser generator, and ts-error at ^1.0.6 for error types. The grammar itself is a nearley grammar file, src/grammar.ne, and the compile-parser script regenerates the TypeScript parser from it:

bash
nearleyc src/grammar.ne --out ./src/grammar.ts

followed by a chain of sed edits that make the generated file satisfy the project's TypeScript settings, removing the optional location marker and the suppression comments, and then an eslint --fix pass over the result.

That pipeline explains something you would otherwise find puzzling in the source: the parser file looks machine written, because it is.

The rest of the toolchain is a normal TypeScript project. The build script removes the dist directory and runs tsc, the dev script runs tsc in watch mode, and the lint script runs eslint over src and test together with a type check. Tests run on ava with ts-node in transpile only mode, coverage comes from nyc with the Istanbul TypeScript config, and there is a benchmark script that runs test/benchmark.ts through ts-node, with benny as the benchmark library and faker to generate data. Releases are handled by semantic-release with a GitHub and an npm plugin, husky handles the git hooks, and npm-watch is there for watching.

The published package declares node 12.0 or newer as the engine requirement and the entry point is ./dist/src/Liqe.js.

Where liqe stops, and what you use instead

Two limits are structural and one is a matter of taste.

The structural limit is that nothing is indexed. liqe evaluates a query against objects that are already in memory, and the matching is a scan. For a collection of a few hundred log lines, or a page of records already loaded for rendering, that is the right cost. For a corpus of a million documents, it is not a search engine and will not become one, because there is no index, no inverted structure, no persistence and no scoring to speak of.

The second limit is that the language is inspired by Lucene, not identical to it. The documentation has a section on compatibility, which is the honest place to check, and the project is careful to say it extends Lucene in various ways. If a user of your product is going to paste in Lucene queries they already know, the differences matter.

The taste question is the alternative to a document database query language. Instead of a structured expression that the database evaluates against an index, liqe takes a text query, parses it into an AST, and evaluates it in your own process. The difference in approach is where the work happens: a database gives you an index, persistence and a server to run, and takes away the ability to change the matching semantics; liqe gives you the syntax and the AST and takes away the index.

There is a third path the project itself points at, which is to keep the parser and write your own evaluator against the public AST. That is the option to consider when the data shape is unusual, since you would not have to give up the query language to get custom matching.

Licence, releases and the state of the repository

The licence needs a careful sentence, because the two sources disagree in a way that is easy to misread.

The package manifest declares the licence as BSD-3-Clause, and the BSD 3-Clause terms are a permissive licence with the usual attribution requirement. The licence field on the repository itself is recorded as a custom licence that GitHub cannot classify, and the LICENSE file in the repository is the authoritative text. If your organisation needs certainty, read that file rather than either summary.

On maintenance, the repository is not archived and the last push was on 2026-06-11. The release history shows v3.8.7 and v3.8.6 published on the same day, 2026-06-11, and v3.8.5 on 2026-02-23, which is the pattern of a semantic-release pipeline cutting a patch release for a fix on top of a feature release.

Two things follow for a consumer. The minor version is where breaking changes live in a semantic version scheme, so an upgrade from 3.7 to 3.8 is the one to read the changelog for, and the AST shape is the part most likely to move under you, since the AST is a public API and its consumers depend on its structure.

The project is small and single maintainer, and the fact that it exists at all, a Lucene-like parser in JavaScript with a serialiser and an in-memory evaluator, is still unusual. The MIT-style terms and the MIT-licensed README badge are not in question here; the BSD-3-Clause identifier in the manifest is the one to rely on after checking the file.

Editorial conclusion

Adopt liqe if you want a Lucene-flavoured search box over documents that already live in memory, and you want the parsed query to be an object you can inspect, transform or evaluate yourself. Do not adopt it as a search engine for a large corpus, since nothing is indexed, scored or persisted, and the README does not claim Lucene compatibility beyond what the parser covers. Verify first that the query syntax you need to accept is in the documented grammar, that the AST shape your code depends on is stable in the version you pin, and that the licence declared in the package manifest is the one your organisation is comfortable with.

Frequently asked questions

What is liqe and what is it used for?

It is a lightweight and performant Lucene-like parser, serializer and search engine for JavaScript and TypeScript, used to parse search queries, serialise parsed queries, and search JSON documents using the Liqe query language, LQL.

What functions does the liqe API expose?

parse turns a query string into AST tokens, filter applies a parsed query to a collection of objects, test checks a single object against a parsed query, and highlight returns the matching fields and substrings with a regular expression when there is a text match.

Which query operators does the Liqe query language support?

Field access with a dotted path, quoted case sensitive terms, regular expressions with name:/foo/o, wildcards with * and ?, the comparisons =, >, >=, < and <=, inclusive ranges as height:[100 TO 200] and exclusive ones as height:{100 TO 200}, boolean values with member:true and member:false, null with member:null, AND, OR, NOT and implicit AND, and grouping with parentheses.

Can I use my own search logic with liqe?

Yes. The documentation states that the Liqe AST is treated as a public API, so you could implement your own search mechanism that uses the Liqe query language. The token types are exported, which is what makes that possible.

Is liqe a full search engine?

It is an in-memory search engine that evaluates queries against JSON documents that are already loaded, so there is no index and no persistence. For large corpora you would need a real index, and the project describes itself as the most complete Lucene-like syntax parser and serializer in JavaScript rather than a Lucene implementation.

Official sources

  1. gajus/liqe on GitHub
  2. Issues
  3. README
  4. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/gajus-liqe.svg)](https://hysenlabs.com/projects/gajus-liqe)

Was this useful?