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)); +}