/*
* 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 {prettierStyle, PrettierStyle} from '../style';
import {randomId} from '../../uuid';
import {getPlatformCommand} from '../../shell-utils';
/**
* Cache of loaded Prettier modules by version.
* This ensures we don't reload the same version multiple times.
*
* Use `clearPrettierModuleCache()` to clear this cache if needed
* (e.g., in long-running processes to free memory).
*/
const prettierModuleCache: Map = new Map();
/**
* Cache of pending Prettier load operations.
* Prevents concurrent requests for the same version from duplicating work.
*/
const pendingLoads: Map> = new Map();
/**
* Clears the in-memory Prettier module cache.
* Call this in long-running processes to free memory when Prettier
* versions are no longer needed.
*/
export function clearPrettierModuleCache(): void {
prettierModuleCache.clear();
pendingLoads.clear();
}
/**
* Gets the cache directory for a specific Prettier version.
* Uses ~/.cache/openrewrite/prettier//
*/
function getPrettierCacheDir(version: string): string {
const cacheBase = path.join(os.homedir(), '.cache', 'openrewrite', 'prettier');
return path.join(cacheBase, version);
}
/**
* Checks if a Prettier version is installed in the cache.
*/
function isPrettierCached(version: string): boolean {
const cacheDir = getPrettierCacheDir(version);
const prettierPath = path.join(cacheDir, 'node_modules', 'prettier', 'package.json');
if (!fs.existsSync(prettierPath)) {
return false;
}
// Verify the installed version matches
try {
const pkg = JSON.parse(fs.readFileSync(prettierPath, 'utf8'));
return pkg.version === version;
} catch {
return false;
}
}
/**
* Installs a specific Prettier version to the cache directory.
*/
async function installPrettierToCache(version: string): Promise {
const cacheDir = getPrettierCacheDir(version);
// Create directory structure
await fsp.mkdir(cacheDir, {recursive: true});
// Create minimal package.json
const packageJson = {
name: `prettier-cache-${version}`,
version: '1.0.0',
private: true,
dependencies: {
prettier: version
}
};
await fsp.writeFile(
path.join(cacheDir, 'package.json'),
JSON.stringify(packageJson, null, 2)
);
// Run npm install
const result = spawnSync(getPlatformCommand('npm'), ['install', '--silent'], {
cwd: cacheDir,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 120000, // 2 minutes
...(os.platform() === 'win32' ? { shell: true } : {})
});
if (result.error) {
throw new Error(`Failed to install Prettier ${version}: ${result.error.message}`);
}
if (result.status !== 0) {
const stderr = result.stderr?.trim() || '';
throw new Error(`Failed to install Prettier ${version}: npm exited with code ${result.status}${stderr ? '\n' + stderr : ''}`);
}
// Verify installation
if (!isPrettierCached(version)) {
throw new Error(`Prettier ${version} installation verification failed`);
}
}
/**
* Loads Prettier from the cache directory for a specific version.
*/
function loadPrettierFromCache(version: string): typeof import('prettier') {
const cacheDir = getPrettierCacheDir(version);
const prettierPath = path.join(cacheDir, 'node_modules', 'prettier');
// Clear require cache for this path to ensure fresh load
// (in case the version was reinstalled)
const resolvedPath = require.resolve(prettierPath);
delete require.cache[resolvedPath];
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require(prettierPath);
}
/**
* Result of detecting Prettier in a project.
*/
export interface PrettierDetectionResult {
/**
* Whether Prettier is available in the project.
*/
available: boolean;
/**
* The Prettier version from the project's node_modules.
*/
version?: string;
/**
* Reference to the bundled Prettier for resolving configs.
* We use our bundled version to resolve configs (stable API),
* but at formatting time, the project's version will be loaded dynamically.
*/
bundledPrettier?: typeof import('prettier');
}
/**
* Manages Prettier configuration detection and loading for a parse session.
*
* This class:
* 1. Detects if Prettier is installed in the project
* 2. Resolves Prettier config per-file (with overrides)
* 3. Caches resolved configs for marker deduplication
* 4. Creates PrettierStyle markers for source files
*
* The marker only stores the version and resolved config - at formatting time,
* the correct version of Prettier will be loaded dynamically (like npx).
*/
export class PrettierConfigLoader {
private detection?: PrettierDetectionResult;
private configCache: Map = new Map();
/**
* Creates a new PrettierConfigLoader for a project.
*
* @param projectRoot The root directory of the project (where package.json is)
*/
constructor(private readonly projectRoot: string) {}
/**
* Detects Prettier in the project and loads our bundled version for config resolution.
* Call this once at the start of parsing.
*/
async detectPrettier(): Promise {
if (this.detection) {
return this.detection;
}
this.detection = { available: false };
try {
// Get the project's Prettier version from package.json or node_modules
const version = this.getPrettierVersionFromProject();
if (!version) {
return this.detection;
}
// Load our bundled Prettier for config resolution
// We use dynamic require to handle cases where Prettier isn't installed
let bundledPrettier: typeof import('prettier');
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
bundledPrettier = require('prettier');
} catch (e) {
// Our bundled Prettier isn't available
console.warn('PrettierConfigLoader: Failed to load bundled Prettier:', e);
return this.detection;
}
this.detection = {
available: true,
version,
bundledPrettier
};
} catch {
// Any error means Prettier isn't properly set up
this.detection = { available: false };
}
return this.detection;
}
/**
* Gets the Prettier version from the project's package.json or node_modules.
* Scans upward from projectRoot to support monorepo structures where Prettier
* is installed at the repository root.
*/
private getPrettierVersionFromProject(): string | undefined {
// Scan upward from projectRoot to find Prettier
// This handles monorepos where Prettier is at the root but files are in subdirectories
let dir = this.projectRoot;
while (true) {
// First, check for prettier in node_modules (actual installation)
const prettierPackageJson = path.join(dir, 'node_modules', 'prettier', 'package.json');
if (fs.existsSync(prettierPackageJson)) {
try {
const pkg = JSON.parse(fs.readFileSync(prettierPackageJson, 'utf8'));
return pkg.version;
} catch {
// Corrupted package.json, continue scanning
}
}
// Check package.json for prettier dependency (might be a range like ^3.0.0)
const packageJsonPath = path.join(dir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const deps = {
...packageJson.dependencies,
...packageJson.devDependencies
};
if (deps.prettier) {
// Found prettier as a dependency, but node_modules wasn't found
// This might mean dependencies aren't installed yet
// Continue scanning upward in case there's a parent with node_modules
}
} catch {
// package.json parse error, continue
}
}
// Move up to parent directory
const parent = path.dirname(dir);
if (parent === dir) {
// Reached filesystem root
break;
}
dir = parent;
}
return undefined;
}
/**
* Finds the .prettierignore file by scanning upward from projectRoot.
* Returns undefined if no .prettierignore file is found.
*/
private findPrettierIgnore(): string | undefined {
let dir = this.projectRoot;
while (true) {
const ignorePath = path.join(dir, '.prettierignore');
if (fs.existsSync(ignorePath)) {
return ignorePath;
}
const parent = path.dirname(dir);
if (parent === dir) {
// Reached filesystem root
break;
}
dir = parent;
}
return undefined;
}
/**
* Gets or creates a PrettierStyle marker for the given source file.
* Returns undefined if Prettier is not available or no config applies to this file.
*
* @param filePath Absolute path to the source file
*/
async getConfigMarker(filePath: string): Promise {
if (!this.detection?.available || !this.detection.bundledPrettier) {
return undefined;
}
try {
// Resolve path against projectRoot if not absolute
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.join(this.projectRoot, filePath);
// Check if file is ignored by .prettierignore
const ignorePath = this.findPrettierIgnore();
let ignored = false;
if (ignorePath) {
const fileInfo = await this.detection.bundledPrettier.getFileInfo(absolutePath, { ignorePath });
ignored = fileInfo.ignored;
}
// Resolve config for this specific file (applies overrides)
// If no config file exists, use empty config (Prettier defaults)
const config = await this.detection.bundledPrettier.resolveConfig(absolutePath) ?? {};
// Create a cache key from the resolved config + version + ignored status
const configKey = JSON.stringify({ config, version: this.detection.version, ignored });
// Check cache for existing marker with same config
let marker = this.configCache.get(configKey);
if (marker) {
return marker;
}
// Create new PrettierStyle instance
marker = prettierStyle(randomId(), config, this.detection.version, ignored);
// Cache and return
this.configCache.set(configKey, marker);
return marker;
} catch (e) {
// Config resolution failed for this file
console.warn(`PrettierConfigLoader: Failed to resolve config for ${filePath}:`, e);
return undefined;
}
}
/**
* Clears the config cache. Call this between parse batches if needed.
*/
clearCache(): void {
this.configCache.clear();
}
}
/**
* Dynamically loads a specific version of Prettier for formatting.
*
* This function will attempt to load Prettier in this order:
* 1. From the in-memory cache (if already loaded)
* 2. From the current working directory's node_modules (if version matches)
* 3. From the cached npm project at ~/.cache/openrewrite/prettier//
* 4. Install to cache and load from there
*
* Concurrent requests for the same version are deduplicated.
*
* @param version The Prettier version to load (e.g., "3.4.2")
* @returns The loaded Prettier module
*/
export async function loadPrettierVersion(version: string): Promise {
// Check in-memory cache first
const cached = prettierModuleCache.get(version);
if (cached) {
return cached;
}
// Check if there's already a pending load for this version
const pending = pendingLoads.get(version);
if (pending) {
return pending;
}
// Create and cache the load promise to prevent concurrent duplicate loads
const loadPromise = loadPrettierVersionInternal(version);
pendingLoads.set(version, loadPromise);
try {
const prettier = await loadPromise;
prettierModuleCache.set(version, prettier);
return prettier;
} finally {
pendingLoads.delete(version);
}
}
/**
* Internal implementation of Prettier version loading.
*/
async function loadPrettierVersionInternal(version: string): Promise {
// Try to load from local node_modules if version matches
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const localPrettier = require('prettier');
if (localPrettier.version === version) {
return localPrettier;
}
} catch {
// Local prettier not available
}
// Check if version is cached on disk
if (isPrettierCached(version)) {
return loadPrettierFromCache(version);
}
// Install to cache and load
await installPrettierToCache(version);
return loadPrettierFromCache(version);
}