// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable rulesdir/no-imperative-dom-api */

import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import type * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
import * as Components from '../../ui/legacy/components/utils/utils.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';

import {ImagePreviewPopover} from './ImagePreviewPopover.js';
import {unescapeCssString} from './StylesSidebarPane.js';

const UIStrings = {
  /**
   * @description Text that is announced by the screen reader when the user focuses on an input field for entering the name of a CSS property in the Styles panel
   * @example {margin} PH1
   */
  cssPropertyName: '`CSS` property name: {PH1}',
  /**
   * @description Text that is announced by the screen reader when the user focuses on an input field for entering the value of a CSS property in the Styles panel
   * @example {10px} PH1
   */
  cssPropertyValue: '`CSS` property value: {PH1}',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/elements/PropertyRenderer.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

function mergeWithSpacing(nodes: Node[], merge: Node[]): Node[] {
  const result = [...nodes];
  if (SDK.CSSPropertyParser.requiresSpace(nodes, merge)) {
    result.push(document.createTextNode(' '));
  }
  result.push(...merge);
  return result;
}

export interface MatchRenderer<MatchT extends SDK.CSSPropertyParser.Match> {
  readonly matchType: Platform.Constructor.Constructor<MatchT>;
  render(match: MatchT, context: RenderingContext): Node[];
}

// A mixin to automatically expose the match type on specific renrerers
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function rendererBase<MatchT extends SDK.CSSPropertyParser.Match>(
    matchT: Platform.Constructor.Constructor<MatchT>) {
  abstract class RendererBase implements MatchRenderer<MatchT> {
    readonly matchType = matchT;
    render(_match: MatchT, _context: RenderingContext): Node[] {
      return [];
    }
  }
  return RendererBase;
}

/**
 * This class implements highlighting for rendered nodes in value traces. On hover, all nodes belonging to the same
 * Match (using object identity) are highlighted.
 **/
export class Highlighting {
  static readonly REGISTRY_NAME = 'css-value-tracing';
  // This holds a stack of active ranges, the top-stack is the currently highlighted set. mouseenter and mouseleave
  // push and pop range sets, respectively.
  readonly #activeHighlights: Range[][] = [];
  // We hold a bidirectional mapping between nodes and matches. A node can belong to multiple matches when matches are
  // nested (via function arguments for instance).
  readonly #nodesForMatches = new Map<SDK.CSSPropertyParser.Match, Node[][]>();
  readonly #matchesForNodes = new Map<Node, SDK.CSSPropertyParser.Match[]>();
  readonly #registry: Highlight;
  readonly #boundOnEnter: (e: Event) => void;
  readonly #boundOnExit: (e: Event) => void;

  constructor() {
    const registry = CSS.highlights.get(Highlighting.REGISTRY_NAME);
    this.#registry = registry ?? new Highlight();
    if (!registry) {
      CSS.highlights.set(Highlighting.REGISTRY_NAME, this.#registry);
    }
    this.#boundOnExit = this.#onExit.bind(this);
    this.#boundOnEnter = this.#onEnter.bind(this);
  }

  addMatch(match: SDK.CSSPropertyParser.Match, nodes: Node[]): void {
    if (nodes.length > 0) {
      const ranges = this.#nodesForMatches.get(match);
      if (ranges) {
        ranges.push(nodes);
      } else {
        this.#nodesForMatches.set(match, [nodes]);
      }
    }
    for (const node of nodes) {
      const matches = this.#matchesForNodes.get(node);
      if (matches) {
        matches.push(match);
      } else {
        this.#matchesForNodes.set(node, [match]);
      }
      if (node instanceof HTMLElement) {
        node.onmouseenter = this.#boundOnEnter;
        node.onmouseleave = this.#boundOnExit;
        node.onfocus = this.#boundOnEnter;
        node.onblur = this.#boundOnExit;
        node.tabIndex = 0;
      }
    }
  }

  * #nodeRangesHitByMouseEvent(e: Event): Generator<Node[]> {
    for (const node of e.composedPath()) {
      const matches = this.#matchesForNodes.get(node as Node);
      if (matches) {
        for (const match of matches) {
          yield* this.#nodesForMatches.get(match) ?? [];
        }
        break;
      }
    }
  }

  #onEnter(e: Event): void {
    this.#registry.clear();
    this.#activeHighlights.push([]);
    for (const nodeRange of this.#nodeRangesHitByMouseEvent(e)) {
      const range = new Range();
      const begin = nodeRange[0];
      const end = nodeRange[nodeRange.length - 1];
      if (begin.parentNode && end.parentNode) {
        range.setStartBefore(begin);
        range.setEndAfter(end);
        this.#activeHighlights[this.#activeHighlights.length - 1].push(range);
        this.#registry.add(range);
      }
    }
  }

  #onExit(): void {
    this.#registry.clear();
    this.#activeHighlights.pop();
    if (this.#activeHighlights.length > 0) {
      this.#activeHighlights[this.#activeHighlights.length - 1].forEach(range => this.#registry.add(range));
    }
  }
}

