Repository: DestinyItemManager/DIM
Pull request #5450 (2020-07-12 05:33:21)

Implement a real query parser for our search language
This implements a brand new, real query parser for our search syntax, instead of the fragile regex-based system we had before. I'm not using it anywhere, but it has tests which is new :-). Please look at the tests to see how this is behaving. This code produces an abstract syntax tree (AST) which can then be passed on to a separate function that builds a filter function out of it by mapping filters to our table of filter function definitions.

There are some changes and new capabilities. In general I favored compatibility with what we had, so every old search will work with the same semantics under this new system.

* Queries now support parentheses for grouping subqueries: `(is:weapon is:sniperrifle) or (is:armor modslot:arrival)` - this is something we couldn't achieve before.
* There are now `and` and `not` keywords to complement `or`. 
  * `not` is exactly equivalent to `-` (which still works), though you may now have whitespace after `-` as well (e.g `- is:maxpower`)
  * As a weird consequence of having a `not` keyword, the search `not forgotten` now means `-"forgotten"` which is the only non-backwards-compatible semantic change. We could conceivably special case queries that have no keywords and no filter functions mentioned and treat them as a complete string search (`not forgotten` => `"not forgotten"`) but that gets rid of the ability to just lazily type words and mostly get to what you want (e.g. `hammerhead surrounded`)
  * `and` has higher precedence than `or`, which in turn has higher precedence than the original "implicit" and which was just a space. Thus `is:weapon is:sniperrifle or is:armor modslot:arrival` is equivalent to `is:weapon and (is:sniperrifle or is:armor) and modslot:arrival`, while `is:weapon and is:sniperrifle or is:armor and modslot:arrival` is equivalent to `(is:weapon and is:sniperrifle) or (is:armor and modslot:arrival)`.
* `not:` queries (not to be confused with the `not` keyword) are automatically translated into `-is:` queries during parsing, so the filter function generator doesn't need to know about that anymore. (e.g. `not:haspower` gets parsed as if it were `-is:haspower`)
* Query parsing tries to be error-resilient - you should still get part of a usable query even if some of it is invalid.
* There is a `canonicalizeQuery` function which can take the parsed AST and turn it back into a string, but in a stable format. This is intended for saved/recent searches so we save a "standardized" form of the query, the better to be able to count them across different usages and users (e.g. if you type a semantically equivalent query as one of your favorites it'll count as a usage of that favorite instead of adding a new recent entry).
* Tests use Jest's snapshot testing ability, which means you simply regenerate snapshots after changing code and then look at the diffs and decide if it's still doing what you want. It's a nice way to assert the behavior of a lot of complex stuff.


Patch:
diff --git a/.github/workflows/i18n-bot-download.yml b/.github/workflows/i18n-bot-download.yml
index 1c3156ed0..ccd2cc19b 100644
--- a/.github/workflows/i18n-bot-download.yml
+++ b/.github/workflows/i18n-bot-download.yml
@@ -1,9 +1,9 @@
 # This workflow runs every Saturday @ 1900 UTC (NOON PST)
 name: i18n download bot
 on:
-  workflow_dispatch:
   schedule:
     - cron: "0 19 * * 6"
+    - cron: "40 2 * * 4"
 jobs:
   build:
     runs-on: ubuntu-latest
diff --git a/.github/workflows/i18n-bot-upload-all.yml b/.github/workflows/i18n-bot-upload-all.yml
deleted file mode 100644
index 54d0ad249..000000000
--- a/.github/workflows/i18n-bot-upload-all.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-# Run this workflow whenever dim.json has keys updated and you have updated the keys in the other i18n files as well
-# Does not notify #i18n in discord
-name: i18n upload all (manual only)
-on:
-  workflow_dispatch:
-
-jobs:
-  build:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout DIM
-        uses: actions/checkout@v2
-
-      - name: Sort dim.json
-        run: dimJSON="$(jq -S . src/locale/dim.json)" && echo "${dimJSON}" > src/locale/dim.json
-
-      - name: sort locale dim.json
-        run: |
-          allLocales=("de" "es" "es-mx" "fr" "it" "ja" "ko" "pl" "pt-br" "ru" "zh-chs" "zh-cht")
-          for lang in ${allLocales[@]}; do
-            jq -S . src/locale/$lang/dim.json > src/locale/$lang/dim_sorted.json && mv src/locale/$lang/dim_sorted.json src/locale/$lang/dim.json
-          done
-
-      - name: Upload updated dim.json to crowdin
-        uses: crowdin/github-action@1.0.4
-        with:
-          upload_sources: true
-          upload_translations: true
-          source: src/locale/dim.json
-          translation: src/locale/%android_code%/dim.json
-          project_id: ${{ secrets.CROWDIN_PROJECT_ID }}
-          token: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index c5f9a14de..bf569e714 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,8 +1,6 @@
 ## Next
 
-* Fix sorting by Power Limit in the compare pane.
 * When opening a loadout in the loadout optimizer from the inventory page, the correct character is now selected rather than the last played character.
-* Allow masterworks to affect more than one stat
 
 ## 6.19.0 <span className="changelog-date">(2020-07-05)</span>
 
diff --git a/jest.config.js b/jest.config.js
index 734c19f84..3c738d7f8 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -9,19 +9,20 @@ module.exports = {
     ...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/' }),
     '^.+\\.scss$': 'identity-obj-proxy',
     '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
-      '<rootDir>/src/__mocks__/fileMock.js'
+      '<rootDir>/src/__mocks__/fileMock.js',
   },
   globals: {
     $BROWSERS: [],
+    $DIM_FLAVOR: 'test',
     $featureFlags: {
-      dimApi: true
+      dimApi: true,
     },
     'ts-jest': {
       tsConfig: {
         target: 'ES2015',
         jsx: 'react',
-        allowJs: true
-      }
-    }
-  }
+        allowJs: true,
+      },
+    },
+  },
 };
diff --git a/src/app/compare/Compare.tsx b/src/app/compare/Compare.tsx
index fea1d9e13..2c321cf35 100644
--- a/src/app/compare/Compare.tsx
+++ b/src/app/compare/Compare.tsx
@@ -122,49 +122,43 @@ class Compare extends React.Component<Props, State> {
       return null;
     }
 
-    const comparisonItems = !sortedHash
-      ? unsortedComparisonItems
-      : Array.from(unsortedComparisonItems).sort(
-          reverseComparator(
-            chainComparator(
-              compareBy((item: DimItem) => {
-                const dtrRating = $featureFlags.reviewsEnabled && getRating(item, ratings);
-                const showRating =
-                  $featureFlags.reviewsEnabled &&
-                  dtrRating &&
-                  shouldShowRating(dtrRating) &&
-                  dtrRating.overallScore;
-
-                const stat =
-                  item.primStat && sortedHash === item.primStat.statHash
-                    ? item.primStat
-                    : sortedHash === 'Rating'
-                    ? { value: showRating || 0 }
-                    : sortedHash === 'EnergyCapacity'
-                    ? {
-                        value: (item.isDestiny2() && item.energy?.energyCapacity) || 0,
-                      }
-                    : sortedHash === 'PowerCap'
-                    ? {
-                        value: (item.isDestiny2() && item.powerCap) || 99999999,
-                      }
-                    : (item.stats || []).find((s) => s.statHash === sortedHash);
-
-                if (!stat) {
-                  return -1;
-                }
-
-                const shouldReverse =
-                  isDimStat(stat) && stat.smallerIsBetter
-                    ? this.state.sortBetterFirst
-                    : !this.state.sortBetterFirst;
-                return shouldReverse ? -stat.value : stat.value;
-              }),
-              compareBy((i) => i.index),
-              compareBy((i) => i.name)
-            )
-          )
-        );
+    const comparisonItems = Array.from(unsortedComparisonItems).sort(
+      reverseComparator(
+        chainComparator(
+          compareBy((item: DimItem) => {
+            const dtrRating = $featureFlags.reviewsEnabled && getRating(item, ratings);
+            const showRating =
+              $featureFlags.reviewsEnabled &&
+              dtrRating &&
+              shouldShowRating(dtrRating) &&
+              dtrRating.overallScore;
+
+            const stat =
+              item.primStat && sortedHash === item.primStat.statHash
+                ? item.primStat
+                : sortedHash === 'Rating'
+                ? { value: showRating || 0 }
+                : sortedHash === 'EnergyCapacity'
+                ? {
+                    value: (item.isDestiny2() && item.energy?.energyCapacity) || 0,
+                  }
+                : (item.stats || []).find((s) => s.statHash === sortedHash);
+
+            if (!stat) {
+              return -1;
+            }
+
+            const shouldReverse =
+              isDimStat(stat) && stat.smallerIsBetter
+                ? this.state.sortBetterFirst
+                : !this.state.sortBetterFirst;
+            return shouldReverse ? -stat.value : stat.value;
+          }),
+          compareBy((i) => i.index),
+          compareBy((i) => i.name)
+        )
+      )
+    );
 
     const stats = this.getAllStatsSelector(this.state, this.props);
 
