diff --git a/package.json b/package.json index 37177ba748..dd5bac56ae 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "redux-logger": "^3.0.6", "redux-thunk": "^3.1.0", "reselect": "^4.1.8", + "smol-toml": "^1.6.1", "source-map": "^0.7.6", "url": "^0.11.4", "valibot": "^1.4.1", diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts index 1de294e4dd..f6ae9032f4 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import fs from 'fs'; import { Command, CommanderError, Option } from 'commander'; +import { parse as parseToml } from 'smol-toml'; import { serializeProfileToJsonSlabsFile, @@ -12,6 +13,7 @@ import { 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, @@ -25,6 +27,11 @@ import { } from 'firefox-profiler/profile-logic/wasm-symbolication'; import type { Profile } from 'firefox-profiler/types/profile'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import { + type AutoLabel, + type LabelDescription, + resolveAllLabels, +} from 'firefox-profiler/utils/label-templates'; /** * A CLI tool for editing profiles. @@ -42,6 +49,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 = @@ -65,6 +75,7 @@ export interface CliOptions { output: string; symbolicateWithServer?: string; symbolicateWasm: WasmSymbolicationCliSpec[]; + insertLabelFrames?: string; } function loadWasmSymbolicationSpecs( @@ -81,6 +92,42 @@ 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]]; + name += ` (${filename})`; + } + result.push(name); + } + return result; +} + +export type ParsedLabelToml = { + labels: LabelDescription[]; + autoLabels: AutoLabel[]; +}; + +export function parseLabelToml(tomlText: string): ParsedLabelToml { + const data = parseToml(tomlText) as unknown as { + labels?: LabelDescription[]; + auto_labels?: AutoLabel[]; + }; + return { + labels: data.labels ?? [], + autoLabels: data.auto_labels ?? [], + }; +} + async function loadProfile(source: ProfileSource): Promise { switch (source.type) { case 'FILE': { @@ -151,7 +198,7 @@ async function encodeProfileWithFilename( } 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; @@ -205,6 +252,19 @@ 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.autoLabels, + parsed.labels, + funcNames + ); + profile = insertStackLabels(profile, labels); + } + const { profile: compactedProfile } = computeCompactedProfile(profile); const outputFilename = options.output; @@ -263,7 +323,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(); @@ -310,6 +371,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/label-templates.test.ts b/src/test/unit/label-templates.test.ts new file mode 100644 index 0000000000..5d7bc7f358 --- /dev/null +++ b/src/test/unit/label-templates.test.ts @@ -0,0 +1,353 @@ +/* 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 { + expandPattern, + reverseModifier, + reverseBlinkSnake, + compilePatternToRegex, + discoverAutoLabels, + resolveAllLabels, +} from 'firefox-profiler/utils/label-templates'; +import type { AutoLabel } from 'firefox-profiler/utils/label-templates'; + +// --------------------------------------------------------------------------- +// expandPattern +// --------------------------------------------------------------------------- + +describe('expandPattern', function () { + it('substitutes a plain variable', function () { + expect( + expandPattern('{Class}.{method}', { + Class: 'Element', + method: 'querySelector', + }) + ).toBe('Element.querySelector'); + }); + + 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}.{method}', { Class: 'Element' }) + ).toThrow('Template variable "method" not provided'); + }); + + it('throws when a modifier is used in a label template', function () { + expect(() => + expandPattern('{Class:blink_snake}', { Class: 'Element' }) + ).toThrow('Template modifier ":blink_snake" is not supported'); + }); +}); + +// --------------------------------------------------------------------------- +// 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')).toBe('HTMLElement'); + expect(reverseBlinkSnake('html_image_element')).toBe('HTMLImageElement'); + expect(reverseBlinkSnake('css_style_sheet')).toBe('CSSStyleSheet'); + expect(reverseBlinkSnake('xml_http_request')).toBe('XmlHttpRequest'); + }); + + it('uses the special-tokens list to recover mixed-case fragments', function () { + expect(reverseBlinkSnake('webgl_rendering_context')).toBe( + 'WebGLRenderingContext' + ); + expect(reverseBlinkSnake('webgl2_rendering_context')).toBe( + 'WebGL2RenderingContext' + ); + expect(reverseBlinkSnake('xpath_evaluator')).toBe('XPathEvaluator'); + }); + + it('handles trailing digit-bearing tokens', function () { + expect(reverseBlinkSnake('canvas_rendering_context_2d')).toBe( + 'CanvasRenderingContext2D' + ); + expect(reverseBlinkSnake('canvas_2d')).toBe('Canvas2D'); + }); +}); + +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')).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 = { + nameTemplate: '{Class}.{method}', + funcPrefixTemplates: [ + '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 = { + nameTemplate: 'set {Class}.{prop}', + funcPrefixTemplates: [ + '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( + [DOM_OPERATION_AUTO], + ['mozilla::dom::Element_Binding::querySelector(args)'] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('Element.querySelector'); + expect(labels[0].funcPrefixes).toEqual([ + 'mozilla::dom::Element_Binding::querySelector(', + ]); + }); + + it('discovers a Blink-style operation, recovering Class via the special-tokens list', function () { + const labels = discoverAutoLabels( + [DOM_OPERATION_AUTO], + ['blink::(anonymous namespace)::v8_html_image_element::ClickOperation'] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('HTMLImageElement.click'); + expect(labels[0].funcPrefixes).toEqual([ + 'blink::(anonymous namespace)::v8_html_image_element::ClickOperation', + ]); + }); + + it('collects every observed engine form under one label entry', function () { + const labels = discoverAutoLabels( + [DOM_OPERATION_AUTO], + [ + 'mozilla::dom::Element_Binding::querySelector(args)', + 'WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*)', + ] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('Element.querySelector'); + expect(labels[0].funcPrefixes).toEqual([ + 'mozilla::dom::Element_Binding::querySelector(', + 'WebCore::jsElementPrototypeFunction_querySelector', + ]); + }); + + it('deduplicates identical observed prefixes', function () { + const labels = discoverAutoLabels( + [DOM_OPERATION_AUTO], + [ + 'mozilla::dom::Element_Binding::querySelector(args)', + 'mozilla::dom::Element_Binding::querySelector(other_args)', + ] + ); + expect(labels).toHaveLength(1); + expect(labels[0].funcPrefixes).toEqual([ + 'mozilla::dom::Element_Binding::querySelector(', + ]); + }); + + it('discovers a setter using a separate auto_labels entry', function () { + const labels = discoverAutoLabels( + [DOM_SETTER_AUTO], + ['mozilla::dom::Element_Binding::set_id(args)'] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('set Element.id'); + }); + + 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( + [DOM_OPERATION_AUTO, DOM_SETTER_AUTO], + ['mozilla::dom::Element_Binding::set_innerHTML(args)'] + ); + expect(labels.map((l) => l.name)).toEqual(['set Element.innerHTML']); + }); +}); + +describe('resolveAllLabels', function () { + // When two `[[auto_labels]]` entries synthesize a label with the same name + // (e.g. a generic `{Class}.{method}` template and a specific + // `CanvasRenderingContext2D.{method}` template both yielding + // `CanvasRenderingContext2D.fill`), every matched funcPrefix should end up + // on the merged label — not just the prefixes from whichever auto_labels + // entry happened to be processed last. Previously the second entry + // overwrote the first in `byName`, dropping the direct Blink call's prefix + // (`blink::Canvas2DRecorderContext::fill(`) and leaving only the V8 wrapper + // prefix, so direct C++ calls to `fill()` never got the label. + const CANVAS_SPECIFIC_AUTO: AutoLabel = { + nameTemplate: 'CanvasRenderingContext2D.{method}', + funcPrefixTemplates: [ + "blink::`anonymous namespace'::v8_canvas_rendering_context_2d::{method:pascal}Operation", + 'blink::Canvas2DRecorderContext::{method}(', + ], + }; + + it('merges prefixes when a generic and a specific auto_label produce the same name', function () { + // Order of funcNames matters for catching the bug. When the C++ direct + // call (only matched by CANVAS_SPECIFIC_AUTO) is processed first, the + // `discovered` Map ends up with the specific entry inserted before the + // generic entry. `[...discovered.values()]` then iterates specific + // before generic, and the buggy `byName.set(l.name, l)` overwrites the + // specific entry (with both prefixes) with the generic entry (with + // only the V8 wrapper prefix). Putting the V8 wrapper first hides the + // bug because the specific entry is then the last value iterated and + // wins the overwrite by accident. + const resolved = resolveAllLabels( + [DOM_OPERATION_AUTO, CANVAS_SPECIFIC_AUTO], + [], + [ + // Direct Blink C++ call: matches only CANVAS_SPECIFIC_AUTO. Without + // the merge fix this prefix gets dropped when the generic-auto + // entry overwrites the specific one in `byName`. + 'blink::Canvas2DRecorderContext::fill() (canvas_2d_recorder_context.cc)', + // V8 wrapper: matches both DOM_OPERATION_AUTO (via the generic + // v8_{Class:blink_snake}::{method:pascal}Operation template) and + // CANVAS_SPECIFIC_AUTO (via its method-only template). Both + // synthesize the name `CanvasRenderingContext2D.fill`. + "blink::`anonymous namespace'::v8_canvas_rendering_context_2d::FillOperationCallback(args)", + ] + ); + const fill = resolved.find( + (l) => l.name === 'CanvasRenderingContext2D.fill' + ); + expect(fill).toBeDefined(); + expect(fill!.funcPrefixes).toEqual( + expect.arrayContaining([ + "blink::`anonymous namespace'::v8_canvas_rendering_context_2d::FillOperation", + 'blink::Canvas2DRecorderContext::fill(', + ]) + ); + // And the result must have exactly one entry for this name, not one per + // contributing auto_labels entry. + expect( + resolved.filter((l) => l.name === 'CanvasRenderingContext2D.fill') + ).toHaveLength(1); + }); + + it('appends explicit [[labels]] prefixes onto an auto-discovered label of the same name', function () { + const resolved = resolveAllLabels( + [DOM_OPERATION_AUTO], + [ + { + name: 'Element.querySelector', + funcPrefixes: ['SomeExtraPrefix::querySelector('], + }, + ], + ['mozilla::dom::Element_Binding::querySelector(args)'] + ); + const qs = resolved.find((l) => l.name === 'Element.querySelector'); + expect(qs).toBeDefined(); + expect(qs!.funcPrefixes).toEqual([ + 'mozilla::dom::Element_Binding::querySelector(', + 'SomeExtraPrefix::querySelector(', + ]); + }); + + it('passes through an explicit-only label when nothing auto-discovers the same name', function () { + const resolved = resolveAllLabels( + [DOM_OPERATION_AUTO], + [{ name: 'Custom Label', funcPrefixes: ['some::prefix('] }], + [] + ); + expect(resolved).toEqual([ + { name: 'Custom Label', funcPrefixes: ['some::prefix('] }, + ]); + }); +}); 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']) diff --git a/src/utils/label-templates.ts b/src/utils/label-templates.ts new file mode 100644 index 0000000000..c9f788861a --- /dev/null +++ b/src/utils/label-templates.ts @@ -0,0 +1,352 @@ +/* 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/. */ + +// An "auto" label: a template form of `LabelDescription` whose concrete +// instantiations are synthesized by matching its `funcPrefixTemplates` +// against function names in the profile. E.g. matching +// "mozilla::dom::{Class}_Binding::{method}(" against +// "mozilla::dom::EventTarget_Binding::addEventListener(JSContext*, ...)" will create +// an instantiation with Class="EventTarget" and method="addEventListener". +// `nameTemplate` and `funcPrefixTemplates` mirror the `name` and +// `funcPrefixes` of `LabelDescription`: each template expands, with the +// recovered variable values, into the corresponding literal field. +export type AutoLabel = { + // Template for the synthesized label's `name`, e.g. "set {Class}.{prop}". + // Only plain `{name}` placeholders are allowed here, no `{name:modifier}`. + nameTemplate: string; + // A list of templates, each matched independently against funcNames; a + // successful match becomes one entry in the synthesized label's + // `funcPrefixes`. A single AutoLabel typically lists one template per + // engine (Gecko / Blink / WebKit) so the same logical label is + // discovered from any of the supported binding-name styles. + // + // Template variables may carry a modifier, written `{name:modifier}`, + // which controls both what the compiled regex accepts and how the + // captured text is transformed back before substitution into + // `nameTemplate`: + // + // - no modifier: matches PascalCase when the variable name starts with + // an uppercase letter (e.g. `{Class}` → `Element`), camelCase + // otherwise (e.g. `{method}` → `querySelector`). The captured text + // is substituted as-is. + // - `:pascal`: matches PascalCase; the first letter is lowercased on + // substitution. Used to recover Blink V8 binding method names + // (`SetSrc` in `SetSrcOperation` → `setSrc`). + // - `:blink_snake`: matches lowercase snake_case; on substitution the + // value is reassembled into PascalCase using `BLINK_SPECIAL_TOKENS` + // to recover acronym casing (`html_image_element` → `HTMLImageElement`). + funcPrefixTemplates: string[]; +}; + +// An explicit label entry, either authored directly in a labels TOML file +// (`[[labels]]`) or synthesized by `discoverAutoLabels` from an +// `[[auto_labels]]` entry. A stack frame whose funcName starts with any +// string in `funcPrefixes` gets `name` attached as its label by +// `insertStackLabels`. +export type LabelDescription = { + name: string; + funcPrefixes: string[]; +}; + +// An AutoLabel with each of its `funcPrefixTemplates` compiled to a regex +// plus the ordered list of variables that regex captures (one entry per +// capture group, in match order). +type CompiledAutoEntry = { + auto: AutoLabel; + funcPrefixTemplates: Array<{ + regex: RegExp; + vars: Array<{ name: string; modifier: string | undefined }>; + }>; +}; + +// Allows mapping strings from auto-detected Blink DOM binding C++ functions +// to correctly-cased class names. +// For example, if we match `v8_{Class:blink_snake}` against `v8_dom_token_list`, +// we want to produce the class name "DOMTokenList" rather than "DomTokenList". +// Based on https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/build/scripts/blinkbuild/name_style_converter.py;l=10;drc=047c7dc4ee1ce908d7fea38ca063fa2f80f92c77 +const BLINK_SPECIAL_TOKENS = [ + 'WebCodecs', + 'WebSocket', + 'String16', + 'Float32', + 'Float64', + 'Base64', + 'IFrame', + 'Latin1', + 'MathML', + 'PlugIn', + 'SQLite', + 'Uint16', + 'Uint32', + 'WebGL2', + 'webgl2', + 'WebGPU', + 'ASCII', + 'CSSOM', + 'CType', + 'DList', + 'Int16', + 'Int32', + 'MPath', + 'OList', + 'TSpan', + 'UList', + 'UTF16', + 'Uint8', + 'WebGL', + 'XPath', + 'ETC1', + 'etc1', + 'HTML', + 'Int8', + 'S3TC', + 's3tc', + 'SPv2', + 'UTF8', + 'sRGB', + 'URLs', + 'API', + 'CSS', + 'DNS', + 'DOM', + 'EXT', + 'RTC', + 'SVG', + 'XSS', + '2D', + 'AX', + 'FE', + 'JS', + 'V0', + 'V8', + 'v8', + 'XR', +]; + +// A map which allows looking up the correctly-cased token based on +// its lower cased variant, e.g. "urls" -> "URLs" +const BLINK_TOKEN_BY_LOWER = (function buildBlinkTokenByLower() { + const tokenByLower = new Map(); + for (const t of BLINK_SPECIAL_TOKENS) { + const lower = t.toLowerCase(); + if (!tokenByLower.has(lower)) { + tokenByLower.set(lower, t); + } + } + return tokenByLower; +})(); + +/** + * Reverse a `:blink_snake`-formed string back to its PascalCase original. + * + * For example, this turns "html_div_element" into "HTMLDivElement". + * + * Lowercasing during `:blink_snake` is one-way: `html_element` could come + * from either `HtmlElement` or `HTMLElement`. `BLINK_SPECIAL_TOKENS` + * supplies canonical-cased fragments to disambiguate; unknown segments + * get their first letter capitalised. + */ +export function reverseBlinkSnake(value: string): string { + return value + .split('_') + .map( + (seg) => + BLINK_TOKEN_BY_LOWER.get(seg) ?? + seg.charAt(0).toUpperCase() + seg.slice(1) + ) + .join(''); +} + +/** + * Reverse the case transformation implied by `modifier` so that a value + * captured from a funcName can be substituted back into a label template + * in its canonical form. See the modifier list on + * `AutoLabel.funcPrefixTemplates`. + */ +export function reverseModifier( + value: string, + modifier: string | undefined +): string { + switch (modifier) { + case 'pascal': + return value.charAt(0).toLowerCase() + value.slice(1); + case 'blink_snake': + return reverseBlinkSnake(value); + case undefined: + return value; + default: + throw new Error(`Unknown template modifier: ${modifier}`); + } +} + +// This regex matches `{name}` and `{name:modifier}` placeholders. +const TEMPLATE_VAR_RE = /\{(\w+)(?::(\w+))?\}/g; + +/** + * Substitute `{name}` placeholders in a label template. Used only to + * produce human-readable label names like `Element.querySelector`; + * modifier syntax (`{name:modifier}`) is not supported here. + */ +export function expandPattern( + template: string, + vars: Record +): string { + return template.replace(TEMPLATE_VAR_RE, (_match, name: string, modifier) => { + if (modifier !== undefined) { + throw new Error( + `Template modifier ":${modifier}" is not supported in label names` + ); + } + if (!(name in vars)) { + throw new Error(`Template variable "${name}" not provided`); + } + return vars[name]; + }); +} + +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]*'; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Compile a pattern string to a regex that matches a prefix of a funcName, + * together with the ordered list of template variables (one per capture + * group). Literal text in the pattern is regex-escaped; each `{name}` or + * `{name:modifier}` placeholder is replaced by a capturing group whose + * character class is chosen by `regexCharClassForVar`. + * + * E.g. "mozilla::dom::{Class}_Binding::set_{prop}(" compiles to + * /^mozilla::dom::([A-Z][A-Za-z0-9]*)_Binding::set_([a-z][A-Za-z0-9]*)\(/ + * with vars [{ name: 'Class' }, { name: 'prop' }]. + * + * The regex is anchored at `^` but not at `$`, so a successful match's + * `m[0]` is the literal funcName prefix used as `funcPrefixes` entry. + */ +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(TEMPLATE_VAR_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 }; +} + +function compileAutoLabel(auto: AutoLabel): CompiledAutoEntry { + const funcPrefixTemplates = auto.funcPrefixTemplates.map((template) => { + const { regex, vars } = compilePatternToRegex(template); + return { regex, vars }; + }); + return { auto, funcPrefixTemplates }; +} + +/** + * 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` collects the actual matched prefix of every + * funcName that hit one of the entry's templates, so the same label still + * attaches to every observed form of the same (Class, method) pair. + */ +export function discoverAutoLabels( + autoLabels: AutoLabel[], + funcNames: Iterable +): LabelDescription[] { + const compiled = autoLabels.map((autoLabel) => compileAutoLabel(autoLabel)); + if (compiled.length === 0) { + return []; + } + + const discovered = new Map(); + + for (const funcName of funcNames) { + for (const { auto, funcPrefixTemplates } of compiled) { + for (const c of funcPrefixTemplates) { + const m = funcName.match(c.regex); + if (!m) { + continue; + } + + const vars: Record = {}; + for (let i = 0; i < c.vars.length; i++) { + const { name, modifier } = c.vars[i]; + vars[name] = reverseModifier(m[i + 1], modifier); + } + + const labelName = expandPattern(auto.nameTemplate, vars); + + const key = auto.nameTemplate + '\0' + labelName; + const existing = discovered.get(key); + if (existing === undefined) { + discovered.set(key, { name: labelName, funcPrefixes: [m[0]] }); + } else if (!existing.funcPrefixes.includes(m[0])) { + existing.funcPrefixes.push(m[0]); + } + break; // first matching template wins for this (auto, funcName) + } + } + } + + return [...discovered.values()]; +} + +/** + * Resolve `autoLabels` against `funcNames`, then merge in `labels`. + * On a name collision (whether between two auto-discovered labels, or between + * an auto-discovered label and an explicit one), funcPrefixes are merged into + * a deduplicated union. Two `autoLabels` entries can legitimately produce + * the same label name from different templates — e.g. a generic `{Class}.{method}` + * entry and a specific `CanvasRenderingContext2D.{method}` entry both yielding + * `CanvasRenderingContext2D.fill` — and we want every matched prefix to apply. + */ +export function resolveAllLabels( + autoLabels: AutoLabel[], + labels: LabelDescription[], + funcNames: Iterable +): LabelDescription[] { + const auto = discoverAutoLabels(autoLabels, funcNames); + const allLabels = auto.concat(labels); + + const byName = new Map(); + for (const { name, funcPrefixes } of allLabels) { + let entry = byName.get(name); + if (entry === undefined) { + entry = { name, funcPrefixes: [] }; + byName.set(name, entry); + } + entry.funcPrefixes.push(...funcPrefixes); + } + return [...byName.values()]; +} diff --git a/yarn.lock b/yarn.lock index a31187503e..1e6521f94b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10217,6 +10217,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"