/**
 * This class is used to guide value tracing when passed to the Renderer. Tracing has two phases. First, substitutions
 * such as var() are applied step by step. In each step, all vars in the value are replaced by their definition until no
 * vars remain. In the second phase, we evaluate other functions such as calc() or min() or color-mix(). Which CSS
 * function types are actually substituted or evaluated is not relevant here, rather it is decided by an individual
 * MatchRenderer.
 *
 * Callers don't need to keep track of the tracing depth (i.e., the number of substitution/evaluation steps).
 * TracingContext is stateful and keeps track of the depth, so callers can progressively produce steps by calling
 * TracingContext#nextSubstitution or TracingContext#nextEvaluation. Calling Renderer with the tracing context will then
 * produce the next step of tracing. The tracing depth is passed to the individual MatchRenderers by way of
 * TracingContext#substitution or TracingContext#applyEvaluation/TracingContext#evaluation (see function-level comments
 * about how these two play together), which MatchRenderers call to request a fresh TracingContext for the next level of
 * substitution/evaluation.
 **/
export class TracingContext {
  #substitutionDepth = 0;
  #hasMoreSubstitutions: boolean;
  #parent: TracingContext|null = null;
  #evaluationCount = 0;
  #appliedEvaluations = 0;
  #hasMoreEvaluations = true;
  #longhandOffset: number;
  readonly #highlighting: Highlighting;
  #parsedValueCache = new Map<SDK.CSSProperty.CSSProperty|SDK.CSSMatchedStyles.CSSRegisteredProperty, {
    matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
    computedStyles: Map<string, string>,
    parsedValue: SDK.CSSPropertyParser.BottomUpTreeMatching|null,
  }>();
  #root: {match: SDK.CSSPropertyParser.Match, context: RenderingContext}|null = null;
  #propertyName: string|null;
  #asyncEvalCallbacks: Array<(() => Promise<boolean>)|undefined> = [];
  readonly expandPercentagesInShorthands: boolean;

  constructor(
      highlighting: Highlighting, expandPercentagesInShorthands: boolean, initialLonghandOffset = 0,
      matchedResult?: SDK.CSSPropertyParser.BottomUpTreeMatching) {
    this.#highlighting = highlighting;
    this.#hasMoreSubstitutions =
        matchedResult?.hasMatches(
            SDK.CSSPropertyParserMatchers.VariableMatch, SDK.CSSPropertyParserMatchers.BaseVariableMatch,
            SDK.CSSPropertyParserMatchers.AttributeMatch, SDK.CSSPropertyParserMatchers.EnvFunctionMatch) ??
        false;
    this.#propertyName = matchedResult?.ast.propertyName ?? null;
    this.#longhandOffset = initialLonghandOffset;
    this.expandPercentagesInShorthands = expandPercentagesInShorthands;
  }

  get highlighting(): Highlighting {
    return this.#highlighting;
  }

  get root(): {match: SDK.CSSPropertyParser.Match, context: RenderingContext}|null {
    return this.#root;
  }

  get propertyName(): string|null {
    return this.#propertyName;
  }

  get longhandOffset(): number {
    return this.#longhandOffset;
  }

  renderingContext(context: RenderingContext): RenderingContext {
    return new RenderingContext(
        context.ast, context.property, context.renderers, context.matchedResult, context.cssControls, context.options,
        this);
  }

  nextSubstitution(): boolean {
    if (!this.#hasMoreSubstitutions) {
      return false;
    }
    this.#substitutionDepth++;
    this.#hasMoreSubstitutions = false;
    this.#asyncEvalCallbacks = [];
    return true;
  }

  nextEvaluation(): boolean {
    if (this.#hasMoreSubstitutions) {
      throw new Error('Need to apply substitutions first');
    }
    if (!this.#hasMoreEvaluations) {
      return false;
    }
    this.#appliedEvaluations = 0;
    this.#hasMoreEvaluations = false;
    this.#evaluationCount++;
    this.#asyncEvalCallbacks = [];
    return true;
  }

  #setHasMoreEvaluations(value: boolean): void {
    if (this.#parent) {
      this.#parent.#setHasMoreEvaluations(value);
    }
    this.#hasMoreEvaluations = value;
  }

  // Evaluations are applied bottom up, i.e., innermost sub-expressions are evaluated first before evaluating any
  // function call. This function produces TracingContexts for each of the arguments of the function call which should
  // be passed to the Renderer calls for the respective subtrees.
  evaluation(args: unknown[], root: {match: SDK.CSSPropertyParser.Match, context: RenderingContext}|null = null):
      TracingContext[]|null {
    const childContexts = args.map(() => {
      const child = new TracingContext(this.#highlighting, this.expandPercentagesInShorthands);
      child.#parent = this;
      child.#substitutionDepth = this.#substitutionDepth;
      child.#evaluationCount = this.#evaluationCount;
      child.#hasMoreSubstitutions = this.#hasMoreSubstitutions;
      child.#parsedValueCache = this.#parsedValueCache;
      child.#root = root;
      child.#propertyName = this.propertyName;
      return child;
    });
    return childContexts;
  }

  #setAppliedEvaluations(value: number): void {
    if (this.#parent) {
      this.#parent.#setAppliedEvaluations(value);
    }
    this.#appliedEvaluations = Math.max(this.#appliedEvaluations, value);
  }

  // After rendering the arguments of a function call, the TracingContext produced by TracingContext#evaluation need to
  // be passed here to determine whether the "current" function call should be evaluated or not. If so, the
  // evaluation callback is run. The callback should return synchronously an array of Nodes as placeholder to be
  // rendered immediately and optionally a callback for asynchronous updates of the placeholder nodes. The callback
  // returns a boolean indicating whether the update was successful or not.
  applyEvaluation(
      children: TracingContext[],
      evaluation: () => ({placeholder: Node[], asyncEvalCallback?: () => Promise<boolean>})): Node[]|null {
    if (this.#evaluationCount === 0 || children.some(child => child.#appliedEvaluations >= this.#evaluationCount)) {
      this.#setHasMoreEvaluations(true);
      children.forEach(child => this.#asyncEvalCallbacks.push(...child.#asyncEvalCallbacks));
      return null;
    }
    this.#setAppliedEvaluations(
        children.map(child => child.#appliedEvaluations).reduce((a, b) => Math.max(a, b), 0) + 1);
    const {placeholder, asyncEvalCallback} = evaluation();
    this.#asyncEvalCallbacks.push(asyncEvalCallback);
    return placeholder;
  }

  #setHasMoreSubstitutions(): void {
    if (this.#parent) {
      this.#parent.#setHasMoreSubstitutions();
    }
    this.#hasMoreSubstitutions = true;
  }

  // Request a tracing context for the next level of substitutions. If this returns null, no further substitution should
  // be applied on this branch of the AST. Otherwise, the TracingContext should be passed to the Renderer call for the
  // substitution subtree.
  substitution(root: {match: SDK.CSSPropertyParser.Match, context: RenderingContext}|null = null): TracingContext|null {
    if (this.#substitutionDepth <= 0) {
      this.#setHasMoreSubstitutions();
      return null;
    }
    const child = new TracingContext(this.#highlighting, this.expandPercentagesInShorthands);
    child.#parent = this;
    child.#substitutionDepth = this.#substitutionDepth - 1;
    child.#evaluationCount = this.#evaluationCount;
    child.#hasMoreSubstitutions = false;
    child.#parsedValueCache = this.#parsedValueCache;
    child.#root = root;
    // Async evaluation callbacks need to be gathered across substitution contexts so that they bubble to the root. That
    // is not the case for evaluation contexts since `applyEvaluation` conditionally collects callbacks for its subtree
    // already.
    child.#asyncEvalCallbacks = this.#asyncEvalCallbacks;
    child.#longhandOffset =
        this.#longhandOffset + (root?.context.matchedResult.getComputedLonghandName(root?.match.node) ?? 0);
    child.#propertyName = this.propertyName;
    return child;
  }

  cachedParsedValue(
      declaration: SDK.CSSProperty.CSSProperty|SDK.CSSMatchedStyles.CSSRegisteredProperty,
      matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
      computedStyles: Map<string, string>): SDK.CSSPropertyParser.BottomUpTreeMatching|null {
    const cachedValue = this.#parsedValueCache.get(declaration);
    if (cachedValue?.matchedStyles === matchedStyles && cachedValue?.computedStyles === computedStyles) {
      return cachedValue.parsedValue;
    }
    const parsedValue = declaration.parseValue(matchedStyles, computedStyles);
    this.#parsedValueCache.set(declaration, {matchedStyles, computedStyles, parsedValue});
    return parsedValue;
  }

  // If this returns `false`, all evaluations for this trace line have failed.
  async runAsyncEvaluations(): Promise<boolean> {
    const results = await Promise.all(this.#asyncEvalCallbacks.map(callback => callback?.()));
    return results.some(result => result !== false);
  }
}

export class RenderingContext {
  constructor(
      readonly ast: SDK.CSSPropertyParser.SyntaxTree, readonly property: SDK.CSSProperty.CSSProperty|null,
      readonly renderers:
          Map<Platform.Constructor.Constructor<SDK.CSSPropertyParser.Match>,
              MatchRenderer<SDK.CSSPropertyParser.Match>>,
      readonly matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching,
      readonly cssControls?: SDK.CSSPropertyParser.CSSControlMap, readonly options: {readonly?: boolean} = {},
      readonly tracing?: TracingContext) {
  }

  addControl(cssType: string, control: HTMLElement): void {
    if (this.cssControls) {
      const controls = this.cssControls.get(cssType);
      if (!controls) {
        this.cssControls.set(cssType, [control]);
      } else {
        controls.push(control);
      }
    }
  }

  getComputedLonghandName(node: CodeMirror.SyntaxNode): string|null {
    if (!this.matchedResult.ast.propertyName) {
      return null;
    }
    const longhands =
        SDK.CSSMetadata.cssMetadata().getLonghands(this.tracing?.propertyName ?? this.matchedResult.ast.propertyName);
    if (!longhands) {
      return null;
    }
    const index = this.matchedResult.getComputedLonghandName(node);
    return longhands[index + (this.tracing?.longhandOffset ?? 0)] ?? null;
  }

  findParent<MatchT extends SDK.CSSPropertyParser.Match>(
      node: CodeMirror.SyntaxNode|null, matchType: Platform.Constructor.Constructor<MatchT>): MatchT|null {
    while (node) {
      const match = this.matchedResult.getMatch(node);
      if (match instanceof matchType) {
        return match;
      }
      node = node.parent;
    }
    if (this.tracing?.root) {
      return this.tracing.root.context.findParent(this.tracing.root.match.node, matchType);
    }
    return null;
  }
}

export class Renderer extends SDK.CSSPropertyParser.TreeWalker {
  readonly #matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching;
  #output: Node[] = [];
  readonly #context: RenderingContext;

  constructor(
      ast: SDK.CSSPropertyParser.SyntaxTree,
      property: SDK.CSSProperty.CSSProperty|null,
      renderers:
          Map<Platform.Constructor.Constructor<SDK.CSSPropertyParser.Match>,
              MatchRenderer<SDK.CSSPropertyParser.Match>>,
      matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching,
      cssControls: SDK.CSSPropertyParser.CSSControlMap,
      options: {
        readonly?: boolean,
      },
      tracing: TracingContext|undefined,
  ) {
    super(ast);
    this.#matchedResult = matchedResult;
    this.#context =
        new RenderingContext(this.ast, property, renderers, this.#matchedResult, cssControls, options, tracing);
  }

  static render(nodeOrNodes: CodeMirror.SyntaxNode|CodeMirror.SyntaxNode[], context: RenderingContext):
      {nodes: Node[], cssControls: SDK.CSSPropertyParser.CSSControlMap} {
    if (!Array.isArray(nodeOrNodes)) {
      return this.render([nodeOrNodes], context);
    }
    const cssControls = new SDK.CSSPropertyParser.CSSControlMap();
    const renderers = nodeOrNodes.map(
        node => this.walkExcludingSuccessors(
            context.ast.subtree(node), context.property, context.renderers, context.matchedResult, cssControls,
            context.options, context.tracing));
    const nodes = renderers.map(node => node.#output).reduce(mergeWithSpacing, []);
    return {nodes, cssControls};
  }

  static renderInto(
      nodeOrNodes: CodeMirror.SyntaxNode|CodeMirror.SyntaxNode[], context: RenderingContext,
      parent: Node): {nodes: Node[], cssControls: SDK.CSSPropertyParser.CSSControlMap} {
    const {nodes, cssControls} = this.render(nodeOrNodes, context);
    if (parent.lastChild && SDK.CSSPropertyParser.requiresSpace([parent.lastChild], nodes)) {
      parent.appendChild(document.createTextNode(' '));
    }
    nodes.map(n => parent.appendChild(n));
    return {nodes, cssControls};
  }

  renderedMatchForTest(_nodes: Node[], _match: SDK.CSSPropertyParser.Match): void {
  }

  protected override enter({node}: SDK.CSSPropertyParser.SyntaxNodeRef): boolean {
    const match = this.#matchedResult.getMatch(node);
    const renderer = match &&
        this.#context.renderers.get(match.constructor as Platform.Constructor.Constructor<SDK.CSSPropertyParser.Match>);
    if (renderer || match instanceof SDK.CSSPropertyParserMatchers.TextMatch) {
      const output = renderer ? renderer.render(match, this.#context) :
                                (match as SDK.CSSPropertyParserMatchers.TextMatch).render();
      this.#context.tracing?.highlighting.addMatch(match, output);
      this.renderedMatchForTest(output, match);
      this.#output = mergeWithSpacing(this.#output, output);
      return false;
    }

    return true;
  }

  static renderNameElement(name: string): HTMLElement {
    const nameElement = document.createElement('span');
    nameElement.setAttribute(
        'jslog', `${VisualLogging.key().track({
          change: true,
          keydown: 'ArrowLeft|ArrowUp|PageUp|Home|PageDown|ArrowRight|ArrowDown|End|Space|Tab|Enter|Escape',
        })}`);
    UI.ARIAUtils.setLabel(nameElement, i18nString(UIStrings.cssPropertyName, {PH1: name}));
    nameElement.className = 'webkit-css-property';
    nameElement.textContent = name;
    nameElement.normalize();
    nameElement.tabIndex = -1;
    return nameElement;
  }

  // This function renders a property value as HTML, customizing the presentation with a set of given AST matchers. This
  // comprises the following steps:
  // 1. Build an AST of the property.
  // 2. Apply tree matchers during bottom up traversal.
  // 3. Render the value from left to right into HTML, deferring rendering of matched subtrees to the matchers
  //
  // More general, longer matches take precedence over shorter, more specific matches. Whitespaces are normalized, for
  // unmatched text and around rendered matching results.
  static renderValueElement(
      property: SDK.CSSProperty.CSSProperty|{name: string, value: string},
      matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching|null,
      renderers: Array<MatchRenderer<SDK.CSSPropertyParser.Match>>,
      tracing?: TracingContext): {valueElement: HTMLElement, cssControls: SDK.CSSPropertyParser.CSSControlMap} {
    const valueElement = document.createElement('span');
    valueElement.setAttribute(
        'jslog', `${VisualLogging.value().track({
          change: true,
          keydown: 'ArrowLeft|ArrowUp|PageUp|Home|PageDown|ArrowRight|ArrowDown|End|Space|Tab|Enter|Escape',
        })}`);
    UI.ARIAUtils.setLabel(valueElement, i18nString(UIStrings.cssPropertyValue, {PH1: property.value}));
    valueElement.className = 'value';
    valueElement.tabIndex = -1;
    const {nodes, cssControls} = this.renderValueNodes(property, matchedResult, renderers, tracing);
    nodes.forEach(node => valueElement.appendChild(node));
    valueElement.normalize();
    return {valueElement, cssControls};
  }

  static renderValueNodes(
      property: SDK.CSSProperty.CSSProperty|{name: string, value: string},
      matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching|null,
      renderers: Array<MatchRenderer<SDK.CSSPropertyParser.Match>>,
      tracing?: TracingContext): {nodes: Node[], cssControls: SDK.CSSPropertyParser.CSSControlMap} {
    if (!matchedResult) {
      return {nodes: [document.createTextNode(property.value)], cssControls: new Map()};
    }
    const rendererMap = new Map<
        Platform.Constructor.Constructor<SDK.CSSPropertyParser.Match>, MatchRenderer<SDK.CSSPropertyParser.Match>>();
    for (const renderer of renderers) {
      rendererMap.set(renderer.matchType, renderer);
    }

    const context = new RenderingContext(
        matchedResult.ast, property instanceof SDK.CSSProperty.CSSProperty ? property : null, rendererMap,
        matchedResult, undefined, {}, tracing);
    return Renderer.render([matchedResult.ast.tree, ...matchedResult.ast.trailingNodes], context);
  }
}

// clang-format off
export class URLRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.URLMatch) {
  // clang-format on
  constructor(private readonly rule: SDK.CSSRule.CSSRule|null, private readonly node: SDK.DOMModel.DOMNode|null) {
    super();
  }
  override render(match: SDK.CSSPropertyParserMatchers.URLMatch): Node[] {
    const url = unescapeCssString(match.url) as Platform.DevToolsPath.UrlString;
    const container = document.createDocumentFragment();
    UI.UIUtils.createTextChild(container, 'url(');
    let hrefUrl: Platform.DevToolsPath.UrlString|null = null;
    if (this.rule?.resourceURL()) {
      hrefUrl = Common.ParsedURL.ParsedURL.completeURL(this.rule.resourceURL(), url);
    } else if (this.node) {
      hrefUrl = this.node.resolveURL(url);
    }
    const link = ImagePreviewPopover.setImageUrl(
        Components.Linkifier.Linkifier.linkifyURL(hrefUrl || url, {
          text: url,
          preventClick: false,
          // crbug.com/1027168
          // We rely on CSS text-overflow: ellipsis to hide long URLs in the Style panel,
          // so that we don't have to keep two versions (original vs. trimmed) of URL
          // at the same time, which complicates both StylesSidebarPane and StylePropertyTreeElement.
          bypassURLTrimming: true,
          showColumnNumber: false,
          inlineFrameIndex: 0,
        }),
        hrefUrl || url);
    container.appendChild(link);
    UI.UIUtils.createTextChild(container, ')');
    return [container];
  }
}

// clang-format off
export class StringRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.StringMatch) {
  // clang-format on
  override render(match: SDK.CSSPropertyParserMatchers.StringMatch): Node[] {
    const element = document.createElement('span');
    element.innerText = match.text;
    UI.Tooltip.Tooltip.install(element, unescapeCssString(match.text));
    return [element];
  }
}

// clang-format off
export class BinOpRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.BinOpMatch) {
  // clang-format on
  override render(match: SDK.CSSPropertyParserMatchers.BinOpMatch, context: RenderingContext): Node[] {
    const [lhs, binop, rhs] = SDK.CSSPropertyParser.ASTUtils.children(match.node).map(child => {
      const span = document.createElement('span');
      Renderer.renderInto(child, context, span);
      return span;
    });

    return [lhs, document.createTextNode(' '), binop, document.createTextNode(' '), rhs];
  }
}
