From b9f7919f6f09ea59e6d4ec900ce4d6058f5a8849 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 23 Apr 2026 15:36:06 -0400 Subject: [PATCH 1/3] Add an insertStackLabels helper. This takes an existing profile and creates label frames based on function name prefix matching. The label frames are inserted as parent stack nodes of the matched stack node. This lets us turn native profiles from e.g. samply into profiles where the JS-only view shows DOM calls. --- src/profile-logic/insert-stack-labels.ts | 289 ++++++++++++++++++++++ src/test/unit/insert-stack-labels.test.ts | 158 ++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 src/profile-logic/insert-stack-labels.ts create mode 100644 src/test/unit/insert-stack-labels.test.ts diff --git a/src/profile-logic/insert-stack-labels.ts b/src/profile-logic/insert-stack-labels.ts new file mode 100644 index 0000000000..6133398bdd --- /dev/null +++ b/src/profile-logic/insert-stack-labels.ts @@ -0,0 +1,289 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { + IndexIntoFrameTable, + IndexIntoStackTable, + RawStackTable, + IndexIntoFuncTable, + Profile, + Category, +} from '../types/profile'; +import { + shallowCloneFrameTable, + shallowCloneFuncTable, +} from 'firefox-profiler/profile-logic/data-structures'; +import { StringTable } from 'firefox-profiler/utils/string-table'; +import { updateRawThreadStacks } from 'firefox-profiler/profile-logic/profile-data'; + +export type LabelDescription = { + name: string; + funcPrefixes: string[]; +}; + +/** + * Takes a profile and creates one which contains "stack labels". + * + * ## Example + * + * Before: + * + * ``` + * - BaselineIC: Call.CallNative + * - mozilla::dom::Element_Binding::getBoundingClientRect <- matches a funcPrefix + * - nsIContent::GetPrimaryFrame + * - mozilla::PresShell::DoFlushLayout + * - mozilla::PresShell::ProcessReflowCommands <- matches a funcPrefix + * ``` + * + * After: + * + * ``` + * - BaselineIC: Call.CallNative + * - Element.getBoundingClientRect <== NEW + * - mozilla::dom::Element_Binding::getBoundingClientRect + * - nsIContent::GetPrimaryFrame + * - mozilla::PresShell::DoFlushLayout + * - Update layout <== NEW + * - mozilla::PresShell::ProcessReflowCommands + * ``` + * + * The label frames are inserted as new parent stack nodes for the matched + * stack node. The caller supplies the list of labels and their matchers + * (as function name prefixes). + * + * ## No "duplicate labels" + * + * This implementation avoids duplicate labels. This is best explained with + * an example. Let "Label A" apply to prefix "a" and "Label B" apply to prefix "b". + * + * Input: + * + * ``` + * - a1 + * - a2 + * - b1 + * - a2 + * ``` + * + * Then we get: + * + * ``` + * - Label A + * - a1 + * - a2 + * - Label B + * - b1 + * - Label A + * - a2 + * ``` + * + * Notably there is no extra "Label A" frame between a1 and a2, even though a2 + * also matches. We avoid it in order to keep the tree simple; and in the JS-only + * call tree, the samples at a1,a2 are already accounted to the Label A node which + * is all we wanted to achieve. + */ +export function insertStackLabels( + profile: Profile, + labelDescriptions: LabelDescription[] +): Profile { + const labelCategoryIndex = profile.meta.categories!.length; + + const newCategories: Category[] = [ + ...profile.meta.categories!, + { + name: 'Label', + color: 'blue', + subcategories: ['Other'], + }, + ]; + + const { + funcTable: oldFuncTable, + frameTable: oldFrameTable, + stackTable: oldStackTable, + sources, + stringArray, + } = profile.shared; + const frameTable = shallowCloneFrameTable(oldFrameTable); + const funcTable = shallowCloneFuncTable(oldFuncTable); + const stringTable = StringTable.withBackingArray(stringArray); + + const rootLabelName = 'Root (unaccounted / catch-all)'; + const rootLabelFrameIndex = frameTable.length; + + const labelFramesStartIndex = rootLabelFrameIndex + 1; + const allLabelNames = [ + rootLabelName, + ...labelDescriptions.map((label) => label.name), + ]; + + // First, add the label frames and funcs to the frameTable + funcTable. + for (let i = 0; i < allLabelNames.length; i++) { + const labelName = allLabelNames[i]; + const funcIndex = funcTable.length++; + funcTable.name[funcIndex] = stringTable.indexForString(labelName); + funcTable.resource[funcIndex] = -1; + funcTable.source[funcIndex] = null; + funcTable.lineNumber[funcIndex] = null; + funcTable.columnNumber[funcIndex] = null; + funcTable.isJS[funcIndex] = false; + funcTable.relevantForJS[funcIndex] = true; + + const frameIndex = frameTable.length++; + frameTable.func[frameIndex] = funcIndex; + frameTable.category[frameIndex] = labelCategoryIndex; + frameTable.subcategory[frameIndex] = 0; + frameTable.nativeSymbol[frameIndex] = null; + frameTable.address[frameIndex] = 0; + frameTable.inlineDepth[frameIndex] = 0; + frameTable.line[frameIndex] = null; + frameTable.column[frameIndex] = null; + frameTable.innerWindowID[frameIndex] = null; + } + + // Run the function name against the substring matchers and return the first + // match. + function getLabelIndexForFunc(funcIndex: IndexIntoFuncTable): number | null { + let nameString = stringArray[funcTable.name[funcIndex]]; + + // Include the filename (in brackets), if present. This allows matchers + // like `onStateChange (chrome://browser/content/tabbrowser/` + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex !== null) { + const filenameString = stringArray[sources.filename[sourceIndex]]; + nameString += ` (${filenameString})`; + } + + // Check against every funcPrefix of every label. Return the first match. + for ( + let labelIndex = 0; + labelIndex < labelDescriptions.length; + labelIndex++ + ) { + const labelDescription = labelDescriptions[labelIndex]; + for ( + let prefixIndex = 0; + prefixIndex < labelDescription.funcPrefixes.length; + prefixIndex++ + ) { + const funcNamePrefix = labelDescription.funcPrefixes[prefixIndex]; + if (nameString.startsWith(funcNamePrefix)) { + return labelIndex; + } + } + } + return null; + } + + // Compute the label frame index (if any) for every func. + const funcIndexToLabelFrameIndex = new Array(funcTable.length); + for (let funcIndex = 0; funcIndex < funcTable.length; funcIndex++) { + const labelIndex = getLabelIndexForFunc(funcIndex); + const labelFrameIndex = + labelIndex !== null ? labelFramesStartIndex + labelIndex : null; + funcIndexToLabelFrameIndex[funcIndex] = labelFrameIndex; + } + + // Now compute where in the stack table we need labels. + const labelFrameIndexToInsertAtStack = new Array( + oldStackTable.length + ); + const inheritedLabelFrameIndexAtStack = new Array( + oldStackTable.length + ); + let stacksToInsertCount = 0; + for (let stackIndex = 0; stackIndex < oldStackTable.length; stackIndex++) { + const parentStackIndex = oldStackTable.prefix[stackIndex]; + const inheritedLabelFrameIndex = + parentStackIndex !== null + ? inheritedLabelFrameIndexAtStack[parentStackIndex] + : null; + const frameIndex = oldStackTable.frame[stackIndex]; + const funcIndex = oldFrameTable.func[frameIndex]; + const labelFrameIndex = funcIndexToLabelFrameIndex[funcIndex]; + if ( + labelFrameIndex !== null && + labelFrameIndex !== inheritedLabelFrameIndex + ) { + labelFrameIndexToInsertAtStack[stackIndex] = labelFrameIndex; + inheritedLabelFrameIndexAtStack[stackIndex] = labelFrameIndex; + stacksToInsertCount++; + } else if ( + funcTable.isJS[funcIndex] || + funcTable.relevantForJS[funcIndex] + ) { + labelFrameIndexToInsertAtStack[stackIndex] = null; + inheritedLabelFrameIndexAtStack[stackIndex] = null; + } else if (parentStackIndex === null) { + labelFrameIndexToInsertAtStack[stackIndex] = rootLabelFrameIndex; + inheritedLabelFrameIndexAtStack[stackIndex] = rootLabelFrameIndex; + stacksToInsertCount++; + } else { + labelFrameIndexToInsertAtStack[stackIndex] = null; + inheritedLabelFrameIndexAtStack[stackIndex] = inheritedLabelFrameIndex; + } + } + + // Now compute the new stack table. + const newStackCount = oldStackTable.length + stacksToInsertCount; + const newPrefixCol = new Array(newStackCount); + const newFrameCol = new Array(newStackCount); + const oldStackToNewStackPlusOne = new Int32Array(oldStackTable.length); + let nextNewStackIndex = 0; + for ( + let oldStackIndex = 0; + oldStackIndex < oldStackTable.length; + oldStackIndex++ + ) { + const labelFrameIndexToInsert = + labelFrameIndexToInsertAtStack[oldStackIndex]; + const oldPrefix = oldStackTable.prefix[oldStackIndex]; + let newPrefix = + oldPrefix !== null ? oldStackToNewStackPlusOne[oldPrefix] - 1 : null; + const frameIndex = oldStackTable.frame[oldStackIndex]; + if (labelFrameIndexToInsert !== null) { + const insertedStackIndex = nextNewStackIndex++; + newPrefixCol[insertedStackIndex] = newPrefix; + newFrameCol[insertedStackIndex] = labelFrameIndexToInsert; + newPrefix = insertedStackIndex; + } + const newStackIndex = nextNewStackIndex++; + newPrefixCol[newStackIndex] = newPrefix; + newFrameCol[newStackIndex] = frameIndex; + oldStackToNewStackPlusOne[oldStackIndex] = newStackIndex + 1; + } + + if (nextNewStackIndex !== newStackCount) { + console.error('Unexpected new stack count!', { + nextNewStackIndex, + newStackCount, + stacksToInsertCount, + }); + } + + const stackTable: RawStackTable = { + prefix: newPrefixCol, + frame: newFrameCol, + length: newStackCount, + }; + + const newShared = { ...profile.shared, stackTable, frameTable, funcTable }; + const newThreads = updateRawThreadStacks(profile.threads, (oldStack) => + oldStack !== null ? oldStackToNewStackPlusOne[oldStack] - 1 : null + ); + const newMeta = { + ...profile.meta, + categories: newCategories, + }; + + const newProfile: Profile = { + ...profile, + meta: newMeta, + shared: newShared, + threads: newThreads, + }; + + return newProfile; +} diff --git a/src/test/unit/insert-stack-labels.test.ts b/src/test/unit/insert-stack-labels.test.ts new file mode 100644 index 0000000000..eb76415e64 --- /dev/null +++ b/src/test/unit/insert-stack-labels.test.ts @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { insertStackLabels } from '../../profile-logic/insert-stack-labels'; +import { callTreeFromProfile, formatTree } from '../fixtures/utils'; + +describe('insertStackLabels', function () { + it('inserts a label before the first frame matching a label', function () { + const { profile } = getProfileFromTextSamples(` + A + B + mozilla::layout::Reflow + mozilla::layout::FrameReflow + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Layout', funcPrefixes: ['mozilla::layout::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Root (unaccounted / catch-all) (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - B (total: 1, self: —)', + ' - Layout (total: 1, self: —)', + ' - mozilla::layout::Reflow (total: 1, self: —)', + ' - mozilla::layout::FrameReflow (total: 1, self: 1)', + ]); + }); + + it('does not repeat the label for consecutive frames in the same label', function () { + const { profile } = getProfileFromTextSamples(` + A + gfx::Composite + gfx::LayerManager::Render + gfx::DrawQuad + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Root (unaccounted / catch-all) (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - Graphics (total: 1, self: —)', + ' - gfx::Composite (total: 1, self: —)', + ' - gfx::LayerManager::Render (total: 1, self: —)', + ' - gfx::DrawQuad (total: 1, self: 1)', + ]); + }); + + it('does not re-insert the label when re-entering a label after an unmatched frame', function () { + // A non-JS, non-label frame (helper) propagates the inherited label context, + // so gfx::DrawQuad is still considered inside the Graphics label and no new + // label is inserted. + const { profile } = getProfileFromTextSamples(` + gfx::Composite + helper + gfx::DrawQuad + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Graphics (total: 1, self: —)', + ' - gfx::Composite (total: 1, self: —)', + ' - helper (total: 1, self: —)', + ' - gfx::DrawQuad (total: 1, self: 1)', + ]); + }); + + it('inserts the right label when multiple labels are configured', function () { + const { profile } = getProfileFromTextSamples(` + A A + layout::Reflow gfx::Composite + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Layout', funcPrefixes: ['layout::'] }, + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Root (unaccounted / catch-all) (total: 2, self: —)', + ' - A (total: 2, self: —)', + ' - Layout (total: 1, self: —)', + ' - layout::Reflow (total: 1, self: 1)', + ' - Graphics (total: 1, self: —)', + ' - gfx::Composite (total: 1, self: 1)', + ]); + }); + + it('inserts a new label when switching from one label to another', function () { + const { profile } = getProfileFromTextSamples(` + layout::Reflow + gfx::Composite + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Layout', funcPrefixes: ['layout::'] }, + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Layout (total: 1, self: —)', + ' - layout::Reflow (total: 1, self: —)', + ' - Graphics (total: 1, self: —)', + ' - gfx::Composite (total: 1, self: 1)', + ]); + }); + + it('wraps unmatched root frames in a root label', function () { + const { profile } = getProfileFromTextSamples(` + A + B + C + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Layout', funcPrefixes: ['layout::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Root (unaccounted / catch-all) (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - B (total: 1, self: —)', + ' - C (total: 1, self: 1)', + ]); + }); + + it('handles multiple samples sharing common prefixes correctly', function () { + const { profile } = getProfileFromTextSamples(` + A A A + B B B + gfx::Composite gfx::Composite C + gfx::DrawQuad gfx::LayerManager::Render + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Root (unaccounted / catch-all) (total: 3, self: —)', + ' - A (total: 3, self: —)', + ' - B (total: 3, self: —)', + ' - Graphics (total: 2, self: —)', + ' - gfx::Composite (total: 2, self: —)', + ' - gfx::DrawQuad (total: 1, self: 1)', + ' - gfx::LayerManager::Render (total: 1, self: 1)', + ' - C (total: 1, self: 1)', + ]); + }); +}); From 7a03e49d57e68d212cf4b4c9a780e5c89df5c7e1 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 23 Apr 2026 15:36:06 -0400 Subject: [PATCH 2/3] Add toml stuff --- package.json | 1 + src/test/unit/label-templates.test.ts | 810 ++++++++++++++++++++++++++ src/utils/label-templates.ts | 426 ++++++++++++++ yarn.lock | 5 + 4 files changed, 1242 insertions(+) create mode 100644 src/test/unit/label-templates.test.ts create mode 100644 src/utils/label-templates.ts diff --git a/package.json b/package.json index 1c082839f2..5dddda1e35 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "redux-logger": "^3.0.6", "redux-thunk": "^3.1.0", "reselect": "^4.1.8", + "smol-toml": "^1.6.1", "valibot": "^1.4.0", "workbox-window": "^7.4.1" }, diff --git a/src/test/unit/label-templates.test.ts b/src/test/unit/label-templates.test.ts new file mode 100644 index 0000000000..c71efcc77f --- /dev/null +++ b/src/test/unit/label-templates.test.ts @@ -0,0 +1,810 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + applyModifier, + expandPattern, + reverseModifier, + reverseBlinkSnake, + compilePatternToRegex, + discoverAutoLabels, + resolveAllLabels, + parseLabelToml, +} from 'firefox-profiler/utils/label-templates'; +import type { AutoLabel } from 'firefox-profiler/utils/label-templates'; +import { insertStackLabels } from 'firefox-profiler/profile-logic/insert-stack-labels'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { callTreeFromProfile, formatTree } from '../fixtures/utils'; + +// --------------------------------------------------------------------------- +// applyModifier +// --------------------------------------------------------------------------- + +describe('applyModifier', function () { + describe(':pascal', function () { + it('uppercases the first letter of a camelCase name', function () { + expect(applyModifier('querySelector', 'pascal')).toBe('QuerySelector'); + }); + + it('handles multi-word camelCase', function () { + expect(applyModifier('addEventListener', 'pascal')).toBe( + 'AddEventListener' + ); + }); + + it('preserves interior uppercase (innerHTML)', function () { + expect(applyModifier('innerHTML', 'pascal')).toBe('InnerHTML'); + }); + + it('handles a single lowercase letter', function () { + expect(applyModifier('fill', 'pascal')).toBe('Fill'); + }); + }); + + describe(':blink_snake', function () { + it('lowercases a simple PascalCase word', function () { + expect(applyModifier('Element', 'blink_snake')).toBe('element'); + }); + + it('handles HTML* class names', function () { + expect(applyModifier('HTMLInputElement', 'blink_snake')).toBe( + 'html_input_element' + ); + }); + + it('handles DOM* class names', function () { + expect(applyModifier('DOMTokenList', 'blink_snake')).toBe( + 'dom_token_list' + ); + }); + + it('handles CSS* class names', function () { + expect(applyModifier('CSSStyleSheet', 'blink_snake')).toBe( + 'css_style_sheet' + ); + }); + + it('handles compound PascalCase names', function () { + expect(applyModifier('DocumentFragment', 'blink_snake')).toBe( + 'document_fragment' + ); + expect(applyModifier('EventTarget', 'blink_snake')).toBe('event_target'); + expect(applyModifier('ShadowRoot', 'blink_snake')).toBe('shadow_root'); + }); + + it('handles all-uppercase acronyms', function () { + expect(applyModifier('URL', 'blink_snake')).toBe('url'); + expect(applyModifier('DOMParser', 'blink_snake')).toBe('dom_parser'); + }); + + it('keeps a mixed-case special token together when listed', function () { + expect( + applyModifier('WebGLRenderingContext', 'blink_snake', ['WebGL']) + ).toBe('webgl_rendering_context'); + expect(applyModifier('XPathEvaluator', 'blink_snake', ['XPath'])).toBe( + 'xpath_evaluator' + ); + }); + + it('keeps a digit-bearing special token together when listed', function () { + expect( + applyModifier('WebGL2RenderingContext', 'blink_snake', ['WebGL2']) + ).toBe('webgl2_rendering_context'); + }); + + it('prefers the longer of two prefix-overlapping special tokens', function () { + // WebGL2 must match before WebGL, regardless of list order. + expect( + applyModifier('WebGL2RenderingContext', 'blink_snake', [ + 'WebGL', + 'WebGL2', + ]) + ).toBe('webgl2_rendering_context'); + expect( + applyModifier('WebGLRenderingContext', 'blink_snake', [ + 'WebGL', + 'WebGL2', + ]) + ).toBe('webgl_rendering_context'); + }); + }); + + it('returns the value unchanged when no modifier is given', function () { + expect(applyModifier('querySelector', undefined)).toBe('querySelector'); + expect(applyModifier('Element', undefined)).toBe('Element'); + }); + + it('throws on an unknown modifier', function () { + expect(() => applyModifier('foo', 'upper')).toThrow( + 'Unknown template modifier: upper' + ); + }); +}); + +// --------------------------------------------------------------------------- +// expandPattern +// --------------------------------------------------------------------------- + +describe('expandPattern', function () { + it('substitutes a plain variable', function () { + expect( + expandPattern('mozilla::dom::{Class}_Binding::{method}(', { + Class: 'Element', + method: 'querySelector', + }) + ).toBe('mozilla::dom::Element_Binding::querySelector('); + }); + + it('applies :pascal to the variable value', function () { + expect( + expandPattern('v8_{Class:blink_snake}::{method:pascal}Operation', { + Class: 'Element', + method: 'querySelector', + }) + ).toBe('v8_element::QuerySelectorOperation'); + }); + + it('applies :blink_snake to the variable value', function () { + expect( + expandPattern('v8_{Class:blink_snake}::Callback', { + Class: 'HTMLInputElement', + }) + ).toBe('v8_html_input_element::Callback'); + }); + + it('leaves unrelated text intact', function () { + expect(expandPattern('no_vars_here', {})).toBe('no_vars_here'); + }); + + it('throws when a referenced variable is not provided', function () { + expect(() => + expandPattern('{Class}_Binding::{method}(', { Class: 'Element' }) + ).toThrow('Template variable "method" not provided'); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: auto_labels → insertStackLabels → formatTree +// +// resolveAllLabels-driven coverage for each engine's func-name shape. Each +// test seeds funcs from one engine and confirms auto-discovery synthesizes +// a label whose forward-expanded prefixes match across all engines. +// --------------------------------------------------------------------------- + +const DOM_OPERATION: AutoLabel = { + label: '{Class}.{method}', + patterns: [ + 'mozilla::dom::{Class}_Binding::{method}(', + "blink::`anonymous namespace'::v8_{Class:blink_snake}::{method:pascal}Operation", + 'blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation', + 'WebCore::js{Class}PrototypeFunction_{method}', + ], +}; + +const CANVAS2D_OPERATION: AutoLabel = { + label: 'CanvasRenderingContext2D.{method}', + patterns: [ + 'mozilla::dom::CanvasRenderingContext2D_Binding::{method}(', + 'mozilla::dom::OffscreenCanvasRenderingContext2D_Binding::{method}(', + "blink::`anonymous namespace'::v8_canvas_rendering_context_2d::{method:pascal}Operation", + 'blink::(anonymous namespace)::v8_canvas_rendering_context_2d::{method:pascal}Operation', + 'blink::Canvas2DRecorderContext::{method}(', + 'WebCore::jsCanvasRenderingContext2DPrototypeFunction_{method}(', + ], +}; + +describe('auto_labels integration with insertStackLabels', function () { + it('matches Gecko-style function names generated from a dom_operation template', function () { + const { profile } = getProfileFromTextSamples(` + A + mozilla::dom::Element_Binding::querySelector(elem) + `); + + const labels = resolveAllLabels( + { + labels: [], + autoLabels: [DOM_OPERATION], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::querySelector(elem)'] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - Element.querySelector (total: 1, self: —)', + ' - mozilla::dom::Element_Binding::querySelector(elem) (total: 1, self: 1)', + ]); + }); + + it('matches both anonymous-namespace forms for Blink', function () { + const { profile } = getProfileFromTextSamples(` + blink::\`anonymous namespace'::v8_element::QuerySelectorOperation + blink::(anonymous namespace)::v8_element::QuerySelectorOperation + `); + + const labels = resolveAllLabels( + { + labels: [], + autoLabels: [DOM_OPERATION], + blinkSpecialTokens: [], + }, + [ + "blink::`anonymous namespace'::v8_element::QuerySelectorOperation", + 'blink::(anonymous namespace)::v8_element::QuerySelectorOperation', + ] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Element.querySelector (total: 1, self: —)', + " - blink::`anonymous namespace'::v8_element::QuerySelectorOperation (total: 1, self: —)", + ' - blink::(anonymous namespace)::v8_element::QuerySelectorOperation (total: 1, self: 1)', + ]); + }); + + it('matches WebKit-style function names', function () { + const { profile } = getProfileFromTextSamples(` + A + WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*) + `); + + const labels = resolveAllLabels( + { + labels: [], + autoLabels: [DOM_OPERATION], + blinkSpecialTokens: [], + }, + [ + 'WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*)', + ] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - Element.querySelector (total: 1, self: —)', + ' - WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*) (total: 1, self: 1)', + ]); + }); + + it('canvas2d_operation matches Gecko, Offscreen, Blink, and WebKit variants', function () { + const { profile } = getProfileFromTextSamples(` + mozilla::dom::CanvasRenderingContext2D_Binding::fillRect(args) + mozilla::dom::OffscreenCanvasRenderingContext2D_Binding::fillRect(args) + blink::(anonymous namespace)::v8_canvas_rendering_context_2d::FillRectOperation + WebCore::jsCanvasRenderingContext2DPrototypeFunction_fillRect(ctx) + `); + + const labels = resolveAllLabels( + { + labels: [], + autoLabels: [CANVAS2D_OPERATION], + blinkSpecialTokens: [], + }, + [ + 'mozilla::dom::CanvasRenderingContext2D_Binding::fillRect(args)', + 'mozilla::dom::OffscreenCanvasRenderingContext2D_Binding::fillRect(args)', + 'blink::(anonymous namespace)::v8_canvas_rendering_context_2d::FillRectOperation', + 'WebCore::jsCanvasRenderingContext2DPrototypeFunction_fillRect(ctx)', + ] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- CanvasRenderingContext2D.fillRect (total: 1, self: —)', + ' - mozilla::dom::CanvasRenderingContext2D_Binding::fillRect(args) (total: 1, self: —)', + ' - mozilla::dom::OffscreenCanvasRenderingContext2D_Binding::fillRect(args) (total: 1, self: —)', + ' - blink::(anonymous namespace)::v8_canvas_rendering_context_2d::FillRectOperation (total: 1, self: —)', + ' - WebCore::jsCanvasRenderingContext2DPrototypeFunction_fillRect(ctx) (total: 1, self: 1)', + ]); + }); + + it('merges an explicit `[[labels]]` entry with the auto-discovered prefixes when names collide', function () { + // The blink::bindings::PerformAttributeSetCEReactionsReflectTypeString + // frame is the generic className-setter runtime — auto-discovery can't + // synthesize it from any template. The explicit `[[labels]]` block adds + // it on top of the auto-discovered "set Element.className" prefixes. + const { profile } = getProfileFromTextSamples(` + blink::bindings::PerformAttributeSetCEReactionsReflectTypeString + mozilla::dom::Element_Binding::set_className(args) + `); + + const DOM_SETTER: AutoLabel = { + label: 'set {Class}.{prop}', + patterns: ['mozilla::dom::{Class}_Binding::set_{prop}('], + }; + + const labels = resolveAllLabels( + { + labels: [ + { + name: 'set Element.className', + funcPrefixes: [ + 'blink::bindings::PerformAttributeSetCEReactionsReflectTypeString', + ], + }, + ], + autoLabels: [DOM_SETTER], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::set_className(args)'] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- set Element.className (total: 1, self: —)', + ' - blink::bindings::PerformAttributeSetCEReactionsReflectTypeString (total: 1, self: —)', + ' - mozilla::dom::Element_Binding::set_className(args) (total: 1, self: 1)', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// :blink_snake digit-boundary handling +// --------------------------------------------------------------------------- + +describe(':blink_snake digit handling', function () { + it('separates trailing digit acronyms (Context2D)', function () { + expect(applyModifier('CanvasRenderingContext2D', 'blink_snake')).toBe( + 'canvas_rendering_context_2d' + ); + }); + + it('keeps a digit-then-uppercase acronym joined when no lowercase follows', function () { + expect(applyModifier('Canvas2D', 'blink_snake')).toBe('canvas_2d'); + }); + + it('splits a digit-then-uppercase pair when followed by lowercase', function () { + expect(applyModifier('WebGL2RenderingContext', 'blink_snake')).toBe( + 'web_gl_2_rendering_context' + ); + }); +}); + +// --------------------------------------------------------------------------- +// reverseSnake / reverseModifier +// --------------------------------------------------------------------------- + +describe('reverseBlinkSnake', function () { + it('reverses a single Pascal word', function () { + expect(reverseBlinkSnake('element')).toBe('Element'); + }); + + it('reverses a multi-word PascalCase name', function () { + expect(reverseBlinkSnake('document_fragment')).toBe('DocumentFragment'); + expect(reverseBlinkSnake('event_target')).toBe('EventTarget'); + }); + + it('uses the special-tokens list to recover all-uppercase fragments', function () { + expect(reverseBlinkSnake('html_element', ['HTML'])).toBe('HTMLElement'); + expect(reverseBlinkSnake('html_image_element', ['HTML'])).toBe( + 'HTMLImageElement' + ); + expect(reverseBlinkSnake('css_style_sheet', ['CSS'])).toBe('CSSStyleSheet'); + expect(reverseBlinkSnake('xml_http_request', ['XML'])).toBe( + 'XMLHttpRequest' + ); + }); + + it('uses the special-tokens list to recover mixed-case fragments', function () { + expect(reverseBlinkSnake('webgl_rendering_context', ['WebGL'])).toBe( + 'WebGLRenderingContext' + ); + expect(reverseBlinkSnake('webgl2_rendering_context', ['WebGL2'])).toBe( + 'WebGL2RenderingContext' + ); + expect(reverseBlinkSnake('xpath_evaluator', ['XPath'])).toBe( + 'XPathEvaluator' + ); + }); + + it('without a special-tokens list, falls back to first-letter capitalization', function () { + // This is the lossy case: HtmlElement and HTMLElement both snake to the + // same string, and without special tokens we can't recover the original. + expect(reverseBlinkSnake('html_element')).toBe('HtmlElement'); + }); + + it('handles trailing digit-bearing tokens', function () { + expect(reverseBlinkSnake('canvas_rendering_context_2d', ['2D'])).toBe( + 'CanvasRenderingContext2D' + ); + expect(reverseBlinkSnake('canvas_2d', ['2D'])).toBe('Canvas2D'); + }); + + it('handles multiple tokens separated by Pascal words', function () { + // The special-tokens list applies independently at each segment; here + // HTML is recovered, then `dom` becomes `Dom` (no DOM in the list), then + // URL is recovered. (Two adjacent special tokens cannot be expressed in + // snake form — `HTMLDOMParser` snakes to `htmldom_parser`, not + // `html_dom_parser`.) + expect(reverseBlinkSnake('html_dom_event_url', ['HTML', 'URL'])).toBe( + 'HTMLDomEventURL' + ); + }); + + it('handles a trailing special token with no follow-up segment', function () { + expect(reverseBlinkSnake('parse_url', ['URL'])).toBe('ParseURL'); + }); + + it('does case-insensitive matching against the special-tokens list', function () { + expect(reverseBlinkSnake('html_element', ['html'])).toBe('htmlElement'); + }); +}); + +describe('reverseModifier', function () { + it('reverses :pascal by lowercasing the first letter', function () { + expect(reverseModifier('QuerySelector', 'pascal')).toBe('querySelector'); + expect(reverseModifier('Fill', 'pascal')).toBe('fill'); + }); + + it('reverses :blink_snake using the special-tokens list', function () { + expect(reverseModifier('html_input_element', 'blink_snake', ['HTML'])).toBe( + 'HTMLInputElement' + ); + }); + + it('returns the value unchanged when no modifier is given', function () { + expect(reverseModifier('Element', undefined)).toBe('Element'); + }); + + it('throws on an unknown modifier', function () { + expect(() => reverseModifier('foo', 'upper')).toThrow( + 'Unknown template modifier: upper' + ); + }); +}); + +// --------------------------------------------------------------------------- +// compilePatternToRegex +// --------------------------------------------------------------------------- + +describe('compilePatternToRegex', function () { + it('compiles a Mozilla-style operation pattern', function () { + const { regex, vars } = compilePatternToRegex( + 'mozilla::dom::{Class}_Binding::{method}(' + ); + expect(vars).toEqual([ + { name: 'Class', modifier: undefined }, + { name: 'method', modifier: undefined }, + ]); + const m = 'mozilla::dom::Element_Binding::querySelector(args)'.match(regex); + expect(m).not.toBeNull(); + expect(m![1]).toBe('Element'); + expect(m![2]).toBe('querySelector'); + }); + + it('compiles a Blink-style pattern with :blink_snake and :pascal', function () { + const { regex, vars } = compilePatternToRegex( + 'blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation' + ); + expect(vars).toEqual([ + { name: 'Class', modifier: 'blink_snake' }, + { name: 'method', modifier: 'pascal' }, + ]); + const m = + 'blink::(anonymous namespace)::v8_html_image_element::SetSrcOperation'.match( + regex + ); + expect(m).not.toBeNull(); + expect(m![1]).toBe('html_image_element'); + expect(m![2]).toBe('SetSrc'); + }); + + it('compiles a WebKit-style pattern', function () { + const { regex } = compilePatternToRegex( + 'WebCore::js{Class}PrototypeFunction_{method}' + ); + const m = 'WebCore::jsElementPrototypeFunction_querySelector(args)'.match( + regex + ); + expect(m).not.toBeNull(); + expect(m![1]).toBe('Element'); + expect(m![2]).toBe('querySelector'); + }); + + it('refuses to match when the literal text does not appear', function () { + const { regex } = compilePatternToRegex( + 'mozilla::dom::{Class}_Binding::{method}(' + ); + expect('mozilla::dom::Element_Other::foo('.match(regex)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// discoverAutoLabels / resolveAllLabels +// --------------------------------------------------------------------------- + +const DOM_OPERATION_AUTO: AutoLabel = { + label: '{Class}.{method}', + patterns: [ + 'mozilla::dom::{Class}_Binding::{method}(', + "blink::`anonymous namespace'::v8_{Class:blink_snake}::{method:pascal}Operation", + 'blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation', + 'WebCore::js{Class}PrototypeFunction_{method}', + ], +}; + +const DOM_SETTER_AUTO: AutoLabel = { + label: 'set {Class}.{prop}', + patterns: [ + 'mozilla::dom::{Class}_Binding::set_{prop}(', + "blink::`anonymous namespace'::v8_{Class:blink_snake}::{prop:pascal}AttributeSetCallback", + 'blink::(anonymous namespace)::v8_{Class:blink_snake}::{prop:pascal}AttributeSetCallback', + 'WebCore::setJS{Class}_{prop}(', + ], +}; + +describe('discoverAutoLabels', function () { + it('discovers a Mozilla-style operation', function () { + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::querySelector(args)'] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('Element.querySelector'); + expect(labels[0].funcPrefixes).toContain( + 'mozilla::dom::Element_Binding::querySelector(' + ); + expect(labels[0].funcPrefixes).toContain( + 'WebCore::jsElementPrototypeFunction_querySelector' + ); + expect(labels[0].funcPrefixes).toContain( + 'blink::(anonymous namespace)::v8_element::QuerySelectorOperation' + ); + }); + + it('discovers a Blink-style operation, recovering Class via the special-tokens list', function () { + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO], + blinkSpecialTokens: ['HTML'], + }, + ['blink::(anonymous namespace)::v8_html_image_element::ClickOperation'] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('HTMLImageElement.click'); + // forward-expanded prefixes for all engines: + expect(labels[0].funcPrefixes).toEqual([ + 'mozilla::dom::HTMLImageElement_Binding::click(', + "blink::`anonymous namespace'::v8_html_image_element::ClickOperation", + 'blink::(anonymous namespace)::v8_html_image_element::ClickOperation', + 'WebCore::jsHTMLImageElementPrototypeFunction_click', + ]); + }); + + it('dedupes when the same (Class, method) is observed in multiple engine forms', function () { + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO], + blinkSpecialTokens: [], + }, + [ + 'mozilla::dom::Element_Binding::querySelector(args)', + 'WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*)', + ] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('Element.querySelector'); + }); + + it('discovers a setter using a separate auto_labels entry', function () { + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_SETTER_AUTO], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::set_id(args)'] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('set Element.id'); + }); + + it('rejects matches whose round-trip does not agree with the observed name', function () { + // funcName has Class part that is NOT a valid PascalCase identifier under + // our regex (starts lowercase), so there should be no match. + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO], + blinkSpecialTokens: [], + }, + ['mozilla::dom::element_Binding::querySelector(args)'] + ); + expect(labels).toHaveLength(0); + }); + + it('does not match a binding setter as a dom_operation method', function () { + // `{method}` (camelCase, no modifier) must not swallow the `set_` of + // `set_innerHTML`, otherwise dom_operation would synthesize a stray + // "Element.set_innerHTML" label alongside dom_setter's "set Element.innerHTML". + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO, DOM_SETTER_AUTO], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::set_innerHTML(args)'] + ); + expect(labels.map((l) => l.name)).toEqual(['set Element.innerHTML']); + }); +}); + +describe('resolveAllLabels', function () { + it('merges an explicit `[[labels]]` block into the auto-discovered prefixes when names collide', function () { + const parsed = parseLabelToml(` +[global] +blink_special_tokens = ["HTML"] + +[[labels]] +name = "Element.querySelector" +funcPrefixes = ["extra::prefix"] + +[[auto_labels]] +label = "{Class}.{method}" +patterns = [ + "mozilla::dom::{Class}_Binding::{method}(", + "WebCore::js{Class}PrototypeFunction_{method}", +] +`); + const labels = resolveAllLabels(parsed, [ + 'mozilla::dom::Element_Binding::querySelector(args)', + 'mozilla::dom::Element_Binding::getAttribute(args)', + ]); + + const byName = new Map(labels.map((l) => [l.name, l])); + // querySelector: auto-discovered prefixes plus the extra one from [[labels]] + expect(byName.get('Element.querySelector')!.funcPrefixes).toEqual([ + 'mozilla::dom::Element_Binding::querySelector(', + 'WebCore::jsElementPrototypeFunction_querySelector', + 'extra::prefix', + ]); + // getAttribute: pure auto-discovered, no explicit override + expect(byName.get('Element.getAttribute')!.funcPrefixes).toEqual([ + 'mozilla::dom::Element_Binding::getAttribute(', + 'WebCore::jsElementPrototypeFunction_getAttribute', + ]); + }); + + it('keeps an explicit-only label whose name does not collide with any auto-discovered one', function () { + const parsed = parseLabelToml(` +[[labels]] +name = "GC" +funcPrefixes = ["js::gc::GCRuntime::collect("] +`); + const labels = resolveAllLabels(parsed, []); + expect(labels).toEqual([ + { name: 'GC', funcPrefixes: ['js::gc::GCRuntime::collect('] }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end auto-discovery → insertStackLabels +// --------------------------------------------------------------------------- + +describe('auto_labels end-to-end', function () { + it('synthesizes a label from an observed Mozilla func and inserts it', function () { + const { profile } = getProfileFromTextSamples(` + A + mozilla::dom::Element_Binding::querySelector(args) + `); + + const parsed = parseLabelToml(` +[[auto_labels]] +label = "{Class}.{method}" +patterns = [ + "mozilla::dom::{Class}_Binding::{method}(", + "blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation", +] +`); + + const funcNames: string[] = []; + for (let i = 0; i < profile.shared.funcTable.length; i++) { + funcNames.push( + profile.shared.stringArray[profile.shared.funcTable.name[i]] + ); + } + const labels = resolveAllLabels(parsed, funcNames); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - Element.querySelector (total: 1, self: —)', + ' - mozilla::dom::Element_Binding::querySelector(args) (total: 1, self: 1)', + ]); + }); + + it('synthesizes a label for HTMLImageElement from a Blink-only profile using the special-tokens list', function () { + const { profile } = getProfileFromTextSamples(` + A + blink::(anonymous namespace)::v8_html_image_element::ClickOperation + `); + + const parsed = parseLabelToml(` +[global] +blink_special_tokens = ["HTML"] + +[[auto_labels]] +label = "{Class}.{method}" +patterns = [ + "mozilla::dom::{Class}_Binding::{method}(", + "blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation", +] +`); + + const funcNames: string[] = []; + for (let i = 0; i < profile.shared.funcTable.length; i++) { + funcNames.push( + profile.shared.stringArray[profile.shared.funcTable.name[i]] + ); + } + const labels = resolveAllLabels(parsed, funcNames); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - HTMLImageElement.click (total: 1, self: —)', + ' - blink::(anonymous namespace)::v8_html_image_element::ClickOperation (total: 1, self: 1)', + ]); + }); + + it('synthesizes a label for WebGL2RenderingContext using a mixed-case special token', function () { + // Blink's NameStyleConverter snakes `WebGL2RenderingContext` to + // `webgl2_rendering_context` (one token `WebGL2`), not + // `web_gl_2_rendering_context`. With `WebGL2` in the special-tokens list + // the recovery agrees with how Blink actually spells the binding name. + const { profile } = getProfileFromTextSamples(` + A + blink::(anonymous namespace)::v8_webgl2_rendering_context::DrawArraysOperation + `); + + const parsed = parseLabelToml(` +[global] +blink_special_tokens = ["WebGL2"] + +[[auto_labels]] +label = "{Class}.{method}" +patterns = [ + "mozilla::dom::{Class}_Binding::{method}(", + "blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation", +] +`); + + const funcNames: string[] = []; + for (let i = 0; i < profile.shared.funcTable.length; i++) { + funcNames.push( + profile.shared.stringArray[profile.shared.funcTable.name[i]] + ); + } + const labels = resolveAllLabels(parsed, funcNames); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - WebGL2RenderingContext.drawArrays (total: 1, self: —)', + ' - blink::(anonymous namespace)::v8_webgl2_rendering_context::DrawArraysOperation (total: 1, self: 1)', + ]); + }); +}); diff --git a/src/utils/label-templates.ts b/src/utils/label-templates.ts new file mode 100644 index 0000000000..342dabac5b --- /dev/null +++ b/src/utils/label-templates.ts @@ -0,0 +1,426 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// --------------------------------------------------------------------------- +// Engine coupling — read this before adding modifiers +// +// Blink derives V8 binding func names from IDL by snake_case-ing the +// interface name (e.g. `HTMLCanvasElement` → `v8_html_canvas_element::...`). +// To match those frames in profiles we have to mirror Blink's exact +// snake_case algorithm — see Blink's `NameStyleConverter` +// (third_party/blink/renderer/build/scripts/blinkbuild/name_style_converter.py). +// That coupling is encoded by the `:blink_snake` modifier and its +// companion `blink_special_tokens` list in the TOML's `[global]` section, +// not by a generic `:snake` — because there is no engine-neutral snake +// convention this translation could appeal to. WebKit and Mozilla bindings +// keep PascalCase in their generated symbols, so they don't need an +// equivalent. If a future engine needs its own convention, add a new +// modifier (e.g. `:webkit_snake`) rather than overloading this one. +// --------------------------------------------------------------------------- + +import { parse as parseToml } from 'smol-toml'; + +/** + * A template-driven label produced by auto-discovery. `label` is the name + * template (e.g. "set {Class}.{prop}"), `patterns` are the per-engine + * funcPrefix templates whose vars are recovered from observed func names. + */ +export type AutoLabel = { + label: string; + patterns: string[]; +}; + +export type LabelConfig = { + name: string; + funcPrefixes?: string[]; +}; + +export type LabelDescription = { + name: string; + funcPrefixes: string[]; +}; + +export type ParsedLabelToml = { + labels: LabelConfig[]; + autoLabels: AutoLabel[]; + blinkSpecialTokens: string[]; +}; + +// --------------------------------------------------------------------------- +// Blink-style tokenization +// --------------------------------------------------------------------------- + +const BLINK_TOKEN_PATTERNS = [ + '[A-Z]?[a-z]+', + '[A-Z]+(?![a-z])', + '[0-9][Dd](?![a-z])', + '[0-9]+', +]; + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function buildBlinkTokenRegex(blinkSpecialTokens: string[]): RegExp { + // Sort by length desc so longer entries that share a prefix with shorter + // ones (e.g. `WebGL2` vs `WebGL`) match first. JS regex alternation picks + // the leftmost successful alternative, not the longest match. + const sorted = [...blinkSpecialTokens].sort((a, b) => b.length - a.length); + const escaped = sorted.map(escapeRegex); + return new RegExp([...escaped, ...BLINK_TOKEN_PATTERNS].join('|'), 'g'); +} + +function buildBlinkLeadingNumberTokenRegex( + blinkSpecialTokens: string[] +): RegExp | null { + const withNumbers = blinkSpecialTokens.filter((t) => /[0-9]/.test(t)); + if (withNumbers.length === 0) { + return null; + } + const sorted = [...withNumbers].sort((a, b) => b.length - a.length); + return new RegExp('^(' + sorted.map(escapeRegex).join('|') + ')', 'i'); +} + +/** + * Tokenize a name following Blink's NameStyleConverter. Special tokens + * (e.g. `HTML`, `WebGL2`, `XPath`, `2D`) are matched first as single units; + * otherwise the default patterns capture camelCase boundaries and runs of + * capitals. + */ +export function tokenizeBlinkName( + name: string, + blinkSpecialTokens: string[] +): string[] { + const tokens: string[] = []; + let remaining = name; + + // Case-insensitive leading match for digit-bearing special tokens. Lets us + // tokenize lowerCamelCase like `webgl2RenderingContext` (where the leading + // special token has been lowercased). + const leadingRe = buildBlinkLeadingNumberTokenRegex(blinkSpecialTokens); + if (leadingRe !== null) { + const m = remaining.match(leadingRe); + if (m !== null) { + tokens.push(m[1]); + remaining = remaining.slice(m[1].length); + } + } + + const tokenRe = buildBlinkTokenRegex(blinkSpecialTokens); + for (const m of remaining.matchAll(tokenRe)) { + tokens.push(m[0]); + } + + return tokens; +} + +// --------------------------------------------------------------------------- +// Forward modifiers +// --------------------------------------------------------------------------- + +export function applyModifier( + value: string, + modifier: string | undefined, + blinkSpecialTokens: string[] = [] +): string { + switch (modifier) { + case 'pascal': + return value.charAt(0).toUpperCase() + value.slice(1); + case 'blink_snake': + return tokenizeBlinkName(value, blinkSpecialTokens) + .map((t) => t.toLowerCase()) + .join('_'); + case undefined: + return value; + default: + throw new Error(`Unknown template modifier: ${modifier}`); + } +} + +export function expandPattern( + pattern: string, + vars: Record, + blinkSpecialTokens: string[] = [] +): string { + return pattern.replace( + /\{(\w+)(?::(\w+))?\}/g, + (_match, name: string, modifier: string | undefined) => { + if (!(name in vars)) { + throw new Error(`Template variable "${name}" not provided`); + } + return applyModifier(vars[name], modifier, blinkSpecialTokens); + } + ); +} + +// --------------------------------------------------------------------------- +// Reverse modifiers +// --------------------------------------------------------------------------- + +function capitalizeFirstAlpha(seg: string): string { + const idx = seg.search(/[A-Za-z]/); + if (idx < 0) { + return seg; + } + return seg.slice(0, idx) + seg.charAt(idx).toUpperCase() + seg.slice(idx + 1); +} + +/** + * Reverse a `:blink_snake`-formed string back to its PascalCase original. + * + * Lowercasing during `:blink_snake` is one-way: `html_element` could come + * from either `HtmlElement` or `HTMLElement`. The caller supplies + * `blinkSpecialTokens` — canonical-cased fragments expected in the + * original — to disambiguate. + * + * Blink's `to_upper_camel_case` only consults SPECIAL_TOKENS for the first + * token, relying on tokenization to preserve the casing of subsequent + * tokens. We extend that to every position because by the time a name has + * been emitted as snake_case in a V8 binding func name, the tokenization is + * already gone. + */ +export function reverseBlinkSnake( + value: string, + blinkSpecialTokens: string[] = [] +): string { + const tokenByLower = new Map(); + for (const t of blinkSpecialTokens) { + tokenByLower.set(t.toLowerCase(), t); + } + return value + .split('_') + .map((seg) => tokenByLower.get(seg) ?? capitalizeFirstAlpha(seg)) + .join(''); +} + +export function reverseModifier( + value: string, + modifier: string | undefined, + blinkSpecialTokens: string[] = [] +): string { + switch (modifier) { + case 'pascal': + return value.charAt(0).toLowerCase() + value.slice(1); + case 'blink_snake': + return reverseBlinkSnake(value, blinkSpecialTokens); + case undefined: + return value; + default: + throw new Error(`Unknown template modifier: ${modifier}`); + } +} + +// --------------------------------------------------------------------------- +// Pattern → regex compilation (used for auto-discovery) +// --------------------------------------------------------------------------- + +const VAR_PATTERN_RE = /\{(\w+)(?::(\w+))?\}/g; + +function regexCharClassForVar( + name: string, + modifier: string | undefined +): string { + if (modifier === 'blink_snake') { + // snake_case identifier: starts with lowercase letter or digit, may + // contain `_`-separated alnum runs. + return '[a-z][a-z0-9]*(?:_[a-z0-9]+)*'; + } + // No modifier or :pascal — matches the case-style expected at the + // expansion site. PascalCase if the var name starts with uppercase, + // camelCase otherwise. `:pascal` always emits a PascalCase result. + // Underscores are excluded from camelCase: DOM method/property names + // are camelCase, and allowing `_` would let `{method}` swallow the + // `set_`/`get_` prefix of binding setters/getters (matching + // `mozilla::dom::Element_Binding::set_innerHTML(` as method= + // `set_innerHTML` instead of leaving it for the dom_setter template). + if (modifier === 'pascal' || /^[A-Z]/.test(name)) { + return '[A-Z][A-Za-z0-9]*'; + } + return '[a-z][A-Za-z0-9]*'; +} + +export function compilePatternToRegex(pattern: string): { + regex: RegExp; + vars: Array<{ name: string; modifier: string | undefined }>; +} { + const vars: Array<{ name: string; modifier: string | undefined }> = []; + let regexStr = ''; + let lastIndex = 0; + for (const m of pattern.matchAll(VAR_PATTERN_RE)) { + regexStr += escapeRegex(pattern.slice(lastIndex, m.index)); + const name = m[1]; + const modifier = m[2] ?? undefined; + regexStr += '(' + regexCharClassForVar(name, modifier) + ')'; + vars.push({ name, modifier }); + lastIndex = m.index! + m[0].length; + } + regexStr += escapeRegex(pattern.slice(lastIndex)); + return { regex: new RegExp('^' + regexStr), vars }; +} + +// --------------------------------------------------------------------------- +// Auto-discovery +// --------------------------------------------------------------------------- + +type CompiledAutoEntry = { + auto: AutoLabel; + patterns: Array<{ + pattern: string; + regex: RegExp; + vars: Array<{ name: string; modifier: string | undefined }>; + }>; +}; + +function compileAutoLabels(parsed: ParsedLabelToml): CompiledAutoEntry[] { + return parsed.autoLabels.map((auto) => { + const patterns = auto.patterns.map((pattern) => { + const { regex, vars } = compilePatternToRegex(pattern); + return { pattern, regex, vars }; + }); + return { auto, patterns }; + }); +} + +/** + * Walk `funcNames` and synthesize a label entry for each unique + * (auto-label, recovered-vars) tuple matched by an `[[auto_labels]]` entry. + * Each entry's funcPrefixes is the forward-expansion of every pattern in + * the entry, so the label still matches across engines even if only + * one engine's funcs were observed. + */ +export function discoverAutoLabels( + parsed: ParsedLabelToml, + funcNames: Iterable +): LabelDescription[] { + const compiled = compileAutoLabels(parsed); + if (compiled.length === 0) { + return []; + } + + type Discovered = { + auto: AutoLabel; + vars: Record; + labelName: string; + }; + const discovered = new Map(); + + for (const funcName of funcNames) { + for (const { auto, patterns } of compiled) { + for (const c of patterns) { + const m = funcName.match(c.regex); + if (!m) { + continue; + } + + const vars: Record = {}; + let recoverFailed = false; + for (let i = 0; i < c.vars.length; i++) { + const { name, modifier } = c.vars[i]; + try { + vars[name] = reverseModifier( + m[i + 1], + modifier, + parsed.blinkSpecialTokens + ); + } catch { + recoverFailed = true; + break; + } + } + if (recoverFailed) { + continue; + } + + // Round-trip verification: forward-expand the same pattern with the + // recovered vars and confirm it's a prefix of the observed func name. + // Filters out cases where the reverse modifier produced something the + // forward modifier doesn't agree with (e.g. special-token mismatch). + let roundTrip: string; + try { + roundTrip = expandPattern(c.pattern, vars, parsed.blinkSpecialTokens); + } catch { + continue; + } + if (!funcName.startsWith(roundTrip)) { + continue; + } + + let labelName: string; + try { + labelName = expandPattern( + auto.label, + vars, + parsed.blinkSpecialTokens + ); + } catch { + continue; + } + + const key = auto.label + '\0' + labelName; + if (!discovered.has(key)) { + discovered.set(key, { auto, vars, labelName }); + } + break; // first matching pattern wins for this (auto, funcName) + } + } + } + + const result: LabelDescription[] = []; + for (const d of discovered.values()) { + const funcPrefixes = d.auto.patterns.map((p) => + expandPattern(p, d.vars, parsed.blinkSpecialTokens) + ); + result.push({ name: d.labelName, funcPrefixes }); + } + return result; +} + +// --------------------------------------------------------------------------- +// TOML entry points +// --------------------------------------------------------------------------- + +export function parseLabelToml(tomlText: string): ParsedLabelToml { + const data = parseToml(tomlText) as unknown as { + global?: { blink_special_tokens?: string[] }; + labels?: LabelConfig[]; + auto_labels?: AutoLabel[]; + }; + return { + labels: data.labels ?? [], + autoLabels: data.auto_labels ?? [], + blinkSpecialTokens: data.global?.blink_special_tokens ?? [], + }; +} + +/** + * Resolve `[[auto_labels]]` against `funcNames`, then merge in `[[labels]]`. + * On a name collision between an auto-discovered label and an explicit one, + * funcPrefixes are concatenated (auto first, explicit second) — explicit + * labels can extend an auto-discovered label with extra prefixes the + * template can't synthesize. + */ +export function resolveAllLabels( + parsed: ParsedLabelToml, + funcNames: Iterable +): LabelDescription[] { + const auto = discoverAutoLabels(parsed, funcNames); + + const byName = new Map(); + for (const l of auto) { + byName.set(l.name, l); + } + for (const l of parsed.labels) { + const extras = l.funcPrefixes ?? []; + const existing = byName.get(l.name); + if (existing) { + byName.set(l.name, { + name: l.name, + funcPrefixes: [...existing.funcPrefixes, ...extras], + }); + } else { + byName.set(l.name, { name: l.name, funcPrefixes: extras }); + } + } + return [...byName.values()]; +} diff --git a/yarn.lock b/yarn.lock index d55c597ecf..9836914f32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10207,6 +10207,11 @@ smob@^1.0.0: resolved "https://registry.yarnpkg.com/smob/-/smob-1.5.0.tgz#85d79a1403abf128d24d3ebc1cdc5e1a9548d3ab" integrity sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig== +smol-toml@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.6.1.tgz#4fceb5f7c4b86c2544024ef686e12ff0983465be" + integrity sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg== + source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" From 7354e3c8b6aff1d7720afb971a2ca25900da498e Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 23 Apr 2026 15:36:06 -0400 Subject: [PATCH 3/3] Add profiler-edit --insert-stack-labels. This command takes a .toml file and adds label frames to a profile based on matching function names. We want to use this for profiles from samply, to insert labels for DOM calls and Layout / Style / etc. Example: Before: https://share.firefox.dev/48wEADM After: https://share.firefox.dev/3P9d3BQ The toml file has to be provided by the user, because the matched function names are specific to the program being profiled. Here's an example toml file: https://gist.github.com/mstange/827c40404c987bc566b8b324efc0a04f --- src/node-tools/profiler-edit.ts | 56 +++++++++++++++++++++++++++-- src/test/unit/profiler-edit.test.ts | 14 ++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts index 49c11a55f8..a69ab64eef 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -8,6 +8,7 @@ import { unserializeProfileOfArbitraryFormat } from 'firefox-profiler/profile-lo import { computeCompactedProfile } from 'firefox-profiler/profile-logic/profile-compacting'; import { GOOGLE_STORAGE_BUCKET } from 'firefox-profiler/app-logic/constants'; import { compress } from 'firefox-profiler/utils/gz'; +import { insertStackLabels } from 'firefox-profiler/profile-logic/insert-stack-labels'; import { SymbolStore } from 'firefox-profiler/profile-logic/symbol-store'; import { symbolicateProfile, @@ -21,6 +22,10 @@ import { } from 'firefox-profiler/profile-logic/wasm-symbolication'; import type { Profile } from 'firefox-profiler/types/profile'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import { + parseLabelToml, + resolveAllLabels, +} from 'firefox-profiler/utils/label-templates'; /** * A CLI tool for editing profiles. @@ -38,6 +43,9 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; * node node-tools-dist/profiler-edit.js -i input.json.gz -o out.json.gz \ * --symbolicate-wasm http://host/a.wasm=./a-unstripped.wasm \ * --symbolicate-wasm http://host/b.wasm=./b-unstripped.wasm + * + * node node-tools-dist/profiler-edit.js --from-hash w1spyw917hg... -o out.json.gz \ + * --insert-label-frames known-functions.toml */ type ProfileSource = @@ -61,6 +69,7 @@ export interface CliOptions { output: string; symbolicateWithServer?: string; symbolicateWasm: WasmSymbolicationCliSpec[]; + insertLabelFrames?: string; } function loadWasmSymbolicationSpecs( @@ -77,6 +86,34 @@ function loadWasmSymbolicationSpecs( }); } +/** + * Reconstruct the func-name strings used by insertStackLabels' prefix matcher + * (mirrors getLabelIndexForFunc in insert-stack-labels.ts), so auto-discovery + * sees the same strings the labeler will compare against. + */ +function collectFuncNames(profile: Profile): string[] { + const { funcTable, sources, stringArray } = profile.shared; + const result: string[] = []; + for (let i = 0; i < funcTable.length; i++) { + let name = stringArray[funcTable.name[i]]; + const sourceIndex = funcTable.source[i]; + if (sourceIndex !== null) { + const filename = stringArray[sources.filename[sourceIndex]]; + const line = funcTable.lineNumber[i]; + const col = funcTable.columnNumber[i]; + if (line !== null && col !== null) { + name += ` (${filename}:${line}:${col})`; + } else if (line !== null) { + name += ` (${filename}:${line})`; + } else { + name += ` (${filename})`; + } + } + result.push(name); + } + return result; +} + async function loadProfile(source: ProfileSource): Promise { switch (source.type) { case 'FILE': { @@ -129,7 +166,7 @@ async function loadProfile(source: ProfileSource): Promise { } export async function run(options: CliOptions) { - const profile = await loadProfile(options.input); + let profile = await loadProfile(options.input); if (options.symbolicateWithServer !== undefined) { const server = options.symbolicateWithServer; @@ -183,6 +220,15 @@ export async function run(options: CliOptions) { loadWasmSymbolicationSpecs(options.symbolicateWasm) ); + if (options.insertLabelFrames !== undefined) { + console.log('Inserting label frames...'); + const tomlText = fs.readFileSync(options.insertLabelFrames, 'utf8'); + const parsed = parseLabelToml(tomlText); + const funcNames = collectFuncNames(profile); + const labels = resolveAllLabels(parsed, funcNames); + profile = insertStackLabels(profile, labels); + } + const { profile: compactedProfile } = computeCompactedProfile(profile); console.log(`Saving profile to ${options.output}`); @@ -243,7 +289,8 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { ) .argParser(collectWasm) .default([] as WasmSymbolicationCliSpec[]) - ); + ) + .option('--insert-label-frames ', 'TOML file with label definitions'); program.parse(processArgv); const opts = program.opts(); @@ -290,6 +337,11 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { ? opts.symbolicateWithServer : undefined, symbolicateWasm: opts.symbolicateWasm, + insertLabelFrames: + typeof opts.insertLabelFrames === 'string' && + opts.insertLabelFrames !== '' + ? opts.insertLabelFrames + : undefined, }; } diff --git a/src/test/unit/profiler-edit.test.ts b/src/test/unit/profiler-edit.test.ts index 82205160fa..20a83c0dad 100644 --- a/src/test/unit/profiler-edit.test.ts +++ b/src/test/unit/profiler-edit.test.ts @@ -20,6 +20,7 @@ describe('makeOptionsFromArgv', function () { }); expect(options.output).toEqual('/path/to/output.json'); expect(options.symbolicateWithServer).toBeUndefined(); + expect(options.insertLabelFrames).toBeUndefined(); }); it('recognizes -i with an http URL', function () { @@ -113,6 +114,19 @@ describe('makeOptionsFromArgv', function () { expect(options.symbolicateWithServer).toEqual('http://localhost:8001/'); }); + it('recognizes optional --insert-label-frames', function () { + const options = makeOptionsFromArgv([ + ...commonArgs, + '-i', + '/path/to/profile.json', + '-o', + '/path/to/output.json', + '--insert-label-frames', + '/path/to/labels.toml', + ]); + expect(options.insertLabelFrames).toEqual('/path/to/labels.toml'); + }); + it('throws when no input is provided', function () { expect(() => makeOptionsFromArgv([...commonArgs, '-o', '/path/to/output.json'])