/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as path from "path";
import * as fs from "fs";
import * as fsp from "fs/promises";
import * as os from "os";
import {spawnSync} from "child_process";
import picomatch from "picomatch";
import {create as produce} from "mutative";
import {SourceFile} from "../tree";
import {Parsers} from "../parser";
import {PrettierConfigLoader} from "./format/prettier-config-loader";
import {ExecutionContext} from "../execution";
import {Marker, replaceMarkerByKind} from "../markers";
// Lock file names defined here to avoid circular dependency with package-manager.ts
// These must be kept in sync with the definitions in package-manager.ts
const JSON_LOCK_FILE_NAMES = ['bun.lock', 'package-lock.json'] as const;
const YAML_LOCK_FILE_NAMES = ['pnpm-lock.yaml'] as const;
const TEXT_LOCK_FILE_NAMES = ['yarn.lock'] as const;
/**
* Detects if a yarn.lock file is Yarn Berry (v2+) format based on content.
* Yarn Berry lock files contain a `__metadata:` key which is not present in Classic.
*/
function isYarnBerryLockFile(content: string): boolean {
return content.includes('__metadata:');
}
/**
* Options for project parsing.
*/
export interface ProjectParserOptions {
/**
* Optional execution context.
*/
ctx?: ExecutionContext;
/**
* Glob patterns to exclude from source file discovery.
* Default excludes: node_modules, dist, build, .git, coverage, *.min.js, *.bundle.js
*/
exclusions?: string[];
/**
* Whether to use git to discover files (respects .gitignore).
* Default: true if in a git repository.
*/
useGit?: boolean;
/**
* Progress callback for file parsing.
*/
onProgress?: (phase: "discovering" | "parsing", current: number, total: number, filePath?: string) => void;
/**
* Whether to enable verbose logging.
*/
verbose?: boolean;
/**
* Optional predicate to filter which files should be parsed.
* Called with the absolute file path after file discovery.
* Return true to include the file, false to exclude it.
* If not provided, all discovered files are parsed.
*/
fileFilter?: (absolutePath: string) => boolean;
/**
* Optional path to make source file paths relative to.
* If not specified, paths are relative to projectPath.
* Use this when parsing a subdirectory but wanting paths relative to the repository root.
*/
relativeTo?: string;
}
/**
* Result of file discovery.
*/
export interface DiscoveredFiles {
/** package.json files - parsed with PackageJsonParser for NodeResolutionResult */
packageJsonFiles: string[];
/** Lock files grouped by parser type */
lockFiles: {
json: string[]; // package-lock.json, bun.lock
yaml: string[]; // pnpm-lock.yaml, yarn.lock (Berry)
text: string[]; // yarn.lock (Classic)
};
/** JavaScript/TypeScript files (includes .prettierrc.js, prettier.config.js) */
jsFiles: string[];
/**
* JS/TS files that won't be parsed into an AST and are instead recorded as
* Quarks (path only, no content). Currently only files over
* {@link MAX_PARSEABLE_SIZE_BYTES}; other unparseable cases may join later.
*/
unparseableFiles: string[];
/** JSON files (tsconfig.json, .prettierrc.json, other .json) */
jsonFiles: string[];
/** YAML files (.prettierrc.yaml, other .yaml/.yml) */
yamlFiles: string[];
/** Plain text config files (.prettierignore, .gitignore, etc.) */
textFiles: string[];
}
/**
* Default exclusion patterns for file discovery.
*/
export const DEFAULT_EXCLUSIONS = [
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/.git/**",
"**/coverage/**",
"**/*.min.js",
"**/*.bundle.js"
];
/**
* Exclusions that apply even to files git reports. Dependency trees and minified
* bundles are sometimes committed but are never a project's own source, whereas the
* remaining entries in {@link DEFAULT_EXCLUSIONS} are what a .gitignore already says.
*/
export const DEFAULT_TRACKED_EXCLUSIONS = [
"**/node_modules/**",
"**/*.min.js",
"**/*.bundle.js"
];
/**
* Source file extensions for JavaScript/TypeScript.
*/
const SOURCE_EXTENSIONS = new Set([
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"
]);
/**
* JS/TS files larger than this are recorded as Quarks rather than parsed into a
* full AST. Matches the 1 MB default in the JVM {@code JavaScriptParser}, which
* likewise diverts oversize files off the parse path. A ~20 MB JSON-like `.js`
* otherwise balloons the TypeScript parser to multi-GB / OOM.
*/
const MAX_PARSEABLE_SIZE_BYTES = 1024 * 1024;
/**
* All lock file names for quick lookup.
*/
const ALL_LOCK_FILE_NAMES = new Set([
...JSON_LOCK_FILE_NAMES,
...YAML_LOCK_FILE_NAMES,
...TEXT_LOCK_FILE_NAMES
]);
/**
* Plain text config files (no extension).
*/
const TEXT_CONFIG_FILES = new Set([
".prettierignore",
".gitignore",
".npmignore",
".eslintignore"
]);
/**
* Parses an entire JavaScript/TypeScript project.
*
* This class handles:
* - File discovery (source files, package.json, lock files)
* - Prettier configuration detection (once per project)
* - Parsing all files with appropriate parsers
* - Sharing PrettierConfigLoader across all JavaScript parsers
*
* Can be used directly for CLI tools or wrapped by RPC handlers.
*/
export class ProjectParser {
private readonly projectPath: string;
private readonly relativeTo: string;
private readonly exclusions: string[];
private readonly gitExclusions: string[];
private readonly ctx: ExecutionContext;
private readonly useGit: boolean;
private readonly onProgress?: ProjectParserOptions["onProgress"];
private readonly verbose: boolean;
private readonly fileFilter?: (absolutePath: string) => boolean;
constructor(projectPath: string, options: ProjectParserOptions = {}) {
this.projectPath = path.resolve(projectPath);
this.relativeTo = options.relativeTo ? path.resolve(options.relativeTo) : this.projectPath;
this.exclusions = options.exclusions ?? DEFAULT_EXCLUSIONS;
this.gitExclusions = options.exclusions ?? DEFAULT_TRACKED_EXCLUSIONS;
this.ctx = options.ctx ?? new ExecutionContext();
this.useGit = options.useGit ?? this.isGitRepository();
this.onProgress = options.onProgress;
this.verbose = options.verbose ?? false;
this.fileFilter = options.fileFilter;
}
/**
* Creates and initializes a PrettierConfigLoader for this project.
* Use this when you need to handle Prettier detection separately from parsing.
*/
async createPrettierLoader(): Promise {
this.log("Detecting Prettier configuration...");
const prettierLoader = new PrettierConfigLoader(this.projectPath);
await prettierLoader.detectPrettier();
return prettierLoader;
}
/**
* Builds an Autodetect marker from the given source files.
* Samples all files to detect common formatting styles.
* Uses dynamic import to avoid circular dependencies.
*/
async buildAutodetectMarker(sourceFiles: SourceFile[]): Promise {
// Dynamic import to break circular dependency at module load time:
// parse-project.ts → project-parser.ts → autodetect.ts → visitor.ts → java → rpc → parse-project.ts
// By deferring the import until runtime, all modules are already loaded when this executes.
const {Autodetect} = await import("./autodetect.js");
const {JS} = await import("./tree.js");
const detector = Autodetect.detector();
for (const sf of sourceFiles) {
if (sf.kind === JS.Kind.CompilationUnit) {
await detector.sample(sf);
}
}
return detector.build();
}
/**
* Parses all source files in the project.
* Yields source files as they are parsed.
*/
async *parse(): AsyncGenerator {
// Discover files
this.log("Discovering files...");
this.onProgress?.("discovering", 0, 0);
let discovered = await this.discoverFiles();
// Apply file filter if provided
if (this.fileFilter) {
discovered = this.applyFileFilter(discovered);
}
const totalFiles = this.countFiles(discovered);
this.log(`Found ${totalFiles} files to parse`);
// Detect Prettier configuration once for the project
const prettierLoader = await this.createPrettierLoader();
let current = 0;
// Parse package.json files first (they get NodeResolutionResult markers)
if (discovered.packageJsonFiles.length > 0) {
this.log(`Parsing ${discovered.packageJsonFiles.length} package.json files...`);
const parser = Parsers.createParser("packageJson", {
ctx: this.ctx,
relativeTo: this.relativeTo
});
for await (const sf of parser.parse(...discovered.packageJsonFiles)) {
current++;
this.onProgress?.("parsing", current, totalFiles, sf.sourcePath);
yield sf;
}
}
// Parse JSON lock files
if (discovered.lockFiles.json.length > 0) {
this.log(`Parsing ${discovered.lockFiles.json.length} JSON lock files...`);
const parser = Parsers.createParser("json", {
ctx: this.ctx,
relativeTo: this.relativeTo
});
for await (const sf of parser.parse(...discovered.lockFiles.json)) {
current++;
this.onProgress?.("parsing", current, totalFiles, sf.sourcePath);
yield sf;
}
}
// Parse YAML lock files
if (discovered.lockFiles.yaml.length > 0) {
this.log(`Parsing ${discovered.lockFiles.yaml.length} YAML lock files...`);
const parser = Parsers.createParser("yaml", {
ctx: this.ctx,
relativeTo: this.relativeTo
});
for await (const sf of parser.parse(...discovered.lockFiles.yaml)) {
current++;
this.onProgress?.("parsing", current, totalFiles, sf.sourcePath);
yield sf;
}
}
// Parse text lock files (yarn.lock Classic)
if (discovered.lockFiles.text.length > 0) {
this.log(`Parsing ${discovered.lockFiles.text.length} text lock files...`);
const parser = Parsers.createParser("plainText", {
ctx: this.ctx,
relativeTo: this.relativeTo
});
for await (const sf of parser.parse(...discovered.lockFiles.text)) {
current++;
this.onProgress?.("parsing", current, totalFiles, sf.sourcePath);
yield sf;
}
}
// Parse JavaScript/TypeScript source files
if (discovered.jsFiles.length > 0) {
this.log(`Parsing ${discovered.jsFiles.length} JavaScript/TypeScript files...`);
const parser = Parsers.createParser("javascript", {
ctx: this.ctx,
relativeTo: this.relativeTo
});
// Check if Prettier is available
const detection = await prettierLoader.detectPrettier();
if (detection.available) {
// Prettier is available: add per-file PrettierStyle markers
for await (const sf of parser.parse(...discovered.jsFiles)) {
current++;
this.onProgress?.("parsing", current, totalFiles, sf.sourcePath);
const prettierMarker = await prettierLoader.getConfigMarker(
path.join(this.relativeTo, sf.sourcePath)
);
if (prettierMarker) {
yield produce(sf, draft => {
draft.markers = replaceMarkerByKind(draft.markers, prettierMarker);
});
} else {
yield sf;
}
}
} else {
// Prettier is NOT available: auto-detect styles from parsed files
this.log("Prettier not found, auto-detecting styles...");
// Dynamic import to break circular dependency at module load time
// (see buildAutodetectMarker for explanation)
const {Autodetect} = await import("./autodetect.js");
const {JS} = await import("./tree.js");
const parsedFiles: SourceFile[] = [];
// Parse all JS files and collect them for sampling
for await (const sf of parser.parse(...discovered.jsFiles)) {
current++;
this.onProgress?.("parsing", current, totalFiles, sf.sourcePath);
if (sf.kind === JS.Kind.CompilationUnit) {
parsedFiles.push(sf);
}
}
// Sample all parsed files and build Autodetect marker
const detector = Autodetect.detector();
for (const sf of parsedFiles) {
await detector.sample(sf);
}
const autodetectMarker = detector.build();
this.log(`Auto-detected styles: indent=${detector.getTabsAndIndentsStyle().indentSize}, ` +
`useTabs=${detector.getTabsAndIndentsStyle().useTabCharacter}`);
// Yield all files with the Autodetect marker
for (const sf of parsedFiles) {
yield produce(sf, draft => {
draft.markers = replaceMarkerByKind(draft.markers, autodetectMarker);
});
}
}
}
// Parse other YAML files
if (discovered.yamlFiles.length > 0) {
this.log(`Parsing ${discovered.yamlFiles.length} YAML files...`);
const parser = Parsers.createParser("yaml", {
ctx: this.ctx,
relativeTo: this.relativeTo
});
for await (const sf of parser.parse(...discovered.yamlFiles)) {
current++;
this.onProgress?.("parsing", current, totalFiles, sf.sourcePath);
yield sf;
}
}
// Parse other JSON files
if (discovered.jsonFiles.length > 0) {
this.log(`Parsing ${discovered.jsonFiles.length} JSON files...`);
const parser = Parsers.createParser("json", {
ctx: this.ctx,
relativeTo: this.relativeTo
});
for await (const sf of parser.parse(...discovered.jsonFiles)) {
current++;
this.onProgress?.("parsing", current, totalFiles, sf.sourcePath);
yield sf;
}
}
// Parse text config files (.prettierignore, .gitignore, etc.)
if (discovered.textFiles.length > 0) {
this.log(`Parsing ${discovered.textFiles.length} text config files...`);
const parser = Parsers.createParser("plainText", {
ctx: this.ctx,
relativeTo: this.relativeTo
});
for await (const sf of parser.parse(...discovered.textFiles)) {
current++;
this.onProgress?.("parsing", current, totalFiles, sf.sourcePath);
yield sf;
}
}
}
/**
* Discovers all files in the project that should be parsed.
*/
async discoverFiles(): Promise {
const discovered: DiscoveredFiles = {
packageJsonFiles: [],
lockFiles: {
json: [],
yaml: [],
text: []
},
jsFiles: [],
unparseableFiles: [],
jsonFiles: [],
yamlFiles: [],
textFiles: []
};
let files: string[];
if (this.useGit) {
files = await this.discoverFilesWithGit();
} else {
files = await this.discoverFilesWithWalk();
}
// Classify files
const yarnLockFiles: string[] = [];
for (const file of files) {
const basename = path.basename(file);
const ext = path.extname(file).toLowerCase();
if (basename === "package.json") {
discovered.packageJsonFiles.push(file);
} else if (SOURCE_EXTENSIONS.has(ext)) {
(this.isOversize(file) ? discovered.unparseableFiles : discovered.jsFiles).push(file);
} else if ((JSON_LOCK_FILE_NAMES as readonly string[]).includes(basename)) {
discovered.lockFiles.json.push(file);
} else if (basename === "yarn.lock") {
// yarn.lock needs content-based classification
yarnLockFiles.push(file);
} else if ((YAML_LOCK_FILE_NAMES as readonly string[]).includes(basename)) {
discovered.lockFiles.yaml.push(file);
} else if ((TEXT_LOCK_FILE_NAMES as readonly string[]).includes(basename)) {
discovered.lockFiles.text.push(file);
} else if (ext === ".json") {
discovered.jsonFiles.push(file);
} else if (ext === ".yaml" || ext === ".yml") {
discovered.yamlFiles.push(file);
} else if (TEXT_CONFIG_FILES.has(basename)) {
discovered.textFiles.push(file);
}
}
// Classify yarn.lock files by content
for (const yarnLockPath of yarnLockFiles) {
const format = await this.classifyYarnLockFile(yarnLockPath);
if (format === "yaml") {
discovered.lockFiles.yaml.push(yarnLockPath);
} else {
discovered.lockFiles.text.push(yarnLockPath);
}
}
return discovered;
}
/**
* Discovers files using git ls-files (respects .gitignore).
*/
private async discoverFilesWithGit(): Promise {
const files: string[] = [];
// Get tracked files
const tracked = spawnSync("git", ["ls-files"], {
cwd: this.projectPath,
encoding: "utf8",
...(os.platform() === 'win32' ? { shell: true } : {})
});
if (tracked.status !== 0 || tracked.error) {
// Fall back to walk if git fails
return this.discoverFilesWithWalk();
}
if (tracked.stdout) {
for (const line of tracked.stdout.split("\n")) {
const trimmed = line.trim();
if (trimmed) {
const fullPath = path.join(this.projectPath, trimmed);
// git ls-files can return deleted files that are still tracked
if (fs.existsSync(fullPath)) {
files.push(fullPath);
}
}
}
}
// Get untracked but not ignored files
const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: this.projectPath,
encoding: "utf8",
...(os.platform() === 'win32' ? { shell: true } : {})
});
if (untracked.stdout) {
for (const line of untracked.stdout.split("\n")) {
const trimmed = line.trim();
if (trimmed) {
// Untracked files should exist, but check anyway for robustness
const fullPath = path.join(this.projectPath, trimmed);
if (fs.existsSync(fullPath)) {
files.push(fullPath);
}
}
}
}
// A project inside an ignored directory is invisible to git; the walk is what
// discovers it.
if (files.length === 0) {
return this.discoverFilesWithWalk();
}
// Filter by our exclusion patterns and accepted file types
return files.filter(file => this.isAcceptedFile(file, this.gitExclusions));
}
/**
* Discovers files by walking the directory tree.
*/
private async discoverFilesWithWalk(): Promise {
const files: string[] = [];
const isExcluded = picomatch(this.exclusions);
const walk = async (dir: string, relativePath: string = "") => {
let entries: fs.Dirent[];
try {
entries = await fsp.readdir(dir, {withFileTypes: true});
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
// Check exclusion patterns
if (isExcluded(relPath) || isExcluded(`${relPath}/`)) {
continue;
}
// Always skip node_modules
if (entry.name === "node_modules") {
continue;
}
if (entry.isDirectory()) {
await walk(fullPath, relPath);
} else if (entry.isFile() && this.isAcceptedFile(fullPath)) {
files.push(fullPath);
}
}
};
await walk(this.projectPath);
return files;
}
/**
* Checks if a file should be accepted for parsing.
*/
private isAcceptedFile(filePath: string, exclusions: string[] = this.exclusions): boolean {
const basename = path.basename(filePath);
const ext = path.extname(filePath).toLowerCase();
// Check exclusion patterns with relative path
const relativePath = path.relative(this.projectPath, filePath);
const isExcluded = picomatch(exclusions);
if (isExcluded(relativePath)) {
return false;
}
// JavaScript/TypeScript files
if (SOURCE_EXTENSIONS.has(ext)) {
return true;
}
// JSON files
if (ext === ".json") {
return true;
}
// YAML files
if (ext === ".yaml" || ext === ".yml") {
return true;
}
// Lock files (some have non-standard extensions)
if (ALL_LOCK_FILE_NAMES.has(basename)) {
return true;
}
// Text config files
if (TEXT_CONFIG_FILES.has(basename)) {
return true;
}
return false;
}
/**
* Whether a source file exceeds {@link MAX_PARSEABLE_SIZE_BYTES}. A stat
* failure returns false so the file follows the normal parse path (which
* surfaces a real read error as a ParseError) rather than being quarked.
*/
private isOversize(filePath: string): boolean {
try {
return fs.statSync(filePath).size > MAX_PARSEABLE_SIZE_BYTES;
} catch {
return false;
}
}
/**
* Classifies a yarn.lock file as YAML (Berry) or text (Classic).
*/
private async classifyYarnLockFile(filePath: string): Promise<"yaml" | "text"> {
try {
const content = await fsp.readFile(filePath, "utf-8");
return isYarnBerryLockFile(content) ? "yaml" : "text";
} catch {
return "text";
}
}
/**
* Checks whether a git work tree encloses the project, which for a project below the
* repository root is an ancestor directory. A linked work tree or submodule has `.git`
* as a file, so presence rather than kind is what counts.
*/
private isGitRepository(): boolean {
let dir = this.projectPath;
while (!fs.existsSync(path.join(dir, ".git"))) {
const parent = path.dirname(dir);
if (parent === dir) {
return false;
}
dir = parent;
}
return true;
}
/**
* Counts total files to parse.
*/
private countFiles(discovered: DiscoveredFiles): number {
return (
discovered.packageJsonFiles.length +
discovered.lockFiles.json.length +
discovered.lockFiles.yaml.length +
discovered.lockFiles.text.length +
discovered.jsFiles.length +
discovered.unparseableFiles.length +
discovered.jsonFiles.length +
discovered.yamlFiles.length +
discovered.textFiles.length
);
}
/**
* Applies the file filter to discovered files.
*/
private applyFileFilter(discovered: DiscoveredFiles): DiscoveredFiles {
const filter = this.fileFilter!;
return {
packageJsonFiles: discovered.packageJsonFiles.filter(filter),
lockFiles: {
json: discovered.lockFiles.json.filter(filter),
yaml: discovered.lockFiles.yaml.filter(filter),
text: discovered.lockFiles.text.filter(filter)
},
jsFiles: discovered.jsFiles.filter(filter),
unparseableFiles: discovered.unparseableFiles.filter(filter),
jsonFiles: discovered.jsonFiles.filter(filter),
yamlFiles: discovered.yamlFiles.filter(filter),
textFiles: discovered.textFiles.filter(filter)
};
}
/**
* Logs a message if verbose mode is enabled.
*/
private log(message: string): void {
if (this.verbose) {
console.log(message);
}
}
}