lingest
Version:
Generates a single Markdown file by concatenating all text files recursively from the current directory. Ideal for LLM context from local projects.
525 lines (485 loc) • 13.5 kB
JavaScript
const fs = require("fs");
const path = require("path");
const yargs = require("yargs/yargs");
const { hideBin } = require("yargs/helpers");
const micromatch = require("micromatch");
const DEFAULT_OUTPUT_FILENAME = "lingest_output.md";
const DEFAULT_IGNORE_DIRS = [
"node_modules",
".git",
"__pycache__",
".pytest_cache",
".tox",
".nox",
".mypy_cache",
".ruff_cache",
".hypothesis",
"bower_components",
".npm",
".yarn",
".pnpm-store",
".gradle",
"build",
".settings",
".build",
".bundle",
"vendor/bundle",
"target",
"pkg",
"obj",
"packages",
"bin",
".svn",
".hg",
"venv",
".venv",
"env",
".env",
"virtualenv",
".idea",
".vscode",
".vs",
".cache",
".sass-cache",
"dist",
"out",
"site-packages",
".docusaurus",
".next",
".nuxt",
".terraform",
"vendor",
"xcuserdata",
];
const DEFAULT_IGNORE_FILES = [
// Python
"*.pyc",
"*.pyo",
"*.pyd",
".coverage",
"poetry.lock",
"Pipfile.lock",
// JavaScript/Node
"package-lock.json",
"yarn.lock",
"bun.lock",
"bun.lockb",
// Java
"*.class",
"*.jar",
"*.war",
"*.ear",
"*.nar",
".classpath",
"gradle-app.setting",
"*.gradle",
".project",
// C/C++
"*.o",
"*.obj",
"*.dll",
"*.dylib",
"*.exe",
"*.lib",
"*.out",
"*.a",
"*.pdb",
// Swift/Xcode
"*.xcodeproj",
"*.xcworkspace",
"*.pbxuser",
"*.mode1v3",
"*.mode2v3",
"*.perspectivev3",
"*.xcuserstate",
".swiftpm",
// Ruby
"*.gem",
"Gemfile.lock",
".ruby-version",
".ruby-gemset",
".rvmrc",
// Rust
"Cargo.lock",
"**/*.rs.bk",
// .NET/C#
"*.suo",
"*.user",
"*.userosscache",
"*.sln.docstates",
"*.nupkg",
// Version control
".gitignore",
".gitattributes",
".gitmodules",
// Images and media
"*.svg",
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.ico",
"*.pdf",
"*.mov",
"*.mp4",
"*.mp3",
"*.wav",
// IDEs and editors
"*.swo",
"*.swn",
"*.sublime-*",
// Temporary and cache files
"*.log",
"*.bak",
"*.swp",
"*.tmp",
"*.temp",
".eslintcache",
".DS_Store",
"Thumbs.db",
"desktop.ini",
// Build artifacts
"*.egg-info",
"*.egg",
"*.whl",
"*.so",
// Minified files and source maps
"*.min.js",
"*.min.css",
"*.map",
// Terraform
"*.tfstate*",
// Gitingest
"digest.txt",
]; // Output file will be handled separately
const argv = yargs(hideBin(process.argv))
.usage("Usage: $0 [options]")
.option("o", {
alias: "output",
describe: "Output file name",
type: "string",
default: DEFAULT_OUTPUT_FILENAME,
})
.option("i", {
alias: "ignore",
describe:
"Comma-separated list of glob patterns to ignore (files or directories)",
type: "string",
default: "",
})
.option("n", {
alias: "include",
describe:
"Comma-separated list of glob patterns to include. If specified, only these will be processed (still respects ignore patterns).",
type: "string",
default: "",
})
.option("f", {
alias: "force",
describe: "Force overwrite of the output file if it already exists",
type: "boolean",
default: false,
})
.option("q", {
alias: "quiet",
describe: "Suppress informational messages, only show errors",
type: "boolean",
default: false,
})
.option("dry-run", {
describe: "Show what would be done without writing the file",
type: "boolean",
default: false,
})
.option("no-tree", {
describe:
"Skip generating the directory tree structure at the beginning of the output",
type: "boolean",
default: false,
})
.help("h")
.alias("h", "help")
.version()
.alias("v", "version")
.epilog("Copyright 2024 Your Name").argv;
const CWD = process.cwd();
const OUTPUT_FILENAME = argv.output;
const OUTPUT_FILE_PATH = path.resolve(CWD, OUTPUT_FILENAME); // Use resolve for absolute path
const userIgnorePatterns = argv.ignore
? argv.ignore
.split(",")
.map((p) => p.trim())
.filter((p) => p)
: [];
const userIncludePatterns = argv.include
? argv.include
.split(",")
.map((p) => p.trim())
.filter((p) => p)
: [];
// Logger function
function log(message, level = "info") {
if (argv.quiet && level === "info") {
return;
}
if (level === "warn") {
console.warn(message);
} else if (level === "error") {
console.error(message);
} else {
console.log(message);
}
}
// Function to generate directory tree structure
function generateDirectoryTree(effectiveIgnorePatterns) {
const tree = [];
const processedPaths = new Set();
function buildTree(currentPath, prefix = "", isLast = true) {
const relativePath = path.relative(CWD, currentPath).replace(/\\/g, "/");
// Skip if already processed or if it's the root
if (processedPaths.has(currentPath) || currentPath === CWD) {
// For root, just process children
if (currentPath === CWD) {
let entries;
try {
entries = fs.readdirSync(currentPath, { withFileTypes: true });
} catch (error) {
return;
}
// Filter and sort entries
const validEntries = entries
.filter((entry) => {
const fullPath = path.join(currentPath, entry.name);
const relativePathForMatch = path
.relative(CWD, fullPath)
.replace(/\\/g, "/");
// Skip output file
if (path.resolve(fullPath) === OUTPUT_FILE_PATH) {
return false;
}
// Check ignore patterns
return !micromatch.isMatch(
relativePathForMatch,
effectiveIgnorePatterns
);
})
.sort((a, b) => {
// Directories first, then files, both alphabetically
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
validEntries.forEach((entry, index) => {
const isLastEntry = index === validEntries.length - 1;
buildTree(path.join(currentPath, entry.name), "", isLastEntry);
});
}
return;
}
processedPaths.add(currentPath);
const name = path.basename(currentPath);
const connector = isLast ? "└── " : "├── ";
tree.push(prefix + connector + name);
// If it's a directory, process its contents
let stat;
try {
stat = fs.statSync(currentPath);
} catch (error) {
return;
}
if (stat.isDirectory()) {
let entries;
try {
entries = fs.readdirSync(currentPath, { withFileTypes: true });
} catch (error) {
return;
}
// Filter and sort entries
const validEntries = entries
.filter((entry) => {
const fullPath = path.join(currentPath, entry.name);
const relativePathForMatch = path
.relative(CWD, fullPath)
.replace(/\\/g, "/");
// Skip output file
if (path.resolve(fullPath) === OUTPUT_FILE_PATH) {
return false;
}
// Check ignore patterns
return !micromatch.isMatch(
relativePathForMatch,
effectiveIgnorePatterns
);
})
.sort((a, b) => {
// Directories first, then files, both alphabetically
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
const newPrefix = prefix + (isLast ? " " : "│ ");
validEntries.forEach((entry, index) => {
const isLastEntry = index === validEntries.length - 1;
buildTree(path.join(currentPath, entry.name), newPrefix, isLastEntry);
});
}
}
buildTree(CWD);
return tree.join("\n");
}
function generateMarkdown() {
log(`Starting lingest in directory: ${CWD}`);
if (userIncludePatterns.length > 0) {
log(`Including files matching: ${userIncludePatterns.join(", ")}`);
}
const effectiveIgnorePatterns = [
...DEFAULT_IGNORE_DIRS.map((dir) => `**/${dir}/**`), // Ignore directory contents
...DEFAULT_IGNORE_DIRS.map((dir) => `**/${dir}`), // Ignore directory itself
...DEFAULT_IGNORE_FILES.map((file) => `**/${file}`),
`**/${path.basename(OUTPUT_FILE_PATH)}`, // Always ignore the output file itself using its basename
...userIgnorePatterns,
];
log(`Ignoring patterns: ${effectiveIgnorePatterns.join(", ")}`);
if (!argv.force && !argv.dryRun && fs.existsSync(OUTPUT_FILE_PATH)) {
log(
`Error: Output file "${OUTPUT_FILE_PATH}" already exists. Use -f or --force to overwrite.`,
"error"
);
process.exit(1);
}
let allFileContents = [];
let processedFilesCount = 0;
function processPath(currentPath) {
let entries;
try {
entries = fs.readdirSync(currentPath, { withFileTypes: true });
} catch (error) {
log(
`Could not read directory ${currentPath}: ${error.message}. Skipping.`,
"warn"
);
return;
}
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
// Get relative path for matching, always use forward slashes for micromatch
const relativePathForMatch = path
.relative(CWD, fullPath)
.replace(/\\/g, "/");
// Skip the output file itself explicitly by absolute path comparison
if (path.resolve(fullPath) === OUTPUT_FILE_PATH) {
continue;
}
// Check against ignore patterns
if (micromatch.isMatch(relativePathForMatch, effectiveIgnorePatterns)) {
log(
`Ignoring (due to ignore pattern): ${relativePathForMatch}`,
"info"
);
continue;
}
if (entry.isDirectory()) {
processPath(fullPath);
} else if (entry.isFile()) {
// If include patterns are specified, the file must match at least one
if (
userIncludePatterns.length > 0 &&
!micromatch.isMatch(relativePathForMatch, userIncludePatterns)
) {
log(
`Skipping (not in include patterns): ${relativePathForMatch}`,
"info"
);
continue;
}
const relativePathForHeader = path
.relative(CWD, fullPath)
.replace(/\\/g, "/");
const header = `================================================\nFILE: ${relativePathForHeader}\n================================================\n`;
if (argv.dryRun) {
log(`[Dry Run] Would process file: ${relativePathForHeader}`);
allFileContents.push(
header +
`[Dry Run] Content of ${relativePathForHeader} would be here.\n`
);
processedFilesCount++;
continue;
}
let fileContentStr;
try {
fileContentStr = fs.readFileSync(fullPath, "utf-8");
allFileContents.push(header + fileContentStr);
log(`Processed file: ${relativePathForHeader}`);
processedFilesCount++;
} catch (error) {
log(
`Skipping file ${relativePathForHeader}. Could not read as UTF-8 text. It might be binary or an encoding issue.`,
"warn"
);
allFileContents.push(
header +
`[Content not included: Could not be read as UTF-8 text. It might be a binary file, have an unsupported encoding, or there might be a permission issue.]\n`
);
}
}
}
}
processPath(CWD);
// Generate directory tree (if not disabled)
let treeHeader = "";
if (!argv.noTree) {
log("Generating directory tree...");
const directoryTree = generateDirectoryTree(effectiveIgnorePatterns);
treeHeader = `# Directory Structure\n\n\`\`\`\n${path.basename(
CWD
)}/\n${directoryTree}\n\`\`\`\n\n# File Contents\n\n`;
}
if (argv.dryRun) {
log(`\n[Dry Run] Summary:`);
log(`[Dry Run] Would process ${processedFilesCount} file(s).`);
log(`[Dry Run] Output would be saved to: ${OUTPUT_FILE_PATH}`);
if (!argv.noTree) {
log(`[Dry Run] Directory tree would be included at the beginning.`);
} else {
log(`[Dry Run] Directory tree generation is disabled.`);
}
return;
}
if (processedFilesCount === 0) {
log(
"No files were processed based on current include/ignore rules.",
"info"
);
try {
const emptyContent =
treeHeader +
"No files found or processed based on current include/ignore rules.";
fs.writeFileSync(OUTPUT_FILE_PATH, emptyContent);
const treeMessage = argv.noTree ? "" : " with directory tree";
log(
`Generated ${OUTPUT_FILENAME}${treeMessage} (no file content processed).`
);
} catch (error) {
log(
`Failed to write empty output file ${OUTPUT_FILE_PATH}: ${error.message}`,
"error"
);
}
return;
}
const finalMarkdown = treeHeader + allFileContents.join("\n\n");
try {
fs.writeFileSync(OUTPUT_FILE_PATH, finalMarkdown);
const treeMessage = argv.noTree ? "" : " with directory tree";
log(
`Successfully generated ${OUTPUT_FILENAME}${treeMessage} and content from ${processedFilesCount} file(s).`
);
} catch (error) {
log(
`Failed to write output file ${OUTPUT_FILE_PATH}: ${error.message}`,
"error"
);
}
}
generateMarkdown();