diff --git a/extensions/ql-vscode/src/view/compare-performance/ComparePerformance.tsx b/extensions/ql-vscode/src/view/compare-performance/ComparePerformance.tsx
index 51555600d4a..74107a40374 100644
--- a/extensions/ql-vscode/src/view/compare-performance/ComparePerformance.tsx
+++ b/extensions/ql-vscode/src/view/compare-performance/ComparePerformance.tsx
@@ -12,7 +12,7 @@ import type {
import { formatDecimal } from "../../common/number";
import { styled } from "styled-components";
import { Codicon, ViewTitle, WarningBox } from "../common";
-import { abbreviateRASteps } from "./RAPrettyPrinter";
+import { abbreviateRANames, abbreviateRASteps } from "./RAPrettyPrinter";
const enum AbsentReason {
NotSeen = "NotSeen",
@@ -190,7 +190,7 @@ const SortOrderDropdown = styled.select``;
interface PipelineStepProps {
before: number | undefined;
after: number | undefined;
- step: string;
+ step: React.ReactNode;
}
/**
@@ -216,6 +216,10 @@ function PipelineStep(props: PipelineStepProps) {
);
}
+const HeaderTR = styled.tr`
+ background-color: var(--vscode-sideBar-background);
+`;
+
interface HighLevelStatsProps {
before: PredicateInfo;
after: PredicateInfo;
@@ -229,13 +233,13 @@ function HighLevelStats(props: HighLevelStatsProps) {
before.evaluationCount > 1 || after.evaluationCount > 1;
return (
<>
-
+
{hasBefore ? "Before" : ""}
{hasAfter ? "After" : ""}
{hasBefore && hasAfter ? "Delta" : ""}
Stats
-
+
{showEvaluationCount && (
) {
totalDiff += row.diff;
}
+ const rowNames = abbreviateRANames(rows.map((row) => row.name));
+
return (
<>
Performance comparison
@@ -393,16 +399,16 @@ export function ComparePerformance(_: Record) {
-
+
Before
After
Delta
Predicate
-
+
- {rows.map((row) => (
+ {rows.map((row, rowIndex) => (
) {
{renderAbsoluteValue(row.before)}
{renderAbsoluteValue(row.after)}
{renderDelta(row.diff)}
- {row.name}
+ {rowNames[rowIndex]}
{expandedPredicates.has(row.name) && (
<>
@@ -433,7 +439,7 @@ export function ComparePerformance(_: Record) {
row.after.pipelines,
).map(({ name, first, second }, pipelineIndex) => (
-
+
|
{first != null && "Before"}
{second != null && "After"}
@@ -448,7 +454,7 @@ export function ComparePerformance(_: Record) {
? " (before)"
: ""}
-
+
{abbreviateRASteps(first?.steps ?? second!.steps).map(
(step, index) => (
();
-
- constructor(readonly names: string[]) {
- const qnames = names.map(parseName);
- const builder = new TrieBuilder();
- qnames
- .map((qname) => builder.visitQName(qname))
- .forEach((r, index) => {
- this.abbreviations.set(names[index], r.abbreviate());
- });
- }
-
- public getAbbreviation(name: string) {
- return this.abbreviations.get(name) ?? name;
- }
-}
-
-/** Name parsed into the form `prefix::name` */
-interface QualifiedName {
- prefix?: QualifiedName;
- name: string;
- args?: QualifiedName[];
-}
-
-function tokeniseName(text: string) {
- return Array.from(text.matchAll(/:+|<|>|,|"[^"]+"|`[^`]+`|[^:<>,"`]+/g));
-}
-
-function parseName(text: string): QualifiedName {
- const tokens = tokeniseName(text);
-
- function next() {
- return tokens.pop()![0];
- }
- function peek() {
- return tokens[tokens.length - 1][0];
- }
- function skipToken(token: string) {
- if (tokens.length > 0 && peek() === token) {
- tokens.pop();
- return true;
- } else {
- return false;
- }
- }
-
- function parseQName(): QualifiedName {
- let args: QualifiedName[] | undefined;
- if (skipToken(">")) {
- args = [];
- while (peek() !== "<") {
- args.push(parseQName());
- skipToken(",");
- }
- args.reverse();
- skipToken("<");
- }
- const name = next();
- const prefix = skipToken("::") ? parseQName() : undefined;
- return {
- prefix,
- name,
- args,
- };
- }
-
- const result = parseQName();
- if (tokens.length > 0) {
- // It's a parse error if we did not consume all tokens.
- // Just treat the whole text as the 'name'.
- return { prefix: undefined, name: text, args: undefined };
- }
- return result;
-}
-
-class TrieNode {
- children = new Map();
- constructor(readonly index: number) {}
-}
-
-interface VisitResult {
- node: TrieNode;
- abbreviate: () => string;
-}
-
-class TrieBuilder {
- root = new TrieNode(0);
- nextId = 1;
-
- getOrCreate(trieNode: TrieNode, child: string) {
- const { children } = trieNode;
- let node = children.get(child);
- if (node == null) {
- node = new TrieNode(this.nextId++);
- children.set(child, node);
- }
- return node;
- }
-
- visitQName(qname: QualifiedName): VisitResult {
- const prefix =
- qname.prefix != null ? this.visitQName(qname.prefix) : undefined;
- const trieNodeBeforeArgs = this.getOrCreate(
- prefix?.node ?? this.root,
- qname.name,
- );
- let trieNode = trieNodeBeforeArgs;
- const args = qname.args?.map((arg) => this.visitQName(arg));
- if (args != null) {
- const argKey = args.map((arg) => arg.node.index).join(",");
- trieNode = this.getOrCreate(trieNodeBeforeArgs, argKey);
- }
- return {
- node: trieNode,
- abbreviate: () => {
- let result = "";
- if (prefix != null) {
- result += prefix.abbreviate();
- result += "::";
- }
- result += qname.name;
- if (args != null) {
- result += "<";
- if (trieNodeBeforeArgs.children.size === 1) {
- result += "...";
- } else {
- result += args.map((arg) => arg.abbreviate()).join(",");
- }
- result += ">";
- }
- return result;
- },
- };
- }
-}
-
-const nameTokenRegex = /\b[^ ]+::[^ (]+\b/g;
-
-export function abbreviateRASteps(steps: string[]): string[] {
- const nameTokens = steps.flatMap((step) =>
- Array.from(step.matchAll(nameTokenRegex)).map((tok) => tok[0]),
- );
- const nameSet = new NameSet(nameTokens);
- return steps.map((step) =>
- step.replace(nameTokenRegex, (match) => nameSet.getAbbreviation(match)),
- );
-}
diff --git a/extensions/ql-vscode/src/view/compare-performance/RAPrettyPrinter.tsx b/extensions/ql-vscode/src/view/compare-performance/RAPrettyPrinter.tsx
new file mode 100644
index 00000000000..39038a8e520
--- /dev/null
+++ b/extensions/ql-vscode/src/view/compare-performance/RAPrettyPrinter.tsx
@@ -0,0 +1,281 @@
+import { Fragment, useState } from "react";
+import { styled } from "styled-components";
+
+/**
+ * A set of names, for generating unambiguous abbreviations.
+ */
+class NameSet {
+ private readonly abbreviations = new Map();
+
+ constructor(readonly names: string[]) {
+ const qnames = names.map(parseName);
+ const builder = new TrieBuilder();
+ qnames
+ .map((qname) => builder.visitQName(qname))
+ .forEach((r, index) => {
+ this.abbreviations.set(names[index], r.abbreviate(true));
+ });
+ }
+
+ public getAbbreviation(name: string): React.ReactNode {
+ return this.abbreviations.get(name) ?? name;
+ }
+}
+
+/** Name parsed into the form `prefix::name` */
+interface QualifiedName {
+ prefix?: QualifiedName;
+ name: string;
+ args?: QualifiedName[];
+}
+
+function qnameToString(name: QualifiedName): string {
+ const parts: string[] = [];
+ if (name.prefix != null) {
+ parts.push(qnameToString(name.prefix));
+ parts.push("::");
+ }
+ parts.push(name.name);
+ if (name.args != null && name.args.length > 0) {
+ parts.push("<");
+ parts.push(name.args.map(qnameToString).join(","));
+ parts.push(">");
+ }
+ return parts.join("");
+}
+
+function tokeniseName(text: string) {
+ return Array.from(text.matchAll(/:+|<|>|,|"[^"]+"|`[^`]+`|[^:<>,"`]+/g));
+}
+
+function parseName(text: string): QualifiedName {
+ const tokens = tokeniseName(text);
+
+ function next() {
+ return tokens.pop()![0];
+ }
+ function peek() {
+ return tokens[tokens.length - 1][0];
+ }
+ function skipToken(token: string) {
+ if (tokens.length > 0 && peek() === token) {
+ tokens.pop();
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ function parseQName(): QualifiedName {
+ let args: QualifiedName[] | undefined;
+ if (skipToken(">")) {
+ args = [];
+ while (peek() !== "<") {
+ args.push(parseQName());
+ skipToken(",");
+ }
+ args.reverse();
+ skipToken("<");
+ }
+ const name = next();
+ const prefix = skipToken("::") ? parseQName() : undefined;
+ return {
+ prefix,
+ name,
+ args,
+ };
+ }
+
+ const result = parseQName();
+ if (tokens.length > 0) {
+ // It's a parse error if we did not consume all tokens.
+ // Just treat the whole text as the 'name'.
+ return { prefix: undefined, name: text, args: undefined };
+ }
+ return result;
+}
+
+class TrieNode {
+ children = new Map();
+ constructor(readonly index: number) {}
+}
+
+interface VisitResult {
+ node: TrieNode;
+ abbreviate: (isRoot?: boolean) => React.ReactNode;
+}
+
+class TrieBuilder {
+ root = new TrieNode(0);
+ nextId = 1;
+
+ getOrCreate(trieNode: TrieNode, child: string) {
+ const { children } = trieNode;
+ let node = children.get(child);
+ if (node == null) {
+ node = new TrieNode(this.nextId++);
+ children.set(child, node);
+ }
+ return node;
+ }
+
+ visitQName(qname: QualifiedName): VisitResult {
+ const prefix =
+ qname.prefix != null ? this.visitQName(qname.prefix) : undefined;
+ const trieNodeBeforeArgs = this.getOrCreate(
+ prefix?.node ?? this.root,
+ qname.name,
+ );
+ let trieNode = trieNodeBeforeArgs;
+ const args = qname.args?.map((arg) => this.visitQName(arg));
+ if (args != null) {
+ const argKey = args.map((arg) => arg.node.index).join(",");
+ trieNode = this.getOrCreate(trieNodeBeforeArgs, argKey);
+ }
+ return {
+ node: trieNode,
+ abbreviate: (isRoot = false) => {
+ const result: React.ReactNode[] = [];
+ if (prefix != null) {
+ result.push(prefix.abbreviate());
+ result.push("::");
+ }
+ const { name } = qname;
+ const hash = name.indexOf("#");
+ if (hash !== -1 && isRoot) {
+ const shortName = name.substring(0, hash);
+ result.push({shortName});
+ result.push(name.substring(hash));
+ } else {
+ result.push(isRoot ? {name} : name);
+ }
+ if (args != null) {
+ result.push("<");
+ if (trieNodeBeforeArgs.children.size === 1) {
+ const argsText = qname
+ .args!.map((arg) => qnameToString(arg))
+ .join(",");
+ result.push({argsText});
+ } else {
+ let first = true;
+ for (const arg of args) {
+ result.push(arg.abbreviate());
+ if (first) {
+ first = false;
+ } else {
+ result.push(",");
+ }
+ }
+ }
+ result.push(">");
+ }
+ return result;
+ },
+ };
+ }
+}
+
+const ExpandableTextButton = styled.button`
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ color: inherit;
+ &:hover {
+ background-color: rgba(128, 128, 128, 0.2);
+ }
+`;
+
+interface ExpandableNamePartProps {
+ children: React.ReactNode;
+}
+
+function ExpandableNamePart(props: ExpandableNamePartProps) {
+ const [isExpanded, setExpanded] = useState(false);
+ return (
+ {
+ setExpanded(!isExpanded);
+ event.stopPropagation();
+ }}
+ >
+ {isExpanded ? props.children : "..."}
+
+ );
+}
+
+/**
+ * Span enclosing an entire qualified name.
+ *
+ * Can be used to gray out uninteresting parts of the name, though this looks worse than expected.
+ */
+const QNameSpan = styled.span`
+ /* color: var(--vscode-disabledForeground); */
+`;
+
+/** Span enclosing the innermost identifier, e.g. the `foo` in `A::B::foo#abc` */
+const IdentifierSpan = styled.span`
+ font-weight: 600;
+`;
+
+/** Span enclosing keywords such as `JOIN` and `WITH`. */
+const KeywordSpan = styled.span`
+ font-weight: 500;
+`;
+
+const nameTokenRegex = /\b[^ (]+\b/g;
+
+function traverseMatches(
+ text: string,
+ regex: RegExp,
+ callbacks: {
+ onMatch: (match: RegExpMatchArray) => void;
+ onText: (text: string) => void;
+ },
+) {
+ const matches = Array.from(text.matchAll(regex));
+ let lastIndex = 0;
+ for (const match of matches) {
+ const before = text.substring(lastIndex, match.index);
+ if (before !== "") {
+ callbacks.onText(before);
+ }
+ callbacks.onMatch(match);
+ lastIndex = match.index + match[0].length;
+ }
+ const after = text.substring(lastIndex);
+ if (after !== "") {
+ callbacks.onText(after);
+ }
+}
+
+export function abbreviateRASteps(steps: string[]): React.ReactNode[] {
+ const nameTokens = steps.flatMap((step) =>
+ Array.from(step.matchAll(nameTokenRegex)).map((tok) => tok[0]),
+ );
+ const nameSet = new NameSet(nameTokens.filter((name) => name.includes("::")));
+ return steps.map((step, index) => {
+ const result: React.ReactNode[] = [];
+ traverseMatches(step, nameTokenRegex, {
+ onMatch(match) {
+ const text = match[0];
+ if (text.includes("::")) {
+ result.push({nameSet.getAbbreviation(text)});
+ } else if (/[A-Z]+/.test(text)) {
+ result.push({text});
+ } else {
+ result.push(match[0]);
+ }
+ },
+ onText(text) {
+ result.push(text);
+ },
+ });
+ return {result};
+ });
+}
+
+export function abbreviateRANames(names: string[]): React.ReactNode[] {
+ const nameSet = new NameSet(names);
+ return names.map((name) => nameSet.getAbbreviation(name));
+}