Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for named exports #4606

Merged
merged 27 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from 20 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
19 changes: 19 additions & 0 deletions .chronus/changes/resolve-module-exports-2024-9-4-18-32-9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: feature
packages:
- "@typespec/compiler"
---

Add support for node `exports` field. Specific typespec exports can be provided with the `typespec` field

```json
"exports": {
".": {
"typespec": "./lib/main.tsp",
},
"./named": {
"typespec": "./lib/named.tsp",
}
}
```
14 changes: 13 additions & 1 deletion docs/extending-typespec/basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,19 @@ Your package.json needs to refer to two main files: your Node module main file,

```jsonc
"main": "dist/src/index.js",
"tspMain": "lib/main.tsp"
"exports": {
".": {
"typespec": "./lib/main.tsp"
},
// Additional named export are possible
"./experimental": {
"typespec": "./lib/experimental.tsp"
},
// Wildcard export as well
"./lib/*": {
"typespec": "./lib/*.tsp"
}
}
```

### d. Install and initialize TypeScript
Expand Down
17 changes: 15 additions & 2 deletions docs/language-basics/imports.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import "./decorators.js";

## Importing a library

The import value can be the name of one of the package dependencies. In this case, TypeSpec will look for the `package.json` file and check the `tspMain` entry (defaulting to `main` if `tspMain` is absent) to determine the library entrypoint to load.
The import value can be the name of one of the package dependencies.

