diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,4 @@
 cypress/junit/
 cypress/downloads/
 .eslintcache
+tree-sitter-swh_search_ql.wasm
diff --git a/Makefile.local b/Makefile.local
--- a/Makefile.local
+++ b/Makefile.local
@@ -5,12 +5,13 @@
 SETTINGS_TEST ?= swh.web.settings.tests
 SETTINGS_DEV ?= swh.web.settings.development
 SETTINGS_PROD = swh.web.settings.production
+SWH_SEARCH_DIR := $(shell python -c "import os;from swh import search; print(os.path.dirname(search.__file__))")
 
 yarn-install: package.json
 	$(YARN) install --frozen-lockfile
 
 .PHONY: build-webpack-dev
-build-webpack-dev: yarn-install
+build-webpack-dev:
 	$(YARN) build-dev
 
 .PHONY: build-webpack-test
@@ -123,3 +124,7 @@
 # https://github.com/typeddjango/django-stubs/issues/166
 check-mypy:
 	DJANGO_SETTINGS_MODULE=$(SETTINGS_DEV) $(MYPY) $(MYPYFLAGS) swh
+
+build-ts-wasm:
+	yarn run tree-sitter build-wasm $(SWH_SEARCH_DIR)/query_language
+	cp $(SWH_SEARCH_DIR)/query_language/tokens.js assets/tokens.js
diff --git a/assets/config/webpack.config.development.js b/assets/config/webpack.config.development.js
--- a/assets/config/webpack.config.development.js
+++ b/assets/config/webpack.config.development.js
@@ -149,10 +149,16 @@
     alias: {
       'pdfjs-dist': 'pdfjs-dist/build/pdf.min.js'
     },
+    // for web-tree-sitter
+    fallback: {
+      'path': false,
+      'fs': false
+    },
     // configure base paths for resolving modules with webpack
     modules: [
       'node_modules',
-      path.resolve(__dirname, '../src')
+      path.resolve(__dirname, '../src'),
+      path.resolve(__dirname, '../../../swh-search/query_language/')
     ]
   },
   stats: 'errors-warnings',
@@ -212,6 +218,18 @@
           }
         }]
       },
+      {
+        test: /\.wasm$/,
+        type: 'javascript/auto',
+        use: [{
+          loader: 'file-loader',
+          options: {
+            name: '[name].[ext]',
+            outputPath: 'js/'
+          }
+        }]
+      },
+
       // expose jquery to the global context as $ and jQuery when importing it
       {
         test: require.resolve('jquery'),
@@ -370,6 +388,14 @@
         {
           from: path.resolve(nodeModules, 'mathjax/es5/output/chtml/fonts/woff-v2/**'),
           to: path.resolve(__dirname, '../../static/fonts/[name][ext]')
+        },
+        {
+          from: path.resolve(__dirname, '../../tree-sitter-swh_search_ql.wasm'),
+          to: path.resolve(__dirname, '../../static/js/swh_ql.wasm')
+        },
+        {
+          from: path.resolve(__dirname, '../tokens.js'),
+          to: path.resolve(__dirname, '../../static/js/')
         }
       ]
     }),
diff --git a/assets/src/bundles/browse/origin-search.js b/assets/src/bundles/browse/origin-search.js
--- a/assets/src/bundles/browse/origin-search.js
+++ b/assets/src/bundles/browse/origin-search.js
@@ -6,6 +6,7 @@
  */
 
 import {handleFetchError, errorMessageFromResponse, isArchivedOrigin} from 'utils/functions';
+import {initAutocomplete} from 'utils/search-ql-autocomplete';
 
 const limit = 100;
 const linksPrev = [];
