Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
53 changes: 49 additions & 4 deletions extensions/copilot/src/extension/tools/node/getErrorsTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import { coalesce } from '../../../util/vs/base/common/arrays';
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { ResourceSet } from '../../../util/vs/base/common/map';
import { isEqualOrParent } from '../../../util/vs/base/common/resources';
import { isWindows } from '../../../util/vs/base/common/platform';
import { isEqual, isEqualOrParent } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
import { DiagnosticSeverity, ExtendedLanguageModelToolResult, LanguageModelPromptTsxPart, MarkdownString, Range } from '../../../vscodeTypes';
Expand Down Expand Up @@ -95,13 +96,15 @@ export class GetErrorsTool extends Disposable implements ICopilotTool<IGetErrors
let inputUri: URI | undefined;
let matchedExactPath = false;

const normalizedResource = this.normalizeDriveLetter(resource);
for (const path of nonNotebookPaths) {
// we support file or folder paths
if (isEqualOrParent(resource, path.uri)) {
const normalizedPath = this.normalizeDriveLetter(path.uri);
if (isEqualOrParent(normalizedResource, normalizedPath)) {
foundMatch = true;

// Track the input URI that matched - prefer exact matches, otherwise use the folder
const isExactMatch = resource.toString() === path.uri.toString();
const isExactMatch = isEqual(normalizedResource, normalizedPath);
if (isExactMatch) {
// Exact match - this is the file itself, no input folder
inputUri = undefined;
Expand Down Expand Up @@ -146,6 +149,48 @@ export class GetErrorsTool extends Disposable implements ICopilotTool<IGetErrors
return results;
}

/**
* Normalize the casing of a Windows drive letter so that diagnostics can be
* matched regardless of the drive letter casing produced by the model or the
* diagnostics provider.
*
* The model (and some language servers) may emit a path with a lower-case drive
* letter (e.g. `c:\foo`) while the diagnostics service reports the same file with
* an upper-case drive letter (e.g. `C:\foo`), or vice versa - the casing is not
* stable across machines (see microsoft/vscode#319858). On Windows the drive
* letter is case-insensitive, so we lower-case *only* the drive letter to make
* the two URIs compare equal.
*
* We intentionally do not lower-case the rest of the path: file systems may be
* case-sensitive past the drive, so `Foo.ts` and `foo.ts` must still be treated
* as different files. This is a no-op on non-Windows platforms and on paths that
* have no drive letter.
*
* The function operates solely on `uri.path`, which is the path component after
* the scheme and authority are stripped. For a standard Windows local file URI
* (`file:///C:/foo/bar`) the authority is empty and `uri.path` is `/C:/foo/bar`,
* so the drive letter is found at `path[1]`. UNC paths (`file://server/share`)
* store the host in the authority and produce a path without a drive letter, so
* they are returned unchanged. The malformed form `file://C:/foo` (drive letter
* in the authority) is not produced by `URI.file()` and is not handled.
*
* Note - This is made public for testing purposes only.
*/
public normalizeDriveLetter(uri: URI): URI {
if (!isWindows) {
return uri;
}
// `uri.path` is of the form `/c:/folder/file` for file URIs with a drive letter.
const path = uri.path;
if (path.length >= 3 && path[0] === '/' && path[2] === ':') {
const lowerDrive = path[1].toLowerCase();
if (lowerDrive !== path[1]) {
return uri.with({ path: `/${lowerDrive}${path.slice(2)}` });
}
}
return uri;
}

async invoke(options: vscode.LanguageModelToolInvocationOptions<IGetErrorsParams>, token: CancellationToken) {
const getAll = () => this.languageDiagnosticsService.getAllDiagnostics()
.map(d => ({ uri: d[0], diagnostics: d[1].filter(e => e.severity <= DiagnosticSeverity.Warning), inputUri: undefined }))
Expand Down Expand Up @@ -356,4 +401,4 @@ export class DiagnosticToolOutput extends PromptElement<IDiagnosticToolOutputPro
)}
</>;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspa
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { createTextDocumentData } from '../../../../util/common/test/shims/textDocument';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
import { isWindows } from '../../../../util/vs/base/common/platform';
import { URI } from '../../../../util/vs/base/common/uri';
import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
Expand All @@ -38,6 +39,8 @@ suite('GetErrorsTool - Tool Invocation', () => {
const noErrorFile = URI.file('/test/workspace/src/noErrorFile.ts');
const eslintErrorFile = URI.file('/test/workspace/eslint/eslint_unexpected_constant_condition_1.ts');
const emptyLineErrorFile = URI.file('/test/workspace/emptyLineError.ts');
const upperCaseDriveFile = URI.file('C:/test/workspace/src/windowsDriveFile.ts');
const lowerCaseDriveFile = URI.file('c:/test/workspace/src/windowsDriveFile.ts');

beforeEach(() => {
collection = createExtensionUnitTestingServices();
Expand All @@ -50,8 +53,9 @@ suite('GetErrorsTool - Tool Invocation', () => {
const eslintErrorDoc = createTextDocumentData(eslintErrorFile, 'if (true) {\n console.log("This is a constant condition");\n}', 'ts').document;
// File with a trailing empty line where the error is reported
const emptyLineErrorDoc = createTextDocumentData(emptyLineErrorFile, 'codeunit 50100 MyCU {\n procedure Foo() {\n\n', 'ts').document;
const upperCaseDriveDoc = createTextDocumentData(upperCaseDriveFile, 'const drive = "C";\n', 'ts').document;

collection.define(IWorkspaceService, new SyncDescriptor(TestWorkspaceService, [[workspaceFolder], [tsDoc1, tsDoc2, jsDoc, noErrorDoc, eslintErrorDoc, emptyLineErrorDoc]]));
collection.define(IWorkspaceService, new SyncDescriptor(TestWorkspaceService, [[workspaceFolder], [tsDoc1, tsDoc2, jsDoc, noErrorDoc, eslintErrorDoc, emptyLineErrorDoc, upperCaseDriveDoc]]));
Comment on lines +56 to +58

// Set up diagnostics service
diagnosticsService = new TestLanguageDiagnosticsService();
Expand Down Expand Up @@ -138,6 +142,21 @@ suite('GetErrorsTool - Tool Invocation', () => {
]);
});

test.skipIf(!isWindows)('getDiagnostics - matches Windows file path with different drive letter casing', () => {
const diagnostic = {
message: 'Drive letter casing diagnostic',
range: new Range(0, 6, 0, 11),
severity: DiagnosticSeverity.Error
};
diagnosticsService.setDiagnostics(upperCaseDriveFile, [diagnostic]);

const results = tool.getDiagnostics([{ uri: lowerCaseDriveFile, range: undefined }]);

expect(results).toEqual([
{ uri: upperCaseDriveFile, diagnostics: [diagnostic] }
]);
});
Comment on lines +145 to +158

test('getDiagnostics - filters by folder path', () => {
// Test with folder path
const srcFolder = URI.file('/test/workspace/src');
Expand Down Expand Up @@ -211,6 +230,22 @@ suite('GetErrorsTool - Tool Invocation', () => {
expect(msg).toMatchSnapshot();
});

test.skipIf(!isWindows)('Tool invocation - filePath matches diagnostics when Windows drive letter casing differs', async () => {
diagnosticsService.setDiagnostics(upperCaseDriveFile, [
{
message: 'Drive letter casing diagnostic',
range: new Range(0, 6, 0, 11),
severity: DiagnosticSeverity.Error
}
]);

const pathRep = accessor.get(IPromptPathRepresentationService);
const result = await tool.invoke({ input: { filePaths: [lowerCaseDriveFile.fsPath] }, toolInvocationToken: null! }, CancellationToken.None);
const msg = await toolResultToString(accessor, result);
expect(msg).toContain(`<errors path="${pathRep.getFilePath(upperCaseDriveFile)}">`);
expect(msg).toContain('Drive letter casing diagnostic');
});

test('Tool invocation - with folder path includes diagnostics from contained files', async () => {
const pathRep = accessor.get(IPromptPathRepresentationService);
const srcFolderUri = URI.file('/test/workspace/src');
Expand Down Expand Up @@ -260,4 +295,82 @@ suite('GetErrorsTool - Tool Invocation', () => {
const msg = await toolResultToString(accessor, result);
expect(msg).toMatchSnapshot();
});
});
});

suite('GetErrorsTool - normalizeDriveLetter', () => {
let accessor: ITestingServicesAccessor;
let collection: TestingServiceCollection;
let tool: GetErrorsTool;

beforeEach(() => {
collection = createExtensionUnitTestingServices();
const workspaceFolder = URI.file('/test/workspace');
const tsDoc = createTextDocumentData(URI.file('/test/workspace/file.ts'), '', 'ts').document;
collection.define(IWorkspaceService, new SyncDescriptor(TestWorkspaceService, [[workspaceFolder], [tsDoc]]));
collection.define(ILanguageDiagnosticsService, new TestLanguageDiagnosticsService());
const fileSystemService = new MockFileSystemService();
collection.define(IFileSystemService, fileSystemService);
accessor = collection.createTestingAccessor();
tool = accessor.get(IInstantiationService).createInstance(GetErrorsTool);
});

afterEach(() => {
accessor.dispose();
});

test.skipIf(isWindows)('non-Windows: returns the same URI instance for any path shape', () => {
const uris = [
URI.parse('file:///C:/foo/bar.ts'),
URI.parse('file:///c:/foo/bar.ts'),
URI.parse('file:///foo/bar.ts'),
URI.parse('file:///'),
];
for (const uri of uris) {
expect(tool.normalizeDriveLetter(uri)).toBe(uri);
}
});

test.skipIf(!isWindows)('Windows: lowercases an uppercase drive letter', () => {
const uri = URI.parse('file:///C:/foo/bar.ts');
const result = tool.normalizeDriveLetter(uri);
expect(result.path).toBe('/c:/foo/bar.ts');
});

test.skipIf(!isWindows)('Windows: returns same URI instance when drive letter is already lowercase', () => {
const uri = URI.parse('file:///c:/foo/bar.ts');
expect(tool.normalizeDriveLetter(uri)).toBe(uri);
});

test.skipIf(!isWindows)('Windows: preserves case of path components after the drive letter', () => {
const uri = URI.parse('file:///C:/MyProject/SrcFile.ts');
const result = tool.normalizeDriveLetter(uri);
expect(result.path).toBe('/c:/MyProject/SrcFile.ts');
});

test.skipIf(!isWindows)('Windows: returns URI unchanged when path has no drive letter', () => {
const uri = URI.parse('file:///foo/bar.ts');
expect(tool.normalizeDriveLetter(uri)).toBe(uri);
});

test.skipIf(!isWindows)('Windows: returns URI unchanged when path is too short to contain a drive letter', () => {
const uri = URI.parse('file:///c');
expect(tool.normalizeDriveLetter(uri)).toBe(uri);
});

test.skipIf(!isWindows)('Windows: returns URI unchanged for root path', () => {
const uri = URI.parse('file:///');
expect(tool.normalizeDriveLetter(uri)).toBe(uri);
});

test.skipIf(!isWindows)('Windows: normalizes drive letter when path ends right after the colon', () => {
const uri = URI.parse('file:///C:');
const result = tool.normalizeDriveLetter(uri);
expect(result.path).toBe('/c:');
});

test.skipIf(!isWindows)('Windows: returns URI unchanged when drive position holds a non-letter character', () => {
// digit at drive position — `toLowerCase()` is a no-op so the URI is returned as-is
const uri = URI.parse('file:///1:/foo/bar.ts');
expect(tool.normalizeDriveLetter(uri)).toBe(uri);
});
});