Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
a04454c
Add config parsing
andrewbranch Jul 9, 2026
7b5485c
Add span mapping, wire into file loading and module resolution with m…
andrewbranch Jul 9, 2026
40dc747
Wire into build mode / tsbuildinfo
andrewbranch Jul 10, 2026
b825fb8
Use node_modules package.json as manifest/identity
andrewbranch Jul 10, 2026
9ccd3aa
IPC infrastructure
andrewbranch Jul 13, 2026
054b2db
Wire up to tsc
andrewbranch Jul 13, 2026
06b5a2d
Exclude content-mapped files from emit
andrewbranch Jul 13, 2026
7b0f76e
Reorganize tests
andrewbranch Jul 14, 2026
bb14819
Wire up to editor
andrewbranch Jul 15, 2026
f44a957
Wire up diagnostics in-editor
andrewbranch Jul 16, 2026
5e9e866
Aggregate synthesized diagnostics
andrewbranch Jul 16, 2026
352be3a
Refactor spanmap to allow gaps as synthesized ranges
andrewbranch Jul 16, 2026
90d043c
Plumb position mapping through language service
andrewbranch Jul 20, 2026
a4cdbdf
Fourslash tests
andrewbranch Jul 20, 2026
5a9dcca
Implement extension API for first-wake
andrewbranch Jul 21, 2026
4195584
Enable declaration emit
andrewbranch Jul 21, 2026
a2b5d99
Support auto-importing from content-mapped files, fix project watch f…
andrewbranch Jul 21, 2026
ebd66ac
Allow content-mapped files in inferred projects
andrewbranch Jul 21, 2026
5037cf5
Fix fourslash tests
andrewbranch Jul 21, 2026
78f1cbc
Add module path completion test
andrewbranch Jul 21, 2026
e9ea490
Handle unregistering content mappers on config change
andrewbranch Jul 22, 2026
44d398c
Support in --watch and --build --watch
andrewbranch Jul 22, 2026
1536047
Include span maps and original text in IPC API
andrewbranch Jul 22, 2026
aca0729
Some renaming
andrewbranch Jul 22, 2026
40e4d51
Merge remote-tracking branch 'upstream/main' into content-mappers
andrewbranch Jul 22, 2026
2dadf89
Pass locale to content mappers in initialize
andrewbranch Jul 22, 2026
6312de0
Add SpanMappingPurpose, support one original to multiple generated ra…
andrewbranch Jul 22, 2026
b88a2a2
Optimize generated to original lookups
andrewbranch Jul 22, 2026
3b05081
Self-review corrections
andrewbranch Jul 22, 2026
78d1771
Add SpanMapKindAlias
andrewbranch Jul 23, 2026
a98a33d
More self-review fixes, move diagnostic source to initialize result
andrewbranch Jul 23, 2026
b29347c
Copilot review feedback
andrewbranch Jul 23, 2026
ff4912e
Generate
andrewbranch Jul 23, 2026
d2c3fb7
Fix leaked child processes in --build
andrewbranch Jul 23, 2026
1244045
Improve content mapper failure diagnostics
andrewbranch Jul 23, 2026
8343320
More copilot review
andrewbranch Jul 23, 2026
f075aa3
Generate
andrewbranch Jul 23, 2026
1d38c36
Fix auto-import default binding name in non-TS-extensioned files
andrewbranch Jul 23, 2026
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
4 changes: 4 additions & 0 deletions Herebyfile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,11 @@ const enumDefs = [
{ name: "ModuleDetectionKind", goPrefix: "ModuleDetectionKind", goFile: "internal/core/compileroptions.go", outDir: "_packages/native-preview/src/enums" },
{ name: "NewLineKind", goPrefix: "NewLineKind", goFile: "internal/core/compileroptions.go", outDir: "_packages/native-preview/src/enums" },
{ name: "JsxEmit", goPrefix: "JsxEmit", goFile: "internal/core/compileroptions.go", outDir: "_packages/native-preview/src/enums" },
{ name: "ScriptKind", goPrefix: "ScriptKind", goFile: "internal/core/scriptkind.go", outDir: "_packages/native-preview/src/enums" },
{ name: "TokenFlags", goPrefix: "TokenFlags", goFile: "internal/ast/tokenflags.go", outDir: "_packages/native-preview/src/enums" },
{ name: "SpanMapKind", goPrefix: "Kind", goFile: "internal/spanmap/spanmap.go", outDir: "_packages/native-preview/src/enums" },
{ name: "SpanMapFidelity", goPrefix: "Fidelity", goFile: "internal/spanmap/spanmap.go", outDir: "_packages/native-preview/src/enums" },
{ name: "SpanMapPurpose", goPrefix: "Purpose", goFile: "internal/spanmap/spanmap.go", outDir: "_packages/native-preview/src/enums" },
{ name: "NodeBuilderFlags", goPrefix: "Flags", goFile: "internal/nodebuilder/types.go", outDir: "_packages/native-preview/src/enums" },
{ name: "CompletionItemKind", goPrefix: "CompletionItemKind", goFile: "internal/lsp/lsproto/lsp_generated.go", outDir: "_packages/native-preview/src/enums" },
// String enum: Go stores internal names with a "\xFE" sentinel prefix, but the escaped
Expand Down
8 changes: 8 additions & 0 deletions _extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@
"title": "%native-preview.codeLens.showLocations.title%",
"enablement": "false"
},
{
"command": "typescript.native-preview.discoverContentMappers",
"title": "%native-preview.discoverContentMappers.title%"
},
{
"command": "typescript.native-preview.dev.runGC",
"title": "%native-preview.dev.runGC.title%",
Expand Down Expand Up @@ -293,6 +297,10 @@
],
"menus": {
"commandPalette": [
{
"command": "typescript.native-preview.discoverContentMappers",
"when": "false"
},
{
"command": "typescript.native-preview.restart",
"when": "typescript.native-preview.serverRunning"
Expand Down
1 change: 1 addition & 0 deletions _extension/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"native-preview.sortImports.title": "Sort Imports",
"native-preview.removeUnusedImports.title": "Remove Unused Imports",
"native-preview.codeLens.showLocations.title": "Show References of CodeLens",
"native-preview.discoverContentMappers.title": "Discover TypeScript Content Mappers",
"native-preview.dev.runGC.title": "TypeScript: Run Garbage Collection",
"native-preview.dev.saveHeapProfile.title": "TypeScript: Save Heap Profile",
"native-preview.dev.saveAllocProfile.title": "TypeScript: Save Allocation Profile",
Expand Down
154 changes: 146 additions & 8 deletions _extension/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as vscode from "vscode";

import { CancellationToken } from "vscode-languageclient";
import {
ClientCapabilities,
CloseAction,
Expand Down Expand Up @@ -37,6 +38,24 @@ import {
} from "./util";
import { getLanguageForUri } from "./util";

// Registration IDs the server uses for content mapper capabilities all share this prefix (see
// RegisterContentMapperExtensions in internal/lsp/server.go). The extension watches for these dynamic
// registrations to learn which foreign file extensions are content-mapped.
const contentMapperRegistrationPrefix = "content-mapper-";

// ContentMapperRegisterOptions is the subset of a dynamic registration's options that our server sends
// for content mapper capabilities: a document selector of simple glob-pattern filters (see
// RegisterContentMapperExtensions in internal/lsp/server.go).
interface ContentMapperRegisterOptions {
documentSelector?: ReadonlyArray<{ pattern: string; }>;
}

// extractPatternFilters reuses the server's glob-pattern filters so the extension's custom providers match
// the same content-mapped files.
function extractPatternFilters(registerOptions: ContentMapperRegisterOptions | undefined): vscode.DocumentFilter[] {
return (registerOptions?.documentSelector ?? []).map(({ pattern }) => ({ pattern }));
}

export class Client implements vscode.Disposable {
private outputChannel: vscode.LogOutputChannel;
private initializedEventEmitter: vscode.EventEmitter<void>;
Expand All @@ -51,6 +70,14 @@ export class Client implements vscode.Disposable {
private disposables: vscode.Disposable[] = [];
isInitialized = false;

// Document filters for content-mapped file extensions, keyed by the server's registration ID. These
// augment the static jsTs document selector so the extension's custom language-feature providers
// (hover, multi-document highlight, on-auto-insert) also cover content-mapped files.
private contentMapperFiltersById = new Map<string, vscode.DocumentFilter[]>();
// Disposables for the custom providers registered against the current (selector-scoped) document set.
// Re-registered whenever the set of content-mapped extensions changes.
private selectorScopedFeatures: vscode.Disposable[] = [];

private exe: ExeInfo | undefined;
private errorHandler: ReportingErrorHandler;

Expand Down Expand Up @@ -84,6 +111,8 @@ export class Client implements vscode.Disposable {
codeLensShowLocationsCommandName,
enableTelemetry: true,
logVerbosity: this.outputChannel.logLevel,
// Content mappers run external plugin processes, so they are only enabled in a trusted workspace.
loadExternalPlugins: vscode.workspace.isTrusted,
},
errorHandler: this.errorHandler,
middleware: {
Expand All @@ -98,6 +127,18 @@ export class Client implements vscode.Disposable {
},
sendNotification: sendNotificationMiddleware,
provideHover: () => undefined,
handleRegisterCapability: async (params, next) => {
await next(params, CancellationToken.None);
if (this.trackContentMapperRegistrations(params.registrations)) {
this.registerSelectorScopedFeatures();
}
},
handleUnregisterCapability: async (params, next) => {
await next(params, CancellationToken.None);
if (this.trackContentMapperUnregistrations(params.unregisterations)) {
this.registerSelectorScopedFeatures();
}
},
},
diagnosticCollectionName: "typescript-push",
diagnosticPullOptions: {
Expand Down Expand Up @@ -133,10 +174,16 @@ export class Client implements vscode.Disposable {
}

if (selector.pattern !== undefined) {
// VS Code's glob matcher is not available via the API;
// see: https://github.com/microsoft/vscode/issues/237304
// But, we're only called on selectors passed above, so just ignore this for now.
throw new Error("Not implemented");
// VS Code's full glob matcher is not available via the API
// (microsoft/vscode#237304), but content mapper registrations only ever
// use simple "**/*<ext>" patterns, so match those by suffix. Any other
// pattern falls through to the next selector.
const glob = typeof selector.pattern === "string" ? selector.pattern : selector.pattern.pattern;
const globPrefix = "**/*";
if (glob.startsWith(globPrefix) && resource.path.endsWith(glob.slice(globPrefix.length))) {
return true;
}
continue;
}

return true;
Expand Down Expand Up @@ -190,9 +237,9 @@ export class Client implements vscode.Disposable {
},
};

// Refresh the initial log verbosity in case the output channel's log
// level changed between construction and start.
// Refresh options in case they changed between construction and start.
this.clientOptions.initializationOptions.logVerbosity = this.outputChannel.logLevel;
this.clientOptions.initializationOptions.loadExternalPlugins = vscode.workspace.isTrusted;

this.client = new NativePreviewLanguageClient(
"js/ts",
Expand Down Expand Up @@ -257,11 +304,77 @@ export class Client implements vscode.Disposable {
this.disposables.push(
logLevelListener,
serverTelemetryListener,
registerMultiDocumentHighlightFeature(this.documentSelector, this.client),
registerSourceDefinitionFeature(this.client),
registerHoverFeature(this.documentSelector, this.client),
registerOnAutoInsertFeature(this.documentSelector, this.client),
);
// Register the selector-scoped custom providers (hover, multi-document highlight). These start
// scoped to the static jsTs selector and expand as content-mapped extensions register.
this.registerSelectorScopedFeatures();
}

// trackContentMapperRegistrations records the content-mapped document filters carried by the server's
// dynamic capability registrations. Returns whether the known set of content-mapper filters changed.
private trackContentMapperRegistrations(registrations: ReadonlyArray<{ id: string; registerOptions?: ContentMapperRegisterOptions; }>): boolean {
let changed = false;
for (const registration of registrations) {
if (!registration.id.startsWith(contentMapperRegistrationPrefix)) {
continue;
}
const filters = extractPatternFilters(registration.registerOptions);
if (filters.length > 0) {
this.contentMapperFiltersById.set(registration.id, filters);
changed = true;
}
else if (this.contentMapperFiltersById.delete(registration.id)) {
changed = true;
}
}
return changed;
}

// trackContentMapperUnregistrations forgets the filters for any content-mapper registration the server
// has removed. Returns whether the known set of content-mapper filters changed.
private trackContentMapperUnregistrations(unregistrations: ReadonlyArray<{ id: string; }>): boolean {
let changed = false;
for (const unregistration of unregistrations) {
if (this.contentMapperFiltersById.delete(unregistration.id)) {
changed = true;
}
}
return changed;
}

// selectorScopedDocumentSelector is the static jsTs selector plus every active content-mapper filter,
// deduplicated. The custom providers are registered against it so they also cover content-mapped files.
private selectorScopedDocumentSelector(): vscode.DocumentSelector {
const seen = new Set<string>();
const filters: vscode.DocumentFilter[] = [];
for (const list of this.contentMapperFiltersById.values()) {
for (const filter of list) {
const key = typeof filter.pattern === "string" ? filter.pattern : JSON.stringify(filter.pattern);
if (!seen.has(key)) {
seen.add(key);
filters.push(filter);
}
}
}
return [...this.documentSelector, ...filters];
}

// registerSelectorScopedFeatures (re)registers the custom language-feature providers whose scope must
// track the set of content-mapped extensions. Existing registrations are disposed first.
private registerSelectorScopedFeatures(): void {
for (const disposable of this.selectorScopedFeatures.splice(0)) {
disposable.dispose();
}
if (!this.client || this.isStopping || this.isDisposed) {
return;
}
const selector = this.selectorScopedDocumentSelector();
this.selectorScopedFeatures.push(
registerMultiDocumentHighlightFeature(selector, this.client),
registerHoverFeature(selector, this.client),
);
}

async stop(): Promise<void> {
Expand All @@ -270,6 +383,10 @@ export class Client implements vscode.Disposable {
}
this.isStopping = true;
this.isInitialized = false;
for (const disposable of this.selectorScopedFeatures.splice(0)) {
disposable.dispose();
}
this.contentMapperFiltersById.clear();
const disposables = this.disposables.splice(0);
await Promise.all(disposables.map(d => d.dispose()));
await this.client?.stop();
Expand All @@ -282,6 +399,10 @@ export class Client implements vscode.Disposable {
this.isDisposed = true;
this.isStopping = true;
this.isInitialized = false;
for (const disposable of this.selectorScopedFeatures.splice(0)) {
disposable.dispose();
}
this.contentMapperFiltersById.clear();
const disposables = this.disposables.splice(0);
await Promise.all(disposables.map(d => d.dispose()));
await this.client?.dispose();
Expand Down Expand Up @@ -391,6 +512,23 @@ export class Client implements vscode.Disposable {
textDocument: { uri },
}, token);
}

async discoverContentMappers(uris: readonly vscode.Uri[], extensions: readonly string[]): Promise<readonly string[]> {
if (!this.client || !this.isInitialized || !vscode.workspace.isTrusted) {
return [];
}
try {
const result = await this.client.sendRequest<{ extensions: string[]; }>("custom/discoverContentMappers", {
textDocuments: uris.map(uri => ({ uri: uri.toString() })),
extensions: [...extensions],
});
return result.extensions;
}
catch (error) {
this.outputChannel.warn(`Content mapper discovery failed: ${String(error)}`);
return [];
}
}
}

// Returns true when running on a VS Code Insiders build.
Expand Down
30 changes: 30 additions & 0 deletions _extension/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,36 @@ export function registerEnablementCommands(context: vscode.ExtensionContext, tel
}));
}

export const discoverContentMappersCommandName = "typescript.native-preview.discoverContentMappers";

export interface DiscoverContentMappersCommandArgs {
readonly uris: readonly vscode.Uri[];
readonly extensions: readonly string[];
}

export function registerDiscoverContentMappersCommand(
context: vscode.ExtensionContext,
discover: (uris: readonly vscode.Uri[], extensions: readonly string[]) => Promise<readonly string[]>,
): void {
context.subscriptions.push(vscode.commands.registerCommand(discoverContentMappersCommandName, async (args: unknown): Promise<readonly string[]> => {
if (!isDiscoverContentMappersCommandArgs(args)) {
return [];
}
return discover(args.uris, args.extensions);
}));
}

function isDiscoverContentMappersCommandArgs(value: unknown): value is DiscoverContentMappersCommandArgs {
if (typeof value !== "object" || value === null) {
return false;
}
const args = value as Partial<DiscoverContentMappersCommandArgs>;
return Array.isArray(args.uris)
&& args.uris.every(uri => uri instanceof vscode.Uri)
&& Array.isArray(args.extensions)
&& args.extensions.every(extension => typeof extension === "string");
}

/**
* Updates the TypeScript 7 setting and reloads extension host.
* Handles both `js/ts.experimental.useTsgo` and `typescript.experimental.useTsgo`.
Expand Down
2 changes: 2 additions & 0 deletions _extension/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as vscode from "vscode";

import {
registerDiscoverContentMappersCommand,
registerEnablementCommands,
updateUseTsgoSetting,
} from "./commands";
Expand Down Expand Up @@ -51,6 +52,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<Extens
const sessionManager = new SessionManager(context, output, languageServerInitializedEventEmitter, telemetryReporter);
context.subscriptions.push(sessionManager);
registerEnablementCommands(context, telemetryReporter, () => sessionManager.stop());
registerDiscoverContentMappersCommand(context, (uris, extensions) => sessionManager.discoverContentMappers(uris, extensions));

let pluginWarningShown = false;
const onDidChangeExtensions = vscode.extensions.onDidChange(() => {
Expand Down
8 changes: 8 additions & 0 deletions _extension/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export class SessionManager implements vscode.Disposable {
this.outputChannel = outputChannel;
this.telemetryReporter = telemetryReporter;
this.initializedEventEmitter = initializedEventEmitter;

// Workspace trust gates content mappers (external plugin processes), which are passed to the
// server as an initialization option. Restart when trust is granted so they become active.
this.disposables.push(vscode.workspace.onDidGrantWorkspaceTrust(() => this.restart(context)));
}

start(context: vscode.ExtensionContext): Promise<void> {
Expand Down Expand Up @@ -72,6 +76,10 @@ export class SessionManager implements vscode.Disposable {
return result.pipe;
}

async discoverContentMappers(uris: readonly vscode.Uri[], extensions: readonly string[]): Promise<readonly string[]> {
return this.currentSession?.client.discoverContentMappers(uris, extensions) ?? [];
}

async dispose(): Promise<void> {
await this.currentSession?.dispose();
await Promise.all(this.disposables.map(d => d.dispose()));
Expand Down
12 changes: 2 additions & 10 deletions _packages/native-preview/src/api/async/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type ClientOptions,
type ClientSocketOptions,
type ClientSpawnOptions,
getAPIProcessArgs,
isSpawnOptions,
resolveExePath,
} from "../options.ts";
Expand Down Expand Up @@ -65,16 +66,7 @@ export class Client {
const { spawn } = await import("node:child_process");

return new Promise((resolve, reject) => {
const args = [
"--api",
"--async",
"--cwd",
options.cwd ?? process.cwd(),
];

if (options.collectTiming) {
args.push("--timing");
}
const args = getAPIProcessArgs(options, true);

// Enable virtual FS callbacks for each provided FS function
const enabledCallbacks: string[] = [];
Expand Down
2 changes: 1 addition & 1 deletion _packages/native-preview/src/api/node/encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ function recordExtendedData(node: Node, strs: StringTable, extendedData: number[
const referencedFilesOffset = encodeFileReferences(sf.referencedFiles, structuredWriter);
const typeRefDirectivesOffset = encodeFileReferences(sf.typeReferenceDirectives, structuredWriter);
const libRefDirectivesOffset = encodeFileReferences(sf.libReferenceDirectives, structuredWriter);
extendedData.push(textIndex, fileNameIndex, pathIndex, sf.languageVariant, sf.scriptKind, referencedFilesOffset, typeRefDirectivesOffset, libRefDirectivesOffset, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA, 0);
extendedData.push(textIndex, fileNameIndex, pathIndex, sf.languageVariant, sf.scriptKind, referencedFilesOffset, typeRefDirectivesOffset, libRefDirectivesOffset, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA, NO_STRUCTURED_DATA, 0, textIndex, NO_STRUCTURED_DATA);
}
else if (
node.kind === SyntaxKind.TemplateHead ||
Expand Down
Loading