@@ -195,6 +196,17 @@
 
 export function initOriginSearch() {
   $(document).ready(() => {
+    const inputBox = document.querySelector('#swh-origins-url-patterns');
+    const submitBtn = document.querySelector('#swh-search-submit');
+    const validQueryCallback = (isValid) => {
+      submitBtn.disabled = !isValid;
+      // if (!isValid)
+      //   inputBox.classList.add('invalid');
+      // else
+      //   inputBox.classList.remove('invalid');
+    };
+    initAutocomplete(inputBox, validQueryCallback);
+
     $('#swh-search-origins').submit(event => {
       event.preventDefault();
       if (event.target.checkValidity()) {
diff --git a/assets/src/utils/autocomplete.css b/assets/src/utils/autocomplete.css
new file mode 100644
--- /dev/null
+++ b/assets/src/utils/autocomplete.css
@@ -0,0 +1,50 @@
+.autocomplete {
+    position: relative;
+    display: inline-block;
+}
+
+input.invalid {
+    outline: none !important;
+
+    /* border: 2px solid red; */
+}
+
+/* position the autocomplete items to be the same width as the container: */
+.autocomplete-items {
+    position: absolute;
+    border: 1px solid #d4d4d4;
+    width: 200px;
+    border-top: none;
+    z-index: 99998;
+    top: 100%;
+    left: 0;
+    right: 0;
+
+    /*
+    z-index: 99999; is taken by swh-top-bar
+    overflow-y: scroll;
+    */
+}
+
+.autocomplete-items div {
+    padding: 3px;
+    padding-left: 5px;
+    cursor: pointer;
+    background-color: #fff;
+
+    /*
+    font-size: 15px;
+    border-bottom: 1px solid #d4d4d4;
+    */
+}
+
+/* when hovering an item: */
+.autocomplete-items div:hover {
+    background-color: #e9e9e9;
+}
+
+/* when navigating through the items using the arrow keys: */
+.autocomplete-active {
+    background-color: #e20026 !important;
+    color: #fff;
+}
diff --git a/assets/src/utils/autocomplete.js b/assets/src/utils/autocomplete.js
new file mode 100644
--- /dev/null
+++ b/assets/src/utils/autocomplete.js
@@ -0,0 +1,148 @@
+import 'utils/autocomplete.css';
+
+export class Autocomplete {
+  constructor(params) {
+    const {inputBox, suggestions} = params;
+    this.inputBox = inputBox;
+    this.suggestions = suggestions;
+    this.currentIndex = -1;
+
+    this.autocompleteList = document.createElement('div');
+    this.autocompleteList.setAttribute('class', 'autocomplete-items');
+    this.inputBox.parentNode.appendChild(this.autocompleteList);
+
+    this.initListeners();
+  }
+
+  initListeners() {
+    this.inputBox.addEventListener('focus', this.updateLists.bind(this));
+    this.inputBox.addEventListener('input', this.updateLists.bind(this));
+
+    this.inputBox.addEventListener('keydown', (e) => {
+      if (e.keyCode === 40) { // down
+        e.preventDefault();
+        this.currentIndex++;
+        this.addActive();
+      } else if (e.keyCode === 38) { // up
+        e.preventDefault();
+        this.currentIndex--;
+        this.addActive();
+      } else if (e.keyCode === 13 || e.keyCode === 9) { // enter or tab
+        e.preventDefault();
+        if (this.currentIndex > -1) {
+          // Simulate a click on the "active" item:
+          if (this.autocompleteList) this.autocompleteList.children[this.currentIndex].click();
+        }
+      } else if (e.keyCode === 27) { // escape
+        e.preventDefault();
+        this.removeAllItems(e.target);
+      }
+    });
+
+    document.addEventListener('click', (e) => { this.removeAllItems(e.target); });
+  }
+
+  updateLists() {
+    const inputValue = this.inputBox.value;
+
+    const tokens = inputValue.split();
+    const lastToken = tokens[tokens.length - 1];
+    const lastChar = lastToken[lastToken.length - 1];
+
+    /* close any already open lists of autocompleted values */
+    this.removeAllItems();
+
+    this.currentIndex = -1;
+
+    const suggestions = this.suggestions.filter(s => (s.indexOf(lastToken) >= 0 || lastChar === ' '));
+
+    suggestions.slice(0, 10).forEach(suggestion => {
+      const itemDiv = document.createElement('div');
+      if (lastChar === ' ') {
+        itemDiv.innerHTML = suggestion;
+      } else {
+        const indexOfLastToken = suggestion.indexOf(lastToken);
+
+        itemDiv.innerHTML = suggestion.substr(0, indexOfLastToken) +
+          '<strong>' +
+          suggestion.substr(indexOfLastToken, lastToken.length) +
+          '</strong>' +
+          suggestion.substr(
+            indexOfLastToken + lastToken.length, suggestion.length - (lastToken.length - 2)
+          );
+
+      }
+
+      itemDiv.setAttribute('data-value', suggestion);
+      itemDiv.setAttribute('data-editable-suggestion', 'false');
+      itemDiv.setAttribute('title', 'Include repos with the provided term in their url (origin)');
+
+      const suggestionClick = (e) => {
+        const toInsert = e.target.getAttribute('data-value');
+        const isEditableSuggestion = e.target.getAttribute('data-editable-suggestion');
+
+        if (isEditableSuggestion === 'true') return;
+
+        const oldValue = this.inputBox.value;
+        const tokens = oldValue.split();
+        const lastToken = tokens[tokens.length - 1];
+        const lastChar = lastToken[lastToken.length - 1];
+
+        let newValue = '';
+
+        if (lastChar === ' ' || oldValue === '') {
+          newValue = oldValue + toInsert;
+        } else {
+          // const position = this.inputBox.selectionStart;
+          const queryWithoutLastToken = tokens.slice(0, tokens.length - 2).join(' ');
+          newValue = queryWithoutLastToken + ((queryWithoutLastToken !== '') ? ' ' : '') + toInsert;
+        }
+
+        this.inputBox.value = newValue;
+        this.inputBox.blur();
+        this.inputBox.focus();
+        // this.inputBox.dispatchEvent(new Event('input'))
+      };
+
+      itemDiv.addEventListener('click', suggestionClick.bind(this));
+
+      this.autocompleteList.appendChild(itemDiv);
+    });
+
+    if (suggestions?.length) {
+      // Select first element on each update
+      this.currentIndex = 0;
+      this.addActive();
+    }
+  }
+
+  addActive() {
+    //  a function to classify an item as "active":
+    if (!this.autocompleteList) return false;
+    //  start by removing the "active" class on all items:
+    const n = this.autocompleteList.childElementCount;
+    this.removeActive();
+    if (this.currentIndex >= n) this.currentIndex = 0;
+    if (this.currentIndex < 0) this.currentIndex = (n - 1);
+    // add class "autocomplete-active":
+    this.autocompleteList.children[this.currentIndex].classList.add('autocomplete-active');
+  }
+
+  removeActive() {
+    /* a function to remove the "active" class from all autocomplete items */
+    Array.from(this.autocompleteList.children).forEach(autocompleteItem => {
+      autocompleteItem.classList.remove('autocomplete-active');
+    });
+  }
+
+  removeAllItems(element) {
+    /*
+        close all autocomplete lists in the document,
+        except the one passed as an argument
+        */
+    if (element !== this.inputBox && this.autocompleteList) {
+      this.autocompleteList.innerHTML = '';
+    }
+  }
+
+}
diff --git a/assets/src/utils/search-ql-autocomplete.js b/assets/src/utils/search-ql-autocomplete.js
new file mode 100644
--- /dev/null
+++ b/assets/src/utils/search-ql-autocomplete.js
@@ -0,0 +1,224 @@
+import {staticAsset} from 'utils/functions';
+import 'web-tree-sitter/tree-sitter.wasm';
+import Parser from 'web-tree-sitter';
+import {Autocomplete} from 'utils/autocomplete.js';
+import {
+  fields, limitField, sortByField, // fields
+  sortByOptions, visitTypeOptions, // options
+  equalOp, containOp, rangeOp, choiceOp, // operators
+  AND, OR, TRUE, FALSE // special tokens
+} from '../../tokens.js';
+
+const filterNames = fields.concat(sortByField, limitField);
+
+const languageSyntax = [
+  {
+    category: 'patternFilter',
+    field: 'patternField',
+    operator: 'containOp',
+    value: 'patternVal',
+    suggestion: ['string', '"string"']
+  },
+  {
+    category: 'booleanFilter',
+    field: 'booleanField',
+    operator: 'equalOp',
+    value: 'booleanVal',
+    suggestion: [TRUE, FALSE]
+  },
+  {
+    category: 'numericFilter',
+    field: 'numericField',
+    operator: 'rangeOp',
+    value: 'numberVal',
+    suggestion: ['15']
+  },
+  {
+    category: 'boundedListFilter',
+    field: 'visitTypeField',
+    operator: 'equalOp',
+    value: 'visitTypeVal',
+    options: visitTypeOptions,
+    suggestion: ['[']
+  },
+  {
+    category: 'unboundedListFilter',
+    field: 'listField',
+    operator: 'choiceOp',
+    value: 'listVal',
+    options: ['string', '"string"'],
+    suggestion: ['[']
+  },
+  {
+    category: 'dateFilter',
+    field: 'dateField',
+    operator: 'rangeOp',
+    value: 'dateVal',
+    suggestion: ['2000-01-01', '2000-01-01T00:00Z']
+  },
+  {
+    category: 'sortBy',
+    field: 'sortByField',
+    operator: 'equalOp',
+    value: 'sortByVal',
+    options: sortByOptions,
+    suggestion: ['[']
+  },
+  {
+    category: 'limit',
+    field: 'limit',
+    operator: 'equalOp',
+    value: 'number',
+    suggestion: ['50']
+  }
+];
+
+const filterOperators = {equalOp, containOp, choiceOp, rangeOp};
+
+const findMissingNode = (node) => {
+  if (node.isMissing()) {
+    return node;
+  }
+  if (node.children.length > 0) {
+    for (let i = 0; i < node.children.length; i++) {
+      const missingNode = findMissingNode(node.children[i]);
+      if (missingNode !== null) { return missingNode; }
+    }
+  }
+
+  return null;
+};
+
+const isWrapperNode = (child, parent) => {
+  if (!child || !parent) return false;
+  if (parent.namedChildren.length === 1 && parent.type !== 'ERROR') return true;
+  return (
+    (child.startPosition.column === parent.startPosition.column) &&
+    (child.endPosition.column === parent.endPosition.column)
+  );
+};
+
+const isCategoryNode = (node) => {
+  if (!node || node === null) return false;
+  if (node.type === 'ERROR' || languageSyntax.filter(f => f.category === node.type).length > 0) { return true; }
+
+  return false;
+};
+
+const suggestNextNode = (tree, inputBox) => {
+  const cursor = inputBox.selectionStart - 1;
+  const query = inputBox.value;
+
+  let lastTokenIndex = cursor;
+  // let distFromLastToken = 0;
+  while (query[lastTokenIndex] === ' ') {
+    lastTokenIndex--;
+    // distFromLastToken++;
+  }
+
+  // if(query === "visit_type = []") debugger;
+
+  const lastTokenPosition = {row: 0, column: lastTokenIndex};
+  const lastTokenNode = tree.rootNode.descendantForPosition(lastTokenPosition, lastTokenPosition);
+
+  const missingNode = findMissingNode(tree.rootNode);
+
+  // Find last token node wrapper
+  let lastTokenNodeWrapper = lastTokenNode;
+  while (isWrapperNode(lastTokenNodeWrapper, lastTokenNodeWrapper.parent)) {
+    lastTokenNodeWrapper = lastTokenNodeWrapper.parent;
+  }
+
+  // Find last token node wrapper sibling
+  const lastTokenNodeWrapperSibling = lastTokenNodeWrapper.previousSibling;
+
+  // Find current filter category
+  let currentFilterCategory = lastTokenNode;
+  while (!isCategoryNode(currentFilterCategory)) {
+    currentFilterCategory = currentFilterCategory.parent;
+  }
+
+  console.log(lastTokenNode);
+  console.log(`LAST NODE: ${lastTokenNode.type}`);
+  console.log(`LAST NODE ANCESTOR: ${lastTokenNodeWrapper.type}`);
+  console.log(`LAST NODE ANCESTOR SIBLING: ${lastTokenNodeWrapperSibling?.type}`);
+  console.log(`LAST CATEGORY: ${currentFilterCategory.type}`);
+
+  // Suggest options for array valued filters
+  if ((lastTokenNode.type === ',' && lastTokenNodeWrapper.type.indexOf('Val') > 0) ||
+    (lastTokenNode.type === '[' && currentFilterCategory)
+  ) {
+    const filter = languageSyntax.filter(f => f.category === currentFilterCategory.type)[0];
+    console.log(filter.options);
+    return filter.options ?? [];
+  }
+  if (
+    (!tree.rootNode.hasError() && (lastTokenNodeWrapper.type.indexOf('Val') > 0)) ||
+    (lastTokenNode.type === ')' || lastTokenNode.type === ']')
+  ) {
+    // Suggest AND/OR
+    return [AND, OR];
+  }
+  if (missingNode && missingNode !== null) {
+    // Suggest missing nodes (Automatically suggested by Tree-sitter)
+    if (missingNode.type === ')') {
+      return [AND, OR, ')'];
+    } else if (missingNode.type === ']') {
+      return [',', ']'];
+    }
+  }
+
+  if (lastTokenNode.type === 'ERROR' ||
+    (lastTokenNode.type === '(') ||
+    ((lastTokenNode.type === AND || lastTokenNode.type === OR))
+  ) {
+    // Suggest field names
+    return filterNames.concat('(');
+  } else if (languageSyntax.map(f => f.field).includes(lastTokenNode.type)) {
+    // Suggest operators
+    const filter = languageSyntax.filter(f => f.field === lastTokenNode.type)[0];
+    return filterOperators[filter.operator];
+  } else if (lastTokenNode.type in filterOperators) {
+    // Suggest values
+    const filter = languageSyntax.filter(f => (
+      f.field === lastTokenNodeWrapperSibling.type
+    ))[0];
+    return filter.suggestion;
+  }
+
+  return [];
+};
+
+export const initAutocomplete = (inputBox, validQueryCallback) => {
+  Parser.init().then(async() => {
+    const parser = new Parser();
+    const swhSearchQL = await Parser.Language.load(staticAsset('js/swh_ql.wasm'));
+    parser.setLanguage(swhSearchQL);
+
+    const autocomplete = new Autocomplete(
+      {inputBox, suggestions: ['('].concat(filterNames)}
+    );
+
+    const getSuggestions = (e) => {
+      // if (e.keycode !== 32) // space
+      // return;
+      const tree = parser.parse(inputBox.value);
+
+      if (tree.rootNode.hasError()) {
+        validQueryCallback(false);
+        // inputBox.classList.add('invalid');
+      } else {
+        validQueryCallback(true);
+        // inputBox.classList.remove('invalid');
+      }
+
+      console.log(`input(${inputBox.value})  => ${tree.rootNode.toString()}`);
+
+      const suggestions = suggestNextNode(tree, inputBox);
+      // if (suggestions)
+      autocomplete.suggestions = suggestions; // .map(item => `${item} `);
+    };
+
+    inputBox.addEventListener('keydown', getSuggestions.bind(this));
+  });
+};
diff --git a/assets/src/utils/tokens.js b/assets/src/utils/tokens.js
new file mode 100644
--- /dev/null
+++ b/assets/src/utils/tokens.js
@@ -0,0 +1,109 @@
+// Copyright (C) 2021  The Software Heritage developers
+// See the AUTHORS file at the top-level directory of this distribution
+// License: GNU General Public License version 3, or any later version
+// See top-level LICENSE file for more information
+
+// Field tokens
+const visitTypeField = 'visit_type';
+const sortByField = 'sort_by';
+const limitField = 'limit';
+
+// Field categories
+const patternFields = ['origin', 'metadata'];
+const booleanFields = ['visited'];
+const numericFields = ['visits'];
+const boundedListFields = [visitTypeField];
+const listFields = ['language', 'license', 'keyword'];
+const dateFields = [
+  'last_visit',
+  'last_eventful_visit',
+  'last_revision',
+  'last_release',
+  'created',
+  'modified',
+  'published'
+];
+
+const fields = [].concat(
+  patternFields,
+  booleanFields,
+  numericFields,
+  boundedListFields,
+  listFields,
+  dateFields
+);
+
+// Operators
+const equalOp = ['='];
+const containOp = [':'];
+const rangeOp = ['<', '<=', '=', '!=', '>=', '>'];
+const choiceOp = ['in', 'not in'];
+
+// Values
+const sortByOptions = [
+  'visits',
+  'last_visit',
+  'last_eventful_visit',
+  'last_revision',
+  'last_release',
+  'created',
+  'modified',
+  'published'
+];
+
+const visitTypeOptions = [
+  'any',
+  'bzr',
+  'cran',
+  'cvs',
+  'deb',
+  'deposit',
+  'ftp',
+  'hg',
+  'git',
+  'nixguix',
+  'npm',
+  'opam',
+  'pypi',
+  'svn',
+  'tar'
+];
+
+// Extra tokens
+const OR = 'or';
+const AND = 'and';
+
+const TRUE = 'true';
+const FALSE = 'false';
+
+module.exports = {
+  // Field tokens
+  visitTypeField,
+  sortByField,
+  limitField,
+
+  // Field categories
+  patternFields,
+  booleanFields,
+  numericFields,
+  boundedListFields,
+  listFields,
+  dateFields,
+  fields,
+
+  // Operators
+  equalOp,
+  containOp,
+  rangeOp,
+  choiceOp,
+
+  // Values
+  sortByOptions,
+  visitTypeOptions,
+
+  // Extra tokens
+  OR,
+  AND,
+  TRUE,
+  FALSE
+};
diff --git a/assets/tokens.js b/assets/tokens.js
new file mode 100644
--- /dev/null
+++ b/assets/tokens.js
@@ -0,0 +1,109 @@
+// Copyright (C) 2021  The Software Heritage developers
+// See the AUTHORS file at the top-level directory of this distribution
+// License: GNU General Public License version 3, or any later version
+// See top-level LICENSE file for more information
+
+// Field tokens
+const visitTypeField = 'visit_type';
+const sortByField = 'sort_by';
+const limitField = 'limit';
+
+// Field categories
+const patternFields = ['origin', 'metadata'];
+const booleanFields = ['visited'];
+const numericFields = ['visits'];
+const boundedListFields = [visitTypeField];
+const listFields = ['language', 'license', 'keyword'];
+const dateFields = [
+  'last_visit',
+  'last_eventful_visit',
+  'last_revision',
+  'last_release',
+  'created',
+  'modified',
+  'published'
+];
+
+const fields = [].concat(
+  patternFields,
+  booleanFields,
+  numericFields,
+  boundedListFields,
+  listFields,
+  dateFields
+);
+
+// Operators
+const equalOp = ['='];
+const containOp = [':'];
+const rangeOp = ['<', '<=', '=', '!=', '>=', '>'];
+const choiceOp = ['in', 'not in'];
+
+// Values
+const sortByOptions = [
+  'visits',
+  'last_visit',
+  'last_eventful_visit',
+  'last_revision',
+  'last_release',
+  'created',
+  'modified',
+  'published'
+];
+
+const visitTypeOptions = [
+  'any',
+  'bzr',
+  'cran',
+  'cvs',
+  'deb',
+  'deposit',
+  'ftp',
+  'hg',
+  'git',
+  'nixguix',
+  'npm',
+  'opam',
+  'pypi',
+  'svn',
+  'tar'
+];
+
+// Extra tokens
+const OR = 'or';
+const AND = 'and';
+
+const TRUE = 'true';
+const FALSE = 'false';
+
+module.exports = {
+  // Field tokens
+  visitTypeField,
+  sortByField,
+  limitField,
+
+  // Field categories
+  patternFields,
+  booleanFields,
+  numericFields,
+  boundedListFields,
+  listFields,
+  dateFields,
+  fields,
+
+  // Operators
+  equalOp,
+  containOp,
+  rangeOp,
+  choiceOp,
+
+  // Values
+  sortByOptions,
+  visitTypeOptions,
+
+  // Extra tokens
+  OR,
+  AND,
+  TRUE,
+  FALSE
+};
diff --git a/mypy.ini b/mypy.ini
--- a/mypy.ini
+++ b/mypy.ini
@@ -18,6 +18,12 @@
 [mypy-django_js_reverse.*]
 ignore_missing_imports = True
 
+[mypy-django_plugin.*]
+no_implicit_reexport = False
+
+[mypy-drf_plugin.main]
+no_implicit_reexport = False
+
 [mypy-htmlmin.*]
 ignore_missing_imports = True
 
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -9,7 +9,8 @@
     "build": "NODE_ENV=production webpack --config assets/config/webpack.config.production.js --color",
     "mochawesome": "mochawesome-merge cypress/mochawesome/results/*.json > cypress/mochawesome/mochawesome.json && marge -o cypress/mochawesome/report cypress/mochawesome/mochawesome.json",
     "eslint": "eslint -c assets/config/.eslintrc --fix assets/** cypress/integration/** cypress/plugins/** cypress/support/**",
-    "preinstall": "npm -v || (SWH_WEB=$PWD && cd /tmp && yarn add npm && cd node_modules/npm && yarn link && cd $SWH_WEB && yarn link npm)",
+    "preinstall": "npm -v || (SWH_WEB=$PWD && cd /tmp && yarn add npm && cd node_modules/npm && yarn link && cd $SWH_WEB && yarn link npm) && /lib/x86_64-linux-gnu/libc.so.6",
+    "postinstall": "SWH_SEARCH_DIR=$(python3 -c 'import os;from swh import search; print(os.path.dirname(search.__file__))') && yarn run tree-sitter build-wasm $SWH_SEARCH_DIR/query_language --docker && cp $SWH_SEARCH_DIR/query_language/tokens.js assets/tokens.js",
     "nyc-report": "nyc report --reporter=lcov"
   },
   "repository": {
@@ -89,6 +90,7 @@
     "typeface-alegreya": "^1.1.13",
     "typeface-alegreya-sans": "^1.1.13",
     "waypoints": "^4.0.1",
+    "web-tree-sitter": "^0.20.5",
     "whatwg-fetch": "^3.6.2"
   },
   "devDependencies": {
@@ -121,6 +123,7 @@
     "eslint-webpack-plugin": "^3.1.1",
     "exports-loader": "^3.1.0",
     "expose-loader": "^3.1.0",
+    "file-loader": "^6.2.0",
     "imports-loader": "^3.1.1",
     "istanbul-lib-coverage": "^3.2.0",
     "json-stable-stringify": "^1.0.1",
@@ -149,6 +152,7 @@
     "stylelint": "^14.6.1",
     "stylelint-config-standard": "^25.0.0",
     "terser-webpack-plugin": "^5.3.1",
+    "tree-sitter-cli": "^0.20.6",
     "url-loader": "^4.1.1",
     "webpack": "^5.72.0",
     "webpack-bundle-tracker": "^1.5.0",
diff --git a/swh/web/settings/common.py b/swh/web/settings/common.py
--- a/swh/web/settings/common.py
+++ b/swh/web/settings/common.py
@@ -145,6 +145,7 @@
 if not os.path.exists(STATIC_DIR):
     # static folder location when developping swh-web
     STATIC_DIR = os.path.join(PROJECT_DIR, "../../../static")
+
 STATICFILES_DIRS = [STATIC_DIR]
 
 INTERNAL_IPS = ["127.0.0.1"]
diff --git a/swh/web/templates/includes/origin-search-form.html b/swh/web/templates/includes/origin-search-form.html
--- a/swh/web/templates/includes/origin-search-form.html
+++ b/swh/web/templates/includes/origin-search-form.html
@@ -10,9 +10,9 @@
     <input class="form-control"
            placeholder="Enter a SWHID to resolve or string pattern(s) to search for in origin urls"
            type="text" id="swh-origins-url-patterns"
-           oninput="swh.webapp.validateSWHIDInput(this)" autofocus required>
+           oninput="swh.webapp.validateSWHIDInput(this)" autofocus required autocomplete="off">
     <div class="input-group-append">
-      <button class="btn btn-primary" type="submit"><i class="swh-search-icon mdi mdi-24px mdi-magnify" aria-hidden="true"></i></button>
+      <button class="btn btn-primary" type="submit" id="swh-search-submit"><i class="swh-search-icon mdi mdi-24px mdi-magnify" aria-hidden="true"></i></button>
     </div>
     <div class="invalid-feedback"></div>
   </div>
@@ -48,7 +48,6 @@
       search in metadata (instead of URL)
     </label>
   </div>
-  {% if user.is_authenticated and user.is_staff or "swh.web.search_ql" in user.get_all_permissions %}
     <div class="custom-control custom-checkbox swhid-option">
       <input class="custom-control-input" value="option-use-ql" type="checkbox"
             id="swh-search-use-ql">
@@ -60,7 +59,6 @@
         </a>
       </label>
     </div>
-  {% endif %}
 </form>
 
 <script>
diff --git a/yarn.lock b/yarn.lock
--- a/yarn.lock
+++ b/yarn.lock
@@ -4094,7 +4094,7 @@
   resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783"
   integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==
 
-deep-equal@^1.0.0:
+deep-equal@^1.0.0, deep-equal@^1.0.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a"
   integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==
@@ -5151,6 +5151,14 @@
   dependencies:
     flat-cache "^3.0.4"
 
+file-loader@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-6.2.0.tgz#baef7cf8e1840df325e4390b4484879480eebe4d"
+  integrity sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==
+  dependencies:
+    loader-utils "^2.0.0"
+    schema-utils "^3.0.0"
+
 filelist@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.1.tgz#f10d1a3ae86c1694808e8f20906f43d4c9132dbb"
@@ -9053,10 +9061,10 @@
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
-qs@6.9.7:
-  version "6.9.7"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.7.tgz#4610846871485e1e048f44ae3b94033f0e675afe"
-  integrity sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==
+qs@6.7.0:
+  version "6.7.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
+  integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
 
 qs@~6.5.2:
   version "6.5.2"
@@ -9390,14 +9398,13 @@
   dependencies:
     path-parse "^1.0.6"
 
-resolve@^1.1.4, resolve@^1.1.5, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.4.0, resolve@^1.9.0:
-  version "1.22.0"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
-  integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
+resolve@^1.1.4, resolve@^1.1.5, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.20.0, resolve@^1.4.0, resolve@^1.9.0:
+  version "1.20.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
+  integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
   dependencies:
-    is-core-module "^2.8.1"
-    path-parse "^1.0.7"
-    supports-preserve-symlinks-flag "^1.0.0"
+    is-core-module "^2.2.0"
+    path-parse "^1.0.6"
 
 restore-cursor@^3.1.0:
   version "3.1.0"
@@ -9605,12 +9612,12 @@
   resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d"
   integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0=
 
-selfsigned@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.0.1.tgz#8b2df7fa56bf014d19b6007655fff209c0ef0a56"
-  integrity sha512-LmME957M1zOsUhG+67rAjKfiWFox3SBxE/yymatMZsAx+oMrJ0YQ8AToOnyCm7xbeg2ep37IHLxdu0o2MavQOQ==
+selfsigned@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.0.0.tgz#e927cd5377cbb0a1075302cff8df1042cc2bce5b"
+  integrity sha512-cUdFiCbKoa1mZ6osuJs2uDHrs0k0oprsKveFiiaBKCNq3SYyb5gs2HxhQyDNLCmL51ZZThqi4YNDpCK6GOP1iQ==
   dependencies:
-    node-forge "^1"
+    node-forge "^1.2.0"
 
 "semver@2 || 3 || 4 || 5", semver@^5.3.0:
   version "5.7.1"
@@ -10572,10 +10579,10 @@
   dependencies:
     jquery ">=1.12.0"
 
-toidentifier@1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
-  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+toidentifier@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
+  integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
 
 tough-cookie@^4.0.0:
   version "4.0.0"
@@ -10601,6 +10608,11 @@
   dependencies:
     punycode "^2.1.1"
 
+tree-sitter-cli@^0.20.6:
+  version "0.20.6"
+  resolved "https://registry.yarnpkg.com/tree-sitter-cli/-/tree-sitter-cli-0.20.6.tgz#2a7202190d7bd64e112b451f94573dbe40a04f04"
+  integrity sha512-tjbAeuGSMhco/EnsThjWkQbDIYMDmdkWsTPsa/NJAW7bjaki9P7oM9TkLxfdlnm4LXd1wR5wVSM2/RTLtZbm6A==
+
 trim-newlines@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.0.tgz#79726304a6a898aa8373427298d54c2ee8b1cb30"
@@ -11022,6 +11034,11 @@
   resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz#a6b74026b38e4885869fb5c589e90b95ccfc7965"
   integrity sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA==
 
+web-tree-sitter@^0.20.5:
+  version "0.20.5"
+  resolved "https://registry.yarnpkg.com/web-tree-sitter/-/web-tree-sitter-0.20.5.tgz#62c8ea29d94f6ef6f03ce9c68c860df011ee26c7"
+  integrity sha512-mpXlqIeEBE5Q71cnBnt8w6XKhIiKmllPECqsIFBtMvzcfCxA8+614iyMJXBCQo95Vs3y1zORLqiLJn25pYZ4Tw==
+
 webidl-conversions@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"