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

Support for project: true or useProjectService: true ? #282

Open
Samuel-Therrien-Beslogic opened this issue May 1, 2024 · 12 comments
Open

Comments

@Samuel-Therrien-Beslogic
Copy link

Samuel-Therrien-Beslogic commented May 1, 2024

Hi! I'm wondering if the resolver currently or plans on supporting project: true like https://typescript-eslint.io/packages/parser/#project ?

As well as plans to support useProjectService once out of experimental phase (currently experimental as EXPERIMENTAL_useProjectService https://typescript-eslint.io/packages/parser/#experimental_useprojectservice ) which solves a few more issues (including performance) with finding the tsconfig.

@Samuel-Therrien-Beslogic Samuel-Therrien-Beslogic changed the title Support for project: true or useprojectservice: true ? Support for project: true or useProjectService: true ? May 29, 2024
@SukkaW
Copy link
Collaborator

SukkaW commented Jul 12, 2024

Is there any guide/docs about how to use/implement projectService in a third-party library (eslint-import-resolver-typescript in this case)?

@Samuel-Therrien-Beslogic
Copy link
Author

Samuel-Therrien-Beslogic commented Jul 12, 2024

Sorry I'm not that knowledgeable about how that would work, I suppose the best place to ask would be https://github.com/typescript-eslint/typescript-eslint/discussions , maybe this discussion typescript-eslint/typescript-eslint#8030 ?

Edit: I've forwarded the question: typescript-eslint/typescript-eslint#8030 (comment)

@Samuel-Therrien-Beslogic
Copy link
Author

Samuel-Therrien-Beslogic commented Sep 19, 2024

@SukkaW here's an answer for you. Hopefully it answers the question: typescript-eslint/typescript-eslint#8030 (reply in thread)

@SukkaW
Copy link
Collaborator

SukkaW commented Sep 19, 2024

typescript-eslint/typescript-eslint#8030 (reply in thread)

un-ts/eslint-plugin-import-x#40 (comment)
un-ts/eslint-plugin-import-x#40 (comment)

Due to the previous performance degradation (up to 50% slower) with parsing, I doubt the non-isolated parsing would do any good with module resolution. For now, I'd prefer not to do module resolution with type information unless the typescript-eslint team has other details that I don't know.

@higherorderfunctor
Copy link

higherorderfunctor commented Dec 17, 2024

I've done a little work on typescript-eslint. It is doable, but it does require patching typescript-eslint (@typescript-eslint/typescript-estree specifically) to make the projectService accessible. I'm on a potato right now, but its about ~3 seconds faster linting a single file. More thorough testing is needed to see if it is actually faster.

The easiest way is to change this one line to add the projectService into globalThis. Where node is single-threaded, I don't recommend loading up another instance of tsserver in the same thread. Probably be better to see if typescript-eslint is willing to expose their instance without patching.

https://github.com/typescript-eslint/typescript-eslint/blob/b2ce15840934fb5bf1ad4b1136658be9578ab82c/packages/typescript-estree/src/parseSettings/createParseSettings.ts#L115

For the transpiled version (e.g., patching with pnpm or something), it would look like this.

-            ? (TSSERVER_PROJECT_SERVICE ??= (0, createProjectService_1.createProjectService)(tsestreeOptions.projectService, jsDocParsingMode, tsconfigRootDir))
+            ? (global.TSSERVER_PROJECT_SERVICE ??= (TSSERVER_PROJECT_SERVICE ??= (0, createProjectService_1.createProjectService)(tsestreeOptions.projectService, jsDocParsingMode, tsconfigRootDir)))

A zero-config working implementation will then look like the following without needing to specify where the tsconfigs are.

import debug from 'debug';
import ts from 'typescript';

import type { NewResolver, ResolvedResult } from 'eslint-plugin-import-x/types.js';

// use the same debugger as eslint with a namespace for our resolver 
const log = debug('tsserver-resolver');

// declare the projectService in global for the patch if using typescript
declare global {
  var TSSERVER_PROJECT_SERVICE: { service: ts.server.ProjectService; } | null;
}

// failure boilerplate
const failure = (message: string): ResolvedResult => {
  log(message);
  return { found: false };
};

// success boilerplate
const success = (resolvedModule: ts.ResolvedModuleFull): ResolvedResult => {
  log('Found', `'${resolvedModule.resolvedFileName}'`);
  return { found: true, path: resolvedModule.resolvedFileName };
};

// zero config resolver
const tsserverResolver: NewResolver = {
  interfaceVersion: 3,
  name: 'tsserver-resolver',
  resolve: (source: string, file: string): ResolvedResult => {
    log('Resolving', `'${source}'`, 'in', `'${file}'`);
    // make sure typescript-eslint has initialized the service
    const projectService = global.TSSERVER_PROJECT_SERVICE?.service;
    if (!projectService) return failure('No project service found');
    // make sure the file is in the projectService
    const project = projectService.getDefaultProjectForFile(ts.server.toNormalizedPath(file), false);
    if (!project) return failure('No project found');
    // resolve the import from the file
    const sourceModule = ts.resolveModuleName(source, file, project.getCompilerOptions(), ts.sys);
    // not found case
    if (!sourceModule.resolvedModule) return failure('No module found');
    // found case
    return success(sourceModule.resolvedModule);
  },
};

Log output linting this file with DEBUG=tsserver-resolver.

  tsserver-resolver Resolving 'debug' in '<REDACTED>/packages/eslint-plugin/lib/settings/typescript.ts' +2ms
  tsserver-resolver Found '<REDACTED>/node_modules/.pnpm/@[email protected]/node_modules/@types/debug/index.d.ts' +16ms
  tsserver-resolver Resolving 'eslint-import-resolver-typescript' in '<REDACTED>/packages/eslint-plugin/lib/settings/typescript.ts' +20ms
  tsserver-resolver Found '<REDACTED>/node_modules/.pnpm/[email protected][email protected][email protected][email protected]_gstg3dkhejoefzvjz5ffhaic5y/node_modules/eslint-import-resolver-typescript/lib/index.d.ts' +15ms
  tsserver-resolver Resolving 'eslint-plugin-import-x/types.js' in '<REDACTED>/packages/eslint-plugin/lib/settings/typescript.ts' +2ms
  tsserver-resolver Found '<REDACTED>/node_modules/.pnpm/[email protected][email protected][email protected][email protected]/node_modules/eslint-plugin-import-x/lib/types.d.ts' +8ms
  tsserver-resolver Resolving 'typescript' in '<REDACTED>/packages/eslint-plugin/lib/settings/typescript.ts' +2ms
  tsserver-resolver Found '<REDACTED>/node_modules/.pnpm/[email protected]/node_modules/typescript/lib/typescript.d.ts' +8ms

@higherorderfunctor
Copy link

Some timings on my work's CI server for a moderately sized monorepo project with import-x. Seems about 20% faster. Also found some issues in the root of a few packages that have a tsconfig.utils.json for things like my esbuild.config.ts files not part of the main source tree that I forgot to add to my eslint-import-resolver-typescript config.

--------------------------------------------------------------------------------
Language                      files          blank        comment           code
--------------------------------------------------------------------------------
TypeScript                      684           3422           3979          25885
eslint-import-resolver-typescript: 3 minutes 11 seconds
tsserver-resolver:                 2 minutes 28 seconds

@SukkaW
Copy link
Collaborator

SukkaW commented Dec 17, 2024

@higherorderfunctor Thanks for the PoC! I will look into this.

@SukkaW
Copy link
Collaborator

SukkaW commented Dec 17, 2024

I don't know if typescript-eslint would be willing to expose the projectService through a global variable.

If we are resolving imports for a current linting file, it is possible to get the existing projectService via typed linting (through rule context). However, since both eslint-plugin-import-x and eslint-plugin-import support recursive resolving for transitive dependency (resolve imports for a file under node_modules), we choose not to expose the rule context in the new resolve interface.

@higherorderfunctor Will projectService also work for transitive dependency as well?

@higherorderfunctor
Copy link

@higherorderfunctor Thanks for the PoC! I will look into this.

There is a suggestion by the typescript-eslint devs on a way without patching over at https://discord.com/channels/1026804805894672454/1318413699731423305.

I havent tested it, but if I get some time this week I'll report back.

@higherorderfunctor
Copy link

higherorderfunctor commented Dec 17, 2024

I don't know if typescript-eslint would be willing to expose the projectService through a global variable.

If we are resolving imports for a current linting file, it is possible to get the existing projectService via typed linting (through rule context). However, since both eslint-plugin-import-x and eslint-plugin-import support recursive resolving for transitive dependency (resolve imports for a file under node_modules), we choose not to expose the rule context in the new resolve interface.

@higherorderfunctor Will projectService also work for transitive dependency as well?

I cannot say yes with any authority, but I'm hopefully optimistic it is a yes. Does either project have unit tests for these scenarios we could use to verify?

From the logs, tsserver tends to load practically everything in node_modules.

Here is some general info:
https://github.com/microsoft/TypeScript/wiki/Standalone-Server-%28tsserver%29

The server will include the loose file, then includes all other files included by triple slash references and module imports from the original file transitively.

From this issue it looks like they actually filter them out in the language service suggestions, so that tells me yes the tsserver projectService is aware of those.

An edge case I'm not sure of is dynamic imports.

You can also load up a projectService without typescript-eslint for testing.

I often use tsserver directly for tools that don't support modern tsconfig features like ${configDir} or multiple extends.

While that example just computes a tsconfig, its not too hard to make a projectService. Here is where it is done in typescript-eslint.

On my work repo, the only regression I noticed was @types/bun (edit: all @types/*) were expected to be in dependencies instead of devDependencies for one package in my monorepo. I haven't dug into why yet.

@higherorderfunctor
Copy link

higherorderfunctor commented Dec 18, 2024

Did a little more digging.

Here is some general info for customizing module resolution that may or may not be useful: https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API#customizing-module-resolution.

In order to use the suggestion on discord to avoid patching typescript-eslint, the context would need to be passed through. Also, type-checking needs to be enabled for this to work.

Here is an MVP implementation that does NOT use typescript-eslint (use DEBUG='tsserver-resolver:*' for the logs).

Again, this is much slower than piggy-backing off typescript-eslint as it is causing twice the projectService-related workload in a single process, but I figured it may make exploring the concept more approachable. It forgoes much of the work done by typescript-eslint like handling extra file extensions, updating the projectService in LSP-mode to catch new files, etc.

Click to expand...
import debug from 'debug';
import type { NewResolver, ResolvedResult } from 'eslint-plugin-import-x/types.js';
import ts from 'typescript';

// noop
const doNothing = (): void => {};

// basic logging facilities
const log = debug('tsserver-resolver:resolver');

const logTsserverErr = debug('tsserver-resolver:tsserver:err');
const logTsserverInfo = debug('tsserver-resolver:tsserver:info');
const logTsserverPerf = debug('tsserver-resolver:tsserver:perf');
const logTsserverEvent = debug('tsserver-resolver:tsserver:event');

const logger: ts.server.Logger = {
  close: doNothing,
  endGroup: doNothing,
  getLogFileName: (): undefined => undefined,
  hasLevel: (): boolean => true,
  info(s) {
    this.msg(s, ts.server.Msg.Info);
  },
  loggingEnabled: (): boolean => logTsserverInfo.enabled || logTsserverErr.enabled || logTsserverPerf.enabled,
  msg: (s, type) => {
    switch (type) {
      case ts.server.Msg.Err:
        logTsserverErr(s);
        break;
      case ts.server.Msg.Perf:
        logTsserverPerf(s);
        break;
      default:
        logTsserverInfo(s);
        break;
    }
  },
  perftrc(s) {
    this.msg(s, ts.server.Msg.Perf);
  },
  startGroup: doNothing,
};

// we won't actually watch files, updates come from LSP or are only read once in CLI
const createStubFileWatcher = (): ts.FileWatcher => ({
  close: doNothing,
});

// general host capabilities, use node built-ins or noop stubs
const host: ts.server.ServerHost = {
  ...ts.sys,
  clearImmediate,
  clearTimeout,
  setImmediate,
  setTimeout,
  watchDirectory: createStubFileWatcher,
  watchFile: createStubFileWatcher,
};

// init the project service
const projectService = new ts.server.ProjectService({
  cancellationToken: { isCancellationRequested: (): boolean => false },
  eventHandler: (e): void => {
    if (logTsserverEvent.enabled) logTsserverEvent(e);
  },
  host,
  jsDocParsingMode: ts.JSDocParsingMode.ParseNone,
  logger,
  session: undefined,
  useInferredProjectPerProjectRoot: false,
  useSingleInferredProject: false,
});

// original PoC (mostly)
const failure = (message: string): ResolvedResult => {
  log(message);
  return { found: false };
};

const success = (resolvedModule: ts.ResolvedModuleFull): ResolvedResult => {
  log('Found', `'${resolvedModule.resolvedFileName}'`);
  return { found: true, path: resolvedModule.resolvedFileName };
};

const tsserverResolver: NewResolver = {
  interfaceVersion: 3,
  name: 'tsserver-resolver',
  resolve: (source: string, file: string): ResolvedResult => {
    log('Resolving', `'${source}'`, 'in', `'${file}'`);
    // const projectService = global.TSSERVER_PROJECT_SERVICE?.service;
    // if (!projectService) return failure('No project service found');
    // NOTE: typescript-eslint does this allowing it to be skipped in the original PoC
    projectService.openClientFile(file, undefined, undefined, process.cwd());
    const project = projectService.getDefaultProjectForFile(ts.server.toNormalizedPath(file), false);
    if (!project) return failure('No project found');
    const sourceModule = ts.resolveModuleName(source, file, project.getCompilerOptions(), ts.sys);
    if (!sourceModule.resolvedModule) return failure('No module found');
    return success(sourceModule.resolvedModule);
  },
};

const settings = {
  'import-x/resolver-next': [tsserverResolver],
};

@higherorderfunctor
Copy link

Sharing what is probably my final update before going into vacation mode.

Refactored to use an Either pattern as it just made working with error management/logging easier for me. That adds the dependency effect for the Either implementation.

DEBUG='tsserver-resolver:*,-tsserver-resolver:tsserver:*,-tsserver-resolver:resolver:trace' can be used to cut out most of the noise.

There are 4 resolving techniques attempted.

        resolveModule(options).pipe(
          Either.orElse(() => resolveTypeReference(options)),
          Either.orElse(() => resolveAmbientModule(options)),
          Either.orElse(() => resolveFallbackRelativePath(options)),

resolveModule is the original method shared.

resolveTypeReference is for inline references like /// <reference types="bun-types" />.

resolveAmbientModule uses the type checker to find imports like bun:test without needing to traverse from bun -> bun-types -> bun-types/test.d.ts -> checking for the ambient module declaration.

resolveFallbackRelativePath fixed an issue for me where I had export type * from 'vitest'; giving the error No named exports found in module 'vitest' import-x/export. vitest.d.cts just had a single export * from './dist/index.js'. For some reason, vitest.d.cts isn't considered part of the projectService. I could do an openClientFile, in which case it will get added to a new inferred project by the compiler, but a simple relative path resolver seemed more efficient than opening up a bunch of files and inferred project's.

Like the case above, at a certain "depth" it does start failing.

2024-12-20T21:49:24.590Z tsserver-resolver:resolver:error Resolved fallback relative path doesn't exist: {
  file: '<REDACTED>/node_modules/.pnpm/[email protected]_@[email protected]_@[email protected][email protected][email protected][email protected]/node_modules/vitest/dist/index.js',
  source: 'chai',
  path: '<REDACTED>/node_modules/.pnpm/[email protected]_@[email protected]_@[email protected][email protected][email protected][email protected]/node_modules/vitest/dist/chai'
}
2024-12-20T21:49:24.590Z tsserver-resolver:resolver:error Result: { found: false }

I don't use chai in my test repo and I'm assuming vitest doesn't export any types from chai, so it is just not loaded into the projectService. This seems like an acceptable natural boundary where further resolution isn't needed if resolveFallbackRelativePath can't resolve.

I still get errors regarding @types/* wanting to be defined in each package in my monorepo and in dependencies not devDependencies. I haven't had much time to dig into that rule to see why.

Click to expand...
import path from 'node:path';

import type { Debugger } from 'debug';
import debug from 'debug';
import { Either, flow } from 'effect';
import type * as importX from 'eslint-plugin-import-x/types.js';
import ts from 'typescript';

/**
 * TSServer setup.
 */

// eslint-disable-next-line @typescript-eslint/no-empty-function
const doNothing = (): void => {};

const logTsserverErr = debug('tsserver-resolver:tsserver:err');
const logTsserverInfo = debug('tsserver-resolver:tsserver:info');
const logTsserverPerf = debug('tsserver-resolver:tsserver:perf');
const logTsserverEvent = debug('tsserver-resolver:tsserver:event');

const logger: ts.server.Logger = {
  close: doNothing,
  endGroup: doNothing,
  getLogFileName: (): undefined => undefined,
  hasLevel: (): boolean => true,
  info(s) {
    this.msg(s, ts.server.Msg.Info);
  },
  loggingEnabled: (): boolean => logTsserverInfo.enabled || logTsserverErr.enabled || logTsserverPerf.enabled,
  msg: (s, type) => {
    // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
    switch (type) {
      case ts.server.Msg.Err:
        logTsserverErr(s);
        break;
      case ts.server.Msg.Perf:
        logTsserverPerf(s);
        break;
      default:
        logTsserverInfo(s);
        break;
    }
  },
  perftrc(s) {
    this.msg(s, ts.server.Msg.Perf);
  },
  startGroup: doNothing,
};

const createStubFileWatcher = (): ts.FileWatcher => ({
  close: doNothing,
});

// general host capabilities, use node built-ins or noop stubs
const host: ts.server.ServerHost = {
  ...ts.sys,
  clearImmediate,
  clearTimeout,
  setImmediate,
  setTimeout,
  watchDirectory: createStubFileWatcher,
  watchFile: createStubFileWatcher,
};

// init the project service
const projectService = new ts.server.ProjectService({
  cancellationToken: { isCancellationRequested: (): boolean => false },
  eventHandler: (e): void => {
    if (logTsserverEvent.enabled) logTsserverEvent(e);
  },
  host,
  jsDocParsingMode: ts.JSDocParsingMode.ParseNone,
  logger,
  session: undefined,
  useInferredProjectPerProjectRoot: false,
  useSingleInferredProject: false,
});

/**
 * Implementation.
 */

const logInfo = debug('tsserver-resolver:resolver:info');
const logError = debug('tsserver-resolver:resolver:error');
const logTrace = debug('tsserver-resolver:resolver:trace');

const logRight: <R>(
  message: (args: NoInfer<R>) => Parameters<Debugger>,
  log?: Debugger,
) => <L = never>(self: Either.Either<R, L>) => Either.Either<R, L> =
  (message, log = logInfo) =>
  (self) =>
    Either.map(self, (args) => {
      log(...message(args));
      return args;
    });

const logLeft: <L>(
  message: (args: NoInfer<L>) => Parameters<Debugger>,
  log?: Debugger,
) => <R>(self: Either.Either<R, L>) => Either.Either<R, L> =
  (message, log = logError) =>
  (self) =>
    Either.mapLeft(self, (args) => {
      log(...message(args));
      return args;
    });

const NOT_FOUND: importX.ResultNotFound = { found: false };

const fail: <T extends Array<unknown>>(
  message: (...value: NoInfer<T>) => Parameters<Debugger>,
  log?: Debugger,
) => (...value: T) => importX.ResultNotFound =
  (message, log = logError) =>
  (...value) => {
    log(...message(...value));
    return NOT_FOUND;
  };

export const success: (path: string) => importX.ResultFound = (path) => ({ found: true, path });

/**
 * Get a `ProjectService` instance.
 */
const getProjectService: () => Either.Either<ts.server.ProjectService, importX.ResultNotFound> = () =>
  Either.right(projectService);

/**
 * Open's the file with tsserver so it loads the project that includes the file.
 *
 * @remarks Not necessary if using the `projectService` from `typescript-eslint`.
 */
export const openClientFile: (options: {
  file: string;
}) => Either.Either<ts.server.OpenConfiguredProjectResult, importX.ResultNotFound> = flow(
  Either.right,
  logRight(({ file }) => ['Opening client file:', { file }], logTrace),
  Either.bind('projectService', getProjectService),
  Either.flatMap(({ file, projectService }) =>
    Either.liftPredicate(
      projectService.openClientFile(file, undefined, undefined, process.cwd()),
      ({ configFileErrors }) => configFileErrors === undefined || configFileErrors.length === 0,
      fail(({ configFileErrors }) => ['Failed to open:', { diagnostics: configFileErrors, file }], logTrace),
    ),
  ),
  logRight(({ configFileName }) => ['Opened client file:', { clientFile: configFileName }], logTrace),
);

/**
 * Get the `Project` for a given file from a `ProjectService`.
 */
const getProject: (options: { file: string }) => Either.Either<ts.server.Project, importX.ResultNotFound> = flow(
  Either.right,
  logRight(({ file }) => ['Getting project:', file], logTrace),
  Either.bind('projectService', getProjectService),
  // Either.bind('clientFile', openClientFile),
  Either.bind('project', ({ file, projectService }) =>
    Either.fromNullable(
      projectService.getDefaultProjectForFile(ts.server.toNormalizedPath(file), false),
      fail(() => ['No project found:', file] as const),
    ),
  ),
  logRight(({ file, project }) => ['Found project:', { file, project: project.getProjectName() }], logTrace),
  Either.map(({ project }) => project),
);

/**
 * Get the `Program` for a given `Project`.
 */
const getProgram: (options: { project: ts.server.Project }) => Either.Either<ts.Program, importX.ResultNotFound> = flow(
  Either.right,
  logRight(({ project }) => ['Getting program:', { project: project.getProjectName() }], logTrace),
  Either.bind('program', ({ project }) =>
    Either.fromNullable(
      project.getLanguageService().getProgram(),
      fail(() => ['No program found']),
    ),
  ),
  logRight(({ project }) => ['Found program:', { project: project.getProjectName() }], logTrace),
  Either.map(({ program }) => program),
);

/**
 * Get the `SourceFile` for a given `Program`.
 *
 * @remarks The `SourceFile` is used to traverse inline-references or a `TypeChecker`.
 */
const getSourceFile: (options: {
  file: string;
  program: ts.Program;
}) => Either.Either<ts.SourceFile, importX.ResultNotFound> = flow(
  Either.right,
  logRight(({ file }) => ['Getting source file:', file], logTrace),
  Either.bind('sourceFile', ({ file, program }) =>
    Either.fromNullable(
      program.getSourceFile(file),
      fail(() => ['No source file found:', { file }]),
    ),
  ),
  logRight(({ file, sourceFile }) => ['Found source file:', { file, sourceFile: sourceFile.fileName }], logTrace),
  Either.map(({ sourceFile }) => sourceFile),
);

/**
 * Get the `TypeChecker` for a given `Program`.
 *
 * @remarks The `TypeChecker` is used to find ambient modules.
 */
const getTypeChecker: (options: { program: ts.Program }) => Either.Either<ts.TypeChecker> = flow(
  Either.right,
  logRight(() => ['Getting type checker'], logTrace),
  Either.bind('typeChecker', ({ program }) => Either.right(program.getTypeChecker())),
  logRight(() => ['Found type checker'], logTrace),
  Either.map(({ typeChecker }) => typeChecker),
);

/**
 * Resolve a module.
 */
export const resolveModule: (options: {
  file: string;
  source: string;
}) => Either.Either<importX.ResultFound, importX.ResultNotFound> = flow(
  Either.right,
  Either.bind('project', getProject),
  logRight(({ file, project, source }) => ['Resolving module:', { file, project: project.getProjectName(), source }]),
  Either.bind('resolvedModule', ({ file, project, source }) => {
    const { resolvedModule } = ts.resolveModuleName(source, file, project.getCompilerOptions(), ts.sys);
    return Either.fromNullable(
      resolvedModule,
      fail(() => ['No module found:', { file, project: project.getProjectName(), source }]),
    );
  }),
  logRight(({ file, project, resolvedModule, source }) => [
    'Resolved module:',
    { file, path: resolvedModule.resolvedFileName, project: project.getProjectName(), source },
  ]),
  Either.map(({ resolvedModule }) => success(resolvedModule.resolvedFileName)),
);

/**
 * Resolve a type reference.
 */
export const resolveTypeReference: (options: {
  file: string;
  source: string;
}) => Either.Either<importX.ResultFound, importX.ResultNotFound> = flow(
  Either.right,
  Either.bind('project', getProject),
  logRight(({ file, project, source }) => [
    'Resolving type reference directive:',
    { file, project: project.getProjectName(), source },
  ]),
  Either.bind('resolvedTypeReferenceDirective', ({ file, project, source }) => {
    const { resolvedTypeReferenceDirective } = ts.resolveTypeReferenceDirective(
      source,
      file,
      project.getCompilerOptions(),
      ts.sys,
    );
    return Either.fromNullable(
      resolvedTypeReferenceDirective?.resolvedFileName,
      fail(() => ['No type reference directive found:', { file, project: project.getProjectName(), source }]),
    );
  }),
  logRight(({ file, project, resolvedTypeReferenceDirective, source }) => [
    'Resolved type reference directive:',
    { file, path: resolvedTypeReferenceDirective, project: project.getProjectName(), source },
  ]),
  Either.map(({ resolvedTypeReferenceDirective }) => success(resolvedTypeReferenceDirective)),
);

/**
 * Resolve an ambient module.
 */
export const resolveAmbientModule: (options: {
  file: string;
  source: string;
}) => Either.Either<importX.ResultFound, importX.ResultNotFound> = flow(
  Either.right,
  logRight(({ file, source }) => ['Resolving ambient module:', { file, source }]),
  Either.bind('project', getProject),
  Either.bind('program', getProgram),
  Either.bind('sourceFile', getSourceFile),
  Either.bind('typeChecker', getTypeChecker),
  Either.bind('resolvedAmbientModule', ({ file, source, typeChecker }) =>
    Either.fromNullable(
      typeChecker
        .getAmbientModules()
        .find((_) => _.getName() === `"${source}"`)
        ?.getDeclarations()?.[0]
        .getSourceFile().fileName,
      fail(() => ['No ambient module found:', { file, source }]),
    ),
  ),
  logRight(({ file, project, resolvedAmbientModule, source }) => [
    'Resolved ambient module:',
    { file, project: project.getProjectName(), resolvedAmbientModule, source },
  ]),
  Either.map(({ resolvedAmbientModule }) => success(resolvedAmbientModule)),
);

/**
 * Resolve a fallback relative path.
 */
export const resolveFallbackRelativePath: (options: {
  file: string;
  source: string;
}) => Either.Either<importX.ResultFound, importX.ResultNotFound> = flow(
  Either.right,
  logRight(({ file, source }) => ['Resolving fallback relative path:', { file, source }]),
  Either.bind('path', ({ file, source }) =>
    Either.try({
      catch: fail((error) => ['No fallback relative path found:', { error, file, source }]),
      try: () => path.resolve(path.dirname(file), source),
    }),
  ),
  Either.flatMap(
    Either.liftPredicate(
      ({ path }) => ts.sys.fileExists(path),
      fail(({ file, path, source }) => ["Resolved fallback relative path doesn't exist:", { file, path, source }]),
    ),
  ),
  logRight(({ file, path, source }) => ['Resolved fallback relative path:', { file, path, source }]),
  Either.map(({ path }) => success(path)),
);

/**
 * Version 3 resolver.
 */
export const tsserverResolver: importX.NewResolver = {
  interfaceVersion: 3,
  name: 'tsserver-resolver',
  resolve: (source: string, file: string): importX.ResolvedResult =>
    Either.right({ file, source }).pipe(
      Either.bind('clientFile', openClientFile),
      Either.flatMap((options) =>
        resolveModule(options).pipe(
          Either.orElse(() => resolveTypeReference(options)),
          Either.orElse(() => resolveAmbientModule(options)),
          Either.orElse(() => resolveFallbackRelativePath(options)),
        ),
      ),
      logRight((result) => ['Result:', result] as const),
      logLeft((result) => ['Result:', result]),
      Either.merge,
    ),
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Development

No branches or pull requests

3 participants