diff --git a/.gitignore b/.gitignore index 72c9614..879c706 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .deno/ bin/ coverage/ +.bench/ dist/ node_modules/ !src/bundled/typescript/types/node_modules/ diff --git a/deno.json b/deno.json index 1e0eef6..7448a19 100644 --- a/deno.json +++ b/deno.json @@ -59,6 +59,8 @@ "tasks": { "build": "deno compile --allow-env --allow-read --allow-run --allow-write --output ./bin/soundscript src/main.ts && deno run -A scripts/release/stage_cli_runtime_support.ts ./bin", "dev": "deno run src/main.ts", + "bench:expand": "deno run -A scripts/perf/expand_benchmark.ts", + "bench:expand:quick": "deno run -A scripts/perf/expand_benchmark.ts --iterations 1 --warmups 0", "check": "deno check src/main.ts src/cli/cli.ts src/lsp_main.ts src/stdlib/*.ts", "fmt": "deno fmt", "fmt:check": "deno fmt --check", diff --git a/scripts/perf/expand_benchmark.ts b/scripts/perf/expand_benchmark.ts new file mode 100644 index 0000000..303ec51 --- /dev/null +++ b/scripts/perf/expand_benchmark.ts @@ -0,0 +1,525 @@ +import { dirname, join } from '@std/path'; + +import { expandProject } from '../../src/frontend/expand_project.ts'; +interface BenchmarkOptions { + iterations: number; + outDir: string; + warmups: number; +} + +interface FixtureFile { + readonly path: string; + readonly contents: string; +} + +interface TimingEntry { + readonly durationMs: number; + readonly metadata: Record; + readonly stage: string; +} + +interface IterationResult { + readonly diagnostics: number; + readonly exitCode: number; + readonly timings: readonly TimingEntry[]; + readonly wallMs: number; +} + +interface ScenarioResult { + readonly iterations: readonly IterationResult[]; + readonly name: string; + readonly projectPath: string; +} + +const DEFAULT_OPTIONS: BenchmarkOptions = { + iterations: 5, + outDir: join(Deno.cwd(), '.bench', 'expand'), + warmups: 1, +}; + +function parseArgs(args: readonly string[]): BenchmarkOptions { + const options: BenchmarkOptions = { ...DEFAULT_OPTIONS }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--iterations') { + options.iterations = Number(args[index + 1] ?? ''); + index += 1; + continue; + } + if (arg === '--warmups') { + options.warmups = Number(args[index + 1] ?? ''); + index += 1; + continue; + } + if (arg === '--out-dir') { + options.outDir = args[index + 1] ?? ''; + index += 1; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (!Number.isInteger(options.iterations) || options.iterations < 1) { + throw new Error('--iterations must be a positive integer.'); + } + if (!Number.isInteger(options.warmups) || options.warmups < 0) { + throw new Error('--warmups must be a non-negative integer.'); + } + if (!options.outDir) { + throw new Error('--out-dir must not be empty.'); + } + return options; +} + +function baseProjectFiles(extraFiles: readonly FixtureFile[]): readonly FixtureFile[] { + return [ + { + path: 'package.json', + contents: JSON.stringify( + { + name: 'soundscript-expand-benchmark-fixture', + version: '1.0.0', + type: 'module', + }, + null, + 2, + ), + }, + { + path: 'tsconfig.json', + contents: JSON.stringify( + { + compilerOptions: { + strict: true, + noEmit: true, + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + }, + include: ['src/**/*.sts'], + }, + null, + 2, + ), + }, + ...extraFiles, + ]; +} + +function noMacroFixture(): readonly FixtureFile[] { + return baseProjectFiles([ + { + path: 'src/main.sts', + contents: [ + 'export interface Item {', + ' readonly id: string;', + ' readonly count: number;', + '}', + '', + 'export function total(items: readonly Item[]): number {', + ' return items.reduce((sum, item) => sum + item.count, 0);', + '}', + '', + ].join('\n'), + }, + ]); +} + +function stdlibMacroFixture(): readonly FixtureFile[] { + return baseProjectFiles([ + { + path: 'src/main.sts', + contents: [ + "type Ok = { tag: 'ok'; value: number };", + "type Err = { tag: 'err'; error: string };", + '', + 'export function score(value: Ok | Err | undefined): number {', + ' return Match(value, [', + ' ({ value }: Ok) => value,', + ' ({ error }: Err) => error.length,', + ' (_: undefined) => 0,', + ' ]);', + '}', + '', + ].join('\n'), + }, + ]); +} + +function userMacroFixture(): readonly FixtureFile[] { + return baseProjectFiles([ + { + path: 'src/macros.macro.sts', + contents: [ + "import 'sts:macros';", + '', + '// #[macro(call)]', + 'export function Two() {', + ' return {', + ' expand(ctx: any) {', + ' return ctx.output.expr(ctx.quote.expr`2`);', + ' },', + ' };', + '}', + '', + ].join('\n'), + }, + { + path: 'src/main.sts', + contents: [ + "import { Two } from './macros.macro';", + '', + 'export const value = Two();', + '', + ].join('\n'), + }, + ]); +} + +function packageMacroFixture(): readonly FixtureFile[] { + return baseProjectFiles([ + { + path: 'node_modules/bench-macro-pkg/package.json', + contents: JSON.stringify( + { + name: 'bench-macro-pkg', + version: '1.0.0', + type: 'module', + types: './dist/index.d.ts', + soundscript: { + source: './src/index.sts', + }, + }, + null, + 2, + ), + }, + { + path: 'node_modules/bench-macro-pkg/dist/index.d.ts', + contents: 'export declare function Three(): number;\n', + }, + { + path: 'node_modules/bench-macro-pkg/src/index.sts', + contents: 'export { Three } from "./macros/three.macro";\n', + }, + { + path: 'node_modules/bench-macro-pkg/src/macros/three.macro.sts', + contents: [ + "import 'sts:macros';", + '', + '// #[macro(call)]', + 'export function Three() {', + ' return {', + ' expand(ctx: any) {', + ' return ctx.output.expr(ctx.quote.expr`3`);', + ' },', + ' };', + '}', + '', + ].join('\n'), + }, + { + path: 'src/main.sts', + contents: [ + "import { Three } from 'bench-macro-pkg';", + '', + 'export const value = Three();', + '', + ].join('\n'), + }, + ]); +} + +function generatedStdlibMacroFixture(): readonly FixtureFile[] { + return baseProjectFiles([ + { + path: 'src/model.macro.sts', + contents: [ + "import 'sts:macros';", + '', + '// #[macro(call)]', + 'export function EmitTodo() {', + ' return {', + ' expand(ctx: any) {', + ' return ctx.output.expr(ctx.quote.expr`todo("generated recursive macro")`);', + ' },', + ' };', + '}', + '', + ].join('\n'), + }, + { + path: 'src/main.sts', + contents: [ + "import { EmitTodo } from './model.macro';", + '', + 'export function fail(): never {', + ' return EmitTodo();', + '}', + '', + ].join('\n'), + }, + ]); +} + +function soundstageLikeFixture(): readonly FixtureFile[] { + return baseProjectFiles([ + { + path: 'src/view.macro.sts', + contents: [ + "import { macroSignature } from 'sts:macros';", + '', + "const DECL = macroSignature.of(macroSignature.decl('target'));", + '', + '// #[macro(decl)]', + 'export function view() {', + ' return {', + " declarationKinds: ['class'] as const,", + " expansionMode: 'augment' as const,", + ' signature: DECL,', + ' expand(ctx: any) {', + ' const name = ctx.syntax.declaration().name ?? ctx.error("expected class name");', + ' return ctx.output.stmt(ctx.quote.stmt`', + ' export function ${`mount${name}`}(target: unknown): void {', + ' void target;', + ' }', + ' `);', + ' },', + ' };', + '}', + '', + ].join('\n'), + }, + { + path: 'src/app.sts', + contents: [ + "import { view } from './view.macro';", + '', + '// #[view]', + 'export class App {', + ' title = "Bench";', + '}', + '', + ].join('\n'), + }, + { + path: 'src/browser.sts', + contents: [ + "import { mountApp } from './app';", + '', + 'export function mount(target: unknown): void {', + ' mountApp(target);', + '}', + '', + ].join('\n'), + }, + ]); +} + +const FIXTURES: ReadonlyArray<{ + readonly name: string; + readonly files: () => readonly FixtureFile[]; +}> = [ + { name: 'no-macros', files: noMacroFixture }, + { name: 'stdlib-macros', files: stdlibMacroFixture }, + { name: 'user-macros', files: userMacroFixture }, + { name: 'package-macros', files: packageMacroFixture }, + { name: 'generated-stdlib-macros', files: generatedStdlibMacroFixture }, + { name: 'soundstage-like', files: soundstageLikeFixture }, +]; + +async function writeFixture(root: string, files: readonly FixtureFile[]): Promise { + for (const file of files) { + const path = join(root, file.path); + await Deno.mkdir(dirname(path), { recursive: true }); + await Deno.writeTextFile(path, file.contents); + } +} + +function parseTiming(line: string): TimingEntry | null { + const match = /^\[soundscript:checker\]\s+(\S+)\s+([\d.]+)ms(?:\s+(.*))?$/u.exec(line); + if (!match) { + return null; + } + + const metadata: Record = {}; + for (const part of (match[3] ?? '').split(/\s+/u)) { + if (!part) { + continue; + } + const separatorIndex = part.indexOf('='); + if (separatorIndex <= 0) { + continue; + } + metadata[part.slice(0, separatorIndex)] = part.slice(separatorIndex + 1); + } + + return { + durationMs: Number(match[2]), + metadata, + stage: match[1], + }; +} + +async function runExpand( + projectPath: string, + outDir: string, +): Promise { + const originalTiming = Deno.env.get('SOUNDSCRIPT_CHECKER_TIMING'); + const originalError = console.error; + const logs: string[] = []; + console.error = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + Deno.env.set('SOUNDSCRIPT_CHECKER_TIMING', '1'); + + const start = performance.now(); + try { + const result = await expandProject({ + outDir, + projectPath, + workingDirectory: dirname(projectPath), + }); + return { + diagnostics: result.diagnostics.length, + exitCode: result.exitCode, + timings: logs.map(parseTiming).filter((entry): entry is TimingEntry => entry !== null), + wallMs: performance.now() - start, + }; + } finally { + if (originalTiming === undefined) { + Deno.env.delete('SOUNDSCRIPT_CHECKER_TIMING'); + } else { + Deno.env.set('SOUNDSCRIPT_CHECKER_TIMING', originalTiming); + } + console.error = originalError; + } +} + +function median(values: readonly number[]): number { + if (values.length === 0) { + return 0; + } + const sorted = [...values].sort((left, right) => left - right); + const midpoint = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[midpoint - 1]! + sorted[midpoint]!) / 2 + : sorted[midpoint]!; +} + +function stageMedian(result: ScenarioResult, stage: string): number { + return median( + result.iterations.map((iteration) => + iteration.timings + .filter((entry) => entry.stage === stage) + .reduce((total, entry) => total + entry.durationMs, 0) + ), + ); +} + +function semanticBuildMedian(result: ScenarioResult): number { + return median( + result.iterations.map((iteration) => + iteration.timings.filter((entry) => + entry.stage === 'project.prepare.semanticBuilderHostReuse' + ).length + ), + ); +} + +function macroDetailMedian(result: ScenarioResult, metadataKey: string): number { + return median( + result.iterations.map((iteration) => + iteration.timings + .filter((entry) => entry.stage === 'project.prepare.macro.expandDetails') + .reduce((total, entry) => total + Number(entry.metadata[metadataKey] ?? 0), 0) + ), + ); +} + +function markdownReport(results: readonly ScenarioResult[]): string { + const lines = [ + '# Soundscript Expand Benchmark', + '', + '| scenario | wall median ms | initial ms | expand macros ms | package export ms | graph compile ms | generated stdlib ms | module eval ms | binding plan ms | macro exec ms | source expansion ms | annotated ms | final ms | semantic builds |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', + ]; + for (const result of results) { + lines.push( + `| ${result.name} | ${ + median(result.iterations.map((iteration) => iteration.wallMs)).toFixed(1) + } | ${stageMedian(result, 'project.prepare.builtin.initialProgram').toFixed(1)} | ${ + stageMedian(result, 'project.prepare.builtin.expandMacros').toFixed(1) + } | ${macroDetailMedian(result, 'packageExportInfoMs').toFixed(1)} | ${ + macroDetailMedian(result, 'graphCompileMs').toFixed(1) + } | ${macroDetailMedian(result, 'generatedStdlibMs').toFixed(1)} | ${ + macroDetailMedian(result, 'moduleEvalMs').toFixed(1) + } | ${macroDetailMedian(result, 'bindingPlanMs').toFixed(1)} | ${ + macroDetailMedian(result, 'macroExecutionMs').toFixed(1) + } | ${macroDetailMedian(result, 'sourceExpansionMs').toFixed(1)} | ${ + stageMedian(result, 'project.prepare.builtin.annotatedProgram').toFixed(1) + } | ${stageMedian(result, 'project.prepare.builtin.finalProgram').toFixed(1)} | ${ + semanticBuildMedian(result).toFixed(1) + } |`, + ); + } + lines.push(''); + return lines.join('\n'); +} + +async function runScenario( + name: string, + files: readonly FixtureFile[], + options: BenchmarkOptions, +): Promise { + const root = await Deno.makeTempDir({ prefix: `soundscript-expand-${name}-` }); + await writeFixture(root, files); + const projectPath = join(root, 'tsconfig.json'); + const iterations: IterationResult[] = []; + + for (let index = 0; index < options.warmups; index += 1) { + await runExpand(projectPath, join(root, `.warmup-${index}`)); + } + + for (let index = 0; index < options.iterations; index += 1) { + iterations.push(await runExpand(projectPath, join(root, `.out-${index}`))); + } + + return { iterations, name, projectPath }; +} + +async function main(): Promise { + const options = parseArgs(Deno.args); + await Deno.mkdir(options.outDir, { recursive: true }); + const results: ScenarioResult[] = []; + for (const fixture of FIXTURES) { + results.push(await runScenario(fixture.name, fixture.files(), options)); + } + + const report = { + createdAt: new Date().toISOString(), + options, + results, + }; + await Deno.writeTextFile( + join(options.outDir, 'expand-benchmark.json'), + `${JSON.stringify(report, null, 2)}\n`, + ); + const markdown = markdownReport(results); + await Deno.writeTextFile(join(options.outDir, 'expand-benchmark.md'), markdown); + console.log(markdown); + + const failed = results.flatMap((result) => + result.iterations + .filter((iteration) => iteration.exitCode !== 0) + .map((iteration) => `${result.name}: exit ${iteration.exitCode} (${iteration.diagnostics})`) + ); + if (failed.length > 0) { + console.error(failed.join('\n')); + Deno.exit(1); + } +} + +if (import.meta.main) { + await main(); +} diff --git a/src/frontend/builtin_macro_support.ts b/src/frontend/builtin_macro_support.ts index efe6d37..9021884 100644 --- a/src/frontend/builtin_macro_support.ts +++ b/src/frontend/builtin_macro_support.ts @@ -68,6 +68,7 @@ import { lowerJsxSyntaxToRuntimeCalls, mapProgramRangeToSource, persistPreparedProgramBuildInfo, + type PreparedCompilerHostReuseState, type PreparedProgram, type PreparedSourceFile, toSourceFileName, @@ -97,6 +98,7 @@ interface BuiltinProgramBase { dispose(): void; frontendDiagnostics(): readonly MergedDiagnostic[]; macroEnvironment: ProjectMacroEnvironment; + outputTextBySourceFileName: ReadonlyMap; preparedProgram: PreparedProgram; program: ts.Program; tsDiagnosticPrograms: readonly BuiltinExpandedTsDiagnosticProgram[]; @@ -1051,6 +1053,37 @@ function createBuiltinMacroDiagnostic(error: MacroError): MergedDiagnostic { }; } +function primePreparedCompilerHostReuseState( + target: PreparedCompilerHostReuseState, + sourceProgram: PreparedProgram, +): void { + const source = sourceProgram.preparedHost.reuseState; + if (target.programSourceFiles.size > 0 || target.rewrittenSourceFiles.size > 0) { + return; + } + + target.macroModuleArtifactCache = source.macroModuleArtifactCache; + target.moduleResolutionCache = source.moduleResolutionCache; + target.moduleResolutionCacheSignature = source.moduleResolutionCacheSignature; + target.previousProgramSourceFiles = new Set(source.programSourceFiles); + target.preparedSourceFiles = new Map(source.preparedSourceFiles); + target.programSourceFiles = new Set(source.programSourceFiles); + target.projectedDeclarationBuilderProgram = source.projectedDeclarationBuilderProgram; + target.projectedDeclarationOutputs = source.projectedDeclarationOutputs; + target.projectedDeclarationOptionSignature = source.projectedDeclarationOptionSignature; + target.projectedDeclarationProgram = source.projectedDeclarationProgram; + target.projectedDeclarationRootNamesSignature = source.projectedDeclarationRootNamesSignature; + target.projectedDeclarationSourceFiles = new Map(source.projectedDeclarationSourceFiles); + target.resolvedModulesByKey = new Map(source.resolvedModulesByKey); + target.rewrittenSourceFiles = new Map(source.rewrittenSourceFiles); + target.semanticDiagnosticsBuildInfoPath = source.semanticDiagnosticsBuildInfoPath; + target.semanticDiagnosticsBuilderProgram = source.semanticDiagnosticsBuilderProgram; + target.semanticDiagnosticsOptionSignature = source.semanticDiagnosticsOptionSignature; + target.semanticDiagnosticsProjectReferencesSignature = + source.semanticDiagnosticsProjectReferencesSignature; + target.semanticDiagnosticsRootNamesSignature = source.semanticDiagnosticsRootNamesSignature; +} + export function withBuiltinMacroSupport( options: CreatePreparedProgramOptions, ): CreatePreparedProgramOptions { @@ -1114,7 +1147,10 @@ export function expandPreparedProgramWithBuiltinsForDiagnostics( } interface BuiltinProgramStageContext { - createStagePreparedProgramOptions(stage: 'annotated' | 'final'): CreatePreparedProgramOptions; + createStagePreparedProgramOptions( + stage: 'annotated' | 'final', + seedProgram: PreparedProgram, + ): CreatePreparedProgramOptions; diagnosticPreparedFiles: Map; frontendDiagnostics: MergedDiagnostic[]; macroEnvironment: ProjectMacroEnvironment; @@ -1154,6 +1190,7 @@ function createBuiltinProgramBaseResult( ownedPreparedPrograms: readonly PreparedProgram[], preparedProgram: PreparedProgram, program: ts.Program, + outputTextBySourceFileName: ReadonlyMap = new Map(), tsDiagnosticPrograms: readonly BuiltinExpandedTsDiagnosticProgram[] = [{ program }], ): BuiltinProgramBase { return { @@ -1179,6 +1216,7 @@ function createBuiltinProgramBaseResult( }, frontendDiagnostics: () => frontendDiagnostics, macroEnvironment, + outputTextBySourceFileName, preparedProgram, program, tsDiagnosticPrograms, @@ -1193,6 +1231,7 @@ function createBuiltinDiagnosticProgramResult( ownedPreparedPrograms: readonly PreparedProgram[], preparedProgram: PreparedProgram, program: ts.Program, + outputTextBySourceFileName: ReadonlyMap = new Map(), tsDiagnosticPrograms: readonly BuiltinExpandedTsDiagnosticProgram[] = [{ program }], ): BuiltinDiagnosticProgram { return createBuiltinProgramBaseResult( @@ -1203,6 +1242,7 @@ function createBuiltinDiagnosticProgramResult( ownedPreparedPrograms, preparedProgram, program, + outputTextBySourceFileName, tsDiagnosticPrograms, ); } @@ -1232,24 +1272,30 @@ function createBuiltinProgramStageContext( { always: true }, ); persistPreparedProgramBuildInfo(preparedProgram); - const getBuiltinStageReuseState = (stage: 'annotated' | 'final') => { + const getBuiltinStageReuseState = ( + stage: 'annotated' | 'final', + seedProgram: PreparedProgram, + ) => { const primaryReuseState = preparedProgram.preparedHost.reuseState; const stageReuseStates = primaryReuseState.builtinStageReuseStates ??= {}; const existingStageReuseState = stageReuseStates[stage]; if (existingStageReuseState) { + primePreparedCompilerHostReuseState(existingStageReuseState, seedProgram); return existingStageReuseState; } const stageReuseState = createPreparedCompilerHostReuseState( supportedOptions.baseHost.getCurrentDirectory?.() ?? ts.sys.getCurrentDirectory(), ); + primePreparedCompilerHostReuseState(stageReuseState, seedProgram); stageReuseStates[stage] = stageReuseState; return stageReuseState; }; const createStagePreparedProgramOptions = ( stage: 'annotated' | 'final', + seedProgram: PreparedProgram, ): CreatePreparedProgramOptions => ({ ...withPersistentSemanticBuildInfoStage(supportedOptions, stage), - reusableCompilerHostState: getBuiltinStageReuseState(stage), + reusableCompilerHostState: getBuiltinStageReuseState(stage, seedProgram), }); const frontendDiagnostics: MergedDiagnostic[] = [...preparedProgram.frontendDiagnostics()]; const macroEnvironment = measureCheckerTiming( @@ -1397,7 +1443,7 @@ function createBuiltinAnnotatedStage( }, () => context.trackPreparedProgram(createPreparedProgram({ - ...context.createStagePreparedProgramOptions('annotated'), + ...context.createStagePreparedProgramOptions('annotated', context.preparedProgram), fileOverrides: annotatedOverrides, invalidateModuleResolutions: false, oldProgram: context.preparedProgram.program, @@ -1449,7 +1495,7 @@ function createBuiltinEmitStagePreparation( } const nextNumericsProgram = context.trackPreparedProgram(createPreparedProgram({ - ...context.createStagePreparedProgramOptions('final'), + ...context.createStagePreparedProgramOptions('final', numericsProgram), fileOverrides: numericOverrides, invalidateModuleResolutions: false, oldProgram: numericsProgram.program, @@ -1754,7 +1800,7 @@ function createBuiltinFinalProgram( }, () => context.trackPreparedProgram(createPreparedProgram({ - ...context.createStagePreparedProgramOptions('final'), + ...context.createStagePreparedProgramOptions('final', emitStage.numericsProgram), fileOverrides: emitStage.finalOverrides, invalidateModuleResolutions: false, oldProgram: emitStage.numericsProgram.program, @@ -1784,7 +1830,7 @@ function createSupplementalTsDiagnosticPrograms( }, () => context.trackPreparedProgram(createPreparedProgram({ - ...context.createStagePreparedProgramOptions('final'), + ...context.createStagePreparedProgramOptions('final', emitStage.numericsProgram), fileOverrides: emitStage.finalOverrides, invalidateModuleResolutions: false, oldProgram: emitStage.numericsProgram.program, @@ -1858,6 +1904,7 @@ function createBuiltinEmitProgramInternal( [...context.ownedPreparedPrograms], context.preparedProgram, analysisPreparedProgram.program, + emitStage.finalOverrides, ); } @@ -1921,6 +1968,7 @@ export function createBuiltinDiagnosticProgram( [...context.ownedPreparedPrograms], context.preparedProgram, analysisPreparedProgram.program, + emitStage.finalOverrides, supplementalTsDiagnosticPrograms, ); } @@ -1935,6 +1983,7 @@ export function createBuiltinDiagnosticProgram( [...context.ownedPreparedPrograms], context.preparedProgram, expandedProgram.program, + emitStage.finalOverrides, ); } diff --git a/src/frontend/expand_project.ts b/src/frontend/expand_project.ts index ab5681f..bad4b65 100644 --- a/src/frontend/expand_project.ts +++ b/src/frontend/expand_project.ts @@ -253,7 +253,8 @@ export async function expandProject(options: ExpandProjectOptions): Promise; export const MACRO_EXPANSION_START_MARKER_PREFIX = '__SS_MACRO_EXPANSION_START_'; export const MACRO_EXPANSION_END_MARKER_PREFIX = '__SS_MACRO_EXPANSION_END_'; +export interface MacroExpansionTimingObserver { + recordMacroExecution(kind: 'advanced' | 'rewrite', durationMs: number): void; +} + export interface MacroModule { expanders: Readonly>; moduleName: string; @@ -275,6 +279,7 @@ function expandAdvancedMacroPlaceholdersInSourceFile( rewriteRegistry: MacroExpanderRegistry = new Map(), siteKindsBySpecifier: ReadonlyMap> = new Map(), annotateExpansions = false, + timingObserver?: MacroExpansionTimingObserver, ): ts.SourceFile { if (registry.size === 0) { return sourceFile; @@ -300,7 +305,9 @@ function expandAdvancedMacroPlaceholdersInSourceFile( if (!expander) { continue; } + const start = performance.now(); const expansion = expander(site.resolved, nestedRegistries); + timingObserver?.recordMacroExecution('advanced', performance.now() - start); if (expansion) { expansions.set(site.resolved.placeholder.id, expansion); runtimeImports.push(...(expansion.runtimeImports ?? [])); @@ -1504,6 +1511,7 @@ export function expandMacroPlaceholdersWithRegistry( registry: MacroExpanderRegistry, preserveMissingExpanders = false, annotateExpansions = false, + timingObserver?: MacroExpansionTimingObserver, ): ts.SourceFile { return expandMacroPlaceholdersInSourceFile(sourceFile, collected, (resolved) => { const expander = registry.get(resolved.placeholder.invocation.nameText); @@ -1516,7 +1524,12 @@ export function expandMacroPlaceholdersWithRegistry( ); } - return expander(resolved); + const start = performance.now(); + try { + return expander(resolved); + } finally { + timingObserver?.recordMacroExecution('rewrite', performance.now() - start); + } }, annotateExpansions); } @@ -1539,6 +1552,9 @@ export function expandPreparedProgramWithRegistry( collected, advancedRegistry, registry, + new Map(), + false, + undefined, ); expandedFiles.set( sourceFile.fileName, @@ -1573,8 +1589,11 @@ export function expandPreparedProgramWithFileRegistries( preserveMissingExpanders = false, annotateExpansions = false, sourceFiles = preparedProgram.program.getSourceFiles(), + timingObserver?: MacroExpansionTimingObserver, ): ReadonlyMap { - const nonDeclarationSourceFiles = sourceFiles.filter((sourceFile) => !sourceFile.isDeclarationFile); + const nonDeclarationSourceFiles = sourceFiles.filter((sourceFile) => + !sourceFile.isDeclarationFile + ); const collected = collectResolvedMacroPlaceholders(preparedProgram, nonDeclarationSourceFiles); const expandedFiles = new Map(); @@ -1592,6 +1611,7 @@ export function expandPreparedProgramWithFileRegistries( registry, siteKindsBySpecifier, annotateExpansions, + timingObserver, ); expandedFiles.set( sourceFile.fileName, @@ -1601,6 +1621,7 @@ export function expandPreparedProgramWithFileRegistries( registry, preserveMissingExpanders, annotateExpansions, + timingObserver, ), ); } diff --git a/src/frontend/project_frontend.ts b/src/frontend/project_frontend.ts index f082a03..f596419 100644 --- a/src/frontend/project_frontend.ts +++ b/src/frontend/project_frontend.ts @@ -801,6 +801,7 @@ export interface PreparedCompilerHost { getCachedPreparedSourceFiles(): readonly PreparedSourceFile[]; getMacroPlaceholderIndex(): MacroPlaceholderIndex; host: ts.CompilerHost; + readOriginalFile(fileName: string): string | undefined; reuseState: PreparedCompilerHostReuseState; sourceFileCacheStats(): { projectedDeclarationHits: number; @@ -4268,6 +4269,7 @@ export function createPreparedCompilerHost( specifier, resolved.resolvedFileName, moduleResolutionHost, + { trustMacroAuthoringSourcePath: true }, )?.sourceEntryPath : undefined; const resolvedFileName = resolved?.resolvedFileName @@ -5023,6 +5025,10 @@ export function createPreparedCompilerHost( })); }, }, + readOriginalFile(fileName: string): string | undefined { + const sourceFileName = toSourceFileName(fileName); + return fileOverrides.get(sourceFileName) ?? baseHost.readFile(sourceFileName); + }, reuseState: reusableState, sourceFileCacheStats(): { projectedDeclarationHits: number; diff --git a/src/frontend/project_frontend_test.ts b/src/frontend/project_frontend_test.ts index 11785da..34907bf 100644 --- a/src/frontend/project_frontend_test.ts +++ b/src/frontend/project_frontend_test.ts @@ -2431,6 +2431,71 @@ Deno.test('createBuiltinExpandedProgram keeps the final rebuilt program when err } }); +Deno.test('createBuiltinExpandedProgram primes builtin stage reuse from previous stages', () => { + const fileName = '/virtual/index.sts'; + const originalTimingEnv = Deno.env.get('SOUNDSCRIPT_CHECKER_TIMING'); + const originalError = console.error; + const logs: string[] = []; + console.error = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + Deno.env.set('SOUNDSCRIPT_CHECKER_TIMING', '1'); + + const builtinExpanded = createBuiltinExpandedProgram({ + baseHost: createBaseHost( + new Map([ + [ + fileName, + [ + "type Ok = { tag: 'ok'; value: number };", + "type Err = { tag: 'err'; error: string };", + 'declare const value: Ok | Err | undefined;', + 'export const matched = Match(value, [', + ' ({ value }: Ok) => value,', + ' ({ error }: Err) => error.length,', + ' (_: undefined) => 0,', + ']);', + 'try {', + ' throw new Error("boom");', + '} catch (error) {', + ' console.log(error.message);', + '}', + '', + ].join('\n'), + ], + ]), + ), + options: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + strict: true, + }, + rootNames: [fileName], + }); + + assertEquals(ts.getPreEmitDiagnostics(builtinExpanded.program), []); + const semanticReuseLogs = logs.filter((line) => + line.includes('[soundscript:checker] project.prepare.semanticBuilderHostReuse ') + ); + assertEquals(semanticReuseLogs.length >= 3, true); + assertStringIncludes(semanticReuseLogs[1]!, 'changedProgramFiles=1'); + assertStringIncludes(semanticReuseLogs[1]!, 'rewrittenSourceFileCacheHits='); + assertStringIncludes(semanticReuseLogs[2]!, 'changedProgramFiles=1'); + assertStringIncludes(semanticReuseLogs[2]!, 'rewrittenSourceFileCacheHits='); + } finally { + if (originalTimingEnv === undefined) { + Deno.env.delete('SOUNDSCRIPT_CHECKER_TIMING'); + } else { + Deno.env.set('SOUNDSCRIPT_CHECKER_TIMING', originalTimingEnv); + } + console.error = originalError; + } +}); + Deno.test('createBuiltinExpandedProgram reuses unchanged builtin rewrite artifacts across .sts edits', () => { const changedFileName = '/virtual/changed.sts'; const stableFileName = '/virtual/stable.sts'; diff --git a/src/frontend/project_macro_support.ts b/src/frontend/project_macro_support.ts index 37f25f2..b35415d 100644 --- a/src/frontend/project_macro_support.ts +++ b/src/frontend/project_macro_support.ts @@ -1,11 +1,15 @@ import ts from 'typescript'; +import { logCheckerTiming } from '../checker/timing.ts'; import { createAnnotationLookup } from '../language/annotation_syntax.ts'; import { dirname, join } from '../platform/path.ts'; import { SOUND_DIAGNOSTIC_CODES } from '../checker/engine/diagnostic_codes.ts'; import { describeUnsupportedFeature } from '../checker/unsupported_feature_messages.ts'; import * as publicMacroApi from '../macros.ts'; -import { getSoundScriptPackageExportInfoForResolvedModule } from '../project/soundscript_packages.ts'; +import { + getSoundScriptPackageExportInfoForResolvedModule, + type SoundScriptPackageExportInfo, +} from '../project/soundscript_packages.ts'; import type { MacroDefinition } from './macro_api.ts'; import { getLoadedMacroDefinitionMetadata } from './macro_api_internal.ts'; @@ -30,7 +34,10 @@ import { usesLegacyDefineMacroAuthoring, } from './macro_factory_support.ts'; import { collectImportedNamedBindings } from './macro_site_kind_support.ts'; -import { expandPreparedProgramWithFileRegistries } from './macro_expander.ts'; +import { + expandPreparedProgramWithFileRegistries, + type MacroExpansionTimingObserver, +} from './macro_expander.ts'; import { classifyImportedBindingUsage, type ImportedBindingUsage, @@ -269,6 +276,68 @@ export interface MacroModuleCacheStats { moduleCacheMisses: number; } +interface MacroExpansionTimingStats { + advancedMacroExecutionCount: number; + authorityResolutionMs: number; + bindingCacheWriteMs: number; + bindingPlanFiles: number; + bindingPlanMs: number; + definitionLoadMs: number; + dependencySignatureMs: number; + exportLoadMs: number; + generatedStdlibFiles: number; + generatedStdlibMs: number; + graphCompileFiles: number; + graphCompileGraphs: number; + graphCompileMs: number; + importUsageMs: number; + interopImportCheckMs: number; + likelyMacroModuleMs: number; + macroExecutionMs: number; + moduleEvalRoots: number; + moduleEvalMs: number; + packageExportInfoMs: number; + policyValidationMs: number; + preferredMacroResolutionMs: number; + resolveImportMs: number; + tsModuleResolutionMs: number; + rewriteMacroExecutionCount: number; + sourceExpansionFiles: number; + sourceExpansionMs: number; +} + +function createMacroExpansionTimingStats(): MacroExpansionTimingStats { + return { + advancedMacroExecutionCount: 0, + authorityResolutionMs: 0, + bindingCacheWriteMs: 0, + bindingPlanFiles: 0, + bindingPlanMs: 0, + definitionLoadMs: 0, + dependencySignatureMs: 0, + exportLoadMs: 0, + generatedStdlibFiles: 0, + generatedStdlibMs: 0, + graphCompileFiles: 0, + graphCompileGraphs: 0, + graphCompileMs: 0, + importUsageMs: 0, + interopImportCheckMs: 0, + likelyMacroModuleMs: 0, + macroExecutionMs: 0, + moduleEvalRoots: 0, + moduleEvalMs: 0, + packageExportInfoMs: 0, + policyValidationMs: 0, + preferredMacroResolutionMs: 0, + resolveImportMs: 0, + tsModuleResolutionMs: 0, + rewriteMacroExecutionCount: 0, + sourceExpansionFiles: 0, + sourceExpansionMs: 0, + }; +} + export interface ProjectMacroEnvironment { cacheStats(): MacroModuleCacheStats; definitionsForFile(sourceFile: ts.SourceFile): ReadonlyMap; @@ -570,20 +639,35 @@ function inferMacroMutableContainerKind( function createModuleResolutionHost(preparedProgram: PreparedProgram): ts.ModuleResolutionHost { const baseHost = preparedProgram.preparedHost.host; + const fileExistsCache = new Map(); + const readFileCache = new Map(); return { directoryExists: baseHost.directoryExists?.bind(baseHost), fileExists(fileName: string): boolean { const sourceFileName = toSourceFileName(fileName); - return preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName) !== undefined || - baseHost.fileExists(sourceFileName); + const cached = fileExistsCache.get(sourceFileName); + if (cached !== undefined) { + return cached; + } + const exists = baseHost.fileExists(sourceFileName) || + preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName) !== undefined; + fileExistsCache.set(sourceFileName, exists); + return exists; }, getCurrentDirectory: baseHost.getCurrentDirectory?.bind(baseHost) ?? (() => ts.sys.getCurrentDirectory()), getDirectories: baseHost.getDirectories?.bind(baseHost), readFile(fileName: string): string | undefined { const sourceFileName = toSourceFileName(fileName); - return preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName)?.originalText ?? - baseHost.readFile(sourceFileName); + if (readFileCache.has(sourceFileName)) { + return readFileCache.get(sourceFileName); + } + const text = isSoundscriptSourceFile(sourceFileName) + ? preparedProgram.preparedHost.readOriginalFile(sourceFileName) ?? + preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName)?.originalText + : baseHost.readFile(sourceFileName); + readFileCache.set(sourceFileName, text); + return text; }, realpath: baseHost.realpath?.bind(baseHost), useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, @@ -956,6 +1040,7 @@ export function createProjectMacroEnvironment( const resolutionHost = createModuleResolutionHost(preparedProgram); const moduleResolutionCache = preparedProgram.preparedHost.reuseState.moduleResolutionCache ?? createPreparedCompilerHostReuseState().moduleResolutionCache; + const packageExportInfoCache = new Map(); const resolvedImportCache = new Map(); const evaluatedModuleCache = new Map(); const compiledArtifactCache = new Map(); @@ -964,7 +1049,16 @@ export function createProjectMacroEnvironment( const macroModuleCandidateCache = new Map(); const macroReexportBridgeCache = new Map(); const macroModuleScanCache = new Map>(); + const macroModuleSourceFileCache = new Map(); const macroModuleSourceTextCache = new Map(); + const macroGraphInteropImportRangeCache = new Map< + string, + { readonly start: number; readonly end: number } | null + >(); + const macroModuleCompilationDependencySourceTextsCache = new Map< + string, + ReadonlyMap + >(); const validatedMacroModuleFiles = new Set(); const definitionsByResolvedFile = new Map>(); const exportsByResolvedFile = new Map(); @@ -1002,6 +1096,30 @@ export function createProjectMacroEnvironment( const stableReuseState = getStableProjectMacroEnvironmentReuseState( preparedProgram.preparedHost.reuseState, ); + let activeMacroTimingStats: MacroExpansionTimingStats | null = null; + let macroModuleEvaluationDepth = 0; + + function recordActiveMacroTiming( + key: keyof MacroExpansionTimingStats, + value: number, + ): void { + if (!activeMacroTimingStats) { + return; + } + activeMacroTimingStats[key] += value; + } + + function measureActiveMacroTiming( + key: keyof MacroExpansionTimingStats, + fn: () => T, + ): T { + const start = performance.now(); + try { + return fn(); + } finally { + recordActiveMacroTiming(key, performance.now() - start); + } + } function macroNamesForFile(sourceFile: ts.SourceFile): ReadonlySet { const cached = macroNamesByFile.get(sourceFile.fileName); @@ -1103,7 +1221,7 @@ export function createProjectMacroEnvironment( continue; } for ( - const [dependencyFileName, sourceText] of collectDependencySourceTextsForCompilation( + const [dependencyFileName, sourceText] of dependencySourceTextsForCompilation( authorityBinding.resolvedFileName, ) ) { @@ -1260,8 +1378,13 @@ export function createProjectMacroEnvironment( return cached; } + const dependencySignatureStart = performance.now(); const signature = serializeDependencySourceTexts( - collectDependencySourceTextsForCompilation(fileName), + dependencySourceTextsForCompilation(fileName), + ); + recordActiveMacroTiming( + 'dependencySignatureMs', + performance.now() - dependencySignatureStart, ); macroModuleExpansionDependencySignatureCache.set(fileName, signature); return signature; @@ -1347,6 +1470,265 @@ export function createProjectMacroEnvironment( return siteKindsBySpecifier; } + function sourceTextHasGeneratedAnnotationSyntax(sourceText: string): boolean { + return /(^|\n)\s*(?:\/\/|\/\*)\s*#\[/u.test(sourceText); + } + + function escapeRegExp(source: string): string { + return source.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + } + + function sourceTextHasGeneratedAnnotationForName(sourceText: string, name: string): boolean { + return new RegExp( + `(^|\\n)\\s*(?://|/\\*)\\s*#\\[\\s*${escapeRegExp(name)}(?:[.\\](]|\\s)`, + 'u', + ).test(sourceText); + } + + function collectGeneratedStdlibMacroSiteNames(sourceFile: ts.SourceFile): { + readonly annotationNames: Set; + readonly callNames: Set; + readonly tagNames: Set; + } { + const annotationNames = new Set(); + const callNames = new Set(); + const tagNames = new Set(); + + for (const [macroName, siteKind] of alwaysAvailableMacroSiteKinds.entries()) { + if (siteKind === 'annotation') { + annotationNames.add(macroName); + } else if (siteKind === 'call') { + callNames.add(macroName); + } else { + tagNames.add(macroName); + } + } + + const generatedSiteKindsBySpecifier = builtinMacroSiteKindsForGeneratedExpansion(); + for (const statement of sourceFile.statements) { + if ( + !ts.isImportDeclaration(statement) || + !statement.importClause || + !ts.isStringLiteral(statement.moduleSpecifier) + ) { + continue; + } + + const explicitKinds = generatedSiteKindsBySpecifier.get(statement.moduleSpecifier.text); + if (!explicitKinds) { + continue; + } + + if (statement.importClause.name) { + const localName = statement.importClause.name.text; + const explicitKind = explicitKinds.get('default'); + if (explicitKind === 'annotation') { + annotationNames.add(localName); + } else if (explicitKind === 'call') { + callNames.add(localName); + } else if (explicitKind === 'tag') { + tagNames.add(localName); + } + } + + const namedBindings = statement.importClause.namedBindings; + if (!namedBindings || !ts.isNamedImports(namedBindings)) { + continue; + } + + for (const element of namedBindings.elements) { + const localName = element.name.text; + const exportName = element.propertyName?.text ?? localName; + const explicitKind = explicitKinds.get(exportName); + if (explicitKind === 'annotation') { + annotationNames.add(localName); + } else if (explicitKind === 'call') { + callNames.add(localName); + } else if (explicitKind === 'tag') { + tagNames.add(localName); + } + } + } + + return { annotationNames, callNames, tagNames }; + } + + function sourceFileMayContainGeneratedStdlibMacro( + sourceFile: ts.SourceFile, + sourceText: string, + ): boolean { + const { annotationNames, callNames, tagNames } = collectGeneratedStdlibMacroSiteNames( + sourceFile, + ); + + if ( + annotationNames.size > 0 && + sourceTextHasGeneratedAnnotationSyntax(sourceText) + ) { + return true; + } + + let found = false; + const visit = (node: ts.Node): void => { + if (found) { + return; + } + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + callNames.has(node.expression.text) + ) { + found = true; + return; + } + if ( + ts.isTaggedTemplateExpression(node) && + ts.isIdentifier(node.tag) && + tagNames.has(node.tag.text) + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + + ts.forEachChild(sourceFile, visit); + return found; + } + + function sourceFileMayContainGeneratedUserMacroImport( + sourceFile: ts.SourceFile, + sourceText: string, + ): boolean { + const importedBindings: Array<{ + readonly exportName: string; + readonly localName: string; + readonly specifier: string; + }> = []; + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + + const specifier = statement.moduleSpecifier.text; + if ( + specifier === MACRO_API_MODULE_SPECIFIER || builtinDefinitionsBySpecifier.has(specifier) + ) { + continue; + } + + if (statement.importClause?.isTypeOnly === true) { + continue; + } + + if (statement.importClause?.name) { + importedBindings.push({ + exportName: 'default', + localName: statement.importClause.name.text, + specifier, + }); + } + + const namedBindings = statement.importClause?.namedBindings; + if (!namedBindings || !ts.isNamedImports(namedBindings)) { + continue; + } + + for (const element of namedBindings.elements) { + if (!element.isTypeOnly) { + importedBindings.push({ + exportName: element.propertyName?.text ?? element.name.text, + localName: element.name.text, + specifier, + }); + } + } + } + + if (importedBindings.length === 0) { + return false; + } + + const importedLocalNames = new Set(importedBindings.map((binding) => binding.localName)); + const usedCandidateNames = new Set(); + for (const localName of importedLocalNames) { + if (sourceTextHasGeneratedAnnotationForName(sourceText, localName)) { + usedCandidateNames.add(localName); + } + } + + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + importedLocalNames.has(node.expression.text) + ) { + usedCandidateNames.add(node.expression.text); + return; + } + if ( + ts.isTaggedTemplateExpression(node) && + ts.isIdentifier(node.tag) && + importedLocalNames.has(node.tag.text) + ) { + usedCandidateNames.add(node.tag.text); + return; + } + ts.forEachChild(node, visit); + }; + + ts.forEachChild(sourceFile, visit); + if (usedCandidateNames.size === 0) { + return false; + } + + for (const binding of importedBindings) { + if (!usedCandidateNames.has(binding.localName)) { + continue; + } + + const resolved = resolvePreferredSoundscriptMacroModule( + binding.specifier, + sourceFile.fileName, + ) ?? + ts.resolveModuleName( + binding.specifier, + sourceFile.fileName, + preparedProgram.options, + resolutionHost, + moduleResolutionCache, + ).resolvedModule; + const resolvedRuntimeFileName = resolved?.resolvedFileName; + if (!resolvedRuntimeFileName) { + continue; + } + + const packageMacroSourceEntry = getSoundScriptPackageExportInfoForResolvedModule( + binding.specifier, + resolvedRuntimeFileName, + resolutionHost, + { trustMacroAuthoringSourcePath: true }, + )?.sourceEntryPath; + const resolvedFileName = packageMacroSourceEntry + ? toSourceFileName(packageMacroSourceEntry) + : toSourceFileName(resolvedRuntimeFileName); + if (!isSoundscriptSourceFile(resolvedFileName)) { + continue; + } + + const scanned = isLoadableMacroModuleFile(resolvedFileName) || + isPureMacroReexportBridgeModule(resolvedFileName) || + isLikelyMacroModule(resolvedFileName) + ? scannedFactoriesForMacroModule(resolvedFileName) + : new Map(); + if (scanned.has(binding.exportName)) { + return true; + } + } + + return false; + } + function createGeneratedMacroError( sourceText: string, invocation: ParsedMacroInvocation, @@ -1376,11 +1758,9 @@ export function createProjectMacroEnvironment( if (override !== undefined) { return override; } - const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName); - if (preparedSource) { - return preparedSource.originalText; - } - return baseHost.readFile(sourceFileName) ?? baseHost.readFile(fileName); + return preparedProgram.preparedHost.readOriginalFile(sourceFileName) ?? + baseHost.readFile(sourceFileName) ?? + baseHost.readFile(fileName); }; return { @@ -1388,6 +1768,7 @@ export function createProjectMacroEnvironment( fileExists(fileName: string): boolean { const sourceFileName = preparedProgram.toSourceFileName(fileName); return fileOverrides.has(sourceFileName) || + preparedProgram.preparedHost.readOriginalFile(sourceFileName) !== undefined || preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName) !== undefined || baseHost.fileExists(sourceFileName) || baseHost.fileExists(fileName); @@ -1683,17 +2064,32 @@ export function createProjectMacroEnvironment( sourceFileName, printer.printFile(currentSourceFile), ); + const generatedSourceFile = ts.createSourceFile( + sourceFileName, + sourceText, + preparedProgram.options.target ?? ts.ScriptTarget.Latest, + true, + scriptKindForHostFile(sourceFileName), + ); + if ( + !sourceFileMayContainGeneratedStdlibMacro(generatedSourceFile, sourceText) && + !sourceFileMayContainGeneratedUserMacroImport(generatedSourceFile, sourceText) + ) { + return currentSourceFile; + } const generatedPreparedProgram = createGeneratedExpansionPreparedProgram( sourceFileName, sourceText, ); try { const programFileName = generatedPreparedProgram.toProgramFileName(sourceFileName); - const generatedSourceFile = generatedPreparedProgram.program.getSourceFile(programFileName); + const generatedProgramSourceFile = generatedPreparedProgram.program.getSourceFile( + programFileName, + ); const generatedPreparedSource = generatedPreparedProgram.preparedHost .getPreparedSourceFile(sourceFileName); const invocations = [...(generatedPreparedSource?.rewriteResult.macrosById.values() ?? [])]; - if (invocations.length === 0 || !generatedSourceFile) { + if (invocations.length === 0 || !generatedProgramSourceFile) { return currentSourceFile; } if (remainingGeneratedRounds <= 0) { @@ -1715,7 +2111,7 @@ export function createProjectMacroEnvironment( const expandedGeneratedFiles = expandPreparedProgramWithFileRegistries( generatedPreparedProgram, new Map([[ - generatedSourceFile.fileName, + generatedProgramSourceFile.fileName, { advancedRegistry: bindings.advancedRegistry, registry: bindings.registry, @@ -1724,10 +2120,11 @@ export function createProjectMacroEnvironment( ]]), preserveMissingExpanders, annotateExpansions, - [generatedSourceFile], + [generatedProgramSourceFile], ); currentSourceFile = stripCompileTimeOnlyImportedBindings( - expandedGeneratedFiles.get(generatedSourceFile.fileName) ?? generatedSourceFile, + expandedGeneratedFiles.get(generatedProgramSourceFile.fileName) ?? + generatedProgramSourceFile, bindings.importedBindingUsage, preserveRemovedImportStatements, ); @@ -1743,101 +2140,114 @@ export function createProjectMacroEnvironment( specifier: string, options: { readonly fromMacroGraph?: boolean } = {}, ): string | null { - const normalizedContainingFileName = toSourceFileName(containingFileName); - if (options.fromMacroGraph && isLoadableMacroModuleFile(normalizedContainingFileName)) { - validateMacroModuleSourcePolicy(normalizedContainingFileName); - } - if (specifier === MACRO_API_MODULE_SPECIFIER || builtinDefinitionsBySpecifier.has(specifier)) { - return specifier; - } - - const cacheKey = `${normalizedContainingFileName}\u0000${specifier}`; - const cached = resolvedImportCache.get(cacheKey); - if (cached !== undefined) { - return cached; - } + const resolveImportStart = performance.now(); + try { + const normalizedContainingFileName = toSourceFileName(containingFileName); + if (options.fromMacroGraph && isLoadableMacroModuleFile(normalizedContainingFileName)) { + validateMacroModuleSourcePolicyMeasured(normalizedContainingFileName); + } + if ( + specifier === MACRO_API_MODULE_SPECIFIER || builtinDefinitionsBySpecifier.has(specifier) + ) { + return specifier; + } - const resolved = - resolvePreferredSoundscriptMacroModule(specifier, normalizedContainingFileName) ?? - ts.resolveModuleName( - specifier, - normalizedContainingFileName, - preparedProgram.options, - resolutionHost, - moduleResolutionCache, - ).resolvedModule; - const resolvedRuntimeFileName = resolved?.resolvedFileName; - if (!resolvedRuntimeFileName) { - resolvedImportCache.set(cacheKey, null); - return null; - } + const cacheKey = `${normalizedContainingFileName}\u0000${specifier}`; + const cached = resolvedImportCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } - if (options.fromMacroGraph) { - const interopImportRange = findMacroGraphInteropImportRange( - normalizedContainingFileName, - specifier, + const preferredResolved = measureActiveMacroTiming( + 'preferredMacroResolutionMs', + () => resolvePreferredSoundscriptMacroModule(specifier, normalizedContainingFileName), ); - if (interopImportRange) { + const resolved = preferredResolved ?? + measureActiveMacroTiming( + 'tsModuleResolutionMs', + () => + ts.resolveModuleName( + specifier, + normalizedContainingFileName, + preparedProgram.options, + resolutionHost, + moduleResolutionCache, + ).resolvedModule, + ); + const resolvedRuntimeFileName = resolved?.resolvedFileName; + if (!resolvedRuntimeFileName) { + resolvedImportCache.set(cacheKey, null); + return null; + } + + if (options.fromMacroGraph) { + const interopImportRange = measureActiveMacroTiming( + 'interopImportCheckMs', + () => findMacroGraphInteropImportRange(normalizedContainingFileName, specifier), + ); + if (interopImportRange) { + throw createMacroModuleError( + normalizedContainingFileName, + sourceTextForMacroModule(normalizedContainingFileName), + `Macro module "${normalizedContainingFileName}" cannot use #[interop] anywhere in its dependency graph. Macro graphs must stay entirely inside soundscript source.`, + MACRO_GRAPH_ERROR_CODES.forbiddenInterop, + interopImportRange.start, + interopImportRange.end, + ); + } + } + + if (isProjectedSoundscriptDeclarationFile(resolvedRuntimeFileName)) { throw createMacroModuleError( normalizedContainingFileName, sourceTextForMacroModule(normalizedContainingFileName), - `Macro module "${normalizedContainingFileName}" cannot use #[interop] anywhere in its dependency graph. Macro graphs must stay entirely inside soundscript source.`, + `Macro module "${normalizedContainingFileName}" cannot import "${specifier}" because macro graphs cannot cross projected declaration boundaries or #[interop] edges.`, MACRO_GRAPH_ERROR_CODES.forbiddenInterop, - interopImportRange.start, - interopImportRange.end, ); } - } - if (isProjectedSoundscriptDeclarationFile(resolvedRuntimeFileName)) { - throw createMacroModuleError( - normalizedContainingFileName, - sourceTextForMacroModule(normalizedContainingFileName), - `Macro module "${normalizedContainingFileName}" cannot import "${specifier}" because macro graphs cannot cross projected declaration boundaries or #[interop] edges.`, - MACRO_GRAPH_ERROR_CODES.forbiddenInterop, - ); - } + const packageMacroSourceEntry = packageExportInfoForResolvedModule( + specifier, + resolvedRuntimeFileName, + )?.sourceEntryPath; + const resolvedFileName = packageMacroSourceEntry + ? toSourceFileName(packageMacroSourceEntry) + : toSourceFileName(resolvedRuntimeFileName); + if (!isLoadableMacroModuleFile(resolvedFileName)) { + if (isPureMacroReexportBridgeModule(resolvedFileName)) { + validateMacroModuleSourcePolicyMeasured(resolvedFileName); + resolvedImportCache.set(cacheKey, resolvedFileName); + return resolvedFileName; + } - const packageMacroSourceEntry = getSoundScriptPackageExportInfoForResolvedModule( - specifier, - resolvedRuntimeFileName, - resolutionHost, - )?.sourceEntryPath; - const resolvedFileName = packageMacroSourceEntry - ? toSourceFileName(packageMacroSourceEntry) - : toSourceFileName(resolvedRuntimeFileName); - if (!isLoadableMacroModuleFile(resolvedFileName)) { - if (isPureMacroReexportBridgeModule(resolvedFileName)) { - validateMacroModuleSourcePolicy(resolvedFileName); - resolvedImportCache.set(cacheKey, resolvedFileName); - return resolvedFileName; - } - - const resolvedSourceText = - preparedProgram.preparedHost.getPreparedSourceFile(resolvedFileName)?.originalText ?? - resolutionHost.readFile(resolvedFileName) ?? - ''; - const looksLikeMacroModule = sourceTextLooksLikeMacroModule(resolvedSourceText) || - usesLegacyDefineMacroAuthoring(resolvedSourceText); - if (looksLikeMacroModule) { + const resolvedSourceText = + preparedProgram.preparedHost.getPreparedSourceFile(resolvedFileName)?.originalText ?? + resolutionHost.readFile(resolvedFileName) ?? + ''; + const looksLikeMacroModule = sourceTextLooksLikeMacroModule(resolvedSourceText) || + usesLegacyDefineMacroAuthoring(resolvedSourceText); + if (looksLikeMacroModule) { + throw createMacroModuleError( + normalizedContainingFileName, + sourceTextForMacroModule(normalizedContainingFileName), + `Macro import "${specifier}" resolved to "${resolvedFileName}", but user-authored macro modules must come from a soundscript .macro.sts module.`, + MACRO_GRAPH_ERROR_CODES.unsupportedSourceKind, + ); + } throw createMacroModuleError( normalizedContainingFileName, sourceTextForMacroModule(normalizedContainingFileName), - `Macro import "${specifier}" resolved to "${resolvedFileName}", but user-authored macro modules must come from a soundscript .macro.sts module.`, - MACRO_GRAPH_ERROR_CODES.unsupportedSourceKind, + `Macro module "${normalizedContainingFileName}" cannot import non-macro source "${specifier}". Macro graphs may only depend on .macro.sts modules.`, + MACRO_GRAPH_ERROR_CODES.nonSoundscriptDependency, ); } - throw createMacroModuleError( - normalizedContainingFileName, - sourceTextForMacroModule(normalizedContainingFileName), - `Macro module "${normalizedContainingFileName}" cannot import non-macro source "${specifier}". Macro graphs may only depend on .macro.sts modules.`, - MACRO_GRAPH_ERROR_CODES.nonSoundscriptDependency, - ); - } - validateMacroModuleSourcePolicy(resolvedFileName); - resolvedImportCache.set(cacheKey, resolvedFileName); - return resolvedFileName; + validateMacroModuleSourcePolicyMeasured(resolvedFileName); + resolvedImportCache.set(cacheKey, resolvedFileName); + return resolvedFileName; + } finally { + recordActiveMacroTiming('resolveImportMs', performance.now() - resolveImportStart); + } } function resolvePreferredSoundscriptMacroModule( @@ -1877,17 +2287,25 @@ export function createProjectMacroEnvironment( } function isLikelyMacroModule(fileName: string): boolean { - const cached = macroModuleCandidateCache.get(fileName); - if (cached !== undefined) { - return cached; - } + const likelyMacroModuleStart = performance.now(); + try { + const cached = macroModuleCandidateCache.get(fileName); + if (cached !== undefined) { + return cached; + } - const sourceText = sourceTextForMacroModule(fileName); - const result = sourceTextLooksLikeMacroModule(sourceText) || - usesLegacyDefineMacroAuthoring(sourceText) || - isPureMacroReexportBridgeModule(fileName); - macroModuleCandidateCache.set(fileName, result); - return result; + const sourceText = sourceTextForMacroModule(fileName); + const result = sourceTextLooksLikeMacroModule(sourceText) || + usesLegacyDefineMacroAuthoring(sourceText) || + isPureMacroReexportBridgeModule(fileName); + macroModuleCandidateCache.set(fileName, result); + return result; + } finally { + recordActiveMacroTiming( + 'likelyMacroModuleMs', + performance.now() - likelyMacroModuleStart, + ); + } } function sourceTextForMacroModule(fileName: string): string { @@ -1896,8 +2314,8 @@ export function createProjectMacroEnvironment( return cached; } - const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(fileName); - const sourceText = preparedSource?.originalText; + const sourceText = preparedProgram.preparedHost.readOriginalFile(fileName) ?? + preparedProgram.preparedHost.getPreparedSourceFile(fileName)?.originalText; if (sourceText === undefined) { throw new Error(`Could not read macro module "${fileName}".`); } @@ -1906,6 +2324,49 @@ export function createProjectMacroEnvironment( return sourceText; } + function sourceFileForMacroModule(fileName: string): ts.SourceFile { + const cached = macroModuleSourceFileCache.get(fileName); + if (cached) { + return cached; + } + const sourceFile = ts.createSourceFile( + fileName, + sourceTextForMacroModule(fileName), + ts.ScriptTarget.Latest, + true, + scriptKindForHostFile(fileName), + ); + macroModuleSourceFileCache.set(fileName, sourceFile); + return sourceFile; + } + + function validateMacroModuleSourcePolicyMeasured(fileName: string): void { + measureActiveMacroTiming('policyValidationMs', () => validateMacroModuleSourcePolicy(fileName)); + } + + function packageExportInfoForResolvedModule( + specifier: string, + resolvedRuntimeFileName: string, + ): SoundScriptPackageExportInfo | undefined { + const cacheKey = `${specifier}\u0000${resolvedRuntimeFileName}`; + const cached = packageExportInfoCache.get(cacheKey); + if (cached !== undefined) { + return cached ?? undefined; + } + const result = measureActiveMacroTiming( + 'packageExportInfoMs', + () => + getSoundScriptPackageExportInfoForResolvedModule( + specifier, + resolvedRuntimeFileName, + resolutionHost, + { trustMacroAuthoringSourcePath: true }, + ), + ); + packageExportInfoCache.set(cacheKey, result ?? null); + return result; + } + function isPureMacroReexportBridgeModule(fileName: string): boolean { const cached = macroReexportBridgeCache.get(fileName); if (cached !== undefined) { @@ -1917,14 +2378,7 @@ export function createProjectMacroEnvironment( return false; } - const sourceText = sourceTextForMacroModule(fileName); - const sourceFile = ts.createSourceFile( - fileName, - sourceText, - ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(fileName), - ); + const sourceFile = sourceFileForMacroModule(fileName); let sawReexport = false; for (const statement of sourceFile.statements) { @@ -1957,13 +2411,7 @@ export function createProjectMacroEnvironment( } const sourceText = sourceTextForMacroModule(fileName); - const sourceFile = ts.createSourceFile( - fileName, - sourceText, - ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(fileName), - ); + const sourceFile = sourceFileForMacroModule(fileName); const interopIndex = sourceText.indexOf('#[interop]'); if (interopIndex >= 0) { throw createMacroModuleError( @@ -2120,14 +2568,17 @@ export function createProjectMacroEnvironment( fileName: string, specifier: string, ): { readonly start: number; readonly end: number } | null { - const sourceText = sourceTextForMacroModule(fileName); - const sourceFile = ts.createSourceFile( - fileName, - sourceText, - ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(fileName), - ); + const cacheKey = `${fileName}\u0000${specifier}`; + const cached = macroGraphInteropImportRangeCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + if (!sourceTextForMacroModule(fileName).includes('#[interop]')) { + macroGraphInteropImportRangeCache.set(cacheKey, null); + return null; + } + + const sourceFile = sourceFileForMacroModule(fileName); const annotationLookup = createAnnotationLookup(sourceFile); for (const statement of sourceFile.statements) { if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) { @@ -2143,8 +2594,11 @@ export function createProjectMacroEnvironment( if (!interopAnnotation || !block) { continue; } - return { start: block.range.start, end: block.range.end }; + const result = { start: block.range.start, end: block.range.end }; + macroGraphInteropImportRangeCache.set(cacheKey, result); + return result; } + macroGraphInteropImportRangeCache.set(cacheKey, null); return null; } @@ -2166,14 +2620,7 @@ export function createProjectMacroEnvironment( } function collectImportedSpecifiersForMacroModule(fileName: string): string[] { - const sourceText = sourceTextForMacroModule(fileName); - const sourceFile = ts.createSourceFile( - fileName, - sourceText, - ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(fileName), - ); + const sourceFile = sourceFileForMacroModule(fileName); const specifiers: string[] = []; for (const statement of sourceFile.statements) { if ( @@ -2204,7 +2651,7 @@ export function createProjectMacroEnvironment( } visited.add(fileName); - validateMacroModuleSourcePolicy(fileName); + validateMacroModuleSourcePolicyMeasured(fileName); const dependencySourceTexts = new Map([[ fileName, sourceTextForMacroModule(fileName), @@ -2230,6 +2677,18 @@ export function createProjectMacroEnvironment( return dependencySourceTexts; } + function dependencySourceTextsForCompilation( + fileName: string, + ): ReadonlyMap { + const cached = macroModuleCompilationDependencySourceTextsCache.get(fileName); + if (cached) { + return cached; + } + const dependencySourceTexts = collectDependencySourceTextsForCompilation(fileName); + macroModuleCompilationDependencySourceTextsCache.set(fileName, dependencySourceTexts); + return dependencySourceTexts; + } + function collectDependencySourceTextsForModule( fileName: string, visited = new Set(), @@ -2368,7 +2827,10 @@ export function createProjectMacroEnvironment( fileExists(candidateFileName: string): boolean { if (isSoundscriptSourceFile(toSourceFileName(candidateFileName))) { const sourceFileName = toSourceFileName(candidateFileName); - if (preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName)) { + if ( + preparedProgram.preparedHost.readOriginalFile(sourceFileName) !== undefined || + preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName) + ) { return true; } } @@ -2377,10 +2839,11 @@ export function createProjectMacroEnvironment( readFile(candidateFileName: string): string | undefined { if (isSoundscriptSourceFile(toSourceFileName(candidateFileName))) { const sourceFileName = toSourceFileName(candidateFileName); - const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName); - if (preparedSource) { - return preparedSource.originalText; + const originalSource = preparedProgram.preparedHost.readOriginalFile(sourceFileName); + if (originalSource !== undefined) { + return originalSource; } + return preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName)?.originalText; } return baseHost.readFile(candidateFileName); }, @@ -2405,59 +2868,105 @@ export function createProjectMacroEnvironment( stableCompiledArtifactCache.delete(fileName); macroCacheStats.moduleCacheMisses += 1; + const graphCompileStart = performance.now(); const dependencySourceTexts = collectDependencySourceTextsForCompilation(fileName); - const macroTargetProgram = createPreparedProgram({ - alwaysAvailableMacroSiteKinds, - baseHost: createMacroTargetBaseHost(), - configuredSoundscriptFileNames: preparedProgram.configuredSoundscriptFileNames, - expansionEnabled: false, - options: { - ...preparedProgram.options, - module: ts.ModuleKind.CommonJS, - moduleResolution: ts.ModuleResolutionKind.Node10, - noEmit: false, - target: ts.ScriptTarget.ES2022, - }, - preserveMacroAuthoring: true, - reusableCompilerHostState: macroTargetReuseState, - rootNames: [fileName], - runtime: preparedProgram.runtime, - }); - const frontendDiagnostics = macroTargetProgram.frontendDiagnostics().filter((diagnostic) => - diagnostic.category === 'error' - ); - if (frontendDiagnostics.length > 0) { - const expansionDisabledDiagnostic = frontendDiagnostics.find((diagnostic) => - diagnostic.code === 'SOUNDSCRIPT_EXPANSION_DISABLED' + const graphRootNames = [...dependencySourceTexts.keys()]; + try { + const macroTargetProgram = createPreparedProgram({ + alwaysAvailableMacroSiteKinds, + baseHost: createMacroTargetBaseHost(), + configuredSoundscriptFileNames: preparedProgram.configuredSoundscriptFileNames, + expansionEnabled: false, + options: { + ...preparedProgram.options, + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.Node10, + noEmit: false, + target: ts.ScriptTarget.ES2022, + }, + preserveMacroAuthoring: true, + reusableCompilerHostState: macroTargetReuseState, + rootNames: graphRootNames, + runtime: preparedProgram.runtime, + }); + const frontendDiagnostics = macroTargetProgram.frontendDiagnostics().filter((diagnostic) => + diagnostic.category === 'error' ); - if (expansionDisabledDiagnostic) { - const diagnosticFilePath = expansionDisabledDiagnostic.filePath ?? fileName; + if (frontendDiagnostics.length > 0) { + const expansionDisabledDiagnostic = frontendDiagnostics.find((diagnostic) => + diagnostic.code === 'SOUNDSCRIPT_EXPANSION_DISABLED' + ); + if (expansionDisabledDiagnostic) { + const diagnosticFilePath = expansionDisabledDiagnostic.filePath ?? fileName; + throw createMacroModuleError( + diagnosticFilePath, + sourceTextForMacroModule(diagnosticFilePath), + `Macro module "${diagnosticFilePath}" cannot contain macro invocations. Macro authoring modules compile as soundscript, but macro syntax is disabled inside the macro target.`, + MACRO_GRAPH_ERROR_CODES.forbiddenInvocation, + 0, + 0, + ); + } + + const diagnostic = frontendDiagnostics[0]!; + const diagnosticFilePath = diagnostic.filePath ?? fileName; throw createMacroModuleError( diagnosticFilePath, sourceTextForMacroModule(diagnosticFilePath), - `Macro module "${diagnosticFilePath}" cannot contain macro invocations. Macro authoring modules compile as soundscript, but macro syntax is disabled inside the macro target.`, - MACRO_GRAPH_ERROR_CODES.forbiddenInvocation, + diagnostic.message, + 'SOUNDSCRIPT_MACRO_EXPANSION', 0, 0, ); } - const diagnostic = frontendDiagnostics[0]!; - const diagnosticFilePath = diagnostic.filePath ?? fileName; - throw createMacroModuleError( - diagnosticFilePath, - sourceTextForMacroModule(diagnosticFilePath), - diagnostic.message, - 'SOUNDSCRIPT_MACRO_EXPANSION', - 0, - 0, - ); + for (const graphFileName of graphRootNames) { + const sourceFile = macroTargetProgram.program.getSourceFile( + macroTargetProgram.toProgramFileName(graphFileName), + ); + if (!sourceFile) { + throw createMacroModuleError( + graphFileName, + sourceTextForMacroModule(graphFileName), + `Failed to compile macro module "${graphFileName}".`, + 'SOUNDSCRIPT_MACRO_EXPANSION', + ); + } + + const tsDiagnostics = [ + ...macroTargetProgram.program.getSyntacticDiagnostics(sourceFile), + ...macroTargetProgram.program.getSemanticDiagnostics(sourceFile), + ].filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error); + if (tsDiagnostics.length > 0) { + throw createMacroModuleErrorFromDiagnostic( + tsDiagnostics[0]!, + graphFileName, + `Failed to compile macro module "${graphFileName}".`, + ); + } + + const javaScriptText = emitCommonJsMacroArtifactWithFallback( + macroTargetProgram, + sourceFile, + graphFileName, + macroTargetProgram.options, + ); + + const artifact: CachedMacroModuleArtifactEntry = { + dependencySourceTexts, + javaScriptText, + }; + compiledArtifactCache.set(graphFileName, artifact); + stableCompiledArtifactCache.set(graphFileName, artifact); + } + } finally { + recordActiveMacroTiming('graphCompileMs', performance.now() - graphCompileStart); + recordActiveMacroTiming('graphCompileGraphs', 1); + recordActiveMacroTiming('graphCompileFiles', graphRootNames.length); } - const sourceFile = macroTargetProgram.program.getSourceFile( - macroTargetProgram.toProgramFileName(fileName), - ); - if (!sourceFile) { + const compiled = compiledArtifactCache.get(fileName); + if (!compiled) { throw createMacroModuleError( fileName, sourceTextForMacroModule(fileName), @@ -2465,33 +2974,7 @@ export function createProjectMacroEnvironment( 'SOUNDSCRIPT_MACRO_EXPANSION', ); } - - const tsDiagnostics = [ - ...macroTargetProgram.program.getSyntacticDiagnostics(sourceFile), - ...macroTargetProgram.program.getSemanticDiagnostics(sourceFile), - ].filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error); - if (tsDiagnostics.length > 0) { - throw createMacroModuleErrorFromDiagnostic( - tsDiagnostics[0]!, - fileName, - `Failed to compile macro module "${fileName}".`, - ); - } - - const javaScriptText = emitCommonJsMacroArtifactWithFallback( - macroTargetProgram, - sourceFile, - fileName, - macroTargetProgram.options, - ); - - const artifact: CachedMacroModuleArtifactEntry = { - dependencySourceTexts, - javaScriptText, - }; - compiledArtifactCache.set(fileName, artifact); - stableCompiledArtifactCache.set(fileName, artifact); - return artifact; + return compiled; } function loadResolvedModuleValue(fileName: string): Record { @@ -2501,7 +2984,7 @@ export function createProjectMacroEnvironment( } const sourceText = sourceTextForMacroModule(fileName); - validateMacroModuleSourcePolicy(fileName); + validateMacroModuleSourcePolicyMeasured(fileName); const compiledArtifact = compileResolvedMacroModuleArtifact(fileName); const moduleRecord: MutableEvaluatedModule = { @@ -2541,17 +3024,28 @@ export function createProjectMacroEnvironment( moduleRecord.directDependencies.add(resolved); return loadResolvedModuleValue(resolved); }; - moduleRecord.exports = macroModuleEvaluator.evaluateCommonJsModule( - compiledArtifact.javaScriptText, - { - crypto: portableGlobalThis.crypto, - exports: moduleRecord.exports, - fileName, - globalThis: portableGlobalThis, - math: portableGlobalThis.Math, - require, - }, - ); + const shouldRecordModuleEval = macroModuleEvaluationDepth === 0; + const moduleEvalStart = performance.now(); + macroModuleEvaluationDepth += 1; + try { + moduleRecord.exports = macroModuleEvaluator.evaluateCommonJsModule( + compiledArtifact.javaScriptText, + { + crypto: portableGlobalThis.crypto, + exports: moduleRecord.exports, + fileName, + globalThis: portableGlobalThis, + math: portableGlobalThis.Math, + require, + }, + ); + } finally { + macroModuleEvaluationDepth -= 1; + if (shouldRecordModuleEval) { + recordActiveMacroTiming('moduleEvalMs', performance.now() - moduleEvalStart); + recordActiveMacroTiming('moduleEvalRoots', 1); + } + } } catch (error) { if (error instanceof MacroError) { throw error; @@ -2578,6 +3072,7 @@ export function createProjectMacroEnvironment( } let definitions: ReadonlyMap; + const definitionLoadStart = performance.now(); try { definitions = collectNamedMacroDefinitions( fileName, @@ -2599,6 +3094,7 @@ export function createProjectMacroEnvironment( 'SOUNDSCRIPT_MACRO_EXPANSION', ); } + recordActiveMacroTiming('definitionLoadMs', performance.now() - definitionLoadStart); definitionsByResolvedFile.set(fileName, definitions); return definitions; } @@ -2610,6 +3106,7 @@ export function createProjectMacroEnvironment( } let exports: LoadedNamedMacroExports; + const exportLoadStart = performance.now(); try { exports = collectNamedMacroExports( fileName, @@ -2633,6 +3130,7 @@ export function createProjectMacroEnvironment( 'SOUNDSCRIPT_MACRO_EXPANSION', ); } + recordActiveMacroTiming('exportLoadMs', performance.now() - exportLoadStart); exportsByResolvedFile.set(fileName, exports); return exports; } @@ -2673,13 +3171,7 @@ export function createProjectMacroEnvironment( } const sourceText = sourceTextForMacroModule(fileName); - const sourceFile = ts.createSourceFile( - fileName, - sourceText, - ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(fileName), - ); + const sourceFile = sourceFileForMacroModule(fileName); const importedBindingsByLocalName = new Map( collectImportedNamedBindings(fileName, sourceText) .map((binding) => [binding.localName, binding] as const), @@ -2828,6 +3320,7 @@ export function createProjectMacroEnvironment( macroCacheStats.bindingPlanCacheMisses += 1; } + const bindingPlanStart = performance.now(); const definitions = new Map(); const registry = new Map(); const advancedRegistry = new Map(); @@ -2901,9 +3394,11 @@ export function createProjectMacroEnvironment( } for (const { localName, exportName } of candidateBindings) { + const authorityStart = performance.now(); const authority = builtinDefinitions ? { dependencyFiles: new Set(), exportName, resolvedFileName: specifier } : resolveMacroBindingAuthority(resolved, exportName); + recordActiveMacroTiming('authorityResolutionMs', performance.now() - authorityStart); if (!authority) { continue; } @@ -2966,6 +3461,7 @@ export function createProjectMacroEnvironment( const originalFileName = preparedProgram.toSourceFileName(sourceFile.fileName); const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(originalFileName); + const importUsageStart = performance.now(); const classificationSourceFile = ts.createSourceFile( originalFileName, preparedSource?.originalText ?? sourceFile.text, @@ -2985,6 +3481,7 @@ export function createProjectMacroEnvironment( importedBindingUsage.set(localName, 'runtimeOnly'); } } + recordActiveMacroTiming('importUsageMs', performance.now() - importUsageStart); const loaded = { advancedRegistry, @@ -2994,6 +3491,7 @@ export function createProjectMacroEnvironment( registry, siteKindsBySpecifier, }; + const bindingCacheWriteStart = performance.now(); storeCachedBindingPlan( sourceFile.fileName, createCachedPerFileMacroBindingPlanEntry( @@ -3004,7 +3502,10 @@ export function createProjectMacroEnvironment( resolutionDependencyFiles, ), ); + recordActiveMacroTiming('bindingCacheWriteMs', performance.now() - bindingCacheWriteStart); bindingsByFile.set(sourceFile.fileName, loaded); + recordActiveMacroTiming('bindingPlanMs', performance.now() - bindingPlanStart); + recordActiveMacroTiming('bindingPlanFiles', 1); return loaded; } @@ -3039,109 +3540,67 @@ export function createProjectMacroEnvironment( preserveMissingExpanders = false, annotateExpansions = false, ): ReadonlyMap { - const expansionModeKey = createExpandedFilesModeKey( - preserveRemovedImportStatements, - preserveMissingExpanders, - annotateExpansions, - ); - const cachedExpandedFiles = stableReuseState.expandedFilesByMode.get(expansionModeKey); - const hadReusableMacroState = cachedExpandedFiles !== undefined || - stableReuseState.bindingPlansByFile.size > 0 || - stableReuseState.dependencySourceTextsByFile.size > 0 || - stableReuseState.expandedFilesByMode.size > 0; - const expandedFiles = cachedExpandedFiles ?? new Map(); - stableReuseState.expandedFilesByMode.set(expansionModeKey, expandedFiles); - const registriesByFile = new Map< - string, - { - registry: ReadonlyMap; - advancedRegistry: ReadonlyMap; - siteKindsBySpecifier: ReadonlyMap>; - } - >(); - const bindingUsageByFile = new Map>(); - const expansionCacheKeyByFile = new Map(); - const hasBindingsByFile = new Map(); - const expansionCache = preparedProgram.preparedHost.reuseState.expandedMacroSourceFiles; - const macroSourceFiles: ts.SourceFile[] = []; - const currentProgramSourceFiles = new Set( - [...preparedProgram.preparedHost.reuseState.programSourceFiles].filter( - isExpandableProgramSourceFile, - ), - ); - const removedProgramSourceFiles = [ - ...preparedProgram.preparedHost.reuseState - .removedProgramSourceFiles, - ].filter(isExpandableProgramSourceFile); - const affectedSourceFiles = new Set(); - - for (const removedFileName of removedProgramSourceFiles) { - clearCachedBindingPlan(removedFileName); - expansionCache.delete(removedFileName); - for (const modeExpandedFiles of stableReuseState.expandedFilesByMode.values()) { - modeExpandedFiles.delete(removedFileName); - } - const removedSourcePath = preparedProgram.toSourceFileName(removedFileName); - for ( - const dependentFileName of stableReuseState.dependentFilesByDependencyFile.get( - removedSourcePath, - ) ?? [] - ) { - if (currentProgramSourceFiles.has(dependentFileName)) { - affectedSourceFiles.add(dependentFileName); - } - } - } - - if (!processedPreparedProgramChangedMacroFiles) { - processedPreparedProgramChangedMacroFiles = true; - const changedMacroModuleFiles = hadReusableMacroState - ? [ - ...preparedProgram.preparedHost.reuseState.changedProgramSourceFiles, - ].filter((changedFileName) => { - const changedSourcePath = preparedProgram.toSourceFileName(changedFileName); - if (isSoundscriptMacroSourceFile(changedSourcePath)) { - return true; - } - try { - return isLikelyMacroModule(changedSourcePath); - } catch { - return false; - } - }) - : []; - if (changedMacroModuleFiles.length > 0) { - stableCompiledArtifactCache.clear(); - compiledArtifactCache.clear(); - preparedProgram.preparedHost.reuseState.builtinAnnotatedSourceFiles.clear(); - preparedProgram.preparedHost.reuseState.builtinFinalSourceFiles.clear(); - for (const currentFileName of currentProgramSourceFiles) { - affectedSourceFiles.add(currentFileName); - } - } - } + const expansionTimingStats = createMacroExpansionTimingStats(); + const previousActiveMacroTimingStats = activeMacroTimingStats; + const expandDetailsStart = performance.now(); + const timingObserver: MacroExpansionTimingObserver = { + recordMacroExecution(kind, durationMs): void { + recordActiveMacroTiming('macroExecutionMs', durationMs); + recordActiveMacroTiming( + kind === 'advanced' ? 'advancedMacroExecutionCount' : 'rewriteMacroExecutionCount', + 1, + ); + }, + }; + activeMacroTimingStats = expansionTimingStats; - if (!cachedExpandedFiles) { - for (const fileName of currentProgramSourceFiles) { - affectedSourceFiles.add(fileName); - } - } else { - for ( - const [dependencyFileName, cachedSourceText] of stableReuseState - .dependencySourceTextsByFile - ) { - let currentSourceText: string | undefined; - try { - currentSourceText = sourceTextForMacroModule(dependencyFileName); - } catch { - currentSourceText = undefined; + const runExpansion = (): ReadonlyMap => { + const expansionModeKey = createExpandedFilesModeKey( + preserveRemovedImportStatements, + preserveMissingExpanders, + annotateExpansions, + ); + const cachedExpandedFiles = stableReuseState.expandedFilesByMode.get(expansionModeKey); + const hadReusableMacroState = cachedExpandedFiles !== undefined || + stableReuseState.bindingPlansByFile.size > 0 || + stableReuseState.dependencySourceTextsByFile.size > 0 || + stableReuseState.expandedFilesByMode.size > 0; + const expandedFiles = cachedExpandedFiles ?? new Map(); + stableReuseState.expandedFilesByMode.set(expansionModeKey, expandedFiles); + const registriesByFile = new Map< + string, + { + registry: ReadonlyMap; + advancedRegistry: ReadonlyMap; + siteKindsBySpecifier: ReadonlyMap>; } - if (currentSourceText === cachedSourceText) { - continue; + >(); + const bindingUsageByFile = new Map>(); + const expansionCacheKeyByFile = new Map(); + const hasBindingsByFile = new Map(); + const expansionCache = preparedProgram.preparedHost.reuseState.expandedMacroSourceFiles; + const macroSourceFiles: ts.SourceFile[] = []; + const currentProgramSourceFiles = new Set( + [...preparedProgram.preparedHost.reuseState.programSourceFiles].filter( + isExpandableProgramSourceFile, + ), + ); + const removedProgramSourceFiles = [ + ...preparedProgram.preparedHost.reuseState + .removedProgramSourceFiles, + ].filter(isExpandableProgramSourceFile); + const affectedSourceFiles = new Set(); + + for (const removedFileName of removedProgramSourceFiles) { + clearCachedBindingPlan(removedFileName); + expansionCache.delete(removedFileName); + for (const modeExpandedFiles of stableReuseState.expandedFilesByMode.values()) { + modeExpandedFiles.delete(removedFileName); } + const removedSourcePath = preparedProgram.toSourceFileName(removedFileName); for ( const dependentFileName of stableReuseState.dependentFilesByDependencyFile.get( - dependencyFileName, + removedSourcePath, ) ?? [] ) { if (currentProgramSourceFiles.has(dependentFileName)) { @@ -3149,181 +3608,300 @@ export function createProjectMacroEnvironment( } } } - for ( - const changedFileName of preparedProgram.preparedHost.reuseState.changedProgramSourceFiles - ) { - if (!isExpandableProgramSourceFile(changedFileName)) { - continue; + + if (!processedPreparedProgramChangedMacroFiles) { + processedPreparedProgramChangedMacroFiles = true; + const changedMacroModuleFiles = hadReusableMacroState + ? [ + ...preparedProgram.preparedHost.reuseState.changedProgramSourceFiles, + ].filter((changedFileName) => { + const changedSourcePath = preparedProgram.toSourceFileName(changedFileName); + if (isSoundscriptMacroSourceFile(changedSourcePath)) { + return true; + } + try { + return isLikelyMacroModule(changedSourcePath); + } catch { + return false; + } + }) + : []; + if (changedMacroModuleFiles.length > 0) { + stableCompiledArtifactCache.clear(); + compiledArtifactCache.clear(); + preparedProgram.preparedHost.reuseState.builtinAnnotatedSourceFiles.clear(); + preparedProgram.preparedHost.reuseState.builtinFinalSourceFiles.clear(); + for (const currentFileName of currentProgramSourceFiles) { + affectedSourceFiles.add(currentFileName); + } + } + } + + if (!cachedExpandedFiles) { + for (const fileName of currentProgramSourceFiles) { + affectedSourceFiles.add(fileName); } - affectedSourceFiles.add(changedFileName); - const changedSourcePath = preparedProgram.toSourceFileName(changedFileName); + } else { for ( - const dependentFileName of stableReuseState.dependentFilesByDependencyFile.get( - changedSourcePath, - ) ?? [] + const [dependencyFileName, cachedSourceText] of stableReuseState + .dependencySourceTextsByFile ) { - if (currentProgramSourceFiles.has(dependentFileName)) { - affectedSourceFiles.add(dependentFileName); + let currentSourceText: string | undefined; + try { + currentSourceText = sourceTextForMacroModule(dependencyFileName); + } catch { + currentSourceText = undefined; + } + if (currentSourceText === cachedSourceText) { + continue; + } + for ( + const dependentFileName of stableReuseState.dependentFilesByDependencyFile.get( + dependencyFileName, + ) ?? [] + ) { + if (currentProgramSourceFiles.has(dependentFileName)) { + affectedSourceFiles.add(dependentFileName); + } } } - } - for (const fileName of currentProgramSourceFiles) { - if (!expandedFiles.has(fileName)) { - affectedSourceFiles.add(fileName); + for ( + const changedFileName of preparedProgram.preparedHost.reuseState + .changedProgramSourceFiles + ) { + if (!isExpandableProgramSourceFile(changedFileName)) { + continue; + } + affectedSourceFiles.add(changedFileName); + const changedSourcePath = preparedProgram.toSourceFileName(changedFileName); + for ( + const dependentFileName of stableReuseState.dependentFilesByDependencyFile.get( + changedSourcePath, + ) ?? [] + ) { + if (currentProgramSourceFiles.has(dependentFileName)) { + affectedSourceFiles.add(dependentFileName); + } + } } - } - for (const fileName of currentProgramSourceFiles) { - if (!affectedSourceFiles.has(fileName) && expandedFiles.has(fileName)) { - macroCacheStats.expandedFileCacheHits += 1; + for (const fileName of currentProgramSourceFiles) { + if (!expandedFiles.has(fileName)) { + affectedSourceFiles.add(fileName); + } + } + for (const fileName of currentProgramSourceFiles) { + if (!affectedSourceFiles.has(fileName) && expandedFiles.has(fileName)) { + macroCacheStats.expandedFileCacheHits += 1; + } + } + if (affectedSourceFiles.size === 0) { + return expandedFiles; } } - if (affectedSourceFiles.size === 0) { - return expandedFiles; - } - } - for (const fileName of affectedSourceFiles) { - const sourceFile = preparedProgram.program.getSourceFile(fileName); - if (!sourceFile || sourceFile.isDeclarationFile) { - expandedFiles.delete(fileName); - continue; - } - const cachedExpandedSourceFile = expansionCache.get(sourceFile.fileName); - const macroNames = macroNamesForFile(sourceFile); - if (macroNames.size === 0) { - clearCachedBindingPlan(sourceFile.fileName); - const sourceFileName = preparedProgram.toSourceFileName(sourceFile.fileName); - const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName); - const nonMacroExpansionCacheKey = createNonMacroExpansionCacheKey( - sourceFile, - preparedSource, - ); - if (cachedExpandedSourceFile?.cacheKey === nonMacroExpansionCacheKey) { - macroCacheStats.expandedFileCacheHits += 1; - expandedFiles.set(sourceFile.fileName, cachedExpandedSourceFile.sourceFile); + for (const fileName of affectedSourceFiles) { + const sourceFile = preparedProgram.program.getSourceFile(fileName); + if (!sourceFile || sourceFile.isDeclarationFile) { + expandedFiles.delete(fileName); continue; } - if (cachedExpandedSourceFile) { - macroCacheStats.expandedFileCacheInvalidations += 1; - } else { - macroCacheStats.expandedFileCacheMisses += 1; + const cachedExpandedSourceFile = expansionCache.get(sourceFile.fileName); + const macroNames = macroNamesForFile(sourceFile); + if (macroNames.size === 0) { + clearCachedBindingPlan(sourceFile.fileName); + const sourceFileName = preparedProgram.toSourceFileName(sourceFile.fileName); + const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile( + sourceFileName, + ); + const nonMacroExpansionCacheKey = createNonMacroExpansionCacheKey( + sourceFile, + preparedSource, + ); + if (cachedExpandedSourceFile?.cacheKey === nonMacroExpansionCacheKey) { + macroCacheStats.expandedFileCacheHits += 1; + expandedFiles.set(sourceFile.fileName, cachedExpandedSourceFile.sourceFile); + continue; + } + if (cachedExpandedSourceFile) { + macroCacheStats.expandedFileCacheInvalidations += 1; + } else { + macroCacheStats.expandedFileCacheMisses += 1; + } + const expandedSourceFile = preparedSource + ? ts.createSourceFile( + sourceFile.fileName, + preparedSource.originalText, + preparedProgram.options.target ?? ts.ScriptTarget.Latest, + true, + scriptKindForHostFile(sourceFile.fileName), + ) + : sourceFile; + expandedFiles.set(sourceFile.fileName, expandedSourceFile); + expansionCache.set(sourceFile.fileName, { + cacheKey: nonMacroExpansionCacheKey, + sourceFile: expandedSourceFile, + }); + continue; } - const expandedSourceFile = preparedSource - ? ts.createSourceFile( - sourceFile.fileName, - preparedSource.originalText, - preparedProgram.options.target ?? ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(sourceFile.fileName), - ) - : sourceFile; - expandedFiles.set(sourceFile.fileName, expandedSourceFile); - expansionCache.set(sourceFile.fileName, { - cacheKey: nonMacroExpansionCacheKey, - sourceFile: expandedSourceFile, - }); - continue; - } - const cachedBindingPlan = stableReuseState.bindingPlansByFile.get(sourceFile.fileName); - if ( - cachedExpandedSourceFile && cachedBindingPlan && - isCachedMacroBindingPlanValid(sourceFile, cachedBindingPlan) - ) { - const sourceFileName = preparedProgram.toSourceFileName(sourceFile.fileName); - const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName); - const cachedExpansionCacheKey = createExpansionCacheKeyFromPreparedState( + const cachedBindingPlan = stableReuseState.bindingPlansByFile.get(sourceFile.fileName); + if ( + cachedExpandedSourceFile && cachedBindingPlan && + isCachedMacroBindingPlanValid(sourceFile, cachedBindingPlan) + ) { + const sourceFileName = preparedProgram.toSourceFileName(sourceFile.fileName); + const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile( + sourceFileName, + ); + const cachedExpansionCacheKey = createExpansionCacheKeyFromPreparedState( + sourceFile, + cachedBindingPlan.expansionDependencySignature, + cachedBindingPlan.importedBindingUsage, + preserveRemovedImportStatements, + preserveMissingExpanders, + annotateExpansions, + preparedSource, + ); + if (cachedExpandedSourceFile.cacheKey === cachedExpansionCacheKey) { + macroCacheStats.expandedFileCacheHits += 1; + expandedFiles.set(sourceFile.fileName, cachedExpandedSourceFile.sourceFile); + continue; + } + } + + const bindings = bindingsForSourceFile(sourceFile); + const expansionCacheKey = createExpansionCacheKey( sourceFile, - cachedBindingPlan.expansionDependencySignature, - cachedBindingPlan.importedBindingUsage, + bindings, preserveRemovedImportStatements, preserveMissingExpanders, annotateExpansions, - preparedSource, ); - if (cachedExpandedSourceFile.cacheKey === cachedExpansionCacheKey) { + if (cachedExpandedSourceFile?.cacheKey === expansionCacheKey) { macroCacheStats.expandedFileCacheHits += 1; expandedFiles.set(sourceFile.fileName, cachedExpandedSourceFile.sourceFile); continue; } + if (cachedExpandedSourceFile) { + macroCacheStats.expandedFileCacheInvalidations += 1; + } else { + macroCacheStats.expandedFileCacheMisses += 1; + } + registriesByFile.set(sourceFile.fileName, { + registry: bindings.registry, + advancedRegistry: bindings.advancedRegistry, + siteKindsBySpecifier: bindings.siteKindsBySpecifier, + }); + expansionCacheKeyByFile.set(sourceFile.fileName, expansionCacheKey); + bindingUsageByFile.set(sourceFile.fileName, bindings.importedBindingUsage); + hasBindingsByFile.set(sourceFile.fileName, hasResolvedMacroBindings(bindings)); + macroSourceFiles.push(sourceFile); } - const bindings = bindingsForSourceFile(sourceFile); - const expansionCacheKey = createExpansionCacheKey( - sourceFile, - bindings, - preserveRemovedImportStatements, - preserveMissingExpanders, - annotateExpansions, - ); - if (cachedExpandedSourceFile?.cacheKey === expansionCacheKey) { - macroCacheStats.expandedFileCacheHits += 1; - expandedFiles.set(sourceFile.fileName, cachedExpandedSourceFile.sourceFile); - continue; - } - if (cachedExpandedSourceFile) { - macroCacheStats.expandedFileCacheInvalidations += 1; - } else { - macroCacheStats.expandedFileCacheMisses += 1; - } - registriesByFile.set(sourceFile.fileName, { - registry: bindings.registry, - advancedRegistry: bindings.advancedRegistry, - siteKindsBySpecifier: bindings.siteKindsBySpecifier, - }); - expansionCacheKeyByFile.set(sourceFile.fileName, expansionCacheKey); - bindingUsageByFile.set(sourceFile.fileName, bindings.importedBindingUsage); - hasBindingsByFile.set(sourceFile.fileName, hasResolvedMacroBindings(bindings)); - macroSourceFiles.push(sourceFile); - } + recordActiveMacroTiming('sourceExpansionFiles', macroSourceFiles.length); + const expanded = macroSourceFiles.length > 0 + ? measureActiveMacroTiming( + 'sourceExpansionMs', + () => + expandPreparedProgramWithFileRegistries( + preparedProgram, + registriesByFile, + preserveMissingExpanders, + annotateExpansions, + macroSourceFiles, + timingObserver, + ), + ) + : new Map(); + for (const [fileName, sourceFile] of expanded.entries()) { + let finalExpandedSourceFile: ts.SourceFile; + if (!hasBindingsByFile.get(fileName)) { + const sourceFileName = preparedProgram.toSourceFileName(fileName); + const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile( + sourceFileName, + ); + finalExpandedSourceFile = preparedSource + ? ts.createSourceFile( + fileName, + preparedSource.originalText, + preparedProgram.options.target ?? ts.ScriptTarget.Latest, + true, + scriptKindForHostFile(fileName), + ) + : sourceFile; + } else { + finalExpandedSourceFile = stripCompileTimeOnlyImportedBindings( + sourceFile, + bindingUsageByFile.get(fileName) ?? new Map(), + preserveRemovedImportStatements, + ); + const generatedStdlibStart = performance.now(); + finalExpandedSourceFile = expandGeneratedStdlibMacros( + finalExpandedSourceFile, + preserveRemovedImportStatements, + preserveMissingExpanders, + annotateExpansions, + ); + recordActiveMacroTiming('generatedStdlibMs', performance.now() - generatedStdlibStart); + recordActiveMacroTiming('generatedStdlibFiles', 1); + } - const expanded = macroSourceFiles.length > 0 - ? expandPreparedProgramWithFileRegistries( - preparedProgram, - registriesByFile, - preserveMissingExpanders, - annotateExpansions, - macroSourceFiles, - ) - : new Map(); - for (const [fileName, sourceFile] of expanded.entries()) { - let finalExpandedSourceFile: ts.SourceFile; - if (!hasBindingsByFile.get(fileName)) { - const sourceFileName = preparedProgram.toSourceFileName(fileName); - const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName); - finalExpandedSourceFile = preparedSource - ? ts.createSourceFile( - fileName, - preparedSource.originalText, - preparedProgram.options.target ?? ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(fileName), - ) - : sourceFile; - } else { - finalExpandedSourceFile = stripCompileTimeOnlyImportedBindings( - sourceFile, - bindingUsageByFile.get(fileName) ?? new Map(), - preserveRemovedImportStatements, - ); - finalExpandedSourceFile = expandGeneratedStdlibMacros( - finalExpandedSourceFile, - preserveRemovedImportStatements, - preserveMissingExpanders, - annotateExpansions, - ); + expandedFiles.set(fileName, finalExpandedSourceFile); + const expansionCacheKey = expansionCacheKeyByFile.get(fileName); + if (expansionCacheKey !== undefined) { + expansionCache.set(fileName, { + cacheKey: expansionCacheKey, + sourceFile: finalExpandedSourceFile, + }); + } } + return expandedFiles; + }; - expandedFiles.set(fileName, finalExpandedSourceFile); - const expansionCacheKey = expansionCacheKeyByFile.get(fileName); - if (expansionCacheKey !== undefined) { - expansionCache.set(fileName, { - cacheKey: expansionCacheKey, - sourceFile: finalExpandedSourceFile, - }); - } + try { + return runExpansion(); + } finally { + const durationMs = performance.now() - expandDetailsStart; + logCheckerTiming( + 'project.prepare.macro.expandDetails', + durationMs, + { + advancedMacroExecutionCount: expansionTimingStats.advancedMacroExecutionCount, + authorityResolutionMs: Number(expansionTimingStats.authorityResolutionMs.toFixed(1)), + bindingCacheWriteMs: Number(expansionTimingStats.bindingCacheWriteMs.toFixed(1)), + bindingPlanFiles: expansionTimingStats.bindingPlanFiles, + bindingPlanMs: Number(expansionTimingStats.bindingPlanMs.toFixed(1)), + definitionLoadMs: Number(expansionTimingStats.definitionLoadMs.toFixed(1)), + dependencySignatureMs: Number(expansionTimingStats.dependencySignatureMs.toFixed(1)), + exportLoadMs: Number(expansionTimingStats.exportLoadMs.toFixed(1)), + generatedStdlibFiles: expansionTimingStats.generatedStdlibFiles, + generatedStdlibMs: Number(expansionTimingStats.generatedStdlibMs.toFixed(1)), + graphCompileFiles: expansionTimingStats.graphCompileFiles, + graphCompileGraphs: expansionTimingStats.graphCompileGraphs, + graphCompileMs: Number(expansionTimingStats.graphCompileMs.toFixed(1)), + importUsageMs: Number(expansionTimingStats.importUsageMs.toFixed(1)), + interopImportCheckMs: Number(expansionTimingStats.interopImportCheckMs.toFixed(1)), + likelyMacroModuleMs: Number(expansionTimingStats.likelyMacroModuleMs.toFixed(1)), + macroExecutionCount: expansionTimingStats.advancedMacroExecutionCount + + expansionTimingStats.rewriteMacroExecutionCount, + macroExecutionMs: Number(expansionTimingStats.macroExecutionMs.toFixed(1)), + moduleEvalMs: Number(expansionTimingStats.moduleEvalMs.toFixed(1)), + moduleEvalRoots: expansionTimingStats.moduleEvalRoots, + packageExportInfoMs: Number(expansionTimingStats.packageExportInfoMs.toFixed(1)), + policyValidationMs: Number(expansionTimingStats.policyValidationMs.toFixed(1)), + preferredMacroResolutionMs: Number( + expansionTimingStats.preferredMacroResolutionMs.toFixed(1), + ), + resolveImportMs: Number(expansionTimingStats.resolveImportMs.toFixed(1)), + rewriteMacroExecutionCount: expansionTimingStats.rewriteMacroExecutionCount, + sourceExpansionFiles: expansionTimingStats.sourceExpansionFiles, + sourceExpansionMs: Number(expansionTimingStats.sourceExpansionMs.toFixed(1)), + tsModuleResolutionMs: Number(expansionTimingStats.tsModuleResolutionMs.toFixed(1)), + }, + { always: true }, + ); + activeMacroTimingStats = previousActiveMacroTimingStats; } - return expandedFiles; }, trackedDependencyFilesForFile(sourceFile: ts.SourceFile): readonly string[] { try { diff --git a/src/frontend/project_macro_support_test.ts b/src/frontend/project_macro_support_test.ts index d3b3b29..b58e001 100644 --- a/src/frontend/project_macro_support_test.ts +++ b/src/frontend/project_macro_support_test.ts @@ -369,6 +369,129 @@ Deno.test('createProjectMacroEnvironment handles duplicate generated stdlib macr assert(!printed.includes('__sts_macro_stmt')); }); +Deno.test('createProjectMacroEnvironment ignores unused generated user macro imports', () => { + const fileName = '/virtual/index.sts'; + const preparedProgram = createGeneratedMacroTestProgram( + [ + "import { EmitPlainRuntimeCode } from './macros/defs.macro';", + '', + 'EmitPlainRuntimeCode();', + '', + ].join('\n'), + [ + "import 'sts:macros';", + '', + '// #[macro(call)]', + 'export function EmitPlainRuntimeCode() {', + ' return {', + ' expand(ctx) {', + ' return ctx.output.stmts(ctx.quote.stmts`', + " import { RuntimeOnlyMacro } from './macros/defs.macro';", + ' export const value = 1;', + ' export const message = "RuntimeOnlyMacro is not invoked";', + ' `);', + ' },', + ' };', + '}', + '', + '// #[macro(call)]', + 'export function RuntimeOnlyMacro() {', + ' return {', + ' expand(ctx) {', + ' return ctx.output.expr(ctx.quote.expr`2`);', + ' },', + ' };', + '}', + '', + ].join('\n'), + ); + + const printed = printExpandedFileWithBuiltins(preparedProgram, fileName); + assertStringIncludes(printed, 'export const value = 1;'); + assertStringIncludes(printed, '"RuntimeOnlyMacro is not invoked"'); + assert(!printed.includes('__sts_macro_stmt')); +}); + +Deno.test('createProjectMacroEnvironment ignores macro-looking generated string literals', () => { + const fileName = '/virtual/index.sts'; + const preparedProgram = createGeneratedMacroTestProgram( + [ + "import { EmitMacroLookingText } from './macros/defs.macro';", + '', + 'EmitMacroLookingText();', + '', + ].join('\n'), + [ + "import 'sts:macros';", + '', + '// #[macro(call)]', + 'export function EmitMacroLookingText() {', + ' return {', + ' expand(ctx) {', + ' return ctx.output.stmts(ctx.quote.stmts`', + ' export const message = "// #[value] is documentation text";', + ' export const multiline = "\\\\n// #[codec]\\\\nnot an annotation";', + ' `);', + ' },', + ' };', + '}', + '', + ].join('\n'), + ); + + const printed = printExpandedFileWithBuiltins(preparedProgram, fileName); + assertStringIncludes(printed, '"// #[value] is documentation text"'); + assertStringIncludes(printed, 'not an annotation'); + assert(!printed.includes('__sts_macro_stmt')); +}); + +Deno.test('createProjectMacroEnvironment reports macro expansion detail timing', () => { + const originalTimingEnv = Deno.env.get('SOUNDSCRIPT_CHECKER_TIMING'); + const originalError = console.error; + const logs: string[] = []; + console.error = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + Deno.env.set('SOUNDSCRIPT_CHECKER_TIMING', '1'); + const preparedProgram = createMacroPreparedProgram( + [ + "import 'sts:macros';", + '', + '// #[macro(call)]', + 'export function Foo() {', + ' return {', + ' expand(ctx) {', + ' return ctx.output.expr(ctx.quote.expr`1`);', + ' },', + ' };', + '}', + '', + ].join('\n'), + ); + + printExpandedFile(preparedProgram, '/virtual/index.sts'); + } finally { + if (originalTimingEnv === undefined) { + Deno.env.delete('SOUNDSCRIPT_CHECKER_TIMING'); + } else { + Deno.env.set('SOUNDSCRIPT_CHECKER_TIMING', originalTimingEnv); + } + console.error = originalError; + } + + const detailLog = logs.find((line) => + line.includes('[soundscript:checker] project.prepare.macro.expandDetails ') + ); + assert(detailLog); + assertStringIncludes(detailLog, 'graphCompileGraphs=1'); + assertStringIncludes(detailLog, 'bindingPlanFiles=1'); + assertStringIncludes(detailLog, 'moduleEvalRoots=1'); + assertStringIncludes(detailLog, 'macroExecutionCount=2'); + assertStringIncludes(detailLog, 'sourceExpansionFiles=1'); +}); + Deno.test('createProjectMacroEnvironment honors macroExpansionRecursionLimit 0 for generated stdlib macros', () => { const fileName = '/virtual/index.sts'; const preparedProgram = createGeneratedMacroTestProgram( @@ -966,6 +1089,61 @@ Deno.test( }, ); +Deno.test( + 'createProjectMacroEnvironment invalidates expanded files for exact macro dependency source changes', + () => { + const fileName = '/virtual/index.sts'; + const macroFile = '/virtual/macros/defs.macro.sts'; + const helperFile = '/virtual/macros/helper.macro.sts'; + const reuseState = createPreparedCompilerHostReuseState('/virtual'); + const macroSource = [ + "import 'sts:macros';", + "import { helperValue } from './helper.macro';", + '', + '// #[macro(call)]', + 'export function Foo() {', + ' return {', + ' expand(ctx) {', + ' return ctx.output.expr(ctx.quote.expr`${JSON.stringify(helperValue)}`);', + ' },', + ' };', + '}', + '', + ].join('\n'); + const createProgram = (helperValue: string, oldProgram?: ts.Program) => + createPreparedProgram({ + baseHost: createBaseHost( + new Map([ + [fileName, "import { Foo } from './macros/defs.macro';\nexport const value = Foo();\n"], + [macroFile, macroSource], + [helperFile, `export const helperValue = "${helperValue}";\n`], + ]), + ), + oldProgram, + options: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + }, + reusableCompilerHostState: reuseState, + rootNames: [fileName], + }); + + // These two helper sources have the same length and collide under the previous + // 32-bit FNV dependency signature shortcut. Cache validity must depend on the + // exact source text instead. + const firstProgram = createProgram('htdi5qip'); + const firstExpanded = printExpandedFile(firstProgram, fileName); + + const secondProgram = createProgram('ofw3agyf', firstProgram.program); + const secondExpanded = printExpandedFile(secondProgram, fileName); + + assertStringIncludes(firstExpanded, '"htdi5qip"'); + assertStringIncludes(secondExpanded, '"ofw3agyf"'); + }, +); + Deno.test('createProjectMacroEnvironment reparses remote statement expansions using the caller source file script kind', () => { const fileName = '/virtual/index.sts'; const macroFile = '/virtual/macros/defs.macro.sts'; diff --git a/src/project/soundscript_packages.ts b/src/project/soundscript_packages.ts index 74286de..4c0d57a 100644 --- a/src/project/soundscript_packages.ts +++ b/src/project/soundscript_packages.ts @@ -23,6 +23,10 @@ export interface SoundScriptPackageInfo { version?: number; } +export interface SoundScriptPackageExportInfoOptions { + trustMacroAuthoringSourcePath?: boolean; +} + interface ModuleResolutionHostLike { directoryExists?(directoryName: string): boolean; fileExists(fileName: string): boolean; @@ -346,39 +350,6 @@ function collectPackageLocalImportSpecifiers( const moduleSpecifiers = new Set( ts.preProcessFile(sourceText, true, true).importedFiles.map((entry) => entry.fileName), ); - const sourceFile = ts.createSourceFile( - fileName, - sourceText, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - - const visit = (node: ts.Node): void => { - if ( - (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && - node.moduleSpecifier && - ts.isStringLiteralLike(node.moduleSpecifier) - ) { - moduleSpecifiers.add(node.moduleSpecifier.text); - } else if ( - ts.isImportEqualsDeclaration(node) && - ts.isExternalModuleReference(node.moduleReference) && - node.moduleReference.expression && - ts.isStringLiteralLike(node.moduleReference.expression) - ) { - moduleSpecifiers.add(node.moduleReference.expression.text); - } else if ( - ts.isImportTypeNode(node) && - ts.isLiteralTypeNode(node.argument) && - ts.isStringLiteralLike(node.argument.literal) - ) { - moduleSpecifiers.add(node.argument.literal.text); - } - - ts.forEachChild(node, visit); - }; - visit(sourceFile); return [...moduleSpecifiers].sort(); } @@ -559,6 +530,99 @@ function parsePackageJson( }; } +function parsePackageJsonForExport( + packageJsonPath: string, + exportKey: string, + host: ModuleResolutionHostLike, + options: SoundScriptPackageExportInfoOptions = {}, +): SoundScriptPackageExportInfo | undefined { + const packageJsonText = host.readFile(packageJsonPath); + if (!packageJsonText) { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(packageJsonText); + } catch { + return undefined; + } + + if (!parsed || typeof parsed !== 'object') { + return undefined; + } + + const packageRecord = parsed as Record; + const packageName = typeof packageRecord.name === 'string' ? packageRecord.name : undefined; + const soundscript = packageRecord.soundscript; + if (!packageName || !soundscript || typeof soundscript !== 'object') { + return undefined; + } + + const soundscriptRecord = soundscript as Record; + const packageRoot = dirname(packageJsonPath); + const rawExportsMap = new Map(); + const rawExports = soundscriptRecord.exports; + + if (rawExports && typeof rawExports === 'object') { + for (const [rawKey, rawValue] of Object.entries(rawExports as Record)) { + const normalizedExportKey = normalizeSoundscriptExportKey(rawKey); + if (!normalizedExportKey || !rawValue || typeof rawValue !== 'object') { + continue; + } + + const source = (rawValue as Record).source; + if (typeof source !== 'string' || source.length === 0) { + continue; + } + + const resolved = resolvePackageLocalSoundScriptSourcePath( + packageName, + packageRoot, + source, + host, + ); + if (resolved) { + rawExportsMap.set(normalizedExportKey, resolved); + } + } + } + + const legacySource = soundscriptRecord.source; + const rawLegacySourceEntryPath = typeof legacySource === 'string' && legacySource.length > 0 + ? resolvePackageLocalSoundScriptSourcePath(packageName, packageRoot, legacySource, host) + : undefined; + const sourceEntryPath = rawExportsMap.get(exportKey) ?? + (exportKey === '.' ? rawLegacySourceEntryPath : undefined); + if (!sourceEntryPath) { + return undefined; + } + + const draftPackageInfo: SoundScriptPackageInfo = { + exports: rawExportsMap, + legacySourceEntryPath: rawLegacySourceEntryPath, + name: packageName, + packageJsonPath, + packageRoot, + toolchain: typeof soundscriptRecord.toolchain === 'string' + ? soundscriptRecord.toolchain + : undefined, + version: typeof soundscriptRecord.version === 'number' ? soundscriptRecord.version : undefined, + }; + if ( + !(options.trustMacroAuthoringSourcePath && isMacroAuthoringSourcePath(sourceEntryPath)) && + !isTrustedPublishedPackageSourceClosure(sourceEntryPath, draftPackageInfo, host) + ) { + return undefined; + } + + return { + exportKey, + packageInfo: draftPackageInfo, + sourceEntryPath, + }; +} + export function loadSoundScriptPackageInfo( packageJsonPath: string, host: ModuleResolutionHostLike, @@ -582,28 +646,28 @@ export function getSoundScriptPackageExportInfoForResolvedModule( moduleSpecifier: string, resolvedFileName: string, host: ModuleResolutionHostLike, + options: SoundScriptPackageExportInfoOptions = {}, ): SoundScriptPackageExportInfo | undefined { const parsedSpecifier = parsePackageSpecifier(moduleSpecifier); if (!parsedSpecifier) { return undefined; } - const packageInfo = getSoundScriptPackageInfoForResolvedModule(resolvedFileName, host); - if (!packageInfo || packageInfo.name !== parsedSpecifier.packageName) { + const packageJsonPath = findNearestPackageJson(resolvedFileName, host); + if (!packageJsonPath) { return undefined; } - const sourceEntryPath = packageInfo.exports.get(parsedSpecifier.exportKey) ?? - (parsedSpecifier.exportKey === '.' ? packageInfo.legacySourceEntryPath : undefined); - if (!sourceEntryPath) { + const packageExport = parsePackageJsonForExport( + packageJsonPath, + parsedSpecifier.exportKey, + host, + options, + ); + if (!packageExport || packageExport.packageInfo.name !== parsedSpecifier.packageName) { return undefined; } - - return { - exportKey: parsedSpecifier.exportKey, - packageInfo, - sourceEntryPath, - }; + return packageExport; } function resolveBareSoundScriptPackageSourceModule( diff --git a/src/project/soundscript_packages_test.ts b/src/project/soundscript_packages_test.ts new file mode 100644 index 0000000..ed1bacd --- /dev/null +++ b/src/project/soundscript_packages_test.ts @@ -0,0 +1,183 @@ +import { assert, assertEquals } from '@std/assert'; + +import { dirname, join } from '../platform/path.ts'; + +import { getSoundScriptPackageExportInfoForResolvedModule } from './soundscript_packages.ts'; + +function createHost(files: ReadonlyMap) { + const knownDirectories = new Set(); + for (const fileName of files.keys()) { + let current = dirname(fileName); + while (current !== dirname(current)) { + knownDirectories.add(current); + current = dirname(current); + } + knownDirectories.add(current); + } + + return { + directoryExists(directoryName: string): boolean { + return knownDirectories.has(directoryName); + }, + fileExists(fileName: string): boolean { + return files.has(fileName); + }, + getCurrentDirectory(): string { + return '/workspace'; + }, + getDirectories(path: string): string[] { + const entries = new Set(); + for (const directory of knownDirectories) { + if (dirname(directory) === path) { + entries.add(directory.slice(path.endsWith('/') ? path.length : path.length + 1)); + } + } + return [...entries]; + }, + readFile(fileName: string): string | undefined { + return files.get(fileName); + }, + }; +} + +Deno.test('getSoundScriptPackageExportInfoForResolvedModule can trust macro source entrypoints for macro graph validation', () => { + const packageRoot = '/workspace/node_modules/pkg'; + const packageJsonPath = join(packageRoot, 'package.json'); + const resolvedRuntimeFileName = join(packageRoot, 'dist/index.d.ts'); + const macroSourcePath = join(packageRoot, 'src/index.macro.sts'); + const host = createHost( + new Map([ + [ + packageJsonPath, + JSON.stringify({ + name: 'pkg', + soundscript: { + version: 1, + exports: { + '.': { + source: './src/index.macro.sts', + }, + }, + }, + }), + ], + [resolvedRuntimeFileName, 'export declare function m(): void;\n'], + [macroSourcePath, "import { helper } from './helper.ts';\nexport { helper };\n"], + [join(packageRoot, 'src/helper.ts'), 'export const helper = 1;\n'], + ]), + ); + + assertEquals( + getSoundScriptPackageExportInfoForResolvedModule('pkg', resolvedRuntimeFileName, host), + undefined, + ); + + const trusted = getSoundScriptPackageExportInfoForResolvedModule( + 'pkg', + resolvedRuntimeFileName, + host, + { trustMacroAuthoringSourcePath: true }, + ); + assert(trusted); + assertEquals(trusted.sourceEntryPath, macroSourcePath); +}); + +Deno.test('getSoundScriptPackageExportInfoForResolvedModule returns full package export info', () => { + const packageRoot = '/workspace/node_modules/pkg'; + const packageJsonPath = join(packageRoot, 'package.json'); + const resolvedRuntimeFileName = join(packageRoot, 'dist/extra.d.ts'); + const indexSourcePath = join(packageRoot, 'src/index.sts'); + const extraSourcePath = join(packageRoot, 'src/extra.sts'); + const host = createHost( + new Map([ + [ + packageJsonPath, + JSON.stringify({ + name: 'pkg', + soundscript: { + version: 1, + exports: { + '.': { + source: './src/index.sts', + }, + './extra': { + source: './src/extra.sts', + }, + }, + }, + }), + ], + [join(packageRoot, 'dist/index.d.ts'), 'export declare const index: number;\n'], + [resolvedRuntimeFileName, 'export declare const extra: number;\n'], + [indexSourcePath, 'export const index = 1;\n'], + [extraSourcePath, 'export const extra = 2;\n'], + ]), + ); + + const packageExport = getSoundScriptPackageExportInfoForResolvedModule( + 'pkg/extra', + resolvedRuntimeFileName, + host, + ); + + assert(packageExport); + assertEquals(packageExport.sourceEntryPath, extraSourcePath); + assertEquals([...packageExport.packageInfo.exports.entries()], [ + ['.', indexSourcePath], + ['./extra', extraSourcePath], + ]); +}); + +Deno.test('getSoundScriptPackageExportInfoForResolvedModule keeps full package info when trusting one macro export', () => { + const packageRoot = '/workspace/node_modules/pkg'; + const packageJsonPath = join(packageRoot, 'package.json'); + const resolvedRuntimeFileName = join(packageRoot, 'dist/index.d.ts'); + const indexSourcePath = join(packageRoot, 'src/index.macro.sts'); + const extraSourcePath = join(packageRoot, 'src/extra.macro.sts'); + const host = createHost( + new Map([ + [ + packageJsonPath, + JSON.stringify({ + name: 'pkg', + soundscript: { + version: 1, + exports: { + '.': { + source: './src/index.macro.sts', + }, + './extra': { + source: './src/extra.macro.sts', + }, + }, + }, + }), + ], + [resolvedRuntimeFileName, 'export declare const index: number;\n'], + [join(packageRoot, 'dist/extra.d.ts'), 'export declare const extra: number;\n'], + [indexSourcePath, "import { helper } from './helper.ts';\nexport { helper };\n"], + [extraSourcePath, "import { extra } from './extra-helper.ts';\nexport { extra };\n"], + [join(packageRoot, 'src/helper.ts'), 'export const helper = 1;\n'], + [join(packageRoot, 'src/extra-helper.ts'), 'export const extra = 2;\n'], + ]), + ); + + assertEquals( + getSoundScriptPackageExportInfoForResolvedModule('pkg', resolvedRuntimeFileName, host), + undefined, + ); + + const packageExport = getSoundScriptPackageExportInfoForResolvedModule( + 'pkg', + resolvedRuntimeFileName, + host, + { trustMacroAuthoringSourcePath: true }, + ); + + assert(packageExport); + assertEquals(packageExport.sourceEntryPath, indexSourcePath); + assertEquals([...packageExport.packageInfo.exports.entries()], [ + ['.', indexSourcePath], + ['./extra', extraSourcePath], + ]); +});