Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 177 additions & 59 deletions packages/typespec-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,69 +303,14 @@ export async function $onEmit(context: EmitContext) {
const modularSourcesRoot =
dpgContext.generationPathDetail?.modularSourcesDir ?? "src";
const project = useContext("outputProject");
modularEmitterOptions = transformModularEmitterOptions(

// Generate all modular sources using the shared helper
modularEmitterOptions = await generateModularSourcesInProject(
dpgContext,
modularSourcesRoot,
{
casing: "camel"
}
project
);

emitLoggerFile(modularEmitterOptions, modularSourcesRoot);

const rootIndexFile = project.createSourceFile(
`${modularSourcesRoot}/index.ts`,
"",
{
overwrite: true
}
);

emitTypes(dpgContext, { sourceRoot: modularSourcesRoot });
buildSubpathIndexFile(modularEmitterOptions, "models", undefined, {
recursive: true
});
const clientMap = getClientHierarchyMap(dpgContext);
if (clientMap.length === 0) {
// If no clients, we still need to build the root index file
buildRootIndex(dpgContext, modularEmitterOptions, rootIndexFile);
}
for (const subClient of clientMap) {
await renameClientName(subClient[1], modularEmitterOptions);
buildApiOptions(dpgContext, subClient, modularEmitterOptions);
buildOperationFiles(dpgContext, subClient, modularEmitterOptions);
buildClientContext(dpgContext, subClient, modularEmitterOptions);
buildRestorePoller(dpgContext, subClient, modularEmitterOptions);
if (dpgContext.rlcOptions?.hierarchyClient) {
buildSubpathIndexFile(modularEmitterOptions, "api", subClient, {
exportIndex: false,
recursive: true
});
} else {
buildSubpathIndexFile(modularEmitterOptions, "api", subClient, {
recursive: true,
exportIndex: true
});
}

buildClassicalClient(dpgContext, subClient, modularEmitterOptions);
buildClassicOperationFiles(dpgContext, subClient, modularEmitterOptions);
buildSubpathIndexFile(modularEmitterOptions, "classic", subClient, {
exportIndex: true,
interfaceOnly: true
});
const { subfolder } = getModularClientOptions(subClient);
// Generate index file for clients with subfolders (multi-client scenarios and nested clients)
if (subfolder) {
buildSubClientIndexFile(dpgContext, subClient, modularEmitterOptions);
}
buildRootIndex(
dpgContext,
modularEmitterOptions,
rootIndexFile,
subClient
);
}
// Enable modular sample generation when explicitly set to true or MPG
if (emitterOptions["generate-sample"] === true) {
const samples = emitSamples(dpgContext);
Expand Down Expand Up @@ -657,3 +602,176 @@ export async function renameClientName(
client.name = emitterOptions.options.typespecTitleMap[client.name]!;
}
}

/**
* Core modular sources generation logic shared between onEmit and buildProject.
* Generates all modular source files into the provided ts-morph Project.
*/
async function generateModularSourcesInProject(
dpgContext: SdkContext,
sourcesRoot: string,
project: Project
): Promise<ModularEmitterOptions> {
const modularEmitterOptions = transformModularEmitterOptions(
dpgContext,
sourcesRoot,
{ casing: "camel" }
);

emitLoggerFile(modularEmitterOptions, sourcesRoot);

const rootIndexFile = project.createSourceFile(
`${sourcesRoot}/index.ts`,
"",
{ overwrite: true }
);

emitTypes(dpgContext, { sourceRoot: sourcesRoot });
buildSubpathIndexFile(modularEmitterOptions, "models", undefined, {
recursive: true
});

const clientMap = getClientHierarchyMap(dpgContext);
if (clientMap.length === 0) {
buildRootIndex(dpgContext, modularEmitterOptions, rootIndexFile);
}
for (const subClient of clientMap) {
await renameClientName(subClient[1], modularEmitterOptions);
buildApiOptions(dpgContext, subClient, modularEmitterOptions);
buildOperationFiles(dpgContext, subClient, modularEmitterOptions);
buildClientContext(dpgContext, subClient, modularEmitterOptions);
buildRestorePoller(dpgContext, subClient, modularEmitterOptions);
if (dpgContext.rlcOptions?.hierarchyClient) {
buildSubpathIndexFile(modularEmitterOptions, "api", subClient, {
exportIndex: false,
recursive: true
});
} else {
buildSubpathIndexFile(modularEmitterOptions, "api", subClient, {
recursive: true,
exportIndex: true
});
}
buildClassicalClient(dpgContext, subClient, modularEmitterOptions);
buildClassicOperationFiles(dpgContext, subClient, modularEmitterOptions);
buildSubpathIndexFile(modularEmitterOptions, "classic", subClient, {
exportIndex: true,
interfaceOnly: true
});
const { subfolder } = getModularClientOptions(subClient);
if (subfolder) {
buildSubClientIndexFile(dpgContext, subClient, modularEmitterOptions);
}
buildRootIndex(dpgContext, modularEmitterOptions, rootIndexFile, subClient);
}

return modularEmitterOptions;
}

/**
* Options for {@link buildProject}.
*/
export interface BuildProjectOptions {
/**
* Partial emitter options to forward to the typespec-ts emitter pipeline.
* `is-modular-library` is always forced to `true`.
*/
emitterOptions?: Partial<EmitterOptions>;
/**
* Virtual root used for in-memory source paths (default: `/in-memory-output/src`).
*/
sourcesRoot?: string;
}

/**
* Run the typespec-ts modular generation pipeline against an already-compiled
* TypeSpec {@link Program} and return the populated ts-morph {@link Project}
* WITHOUT writing anything to disk.
*
* This is the programmatic entry point consumed by flight-instructor's external
* emitter commands.
*/
export async function buildProject(
program: Program,
options: BuildProjectOptions = {}
): Promise<Project> {
const sourcesRoot = options.sourcesRoot ?? "/in-memory-output/src";

// Build a minimal EmitContext that satisfies the pipeline without touching FS.
const fakeEmitContext = {
program,
options: {
"is-modular-library": true,
...options.emitterOptions
},
emitterOutputDir: "/in-memory-output"
} as unknown as EmitContext;

const outputProject = new Project();
const dpgContext = await createContextWithDefaultOptions(fakeEmitContext);

// Set generation paths without any FS look-ups.
dpgContext.generationPathDetail = {
rootDir: "/in-memory-output",
metadataDir: "/in-memory-output",
rlcSourcesDir: sourcesRoot,
modularSourcesDir: sourcesRoot
};
dpgContext.allServiceNamespaces = listAllServiceNamespaces(dpgContext);

const rlcOptions = transformRLCOptions(
fakeEmitContext.options as EmitterOptions,
dpgContext
);
dpgContext.rlcOptions = rlcOptions;

// Wire up the shared contexts used by the builder functions.
provideContext("rlcMetaTree", new Map());
provideContext("symbolMap", new Map());
provideContext("outputProject", outputProject);
provideContext("emitContext", {
compilerContext: fakeEmitContext,
tcgcContext: dpgContext
});

const staticHelpers = await loadStaticHelpers(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may have some concerns here - if a new helper is added, we'd need to update both places, which is easy to forget and hard to contain.

outputProject,
{
...SerializationHelpers,
...PagingHelpers,
...PollingHelpers,
...SimplePollerHelpers,
...UrlTemplateHelpers,
...MultipartHelpers,
...CloudSettingHelpers,
...XmlHelpers
},
{
sourcesDir: sourcesRoot,
options: rlcOptions,
program
}
);

const extraDependencies = isAzurePackage({ options: rlcOptions })
? {
...AzurePollingDependencies,
...AzureCoreDependencies,
...AzureIdentityDependencies
}
: { ...DefaultCoreDependencies };

const binder = provideBinder(outputProject, {
staticHelpers,
dependencies: { ...extraDependencies }
});
provideSdkTypes(dpgContext);
Copy link
Member

@JialinHuang803 JialinHuang803 Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am thinking about aggregating the code from line 729 (provideContext) to this line as a new shared method called something like initializeModularPipeline() which returns the binder, and both $onEmit and buildProject can call it. In this way, the structure would be like:

   ├── $onEmit(context)                          [ENTRY POINT — TypeSpec compiler hook]
   │   │  Orchestrates the full emitter pipeline: FS setup, RLC or Modular
   │   │  generation, metadata, tests, and writing files to disk.
   │   │
   │   ├── ...
   │   │
   │   ├── generateModularSources()             [MODULAR — emit source files]
   │   │     Calls initializeModularPipeline() → generateModularSourcesInProject()
   │   │     → binder.resolveAllReferences() → writes files to disk.
   │   │
   │   └── ...
   │
   ├── generateModularSourcesInProject(...)     [SHARED — modular source generation]
   │     Pure generation logic: emit types, build clients, operations,
   │     index files. No FS, no context wiring, no binder resolution.
   │     Used by both $onEmit and buildProject.
   │
   ├── initializeModularPipeline(...)           [SHARED — pipeline bootstrap]  ✨ NEW
   │     Wires up provideContext, loads static helpers, resolves
   │     dependencies, creates binder. SINGLE SOURCE OF TRUTH for
   │     helpers/deps registration.
   │
   ├── buildProject(program, options)           [ENTRY POINT — programmatic API]
   │     In-memory modular generation for flight-instructor.
   │     Creates fake EmitContext → initializeModularPipeline()
   │     → generateModularSourcesInProject() → binder.resolveAllReferences()
   │     → returns ts-morph Project (no disk writes).

Not sure if it would be better.


// Generate all modular source files into the in-memory project using the shared helper.
await generateModularSourcesInProject(dpgContext, sourcesRoot, outputProject);

// Resolve all cross-file symbol references in the in-memory project.
binder.resolveAllReferences(sourcesRoot);

return outputProject;
}
Loading