parser.tsx 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346
  1. import * as Sentry from '@sentry/react';
  2. import merge from 'lodash/merge';
  3. import moment from 'moment';
  4. import type {LocationRange} from 'pegjs';
  5. import {t} from 'sentry/locale';
  6. import type {TagCollection} from 'sentry/types/group';
  7. import {
  8. isMeasurement,
  9. isSpanOperationBreakdownField,
  10. measurementType,
  11. } from 'sentry/utils/discover/fields';
  12. import grammar from './grammar.pegjs';
  13. import {getKeyName} from './utils';
  14. type TextFn = () => string;
  15. type LocationFn = () => LocationRange;
  16. type ListItem<V> = [
  17. space: ReturnType<TokenConverter['tokenSpaces']>,
  18. comma: string,
  19. space: ReturnType<TokenConverter['tokenSpaces']>,
  20. notComma: undefined,
  21. value: V | null,
  22. ];
  23. const listJoiner = <K,>([s1, comma, s2, _, value]: ListItem<K>) => ({
  24. separator: [s1.value, comma, s2.value].join(''),
  25. value,
  26. });
  27. /**
  28. * A token represents a node in the syntax tree. These are all extrapolated
  29. * from the grammar and may not be named exactly the same.
  30. */
  31. export enum Token {
  32. SPACES = 'spaces',
  33. FILTER = 'filter',
  34. FREE_TEXT = 'freeText',
  35. LOGIC_GROUP = 'logicGroup',
  36. LOGIC_BOOLEAN = 'logicBoolean',
  37. KEY_SIMPLE = 'keySimple',
  38. KEY_EXPLICIT_TAG = 'keyExplicitTag',
  39. KEY_AGGREGATE = 'keyAggregate',
  40. KEY_AGGREGATE_ARGS = 'keyAggregateArgs',
  41. KEY_AGGREGATE_PARAMS = 'keyAggregateParam',
  42. L_PAREN = 'lParen',
  43. R_PAREN = 'rParen',
  44. VALUE_ISO_8601_DATE = 'valueIso8601Date',
  45. VALUE_RELATIVE_DATE = 'valueRelativeDate',
  46. VALUE_DURATION = 'valueDuration',
  47. VALUE_SIZE = 'valueSize',
  48. VALUE_PERCENTAGE = 'valuePercentage',
  49. VALUE_BOOLEAN = 'valueBoolean',
  50. VALUE_NUMBER = 'valueNumber',
  51. VALUE_TEXT = 'valueText',
  52. VALUE_NUMBER_LIST = 'valueNumberList',
  53. VALUE_TEXT_LIST = 'valueTextList',
  54. }
  55. /**
  56. * An operator in a key value term
  57. */
  58. export enum TermOperator {
  59. DEFAULT = '',
  60. GREATER_THAN_EQUAL = '>=',
  61. LESS_THAN_EQUAL = '<=',
  62. GREATER_THAN = '>',
  63. LESS_THAN = '<',
  64. EQUAL = '=',
  65. NOT_EQUAL = '!=',
  66. }
  67. /**
  68. * Logic operators
  69. */
  70. export enum BooleanOperator {
  71. AND = 'AND',
  72. OR = 'OR',
  73. }
  74. /**
  75. * The Token.Filter may be one of many types of filters. This enum declares the
  76. * each variant filter type.
  77. */
  78. export enum FilterType {
  79. TEXT = 'text',
  80. TEXT_IN = 'textIn',
  81. DATE = 'date',
  82. SPECIFIC_DATE = 'specificDate',
  83. RELATIVE_DATE = 'relativeDate',
  84. DURATION = 'duration',
  85. SIZE = 'size',
  86. NUMERIC = 'numeric',
  87. NUMERIC_IN = 'numericIn',
  88. BOOLEAN = 'boolean',
  89. AGGREGATE_DURATION = 'aggregateDuration',
  90. AGGREGATE_SIZE = 'aggregateSize',
  91. AGGREGATE_PERCENTAGE = 'aggregatePercentage',
  92. AGGREGATE_NUMERIC = 'aggregateNumeric',
  93. AGGREGATE_DATE = 'aggregateDate',
  94. AGGREGATE_RELATIVE_DATE = 'aggregateRelativeDate',
  95. HAS = 'has',
  96. IS = 'is',
  97. }
  98. export const allOperators = [
  99. TermOperator.DEFAULT,
  100. TermOperator.GREATER_THAN_EQUAL,
  101. TermOperator.LESS_THAN_EQUAL,
  102. TermOperator.GREATER_THAN,
  103. TermOperator.LESS_THAN,
  104. TermOperator.EQUAL,
  105. TermOperator.NOT_EQUAL,
  106. ] as const;
  107. const basicOperators = [TermOperator.DEFAULT, TermOperator.NOT_EQUAL] as const;
  108. /**
  109. * Map of certain filter types to other filter types with applicable operators
  110. * e.g. SpecificDate can use the operators from Date to become a Date filter.
  111. */
  112. export const interchangeableFilterOperators = {
  113. [FilterType.SPECIFIC_DATE]: [FilterType.DATE],
  114. [FilterType.DATE]: [FilterType.SPECIFIC_DATE],
  115. };
  116. const textKeys = [Token.KEY_SIMPLE, Token.KEY_EXPLICIT_TAG] as const;
  117. /**
  118. * This constant-type configuration object declares how each filter type
  119. * operates. Including what types of keys, operators, and values it may
  120. * receive.
  121. *
  122. * This configuration is used to generate the discriminate Filter type that is
  123. * returned from the tokenFilter converter.
  124. */
  125. export const filterTypeConfig = {
  126. [FilterType.TEXT]: {
  127. validKeys: textKeys,
  128. validOps: basicOperators,
  129. validValues: [Token.VALUE_TEXT],
  130. canNegate: true,
  131. },
  132. [FilterType.TEXT_IN]: {
  133. validKeys: textKeys,
  134. validOps: [],
  135. validValues: [Token.VALUE_TEXT_LIST],
  136. canNegate: true,
  137. },
  138. [FilterType.DATE]: {
  139. validKeys: [Token.KEY_SIMPLE],
  140. validOps: allOperators,
  141. validValues: [Token.VALUE_ISO_8601_DATE],
  142. canNegate: false,
  143. },
  144. [FilterType.SPECIFIC_DATE]: {
  145. validKeys: [Token.KEY_SIMPLE],
  146. validOps: [],
  147. validValues: [Token.VALUE_ISO_8601_DATE],
  148. canNegate: false,
  149. },
  150. [FilterType.RELATIVE_DATE]: {
  151. validKeys: [Token.KEY_SIMPLE],
  152. validOps: [],
  153. validValues: [Token.VALUE_RELATIVE_DATE],
  154. canNegate: false,
  155. },
  156. [FilterType.DURATION]: {
  157. validKeys: [Token.KEY_SIMPLE],
  158. validOps: allOperators,
  159. validValues: [Token.VALUE_DURATION],
  160. canNegate: true,
  161. },
  162. [FilterType.SIZE]: {
  163. validKeys: [Token.KEY_SIMPLE],
  164. validOps: allOperators,
  165. validValues: [Token.VALUE_SIZE],
  166. canNegate: true,
  167. },
  168. [FilterType.NUMERIC]: {
  169. validKeys: [Token.KEY_SIMPLE],
  170. validOps: allOperators,
  171. validValues: [Token.VALUE_NUMBER],
  172. canNegate: true,
  173. },
  174. [FilterType.NUMERIC_IN]: {
  175. validKeys: [Token.KEY_SIMPLE],
  176. validOps: [],
  177. validValues: [Token.VALUE_NUMBER_LIST],
  178. canNegate: true,
  179. },
  180. [FilterType.BOOLEAN]: {
  181. validKeys: [Token.KEY_SIMPLE],
  182. validOps: basicOperators,
  183. validValues: [Token.VALUE_BOOLEAN],
  184. canNegate: true,
  185. },
  186. [FilterType.AGGREGATE_DURATION]: {
  187. validKeys: [Token.KEY_AGGREGATE],
  188. validOps: allOperators,
  189. validValues: [Token.VALUE_DURATION],
  190. canNegate: true,
  191. },
  192. [FilterType.AGGREGATE_SIZE]: {
  193. validKeys: [Token.KEY_AGGREGATE],
  194. validOps: allOperators,
  195. validValues: [Token.VALUE_SIZE],
  196. canNegate: true,
  197. },
  198. [FilterType.AGGREGATE_NUMERIC]: {
  199. validKeys: [Token.KEY_AGGREGATE],
  200. validOps: allOperators,
  201. validValues: [Token.VALUE_NUMBER],
  202. canNegate: true,
  203. },
  204. [FilterType.AGGREGATE_PERCENTAGE]: {
  205. validKeys: [Token.KEY_AGGREGATE],
  206. validOps: allOperators,
  207. validValues: [Token.VALUE_PERCENTAGE],
  208. canNegate: true,
  209. },
  210. [FilterType.AGGREGATE_DATE]: {
  211. validKeys: [Token.KEY_AGGREGATE],
  212. validOps: allOperators,
  213. validValues: [Token.VALUE_ISO_8601_DATE],
  214. canNegate: true,
  215. },
  216. [FilterType.AGGREGATE_RELATIVE_DATE]: {
  217. validKeys: [Token.KEY_AGGREGATE],
  218. validOps: allOperators,
  219. validValues: [Token.VALUE_RELATIVE_DATE],
  220. canNegate: true,
  221. },
  222. [FilterType.HAS]: {
  223. validKeys: [Token.KEY_SIMPLE],
  224. validOps: basicOperators,
  225. validValues: [],
  226. canNegate: true,
  227. },
  228. [FilterType.IS]: {
  229. validKeys: [Token.KEY_SIMPLE],
  230. validOps: basicOperators,
  231. validValues: [Token.VALUE_TEXT],
  232. canNegate: true,
  233. },
  234. } as const;
  235. type FilterTypeConfig = typeof filterTypeConfig;
  236. /**
  237. * The invalid reason is used to mark fields invalid fields and can be
  238. * used to determine why the field was invalid. This is primarily use for the
  239. * invalidMessages option
  240. */
  241. export enum InvalidReason {
  242. FREE_TEXT_NOT_ALLOWED = 'free-text-not-allowed',
  243. WILDCARD_NOT_ALLOWED = 'wildcard-not-allowed',
  244. LOGICAL_OR_NOT_ALLOWED = 'logic-or-not-allowed',
  245. LOGICAL_AND_NOT_ALLOWED = 'logic-and-not-allowed',
  246. MUST_BE_QUOTED = 'must-be-quoted',
  247. FILTER_MUST_HAVE_VALUE = 'filter-must-have-value',
  248. INVALID_BOOLEAN = 'invalid-boolean',
  249. INVALID_FILE_SIZE = 'invalid-file-size',
  250. INVALID_NUMBER = 'invalid-number',
  251. EMPTY_VALUE_IN_LIST_NOT_ALLOWED = 'empty-value-in-list-not-allowed',
  252. INVALID_KEY = 'invalid-key',
  253. INVALID_DURATION = 'invalid-duration',
  254. INVALID_DATE_FORMAT = 'invalid-date-format',
  255. }
  256. /**
  257. * Object representing an invalid filter state
  258. */
  259. type InvalidFilter = {
  260. /**
  261. * The message indicating why the filter is invalid
  262. */
  263. reason: string;
  264. /**
  265. * The invalid reason type
  266. */
  267. type: InvalidReason;
  268. /**
  269. * In the case where a filter is invalid, we may be expecting a different
  270. * type for this filter based on the key. This can be useful to hint to the
  271. * user what values they should be providing.
  272. *
  273. * This may be multiple filter types.
  274. */
  275. expectedType?: FilterType[];
  276. };
  277. type FilterMap = {
  278. [F in keyof FilterTypeConfig]: {
  279. /**
  280. * The filter type being represented
  281. */
  282. filter: F;
  283. /**
  284. * When a filter is marked as 'invalid' a reason is given. If the filter is
  285. * not invalid this will always be null
  286. */
  287. invalid: InvalidFilter | null;
  288. /**
  289. * The key of the filter
  290. */
  291. key: KVConverter<FilterTypeConfig[F]['validKeys'][number]>;
  292. /**
  293. * Indicates if the filter has been negated
  294. */
  295. negated: FilterTypeConfig[F]['canNegate'] extends true ? boolean : false;
  296. /**
  297. * The operator applied to the filter
  298. */
  299. operator: FilterTypeConfig[F]['validOps'][number];
  300. type: Token.FILTER;
  301. /**
  302. * The value of the filter
  303. */
  304. value: KVConverter<FilterTypeConfig[F]['validValues'][number]>;
  305. /**
  306. * A warning message associated with this filter
  307. */
  308. warning: React.ReactNode;
  309. };
  310. };
  311. type TextFilter = FilterMap[FilterType.TEXT];
  312. type InFilter = FilterMap[FilterType.TEXT_IN] | FilterMap[FilterType.NUMERIC_IN];
  313. /**
  314. * The Filter type discriminates on the FilterType enum using the `filter` key.
  315. *
  316. * When receiving this type you may narrow it to a specific filter by checking
  317. * this field. This will give you proper types on what the key, value, and
  318. * operator results are.
  319. */
  320. type FilterResult = FilterMap[FilterType];
  321. type TokenConverterOpts = {
  322. config: SearchConfig;
  323. location: LocationFn;
  324. text: TextFn;
  325. };
  326. /**
  327. * Used to construct token results via the token grammar
  328. */
  329. export class TokenConverter {
  330. text: TextFn;
  331. location: LocationFn;
  332. config: SearchConfig;
  333. constructor({text, location, config}: TokenConverterOpts) {
  334. this.text = text;
  335. this.location = location;
  336. this.config = config;
  337. }
  338. /**
  339. * Validates various types of keys
  340. */
  341. keyValidation = {
  342. isNumeric: (key: string) =>
  343. this.config.numericKeys.has(key) ||
  344. isMeasurement(key) ||
  345. isSpanOperationBreakdownField(key),
  346. isBoolean: (key: string) => this.config.booleanKeys.has(key),
  347. isPercentage: (key: string) => this.config.percentageKeys.has(key),
  348. isDate: (key: string) => this.config.dateKeys.has(key),
  349. isDuration: (key: string) =>
  350. this.config.durationKeys.has(key) ||
  351. isSpanOperationBreakdownField(key) ||
  352. measurementType(key) === 'duration',
  353. isSize: (key: string) => this.config.sizeKeys.has(key),
  354. };
  355. /**
  356. * Creates shared `text` and `location` keys.
  357. */
  358. get defaultTokenFields() {
  359. return {
  360. text: this.text(),
  361. location: this.location(),
  362. };
  363. }
  364. tokenSpaces = (value: string) => ({
  365. ...this.defaultTokenFields,
  366. type: Token.SPACES as const,
  367. value,
  368. });
  369. tokenFilter = <T extends FilterType>(
  370. filter: T,
  371. key: FilterMap[T]['key'],
  372. value: FilterMap[T]['value'],
  373. operator: FilterMap[T]['operator'] | undefined,
  374. negated: FilterMap[T]['negated']
  375. ) => {
  376. const filterToken = {
  377. type: Token.FILTER as const,
  378. filter,
  379. key,
  380. value,
  381. negated,
  382. operator: operator ?? TermOperator.DEFAULT,
  383. invalid: this.checkInvalidFilter(filter, key, value),
  384. warning: this.checkFilterWarning(key),
  385. } as FilterResult;
  386. return {
  387. ...this.defaultTokenFields,
  388. ...filterToken,
  389. };
  390. };
  391. tokenLParen = (value: '(') => ({
  392. ...this.defaultTokenFields,
  393. type: Token.L_PAREN as const,
  394. value,
  395. });
  396. tokenRParen = (value: ')') => ({
  397. ...this.defaultTokenFields,
  398. type: Token.R_PAREN as const,
  399. value,
  400. });
  401. tokenFreeText = (value: string, quoted: boolean) => ({
  402. ...this.defaultTokenFields,
  403. type: Token.FREE_TEXT as const,
  404. value,
  405. quoted,
  406. invalid: this.checkInvalidFreeText(value),
  407. });
  408. tokenLogicGroup = (
  409. inner: Array<
  410. | ReturnType<TokenConverter['tokenLogicBoolean']>
  411. | ReturnType<TokenConverter['tokenFilter']>
  412. | ReturnType<TokenConverter['tokenFreeText']>
  413. >
  414. ) => ({
  415. ...this.defaultTokenFields,
  416. type: Token.LOGIC_GROUP as const,
  417. inner,
  418. });
  419. tokenLogicBoolean = (bool: BooleanOperator) => ({
  420. ...this.defaultTokenFields,
  421. type: Token.LOGIC_BOOLEAN as const,
  422. value: bool,
  423. invalid: this.checkInvalidLogicalBoolean(bool),
  424. });
  425. tokenKeySimple = (value: string, quoted: boolean) => ({
  426. ...this.defaultTokenFields,
  427. type: Token.KEY_SIMPLE as const,
  428. value,
  429. quoted,
  430. });
  431. tokenKeyExplicitTag = (
  432. prefix: string,
  433. key: ReturnType<TokenConverter['tokenKeySimple']>
  434. ) => ({
  435. ...this.defaultTokenFields,
  436. type: Token.KEY_EXPLICIT_TAG as const,
  437. prefix,
  438. key,
  439. });
  440. tokenKeyAggregateParam = (value: string, quoted: boolean) => ({
  441. ...this.defaultTokenFields,
  442. type: Token.KEY_AGGREGATE_PARAMS as const,
  443. value,
  444. quoted,
  445. });
  446. tokenKeyAggregate = (
  447. name: ReturnType<TokenConverter['tokenKeySimple']>,
  448. args: ReturnType<TokenConverter['tokenKeyAggregateArgs']> | null,
  449. argsSpaceBefore: ReturnType<TokenConverter['tokenSpaces']>,
  450. argsSpaceAfter: ReturnType<TokenConverter['tokenSpaces']>
  451. ) => ({
  452. ...this.defaultTokenFields,
  453. type: Token.KEY_AGGREGATE as const,
  454. name,
  455. args,
  456. argsSpaceBefore,
  457. argsSpaceAfter,
  458. });
  459. tokenKeyAggregateArgs = (
  460. arg1: ReturnType<TokenConverter['tokenKeyAggregateParam']>,
  461. args: ListItem<ReturnType<TokenConverter['tokenKeyAggregateParam']>>[]
  462. ) => ({
  463. ...this.defaultTokenFields,
  464. type: Token.KEY_AGGREGATE_ARGS as const,
  465. args: [{separator: '', value: arg1}, ...args.map(listJoiner)],
  466. });
  467. tokenValueIso8601Date = (value: string) => ({
  468. ...this.defaultTokenFields,
  469. type: Token.VALUE_ISO_8601_DATE as const,
  470. value: value,
  471. parsed: this.config.parse ? parseDate(value) : undefined,
  472. });
  473. tokenValueRelativeDate = (
  474. value: string,
  475. sign: '-' | '+',
  476. unit: 'w' | 'd' | 'h' | 'm'
  477. ) => ({
  478. ...this.defaultTokenFields,
  479. type: Token.VALUE_RELATIVE_DATE as const,
  480. value: value,
  481. parsed: this.config.parse ? parseRelativeDate(value, {unit, sign}) : undefined,
  482. sign,
  483. unit,
  484. });
  485. tokenValueDuration = (
  486. value: string,
  487. unit: 'ms' | 's' | 'min' | 'm' | 'hr' | 'h' | 'day' | 'd' | 'wk' | 'w'
  488. ) => ({
  489. ...this.defaultTokenFields,
  490. type: Token.VALUE_DURATION as const,
  491. value: value,
  492. parsed: this.config.parse ? parseDuration(value, unit) : undefined,
  493. unit,
  494. });
  495. tokenValueSize = (
  496. value: string,
  497. // warning: size units are case insensitive, this type is incomplete
  498. unit:
  499. | 'bit'
  500. | 'nb'
  501. | 'bytes'
  502. | 'kb'
  503. | 'mb'
  504. | 'gb'
  505. | 'tb'
  506. | 'pb'
  507. | 'eb'
  508. | 'zb'
  509. | 'yb'
  510. | 'kib'
  511. | 'mib'
  512. | 'gib'
  513. | 'tib'
  514. | 'pib'
  515. | 'eib'
  516. | 'zib'
  517. | 'yib'
  518. ) => ({
  519. ...this.defaultTokenFields,
  520. type: Token.VALUE_SIZE as const,
  521. value: value,
  522. // units are case insensitive, normalize them in their parsed representation
  523. // so that we dont have to compare all possible permutations.
  524. parsed: this.config.parse ? parseSize(value, unit) : undefined,
  525. unit,
  526. });
  527. tokenValuePercentage = (value: string) => ({
  528. ...this.defaultTokenFields,
  529. type: Token.VALUE_PERCENTAGE as const,
  530. value: value,
  531. parsed: this.config.parse ? parsePercentage(value) : undefined,
  532. });
  533. tokenValueBoolean = (value: string) => ({
  534. ...this.defaultTokenFields,
  535. type: Token.VALUE_BOOLEAN as const,
  536. value: value,
  537. parsed: this.config.parse ? parseBoolean(value) : undefined,
  538. });
  539. tokenValueNumber = (value: string, unit: 'k' | 'm' | 'b' | 'K' | 'M' | 'B') => {
  540. return {
  541. ...this.defaultTokenFields,
  542. type: Token.VALUE_NUMBER as const,
  543. value,
  544. unit,
  545. parsed: this.config.parse ? parseNumber(value, unit) : undefined,
  546. };
  547. };
  548. tokenValueNumberList = (
  549. item1: ReturnType<TokenConverter['tokenValueNumber']>,
  550. items: ListItem<ReturnType<TokenConverter['tokenValueNumber']>>[]
  551. ) => ({
  552. ...this.defaultTokenFields,
  553. type: Token.VALUE_NUMBER_LIST as const,
  554. items: [{separator: '', value: item1}, ...items.map(listJoiner)],
  555. });
  556. tokenValueTextList = (
  557. item1: ReturnType<TokenConverter['tokenValueText']>,
  558. items: ListItem<ReturnType<TokenConverter['tokenValueText']>>[]
  559. ) => ({
  560. ...this.defaultTokenFields,
  561. type: Token.VALUE_TEXT_LIST as const,
  562. items: [{separator: '', value: item1}, ...items.map(listJoiner)],
  563. });
  564. tokenValueText = (value: string, quoted: boolean) => {
  565. return {
  566. ...this.defaultTokenFields,
  567. type: Token.VALUE_TEXT as const,
  568. value,
  569. quoted,
  570. };
  571. };
  572. /**
  573. * This method is used while tokenizing to predicate whether a filter should
  574. * match or not. We do this because not all keys are valid for specific
  575. * filter types. For example, boolean filters should only match for keys
  576. * which can be filtered as booleans.
  577. *
  578. * See [0] and look for &{ predicate } to understand how predicates are
  579. * declared in the grammar
  580. *
  581. * [0]:https://pegjs.org/documentation
  582. */
  583. predicateFilter = <T extends FilterType>(type: T, key: FilterMap[T]['key']) => {
  584. const keyName = getKeyName(key);
  585. const aggregateKey = key as ReturnType<TokenConverter['tokenKeyAggregate']>;
  586. const {isNumeric, isDuration, isBoolean, isDate, isPercentage, isSize} =
  587. this.keyValidation;
  588. const checkAggregate = (check: (s: string) => boolean) =>
  589. aggregateKey.args?.args.some(arg => check(arg?.value?.value ?? ''));
  590. switch (type) {
  591. case FilterType.NUMERIC:
  592. case FilterType.NUMERIC_IN:
  593. return isNumeric(keyName);
  594. case FilterType.DURATION:
  595. return isDuration(keyName);
  596. case FilterType.SIZE:
  597. return isSize(keyName);
  598. case FilterType.BOOLEAN:
  599. return isBoolean(keyName);
  600. case FilterType.DATE:
  601. case FilterType.RELATIVE_DATE:
  602. case FilterType.SPECIFIC_DATE:
  603. return isDate(keyName);
  604. case FilterType.AGGREGATE_DURATION:
  605. return checkAggregate(isDuration);
  606. case FilterType.AGGREGATE_DATE:
  607. return checkAggregate(isDate);
  608. case FilterType.AGGREGATE_PERCENTAGE:
  609. return checkAggregate(isPercentage);
  610. default:
  611. return true;
  612. }
  613. };
  614. /**
  615. * Predicates weather a text filter have operators for specific keys.
  616. */
  617. predicateTextOperator = (key: TextFilter['key']) =>
  618. this.config.textOperatorKeys.has(getKeyName(key));
  619. /**
  620. * When flattenParenGroups is enabled, paren groups should not be parsed,
  621. * instead parsing the parens and inner group as individual tokens.
  622. */
  623. predicateParenGroup = (): boolean => {
  624. return !this.config.flattenParenGroups;
  625. };
  626. /**
  627. * Checks the validity of a free text based on the provided search configuration
  628. */
  629. checkInvalidFreeText = (value: string) => {
  630. if (this.config.disallowFreeText) {
  631. return {
  632. type: InvalidReason.FREE_TEXT_NOT_ALLOWED,
  633. reason: this.config.invalidMessages[InvalidReason.FREE_TEXT_NOT_ALLOWED],
  634. };
  635. }
  636. if (this.config.disallowWildcard && value.includes('*')) {
  637. return {
  638. type: InvalidReason.WILDCARD_NOT_ALLOWED,
  639. reason: this.config.invalidMessages[InvalidReason.WILDCARD_NOT_ALLOWED],
  640. };
  641. }
  642. return null;
  643. };
  644. /**
  645. * Checks the validity of a logical boolean filter based on the provided search configuration
  646. */
  647. checkInvalidLogicalBoolean = (value: BooleanOperator) => {
  648. if (this.config.disallowedLogicalOperators.has(value)) {
  649. if (value === BooleanOperator.OR) {
  650. return {
  651. type: InvalidReason.LOGICAL_OR_NOT_ALLOWED,
  652. reason: this.config.invalidMessages[InvalidReason.LOGICAL_OR_NOT_ALLOWED],
  653. };
  654. }
  655. if (value === BooleanOperator.AND) {
  656. return {
  657. type: InvalidReason.LOGICAL_AND_NOT_ALLOWED,
  658. reason: this.config.invalidMessages[InvalidReason.LOGICAL_AND_NOT_ALLOWED],
  659. };
  660. }
  661. }
  662. return null;
  663. };
  664. /**
  665. * Checks a filter against some non-grammar validation rules
  666. */
  667. checkFilterWarning = <T extends FilterType>(key: FilterMap[T]['key']) => {
  668. if (![Token.KEY_SIMPLE, Token.KEY_EXPLICIT_TAG].includes(key.type)) {
  669. return null;
  670. }
  671. const keyName = getKeyName(
  672. key as TokenResult<Token.KEY_SIMPLE | Token.KEY_EXPLICIT_TAG>
  673. );
  674. return this.config.getFilterTokenWarning?.(keyName) ?? null;
  675. };
  676. /**
  677. * Checks a filter against some non-grammar validation rules
  678. */
  679. checkInvalidFilter = <T extends FilterType>(
  680. filter: T,
  681. key: FilterMap[T]['key'],
  682. value: FilterMap[T]['value']
  683. ) => {
  684. // Text filter is the "fall through" filter that will match when other
  685. // filter predicates fail.
  686. if (
  687. this.config.validateKeys &&
  688. this.config.supportedTags &&
  689. !this.config.supportedTags[key.text]
  690. ) {
  691. return {
  692. type: InvalidReason.INVALID_KEY,
  693. reason: t('Invalid key. "%s" is not a supported search key.', key.text),
  694. };
  695. }
  696. if (filter === FilterType.TEXT) {
  697. return this.checkInvalidTextFilter(
  698. key as TextFilter['key'],
  699. value as TextFilter['value']
  700. );
  701. }
  702. if (filter === FilterType.IS || filter === FilterType.HAS) {
  703. return this.checkInvalidTextValue(value as TextFilter['value']);
  704. }
  705. if ([FilterType.TEXT_IN, FilterType.NUMERIC_IN].includes(filter)) {
  706. return this.checkInvalidInFilter(value as InFilter['value']);
  707. }
  708. return null;
  709. };
  710. /**
  711. * Validates text filters which may have failed predication
  712. */
  713. checkInvalidTextFilter = (key: TextFilter['key'], value: TextFilter['value']) => {
  714. // Explicit tag keys will always be treated as text filters
  715. if (key.type === Token.KEY_EXPLICIT_TAG) {
  716. return this.checkInvalidTextValue(value);
  717. }
  718. const keyName = getKeyName(key);
  719. if (this.keyValidation.isDuration(keyName)) {
  720. return {
  721. type: InvalidReason.INVALID_DURATION,
  722. reason: t('Invalid duration. Expected number followed by duration unit suffix'),
  723. expectedType: [FilterType.DURATION],
  724. };
  725. }
  726. if (this.keyValidation.isDate(keyName)) {
  727. const date = new Date();
  728. date.setSeconds(0);
  729. date.setMilliseconds(0);
  730. const example = date.toISOString();
  731. return {
  732. type: InvalidReason.INVALID_DATE_FORMAT,
  733. reason: t(
  734. 'Invalid date format. Expected +/-duration (e.g. +1h) or ISO 8601-like (e.g. %s or %s)',
  735. example.slice(0, 10),
  736. example
  737. ),
  738. expectedType: [
  739. FilterType.DATE,
  740. FilterType.SPECIFIC_DATE,
  741. FilterType.RELATIVE_DATE,
  742. ],
  743. };
  744. }
  745. if (this.keyValidation.isBoolean(keyName)) {
  746. return {
  747. type: InvalidReason.INVALID_BOOLEAN,
  748. reason: this.config.invalidMessages[InvalidReason.INVALID_BOOLEAN],
  749. expectedType: [FilterType.BOOLEAN],
  750. };
  751. }
  752. if (this.keyValidation.isSize(keyName)) {
  753. return {
  754. type: InvalidReason.INVALID_FILE_SIZE,
  755. reason: this.config.invalidMessages[InvalidReason.INVALID_FILE_SIZE],
  756. expectedType: [FilterType.SIZE],
  757. };
  758. }
  759. if (this.keyValidation.isNumeric(keyName)) {
  760. return {
  761. type: InvalidReason.INVALID_NUMBER,
  762. reason: this.config.invalidMessages[InvalidReason.INVALID_NUMBER],
  763. expectedType: [FilterType.NUMERIC, FilterType.NUMERIC_IN],
  764. };
  765. }
  766. return this.checkInvalidTextValue(value);
  767. };
  768. /**
  769. * Validates the value of a text filter
  770. */
  771. checkInvalidTextValue = (value: TextFilter['value']) => {
  772. if (this.config.disallowWildcard && value.value.includes('*')) {
  773. return {
  774. type: InvalidReason.WILDCARD_NOT_ALLOWED,
  775. reason: this.config.invalidMessages[InvalidReason.WILDCARD_NOT_ALLOWED],
  776. };
  777. }
  778. if (!value.quoted && /(^|[^\\])"/.test(value.value)) {
  779. return {
  780. type: InvalidReason.MUST_BE_QUOTED,
  781. reason: this.config.invalidMessages[InvalidReason.MUST_BE_QUOTED],
  782. };
  783. }
  784. if (!value.quoted && value.value === '') {
  785. return {
  786. type: InvalidReason.FILTER_MUST_HAVE_VALUE,
  787. reason: this.config.invalidMessages[InvalidReason.FILTER_MUST_HAVE_VALUE],
  788. };
  789. }
  790. return null;
  791. };
  792. /**
  793. * Validates IN filter values do not have an missing elements
  794. */
  795. checkInvalidInFilter = ({items}: InFilter['value']) => {
  796. const hasEmptyValue = items.some(item => item.value === null);
  797. if (hasEmptyValue) {
  798. return {
  799. type: InvalidReason.EMPTY_VALUE_IN_LIST_NOT_ALLOWED,
  800. reason:
  801. this.config.invalidMessages[InvalidReason.EMPTY_VALUE_IN_LIST_NOT_ALLOWED],
  802. };
  803. }
  804. if (
  805. this.config.disallowWildcard &&
  806. items.some(item => item.value.value.includes('*'))
  807. ) {
  808. return {
  809. type: InvalidReason.WILDCARD_NOT_ALLOWED,
  810. reason: this.config.invalidMessages[InvalidReason.WILDCARD_NOT_ALLOWED],
  811. };
  812. }
  813. return null;
  814. };
  815. }
  816. function parseDate(input: string): {value: Date} {
  817. const date = moment(input).toDate();
  818. if (isNaN(date.getTime())) {
  819. throw new Error('Invalid date');
  820. }
  821. return {value: date};
  822. }
  823. function parseRelativeDate(
  824. input: string,
  825. {sign, unit}: {sign: '-' | '+'; unit: string}
  826. ): {value: Date} {
  827. let date = new Date().getTime();
  828. const number = numeric(input);
  829. if (isNaN(date)) {
  830. throw new Error('Invalid date');
  831. }
  832. let offset: number | undefined;
  833. switch (unit) {
  834. case 'm':
  835. offset = number * 1000 * 60;
  836. break;
  837. case 'h':
  838. offset = number * 1000 * 60 * 60;
  839. break;
  840. case 'd':
  841. offset = number * 1000 * 60 * 60 * 24;
  842. break;
  843. case 'w':
  844. offset = number * 1000 * 60 * 60 * 24 * 7;
  845. break;
  846. default:
  847. throw new Error('Invalid unit');
  848. }
  849. if (offset === undefined) {
  850. throw new Error('Unreachable');
  851. }
  852. date = sign === '-' ? date - offset : date + offset;
  853. return {value: new Date(date)};
  854. }
  855. // The parser supports floats and ints, parseFloat handles both.
  856. function numeric(input: string) {
  857. const number = parseFloat(input);
  858. if (isNaN(number)) {
  859. throw new Error('Invalid number');
  860. }
  861. return number;
  862. }
  863. function parseDuration(
  864. input: string,
  865. unit: 'ms' | 's' | 'min' | 'm' | 'hr' | 'h' | 'day' | 'd' | 'wk' | 'w'
  866. ): {value: number} {
  867. let number = numeric(input);
  868. switch (unit) {
  869. case 'ms':
  870. break;
  871. case 's':
  872. number *= 1e3;
  873. break;
  874. case 'min':
  875. case 'm':
  876. number *= 1e3 * 60;
  877. break;
  878. case 'hr':
  879. case 'h':
  880. number *= 1e3 * 60 * 60;
  881. break;
  882. case 'day':
  883. case 'd':
  884. number *= 1e3 * 60 * 60 * 24;
  885. break;
  886. case 'wk':
  887. case 'w':
  888. number *= 1e3 * 60 * 60 * 24 * 7;
  889. break;
  890. default:
  891. throw new Error('Invalid unit');
  892. }
  893. return {
  894. value: number,
  895. };
  896. }
  897. function parseNumber(
  898. input: string,
  899. unit: 'k' | 'm' | 'b' | 'K' | 'M' | 'B'
  900. ): {value: number} {
  901. let number = numeric(input);
  902. switch (unit) {
  903. case 'K':
  904. case 'k':
  905. number = number * 1e3;
  906. break;
  907. case 'M':
  908. case 'm':
  909. number = number * 1e6;
  910. break;
  911. case 'B':
  912. case 'b':
  913. number = number * 1e9;
  914. break;
  915. case null:
  916. case undefined:
  917. break;
  918. default:
  919. throw new Error('Invalid unit');
  920. }
  921. return {value: number};
  922. }
  923. function parseSize(input: string, unit: string): {value: number} {
  924. if (!unit) {
  925. unit = 'bytes';
  926. }
  927. let number = numeric(input);
  928. // parser is case insensitive to units
  929. switch (unit.toLowerCase()) {
  930. case 'bit':
  931. number /= 8;
  932. break;
  933. case 'nb':
  934. number /= 2;
  935. break;
  936. case 'bytes':
  937. break;
  938. case 'kb':
  939. number *= 1000;
  940. break;
  941. case 'mb':
  942. number *= 1000 ** 2;
  943. break;
  944. case 'gb':
  945. number *= 1000 ** 3;
  946. break;
  947. case 'tb':
  948. number *= 1000 ** 4;
  949. break;
  950. case 'pb':
  951. number *= 1000 ** 5;
  952. break;
  953. case 'eb':
  954. number *= 1000 ** 6;
  955. break;
  956. case 'zb':
  957. number *= 1000 ** 7;
  958. break;
  959. case 'yb':
  960. number *= 1000 ** 8;
  961. break;
  962. case 'kib':
  963. number *= 1024;
  964. break;
  965. case 'mib':
  966. number *= 1024 ** 2;
  967. break;
  968. case 'gib':
  969. number *= 1024 ** 3;
  970. break;
  971. case 'tib':
  972. number *= 1024 ** 4;
  973. break;
  974. case 'pib':
  975. number *= 1024 ** 5;
  976. break;
  977. case 'eib':
  978. number *= 1024 ** 6;
  979. break;
  980. case 'zib':
  981. number *= 1024 ** 7;
  982. break;
  983. case 'yib':
  984. number *= 1024 ** 8;
  985. break;
  986. default:
  987. throw new Error('Invalid unit');
  988. }
  989. return {value: number};
  990. }
  991. function parsePercentage(input: string): {value: number} {
  992. return {value: numeric(input)};
  993. }
  994. function parseBoolean(input: string): {value: boolean} {
  995. if (/^true$/i.test(input) || input === '1') {
  996. return {value: true};
  997. }
  998. if (/^false$/i.test(input) || input === '0') {
  999. return {value: false};
  1000. }
  1001. throw new Error('Invalid boolean');
  1002. }
  1003. /**
  1004. * Maps token conversion methods to their result types
  1005. */
  1006. type ConverterResultMap = {
  1007. [K in keyof TokenConverter & `token${string}`]: ReturnType<TokenConverter[K]>;
  1008. };
  1009. type Converter = keyof ConverterResultMap;
  1010. /**
  1011. * Converter keys specific to Key and Value tokens
  1012. */
  1013. type KVTokens = Converter & `token${'Key' | 'Value'}${string}`;
  1014. /**
  1015. * Similar to TokenResult, but only includes Key* and Value* token type
  1016. * results. This avoids a circular reference when this is used for the Filter
  1017. * token converter result
  1018. */
  1019. type KVConverter<T extends Token> = ConverterResultMap[KVTokens] & {type: T};
  1020. /**
  1021. * Each token type is discriminated by the `type` field.
  1022. */
  1023. export type TokenResult<T extends Token> = ConverterResultMap[Converter] & {type: T};
  1024. export type ParseResultToken =
  1025. | TokenResult<Token.LOGIC_BOOLEAN>
  1026. | TokenResult<Token.LOGIC_GROUP>
  1027. | TokenResult<Token.FILTER>
  1028. | TokenResult<Token.FREE_TEXT>
  1029. | TokenResult<Token.SPACES>
  1030. | TokenResult<Token.L_PAREN>
  1031. | TokenResult<Token.R_PAREN>;
  1032. /**
  1033. * Result from parsing a search query.
  1034. */
  1035. export type ParseResult = ParseResultToken[];
  1036. /**
  1037. * Configures behavior of search parsing
  1038. */
  1039. export type SearchConfig = {
  1040. /**
  1041. * Keys considered valid for boolean filter types
  1042. */
  1043. booleanKeys: Set<string>;
  1044. /**
  1045. * Keys considered valid for date filter types
  1046. */
  1047. dateKeys: Set<string>;
  1048. /**
  1049. * Disallow free text search
  1050. */
  1051. disallowFreeText: boolean;
  1052. /**
  1053. * Disallow wildcards in free text search AND in tag values
  1054. */
  1055. disallowWildcard: boolean;
  1056. /**
  1057. * Disallow specific boolean operators
  1058. */
  1059. disallowedLogicalOperators: Set<BooleanOperator>;
  1060. /**
  1061. * Keys which are considered valid for duration filters
  1062. */
  1063. durationKeys: Set<string>;
  1064. /**
  1065. * Configures the associated messages for invalid reasons
  1066. */
  1067. invalidMessages: Partial<Record<InvalidReason, string>>;
  1068. /**
  1069. * Keys considered valid for numeric filter types
  1070. */
  1071. numericKeys: Set<string>;
  1072. /**
  1073. * Keys considered valid for the percentage aggregate and may have percentage
  1074. * search values
  1075. */
  1076. percentageKeys: Set<string>;
  1077. /**
  1078. * Keys considered valid for size filter types
  1079. */
  1080. sizeKeys: Set<string>;
  1081. /**
  1082. * Text filter keys we allow to have operators
  1083. */
  1084. textOperatorKeys: Set<string>;
  1085. /**
  1086. * When true, the parser will not parse paren groups and will return individual paren tokens
  1087. */
  1088. flattenParenGroups?: boolean;
  1089. /**
  1090. * A function that returns a warning message for a given filter token key
  1091. */
  1092. getFilterTokenWarning?: (key: string) => React.ReactNode;
  1093. /**
  1094. * Determines if user input values should be parsed
  1095. */
  1096. parse?: boolean;
  1097. /**
  1098. * If validateKeys is set to true, tag keys that don't exist in supportedTags will be consider invalid
  1099. */
  1100. supportedTags?: TagCollection;
  1101. /**
  1102. * If set to true, tag keys that don't exist in supportedTags will be consider invalid
  1103. */
  1104. validateKeys?: boolean;
  1105. };
  1106. export const defaultConfig: SearchConfig = {
  1107. textOperatorKeys: new Set([
  1108. 'release.version',
  1109. 'release.build',
  1110. 'release.package',
  1111. 'release.stage',
  1112. ]),
  1113. durationKeys: new Set(['transaction.duration']),
  1114. percentageKeys: new Set(['percentage']),
  1115. // do not put functions in this Set
  1116. numericKeys: new Set([
  1117. 'project_id',
  1118. 'project.id',
  1119. 'issue.id',
  1120. 'stack.colno',
  1121. 'stack.lineno',
  1122. 'stack.stack_level',
  1123. 'transaction.duration',
  1124. ]),
  1125. dateKeys: new Set([
  1126. 'start',
  1127. 'end',
  1128. 'firstSeen',
  1129. 'lastSeen',
  1130. 'last_seen()',
  1131. 'time',
  1132. 'event.timestamp',
  1133. 'timestamp',
  1134. 'timestamp.to_hour',
  1135. 'timestamp.to_day',
  1136. ]),
  1137. booleanKeys: new Set([
  1138. 'error.handled',
  1139. 'error.unhandled',
  1140. 'stack.in_app',
  1141. 'team_key_transaction',
  1142. ]),
  1143. sizeKeys: new Set([]),
  1144. disallowedLogicalOperators: new Set(),
  1145. disallowFreeText: false,
  1146. disallowWildcard: false,
  1147. invalidMessages: {
  1148. [InvalidReason.FREE_TEXT_NOT_ALLOWED]: t('Free text is not supported in this search'),
  1149. [InvalidReason.WILDCARD_NOT_ALLOWED]: t('Wildcards not supported in search'),
  1150. [InvalidReason.LOGICAL_OR_NOT_ALLOWED]: t(
  1151. 'The OR operator is not allowed in this search'
  1152. ),
  1153. [InvalidReason.LOGICAL_AND_NOT_ALLOWED]: t(
  1154. 'The AND operator is not allowed in this search'
  1155. ),
  1156. [InvalidReason.MUST_BE_QUOTED]: t('Quotes must enclose text or be escaped'),
  1157. [InvalidReason.FILTER_MUST_HAVE_VALUE]: t('Filter must have a value'),
  1158. [InvalidReason.INVALID_BOOLEAN]: t('Invalid boolean. Expected true, 1, false, or 0.'),
  1159. [InvalidReason.INVALID_FILE_SIZE]: t(
  1160. 'Invalid file size. Expected number followed by file size unit suffix'
  1161. ),
  1162. [InvalidReason.INVALID_NUMBER]: t(
  1163. 'Invalid number. Expected number then optional k, m, or b suffix (e.g. 500k)'
  1164. ),
  1165. [InvalidReason.EMPTY_VALUE_IN_LIST_NOT_ALLOWED]: t(
  1166. 'Lists should not have empty values'
  1167. ),
  1168. },
  1169. };
  1170. function tryParseSearch<T extends {config: SearchConfig}>(
  1171. query: string,
  1172. config: T
  1173. ): ParseResult | null {
  1174. try {
  1175. return grammar.parse(query, config);
  1176. } catch (e) {
  1177. Sentry.withScope(scope => {
  1178. scope.setFingerprint(['search-syntax-parse-error']);
  1179. scope.setExtra('message', e.message?.slice(-100));
  1180. scope.setExtra('found', e.found);
  1181. Sentry.captureException(e);
  1182. });
  1183. return null;
  1184. }
  1185. }
  1186. /**
  1187. * Parse a search query into a ParseResult. Failing to parse the search query
  1188. * will result in null.
  1189. */
  1190. export function parseSearch(
  1191. query: string,
  1192. additionalConfig?: Partial<SearchConfig>
  1193. ): ParseResult | null {
  1194. const config = additionalConfig
  1195. ? merge({...defaultConfig}, additionalConfig)
  1196. : defaultConfig;
  1197. return tryParseSearch(query, {
  1198. config,
  1199. TokenConverter,
  1200. TermOperator,
  1201. FilterType,
  1202. });
  1203. }
  1204. /**
  1205. * Join a parsed query array into a string.
  1206. * Should handle null cases to chain easily with parseSearch.
  1207. * Option to add a leading space when applicable (e.g. to combine with other strings).
  1208. * Option to add a space between elements (e.g. for when no Token.Spaces present).
  1209. */
  1210. export function joinQuery(
  1211. parsedTerms: ParseResult | null | undefined,
  1212. leadingSpace?: boolean,
  1213. additionalSpaceBetween?: boolean
  1214. ): string {
  1215. if (!parsedTerms || !parsedTerms.length) {
  1216. return '';
  1217. }
  1218. return (
  1219. (leadingSpace ? ' ' : '') +
  1220. (parsedTerms.length === 1
  1221. ? parsedTerms[0].text
  1222. : parsedTerms.map(p => p.text).join(additionalSpaceBetween ? ' ' : ''))
  1223. );
  1224. }