Skip to content

Commit 5277131

Browse files
yangshunclaude
andcommitted
Cache lstatSync results to avoid redundant syscalls and move exclude note in README
Stat each directory entry once and reuse cached results across sorting, filtering, and tree building. Move the -I trailing slash documentation to after the CLI options section and add it to the options table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5cc0190 commit 5277131

2 files changed

Lines changed: 53 additions & 39 deletions

File tree

README.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Note: Symlinks are not followed.
88

99
## Quickstart
1010

11-
Instantly execute the command in your current directory via `npx`:
11+
Instantly execute the command in your current directory via `npx` / `pnpx` / `bunx` / `yarn dlx`:
1212

1313
```bash
1414
npx tree-node-cli -I "node_modules" # ignore node_modules
@@ -89,6 +89,8 @@ Options:
8989
--version Display version number
9090
```
9191

92+
**Note:** To exclude the contents of a directory while retaining the directory itself, use a trailing slash with the `-I` option (e.g., `-I "node_modules/"`). For the Node.js API, provide a regex matching the directory contents (e.g., `/node_modules\//`). See [#33](https://github.com/yangshun/tree-node-cli/issues/33) for more details.
93+
9294
## Node.js API
9395

9496
### `tree(path, options?)`
@@ -114,10 +116,15 @@ Returns the tree as a structured `TreeJsonEntry` object (or `null` if the path i
114116
```ts
115117
// Discriminated union — `contents` only exists on directories
116118
type TreeJsonEntry =
117-
| { type: 'directory'; name: string; contents: TreeJsonEntry[];
118-
size?: number; mode?: string; time?: string }
119-
| { type: 'file'; name: string;
120-
size?: number; mode?: string; time?: string }
119+
| {
120+
type: 'directory';
121+
name: string;
122+
contents: TreeJsonEntry[];
123+
size?: number;
124+
mode?: string;
125+
time?: string;
126+
}
127+
| { type: 'file'; name: string; size?: number; mode?: string; time?: string };
121128
// size: present when `sizes: true`
122129
// mode: present when `permissions: true`
123130
// time: present when `date: true`
@@ -142,21 +149,19 @@ console.log(result);
142149
| `dirsFirst` | `false` | `boolean` | List directories before files. |
143150
| `fullPath` | `false` | `boolean` | Print the full path prefix for each file. |
144151
| `noIndent` | `false` | `boolean` | Print entries without tree indentation lines. |
145-
| `permissions` | `false` | `boolean` | Show file type and permissions (e.g. `[drwxr-xr-x]`). |
152+
| `permissions` | `false` | `boolean` | Show file type and permissions (e.g. `[drwxr-xr-x]`). |
146153
| `quote` | `false` | `boolean` | Quote filenames in double quotes. |
147154
| `prune` | `false` | `boolean` | Remove empty directories from output. |
148155
| `dirsOnly` | `false` | `boolean` | List directories only. |
149156
| `sizes` | `false` | `boolean` | Show filesizes as well. |
150-
| `exclude` | `[]` | `RegExp[]` | An array of regex to test each filename against. Matching files will be excluded and matching directories will not be traversed into. |
157+
| `exclude` | `[]` | `RegExp[]` | An array of regex to test each filename against. Matching files will be excluded and matching directories will not be traversed into. To exclude a directory's contents while still showing the directory itself, use a regex that matches the path with a trailing slash (e.g., `/node_modules\//`). |
151158
| `maxDepth` | `Number.POSITIVE_INFINITY` | `number` | Max display depth of the directory tree. |
152159
| `reverse` | `false` | `boolean` | Sort the output in reverse alphabetic order. |
153160
| `trailingSlash` | `false` | `boolean` | Appends a trailing slash behind directories. |
154161
| `lineAscii` | `false` | `boolean` | Turn on ASCII line graphics. |
155162
| `filesFirst` | `false` | `boolean` | List files before directories. |
156-
| `sortBy` | `'name'` | `string` | Sort the output. Options: `'name'`, `'version'`, `'mtime'`, `'ctime'`, `'size'`. |
157-
| `unsorted` | `false` | `boolean` | Do not sort. List files in directory order. |
158-
159-
**Note:** To exclude the contents of a directory while retaining the directory itself, use a trailing slash with the `-I` option (e.g., `-I "node_modules/"`). For the Node.js API, provide a regex matching the directory contents (e.g., `/node_modules\//`). See [#33](https://github.com/yangshun/tree-node-cli/issues/33) for more details.
163+
| `sortBy` | `'name'` | `string` | Sort the output. Options: `'name'`, `'version'`, `'mtime'`, `'ctime'`, `'size'`. |
164+
| `unsorted` | `false` | `boolean` | Do not sort. List files in directory order. |
160165

161166
## License
162167

src/index.ts

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,22 @@ function isExcluded(
9999
return false;
100100
}
101101

102+
function statEntries(
103+
dirPath: string,
104+
entries: string[],
105+
): Map<string, fs.Stats> {
106+
const stats = new Map<string, fs.Stats>();
107+
108+
for (const entry of entries) {
109+
stats.set(entry, fs.lstatSync(nodePath.join(dirPath, entry)));
110+
}
111+
112+
return stats;
113+
}
114+
102115
function sortContents(
103116
contents: string[],
104-
dirPath: string,
117+
stats: Map<string, fs.Stats>,
105118
options: RequiredOptions,
106119
): string[] {
107120
if (options.unsorted) {
@@ -118,25 +131,13 @@ function sortContents(
118131
);
119132
break;
120133
case 'mtime':
121-
contents.sort(
122-
(a, b) =>
123-
fs.lstatSync(nodePath.join(dirPath, a)).mtimeMs -
124-
fs.lstatSync(nodePath.join(dirPath, b)).mtimeMs,
125-
);
134+
contents.sort((a, b) => stats.get(a)!.mtimeMs - stats.get(b)!.mtimeMs);
126135
break;
127136
case 'ctime':
128-
contents.sort(
129-
(a, b) =>
130-
fs.lstatSync(nodePath.join(dirPath, a)).ctimeMs -
131-
fs.lstatSync(nodePath.join(dirPath, b)).ctimeMs,
132-
);
137+
contents.sort((a, b) => stats.get(a)!.ctimeMs - stats.get(b)!.ctimeMs);
133138
break;
134139
case 'size':
135-
contents.sort(
136-
(a, b) =>
137-
fs.lstatSync(nodePath.join(dirPath, a)).size -
138-
fs.lstatSync(nodePath.join(dirPath, b)).size,
139-
);
140+
contents.sort((a, b) => stats.get(a)!.size - stats.get(b)!.size);
140141
break;
141142
default:
142143
contents.sort();
@@ -152,7 +153,7 @@ function sortContents(
152153

153154
function filterAndOrderContents(
154155
contents: string[],
155-
dirPath: string,
156+
stats: Map<string, fs.Stats>,
156157
options: RequiredOptions,
157158
): string[] {
158159
if (!options.allFiles) {
@@ -164,9 +165,7 @@ function filterAndOrderContents(
164165
}
165166

166167
const dirSet = new Set(
167-
contents.filter((file) =>
168-
fs.lstatSync(nodePath.join(dirPath, file)).isDirectory(),
169-
),
168+
contents.filter((file) => stats.get(file)!.isDirectory()),
170169
);
171170

172171
if (options.dirsOnly) {
@@ -187,10 +186,13 @@ function filterAndOrderContents(
187186
function getDirectoryContents(
188187
dirPath: string,
189188
options: RequiredOptions,
190-
): string[] {
191-
const contents = fs.readdirSync(dirPath);
192-
const sorted = sortContents(contents, dirPath, options);
193-
return filterAndOrderContents(sorted, dirPath, options);
189+
): { entries: string[]; stats: Map<string, fs.Stats> } {
190+
const entries = fs.readdirSync(dirPath);
191+
const stats = statEntries(dirPath, entries);
192+
const sorted = sortContents(entries, stats, options);
193+
const filtered = filterAndOrderContents(sorted, stats, options);
194+
195+
return { entries: filtered, stats };
194196
}
195197

196198
function computeSizes(dirPath: string, cache: Map<string, number>): number {
@@ -219,8 +221,9 @@ function buildTree(
219221
currentDepth: number,
220222
options: RequiredOptions,
221223
sizeCache: Map<string, number>,
224+
cachedStat?: fs.Stats,
222225
): TreeNode | null {
223-
const stat = fs.lstatSync(path);
226+
const stat = cachedStat ?? fs.lstatSync(path);
224227
const isDir = stat.isDirectory();
225228
const isFile = !isDir;
226229

@@ -242,16 +245,18 @@ function buildTree(
242245
return node;
243246
}
244247

245-
const contents = getDirectoryContents(path, options);
248+
const { entries, stats } = getDirectoryContents(path, options);
246249

247-
for (const entry of contents) {
250+
for (const entry of entries) {
248251
const child = buildTree(
249252
entry,
250253
nodePath.join(path, entry),
251254
currentDepth + 1,
252255
options,
253256
sizeCache,
257+
stats.get(entry),
254258
);
259+
255260
if (child) {
256261
node.children.push(child);
257262
}
@@ -277,6 +282,7 @@ function buildTreeNode(
277282

278283
const resolvedPath = nodePath.join(process.cwd(), path);
279284
const rootName = nodePath.basename(resolvedPath);
285+
280286
return buildTree(rootName, path, 0, options, sizeCache);
281287
}
282288

@@ -291,7 +297,10 @@ export function tree(path: string, options?: Options): string {
291297
return formatAsText(root, combinedOptions, path).join('\n');
292298
}
293299

294-
export function treeJson(path: string, options?: Options): TreeJsonEntry | null {
300+
export function treeJson(
301+
path: string,
302+
options?: Options,
303+
): TreeJsonEntry | null {
295304
const combinedOptions: RequiredOptions = { ...DEFAULT_OPTIONS, ...options };
296305
const root = buildTreeNode(path, combinedOptions);
297306

0 commit comments

Comments
 (0)