Skip to content

Commit

Permalink
Add support for named exports (#4606)
Browse files Browse the repository at this point in the history
fix #4562

---------

Co-authored-by: Christopher Radek <[email protected]>
  • Loading branch information
timotheeguerin and chrisradek authored Oct 4, 2024
1 parent b3dbbfd commit 77edf34
Show file tree
Hide file tree
Showing 22 changed files with 1,292 additions and 113 deletions.
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",
}
}
```
8 changes: 8 additions & 0 deletions .chronus/changes/resolve-module-exports-2024-9-4-22-21-14.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: fix
packages:
- "@typespec/playground"
---

Do not treat path as relative internally
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
3 changes: 1 addition & 2 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,8 +662,7 @@ const diagnostics = {
"library-invalid": {
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
45 changes: 31 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,29 @@ 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, message: e.message },
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

0 comments on commit 77edf34

Please sign in to comment.