Skip to content

Commit 7feb127

Browse files
authored
feat: port listDirectory from VSC (#930)
* feat: port readDirectoryRecursively from VSC * test: avoid duplicate length checks * fix: account for different node versions * fix: avoid duplication in the tests * fix: implement workaround everywhere * feat: port tool with minimal changes from VSC * feat: make fatal errors optional * refactor: avoid adding errors to results * test: skip permissions test on windows * refactor: fixup implementation to follow flare agent interface * test: enable all tests * test: add type assertion to avoid error
1 parent 5b8e83c commit 7feb127

File tree

5 files changed

+172
-1
lines changed

5 files changed

+172
-1
lines changed

core/aws-lsp-core/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export * as timeoutUtils from './util/timeoutUtils'
1515
export * from './util/awsError'
1616
export * from './base/index'
1717
export * as testFolder from './test/testFolder'
18+
export * as workspaceUtils from './util/workspaceUtils'
1819
export * as processUtils from './util/processUtils'

core/aws-lsp-core/src/util/workspaceUtils.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ type Dirent = ElementType<Awaited<ReturnType<Features['workspace']['fs']['readdi
66

77
// Port of https://github.com/aws/aws-toolkit-vscode/blob/dfee9f7a400e677e91a75e9c20d9515a52a6fad4/packages/core/src/shared/utilities/workspaceUtils.ts#L699
88
export async function readDirectoryRecursively(
9-
features: Features,
9+
features: Pick<Features, 'workspace' | 'logging'> & Partial<Features>,
1010
folderPath: string,
1111
options?: {
1212
maxDepth?: number
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import * as assert from 'assert'
2+
import { Writable } from 'stream'
3+
import { ListDirectory } from './listDirectory'
4+
import { testFolder } from '@aws/lsp-core'
5+
import * as path from 'path'
6+
import * as fs from 'fs/promises'
7+
import { TestFeatures } from '@aws/language-server-runtimes/testing'
8+
import { Features } from '@aws/language-server-runtimes/server-interface/server'
9+
10+
describe('ListDirectory Tool', () => {
11+
let tempFolder: testFolder.TestFolder
12+
let testFeatures: TestFeatures
13+
14+
before(async () => {
15+
testFeatures = new TestFeatures()
16+
// @ts-ignore does not require all fs operations to be implemented
17+
testFeatures.workspace.fs = {
18+
exists: path =>
19+
fs
20+
.access(path)
21+
.then(() => true)
22+
.catch(() => false),
23+
readdir: path => fs.readdir(path, { withFileTypes: true }),
24+
} as Features['workspace']['fs']
25+
tempFolder = await testFolder.TestFolder.create()
26+
})
27+
28+
after(async () => {
29+
await tempFolder.delete()
30+
})
31+
32+
it('lists directory contents', async () => {
33+
await tempFolder.nest('subfolder')
34+
await tempFolder.write('fileA.txt', 'fileA content')
35+
36+
const listDirectory = new ListDirectory(testFeatures)
37+
const result = await listDirectory.invoke({ path: tempFolder.path, maxDepth: 0 })
38+
39+
assert.strictEqual(result.output.kind, 'text')
40+
const lines = result.output.content.split('\n')
41+
const hasFileA = lines.some((line: string | string[]) => line.includes('[FILE] ') && line.includes('fileA.txt'))
42+
const hasSubfolder = lines.some(
43+
(line: string | string[]) => line.includes('[DIR] ') && line.includes('subfolder')
44+
)
45+
46+
assert.ok(hasFileA, 'Should list fileA.txt in the directory output')
47+
assert.ok(hasSubfolder, 'Should list the subfolder in the directory output')
48+
})
49+
50+
it('lists directory contents recursively', async () => {
51+
await tempFolder.nest('subfolder')
52+
await tempFolder.write('fileA.txt', 'fileA content')
53+
await tempFolder.write(path.join('subfolder', 'fileB.md'), '# fileB')
54+
55+
const listDirectory = new ListDirectory(testFeatures)
56+
const result = await listDirectory.invoke({ path: tempFolder.path })
57+
58+
assert.strictEqual(result.output.kind, 'text')
59+
const lines = result.output.content.split('\n')
60+
const hasFileA = lines.some((line: string | string[]) => line.includes('[FILE] ') && line.includes('fileA.txt'))
61+
const hasSubfolder = lines.some(
62+
(line: string | string[]) => line.includes('[DIR] ') && line.includes('subfolder')
63+
)
64+
const hasFileB = lines.some((line: string | string[]) => line.includes('[FILE] ') && line.includes('fileB.md'))
65+
66+
assert.ok(hasFileA, 'Should list fileA.txt in the directory output')
67+
assert.ok(hasSubfolder, 'Should list the subfolder in the directory output')
68+
assert.ok(hasFileB, 'Should list fileB.md in the subfolder in the directory output')
69+
})
70+
71+
it('throws error if path does not exist', async () => {
72+
const missingPath = path.join(tempFolder.path, 'no_such_file.txt')
73+
const listDirectory = new ListDirectory(testFeatures)
74+
75+
await assert.rejects(listDirectory.invoke({ path: missingPath, maxDepth: 0 }))
76+
})
77+
78+
it('expands ~ path', async () => {
79+
const listDirectory = new ListDirectory(testFeatures)
80+
const result = await listDirectory.invoke({ path: '~', maxDepth: 0 })
81+
82+
assert.strictEqual(result.output.kind, 'text')
83+
assert.ok(result.output.content.length > 0)
84+
})
85+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// VSC Port from https://github.com/aws/aws-toolkit-vscode/blob/dfee9f7a400e677e91a75e9c20d9515a52a6fad4/packages/core/src/codewhispererChat/tools/listDirectory.ts#L18
2+
import { InvokeOutput } from './toolShared'
3+
import { workspaceUtils } from '@aws/lsp-core'
4+
import { Features } from '@aws/language-server-runtimes/server-interface/server'
5+
import { sanitize } from '@aws/lsp-core/out/util/path'
6+
7+
export interface ListDirectoryParams {
8+
path: string
9+
maxDepth?: number
10+
}
11+
12+
export class ListDirectory {
13+
private readonly logging: Features['logging']
14+
private readonly workspace: Features['workspace']
15+
16+
constructor(features: Pick<Features, 'logging' | 'workspace'>) {
17+
this.logging = features.logging
18+
this.workspace = features.workspace
19+
}
20+
21+
public async queueDescription(params: ListDirectoryParams, updates: WritableStream) {
22+
const writer = updates.getWriter()
23+
if (params.maxDepth === undefined) {
24+
await writer.write(`Listing directory recursively: ${params.path}`)
25+
} else if (params.maxDepth === 0) {
26+
await writer.write(`Listing directory: ${params.path}`)
27+
} else {
28+
const level = params.maxDepth > 1 ? 'levels' : 'level'
29+
await writer.write(`Listing directory: ${params.path} limited to ${params.maxDepth} subfolder ${level}`)
30+
}
31+
await writer.close()
32+
}
33+
34+
public async invoke(params: ListDirectoryParams): Promise<InvokeOutput> {
35+
const path = sanitize(params.path)
36+
try {
37+
const listing = await workspaceUtils.readDirectoryRecursively(
38+
{ workspace: this.workspace, logging: this.logging },
39+
path,
40+
{ maxDepth: params.maxDepth }
41+
)
42+
return this.createOutput(listing.join('\n'))
43+
} catch (error: any) {
44+
this.logging.error(`Failed to list directory "${path}": ${error.message || error}`)
45+
throw new Error(`Failed to list directory "${path}": ${error.message || error}`)
46+
}
47+
}
48+
49+
private createOutput(content: string): InvokeOutput {
50+
return {
51+
output: {
52+
kind: 'text',
53+
content: content,
54+
},
55+
}
56+
}
57+
58+
public getSpec() {
59+
return {
60+
name: 'listDirectory',
61+
description:
62+
'List the contents of a directory and its subdirectories.\n * Use this tool for discovery, before using more targeted tools like fsRead.\n *Useful to try to understand the file structure before diving deeper into specific files.\n *Can be used to explore the codebase.\n *Results clearly distinguish between files, directories or symlinks with [FILE], [DIR] and [LINK] prefixes.',
63+
inputSchema: {
64+
type: 'object',
65+
properties: {
66+
path: {
67+
type: 'string',
68+
description: 'Absolute path to a directory, e.g., `/repo`.',
69+
},
70+
maxDepth: {
71+
type: 'number',
72+
description:
73+
'Maximum depth to traverse when listing directories. Use `0` to list only the specified directory, `1` to include immediate subdirectories, etc. If it is not provided, it will list all subdirectories recursively.',
74+
},
75+
},
76+
required: ['path'],
77+
},
78+
} as const
79+
}
80+
}

server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts

+5
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import { Server } from '@aws/language-server-runtimes/server-interface'
22
import { FsRead, FsReadParams } from './fsRead'
33
import { FsWrite, FsWriteParams } from './fsWrite'
4+
import { ListDirectory, ListDirectoryParams } from './listDirectory'
45
import { ExecuteBash, ExecuteBashParams } from './executeBash'
56

67
export const FsToolsServer: Server = ({ workspace, logging, agent }) => {
78
const fsReadTool = new FsRead({ workspace, logging })
89
const fsWriteTool = new FsWrite({ workspace, logging })
910

11+
const listDirectoryTool = new ListDirectory({ workspace, logging })
12+
1013
agent.addTool(fsReadTool.getSpec(), (input: FsReadParams) => fsReadTool.invoke(input))
1114

1215
agent.addTool(fsWriteTool.getSpec(), (input: FsWriteParams) => fsWriteTool.invoke(input))
1316

17+
agent.addTool(listDirectoryTool.getSpec(), (input: ListDirectoryParams) => listDirectoryTool.invoke(input))
18+
1419
return () => {}
1520
}
1621

0 commit comments

Comments
 (0)