Skip to content

Commit

Permalink
Impl. batch processing to ensure array in getDirectoryStructure metho…
Browse files Browse the repository at this point in the history
…d doesn't exceed max call stack size; add tests and
  • Loading branch information
angusryer committed Jul 14, 2024
1 parent 160c332 commit e337506
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 60 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ fsvz is a lightweight, dependency-free command-line tool built with Node.js for
## Features

- **No External Dependencies**: Built purely with Node.js built-in modules.
- **Customizable Output**: Choose between simple and fancy tree-like outputs. Fancy is default.
- **Customizable Output**: Choose between simple and fancy tree-like console outputs, or export to JSON or CSV.
- **Pattern Ignoring**: Ability to ignore files and directories based on a provided glob pattern.
- **JSON & CSV Output**: Export the directory/file structure as JSON or CSV.

## Installation

Expand All @@ -35,6 +34,8 @@ To display the directory structure of the current directory, simply type:
fsvz
```

**NOTE**: For deeply nested hierarchies, it will take a while to display the entire structure. Although I've tested it with a few thousand files and directories, I have not tested it with extremely large directories. If you run into any issues, please let me know by opening an issue.

### Fancy and Simple Outputs to the CLI

For an output that uses dashes instead of an ASCII tree-like structure, use the `--simple` or `-s` option:
Expand Down
176 changes: 119 additions & 57 deletions fsvz.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,75 +222,134 @@ function convertToCSV(data) {
return csv.join("\n");
}

function getDirectoryStructure(dir, options, level = 0, prefix = "") {
function formatStructure(structure, options) {
const formatItem = (item, prefix) => {
let linePrefix = options.simple ? "- " : prefix;
switch (item.type) {
case "directory":
return `${linePrefix}${dirColor}${item.name}${resetColor}/`;
case "file":
return `${linePrefix}${item.name}`;
case "symbolic link":
return `${linePrefix}${linkColor}${item.name}${resetColor} [symbolic link -> ${item.target}]`;
default:
return `${linePrefix}${item.name}`;
}
};

let result = [];
let files;
try {
files = fs.readdirSync(dir).sort();
} catch (error) {
console.error(`Error reading directory ${dir}: ${error.message}`);
return result;
let stack = [{ items: structure, prefix: "", level: 0 }];

while (stack.length > 0) {
let { items, prefix, level } = stack.pop();

items.forEach((item, index) => {
const isLast = index === items.length - 1;
const lastSymbol = isLast ? "└── " : "├── ";
result.push(formatItem(item, prefix + (options.simple ? "- " : lastSymbol)));

if (item.type === "directory" && item.children) {
const newPrefix = isLast ? " " : "│ ";
stack.push({
items: item.children,
prefix: prefix + newPrefix,
level: level + 1,
});
}
});
}

files.forEach((file, index) => {
// Skip the current and parent directory, and any files that match the ignore pattern provided
if (file === "." || file === ".." || options.ignorePattern?.test(file)) return;
return result.join("\n");
}

const filePath = path.join(dir, file);
function flattenStructure(structure) {
let result = [];
let stack = [...structure];

while (stack.length > 0) {
let item = stack.pop();
result.push(item);
if (item.type === "directory" && item.children) {
stack.push(...item.children);
}
}

return result;
}

function getDirectoryStructure(rootDir, options, maxDepth = 10) {
let result = [];
let stack = [{ dir: rootDir, level: 0 }];
let visitedDirs = new Set();

// Get the file stats (symlink, directory, file)
let stats;
while (stack.length > 0) {
let { dir, level } = stack.pop();

if (visitedDirs.has(dir)) {
continue;
}
visitedDirs.add(dir);

if (level > maxDepth) {
continue;
}

let files;
try {
stats = fs.lstatSync(filePath);
if (!fs.existsSync(dir)) {
continue;
}
files = fs.readdirSync(dir).sort();
} catch (error) {
console.error(`Error reading file ${filePath}: ${error.message}`);
return;
console.error(`Error reading directory ${dir}: ${error.message}`);
continue;
}

const isLast = index === files.length - 1;
const lastSymbol = isLast ? "└── " : "├── "; // ASCII lines!
let children = [];
files.forEach((file) => {
if (file === "." || file === ".." || options.ignorePattern?.test(file)) return;

let linePrefix = prefix + (options.simple ? "- " : lastSymbol); // default to ASCII lines
const filePath = path.join(dir, file);
let stats;

if (options.dirsOnly && !stats.isDirectory()) return;

if (stats.isSymbolicLink()) {
let targetPath;
try {
targetPath = fs.readlinkSync(filePath);
stats = fs.lstatSync(filePath);
} catch (error) {
targetPath = "unresolved";
console.error(`Error reading file ${filePath}: ${error.message}`);
return;
}

result.push({
name: file,
type: "symbolic link",
target: targetPath,
display: `${linePrefix}${linkColor}${file}${resetColor} [symbolic link -> ${targetPath}]`,
});
} else if (stats.isDirectory()) {
result.push({
name: file,
type: "directory",
display: `${linePrefix}${dirColor}${file}${resetColor}/`,
});
const newPrefix = isLast ? " " : "│ ";
result.push(
...getDirectoryStructure(
filePath,
options,
level + 1,
prefix + (options.simple ? newPrefix : " ")
)
);
} else {
result.push({
name: file,
type: "file",
display: `${linePrefix}${file}`,
});
}
});
if (options.dirsOnly && !stats.isDirectory()) return;

if (stats.isSymbolicLink()) {
let targetPath;
try {
targetPath = fs.readlinkSync(filePath);
} catch (error) {
targetPath = "unresolved";
}

children.push({
name: file,
type: "symbolic link",
target: targetPath,
});
} else if (stats.isDirectory()) {
children.push({
name: file,
type: "directory",
children: getDirectoryStructure(filePath, options, maxDepth - 1),
});
} else {
children.push({
name: file,
type: "file",
});
}
});

result = result.concat(children);
}

return result;
}
Expand Down Expand Up @@ -327,18 +386,19 @@ function main() {
);
}
} else if (options.csvOutput) {
output = convertToCSV(structure);
const flattenedStructure = flattenStructure(structure);
output = convertToCSV(flattenedStructure);
if (options.csvOutput) {
fs.writeFileSync(
options.csvOutput.endsWith(".csv") ? options.csvOutput : options.csvOutput + ".csv",
stripAnsiCodes(output)
);
}
} else if (options.rawOutput) {
output = structure.map((item) => item.display).join("\n");
output = formatStructure(structure, options);
fs.writeFileSync(options.rawOutput, stripAnsiCodes(output));
} else {
output = structure.map((item) => item.display).join("\n");
output = formatStructure(structure, options);
console.log(output);
}
}
Expand All @@ -352,6 +412,8 @@ if (require.main === module) {
getOptions,
globToRegex,
getDirectoryStructure,
formatStructure,
flattenStructure,
convertToCSV,
stripAnsiCodes,
};
Expand Down
97 changes: 97 additions & 0 deletions fsvz.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,100 @@ describe("getOptions", () => {
exitSpy.mockRestore();
});
});

describe("getDirectoryStructure", () => {
const fs = require("fs");
const path = require("path");
const tmpDir = path.join(__dirname, "tmp");
const { getDirectoryStructure } = require("./fsvz");

beforeAll(() => {
// Create a temporary directory structure
fs.mkdirSync(path.join(tmpDir, "dir"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "dir", "file1.txt"), "");
fs.writeFileSync(path.join(tmpDir, "dir", "file2.txt"), "");
fs.mkdirSync(path.join(tmpDir, "dir", "subdir"), { recursive: true });
fs.mkdirSync(path.join(tmpDir, "dir", "subdir2"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "dir", "subdir2", "file3.txt"), "");
fs.symlinkSync(path.join(tmpDir, "target"), path.join(tmpDir, "dir", "symlink"));
});

afterAll(() => {
// Remove the temporary directory
fs.rmSync(tmpDir, { recursive: true });
});

test("returns an empty array for an empty directory", () => {
fs.mkdirSync(path.join(tmpDir, "emptydir"), { recursive: true });
const structure = getDirectoryStructure(path.join(tmpDir, "emptydir"), {});
expect(structure).toEqual([]);
fs.rmdirSync(path.join(tmpDir, "emptydir"));
});

test("returns the correct structure for a directory with files and subdirectories", () => {
const structure = getDirectoryStructure(path.join(tmpDir, "dir"), {});

const expectedStructure = [
{
name: "file1.txt",
type: "file",
},
{
name: "file2.txt",
type: "file",
},
{
name: "subdir",
type: "directory",
children: [],
},
{
name: "subdir2",
type: "directory",
children: [
{
name: "file3.txt",
type: "file",
},
],
},
{
name: "symlink",
type: "symbolic link",
target: path.join(tmpDir, "target"),
},
];

expect(structure).toMatchObject(expectedStructure);
});

test("excludes files when 'dirsOnly' option is true", () => {
const options = { dirsOnly: true };
const structure = getDirectoryStructure(path.join(tmpDir, "dir"), options);
const expectedStructure = [
{ name: "subdir", type: "directory" },
{ name: "subdir2", type: "directory" },
];

expectedStructure.forEach((expectedItem) => {
const item = structure.find(
(i) => i.name === expectedItem.name && i.type === expectedItem.type
);
expect(item).toMatchObject(expectedItem);
});
});

test("handles symbolic links correctly", () => {
const structure = getDirectoryStructure(path.join(tmpDir, "dir"), {});
const expectedItem = {
name: "symlink",
type: "symbolic link",
target: path.join(tmpDir, "target"),
};

const item = structure.find(
(i) => i.name === expectedItem.name && i.type === expectedItem.type
);
expect(item).toMatchObject(expectedItem);
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fsvz",
"version": "1.0.6",
"version": "1.0.7",
"description": "A CLI utility to generate a console visualization, CSV or JSON output of a specified directory structure and the files therein.",
"main": "fsvz.js",
"bin": {
Expand Down

0 comments on commit e337506

Please sign in to comment.