/*
* 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 {AfterRecipeText, dedentAfter, SourceSpec} from "../test";
import {JavaScriptParser} from "./parser";
import {PackageJsonParser} from "./package-json-parser";
import {JS} from "./tree";
import ts from 'typescript';
import {json, Json} from "../json";
import {DependencyWorkspace} from "./dependency-workspace";
import {setTemplateSourceFileCache} from "./templating/engine";
import {PrettierConfigLoader} from "./format/prettier-config-loader";
import {create as produce} from "mutative";
import * as fs from "fs";
import * as path from "path";
import {Autodetect} from "./autodetect";
import {Marker, replaceMarkerByKind} from "../markers";
/**
* Shared TypeScript source file cache for test parsers.
* Automatically configured for template parsing in tests.
*/
export const sourceFileCache: Map = new Map();
// Automatically enable sourceFileCache for template parsing in tests
setTemplateSourceFileCache(sourceFileCache);
export async function* npm(relativeTo: string, ...sourceSpecs: SourceSpec[]): AsyncGenerator, void, unknown> {
// Ensure the target directory exists
if (!fs.existsSync(relativeTo)) {
fs.mkdirSync(relativeTo, { recursive: true });
}
// Find package.json and package-lock.json specs
const packageJsonSpec = sourceSpecs.find(spec => spec.path === 'package.json');
const packageLockSpec = sourceSpecs.find(spec => spec.path === 'package-lock.json');
// Write package-lock.json first (if provided) so PackageJsonParser can read it
if (packageLockSpec && packageLockSpec.before) {
fs.writeFileSync(
path.join(relativeTo, 'package-lock.json'),
packageLockSpec.before
);
}
// Write non-JS/TS/JSON files to disk FIRST so Prettier config detection can find them
// Exclude all package.json files (root and workspace members) - they're handled separately
for (const spec of sourceSpecs) {
const isPackageJson = spec.path?.endsWith('package.json');
const isPackageLock = spec.path === 'package-lock.json';
const isJsTs = spec.kind === JS.Kind.CompilationUnit;
if (!isPackageJson && !isPackageLock && !isJsTs) {
if (spec.before && spec.path) {
const filePath = path.join(relativeTo, spec.path);
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, spec.before);
}
}
}
// Collect all package.json specs (root and workspace members)
const allPackageJsonSpecs = sourceSpecs.filter(spec => spec.path?.endsWith('package.json'));
// Yield package.json FIRST so its PackageJsonParser is used for all JSON specs
// (The test framework uses the first spec's parser for all specs of the same kind)
if (packageJsonSpec) {
// Parse package.json to check if there are dependencies or workspaces
const packageJsonContent = JSON.parse(packageJsonSpec.before!);
const hasDependencies = Object.keys({
...packageJsonContent.dependencies,
...packageJsonContent.devDependencies
}).length > 0;
const hasWorkspaces = Array.isArray(packageJsonContent.workspaces) && packageJsonContent.workspaces.length > 0;
// Get or create cached workspace with node_modules
// If packageLockSpec is provided, use it for deterministic installs with npm ci
// For workspaces, include all workspace member package.json files
if (hasDependencies || hasWorkspaces) {
// Build workspace packages map for DependencyWorkspace
const workspacePackages: Record | undefined = hasWorkspaces
? Object.fromEntries(
allPackageJsonSpecs
.filter(spec => spec.path !== 'package.json' && spec.before)
.map(spec => [spec.path!, spec.before!])
)
: undefined;
const cachedWorkspace = await DependencyWorkspace.getOrCreateWorkspace({
packageJsonContent: packageJsonSpec.before!,
packageLockContent: packageLockSpec?.before ?? undefined,
workspacePackages
});
// Symlink node_modules from cached workspace to test directory
const cachedNodeModules = path.join(cachedWorkspace, 'node_modules');
const testNodeModules = path.join(relativeTo, 'node_modules');
// Remove existing node_modules if present
if (fs.existsSync(testNodeModules)) {
fs.rmSync(testNodeModules, { recursive: true, force: true });
}
// Create symlink
fs.symlinkSync(cachedNodeModules, testNodeModules, 'junction');
}
// Write the actual package.json from the test spec
fs.writeFileSync(
path.join(relativeTo, 'package.json'),
packageJsonSpec.before!
);
// Use PackageJsonParser to parse and attach NodeResolutionResult marker
yield {
...packageJsonSpec,
parser: () => new PackageJsonParser({relativeTo})
};
}
// Write and yield workspace member package.json files with PackageJsonParser
for (const spec of allPackageJsonSpecs) {
if (spec.path !== 'package.json') {
// Write workspace member package.json to disk
if (spec.before && spec.path) {
const filePath = path.join(relativeTo, spec.path);
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, spec.before);
}
yield {
...spec,
parser: () => new PackageJsonParser({relativeTo})
};
}
}
// Yield package-lock.json after package.json (so PackageJsonParser is used as the group parser)
if (packageLockSpec) {
yield packageLockSpec;
}
// Detect Prettier configuration for the project
const prettierLoader = new PrettierConfigLoader(relativeTo);
const detection = await prettierLoader.detectPrettier();
// Collect JS/TS specs for potential auto-detection
const jsSpecs = sourceSpecs.filter(
spec => spec.path !== 'package.json' && spec.path !== 'package-lock.json' && spec.kind === JS.Kind.CompilationUnit
);
// Build style marker: either PrettierStyle per-file or Autodetect for all
let autodetectMarker: Marker | null = null;
if (!detection.available && jsSpecs.length > 0) {
// Prettier is NOT available: auto-detect styles from the source files
// Pre-parse all JS specs to sample them
const detector = Autodetect.detector();
const tempParser = new JavaScriptParser({sourceFileCache, relativeTo});
for (const spec of jsSpecs) {
if (spec.before) {
const fileName = spec.path ?? `file.${spec.ext}`;
const filePath = path.join(relativeTo, fileName);
for await (const sf of tempParser.parse({text: spec.before, sourcePath: filePath})) {
if (sf.kind === JS.Kind.CompilationUnit) {
await detector.sample(sf as JS.CompilationUnit);
}
}
}
}
autodetectMarker = detector.build();
}
for (const spec of sourceSpecs) {
const isPackageJson = spec.path?.endsWith('package.json');
const isPackageLock = spec.path === 'package-lock.json';
if (!isPackageJson && !isPackageLock) {
if (spec.kind === JS.Kind.CompilationUnit) {
// For JS/TS files, use a parser that adds style marker if available
// spec.path may be undefined, so generate a reasonable path from the extension
const fileName = spec.path ?? `file.${spec.ext}`;
const filePath = path.join(relativeTo, fileName);
// Get the appropriate style marker
let styleMarker: Marker | undefined;
if (detection.available) {
// Use per-file PrettierStyle marker
styleMarker = await prettierLoader.getConfigMarker(filePath);
} else {
// Use shared Autodetect marker
styleMarker = autodetectMarker ?? undefined;
}
yield {
...spec,
parser: () => new JavaScriptParser({sourceFileCache, relativeTo}),
// Add style marker before recipe runs if available
// Compose with existing beforeRecipe if present
beforeRecipe: styleMarker ? (sf: JS.CompilationUnit) => {
const withMarker = produce(sf, draft => {
draft.markers = replaceMarkerByKind(draft.markers, styleMarker);
});
return spec.beforeRecipe ? spec.beforeRecipe(withMarker) : withMarker;
} : spec.beforeRecipe
}
} else {
// Non-JS/TS files were already written to disk above
yield spec;
}
}
}
}
export function packageJson(before: string, after?: AfterRecipeText): SourceSpec {
return {
...json(before, after),
path: 'package.json'
};
}
export function packageLockJson(before: string, after?: AfterRecipeText): SourceSpec {
return {
...json(before, after),
path: 'package-lock.json'
};
}
export function javascript(before: string | null, after?: AfterRecipeText): SourceSpec {
return {
kind: JS.Kind.CompilationUnit,
before: before,
after: dedentAfter(after),
ext: 'js',
parser: ctx => new JavaScriptParser({sourceFileCache})
};
}
export function typescript(before: string | null, after?: AfterRecipeText): SourceSpec {
return {
kind: JS.Kind.CompilationUnit,
before: before,
after: dedentAfter(after),
ext: 'ts',
parser: () => new JavaScriptParser({sourceFileCache})
};
}
export function tsx(before: string | null, after?: AfterRecipeText): SourceSpec {
return {
kind: JS.Kind.CompilationUnit,
before: before,
after: dedentAfter(after),
ext: 'tsx',
parser: () => new JavaScriptParser({sourceFileCache})
};
}
export function jsx(before: string | null, after?: AfterRecipeText): SourceSpec {
return {
kind: JS.Kind.CompilationUnit,
before: before,
after: dedentAfter(after),
ext: 'jsx',
parser: () => new JavaScriptParser({sourceFileCache})
};
}