```typespec
import "/rest";
Expand All @@ -32,12 +32,25 @@ import "/rest";
```json
// ./node_modules/@typespec/rest/package.json
{
"tspMain": "./lib/main.tsp"
"exports": {
".": { "typespec": "./lib/main.tsp" }
}
}
```

This results in `./node_modules/@typespec/rest/lib/main.tsp` being imported.

### Package resolution algorithm

When trying to import a package TypeSpec follows the following logic

1. Parse the package name from the import specificier into `pkgName` and `subPath` (e.g. `@scope/lib/named` => pkgName: `@scope/lib` subpath: `named` )
1. Look to see if `pkgName` is itself(Containing package)
1. Otherwise lookup for a parent folder with a `node_modules/${pkgName}` sub folder
1. Reading the `package.json` of the package
a. If `exports` is defined respect the [ESM logic](https://github.com/nodejs/node/blob/main/doc/api/esm.md) to resolve the `typespec` condition(TypeSpec will not respect the `default` condition)
b. If `exports` is not found or for back compat the `.` export is missing the `typespec` condition fallback to checking `tspMain` or `main`

## Importing a directory

If the import value is a directory, TypeSpec will check if that directory is a Node package and follow the npm package [lookup logic](#importing-a-library), or if the directory contains a `main.tsp` file.
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ const diagnostics = {
severity: "error",
messages: {
tspMain: paramMessage`Library "${"path"}" has an invalid tspMain file.`,
default: paramMessage`Library "${"path"}" has an invalid main file.`,
default: paramMessage`Library "${"path"}" is invalid: ${"message"}`,
},
},
"incompatible-library": {
Expand Down
42 changes: 13 additions & 29 deletions packages/compiler/src/core/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { validateEncodedNamesConflicts } from "../lib/encoded-names.js";
import { MANIFEST } from "../manifest.js";
import {
ModuleResolutionResult,
ResolveModuleError,
ResolveModuleHost,
ResolvedModule,
resolveModule,
Expand All @@ -26,7 +27,13 @@ import { CompilerOptions } from "./options.js";
import { parse, parseStandaloneTypeReference } from "./parser.js";
import { getDirectoryPath, joinPaths, resolvePath } from "./path-utils.js";
import { createProjector } from "./projector.js";
import { SourceLoader, SourceResolution, createSourceLoader, loadJsFile } from "./source-loader.js";
import {
SourceLoader,
SourceResolution,
createSourceLoader,
loadJsFile,
moduleResolutionErrorToDiagnostic,
} from "./source-loader.js";
import { StateMap, StateSet, createStateAccessors } from "./state-accessors.js";
import {
CompilerHost,
Expand Down Expand Up @@ -148,7 +155,6 @@ export async function compile(
const logger = createLogger({ sink: host.logSink });
const tracer = createTracer(logger, { filter: options.trace });
const resolvedMain = await resolveTypeSpecEntrypoint(host, mainFile, reportDiagnostic);

const program: Program = {
checker: undefined!,
compilerOptions: resolveOptions(options),
Expand Down Expand Up @@ -190,12 +196,11 @@ export async function compile(
if (resolvedMain === undefined) {
return program;
}
await checkForCompilerVersionMismatch(resolvedMain);
const basedir = getDirectoryPath(resolvedMain) || "/";
await checkForCompilerVersionMismatch(basedir);

await loadSources(resolvedMain);

const basedir = getDirectoryPath(resolvedMain);

let emit = options.emit;
let emitterOptions = options.options;
/* eslint-disable @typescript-eslint/no-deprecated */
Expand Down Expand Up @@ -632,28 +637,8 @@ export async function compile(
try {
return [await resolveModule(getResolveModuleHost(), specifier, { baseDir }), []];
} catch (e: any) {
if (e.code === "MODULE_NOT_FOUND") {
return [
undefined,
[
createDiagnostic({
code: "import-not-found",
format: { path: specifier },
target: NoTarget,
}),
],
];
} else if (e.code === "INVALID_MAIN") {
return [
undefined,
[
createDiagnostic({
code: "library-invalid",
format: { path: specifier },
target: NoTarget,
}),
],
];
if (e instanceof ResolveModuleError) {
return [undefined, [moduleResolutionErrorToDiagnostic(e, specifier, NoTarget)]];
} else {
throw e;
}
Expand All @@ -677,8 +662,7 @@ export async function compile(
// different version of TypeSpec than the current one. Abort the compilation
// with an error if the TypeSpec entry point resolves to a different local
// compiler.
async function checkForCompilerVersionMismatch(mainPath: string): Promise<boolean> {
const baseDir = getDirectoryPath(mainPath);
async function checkForCompilerVersionMismatch(baseDir: string): Promise<boolean> {
let actual: ResolvedModule;
try {
const resolved = await resolveModule(
Expand Down
46 changes: 32 additions & 14 deletions packages/compiler/src/core/source-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ModuleResolutionResult,
ResolvedModule,
resolveModule,
ResolveModuleError,
ResolveModuleHost,
} from "../module-resolver/module-resolver.js";
import { PackageJson } from "../types/package-json.js";
Expand Down Expand Up @@ -251,22 +252,12 @@ export async function createSourceLoader(
// but using tspMain instead of main.
return resolveTspMain(pkg) ?? pkg.main;
},
conditions: ["typespec"],
fallbackOnMissingCondition: true,
});
} catch (e: any) {
if (e.code === "MODULE_NOT_FOUND") {
diagnostics.add(
createDiagnostic({ code: "import-not-found", format: { path: specifier }, target }),
);
return undefined;
} else if (e.code === "INVALID_MAIN") {
diagnostics.add(
createDiagnostic({
code: "library-invalid",
format: { path: specifier },
messageId: "tspMain",
target,
}),
);
if (e instanceof ResolveModuleError) {
diagnostics.add(moduleResolutionErrorToDiagnostic(e, specifier, target));
return undefined;
} else {
throw e;
Expand Down Expand Up @@ -372,3 +363,30 @@ export async function loadJsFile(
};
return [node, diagnostics];
}

export function moduleResolutionErrorToDiagnostic(
e: ResolveModuleError,
specifier: string,
target: DiagnosticTarget | typeof NoTarget,
): Diagnostic {
switch (e.code) {
case "MODULE_NOT_FOUND":
return createDiagnostic({ code: "import-not-found", format: { path: specifier }, target });
case "INVALID_MODULE":
case "INVALID_MODULE_EXPORT_TARGET":
return createDiagnostic({
code: "library-invalid",
format: { path: specifier, message: e.message },
target,
});
case "INVALID_MAIN":
return createDiagnostic({
code: "library-invalid",
format: { path: specifier },
messageId: "tspMain",
target,
});
default:
return createDiagnostic({ code: "import-not-found", format: { path: specifier }, target });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Exports } from "../../types/package-json.js";
import { resolvePackageImportsExports } from "../esm/resolve-package-imports-exports.js";
import { resolvePackageTarget } from "../esm/resolve-package-target.js";
import {
EsmResolutionContext,
InvalidModuleSpecifierError,
NoMatchingConditionsError,
} from "./utils.js";

/** Implementation of PACKAGE_EXPORTS_RESOLVE https://github.com/nodejs/node/blob/main/doc/api/esm.md */
export async function resolvePackageExports(
context: EsmResolutionContext,
subpath: string,
exports: Exports,
): Promise<string | null | undefined> {
if (exports === null) return undefined;

if (subpath === ".") {
let mainExport: Exports | undefined;
if (typeof exports === "string" || Array.isArray(exports) || isConditions(exports)) {
mainExport = exports;
} else if (exports["."]) {
mainExport = exports["."];
}

if (mainExport) {
if (context.ignoreDefaultCondition && typeof mainExport === "string") {
return undefined;
}
const resolved = await resolvePackageTarget(context, {
target: mainExport,
isImports: false,
});

// If resolved is not null or undefined, return resolved.
if (resolved) {
return resolved;
} else {
throw new NoMatchingConditionsError(context);
}
}
} else if (isMappings(exports)) {
// Let resolved be the result of PACKAGE_IMPORTS_EXPORTS_RESOLVE
const resolvedMatch = await resolvePackageImportsExports(context, {
matchKey: subpath,
matchObj: exports,
isImports: false,
});

// If resolved is not null or undefined, return resolved.
if (resolvedMatch) {
return resolvedMatch;
}
}

// 4. Throw a Package Path Not Exported error.
throw new InvalidModuleSpecifierError(context);
}

/** Conditions is an export object where all keys are conditions(not a path starting with .). E.g. import, default, types, etc. */
function isConditions(item: Exports) {
return typeof item === "object" && Object.keys(item).every((k) => !k.startsWith("."));
}
/**
* Mappings is an export object where all keys start with '.
*/
export function isMappings(exports: Exports): exports is Record<string, Exports> {
return typeof exports === "object" && !isConditions(exports);
}
Loading
Loading