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

fix(typescript-estree): use simpler absolutify behavior for project service client file paths #8520

32 changes: 20 additions & 12 deletions packages/typescript-estree/src/useProgramFromProjectService.ts
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
@@ -1,43 +1,45 @@
import { minimatch } from 'minimatch';
import path from 'path';

import { createProjectProgram } from './create-program/createProjectProgram';
import type { ProjectServiceSettings } from './create-program/createProjectService';
import {
type ASTAndDefiniteProgram,
ensureAbsolutePath,
getCanonicalFileName,
} from './create-program/shared';
import { type ASTAndDefiniteProgram } from './create-program/shared';
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
import type { MutableParseSettings } from './parseSettings';

export function useProgramFromProjectService(
{ allowDefaultProjectForFiles, service }: ProjectServiceSettings,
parseSettings: Readonly<MutableParseSettings>,
hasFullTypeInformation: boolean,
): ASTAndDefiniteProgram | undefined {
const filePath = getCanonicalFileName(parseSettings.filePath);
const filePathAbsolute = absolutify(parseSettings.filePath);
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved

const opened = service.openClientFile(
ensureAbsolutePath(filePath, service.host.getCurrentDirectory()),
filePathAbsolute,
parseSettings.codeFullText,
/* scriptKind */ undefined,
parseSettings.tsconfigRootDir,
);

if (hasFullTypeInformation) {
const isDefaultProjectAllowedPath = filePathMatchedBy(
parseSettings.filePath,
allowDefaultProjectForFiles,
);

if (opened.configFileName) {
if (filePathMatchedBy(filePath, allowDefaultProjectForFiles)) {
if (isDefaultProjectAllowedPath) {
throw new Error(
`${filePath} was included by allowDefaultProjectForFiles but also was found in the project service. Consider removing it from allowDefaultProjectForFiles.`,
`${parseSettings.filePath} was included by allowDefaultProjectForFiles but also was found in the project service. Consider removing it from allowDefaultProjectForFiles.`,
);
}
} else if (!filePathMatchedBy(filePath, allowDefaultProjectForFiles)) {
} else if (!isDefaultProjectAllowedPath) {
throw new Error(
`${filePath} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProjectForFiles.`,
`${parseSettings.filePath} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProjectForFiles.`,
);
}
}

const scriptInfo = service.getScriptInfo(filePath);
const scriptInfo = service.getScriptInfo(filePathAbsolute);
const program = service
.getDefaultProjectForFile(scriptInfo!.fileName, true)!
.getLanguageService(/*ensureSynchronized*/ true)
Expand All @@ -48,6 +50,12 @@ export function useProgramFromProjectService(
}

return createProjectProgram(parseSettings, [program]);

function absolutify(filePath: string): string {
return path.isAbsolute(filePath)
? filePath
: path.join(service.host.getCurrentDirectory(), filePath);
}
}

function filePathMatchedBy(
Expand Down
@@ -0,0 +1,140 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type -- Fancy mocks */
import path from 'path';

import type { TypeScriptProjectService } from '../../src/create-program/createProjectService';
import type { ParseSettings } from '../../src/parseSettings';
import { useProgramFromProjectService } from '../../src/useProgramFromProjectService';

const mockCreateProjectProgram = jest.fn();

jest.mock('../../src/create-program/createProjectProgram', () => ({
get createProjectProgram() {
return mockCreateProjectProgram;
},
}));

const currentDirectory = '/repos/repo';

function createMockProjectService() {
const openClientFile = jest.fn();
const service = {
getDefaultProjectForFile: () => ({
getLanguageService: () => ({
getProgram: () => ({
getSourceFile: () => ({}),
}),
}),
}),
getScriptInfo: () => ({}),
host: {
getCurrentDirectory: () => currentDirectory,
},
openClientFile,
};

return {
service: service as typeof service & TypeScriptProjectService,
openClientFile,
};
}

const mockParseSettings = {
filePath: 'path/PascalCaseDirectory/camelCaseFile.ts',
} as ParseSettings;

describe('useProgramFromProjectService', () => {
it('passes an absolute, case-matching file path to service.openClientFile', () => {
const { service } = createMockProjectService();

useProgramFromProjectService(
{ allowDefaultProjectForFiles: undefined, service },
mockParseSettings,
false,
);

expect(service.openClientFile).toHaveBeenCalledWith(
path.normalize('/repos/repo/path/PascalCaseDirectory/camelCaseFile.ts'),
undefined,
undefined,
undefined,
);
});

it('throws an error when hasFullTypeInformation is enabled and the file is both in the project service and allowDefaultProjectForFiles', () => {
const { service } = createMockProjectService();

service.openClientFile.mockReturnValue({ configFileName: 'tsconfig.json' });

expect(() =>
useProgramFromProjectService(
{ allowDefaultProjectForFiles: [mockParseSettings.filePath], service },
mockParseSettings,
true,
),
).toThrow(
`${mockParseSettings.filePath} was included by allowDefaultProjectForFiles but also was found in the project service. Consider removing it from allowDefaultProjectForFiles.`,
);
});

it('throws an error when hasFullTypeInformation is enabled and the file is neither in the project service nor allowDefaultProjectForFiles', () => {
const { service } = createMockProjectService();

service.openClientFile.mockReturnValue({});

expect(() =>
useProgramFromProjectService(
{ allowDefaultProjectForFiles: [], service },
mockParseSettings,
true,
),
).toThrow(
`${mockParseSettings.filePath} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProjectForFiles.`,
);
});

it('returns undefined when hasFullTypeInformation is disabled, the file is both in the project service and allowDefaultProjectForFiles, and the service does not have a matching program', () => {
const { service } = createMockProjectService();

service.openClientFile.mockReturnValue({ configFileName: 'tsconfig.json' });

const actual = useProgramFromProjectService(
{ allowDefaultProjectForFiles: [mockParseSettings.filePath], service },
mockParseSettings,
false,
);

expect(actual).toBeUndefined();
});

it('returns a created program when hasFullTypeInformation is disabled, the file is both in the project service and allowDefaultProjectForFiles, and the service has a matching program', () => {
const { service } = createMockProjectService();
const program = { getSourceFile: jest.fn() };

service.openClientFile.mockReturnValue({ configFileName: 'tsconfig.json' });
mockCreateProjectProgram.mockReturnValue(program);

const actual = useProgramFromProjectService(
{ allowDefaultProjectForFiles: [mockParseSettings.filePath], service },
mockParseSettings,
false,
);

expect(actual).toBe(program);
});

it('returns a created program when hasFullTypeInformation is disabled, the file is neither in the project service nor allowDefaultProjectForFiles, and the service has a matching program', () => {
const { service } = createMockProjectService();
const program = { getSourceFile: jest.fn() };

service.openClientFile.mockReturnValue({});
mockCreateProjectProgram.mockReturnValue(program);

const actual = useProgramFromProjectService(
{ allowDefaultProjectForFiles: [], service },
mockParseSettings,
false,
);

expect(actual).toBe(program);
});
});