From bea73d399f53cde23e95c594cddca017774166b3 Mon Sep 17 00:00:00 2001 From: mrbeandev Date: Mon, 20 Apr 2026 10:02:04 +0530 Subject: [PATCH] fix(search): tokenize ~= and ~* as single fuzzy-match operators The lexer treats `~` as a relation prefix (as in `~author.title`), so when a user wrote `note.title ~= books` or `note.title ~* books` the `~` was emitted as its own token and the `=`/`*` started a second token. The parser then failed with "Unrecognized expression books". Add a dedicated branch that keeps `~=` and `~*` glued together when the current word is the bare tilde, mirroring how `~!` and `#!` are handled one branch above. Relation prefixes such as `~author` are unaffected because they never have `=` or `*` as their first character. Regression coverage is added in lex.spec.ts for both operators, the quoted variant, and a relation-prefix case to guard against accidental tokenization of `~foo` as an operator in the future. Fixes #9426 --- apps/server/src/services/search/services/lex.spec.ts | 12 ++++++++++++ apps/server/src/services/search/services/lex.ts | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/apps/server/src/services/search/services/lex.spec.ts b/apps/server/src/services/search/services/lex.spec.ts index 668522b18d7..5b332da04ed 100644 --- a/apps/server/src/services/search/services/lex.spec.ts +++ b/apps/server/src/services/search/services/lex.spec.ts @@ -171,6 +171,18 @@ describe("Lexer expression", () => { expect(lex(`#!capital ~!neighbor`).expressionTokens.map((t) => t.token)).toEqual(["#!capital", "~!neighbor"]); }); + it("fuzzy operators ~= and ~* are tokenized as single operators", () => { + // regression: https://github.com/TriliumNext/Trilium/issues/9426 + expect(lex(`note.title ~= books`).expressionTokens.map((t) => t.token)).toEqual(["note", ".", "title", "~=", "books"]); + expect(lex(`note.title ~* books`).expressionTokens.map((t) => t.token)).toEqual(["note", ".", "title", "~*", "books"]); + expect(lex(`#author ~= tolkien`).expressionTokens.map((t) => t.token)).toEqual(["#author", "~=", "tolkien"]); + expect(lex(`#author ~*'lord of the rings'`).expressionTokens.map((t) => t.token)).toEqual(["#author", "~*", "lord of the rings"]); + }); + + it("relation prefix still works when ~ is not followed by = or *", () => { + expect(lex(`~author.title = Tolkien`).expressionTokens.map((t) => t.token)).toEqual(["~author", ".", "title", "=", "tolkien"]); + }); + it("negation of sub-expression", () => { expect(lex(`# not(#capital) and note.noteId != "root"`).expressionTokens.map((t) => t.token)).toEqual(["#", "not", "(", "#capital", ")", "and", "note", ".", "noteid", "!=", "root"]); }); diff --git a/apps/server/src/services/search/services/lex.ts b/apps/server/src/services/search/services/lex.ts index 2fcd99ca2ad..6d41146d255 100644 --- a/apps/server/src/services/search/services/lex.ts +++ b/apps/server/src/services/search/services/lex.ts @@ -106,6 +106,10 @@ function lex(str: string) { } else if (["#", "~"].includes(currentWord) && chr === "!") { currentWord += chr; continue; + } else if (currentWord === "~" && (chr === "=" || chr === "*")) { + // ~= and ~* are fuzzy-match operators, not a relation prefix followed by an operator + currentWord += chr; + continue; } else if (chr === " ") { finishWord(i - 1); continue;