From bcb7ca796676864ace8eb83c0acbd0a6b9b8d924 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 16:27:24 +0200 Subject: [PATCH 01/17] add peggy to package.json --- package-lock.json | 63 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 4 ++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index c401dfe77198..6051fd40ebac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -233,6 +233,7 @@ "memfs": "^4.6.0", "onchange": "^7.1.0", "patch-package": "^8.0.0", + "peggy": "^4.0.3", "portfinder": "^1.0.28", "prettier": "^2.8.8", "pusher-js-mock": "^0.3.3", @@ -7879,6 +7880,33 @@ "react-native": ">=0.70.0 <1.0.x" } }, + "node_modules/@peggyjs/from-mem": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@peggyjs/from-mem/-/from-mem-1.3.0.tgz", + "integrity": "sha512-kzGoIRJjkg3KuGI4bopz9UvF3KguzfxalHRDEIdqEZUe45xezsQ6cx30e0RKuxPUexojQRBfu89Okn7f4/QXsw==", + "dev": true, + "dependencies": { + "semver": "7.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@peggyjs/from-mem/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@perf-profiler/android": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/@perf-profiler/android/-/android-0.12.1.tgz", @@ -35858,6 +35886,32 @@ "through2": "^2.0.3" } }, + "node_modules/peggy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/peggy/-/peggy-4.0.3.tgz", + "integrity": "sha512-v7/Pt6kGYsfXsCrfb52q7/yg5jaAwiVaUMAPLPvy4DJJU6Wwr72t6nDIqIDkGfzd1B4zeVuTnQT0RGeOhe/uSA==", + "dev": true, + "dependencies": { + "@peggyjs/from-mem": "1.3.0", + "commander": "^12.1.0", + "source-map-generator": "0.8.0" + }, + "bin": { + "peggy": "bin/peggy.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/peggy/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/pend": { "version": "1.2.0", "dev": true, @@ -40226,6 +40280,15 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-generator": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map-generator/-/source-map-generator-0.8.0.tgz", + "integrity": "sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "license": "BSD-3-Clause", diff --git a/package.json b/package.json index 5420a3e886ef..19c4c30fdee1 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,8 @@ "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.ts", "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1", "e2e-test-runner-build": "ncc build tests/e2e/testRunner.ts -o tests/e2e/dist/", - "react-compiler-healthcheck": "react-compiler-healthcheck --verbose" + "react-compiler-healthcheck": "react-compiler-healthcheck --verbose", + "generate-search-parser": "peggy --format es -o src/libs/Search/searchParser.js src/libs/Search/searchParser.peggy " }, "dependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", @@ -287,6 +288,7 @@ "memfs": "^4.6.0", "onchange": "^7.1.0", "patch-package": "^8.0.0", + "peggy": "^4.0.3", "portfinder": "^1.0.28", "prettier": "^2.8.8", "pusher-js-mock": "^0.3.3", From 885b47bc9481cf717dc88ff307adfb1ee681a651 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 16:27:54 +0200 Subject: [PATCH 02/17] add search parser grammar --- src/libs/Search/searchParser.peggy | 87 ++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/libs/Search/searchParser.peggy diff --git a/src/libs/Search/searchParser.peggy b/src/libs/Search/searchParser.peggy new file mode 100644 index 000000000000..0bbf98394e9b --- /dev/null +++ b/src/libs/Search/searchParser.peggy @@ -0,0 +1,87 @@ +// src/libs/Search/searchParser.peggy +{ + const defaultValues = { + "type": "expense", + "status": "all", + "sortBy": "date", + "sortOrder": "desc", + "offset": 0 + }; + + function buildFilter(operator, left, right) { + return { operator, left, right }; + } + + function applyDefaults(filters) { + return { + ...defaultValues, + filters + }; + } +} + +query + = _ filters:filterList { return applyDefaults(filters); } + +filterList + = head:filter tail:(filter logicalAnd)* { + return tail.reduce((result, [right, op]) => buildFilter(op, result, right), head); + } + +filter + = _ field:key? _ op:operator? _ value:identifier { + if (!field && !op) { + return buildFilter('eq', 'freeText', value.trim()); + } else { + const values = value.split(','); + return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter('eq', field, val.trim())), buildFilter('eq', field, values[0])); + } + } + +operator + = (":" / "=") { return "eq"; } + / "!=" { return "neq"; } + / ">" { return "gt"; } + / ">=" { return "gte"; } + / "<" { return "lt"; } + / "<=" { return "lte"; } + +key + = "type" { return "type"; } + / "status" { return "status"; } + / "date" { return "date"; } + / "amount" { return "amount"; } + / "expenseType" { return "expenseType"; } + / "in" { return "in"; } + / "currency" { return "currency"; } + / "merchant" { return "merchant"; } + / "description" { return "description"; } + / "from" { return "from"; } + / "to" { return "to"; } + / "category" { return "category"; } + / "tag" { return "tag"; } + / "taxRate" { return "taxRate"; } + / "card" { return "card"; } + / "reportID" { return "reportID"; } + / "freeText" { return "freeText"; } + / "sortBy" { return "sortBy"; } + / "sortOrder" { return "sortOrder"; } + / "offset" { return "offset"; } + +identifier + = parts:(quotedString / alphanumeric)+ { return parts.join(''); } + +quotedString + = '"' chars:[^"\r\n]* '"' { return chars.join(''); } + +alphanumeric + = chars:[A-Za-z0-9_@./#&+\-\\',]+ { return chars.join(''); } + +logicalAnd + = _ { return "and"; } + +_ "whitespace" + = [ \t\r\n]* + +start + = query \ No newline at end of file From 177824f4bcaaf207e9ada51061e8d64b78b6b76f Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 16:28:31 +0200 Subject: [PATCH 03/17] handle generating parser --- .eslintrc.js | 3 +++ .gitignore | 3 +++ scripts/postInstall.sh | 3 +++ 3 files changed, 9 insertions(+) diff --git a/.eslintrc.js b/.eslintrc.js index 198620c70b0f..0b3e2144f18f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -100,6 +100,9 @@ module.exports = { 'prettier', ], plugins: ['@typescript-eslint', 'jsdoc', 'you-dont-need-lodash-underscore', 'react-native-a11y', 'react', 'testing-library', 'eslint-plugin-react-compiler'], + + // Ignore the searchParser.js file as it's generated by the peggy parser generator. + ignores: ['src/lib/Search/searchParser.js'], ignorePatterns: ['lib/**'], parser: '@typescript-eslint/parser', parserOptions: { diff --git a/.gitignore b/.gitignore index aa6aad4cc429..713b0934db65 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,6 @@ web-build/ # Storage location for downloaded app source maps (see scripts/symbolicate-profile.ts) .sourcemaps/ + +# Generated search parser +src/libs/Search/searchParser.js \ No newline at end of file diff --git a/scripts/postInstall.sh b/scripts/postInstall.sh index 782c8ef5822c..81b47d8722e9 100755 --- a/scripts/postInstall.sh +++ b/scripts/postInstall.sh @@ -10,6 +10,9 @@ cd "$ROOT_DIR" || exit 1 # Apply packages using patch-package scripts/applyPatches.sh +# Generate the search query parser +npm run generate-search-parser + # Install node_modules in subpackages, unless we're in a CI/CD environment, # where the node_modules for subpackages are cached separately. # See `.github/actions/composite/setupNode/action.yml` for more context. From 4cd1b8b9ae393cf16291092729cab02dc42bdc47 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 16:29:26 +0200 Subject: [PATCH 04/17] create buildJSONQuery function --- src/libs/SearchUtils.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 91d742f44e62..d967cb0ec564 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -11,6 +11,8 @@ import DateUtils from './DateUtils'; import getTopmostCentralPaneRoute from './Navigation/getTopmostCentralPaneRoute'; import navigationRef from './Navigation/navigationRef'; import type {AuthScreensParamList, RootStackParamList, State} from './Navigation/types'; +// This file is generated by the npm run generate-search-parser command. +import * as searchParser from './Search/searchParser'; import * as TransactionUtils from './TransactionUtils'; import * as UserUtils from './UserUtils'; @@ -301,7 +303,26 @@ function isSearchResultsEmpty(searchResults: SearchResults) { return !Object.keys(searchResults?.data).some((key) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)); } +// This function should replace the current getQueryParams after the search v2.1 implementation. +function __getQueryHash(query: string): number { + return UserUtils.hashText(query, 2 ** 32); +} + +function buildJSONQuery(query: string) { + try { + // Add the full input and hash to the results + const result = searchParser.parse(query); + result['input'] = query; + result['hash'] = __getQueryHash(result); + return result; + } catch (e) { + // TODO: We need better error handling here. + console.log(e); + } +} + export { + buildJSONQuery, getListItem, getQueryHash, getSections, From c18d234bb665ebcab0aed98dcb61ae0b2b182fbd Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 16:35:32 +0200 Subject: [PATCH 05/17] fix new line in searchParser.peggy --- src/libs/Search/searchParser.peggy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Search/searchParser.peggy b/src/libs/Search/searchParser.peggy index 0bbf98394e9b..daf2f42b53fa 100644 --- a/src/libs/Search/searchParser.peggy +++ b/src/libs/Search/searchParser.peggy @@ -84,4 +84,4 @@ _ "whitespace" = [ \t\r\n]* start - = query \ No newline at end of file + = query From 065c8ab61d21ecf8b4c9f86fd761d74e19611ebc Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 16:49:09 +0200 Subject: [PATCH 06/17] fix eslint --- .eslintrc.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 0b3e2144f18f..dd69e412d635 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -102,8 +102,7 @@ module.exports = { plugins: ['@typescript-eslint', 'jsdoc', 'you-dont-need-lodash-underscore', 'react-native-a11y', 'react', 'testing-library', 'eslint-plugin-react-compiler'], // Ignore the searchParser.js file as it's generated by the peggy parser generator. - ignores: ['src/lib/Search/searchParser.js'], - ignorePatterns: ['lib/**'], + ignorePatterns: ['lib/**', 'src/lib/Search/searchParser.js'], parser: '@typescript-eslint/parser', parserOptions: { project: path.resolve(__dirname, './tsconfig.json'), From 8804112b506aa93312ea191589ce01236741e2a4 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 16:51:03 +0200 Subject: [PATCH 07/17] fix new line in .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 713b0934db65..72cabc8aad04 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,4 @@ web-build/ .sourcemaps/ # Generated search parser -src/libs/Search/searchParser.js \ No newline at end of file +src/libs/Search/searchParser.js From f89daf4f33c5deb145a4b554b415377b1a395c27 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 17:48:51 +0200 Subject: [PATCH 08/17] fix eslint --- .eslintignore | 1 + .eslintrc.js | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.eslintignore b/.eslintignore index aa8b769dfede..5643c0dc3d2a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -10,3 +10,4 @@ docs/vendor/** docs/assets/** web/gtm.js **/.expo/** +src/libs/Search/searchParser.js diff --git a/.eslintrc.js b/.eslintrc.js index dd69e412d635..198620c70b0f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -100,9 +100,7 @@ module.exports = { 'prettier', ], plugins: ['@typescript-eslint', 'jsdoc', 'you-dont-need-lodash-underscore', 'react-native-a11y', 'react', 'testing-library', 'eslint-plugin-react-compiler'], - - // Ignore the searchParser.js file as it's generated by the peggy parser generator. - ignorePatterns: ['lib/**', 'src/lib/Search/searchParser.js'], + ignorePatterns: ['lib/**'], parser: '@typescript-eslint/parser', parserOptions: { project: path.resolve(__dirname, './tsconfig.json'), From 73bffd6adf5bd717ae1ba23ad96ed2e324c4de8c Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 17:49:16 +0200 Subject: [PATCH 09/17] add type for result in SearchUtils --- src/libs/SearchUtils.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index d967cb0ec564..6ddcac9263ce 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -12,6 +12,7 @@ import getTopmostCentralPaneRoute from './Navigation/getTopmostCentralPaneRoute' import navigationRef from './Navigation/navigationRef'; import type {AuthScreensParamList, RootStackParamList, State} from './Navigation/types'; // This file is generated by the npm run generate-search-parser command. +// import * as searchParser from './Search/searchParser'; import * as TransactionUtils from './TransactionUtils'; import * as UserUtils from './UserUtils'; @@ -304,20 +305,25 @@ function isSearchResultsEmpty(searchResults: SearchResults) { } // This function should replace the current getQueryParams after the search v2.1 implementation. -function __getQueryHash(query: string): number { +function getQueryHashFromString(query: string): number { return UserUtils.hashText(query, 2 ** 32); } +type JSONQuery = { + input: string; + hash: number; +}; + function buildJSONQuery(query: string) { try { // Add the full input and hash to the results - const result = searchParser.parse(query); - result['input'] = query; - result['hash'] = __getQueryHash(result); + const result = searchParser.parse(query) as JSONQuery; + result.input = query; + result.hash = getQueryHashFromString(query); return result; } catch (e) { // TODO: We need better error handling here. - console.log(e); + console.error(e); } } From 3aabbc662fdb0635885f0c4f02d89f753def7eb2 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 19:01:57 +0200 Subject: [PATCH 10/17] add searchParser.js to repo --- .gitignore | 3 - scripts/postInstall.sh | 3 - src/libs/Search/searchParser.js | 1143 +++++++++++++++++++++++++++++++ 3 files changed, 1143 insertions(+), 6 deletions(-) create mode 100644 src/libs/Search/searchParser.js diff --git a/.gitignore b/.gitignore index 72cabc8aad04..aa6aad4cc429 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,3 @@ web-build/ # Storage location for downloaded app source maps (see scripts/symbolicate-profile.ts) .sourcemaps/ - -# Generated search parser -src/libs/Search/searchParser.js diff --git a/scripts/postInstall.sh b/scripts/postInstall.sh index 81b47d8722e9..782c8ef5822c 100755 --- a/scripts/postInstall.sh +++ b/scripts/postInstall.sh @@ -10,9 +10,6 @@ cd "$ROOT_DIR" || exit 1 # Apply packages using patch-package scripts/applyPatches.sh -# Generate the search query parser -npm run generate-search-parser - # Install node_modules in subpackages, unless we're in a CI/CD environment, # where the node_modules for subpackages are cached separately. # See `.github/actions/composite/setupNode/action.yml` for more context. diff --git a/src/libs/Search/searchParser.js b/src/libs/Search/searchParser.js new file mode 100644 index 000000000000..a7dbfa8abe7d --- /dev/null +++ b/src/libs/Search/searchParser.js @@ -0,0 +1,1143 @@ +// @generated by Peggy 4.0.3. +// +// https://peggyjs.org/ + + +function peg$subclass(child, parent) { + function C() { this.constructor = child; } + C.prototype = parent.prototype; + child.prototype = new C(); +} + +function peg$SyntaxError(message, expected, found, location) { + var self = Error.call(this, message); + // istanbul ignore next Check is a necessary evil to support older environments + if (Object.setPrototypeOf) { + Object.setPrototypeOf(self, peg$SyntaxError.prototype); + } + self.expected = expected; + self.found = found; + self.location = location; + self.name = "SyntaxError"; + return self; +} + +peg$subclass(peg$SyntaxError, Error); + +function peg$padEnd(str, targetLength, padString) { + padString = padString || " "; + if (str.length > targetLength) { return str; } + targetLength -= str.length; + padString += padString.repeat(targetLength); + return str + padString.slice(0, targetLength); +} + +peg$SyntaxError.prototype.format = function(sources) { + var str = "Error: " + this.message; + if (this.location) { + var src = null; + var k; + for (k = 0; k < sources.length; k++) { + if (sources[k].source === this.location.source) { + src = sources[k].text.split(/\r\n|\n|\r/g); + break; + } + } + var s = this.location.start; + var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + var e = this.location.end; + var filler = peg$padEnd("", offset_s.line.toString().length, ' '); + var line = src[s.line - 1]; + var last = s.line === e.line ? e.column : line.length + 1; + var hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + peg$padEnd("", s.column - 1, ' ') + + peg$padEnd("", hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; +}; + +peg$SyntaxError.buildMessage = function(expected, found) { + var DESCRIBE_EXPECTATION_FNS = { + literal: function(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + class: function(expectation) { + var escapedParts = expectation.parts.map(function(part) { + return Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part); + }); + + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; + }, + + any: function() { + return "any character"; + }, + + end: function() { + return "end of input"; + }, + + other: function(expectation) { + return expectation.description; + } + }; + + function hex(ch) { + return ch.charCodeAt(0).toString(16).toUpperCase(); + } + + function literalEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } + + function classEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + var descriptions = expected.map(describeExpectation); + var i, j; + + descriptions.sort(); + + if (descriptions.length > 0) { + for (i = 1, j = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } + + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; + } + } + + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + } + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; +}; + +function peg$parse(input, options) { + options = options !== undefined ? options : {}; + + var peg$FAILED = {}; + var peg$source = options.grammarSource; + + var peg$startRuleFunctions = { query: peg$parsequery }; + var peg$startRuleFunction = peg$parsequery; + + var peg$c0 = "!="; + var peg$c1 = ">"; + var peg$c2 = ">="; + var peg$c3 = "<"; + var peg$c4 = "<="; + var peg$c5 = "type"; + var peg$c6 = "status"; + var peg$c7 = "date"; + var peg$c8 = "amount"; + var peg$c9 = "expenseType"; + var peg$c10 = "in"; + var peg$c11 = "currency"; + var peg$c12 = "merchant"; + var peg$c13 = "description"; + var peg$c14 = "from"; + var peg$c15 = "to"; + var peg$c16 = "category"; + var peg$c17 = "tag"; + var peg$c18 = "taxRate"; + var peg$c19 = "card"; + var peg$c20 = "reportID"; + var peg$c21 = "freeText"; + var peg$c22 = "sortBy"; + var peg$c23 = "sortOrder"; + var peg$c24 = "offset"; + var peg$c25 = "\""; + + var peg$r0 = /^[:=]/; + var peg$r1 = /^[^"\r\n]/; + var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',]/; + var peg$r3 = /^[ \t\r\n]/; + + var peg$e0 = peg$classExpectation([":", "="], false, false); + var peg$e1 = peg$literalExpectation("!=", false); + var peg$e2 = peg$literalExpectation(">", false); + var peg$e3 = peg$literalExpectation(">=", false); + var peg$e4 = peg$literalExpectation("<", false); + var peg$e5 = peg$literalExpectation("<=", false); + var peg$e6 = peg$literalExpectation("type", false); + var peg$e7 = peg$literalExpectation("status", false); + var peg$e8 = peg$literalExpectation("date", false); + var peg$e9 = peg$literalExpectation("amount", false); + var peg$e10 = peg$literalExpectation("expenseType", false); + var peg$e11 = peg$literalExpectation("in", false); + var peg$e12 = peg$literalExpectation("currency", false); + var peg$e13 = peg$literalExpectation("merchant", false); + var peg$e14 = peg$literalExpectation("description", false); + var peg$e15 = peg$literalExpectation("from", false); + var peg$e16 = peg$literalExpectation("to", false); + var peg$e17 = peg$literalExpectation("category", false); + var peg$e18 = peg$literalExpectation("tag", false); + var peg$e19 = peg$literalExpectation("taxRate", false); + var peg$e20 = peg$literalExpectation("card", false); + var peg$e21 = peg$literalExpectation("reportID", false); + var peg$e22 = peg$literalExpectation("freeText", false); + var peg$e23 = peg$literalExpectation("sortBy", false); + var peg$e24 = peg$literalExpectation("sortOrder", false); + var peg$e25 = peg$literalExpectation("offset", false); + var peg$e26 = peg$literalExpectation("\"", false); + var peg$e27 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e28 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ","], false, false); + var peg$e29 = peg$otherExpectation("whitespace"); + var peg$e30 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); + + var peg$f0 = function(filters) { return applyDefaults(filters); }; + var peg$f1 = function(head, tail) { + return tail.reduce((result, [right, op]) => buildFilter(op, result, right), head); + }; + var peg$f2 = function(field, op, value) { + if (!field && !op) { + return buildFilter('eq', 'freeText', value.trim()); + } else { + const values = value.split(','); + return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter('eq', field, val.trim())), buildFilter('eq', field, values[0])); + } + }; + var peg$f3 = function() { return "eq"; }; + var peg$f4 = function() { return "neq"; }; + var peg$f5 = function() { return "gt"; }; + var peg$f6 = function() { return "gte"; }; + var peg$f7 = function() { return "lt"; }; + var peg$f8 = function() { return "lte"; }; + var peg$f9 = function() { return "type"; }; + var peg$f10 = function() { return "status"; }; + var peg$f11 = function() { return "date"; }; + var peg$f12 = function() { return "amount"; }; + var peg$f13 = function() { return "expenseType"; }; + var peg$f14 = function() { return "in"; }; + var peg$f15 = function() { return "currency"; }; + var peg$f16 = function() { return "merchant"; }; + var peg$f17 = function() { return "description"; }; + var peg$f18 = function() { return "from"; }; + var peg$f19 = function() { return "to"; }; + var peg$f20 = function() { return "category"; }; + var peg$f21 = function() { return "tag"; }; + var peg$f22 = function() { return "taxRate"; }; + var peg$f23 = function() { return "card"; }; + var peg$f24 = function() { return "reportID"; }; + var peg$f25 = function() { return "freeText"; }; + var peg$f26 = function() { return "sortBy"; }; + var peg$f27 = function() { return "sortOrder"; }; + var peg$f28 = function() { return "offset"; }; + var peg$f29 = function(parts) { return parts.join(''); }; + var peg$f30 = function(chars) { return chars.join(''); }; + var peg$f31 = function(chars) { return chars.join(''); }; + var peg$f32 = function() { return "and"; }; + var peg$currPos = options.peg$currPos | 0; + var peg$savedPos = peg$currPos; + var peg$posDetailsCache = [{ line: 1, column: 1 }]; + var peg$maxFailPos = peg$currPos; + var peg$maxFailExpected = options.peg$maxFailExpected || []; + var peg$silentFails = options.peg$silentFails | 0; + + var peg$result; + + if (options.startRule) { + if (!(options.startRule in peg$startRuleFunctions)) { + throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); + } + + peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; + } + + function text() { + return input.substring(peg$savedPos, peg$currPos); + } + + function offset() { + return peg$savedPos; + } + + function range() { + return { + source: peg$source, + start: peg$savedPos, + end: peg$currPos + }; + } + + function location() { + return peg$computeLocation(peg$savedPos, peg$currPos); + } + + function expected(description, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildStructuredError( + [peg$otherExpectation(description)], + input.substring(peg$savedPos, peg$currPos), + location + ); + } + + function error(message, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildSimpleError(message, location); + } + + function peg$literalExpectation(text, ignoreCase) { + return { type: "literal", text: text, ignoreCase: ignoreCase }; + } + + function peg$classExpectation(parts, inverted, ignoreCase) { + return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; + } + + function peg$anyExpectation() { + return { type: "any" }; + } + + function peg$endExpectation() { + return { type: "end" }; + } + + function peg$otherExpectation(description) { + return { type: "other", description: description }; + } + + function peg$computePosDetails(pos) { + var details = peg$posDetailsCache[pos]; + var p; + + if (details) { + return details; + } else { + if (pos >= peg$posDetailsCache.length) { + p = peg$posDetailsCache.length - 1; + } else { + p = pos; + while (!peg$posDetailsCache[--p]) {} + } + + details = peg$posDetailsCache[p]; + details = { + line: details.line, + column: details.column + }; + + while (p < pos) { + if (input.charCodeAt(p) === 10) { + details.line++; + details.column = 1; + } else { + details.column++; + } + + p++; + } + + peg$posDetailsCache[pos] = details; + + return details; + } + } + + function peg$computeLocation(startPos, endPos, offset) { + var startPosDetails = peg$computePosDetails(startPos); + var endPosDetails = peg$computePosDetails(endPos); + + var res = { + source: peg$source, + start: { + offset: startPos, + line: startPosDetails.line, + column: startPosDetails.column + }, + end: { + offset: endPos, + line: endPosDetails.line, + column: endPosDetails.column + } + }; + if (offset && peg$source && (typeof peg$source.offset === "function")) { + res.start = peg$source.offset(res.start); + res.end = peg$source.offset(res.end); + } + return res; + } + + function peg$fail(expected) { + if (peg$currPos < peg$maxFailPos) { return; } + + if (peg$currPos > peg$maxFailPos) { + peg$maxFailPos = peg$currPos; + peg$maxFailExpected = []; + } + + peg$maxFailExpected.push(expected); + } + + function peg$buildSimpleError(message, location) { + return new peg$SyntaxError(message, null, null, location); + } + + function peg$buildStructuredError(expected, found, location) { + return new peg$SyntaxError( + peg$SyntaxError.buildMessage(expected, found), + expected, + found, + location + ); + } + + function peg$parsequery() { + var s0, s1, s2; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parsefilterList(); + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f0(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsefilterList() { + var s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$parsefilter(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$parsefilter(); + if (s4 !== peg$FAILED) { + s5 = peg$parselogicalAnd(); + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$parsefilter(); + if (s4 !== peg$FAILED) { + s5 = peg$parselogicalAnd(); + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f1(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsefilter() { + var s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parsekey(); + if (s2 === peg$FAILED) { + s2 = null; + } + s3 = peg$parse_(); + s4 = peg$parseoperator(); + if (s4 === peg$FAILED) { + s4 = null; + } + s5 = peg$parse_(); + s6 = peg$parseidentifier(); + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f2(s2, s4, s6); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseoperator() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.charAt(peg$currPos); + if (peg$r0.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f3(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c0) { + s1 = peg$c0; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f4(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 62) { + s1 = peg$c1; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f5(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c2) { + s1 = peg$c2; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f6(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c3; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f7(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c4) { + s1 = peg$c4; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f8(); + } + s0 = s1; + } + } + } + } + } + + return s0; + } + + function peg$parsekey() { + var s0, s1; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c5) { + s1 = peg$c5; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e6); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f9(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 6) === peg$c6) { + s1 = peg$c6; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f10(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c7) { + s1 = peg$c7; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f11(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 6) === peg$c8) { + s1 = peg$c8; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e9); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f12(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 11) === peg$c9) { + s1 = peg$c9; + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e10); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f13(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c10) { + s1 = peg$c10; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e11); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f14(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c11) { + s1 = peg$c11; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f15(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c12) { + s1 = peg$c12; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e13); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f16(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 11) === peg$c13) { + s1 = peg$c13; + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e14); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f17(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c14) { + s1 = peg$c14; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e15); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f18(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c15) { + s1 = peg$c15; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e16); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f19(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c16) { + s1 = peg$c16; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e17); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f20(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 3) === peg$c17) { + s1 = peg$c17; + peg$currPos += 3; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e18); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f21(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 7) === peg$c18) { + s1 = peg$c18; + peg$currPos += 7; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e19); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f22(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c19) { + s1 = peg$c19; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e20); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f23(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c20) { + s1 = peg$c20; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e21); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f24(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c21) { + s1 = peg$c21; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e22); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f25(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 6) === peg$c22) { + s1 = peg$c22; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e23); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f26(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 9) === peg$c23) { + s1 = peg$c23; + peg$currPos += 9; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e24); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f27(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 6) === peg$c24) { + s1 = peg$c24; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e25); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f28(); + } + s0 = s1; + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + + return s0; + } + + function peg$parseidentifier() { + var s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = peg$parsequotedString(); + if (s2 === peg$FAILED) { + s2 = peg$parsealphanumeric(); + } + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = peg$parsequotedString(); + if (s2 === peg$FAILED) { + s2 = peg$parsealphanumeric(); + } + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f29(s1); + } + s0 = s1; + + return s0; + } + + function peg$parsequotedString() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 34) { + s1 = peg$c25; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e26); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = input.charAt(peg$currPos); + if (peg$r1.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e27); } + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = input.charAt(peg$currPos); + if (peg$r1.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e27); } + } + } + if (input.charCodeAt(peg$currPos) === 34) { + s3 = peg$c25; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e26); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f30(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsealphanumeric() { + var s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = input.charAt(peg$currPos); + if (peg$r2.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e28); } + } + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = input.charAt(peg$currPos); + if (peg$r2.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e28); } + } + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f31(s1); + } + s0 = s1; + + return s0; + } + + function peg$parselogicalAnd() { + var s0, s1; + + s0 = peg$currPos; + s1 = peg$parse_(); + peg$savedPos = s0; + s1 = peg$f32(); + s0 = s1; + + return s0; + } + + function peg$parse_() { + var s0, s1; + + peg$silentFails++; + s0 = []; + s1 = input.charAt(peg$currPos); + if (peg$r3.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e30); } + } + while (s1 !== peg$FAILED) { + s0.push(s1); + s1 = input.charAt(peg$currPos); + if (peg$r3.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e30); } + } + } + peg$silentFails--; + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e29); } + + return s0; + } + + + const defaultValues = { + "type": "expense", + "status": "all", + "sortBy": "date", + "sortOrder": "desc", + "offset": 0 + }; + + function buildFilter(operator, left, right) { + return { operator, left, right }; + } + + function applyDefaults(filters) { + return { + ...defaultValues, + filters + }; + } + + peg$result = peg$startRuleFunction(); + + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos + }); + } + if (peg$result !== peg$FAILED && peg$currPos === input.length) { + return peg$result; + } else { + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); + } +} + +const peg$allowedStartRules = [ + "query" +]; + +export { + peg$allowedStartRules as StartRules, + peg$SyntaxError as SyntaxError, + peg$parse as parse +}; From 3821ecc515ea20298fb0922cda69b603a6003dde Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 19:08:24 +0200 Subject: [PATCH 11/17] update grammar for searchParser --- src/libs/Search/searchParser.peggy | 33 ++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/libs/Search/searchParser.peggy b/src/libs/Search/searchParser.peggy index daf2f42b53fa..26561c679d8a 100644 --- a/src/libs/Search/searchParser.peggy +++ b/src/libs/Search/searchParser.peggy @@ -18,24 +18,41 @@ filters }; } + + function updateDefaultValues(field, value) { + defaultValues[field] = value; + } + + function isDefaultField(field) { + return defaultValues.hasOwnProperty(field); + } } query - = _ filters:filterList { return applyDefaults(filters); } + = _ filters:filterList? _ { return applyDefaults(filters); } filterList - = head:filter tail:(filter logicalAnd)* { - return tail.reduce((result, [right, op]) => buildFilter(op, result, right), head); + = head:filter tail:(logicalAnd filter)* { + const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter(filter => filter !== null); + if (!allFilters.length) { + return null; + } + return allFilters.reduce((result, filter) => buildFilter("and", result, filter)); } filter = _ field:key? _ op:operator? _ value:identifier { + if (isDefaultField(field)) { + updateDefaultValues(field, value.trim()); + return null; + } + if (!field && !op) { return buildFilter('eq', 'freeText', value.trim()); - } else { - const values = value.split(','); - return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter('eq', field, val.trim())), buildFilter('eq', field, values[0])); } + + const values = value.split(','); + return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter('eq', field, val.trim())), buildFilter('eq', field, values[0])); } operator @@ -67,10 +84,10 @@ key / "sortBy" { return "sortBy"; } / "sortOrder" { return "sortOrder"; } / "offset" { return "offset"; } - + identifier = parts:(quotedString / alphanumeric)+ { return parts.join(''); } - + quotedString = '"' chars:[^"\r\n]* '"' { return chars.join(''); } From 112752740c31076fbcd10ba682e7f504fd2922ce Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 19:26:24 +0200 Subject: [PATCH 12/17] add generated parser to prettier ignore --- .prettierignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.prettierignore b/.prettierignore index 09de20ba30b0..8e5d5f971819 100644 --- a/.prettierignore +++ b/.prettierignore @@ -19,3 +19,6 @@ package-lock.json src/libs/E2E/reactNativeLaunchingTest.ts # Temporary while we keep react-compiler in our repo lib/** + +# Automatically generated file +src/libs/Search/searchParser.js \ No newline at end of file From 05c054817df82f1185f3d531881a1eda27058dec Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 10 Jul 2024 19:38:21 +0200 Subject: [PATCH 13/17] add new line in prettier ignore --- .prettierignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.prettierignore b/.prettierignore index 8e5d5f971819..b11fa4f762ef 100644 --- a/.prettierignore +++ b/.prettierignore @@ -21,4 +21,4 @@ src/libs/E2E/reactNativeLaunchingTest.ts lib/** # Automatically generated file -src/libs/Search/searchParser.js \ No newline at end of file +src/libs/Search/searchParser.js From cd6abeff9c12de6683681647590d5b3663fff209 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Thu, 11 Jul 2024 13:59:36 +0200 Subject: [PATCH 14/17] Add fixes to Search parser peggy grammar --- src/libs/Search/searchParser.js | 64 +++++++++++++++++++----------- src/libs/Search/searchParser.peggy | 26 ++++++++++-- src/libs/SearchUtils.ts | 4 -- 3 files changed, 63 insertions(+), 31 deletions(-) diff --git a/src/libs/Search/searchParser.js b/src/libs/Search/searchParser.js index a7dbfa8abe7d..28143edb40f7 100644 --- a/src/libs/Search/searchParser.js +++ b/src/libs/Search/searchParser.js @@ -196,7 +196,7 @@ function peg$parse(input, options) { var peg$c18 = "taxRate"; var peg$c19 = "card"; var peg$c20 = "reportID"; - var peg$c21 = "freeText"; + var peg$c21 = "keyword"; var peg$c22 = "sortBy"; var peg$c23 = "sortOrder"; var peg$c24 = "offset"; @@ -229,7 +229,7 @@ function peg$parse(input, options) { var peg$e19 = peg$literalExpectation("taxRate", false); var peg$e20 = peg$literalExpectation("card", false); var peg$e21 = peg$literalExpectation("reportID", false); - var peg$e22 = peg$literalExpectation("freeText", false); + var peg$e22 = peg$literalExpectation("keyword", false); var peg$e23 = peg$literalExpectation("sortBy", false); var peg$e24 = peg$literalExpectation("sortOrder", false); var peg$e25 = peg$literalExpectation("offset", false); @@ -241,15 +241,26 @@ function peg$parse(input, options) { var peg$f0 = function(filters) { return applyDefaults(filters); }; var peg$f1 = function(head, tail) { - return tail.reduce((result, [right, op]) => buildFilter(op, result, right), head); + const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter(filter => filter !== null); + if (!allFilters.length) { + return null; + } + return allFilters.reduce((result, filter) => buildFilter("and", result, filter)); }; var peg$f2 = function(field, op, value) { + if (isDefaultField(field)) { + updateDefaultValues(field, value.trim()); + return null; + } + if (!field && !op) { - return buildFilter('eq', 'freeText', value.trim()); - } else { - const values = value.split(','); - return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter('eq', field, val.trim())), buildFilter('eq', field, values[0])); + return buildFilter('eq', 'keyword', value.trim()); } + + const values = value.split(','); + const operatorValue = op ?? 'eq'; + + return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter(operatorValue, field, val.trim())), buildFilter(operatorValue, field, values[0])); }; var peg$f3 = function() { return "eq"; }; var peg$f4 = function() { return "neq"; }; @@ -273,7 +284,7 @@ function peg$parse(input, options) { var peg$f22 = function() { return "taxRate"; }; var peg$f23 = function() { return "card"; }; var peg$f24 = function() { return "reportID"; }; - var peg$f25 = function() { return "freeText"; }; + var peg$f25 = function() { return "keyword"; }; var peg$f26 = function() { return "sortBy"; }; var peg$f27 = function() { return "sortOrder"; }; var peg$f28 = function() { return "offset"; }; @@ -444,18 +455,17 @@ function peg$parse(input, options) { } function peg$parsequery() { - var s0, s1, s2; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); s2 = peg$parsefilterList(); - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f0(s2); - } else { - peg$currPos = s0; - s0 = peg$FAILED; + if (s2 === peg$FAILED) { + s2 = null; } + s3 = peg$parse_(); + peg$savedPos = s0; + s0 = peg$f0(s2); return s0; } @@ -468,9 +478,9 @@ function peg$parse(input, options) { if (s1 !== peg$FAILED) { s2 = []; s3 = peg$currPos; - s4 = peg$parsefilter(); - if (s4 !== peg$FAILED) { - s5 = peg$parselogicalAnd(); + s4 = peg$parselogicalAnd(); + s5 = peg$parsefilter(); + if (s5 !== peg$FAILED) { s4 = [s4, s5]; s3 = s4; } else { @@ -480,9 +490,9 @@ function peg$parse(input, options) { while (s3 !== peg$FAILED) { s2.push(s3); s3 = peg$currPos; - s4 = peg$parsefilter(); - if (s4 !== peg$FAILED) { - s5 = peg$parselogicalAnd(); + s4 = peg$parselogicalAnd(); + s5 = peg$parsefilter(); + if (s5 !== peg$FAILED) { s4 = [s4, s5]; s3 = s4; } else { @@ -850,9 +860,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 8) === peg$c21) { + if (input.substr(peg$currPos, 7) === peg$c21) { s1 = peg$c21; - peg$currPos += 8; + peg$currPos += 7; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e22); } @@ -1103,6 +1113,14 @@ function peg$parse(input, options) { filters }; } + + function updateDefaultValues(field, value) { + defaultValues[field] = value; + } + + function isDefaultField(field) { + return defaultValues.hasOwnProperty(field); + } peg$result = peg$startRuleFunction(); diff --git a/src/libs/Search/searchParser.peggy b/src/libs/Search/searchParser.peggy index 26561c679d8a..0957239b6acb 100644 --- a/src/libs/Search/searchParser.peggy +++ b/src/libs/Search/searchParser.peggy @@ -1,4 +1,20 @@ -// src/libs/Search/searchParser.peggy +// This files defines the grammar that's used by [Peggy](https://peggyjs.org/) to generate the searchParser.js file. +// The searchParser is setup to parse our custom search syntax and output an AST with the filters. +// +// Here's a general grammar structure: +// +// start: entry point for the parser. It calls the query rule and return its value. +// query: rule to process the values returned by the filterList rule. Takes filters as an argument and returns the final AST output. +// filterList: rule to process the array of filters returned by the filter rule. It takes head and tail as arguments, filters it for null values and builds the AST. +// filter: rule to build the filter object. It takes field, operator and value as input and returns {operator, left: field, right: value} or null if the left value is a defaultValues +// operator: rule to match pre-defined search syntax operators, e.g. !=, >, etc +// key: rule to match pre-defined search syntax fields, e.g. amount, merchant, etc +// identifier: composite rule to match patterns defined by the quotedString and alphanumeric rules +// quotedString: rule to match a quoted string pattern, e.g. "this is a quoted string" +// alphanumeric: rule to match unquoted alphanumeric characters, e.g. a-z, 0-9, _, @, etc +// logicalAnd: rule to match whitespace and return it as a logical 'and' operator +// whitespace: rule to match whitespaces + { const defaultValues = { "type": "expense", @@ -48,11 +64,13 @@ filter } if (!field && !op) { - return buildFilter('eq', 'freeText', value.trim()); + return buildFilter('eq', 'keyword', value.trim()); } const values = value.split(','); - return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter('eq', field, val.trim())), buildFilter('eq', field, values[0])); + const operatorValue = op ?? 'eq'; + + return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter(operatorValue, field, val.trim())), buildFilter(operatorValue, field, values[0])); } operator @@ -80,7 +98,7 @@ key / "taxRate" { return "taxRate"; } / "card" { return "card"; } / "reportID" { return "reportID"; } - / "freeText" { return "freeText"; } + / "keyword" { return "keyword"; } / "sortBy" { return "sortBy"; } / "sortOrder" { return "sortOrder"; } / "offset" { return "offset"; } diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 6ddcac9263ce..35ce7cdbbaaa 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -11,8 +11,6 @@ import DateUtils from './DateUtils'; import getTopmostCentralPaneRoute from './Navigation/getTopmostCentralPaneRoute'; import navigationRef from './Navigation/navigationRef'; import type {AuthScreensParamList, RootStackParamList, State} from './Navigation/types'; -// This file is generated by the npm run generate-search-parser command. -// import * as searchParser from './Search/searchParser'; import * as TransactionUtils from './TransactionUtils'; import * as UserUtils from './UserUtils'; @@ -304,7 +302,6 @@ function isSearchResultsEmpty(searchResults: SearchResults) { return !Object.keys(searchResults?.data).some((key) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)); } -// This function should replace the current getQueryParams after the search v2.1 implementation. function getQueryHashFromString(query: string): number { return UserUtils.hashText(query, 2 ** 32); } @@ -322,7 +319,6 @@ function buildJSONQuery(query: string) { result.hash = getQueryHashFromString(query); return result; } catch (e) { - // TODO: We need better error handling here. console.error(e); } } From 7f528391c30b296c117fbac55264d9e64d1de806 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Thu, 11 Jul 2024 14:08:25 +0200 Subject: [PATCH 15/17] Move Search Parser to a dedicated dir and ignore it for typecheck GH workflow --- .github/workflows/typecheck.yml | 2 +- package.json | 2 +- src/libs/{Search => SearchParser}/searchParser.js | 0 src/libs/{Search => SearchParser}/searchParser.peggy | 0 src/libs/SearchUtils.ts | 2 +- 5 files changed, 3 insertions(+), 3 deletions(-) rename src/libs/{Search => SearchParser}/searchParser.js (100%) rename src/libs/{Search => SearchParser}/searchParser.peggy (100%) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 476b01f87b07..11c772ef38d7 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -34,7 +34,7 @@ jobs: # - git diff is used to see the files that were added on this branch # - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main # - wc counts the words in the result of the intersection - count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'desktop/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' 'workflow_tests/*.js' '.github/libs/*.js' '.github/scripts/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) + count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'desktop/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' 'workflow_tests/*.js' '.github/libs/*.js' '.github/scripts/*.js' 'src/libs/SearchParser/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) if [ "$count_new_js" -gt "0" ]; then echo "ERROR: Found new JavaScript files in the project; use TypeScript instead." exit 1 diff --git a/package.json b/package.json index 19dd87acd011..57165f7dbfb3 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1", "e2e-test-runner-build": "ncc build tests/e2e/testRunner.ts -o tests/e2e/dist/", "react-compiler-healthcheck": "react-compiler-healthcheck --verbose", - "generate-search-parser": "peggy --format es -o src/libs/Search/searchParser.js src/libs/Search/searchParser.peggy " + "generate-search-parser": "peggy --format es -o src/libs/SearchParser/searchParser.js src/libs/SearchParser/searchParser.peggy " }, "dependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", diff --git a/src/libs/Search/searchParser.js b/src/libs/SearchParser/searchParser.js similarity index 100% rename from src/libs/Search/searchParser.js rename to src/libs/SearchParser/searchParser.js diff --git a/src/libs/Search/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy similarity index 100% rename from src/libs/Search/searchParser.peggy rename to src/libs/SearchParser/searchParser.peggy diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 35ce7cdbbaaa..3225a21c2661 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -11,7 +11,7 @@ import DateUtils from './DateUtils'; import getTopmostCentralPaneRoute from './Navigation/getTopmostCentralPaneRoute'; import navigationRef from './Navigation/navigationRef'; import type {AuthScreensParamList, RootStackParamList, State} from './Navigation/types'; -import * as searchParser from './Search/searchParser'; +import * as searchParser from './SearchParser/searchParser'; import * as TransactionUtils from './TransactionUtils'; import * as UserUtils from './UserUtils'; From 5991da15554cf5c9cfedc21b819d294ab4ba8b34 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Thu, 11 Jul 2024 14:13:46 +0200 Subject: [PATCH 16/17] fix eslintignore and prettierignore --- .eslintignore | 2 +- .prettierignore | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.eslintignore b/.eslintignore index 5643c0dc3d2a..3d966d096add 100644 --- a/.eslintignore +++ b/.eslintignore @@ -10,4 +10,4 @@ docs/vendor/** docs/assets/** web/gtm.js **/.expo/** -src/libs/Search/searchParser.js +src/libs/SearchParser/searchParser.js diff --git a/.prettierignore b/.prettierignore index b11fa4f762ef..a9f7e1464529 100644 --- a/.prettierignore +++ b/.prettierignore @@ -20,5 +20,5 @@ src/libs/E2E/reactNativeLaunchingTest.ts # Temporary while we keep react-compiler in our repo lib/** -# Automatically generated file -src/libs/Search/searchParser.js +# Automatically generated files +src/libs/SearchParser/searchParser.js From c4587d3660745e54b01aaa6b2b6b427cc8a7e787 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Thu, 11 Jul 2024 14:58:46 +0200 Subject: [PATCH 17/17] Fix typecheck github action --- .github/workflows/typecheck.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 11c772ef38d7..3bfc0ed28d1a 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -34,7 +34,7 @@ jobs: # - git diff is used to see the files that were added on this branch # - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main # - wc counts the words in the result of the intersection - count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'desktop/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' 'workflow_tests/*.js' '.github/libs/*.js' '.github/scripts/*.js' 'src/libs/SearchParser/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) + count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'desktop/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' 'workflow_tests/*.js' '.github/libs/*.js' '.github/scripts/*.js' ':!src/libs/SearchParser/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) if [ "$count_new_js" -gt "0" ]; then echo "ERROR: Found new JavaScript files in the project; use TypeScript instead." exit 1