Skip to content

Commit

Permalink
feat(core): use custom resolution to resolve from source local plugin…
Browse files Browse the repository at this point in the history
…s with artifacts pointing to the outputs
  • Loading branch information
leosvelperez committed Dec 5, 2024
1 parent 625d8f3 commit d1c4fe2
Show file tree
Hide file tree
Showing 4 changed files with 243 additions and 15 deletions.
102 changes: 102 additions & 0 deletions e2e/plugin/src/nx-plugin-ts-solution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { ProjectConfiguration } from '@nx/devkit';
import {
checkFilesExist,
cleanupProject,
createFile,
newProject,
runCLI,
uniq,
updateFile,
} from '@nx/e2e/utils';
import {
ASYNC_GENERATOR_EXECUTOR_CONTENTS,
NX_PLUGIN_V2_CONTENTS,
} from './nx-plugin.fixtures';

describe('Nx Plugin (TS solution)', () => {
let workspaceName: string;

beforeAll(() => {
workspaceName = newProject({ preset: 'ts', packages: ['@nx/plugin'] });
});

afterAll(() => cleanupProject());

it('should be able to infer projects and targets', async () => {
const plugin = uniq('plugin');
runCLI(`generate @nx/plugin:plugin packages/${plugin} --linter=eslint`);

// Setup project inference + target inference
updateFile(`packages/${plugin}/src/index.ts`, NX_PLUGIN_V2_CONTENTS);

// Register plugin in nx.json (required for inference)
updateFile(`nx.json`, (nxJson) => {
const nx = JSON.parse(nxJson);
nx.plugins = [
{
plugin: `@${workspaceName}/${plugin}`,
options: { inferredTags: ['my-tag'] },
},
];
return JSON.stringify(nx, null, 2);
});

// Create project that should be inferred by Nx
const inferredProject = uniq('inferred');
createFile(
`packages/${inferredProject}/package.json`,
JSON.stringify({
name: inferredProject,
version: '0.0.1',
})
);
createFile(`packages/${inferredProject}/my-project-file`);

// Attempt to use inferred project w/ Nx
expect(runCLI(`build ${inferredProject}`)).toContain(
'custom registered target'
);
const configuration = JSON.parse(
runCLI(`show project ${inferredProject} --json`)
);
expect(configuration.tags).toContain('my-tag');
expect(configuration.metadata.technologies).toEqual(['my-plugin']);
});

it('should be able to use local generators and executors', async () => {
const plugin = uniq('plugin');
const generator = uniq('generator');
const executor = uniq('executor');
const generatedProject = uniq('project');

runCLI(`generate @nx/plugin:plugin packages/${plugin} --linter=eslint`);

runCLI(
`generate @nx/plugin:generator --name ${generator} --path packages/${plugin}/src/generators/${generator}/generator`
);

runCLI(
`generate @nx/plugin:executor --name ${executor} --path packages/${plugin}/src/executors/${executor}/executor`
);

updateFile(
`packages/${plugin}/src/executors/${executor}/executor.ts`,
ASYNC_GENERATOR_EXECUTOR_CONTENTS
);

runCLI(
`generate @${workspaceName}/${plugin}:${generator} --name ${generatedProject}`
);

updateFile(`libs/${generatedProject}/project.json`, (f) => {
const project: ProjectConfiguration = JSON.parse(f);
project.targets['execute'] = {
executor: `@${workspaceName}/${plugin}:${executor}`,
};
return JSON.stringify(project, null, 2);
});

expect(() => checkFilesExist(`libs/${generatedProject}`)).not.toThrow();
expect(() => runCLI(`execute ${generatedProject}`)).not.toThrow();
});
});
4 changes: 3 additions & 1 deletion e2e/utils/create-project-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,12 @@ export function newProject({
name = uniq('proj'),
packageManager = getSelectedPackageManager(),
packages,
preset = 'apps',
}: {
name?: string;
packageManager?: 'npm' | 'yarn' | 'pnpm' | 'bun';
readonly packages?: Array<NxPackage>;
preset?: string;
} = {}): string {
const newProjectStart = performance.mark('new-project:start');
try {
Expand All @@ -93,7 +95,7 @@ export function newProject({
'create-nx-workspace:start'
);
runCreateWorkspace(projScope, {
preset: 'apps',
preset,
packageManager,
});
const createNxWorkspaceEnd = performance.mark('create-nx-workspace:end');
Expand Down
49 changes: 49 additions & 0 deletions packages/nx/src/config/schema-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync } from 'fs';
import { extname, join } from 'path';
import { registerPluginTSTranspiler } from '../project-graph/plugins';
import { normalizePath } from '../utils/path';

/**
* This function is used to get the implementation factory of an executor or generator.
Expand Down Expand Up @@ -43,6 +44,21 @@ export function resolveImplementation(
(x) => implementationModulePath + x
);

if (!directory.includes('node_modules')) {
// It might be a local plugin where the implementation path points to the
// outputs which might not exist or can be stale. We prioritize finding
// the implementation from the source over the outputs.
for (const maybeImplementation of validImplementations) {
const maybeImplementationFromSource = tryResolveFromSource(
maybeImplementation,
directory
);
if (maybeImplementationFromSource) {
return maybeImplementationFromSource;
}
}
}

for (const maybeImplementation of validImplementations) {
const maybeImplementationPath = join(directory, maybeImplementation);
if (existsSync(maybeImplementationPath)) {
Expand All @@ -62,6 +78,16 @@ export function resolveImplementation(
}

export function resolveSchema(schemaPath: string, directory: string): string {
if (!directory.includes('node_modules')) {
// It might be a local plugin where the schema path points to the outputs
// which might not exist or can be stale. We prioritize finding the schema
// from the source over the outputs.
const schemaPathFromSource = tryResolveFromSource(schemaPath, directory);
if (schemaPathFromSource) {
return schemaPathFromSource;
}
}

const maybeSchemaPath = join(directory, schemaPath);
if (existsSync(maybeSchemaPath)) {
return maybeSchemaPath;
Expand All @@ -71,3 +97,26 @@ export function resolveSchema(schemaPath: string, directory: string): string {
paths: [directory],
});
}

function tryResolveFromSource(path: string, directory: string): string | null {
const segments = normalizePath(path).replace(/^\.\//, '').split('/');
for (let i = 1; i < segments.length; i++) {
// We try to find the path relative to the following common directories:
// - the root of the project
// - the src directory
// - the src/lib directory
const possiblePaths = [
join(directory, ...segments.slice(i)),
join(directory, 'src', ...segments.slice(i)),
join(directory, 'src', 'lib', ...segments.slice(i)),
];

for (const possiblePath of possiblePaths) {
if (existsSync(possiblePath)) {
return possiblePath;
}
}
}

return null;
}
103 changes: 89 additions & 14 deletions packages/nx/src/project-graph/plugins/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,32 +130,41 @@ function findNxProjectForImportPath(
root = workspaceRoot
): ProjectConfiguration | null {
const tsConfigPaths: Record<string, string[]> = readTsConfigPaths(root);
const possiblePaths = tsConfigPaths[importPath]?.map((p) =>
normalizePath(path.relative(root, path.join(root, p)))
);
if (possiblePaths?.length) {
const projectRootMappings: ProjectRootMappings = new Map();
const possibleTsPaths =
tsConfigPaths[importPath]?.map((p) =>
normalizePath(path.relative(root, path.join(root, p)))
) ?? [];

const projectRootMappings: ProjectRootMappings = new Map();
if (possibleTsPaths.length) {
const projectNameMap = new Map<string, ProjectConfiguration>();
for (const projectRoot in projects) {
const project = projects[projectRoot];
projectRootMappings.set(project.root, project.name);
projectNameMap.set(project.name, project);
}
for (const tsConfigPath of possiblePaths) {
for (const tsConfigPath of possibleTsPaths) {
const nxProject = findProjectForPath(tsConfigPath, projectRootMappings);
if (nxProject) {
return projectNameMap.get(nxProject);
}
}
logger.verbose(
'Unable to find local plugin',
possiblePaths,
projectRootMappings
);
throw new Error(
'Unable to resolve local plugin with import path ' + importPath
);
}

// try to resolve from the projects' package.json names
const projectName = getNameFromPackageJson(importPath, root, projects);
if (projectName) {
return projects[projectName];
}

logger.verbose(
'Unable to find local plugin',
possibleTsPaths,
projectRootMappings
);
throw new Error(
'Unable to resolve local plugin with import path ' + importPath
);
}

let tsconfigPaths: Record<string, string[]>;
Expand All @@ -174,6 +183,72 @@ function readTsConfigPaths(root: string = workspaceRoot) {
return tsconfigPaths ?? {};
}

let packageJsonMap: Record<string, string>;
let seenProjects: Set<string>;

/**
* Locate the project name from the package.json files in the provided projects.
* Progressively build up a map of package names to project names to avoid
* reading the same package.json multiple times and reading unnecessary ones.
*/
function getNameFromPackageJson(
importPath: string,
root: string = workspaceRoot,
projects: Record<string, ProjectConfiguration>
): string | null {
packageJsonMap ??= {};
seenProjects ??= new Set();

const resolveFromPackageJson = (projectName: string) => {
try {
const packageJson = readJsonFile(
path.join(root, projects[projectName].root, 'package.json')
);
packageJsonMap[packageJson.name ?? projectName] = projectName;

if (packageJsonMap[importPath]) {
// we found the importPath, we progressively build up packageJsonMap
// so we can return early
return projectName;
}
} catch {}

return null;
};

if (packageJsonMap[importPath]) {
if (!!projects[packageJsonMap[importPath]]) {
return packageJsonMap[importPath];
} else {
// the previously resolved project might have been resolved with the
// project root as the name, so we need to resolve it again to get
// the actual project name
const projectName = Object.keys(projects).find(
(p) => projects[p].root === packageJsonMap[importPath]
);
const resolvedProject = resolveFromPackageJson(projectName);
if (resolvedProject) {
return resolvedProject;
}
}
}

for (const projectName of Object.keys(projects)) {
if (seenProjects.has(projectName)) {
// we already parsed this project
continue;
}
seenProjects.add(projectName);

const resolvedProject = resolveFromPackageJson(projectName);
if (resolvedProject) {
return resolvedProject;
}
}

return null;
}

function readPluginMainFromProjectConfiguration(
plugin: ProjectConfiguration
): string | null {
Expand Down

0 comments on commit d1c4fe2

Please sign in to comment.