Browse Source

feat(ddm): Formula highlighting (#65972)

ArthurKnaus 1 year ago
parent
commit
73a632e6e0

+ 98 - 40
static/app/views/ddm/formulaInput.tsx

@@ -1,10 +1,10 @@
-import {useCallback, useMemo, useState} from 'react';
+import {useCallback, useEffect, useMemo, useState} from 'react';
 import styled from '@emotion/styled';
 import debounce from 'lodash/debounce';
 
-import Input from 'sentry/components/input';
-import {Tooltip} from 'sentry/components/tooltip';
+import Input, {inputStyles} from 'sentry/components/input';
 import {t} from 'sentry/locale';
+import {FormularFormatter} from 'sentry/views/ddm/formulaParser/formatter';
 import {joinTokens, parseFormula} from 'sentry/views/ddm/formulaParser/parser';
 import {type TokenList, TokenType} from 'sentry/views/ddm/formulaParser/types';
 
@@ -31,81 +31,139 @@ function unescapeVariables(formula: string): string {
   return formula.replaceAll('$', '');
 }
 
+function equalizeWhitespace(formula: TokenList): TokenList {
+  return formula.map(token => {
+    // Ensure equal spacing
+    if (token.type === TokenType.WHITESPACE) {
+      return {...token, content: ' '};
+    }
+    return token;
+  });
+}
 export function FormulaInput({
   availableVariables,
   formulaVariables,
-  value,
+  value: valueProp,
   onChange,
   ...props
 }: Props) {
-  const [error, setError] = useState<string | null>(null);
+  const [errors, setErrors] = useState<any>([]);
+  const [showErrors, setIsValidationEnabled] = useState(false);
+  const [value, setValue] = useState<string>(() => unescapeVariables(valueProp));
 
-  const defaultValue = useMemo(() => unescapeVariables(value), [value]);
-
-  const validateVariables = useCallback(
-    (tokens: TokenList): string | null => {
-      for (const token of tokens) {
-        if (token.type !== TokenType.VARIABLE) {
-          continue;
-        }
-        if (formulaVariables.has(token.content)) {
-          return t('Formulas cannot reference other formulas.', token.content);
-        }
-        if (!availableVariables.has(token.content)) {
-          return t('Unknown variable "%s"', token.content);
-        }
+  const validateVariable = useCallback(
+    (variable: string): string | null => {
+      if (formulaVariables.has(variable)) {
+        return t('Formulas cannot reference other formulas.', variable);
+      }
+      if (!availableVariables.has(variable)) {
+        return t('Unknown variable "%s"', variable);
       }
-
       return null;
     },
     [availableVariables, formulaVariables]
   );
 
+  useEffect(() => {
+    setIsValidationEnabled(false);
+
+    const timeoutId = setTimeout(() => {
+      setIsValidationEnabled(true);
+    }, 500);
+
+    return () => {
+      clearTimeout(timeoutId);
+    };
+  }, [value]);
+
   const handleChange = useMemo(
     () =>
       debounce((e: React.ChangeEvent<HTMLInputElement>) => {
         const newValue = e.target.value.trim();
 
         let tokens: TokenList = [];
+        const newErrors: any[] = [];
         if (newValue) {
           try {
             tokens = parseFormula(newValue);
           } catch (err) {
-            setError(t('Invalid formula: %s', err.message));
-            return;
+            newErrors.push({
+              message: err.message,
+              start: err.location.start.offset,
+            });
           }
         }
 
-        const validationError = validateVariables(tokens);
-        if (validationError) {
-          setError(validationError);
+        // validate variables
+        let charCount = 0;
+        tokens.forEach(token => {
+          if (token.type === TokenType.VARIABLE) {
+            const error = validateVariable(token.content);
+            if (error) {
+              newErrors.push({
+                message: error,
+                start: charCount,
+                end: charCount + token.content.length,
+              });
+            }
+          }
+          charCount += token.content.length;
+        });
+
+        newErrors.sort((a, b) => a.start - b.start);
+        setErrors(newErrors);
+
+        if (newErrors.length > 0) {
           return;
         }
-
-        setError(null);
-        onChange(joinTokens(escapeVariables(tokens)));
+        onChange(joinTokens(equalizeWhitespace(escapeVariables(tokens))));
       }, 200),
-    [onChange, validateVariables]
+    [onChange, validateVariable]
   );
+
   return (
-    <Tooltip
-      position="top-start"
-      title={error || ''}
-      disabled={!error}
-      skipWrapper
-      forceVisible={!!error}
-    >
+    <Wrapper>
       <StyledInput
         {...props}
-        hasError={!!error}
-        defaultValue={defaultValue}
-        onChange={handleChange}
+        monospace
+        hasError={showErrors && errors.length > 0}
+        defaultValue={value}
+        onChange={e => {
+          setValue(e.target.value);
+          handleChange(e);
+        }}
       />
-    </Tooltip>
+      <RendererOverlay monospace>
+        <FormularFormatter formula={value} errors={showErrors ? errors : []} />
+      </RendererOverlay>
+    </Wrapper>
   );
 }
 
+const Wrapper = styled('div')`
+  position: relative;
+`;
+
+const RendererOverlay = styled('div')`
+  ${inputStyles}
+  border-color: transparent;
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  align-items: center;
+  justify-content: center;
+  pointer-events: none;
+  background: none;
+  white-space: nowrap;
+  overflow: hidden;
+  resize: none;
+`;
+
 const StyledInput = styled(Input)<{hasError: boolean}>`
+  caret-color: ${p => p.theme.subText};
+  color: transparent;
   ${p =>
     p.hasError &&
     `

+ 125 - 0
static/app/views/ddm/formulaParser/formatter.tsx

@@ -0,0 +1,125 @@
+import {Fragment, useMemo} from 'react';
+import {useTheme} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {Tooltip} from 'sentry/components/tooltip';
+import {TokenType} from 'sentry/views/ddm/formulaParser/types';
+
+import grammar from './formulaFormatting.pegjs';
+
+const operatorTokens = new Set([
+  TokenType.PLUS,
+  TokenType.MINUS,
+  TokenType.MULTIPLY,
+  TokenType.DIVIDE,
+]);
+
+interface FormularFormatterProps {
+  formula: string;
+  errors?: {
+    message: string;
+    start: number;
+    end?: number;
+  }[];
+}
+
+export function FormularFormatter({formula, errors}: FormularFormatterProps) {
+  const theme = useTheme();
+  const tokens = useMemo(() => {
+    try {
+      return grammar.parse(formula);
+    } catch (err) {
+      return undefined;
+    }
+  }, [formula]);
+
+  if (!tokens) {
+    // If the formula cannot be parsed, we simply return it without any highlighting
+    return <Fragment>{formula}</Fragment>;
+  }
+
+  const findMatchingError = (charCount: number) => {
+    if (!errors || errors.length === 0) {
+      return null;
+    }
+    return errors.find(
+      error => error.start <= charCount && (!error.end || error.end >= charCount)
+    );
+  };
+
+  let charCount = 0;
+  let hasActiveTooltip = false;
+
+  const renderedTokens = (
+    <Fragment>
+      {tokens.map((token, index) => {
+        const error = findMatchingError(charCount);
+        charCount += token.content.length;
+
+        if (error) {
+          const content = (
+            <Token key={index} style={{color: theme.errorText}}>
+              {token.content}
+            </Token>
+          );
+
+          // Only show one tooltip at a time
+          const showTooltip = !hasActiveTooltip;
+          hasActiveTooltip = true;
+
+          return showTooltip ? (
+            <Tooltip title={error.message} key={index} forceVisible>
+              {content}
+            </Tooltip>
+          ) : (
+            content
+          );
+        }
+
+        if (token.type === TokenType.VARIABLE) {
+          return (
+            <Token key={index} style={{color: theme.yellow400, fontWeight: 'bold'}}>
+              {token.content}
+            </Token>
+          );
+        }
+
+        if (operatorTokens.has(token.type)) {
+          return (
+            <Token key={index} style={{color: theme.blue300}}>
+              {token.content}
+            </Token>
+          );
+        }
+
+        return (
+          <Token key={index} style={{color: theme.gray500}}>
+            {token.content}
+          </Token>
+        );
+      })}
+      {!hasActiveTooltip && findMatchingError(charCount) && (
+        <Tooltip title={findMatchingError(charCount)?.message} forceVisible>
+          &nbsp;
+        </Tooltip>
+      )}
+    </Fragment>
+  );
+
+  // Unexpected EOL might not match a token
+  const remainingError = !hasActiveTooltip && findMatchingError(charCount);
+  return (
+    <Fragment>
+      {renderedTokens}
+      {remainingError && (
+        <Tooltip title={findMatchingError(charCount)?.message} forceVisible>
+          &nbsp;
+        </Tooltip>
+      )}
+    </Fragment>
+  );
+}
+
+const Token = styled('span')`
+  white-space: pre;
+`;

+ 21 - 0
static/app/views/ddm/formulaParser/formulaFormatting.pegjs

@@ -0,0 +1,21 @@
+expression = token*
+token = number / variable / _ / open_paren / close_paren / plus / minus / multiply / divide / generic_token
+
+
+number              = [0-9]+('.'[0-9]+)? { return { type: "number", content: text()}}
+variable            = [a-z]+ { return { type: "variable", content: text()}}
+_                   = " "+ { return { type: "whitespace", content: text()}}
+
+open_paren          = "(" { return { type: "openParen", content: text()}}
+close_paren         = ")" { return { type: "closeParen", content: text()}}
+plus                = "+" { return { type: "plus", content: text()}}
+minus               = "-" { return { type: "minus", content: text()}}
+multiply            = "*" { return { type: "multiply", content: text()}}
+divide              = "/" { return { type: "divide", content: text()}}
+
+// \u00A0-\uFFFF is the entire Unicode BMP _including_ surrogate pairs and
+// unassigned code points, which aren't parse-able naively. A more precise
+// approach would be to define all valid Unicode ranges exactly but for
+// permissive parsing we don't mind the lack of precision.
+generic_token
+  = [a-zA-Z0-9\u00A0-\uFFFF"'`_\-.=><:,*;!\[\]?$%|/\\@#&~^+{}]+ { return { type: 'generic', content: text() } }

+ 1 - 0
static/app/views/ddm/formulaParser/types.ts

@@ -8,6 +8,7 @@ export enum TokenType {
   MINUS = 'minus',
   MULTIPLY = 'multiply',
   DIVIDE = 'divide',
+  GENERIC = 'generic',
 }
 
 export interface Token {

+ 7 - 2
static/app/views/ddm/widget.tsx

@@ -1,4 +1,4 @@
-import {memo, useCallback, useMemo} from 'react';
+import {Fragment, memo, useCallback, useMemo} from 'react';
 import styled from '@emotion/styled';
 import * as Sentry from '@sentry/react';
 import type {SeriesOption} from 'echarts';
@@ -53,6 +53,7 @@ import type {Series} from 'sentry/views/ddm/chart/types';
 import {useFocusArea} from 'sentry/views/ddm/chart/useFocusArea';
 import {useMetricChartSamples} from 'sentry/views/ddm/chart/useMetricChartSamples';
 import type {FocusAreaProps} from 'sentry/views/ddm/context';
+import {FormularFormatter} from 'sentry/views/ddm/formulaParser/formatter';
 import {QuerySymbol} from 'sentry/views/ddm/querySymbol';
 import {SummaryTable} from 'sentry/views/ddm/summaryTable';
 import {useSeriesHover} from 'sentry/views/ddm/useSeriesHover';
@@ -99,7 +100,11 @@ export function getWidgetTitle(queries: MetricsQueryApiQueryParams[]) {
   if (filteredQueries.length === 1) {
     const firstQuery = filteredQueries[0];
     if (isMetricFormula(firstQuery)) {
-      return formatMetricsFormula(firstQuery.formula);
+      return (
+        <Fragment>
+          = <FormularFormatter formula={formatMetricsFormula(firstQuery.formula)} />
+        </Fragment>
+      );
     }
     return getFormattedMQL(firstQuery);
   }