Path: blob/main/extensions/copilot/src/extension/prompt/node/testFiles.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { CancellationToken } from 'vscode';6import { TextDocumentSnapshot } from '../../../platform/editing/common/textDocumentSnapshot';7import { ISearchService } from '../../../platform/search/common/searchService';8import { ITabsAndEditorsService } from '../../../platform/tabs/common/tabsAndEditorsService';9import { isMatch } from '../../../util/common/glob';10import { Schemas } from '../../../util/vs/base/common/network';11import * as resources from '../../../util/vs/base/common/resources';12import { URI } from '../../../util/vs/base/common/uri';1314type TestHint = {15prefix?: string;16suffixes?: string[];17location: 'sameFolder' | 'testFolder';18};1920const nullTestHint: Required<TestHint> = {21location: 'sameFolder',22prefix: 'test_',23suffixes: ['.test', '.spec', '_test', 'Test', '_spec', '_test', 'Tests', '.Tests', 'Spec'],24};2526const testHintsByLanguage: Record<string, TestHint> = {27csharp: { suffixes: ['Test'], location: 'testFolder' },28dart: { suffixes: ['_test'], location: 'testFolder' },29go: { suffixes: ['_test'], location: 'sameFolder' },30java: { suffixes: ['Test'], location: 'testFolder' },31javascript: { suffixes: ['.test', '.spec'], location: 'sameFolder' },32javascriptreact: { suffixes: ['.test', '.spec'], location: 'sameFolder' },33kotlin: { suffixes: ['Test'], location: 'testFolder' },34php: { suffixes: ['Test'], location: 'testFolder' },35powershell: { suffixes: ['.Tests'], location: 'testFolder' },36python: { prefix: 'test_', suffixes: ['_test'], location: 'testFolder' },37ruby: { suffixes: ['_test', '_spec'], location: 'testFolder' },38rust: { suffixes: [''], location: 'testFolder' }, // same file`39swift: { suffixes: ['Tests'], location: 'testFolder' },40typescript: { suffixes: ['.test', '.spec'], location: 'sameFolder' },41typescriptreact: { suffixes: ['.test', '.spec'], location: 'sameFolder' },42};4344export const suffix2Language: Record<string, keyof typeof testHintsByLanguage> = {45cs: 'csharp',46dart: 'dart',47go: 'go',48java: 'java',49js: 'javascriptreact',50kt: 'kotlin',51php: 'php',52ps1: 'powershell',53py: 'python',54rb: 'ruby',55rs: 'rust',56swift: 'swift',57ts: 'typescript',58tsx: 'typescriptreact',59};6061const testHintsBySuffix: { [key: string]: TestHint } = (function () {62const result: { [key: string]: TestHint } = {};63for (const [suffix, langId] of Object.entries(suffix2Language)) {64result[suffix] = <TestHint>testHintsByLanguage[langId];65}66return result;67})();6869/**70* @remark does NOT respect copilot-ignore71*/72export class TestFileFinder {7374constructor(75@ISearchService private readonly _search: ISearchService,76@ITabsAndEditorsService private readonly _tabs: ITabsAndEditorsService77) {78}7980private _findTabMatchingPattern(pattern: string): URI | undefined {8182const tab = this._tabs.tabs.find(info => {83// return a tab which uri matches the pattern84return info.uri && info.uri.scheme !== Schemas.untitled && isMatch(info.uri, pattern);85});8687return tab?.uri;88}8990/**91* Given a source file, find the corresponding test file.92*/93async findTestFileForSourceFile(document: TextDocumentSnapshot, token: CancellationToken): Promise<URI | undefined> {9495if (document.isUntitled) {96return undefined;97}9899const basename = resources.basename(document.uri);100const ext = resources.extname(document.uri);101102const testHint = testHintsByLanguage[document.languageId] ?? nullTestHint;103104const testNameCandidates: string[] = [];105if (testHint.prefix) {106testNameCandidates.push(testHint.prefix + basename);107}108if (testHint.suffixes) {109for (const suffix of testHint.suffixes ?? []) {110const testName = basename.replace(`${ext}`, `${suffix}${ext}`);111testNameCandidates.push(testName);112}113}114115const pattern =116testNameCandidates.length === 1117? `**/${testNameCandidates[0]}` // @ulugbekna: there must be at least two sub-patterns within braces for the glob to work118: `**/{${testNameCandidates.join(',')}}`;119120// try open editors/tabs first121// use search service as fallback122123let result = this._findTabMatchingPattern(pattern);124125if (!result) {126if (document.languageId === 'python') {127result = await this._search.findFilesWithExcludes(pattern, '**/*.pyc', 1, token);128} else {129result = await this._search.findFilesWithDefaultExcludes(pattern, 1, token);130}131}132133return result;134}135136/**137* Given a source file, find any test file (for the same language)138*/139async findAnyTestFileForSourceFile(document: TextDocumentSnapshot, token: CancellationToken): Promise<URI | undefined> {140141const testHint = testHintsByLanguage[document.languageId] ?? nullTestHint;142143const patterns: string[] = [];144if (testHint.prefix) {145patterns.push(`${testHint.prefix}*`);146}147if (testHint.suffixes) {148const ext = resources.extname(document.uri);149for (const suffix of testHint.suffixes ?? []) {150patterns.push(`*${suffix}${ext}`);151}152}153154const pattern =155patterns.length === 1156? `**/${patterns[0]}` // @ulugbekna: there must be at least two sub-patterns within braces for the glob to work157: `**/{${patterns.join(',')}}`;158159// try open editors/tabs first160// use search service as fallback161let result = this._findTabMatchingPattern(pattern);162if (!result) {163if (document.languageId === 'python') {164result = await this._search.findFilesWithExcludes(pattern, '**/*.pyc', 1, token);165} else {166result = await this._search.findFilesWithDefaultExcludes(pattern, 1, token);167}168169}170return result;171}172173/**174* Given a test file, find the corresponding source file.175*/176async findFileForTestFile(document: TextDocumentSnapshot, token: CancellationToken): Promise<URI | undefined> {177178const testHint = testHintsByLanguage[document.languageId] ?? nullTestHint;179180const basename = resources.basename(document.uri);181const parts: string[] = [];182183// collect potential suffixes and prefixes184if (testHint.suffixes) {185parts.splice(0, 0, ...testHint.suffixes);186}187if (testHint.prefix) {188parts.splice(0, 0, testHint.prefix);189}190191for (const part of parts) {192const candidate = basename.replace(part, '');193if (candidate !== basename) {194const pattern = `**/${candidate}`;195196let result = this._findTabMatchingPattern(pattern);197if (!result) {198result = await this._search.findFilesWithDefaultExcludes(pattern, 1, token);199}200if (result) {201return result;202}203}204}205206return undefined;207}208}209210export function isTestFile(candidate: URI | TextDocumentSnapshot): boolean {211212let testHint: TestHint | undefined;213if (candidate instanceof TextDocumentSnapshot) {214testHint = testHintsByLanguage[candidate.languageId];215candidate = candidate.uri;216}217218const sourceFileName = resources.basename(candidate);219const sourceFileExtension = resources.extname(candidate);220testHint ??= testHintsBySuffix[sourceFileExtension.replace('.', '')];221222if (testHint) {223224if (testHint.suffixes) {225const foundSuffixMatch = testHint.suffixes.some(suffix =>226sourceFileName.endsWith(suffix + sourceFileExtension)227);228if (foundSuffixMatch) {229return true;230}231}232if (testHint.prefix && sourceFileName.startsWith(testHint.prefix)) {233return true;234}235236} else {237const foundSuffixMatch = nullTestHint.suffixes.some(suffix => sourceFileName.endsWith(suffix + sourceFileExtension));238if (foundSuffixMatch) {239return true;240}241if (sourceFileName.startsWith(nullTestHint.prefix)) {242return true;243}244}245return false;246}247248export function suggestTestFileBasename(document: TextDocumentSnapshot): string {249const testHint = testHintsByLanguage[document.languageId] ?? nullTestHint;250const basename = resources.basename(document.uri);251252if (testHint.prefix) {253return testHint.prefix + basename;254}255256const ext = resources.extname(document.uri);257const suffix = testHint.suffixes && testHint.suffixes.length > 0258? testHint.suffixes[0]259: '.test';260261return basename.replace(`${ext}`, `${suffix}${ext}`);262}263264265export function suggestTestFileDir(document: TextDocumentSnapshot): URI {266const srcFileLocation = resources.joinPath(document.uri, '..'); // same folder267if (document.languageId === 'java') { // Java268/*269* According to the standard project structure of Maven, the corresponding test file for270* `$module/src/main/java/...$packages/$Class.java` is usually `$module/src/test/java/...$packages/${Class}Test.java`.271* Yet, it's worth noting that this structure might be altered by the user (though it's rare). In such cases, we can272* only obtain the accurate path from a language extension installed by the user, like `redhat.java`, for instance. But273* for simplicity's sake, we always assume the user is sticking to the standard project structure mentioned above at274* this stage.275*/276const srcFilePath = srcFileLocation.path;277if (srcFilePath.includes('/src/main/')) {278const testFilePath = srcFilePath.replace('/src/main/', '/src/test/');279return srcFileLocation.with({ path: testFilePath });280}281}282return srcFileLocation; // same folder283}284285export function suggestUntitledTestFileLocation(document: TextDocumentSnapshot): URI {286const newBasename = suggestTestFileBasename(document);287const newLocation = suggestTestFileDir(document);288const testFileUri = URI.joinPath(newLocation, newBasename).with({ scheme: Schemas.untitled });289return testFileUri;290}291292293