diff --git a/src/app/inventory/item-types.ts b/src/app/inventory/item-types.ts
index 06a978542..602541f70 100644
--- a/src/app/inventory/item-types.ts
+++ b/src/app/inventory/item-types.ts
@@ -260,16 +260,14 @@ export interface DimMasterwork {
   typeIcon: string;
   /** The localized description associated with the type. */
   typeDesc: string | null;
+  /** The stat that is enhanced by this masterwork. */
+  statHash?: number;
+  /** The name of the stat enhanced by this masterwork. */
+  statName?: string;
   /** The tier of the masterwork (not the same as the stat!). */
   tier?: number;
-  /** The stats that are enhanced by this masterwork. */
-  stats?: {
-    hash?: number;
-    /** The name of the stat enhanced by this masterwork. */
-    name?: string;
-    /** How much the stat is enhanced by this masterwork. */
-    value?: number;
-  }[];
+  /** How much the stat is enhanced by this masterwork. */
+  statValue?: number;
 }
 
 export interface DimStat {
diff --git a/src/app/inventory/spreadsheets.ts b/src/app/inventory/spreadsheets.ts
index 00dc4f03f..e21206354 100644
--- a/src/app/inventory/spreadsheets.ts
+++ b/src/app/inventory/spreadsheets.ts
@@ -16,7 +16,7 @@ import { getActivePlatform } from '../accounts/platforms';
 import { getClass } from './store/character-utils';
 import { download } from 'app/utils/util';
 import { getRating } from '../item-review/reducer';
-import { getSpecialtySocketMetadata, getMasterworkStatNames } from 'app/utils/item-utils';
+import { getSpecialtySocketMetadata } from 'app/utils/item-utils';
 import store from '../store/store';
 import { t } from 'app/i18next-t';
 
@@ -315,7 +315,7 @@ function downloadArmor(items: DimItem[], nameMap: { [key: string]: string }, ite
       row['Power Limit'] = item.powerCap;
     }
     if (item.isDestiny2()) {
-      row['Masterwork Type'] = getMasterworkStatNames(item.masterworkInfo) || undefined;
+      row['Masterwork Type'] = item.masterworkInfo?.statName;
       row['Masterwork Tier'] = item.masterworkInfo?.tier
         ? Math.min(10, item.masterworkInfo.tier)
         : undefined;
@@ -438,7 +438,7 @@ function downloadWeapons(
       row['Power Limit'] = item.powerCap;
     }
     if (item.isDestiny2()) {
-      row['Masterwork Type'] = getMasterworkStatNames(item.masterworkInfo) || undefined;
+      row['Masterwork Type'] = item.masterworkInfo?.statName;
       row['Masterwork Tier'] = item.masterworkInfo?.tier
         ? Math.min(10, item.masterworkInfo.tier)
         : undefined;
diff --git a/src/app/inventory/store/masterwork.ts b/src/app/inventory/store/masterwork.ts
index 1b6e37117..89cd23e47 100644
--- a/src/app/inventory/store/masterwork.ts
+++ b/src/app/inventory/store/masterwork.ts
@@ -104,18 +104,18 @@ function buildForsakenMasterworkStats(
         ) ?? null;
     }
 
+    const statDef = defs.Stat.get(masterwork.statTypeHash);
+
     return {
       typeName: null,
       typeIcon: masterworkSocket.plug.plugItem.displayProperties.icon,
       typeDesc: masterworkSocket.plug.plugItem.displayProperties.description,
+      statHash: masterwork.statTypeHash,
+      statName: statDef.displayProperties.name,
+      statValue: masterworkSocket.plug.stats
+        ? masterworkSocket.plug.stats[masterwork.statTypeHash]
+        : 0,
       tier: masterwork.value,
-      stats: [
-        {
-          hash: masterwork.statTypeHash,
-          name: defs.Stat.get(masterwork.statTypeHash).displayProperties.name,
-          value: masterworkSocket.plug.stats?.[masterwork.statTypeHash] || 0,
-        },
-      ],
     };
   }
   return null;
@@ -129,29 +129,36 @@ function buildMasterworkInfo(
   defs: D2ManifestDefinitions
 ): DimMasterwork | null {
   const socket = sockets.sockets.find((socket) => Boolean(socket.plug?.plugObjectives.length));
-  if (!socket?.plug?.plugObjectives?.length) {
+  if (
+    !socket ||
+    !socket.plug ||
+    !socket.plug.plugObjectives ||
+    !socket.plug.plugObjectives.length
+  ) {
     return null;
   }
   const plugObjective = socket.plug.plugObjectives[0];
   const investmentStats = socket.plug.plugItem.investmentStats;
+  if (!investmentStats || !investmentStats.length) {
+    return null;
+  }
+  const statHash = investmentStats[0].statTypeHash;
+
   const objectiveDef = defs.Objective.get(plugObjective.objectiveHash);
+  const statDef = defs.Stat.get(statHash);
 
-  if (!investmentStats?.length || !objectiveDef) {
+  if (!objectiveDef || !statDef) {
     return null;
   }
 
-  const stats = investmentStats.map((stat) => ({
-    hash: stat.statTypeHash,
-    name: defs.Stat.get(stat.statTypeHash).displayProperties.name,
-    value: socket.plug?.stats?.[stat.statTypeHash] || 0,
-  }));
-
   return {
     progress: plugObjective.progress,
     typeName: socket.plug.plugItem.plug.plugCategoryHash === 2109207426 ? 'Vanguard' : 'Crucible',
     typeIcon: objectiveDef.displayProperties.icon,
     typeDesc: objectiveDef.progressDescription,
-    tier: socket.plug?.plugItem.investmentStats[0].value,
-    stats,
+    statHash,
+    statName: statDef.displayProperties.name,
+    statValue: socket.plug.stats ? socket.plug.stats[statHash] : 0,
+    tier: investmentStats[0].value,
   };
 }
diff --git a/src/app/item-popup/ItemSockets.scss b/src/app/item-popup/ItemSockets.scss
index a5ff22fea..4459eb706 100644
--- a/src/app/item-popup/ItemSockets.scss
+++ b/src/app/item-popup/ItemSockets.scss
@@ -67,6 +67,10 @@
   }
 }
 
+.synergyStat {
+  color: $orange;
+}
+
 .plug-objectives {
   margin: 4px 0;
   padding: 8px;
diff --git a/src/app/item-popup/ItemStat.tsx b/src/app/item-popup/ItemStat.tsx
index d3fe53f0a..96f2cc2c2 100644
--- a/src/app/item-popup/ItemStat.tsx
+++ b/src/app/item-popup/ItemStat.tsx
@@ -33,16 +33,9 @@ export default function ItemStat({ stat, item }: { stat: DimStat; item?: DimItem
     getSocketsWithStyle(item.sockets, DestinySocketCategoryStyle.EnergyMeter);
   const armor2MasterworkValue =
     armor2MasterworkSockets && getSumOfArmorStats(armor2MasterworkSockets, [stat.statHash]);
-
-  const masterworkIndex =
-    (item?.isDestiny2() &&
-      item.masterworkInfo?.stats?.findIndex((s) => s.hash === stat.statHash)) ||
-    0;
-
   const isMasterworkedStat =
-    item?.isDestiny2() && item.masterworkInfo?.stats?.[masterworkIndex]?.hash === stat.statHash;
-  const masterworkValue =
-    (item?.isDestiny2() && item.masterworkInfo?.stats?.[masterworkIndex]?.value) || 0;
+    item?.isDestiny2() && item.masterworkInfo && stat.statHash === item.masterworkInfo.statHash;
+  const masterworkValue = (item?.isDestiny2() && item.masterworkInfo?.statValue) || 0;
   const masterworkDisplayValue = (isMasterworkedStat && masterworkValue) || armor2MasterworkValue;
 
   const moddedStatValue = item && getModdedStatValue(item, stat);
@@ -174,16 +167,12 @@ export function ItemStatValue({ stat, item }: { stat: DimStat; item?: DimItem })
     getSocketsWithStyle(item.sockets, DestinySocketCategoryStyle.EnergyMeter);
   const armor2MasterworkValue =
     armor2MasterworkSockets && getSumOfArmorStats(armor2MasterworkSockets, [stat.statHash]);
-
-  const masterworkIndex =
-    (item?.isDestiny2() &&
-      item.masterworkInfo?.stats?.findIndex((s) => s.hash === stat.statHash)) ||
-    0;
-
   const isMasterworkedStat =
-    item?.isDestiny2() && item.masterworkInfo?.stats?.[masterworkIndex]?.hash === stat.statHash;
-  const masterworkValue =
-    (item?.isDestiny2() && item.masterworkInfo?.stats?.[masterworkIndex]?.value) || 0;
+    item?.isDestiny2() &&
+    (item?.bucket.inArmor
+      ? item.masterwork && item.energy
+      : item.masterworkInfo && stat.statHash === item.masterworkInfo.statHash);
+  const masterworkValue = (item?.isDestiny2() && item.masterworkInfo?.statValue) || 0;
   const masterworkDisplayValue = (isMasterworkedStat && masterworkValue) || armor2MasterworkValue;
 
   const moddedStatValue = item && getModdedStatValue(item, stat);
diff --git a/src/app/item-popup/PlugTooltip.tsx b/src/app/item-popup/PlugTooltip.tsx
index 697bf4b1d..b3a668a28 100644
--- a/src/app/item-popup/PlugTooltip.tsx
+++ b/src/app/item-popup/PlugTooltip.tsx
@@ -12,6 +12,7 @@ import { statAllowList } from 'app/inventory/store/stats';
 
 // TODO: Connect this to redux
 export default function PlugTooltip({
+  item,
   plug,
   defs,
   wishListsEnabled,
@@ -32,9 +33,23 @@ export default function PlugTooltip({
     plug.plugItem.collectibleHash &&
     defs.Collectible.get(plug.plugItem.collectibleHash).sourceString;
 
+  // display perk's synergy with masterwork stat
+  const synergyStat = item.masterworkInfo?.statHash &&
+    plug.plugItem.investmentStats &&
+    plug.plugItem.investmentStats.some(
+      (stat) =>
+        stat.value > 0 &&
+        stat.statTypeHash &&
+        item.masterworkInfo &&
+        item.masterworkInfo.statHash === stat.statTypeHash
+    ) && <span className="synergyStat"> ({item.masterworkInfo.statName})</span>;
+
   return (
     <>
-      <h2>{plug.plugItem.displayProperties.name}</h2>
+      <h2>
+        {plug.plugItem.displayProperties.name}
+        {synergyStat}
+      </h2>
 
       {plug.plugItem.displayProperties.description ? (
         <div>{plug.plugItem.displayProperties.description}</div>
diff --git a/src/app/organizer/Columns.tsx b/src/app/organizer/Columns.tsx
index 357956527..1e2fd38a6 100644
--- a/src/app/organizer/Columns.tsx
+++ b/src/app/organizer/Columns.tsx
@@ -14,7 +14,6 @@ import {
   getItemSpecialtyModSlotDisplayName,
   getSpecialtySocketMetadata,
   getItemPowerCapFinalSeason,
-  getMasterworkStatNames,
 } from 'app/utils/item-utils';
 
 import BungieImage from 'app/dim-ui/BungieImage';
@@ -495,8 +494,7 @@ export function getColumns(
       isWeapon && {
         id: 'masterworkStat',
         header: t('Organizer.Columns.MasterworkStat'),
-        value: (item) =>
-          item.isDestiny2() ? getMasterworkStatNames(item.masterworkInfo) : undefined,
+        value: (item) => (item.isDestiny2() ? item.masterworkInfo?.statName : undefined),
       },
     destinyVersion === 2 &&
       isWeapon && {
diff --git a/src/app/search/query-parser.ts b/src/app/search/query-parser.ts
new file mode 100644
index 000000000..b30ff4c3f
--- /dev/null
+++ b/src/app/search/query-parser.ts
@@ -0,0 +1,418 @@
+/*
+; Lazy BNF diagram of our search grammar
+<query> ::= <term> | <term> <term>
+<clause> ::= <opt-whitespace> <clause>
+<terms> ::= <term> { " " <term>}
+<term> ::= <string> | <filter> | <group> | <boolean>
+<filter> ::= ["-"]<filterName>:<filterValue>[<operator><number>]
+<filterName> ::= <the set of known filter names - is, notes, perks, tag, stat, etc.>
+<filterValue> ::= <keyword> | "stat:" <statName> | <string>
+<keyword> ::= <the set of known keyword filters - locked, sniperrifle, tagged, etc.>
+<statName> ::= <the set of known stat keywords - charge, impact, resilience, etc.>
+<operator> ::= "none" | "=" | "<" | "<=" | ">" | ">="
+; Numbers are positive only, but don't need to be
+<number> ::= DIGIT{DIGIT}
+<group> ::= "(" <query> ")"
+<boolean> ::= "or" | "not" | "and"
+; Strings are basically anything within matching quotes, either single or double
+<string> ::= WORD | "\"" WORD {" " WORD} "\"" | "'" WORD {" " WORD} "'\"'"
+*/
+
+import _ from 'lodash';
+
+/* **** Parser **** */
+
+/**
+ * A tree of the parsed query. Boolean/unary operators have children (operands)that
+ * describe their relationship.
+ */
+type QueryAST = (AndOp | OrOp | NotOp | FilterOp | NoOp) & {
+  error?: Error;
+};
+
+/** If ALL of of the operands are true, this resolves to true. There may be any number of operands. */
+interface AndOp {
+  op: 'and';
+  operands: QueryAST[];
+}
+
+/** If any of the operands is true, this resolves to true. There may be any number of operands. */
+interface OrOp {
+  op: 'or';
+  operands: QueryAST[];
+}
+
+/** An operator which negates the result of its only operand. */
+interface NotOp {
+  op: 'not';
+  operand: QueryAST;
+}
+
+/** This represents one of our filter function definitions, such as is:, season:, etc. */
+interface FilterOp {
+  op: 'filter';
+  /**
+   * The name of the filter function, without any trailing :. The only weird case is
+   * stats, which will appear like "stat:strength".
+   */
+  type: string;
+  /**
+   * Any arguments to the filter function as a single string. e.g: haspower, arrivals, >=1000
+   */
+  args: string;
+}
+
+/** This is mostly for error cases and empty string */
+interface NoOp {
+  op: 'noop';
+}
+
+/**
+ * The lexer is implemented as a generator, but generators don't support peeking without advancing
+ * the iterator. This wraps the generator in an object that buffers the next element if you call peek().
+ */
+class PeekableGenerator<T> {
+  private gen: Generator<T>;
+  private next: T | undefined;
+
+  constructor(gen: Generator<T>) {
+    this.gen = gen;
+  }
+
+  /**
+   * Get what the next item from the generator will be, without advancing it.
+   */
+  peek(): T | undefined {
+    if (!this.next) {
+      this.next = this.gen.next().value;
+    }
+    return this.next;
+  }
+
+  /**
+   * Get the next element from the generator and advance it to the next element.
+   */
+  pop(): T | undefined {
+    if (this.next) {
+      const ret = this.next;
+      this.next = undefined;
+      return ret;
+    }
+    return this.gen.next().value;
+  }
+}
+
+/**
+ * A table of operator precedence for our three binary operators. Operators with higher precedence group together
+ * before those with lower precedence. The "op" property maps them to an AST node.
+ */
+const operators = {
+  // The implicit `and` (two statements separated by whitespace) has lower precedence than either the explicit or or and.
+  implicit_and: {
+    precedence: 1,
+    op: 'and',
+  },
+  or: {
+    precedence: 2,
+    op: 'or',
+  },
+  and: {
+    precedence: 3,
+    op: 'and',
+  },
+} as const;
+
+/**
+ * The query parser first lexes the string, then parses it into an AST (abstract syntax tree)
+ * representing the logical structure of the query. This AST can then be walked to match up
+ * to defined filters and generate an actual filter function.
+ *
+ * We choose to produce an AST instead of executing the search inline with parsing both to
+ * make testing easier, and to allow for things like canonicalization of search queries.
+ */
+export function parseQuery(query: string): QueryAST {
+  // This implements operator precedence via this mechanism:
+  // https://eli.thegreenplace.net/2012/08/02/parsing-expressions-by-precedence-climbing
+
+  /**
+   * This extracts the next "atom" aka "value" from the token stream. An atom is either
+   * an individual filter expression, or a grouped expression. Basically anything that's
+   * not a binary operator. "not" is also in here because it's really just a modifier on an atom.
+   */
+  function parseAtom(tokens: PeekableGenerator<Token>): QueryAST {
+    const token: Token | undefined = tokens.peek();
+
+    if (!token) {
+      throw new Error('expected an atom');
+    }
+
+    switch (token[0]) {
+      case 'filter': {
+        tokens.pop();
+        const keyword = token[1];
+        if (keyword === 'not') {
+          // `not:` a synonym for `-is:`. We could fix this up in filter execution but I chose to normalize it here.
+          return {
+            op: 'not',
+            operand: {
+              op: 'filter',
+              type: 'is',
+              args: token[2],
+            },
+          };
+        } else {
+          return {
+            op: 'filter',
+            type: keyword,
+            args: token[2],
+          };
+        }
+      }
+      case 'not': {
+        tokens.pop();
+        return {
+          op: 'not',
+          // The operand should always be an atom
+          operand: parseAtom(tokens),
+        };
+      }
+      case '(': {
+        tokens.pop();
+        const result = parse(new PeekableGenerator(untilCloseParen(tokens)));
+        if (tokens.peek()?.[0] === ')') {
+          tokens.pop();
+        }
+        return result;
+      }
+      default:
+        throw new Error('Unexpected token type, looking for an atom: ' + token + ', ' + query);
+    }
+  }
+
+  /**
+   * Parse a stream of tokens into an AST. `minPrecedence` determined the minimum operator precedence
+   * of operators that will be included in this portion of the parse.
+   */
+  function parse(tokens: PeekableGenerator<Token>, minPrecedence = 1): QueryAST {
+    let ast: QueryAST = { op: 'noop' };
+
+    try {
+      ast = parseAtom(tokens);
+
+      let token: Token | undefined;
+      while ((token = tokens.peek())) {
+        const operator = operators[token[0] as keyof typeof operators];
+        if (!operator) {
+          throw new Error('Expected an operator, got ' + token);
+        } else if (operator.precedence < minPrecedence) {
+          break;
+        }
+
+        tokens.pop();
+        const nextMinPrecedence = operator.precedence + 1; // all our operators are left-associative
+        const rhs = parse(tokens, nextMinPrecedence);
+
+        // Our operators allow for more than 2 operands, to avoid deep logic trees.
+        // This logic tries to combine them where possible.
+        if (isSameOp(operator.op, ast)) {
+          ast.operands.push(rhs);
+        } else {
+          ast = {
+            op: operator.op,
+            operands: isSameOp(operator.op, rhs) ? [ast, ...rhs.operands] : [ast, rhs],
+          };
+        }
+      }
+    } catch (e) {
+      ast.error = e;
+    }
+
+    return ast;
+  }
+
+  const tokens = new PeekableGenerator(lexer(query));
+  if (!tokens.peek()) {
+    return { op: 'noop' };
+  }
+  const ast = parse(tokens);
+  return ast;
+}
+
+function isSameOp<T extends 'and' | 'or'>(binOp: T, op: QueryAST): op is AndOp | OrOp {
+  return binOp === op.op;
+}
+
+/**
+ * Yield tokens from the passed in generator until we reach a closing parenthesis token.
+ */
+function* untilCloseParen(tokens: PeekableGenerator<Token>): Generator<Token> {
+  let token: Token | undefined;
+  while ((token = tokens.pop())) {
+    if (token[0] === ')') {
+      return;
+    }
+    yield token;
+  }
+}
+
+/* **** Lexer **** */
+
+// Lexer token types
+type NoArgTokenType = '(' | ')' | 'not' | 'or' | 'and' | 'implicit_and';
+export type Token = [NoArgTokenType] | ['filter', string, string];
+
+// Two different kind of quotes
+const quoteRegexes = {
+  '"': /.*?"/y,
+  "'": /.*?'/y,
+};
+// Parens: `(` can be followed by whitespace, while `)` can be preceded by it
+const parens = /(\(\s*|\s*\))/y;
+// A `-` followed by any amount of whitespace is the same as "not"
+const negation = /-\s*/y;
+// `not`, `or`, and `and` keywords. or and not can be preceded by whitespace, and any of them can be followed by whitespace.
+// `not` can't be preceded by whitespace because that whitespace is an implicit `and`.
+const booleanKeywords = /(not|\s+or|\s+and)\s+/y;
+// Filter names like is:, stat:discipline:, etc
+const filterName = /[a-z]+:([a-z]+:)?/y;
+// Arguments to filters are pretty unconstrained
+const filterArgs = /[^\s()]+/y;
+// Words without quotes are basically any non-whitespace that doesn't terminate a group
+const bareWords = /[^\s)]+/y;
+// Whitespace that doesn't match anything else is an implicit `and`
+const whitespace = /\s+/y;
+
+/**
+ * The lexer yields a series of tokens representing the linear structure of the search query.
+ * This throws an exception if it finds an invalid input.
+ *
+ * Example: "is:blue -is:maxpower" turns into:
+ * ["filter", "is", "blue"], ["implicit_and"], ["not"], ["filter", "is", "maxpower"]
+ */
+export function* lexer(query: string): Generator<Token> {
+  query = query.trim().toLowerCase();
+
+  // http://blog.tatedavies.com/2012/08/28/replace-microsoft-chars-in-javascript/
+  query = query.replace(/[\u2018|\u2019|\u201A]/g, "'");
+  query = query.replace(/[\u201C|\u201D|\u201E]/g, '"');
+
+  let match: string | undefined;
+  let i = 0;
+
+  const consume = (str: string) => (i += str.length);
+
+  /**
+   * If `query` matches `re` starting at `i`, return the matched portion of the string. Otherwise return undefined.
+   * This avoids having to make slices of strings just to start the regex in the middle of a string.
+   *
+   * Note that regexes passed to this must have the "sticky" flag set (y) and should not use ^, which will match the
+   * beginning of the string, ignoring the index we want to start from. The sticky flag ensures our regex will match
+   * based on the beginning of the string.
+   */
+  const extract = (re: RegExp): string | undefined => {
+    // These checks only run in unit tests
+    if ($DIM_FLAVOR === 'test') {
+      if (!re.sticky) {
+        throw new Error('regexp must be sticky');
+      }
+      if (re.source.startsWith('^')) {
+        throw new Error('regexp cannot start with ^ and be repositioned');
+      }
+    }
+
+    re.lastIndex = i;
+    const match = re.exec(query);
+    if (match) {
+      const result = match[0];
+      if (result.length > 0) {
+        consume(result);
+        return result;
+      }
+    }
+    return undefined;
+  };
+
+  /**
+   * Consume and return the contents of a quoted string.
+   */
+  const consumeString = (startingQuoteChar: string) => {
+    // Quoted string
+    consume(startingQuoteChar);
+    if ((match = extract(quoteRegexes[startingQuoteChar])) !== undefined) {
+      // Slice off the last character
+      return match.slice(0, match.length - 1);
+    } else {
+      throw new Error('Unterminated quotes: |' + query.slice(i) + '| ' + i);
+    }
+  };
+
+  while (i < query.length) {
+    const char = query[i];
+    const startingIndex = i;
+
+    if ((match = extract(parens)) !== undefined) {
+      // Start/end group
+      yield [match.trim() as NoArgTokenType];
+    } else if (char === '"' || char === "'") {
+      // Quoted string
+      yield ['filter', 'keyword', consumeString(char)];
+    } else if ((match = extract(negation)) !== undefined) {
+      // minus sign is the same as "not"
+      yield ['not'];
+    } else if ((match = extract(booleanKeywords)) !== undefined) {
+      // boolean keywords
+      yield [match.trim() as NoArgTokenType];
+    } else if ((match = extract(filterName)) !== undefined) {
+      // Keyword searches - is:, stat:discipline:, etc
+      const keyword = match.slice(0, match.length - 1);
+      const nextChar = query[i];
+
+      let args = '';
+
+      if (nextChar === '"' || nextChar === "'") {
+        args = consumeString(nextChar);
+      } else if ((match = extract(filterArgs)) !== undefined) {
+        args = match;
+      } else {
+        throw new Error('missing keyword arguments for ' + match);
+      }
+
+      yield ['filter', keyword, args];
+    } else if ((match = extract(bareWords)) !== undefined) {
+      // bare words that aren't keywords are effectively "keyword" type filters
+      yield ['filter', 'keyword', match];
+    } else if ((match = extract(whitespace)) !== undefined) {
+      yield ['implicit_and'];
+    } else {
+      throw new Error('unrecognized tokens: |' + query.slice(i) + '| ' + i);
+    }
+
+    if (startingIndex === i) {
+      throw new Error('bug: forgot to consume characters');
+    }
+  }
+}
+
+/**
+ * Build a standardized version of the query as a string. This is useful for deduping queries.
+ * Example: 'is:weapon and is:sniperrifle or not is:armor and modslot:arrival' =>
+ *          '(-is:armor modslot:arrival) or (is:sniperrifle is:weapon)'
+ */
+export function canonicalizeQuery(query: QueryAST, depth = 0) {
+  switch (query.op) {
+    case 'filter':
+      return `${query.type}:${/\s/.test(query.args) ? `"${query.args}"` : query.args}`;
+    case 'not':
+      return `-${canonicalizeQuery(query.operand, depth + 1)}`;
+    case 'and':
+    case 'or': {
+      const sortedOperands = _.sortBy(
+        query.operands.map((q) => canonicalizeQuery(q, depth + 1)),
+        (q) => q.replace(/[(-](.*)/, '$1')
+      ).join(query.op === 'and' ? ' ' : ` ${query.op} `);
+      return depth === 0 ? sortedOperands : `(${sortedOperands})`;
+    }
+    case 'noop':
+      return '';
+  }
+}
diff --git a/src/app/search/search-filter.ts b/src/app/search/search-filter.ts
index 173f884f7..7a62f71d1 100644
--- a/src/app/search/search-filter.ts
+++ b/src/app/search/search-filter.ts
@@ -1104,8 +1104,9 @@ function searchFilters(
             filterValue
           );
         }
-        return item.masterworkInfo?.stats?.some(
-          (s) => s.hash === hashes.statHashByName?.[filterValue]
+        return (
+          hashes.statHashByName[filterValue] && // make sure it exists or undefined can match undefined
+          hashes.statHashByName[filterValue] === item.masterworkInfo.statHash
         );
       },
       season(item: D2Item, filterValue: string) {
diff --git a/src/app/utils/item-utils.ts b/src/app/utils/item-utils.ts
index ac987b2c8..996b63f34 100644
--- a/src/app/utils/item-utils.ts
+++ b/src/app/utils/item-utils.ts
@@ -3,7 +3,7 @@ import {
   DestinyEnergyType,
   DestinyInventoryItemDefinition,
 } from 'bungie-api-ts/destiny2';
-import { DimItem, DimSocket, DimMasterwork } from 'app/inventory/item-types';
+import { DimItem, DimSocket } from 'app/inventory/item-types';
 
 import modMetadataBySlotTag from 'data/d2/specialty-modslot-metadata.json';
 import powerCapToSeason from 'data/d2/lightcap-to-season.json';
@@ -101,12 +101,3 @@ export const isArmor2Mod = (item: DestinyInventoryItemDefinition): boolean =>
 /** given item, get the final season it will be relevant (able to hit max power level) */
 export const getItemPowerCapFinalSeason = (item: DimItem): number | undefined =>
   item.isDestiny2() ? powerCapToSeason[item.powerCap ?? -99999999] : undefined;
-
-export function getMasterworkStatNames(mw: DimMasterwork | null) {
-  let mwStatName = '';
-  const loops = mw?.stats?.length || 0;
-  for (let i = 0; i < loops; i++) {
-    mwStatName += `${mw?.stats?.[i]?.name ?? ''}${mw?.stats?.[i + 1]?.name ? ', ' : ''}`;
-  }
-  return mwStatName;
-}


Test patch:
diff --git a/src/app/search/__snapshots__/query-parser.test.ts.snap b/src/app/search/__snapshots__/query-parser.test.ts.snap
new file mode 100644
index 000000000..d727a0afc
--- /dev/null
+++ b/src/app/search/__snapshots__/query-parser.test.ts.snap
@@ -0,0 +1,1692 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`parse |"grenade launcher reserves"|: ast 1`] = `
+Object {
+  "args": "grenade launcher reserves",
+  "op": "filter",
+  "type": "keyword",
+}
+`;
+
+exports[`parse |"grenade launcher reserves"|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "keyword",
+    "grenade launcher reserves",
+  ],
+]
+`;
+
+exports[`parse |"수집가"|: ast 1`] = `
+Object {
+  "args": "수집가",
+  "op": "filter",
+  "type": "keyword",
+}
+`;
+
+exports[`parse |"수집가"|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "keyword",
+    "수집가",
+  ],
+]
+`;
+
+exports[`parse |( power:>1000 and -modslot:arrival ) |: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "args": ">1000",
+      "op": "filter",
+      "type": "power",
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "arrival",
+        "op": "filter",
+        "type": "modslot",
+      },
+    },
+  ],
+}
+`;
+
+exports[`parse |( power:>1000 and -modslot:arrival ) |: lexer 1`] = `
+Array [
+  Array [
+    "(",
+  ],
+  Array [
+    "filter",
+    "power",
+    ">1000",
+  ],
+  Array [
+    "and",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "modslot",
+    "arrival",
+  ],
+  Array [
+    ")",
+  ],
+]
+`;
+
+exports[`parse |(is:hunter power:>=540) or (is:warlock power:>=560)|: ast 1`] = `
+Object {
+  "op": "or",
+  "operands": Array [
+    Object {
+      "op": "and",
+      "operands": Array [
+        Object {
+          "args": "hunter",
+          "op": "filter",
+          "type": "is",
+        },
+        Object {
+          "args": ">=540",
+          "op": "filter",
+          "type": "power",
+        },
+      ],
+    },
+    Object {
+      "op": "and",
+      "operands": Array [
+        Object {
+          "args": "warlock",
+          "op": "filter",
+          "type": "is",
+        },
+        Object {
+          "args": ">=560",
+          "op": "filter",
+          "type": "power",
+        },
+      ],
+    },
+  ],
+}
+`;
+
+exports[`parse |(is:hunter power:>=540) or (is:warlock power:>=560)|: lexer 1`] = `
+Array [
+  Array [
+    "(",
+  ],
+  Array [
+    "filter",
+    "is",
+    "hunter",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "power",
+    ">=540",
+  ],
+  Array [
+    ")",
+  ],
+  Array [
+    "or",
+  ],
+  Array [
+    "(",
+  ],
+  Array [
+    "filter",
+    "is",
+    "warlock",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "power",
+    ">=560",
+  ],
+  Array [
+    ")",
+  ],
+]
+`;
+
+exports[`parse |(is:weapon and is:sniperrifle) or not (is:armor and modslot:arrival)|: ast 1`] = `
+Object {
+  "op": "or",
+  "operands": Array [
+    Object {
+      "op": "and",
+      "operands": Array [
+        Object {
+          "args": "weapon",
+          "op": "filter",
+          "type": "is",
+        },
+        Object {
+          "args": "sniperrifle",
+          "op": "filter",
+          "type": "is",
+        },
+      ],
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "op": "and",
+        "operands": Array [
+          Object {
+            "args": "armor",
+            "op": "filter",
+            "type": "is",
+          },
+          Object {
+            "args": "arrival",
+            "op": "filter",
+            "type": "modslot",
+          },
+        ],
+      },
+    },
+  ],
+}
+`;
+
+exports[`parse |(is:weapon and is:sniperrifle) or not (is:armor and modslot:arrival)|: lexer 1`] = `
+Array [
+  Array [
+    "(",
+  ],
+  Array [
+    "filter",
+    "is",
+    "weapon",
+  ],
+  Array [
+    "and",
+  ],
+  Array [
+    "filter",
+    "is",
+    "sniperrifle",
+  ],
+  Array [
+    ")",
+  ],
+  Array [
+    "or",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "(",
+  ],
+  Array [
+    "filter",
+    "is",
+    "armor",
+  ],
+  Array [
+    "and",
+  ],
+  Array [
+    "filter",
+    "modslot",
+    "arrival",
+  ],
+  Array [
+    ")",
+  ],
+]
+`;
+
+exports[`parse |(is:weapon is:sniperrifle) or (is:armor modslot:arrival)|: ast 1`] = `
+Object {
+  "op": "or",
+  "operands": Array [
+    Object {
+      "op": "and",
+      "operands": Array [
+        Object {
+          "args": "weapon",
+          "op": "filter",
+          "type": "is",
+        },
+        Object {
+          "args": "sniperrifle",
+          "op": "filter",
+          "type": "is",
+        },
+      ],
+    },
+    Object {
+      "op": "and",
+      "operands": Array [
+        Object {
+          "args": "armor",
+          "op": "filter",
+          "type": "is",
+        },
+        Object {
+          "args": "arrival",
+          "op": "filter",
+          "type": "modslot",
+        },
+      ],
+    },
+  ],
+}
+`;
+
+exports[`parse |(is:weapon is:sniperrifle) or (is:armor modslot:arrival)|: lexer 1`] = `
+Array [
+  Array [
+    "(",
+  ],
+  Array [
+    "filter",
+    "is",
+    "weapon",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "is",
+    "sniperrifle",
+  ],
+  Array [
+    ")",
+  ],
+  Array [
+    "or",
+  ],
+  Array [
+    "(",
+  ],
+  Array [
+    "filter",
+    "is",
+    "armor",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "modslot",
+    "arrival",
+  ],
+  Array [
+    ")",
+  ],
+]
+`;
+
+exports[`parse |- is:exotic - (power:>1000)|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "exotic",
+        "op": "filter",
+        "type": "is",
+      },
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": ">1000",
+        "op": "filter",
+        "type": "power",
+      },
+    },
+  ],
+}
+`;
+
+exports[`parse |- is:exotic - (power:>1000)|: lexer 1`] = `
+Array [
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "is",
+    "exotic",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "(",
+  ],
+  Array [
+    "filter",
+    "power",
+    ">1000",
+  ],
+  Array [
+    ")",
+  ],
+]
+`;
+
+exports[`parse |-(power:>1000 and -modslot:arrival)|: ast 1`] = `
+Object {
+  "op": "not",
+  "operand": Object {
+    "op": "and",
+    "operands": Array [
+      Object {
+        "args": ">1000",
+        "op": "filter",
+        "type": "power",
+      },
+      Object {
+        "op": "not",
+        "operand": Object {
+          "args": "arrival",
+          "op": "filter",
+          "type": "modslot",
+        },
+      },
+    ],
+  },
+}
+`;
+
+exports[`parse |-(power:>1000 and -modslot:arrival)|: lexer 1`] = `
+Array [
+  Array [
+    "not",
+  ],
+  Array [
+    "(",
+  ],
+  Array [
+    "filter",
+    "power",
+    ">1000",
+  ],
+  Array [
+    "and",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "modslot",
+    "arrival",
+  ],
+  Array [
+    ")",
+  ],
+]
+`;
+
+exports[`parse |-is:equipped is:haspower is:incurrentchar|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "equipped",
+        "op": "filter",
+        "type": "is",
+      },
+    },
+    Object {
+      "args": "haspower",
+      "op": "filter",
+      "type": "is",
+    },
+    Object {
+      "args": "incurrentchar",
+      "op": "filter",
+      "type": "is",
+    },
+  ],
+}
+`;
+
+exports[`parse |-is:equipped is:haspower is:incurrentchar|: lexer 1`] = `
+Array [
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "is",
+    "equipped",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "is",
+    "haspower",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "is",
+    "incurrentchar",
+  ],
+]
+`;
+
+exports[`parse |-is:exotic -is:locked -is:maxpower -is:tagged stat:total:<55|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "exotic",
+        "op": "filter",
+        "type": "is",
+      },
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "locked",
+        "op": "filter",
+        "type": "is",
+      },
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "maxpower",
+        "op": "filter",
+        "type": "is",
+      },
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "tagged",
+        "op": "filter",
+        "type": "is",
+      },
+    },
+    Object {
+      "args": "<55",
+      "op": "filter",
+      "type": "stat:total",
+    },
+  ],
+}
+`;
+
+exports[`parse |-is:exotic -is:locked -is:maxpower -is:tagged stat:total:<55|: lexer 1`] = `
+Array [
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "is",
+    "exotic",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "is",
+    "locked",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "is",
+    "maxpower",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "is",
+    "tagged",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "stat:total",
+    "<55",
+  ],
+]
+`;
+
+exports[`parse |-source:garden -source:lastwish sunsetsafter:arrival|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "garden",
+        "op": "filter",
+        "type": "source",
+      },
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "lastwish",
+        "op": "filter",
+        "type": "source",
+      },
+    },
+    Object {
+      "args": "arrival",
+      "op": "filter",
+      "type": "sunsetsafter",
+    },
+  ],
+}
+`;
+
+exports[`parse |-source:garden -source:lastwish sunsetsafter:arrival|: lexer 1`] = `
+Array [
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "source",
+    "garden",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "source",
+    "lastwish",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "sunsetsafter",
+    "arrival",
+  ],
+]
+`;
+
+exports[`parse |-witherhoard|: ast 1`] = `
+Object {
+  "op": "not",
+  "operand": Object {
+    "args": "witherhoard",
+    "op": "filter",
+    "type": "keyword",
+  },
+}
+`;
+
+exports[`parse |-witherhoard|: lexer 1`] = `
+Array [
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "keyword",
+    "witherhoard",
+  ],
+]
+`;
+
+exports[`parse |cluster tracking|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "args": "cluster",
+      "op": "filter",
+      "type": "keyword",
+    },
+    Object {
+      "args": "tracking",
+      "op": "filter",
+      "type": "keyword",
+    },
+  ],
+}
+`;
+
+exports[`parse |cluster tracking|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "keyword",
+    "cluster",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "keyword",
+    "tracking",
+  ],
+]
+`;
+
+exports[`parse |is:armor2.0|: ast 1`] = `
+Object {
+  "args": "armor2.0",
+  "op": "filter",
+  "type": "is",
+}
+`;
+
+exports[`parse |is:armor2.0|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "is",
+    "armor2.0",
+  ],
+]
+`;
+
+exports[`parse |is:blue is:haspower -is:maxpower|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "args": "blue",
+      "op": "filter",
+      "type": "is",
+    },
+    Object {
+      "args": "haspower",
+      "op": "filter",
+      "type": "is",
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "maxpower",
+        "op": "filter",
+        "type": "is",
+      },
+    },
+  ],
+}
+`;
+
+exports[`parse |is:blue is:haspower -is:maxpower|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "is",
+    "blue",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "is",
+    "haspower",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "is",
+    "maxpower",
+  ],
+]
+`;
+
+exports[`parse |is:blue is:haspower not:maxpower|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "args": "blue",
+      "op": "filter",
+      "type": "is",
+    },
+    Object {
+      "args": "haspower",
+      "op": "filter",
+      "type": "is",
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "maxpower",
+        "op": "filter",
+        "type": "is",
+      },
+    },
+  ],
+}
+`;
+
+exports[`parse |is:blue is:haspower not:maxpower|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "is",
+    "blue",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "is",
+    "haspower",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "not",
+    "maxpower",
+  ],
+]
+`;
+
+exports[`parse |is:blue is:weapon or is:armor not:maxpower|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "args": "blue",
+      "op": "filter",
+      "type": "is",
+    },
+    Object {
+      "op": "or",
+      "operands": Array [
+        Object {
+          "args": "weapon",
+          "op": "filter",
+          "type": "is",
+        },
+        Object {
+          "args": "armor",
+          "op": "filter",
+          "type": "is",
+        },
+      ],
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "maxpower",
+        "op": "filter",
+        "type": "is",
+      },
+    },
+  ],
+}
+`;
+
+exports[`parse |is:blue is:weapon or is:armor not:maxpower|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "is",
+    "blue",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "is",
+    "weapon",
+  ],
+  Array [
+    "or",
+  ],
+  Array [
+    "filter",
+    "is",
+    "armor",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "not",
+    "maxpower",
+  ],
+]
+`;
+
+exports[`parse |is:rocketlauncher (perk:"cluster" or perk:"tracking module")|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "args": "rocketlauncher",
+      "op": "filter",
+      "type": "is",
+    },
+    Object {
+      "op": "or",
+      "operands": Array [
+        Object {
+          "args": "cluster",
+          "op": "filter",
+          "type": "perk",
+        },
+        Object {
+          "args": "tracking module",
+          "op": "filter",
+          "type": "perk",
+        },
+      ],
+    },
+  ],
+}
+`;
+
+exports[`parse |is:rocketlauncher (perk:"cluster" or perk:"tracking module")|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "is",
+    "rocketlauncher",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "(",
+  ],
+  Array [
+    "filter",
+    "perk",
+    "cluster",
+  ],
+  Array [
+    "or",
+  ],
+  Array [
+    "filter",
+    "perk",
+    "tracking module",
+  ],
+  Array [
+    ")",
+  ],
+]
+`;
+
+exports[`parse |is:rocketlauncher -"cluster" -"tracking module"|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "args": "rocketlauncher",
+      "op": "filter",
+      "type": "is",
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "cluster",
+        "op": "filter",
+        "type": "keyword",
+      },
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "tracking module",
+        "op": "filter",
+        "type": "keyword",
+      },
+    },
+  ],
+}
+`;
+
+exports[`parse |is:rocketlauncher -"cluster" -"tracking module"|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "is",
+    "rocketlauncher",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "keyword",
+    "cluster",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "keyword",
+    "tracking module",
+  ],
+]
+`;
+
+exports[`parse |is:rocketlauncher -"cluster" -'tracking module'|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "args": "rocketlauncher",
+      "op": "filter",
+      "type": "is",
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "cluster",
+        "op": "filter",
+        "type": "keyword",
+      },
+    },
+    Object {
+      "op": "not",
+      "operand": Object {
+        "args": "tracking module",
+        "op": "filter",
+        "type": "keyword",
+      },
+    },
+  ],
+}
+`;
+
+exports[`parse |is:rocketlauncher -"cluster" -'tracking module'|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "is",
+    "rocketlauncher",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "keyword",
+    "cluster",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "keyword",
+    "tracking module",
+  ],
+]
+`;
+
+exports[`parse |is:weapon (is:sniperrifle or (is:armor and modslot:arrival))|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "args": "weapon",
+      "op": "filter",
+      "type": "is",
+    },
+    Object {
+      "op": "or",
+      "operands": Array [
+        Object {
+          "args": "sniperrifle",
+          "op": "filter",
+          "type": "is",
+        },
+        Object {
+          "op": "and",
+          "operands": Array [
+            Object {
+              "args": "armor",
+              "op": "filter",
+              "type": "is",
+            },
+            Object {
+              "args": "arrival",
+              "op": "filter",
+              "type": "modslot",
+            },
+          ],
+        },
+      ],
+    },
+  ],
+}
+`;
+
+exports[`parse |is:weapon (is:sniperrifle or (is:armor and modslot:arrival))|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "is",
+    "weapon",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "(",
+  ],
+  Array [
+    "filter",
+    "is",
+    "sniperrifle",
+  ],
+  Array [
+    "or",
+  ],
+  Array [
+    "(",
+  ],
+  Array [
+    "filter",
+    "is",
+    "armor",
+  ],
+  Array [
+    "and",
+  ],
+  Array [
+    "filter",
+    "modslot",
+    "arrival",
+  ],
+  Array [
+    ")",
+  ],
+  Array [
+    ")",
+  ],
+]
+`;
+
+exports[`parse |is:weapon and is:sniperrifle or not is:armor and modslot:arrival|: ast 1`] = `
+Object {
+  "op": "or",
+  "operands": Array [
+    Object {
+      "op": "and",
+      "operands": Array [
+        Object {
+          "args": "weapon",
+          "op": "filter",
+          "type": "is",
+        },
+        Object {
+          "args": "sniperrifle",
+          "op": "filter",
+          "type": "is",
+        },
+      ],
+    },
+    Object {
+      "op": "and",
+      "operands": Array [
+        Object {
+          "op": "not",
+          "operand": Object {
+            "args": "armor",
+            "op": "filter",
+            "type": "is",
+          },
+        },
+        Object {
+          "args": "arrival",
+          "op": "filter",
+          "type": "modslot",
+        },
+      ],
+    },
+  ],
+}
+`;
+
+exports[`parse |is:weapon and is:sniperrifle or not is:armor and modslot:arrival|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "is",
+    "weapon",
+  ],
+  Array [
+    "and",
+  ],
+  Array [
+    "filter",
+    "is",
+    "sniperrifle",
+  ],
+  Array [
+    "or",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "is",
+    "armor",
+  ],
+  Array [
+    "and",
+  ],
+  Array [
+    "filter",
+    "modslot",
+    "arrival",
+  ],
+]
+`;
+
+exports[`parse |is:weapon is:sniperrifle or is:armor and modslot:arrival|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "args": "weapon",
+      "op": "filter",
+      "type": "is",
+    },
+    Object {
+      "op": "or",
+      "operands": Array [
+        Object {
+          "args": "sniperrifle",
+          "op": "filter",
+          "type": "is",
+        },
+        Object {
+          "op": "and",
+          "operands": Array [
+            Object {
+              "args": "armor",
+              "op": "filter",
+              "type": "is",
+            },
+            Object {
+              "args": "arrival",
+              "op": "filter",
+              "type": "modslot",
+            },
+          ],
+        },
+      ],
+    },
+  ],
+}
+`;
+
+exports[`parse |is:weapon is:sniperrifle or is:armor and modslot:arrival|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "is",
+    "weapon",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "is",
+    "sniperrifle",
+  ],
+  Array [
+    "or",
+  ],
+  Array [
+    "filter",
+    "is",
+    "armor",
+  ],
+  Array [
+    "and",
+  ],
+  Array [
+    "filter",
+    "modslot",
+    "arrival",
+  ],
+]
+`;
+
+exports[`parse |is:weapon is:sniperrifle or not is:armor modslot:arrival|: ast 1`] = `
+Object {
+  "op": "and",
+  "operands": Array [
+    Object {
+      "args": "weapon",
+      "op": "filter",
+      "type": "is",
+    },
+    Object {
+      "op": "or",
+      "operands": Array [
+        Object {
+          "args": "sniperrifle",
+          "op": "filter",
+          "type": "is",
+        },
+        Object {
+          "op": "not",
+          "operand": Object {
+            "args": "armor",
+            "op": "filter",
+            "type": "is",
+          },
+        },
+      ],
+    },
+    Object {
+      "args": "arrival",
+      "op": "filter",
+      "type": "modslot",
+    },
+  ],
+}
+`;
+
+exports[`parse |is:weapon is:sniperrifle or not is:armor modslot:arrival|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "is",
+    "weapon",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "is",
+    "sniperrifle",
+  ],
+  Array [
+    "or",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "is",
+    "armor",
+  ],
+  Array [
+    "implicit_and",
+  ],
+  Array [
+    "filter",
+    "modslot",
+    "arrival",
+  ],
+]
+`;
+
+exports[`parse |name:"Gahlran's Right Hand"|: ast 1`] = `
+Object {
+  "args": "gahlran's right hand",
+  "op": "filter",
+  "type": "name",
+}
+`;
+
+exports[`parse |name:"Gahlran's Right Hand"|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "name",
+    "gahlran's right hand",
+  ],
+]
+`;
+
+exports[`parse |name:"Hard Light"|: ast 1`] = `
+Object {
+  "args": "hard light",
+  "op": "filter",
+  "type": "name",
+}
+`;
+
+exports[`parse |name:"Hard Light"|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "name",
+    "hard light",
+  ],
+]
+`;
+
+exports[`parse |name:'Hard Light'|: ast 1`] = `
+Object {
+  "args": "hard light",
+  "op": "filter",
+  "type": "name",
+}
+`;
+
+exports[`parse |name:'Hard Light'|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "name",
+    "hard light",
+  ],
+]
+`;
+
+exports[`parse |not -not:maxpower|: ast 1`] = `
+Object {
+  "op": "not",
+  "operand": Object {
+    "op": "not",
+    "operand": Object {
+      "op": "not",
+      "operand": Object {
+        "args": "maxpower",
+        "op": "filter",
+        "type": "is",
+      },
+    },
+  },
+}
+`;
+
+exports[`parse |not -not:maxpower|: lexer 1`] = `
+Array [
+  Array [
+    "not",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "not",
+    "maxpower",
+  ],
+]
+`;
+
+exports[`parse |not forgotten|: ast 1`] = `
+Object {
+  "op": "not",
+  "operand": Object {
+    "args": "forgotten",
+    "op": "filter",
+    "type": "keyword",
+  },
+}
+`;
+
+exports[`parse |not forgotten|: lexer 1`] = `
+Array [
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "keyword",
+    "forgotten",
+  ],
+]
+`;
+
+exports[`parse |not not not:maxpower|: ast 1`] = `
+Object {
+  "op": "not",
+  "operand": Object {
+    "op": "not",
+    "operand": Object {
+      "op": "not",
+      "operand": Object {
+        "args": "maxpower",
+        "op": "filter",
+        "type": "is",
+      },
+    },
+  },
+}
+`;
+
+exports[`parse |not not not:maxpower|: lexer 1`] = `
+Array [
+  Array [
+    "not",
+  ],
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "not",
+    "maxpower",
+  ],
+]
+`;
+
+exports[`parse |not not:maxpower|: ast 1`] = `
+Object {
+  "op": "not",
+  "operand": Object {
+    "op": "not",
+    "operand": Object {
+      "args": "maxpower",
+      "op": "filter",
+      "type": "is",
+    },
+  },
+}
+`;
+
+exports[`parse |not not:maxpower|: lexer 1`] = `
+Array [
+  Array [
+    "not",
+  ],
+  Array [
+    "filter",
+    "not",
+    "maxpower",
+  ],
+]
+`;
+
+exports[`parse |not:maxpower|: ast 1`] = `
+Object {
+  "op": "not",
+  "operand": Object {
+    "args": "maxpower",
+    "op": "filter",
+    "type": "is",
+  },
+}
+`;
+
+exports[`parse |not:maxpower|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "not",
+    "maxpower",
+  ],
+]
+`;
+
+exports[`parse |perk:"수집가"|: ast 1`] = `
+Object {
+  "args": "수집가",
+  "op": "filter",
+  "type": "perk",
+}
+`;
+
+exports[`parse |perk:"수집가"|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "perk",
+    "수집가",
+  ],
+]
+`;
+
+exports[`parse |perk:수집가|: ast 1`] = `
+Object {
+  "args": "수집가",
+  "op": "filter",
+  "type": "perk",
+}
+`;
+
+exports[`parse |perk:수집가|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "perk",
+    "수집가",
+  ],
+]
+`;
+
+exports[`parse |‘grenade launcher reserves’|: ast 1`] = `
+Object {
+  "args": "grenade launcher reserves",
+  "op": "filter",
+  "type": "keyword",
+}
+`;
+
+exports[`parse |‘grenade launcher reserves’|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "keyword",
+    "grenade launcher reserves",
+  ],
+]
+`;
+
+exports[`parse |“grenade launcher reserves”|: ast 1`] = `
+Object {
+  "args": "grenade launcher reserves",
+  "op": "filter",
+  "type": "keyword",
+}
+`;
+
+exports[`parse |“grenade launcher reserves”|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "keyword",
+    "grenade launcher reserves",
+  ],
+]
+`;
+
+exports[`parse |수집가|: ast 1`] = `
+Object {
+  "args": "수집가",
+  "op": "filter",
+  "type": "keyword",
+}
+`;
+
+exports[`parse |수집가|: lexer 1`] = `
+Array [
+  Array [
+    "filter",
+    "keyword",
+    "수집가",
+  ],
+]
+`;
diff --git a/src/app/search/query-parser.test.ts b/src/app/search/query-parser.test.ts
new file mode 100644
index 000000000..08ca122c0
--- /dev/null
+++ b/src/app/search/query-parser.test.ts
@@ -0,0 +1,114 @@
+import { parseQuery, lexer, Token, canonicalizeQuery } from './query-parser';
+
+// To update the snapshots, run:
+// npx jest --updateSnapshot src/app/search/query-parser.test.ts
+
+// TODO: failed parse - a failed parse should return the parts that didn't fail plus an error code??
+
+// Some of these are contrived, some are from past search parsing issues
+const cases = [
+  ['is:blue is:haspower -is:maxpower'],
+  ['is:blue is:haspower not:maxpower'],
+  ['not:maxpower'],
+  ['not -not:maxpower'],
+  ['not not not:maxpower'],
+  ['is:blue is:weapon or is:armor not:maxpower'], // => is:blue and (is:weapon or is:armor) and -is:maxpower
+  ['not not:maxpower'],
+  ['-is:equipped is:haspower is:incurrentchar'],
+  ['-source:garden -source:lastwish sunsetsafter:arrival'],
+  ['-is:exotic -is:locked -is:maxpower -is:tagged stat:total:<55'],
+  ['(is:weapon is:sniperrifle) or (is:armor modslot:arrival)'],
+  ['(is:weapon and is:sniperrifle) or not (is:armor and modslot:arrival)'],
+  ['is:weapon and is:sniperrifle or not is:armor and modslot:arrival'], // => (is:weapon and is:sniperrifle) or (-is:armor and modslot:arrival)
+  ['is:weapon is:sniperrifle or not is:armor modslot:arrival'], // => is:weapon and (is:sniperrifle or -is:armor) and modslot:arrival
+  ['is:weapon is:sniperrifle or is:armor and modslot:arrival'], // => is:weapon and (is:sniperrifle or (-is:armor modslot:arrival))
+  ['is:weapon (is:sniperrifle or (is:armor and modslot:arrival))'], // => is:weapon and (is:sniperrifle or (-is:armor modslot:arrival))
+  ['-(power:>1000 and -modslot:arrival)'],
+  ['( power:>1000 and -modslot:arrival ) '],
+  ['- is:exotic - (power:>1000)'],
+  ['is:armor2.0'],
+  ['not forgotten'], // => -"forgotten"
+  ['cluster tracking'], // => "cluster" and "tracking"
+  ['name:"Hard Light"'],
+  ["name:'Hard Light'"],
+  ['name:"Gahlran\'s Right Hand"'],
+  ['-witherhoard'],
+  ['perk:수집가'],
+  ['perk:"수집가"'],
+  ['"수집가"'],
+  ['수집가'],
+  ['is:rocketlauncher -"cluster" -"tracking module"'],
+  ['is:rocketlauncher -"cluster" -\'tracking module\''],
+  ['is:rocketlauncher (perk:"cluster" or perk:"tracking module")'],
+  ['(is:hunter power:>=540) or (is:warlock power:>=560)'],
+  ['"grenade launcher reserves"'],
+  // These test copy/pasting from somewhere that automatically converts quotes to "smart quotes"
+  ['“grenade launcher reserves”'],
+  ['‘grenade launcher reserves’'],
+];
+
+// Each of these asserts that the first query is the same as the second query once parsed
+const equivalentSearches = [
+  [
+    'is:blue is:weapon or is:armor not:maxpower',
+    'is:blue and (is:weapon or is:armor) and -is:maxpower',
+  ],
+  ['not forgotten', "-'forgotten'"],
+  ['cluster tracking', '"cluster" and "tracking"'],
+  [
+    'is:weapon and is:sniperrifle or not is:armor and modslot:arrival',
+    '(is:weapon and is:sniperrifle) or (-is:armor and modslot:arrival)',
+  ],
+  [
+    'is:weapon is:sniperrifle or not is:armor modslot:arrival',
+    'is:weapon and (is:sniperrifle or -is:armor) and modslot:arrival',
+  ],
+  [
+    'is:weapon is:sniperrifle or is:armor and modslot:arrival',
+    'is:weapon and (is:sniperrifle or (is:armor and modslot:arrival))',
+  ],
+  [
+    'is:rocketlauncher perk:"cluster" or perk:"tracking module"',
+    'is:rocketlauncher (perk:"cluster" or perk:"tracking module")',
+  ],
+  ['is:blue (is:rocketlauncher', 'is:blue is:rocketlauncher'],
+];
+
+// Test what we generate as the canonical form of queries. The first is the input,
+// the second is the canonical version
+const canonicalize = [
+  ['is:blue is:haspower not:maxpower', 'is:blue is:haspower -is:maxpower'],
+  [
+    'is:weapon and is:sniperrifle or not is:armor and modslot:arrival',
+    '(-is:armor modslot:arrival) or (is:sniperrifle is:weapon)',
+  ],
+  [
+    'is:rocketlauncher perk:"cluster" or perk:\'tracking module\'',
+    'is:rocketlauncher (perk:"tracking module" or perk:cluster)',
+  ],
+  ['( power:>1000 and -modslot:arrival ) ', '-modslot:arrival power:>1000'],
+];
+
+test.each(cases)('parse |%s|', (query) => {
+  // Test just the lexer
+  const tokens: Token[] = [];
+  for (const t of lexer(query)) {
+    tokens.push(t);
+  }
+  expect(tokens).toMatchSnapshot('lexer');
+
+  // Test the full parse tree
+  const ast = parseQuery(query);
+  expect(ast).toMatchSnapshot('ast');
+});
+
+test.each(equivalentSearches)('|%s| is equivalent to |%s|', (firstQuery, secondQuery) => {
+  const firstAST = parseQuery(firstQuery);
+  const secondAST = parseQuery(secondQuery);
+  expect(firstAST).toEqual(secondAST);
+});
+
+test.each(canonicalize)('|%s| is canonically |%s|', (query, canonical) => {
+  const canonicalized = canonicalizeQuery(parseQuery(query));
+  expect(canonicalized).toEqual(canonical);
+});