diff --git a/.circleci/config.yml b/.circleci/config.yml index ab30b199593f89..99d9801fb69669 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -69,6 +69,50 @@ commands: - ~/.pnpm-store - ~/.cache/Cypress - node_modules + install-sdkman: + description: Install SDKMAN + steps: + - restore_cache: + name: Restore SDKMAN executable and binaries from cache + keys: + - sdkman-cli-{{ arch }}-v2 + - run: + name: Installing SDKMAN + command: | + if [ ! -d ~/.sdkman ] + then + curl -s "https://get.sdkman.io?rcupdate=false" | bash + sed -i -e 's/sdkman_auto_answer=false/sdkman_auto_answer=true/g' ~/.sdkman/etc/config + fi + echo -e '\nsource "/home/circleci/.sdkman/bin/sdkman-init.sh"' >> $BASH_ENV + source $BASH_ENV + sdk version + - save_cache: + name: Save SDKMAN executable and binaries to cache + key: sdkman-cli-{{ arch }}-v2 + paths: + - ~/.sdkman + install-gradle: + description: Install gradle + parameters: + gradle-version: + type: string + default: '' + steps: + - restore_cache: + name: Restore Gradle binary from cache + keys: + - gradle-cli-{{ arch }}-v1 + - run: + name: Installing Gradle + command: | + sdk install gradle << parameters.gradle-version >> + gradle --version + - save_cache: + name: Save Gradle binary to cache + key: gradle-cli-{{ arch }}-v1 + paths: + - ~/.sdkman/candidates/gradle/ # ------------------------- # JOBS # ------------------------- @@ -96,6 +140,9 @@ jobs: sudo apt-get install -y ca-certificates lsof - browser-tools/install-chrome - browser-tools/install-chromedriver + - install-sdkman + - install-gradle: + gradle-version: '8.5' - run-pnpm-install: os: linux - run: diff --git a/.github/workflows/e2e-matrix.yml b/.github/workflows/e2e-matrix.yml index 83586229be10c1..44a8dad50a2b37 100644 --- a/.github/workflows/e2e-matrix.yml +++ b/.github/workflows/e2e-matrix.yml @@ -165,6 +165,8 @@ jobs: codeowners: 'S04SJ6HHP0X' - project: e2e-expo codeowners: 'S04TNCNJG5N' + - project: e2e-gradle + codeowners: 'S04TNCNJG5N' - project: e2e-jest codeowners: 'S04T16BTJJY' - project: e2e-js @@ -242,6 +244,8 @@ jobs: project: e2e-esbuild - node_version: 18 project: e2e-expo + - node_version: 18 + project: e2e-gradle - node_version: 18 project: e2e-jest - node_version: 18 diff --git a/.nx/workflows/agents.yaml b/.nx/workflows/agents.yaml index 5770b70ac35895..cef21cd42454d9 100644 --- a/.nx/workflows/agents.yaml +++ b/.nx/workflows/agents.yaml @@ -20,6 +20,8 @@ launch-templates: node_modules ~/.cache/Cypress ~/.pnpm-store + ~/.sdkman + ~/.sdkman/candidates/gradle BASE_BRANCH: 'master' - name: Install e2e deps script: | @@ -49,3 +51,21 @@ launch-templates: - name: Load Cargo Env script: echo "PATH=$HOME/.cargo/bin:$PATH" >> $NX_CLOUD_ENV + + - name: Install zip and unzip + script: sudo apt-get -yqq install zip unzip + + - name: Install SDKMAN and gradle + script: | + if [ ! -d $HOME/.sdkman ] + then + curl -s "https://get.sdkman.io" | bash + source "$HOME/.sdkman/bin/sdkman-init.sh" + fi + sdk version + if [ ! -d $HOME/.sdkman/candidates/gradle/8.5 ] + then + sdk install gradle 8.5 + fi + gradle --version + echo "PATH=$HOME/.sdkman/candidates/gradle/8.5/bin:$PATH" >> $NX_CLOUD_ENV diff --git a/CODEOWNERS b/CODEOWNERS index fa128afba8c36d..b69e2cc895bd33 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -144,6 +144,10 @@ rust-toolchain @nrwl/nx-native-reviewers /packages/devkit/public-api.ts @FrozenPandaz @vsavkin /packages/devkit/nx.ts @FrozenPandaz @vsavkin +# Gradle +/packages/gradle/** @FrozenPandaz @xiongemi +/e2e/gradle/** @FrozenPandaz @xiongemi + # Nx-Plugin /docs/generated/packages/plugin/** @nrwl/nx-devkit-reviewers @nrwl/nx-docs-reviewers /docs/shared/packages/plugin/** @nrwl/nx-devkit-reviewers @nrwl/nx-docs-reviewers diff --git a/e2e/gradle/jest.config.ts b/e2e/gradle/jest.config.ts new file mode 100644 index 00000000000000..735a04c47420f1 --- /dev/null +++ b/e2e/gradle/jest.config.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +export default { + transform: { + '^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'html'], + maxWorkers: 1, + globals: {}, + globalSetup: '../utils/global-setup.ts', + globalTeardown: '../utils/global-teardown.ts', + displayName: 'e2e-gradle', + testTimeout: 600000, + preset: '../../jest.preset.js', +}; diff --git a/e2e/gradle/project.json b/e2e/gradle/project.json new file mode 100644 index 00000000000000..c20d6f05d778ea --- /dev/null +++ b/e2e/gradle/project.json @@ -0,0 +1,10 @@ +{ + "name": "e2e-gradle", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "e2e/gradle", + "projectType": "application", + "targets": { + "e2e": {} + }, + "implicitDependencies": ["eslint"] +} diff --git a/e2e/gradle/src/gradle.test.ts b/e2e/gradle/src/gradle.test.ts new file mode 100644 index 00000000000000..8611a082028a48 --- /dev/null +++ b/e2e/gradle/src/gradle.test.ts @@ -0,0 +1,87 @@ +import { + checkFilesExist, + cleanupProject, + createFile, + e2eConsoleLogger, + newProject, + runCLI, + runCommand, + uniq, + updateFile, + updateJson, +} from '@nx/e2e/utils'; +import { execSync } from 'child_process'; + +describe('Gradle', () => { + let gradleProjectName = uniq('my-gradle-project'); + + beforeAll(() => { + newProject(); + createGradleProject(gradleProjectName); + }); + afterAll(() => cleanupProject()); + + it('should build', () => { + const projects = runCLI(`show projects`); + expect(projects).toContain('app'); + expect(projects).toContain('list'); + expect(projects).toContain('utilities'); + expect(projects).toContain(gradleProjectName); + + const buildOutput = runCLI('build app', { verbose: true }); + // app depends on list and utilities + expect(buildOutput).toContain('nx run list:build'); + expect(buildOutput).toContain('nx run utilities:build'); + + checkFilesExist( + `app/build/libs/app.jar`, + `list/build/libs/list.jar`, + `utilities/build/libs/utilities.jar` + ); + }); + + it('should track dependencies for new app', () => { + createFile( + 'app2/build.gradle.kts', + ` + plugins { + id("gradleProject.kotlin-application-conventions") + } + + dependencies { + implementation(project(":app")) + } + ` + ); + updateFile(`settings.gradle.kts`, (content) => { + content += `\r\ninclude("app2")`; + return content; + }); + const buildOutput = runCLI('build app2', { verbose: true }); + // app2 depends on app + expect(buildOutput).toContain('nx run app:build'); + }); +}); + +function createGradleProject(projectName: string) { + e2eConsoleLogger(`Using java version: ${execSync('java --version')}`); + e2eConsoleLogger(`Using gradle version: ${execSync('gradle --version')}`); + e2eConsoleLogger(execSync(`gradle help --task :init`).toString()); + e2eConsoleLogger( + runCommand( + `gradle init --type kotlin-application --dsl kotlin --project-name ${projectName} --package gradleProject --no-incubating --split-project` + ) + ); + updateJson('nx.json', (nxJson) => { + nxJson.plugins = ['@nx/gradle']; + return nxJson; + }); + createFile( + 'build.gradle.kts', + `allprojects { + apply { + plugin("project-report") + } + }` + ); +} diff --git a/e2e/gradle/tsconfig.json b/e2e/gradle/tsconfig.json new file mode 100644 index 00000000000000..6d5abf84832009 --- /dev/null +++ b/e2e/gradle/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/e2e/gradle/tsconfig.spec.json b/e2e/gradle/tsconfig.spec.json new file mode 100644 index 00000000000000..1a24bfb0a13536 --- /dev/null +++ b/e2e/gradle/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx", + "**/*.d.ts", + "jest.config.ts" + ] +} diff --git a/e2e/utils/create-project-utils.ts b/e2e/utils/create-project-utils.ts index 06b13e47167a99..6c4b6b24355e90 100644 --- a/e2e/utils/create-project-utils.ts +++ b/e2e/utils/create-project-utils.ts @@ -43,6 +43,7 @@ const nxPackages = [ `@nx/eslint-plugin`, `@nx/express`, `@nx/esbuild`, + `@nx/gradle`, `@nx/jest`, `@nx/js`, `@nx/eslint`, diff --git a/nx-dev/nx-dev/public/images/icons/gradle.svg b/nx-dev/nx-dev/public/images/icons/gradle.svg new file mode 100644 index 00000000000000..c667328188e8c2 --- /dev/null +++ b/nx-dev/nx-dev/public/images/icons/gradle.svg @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/nx-dev/ui-references/src/lib/icons-map.ts b/nx-dev/ui-references/src/lib/icons-map.ts index 6f2d811b6df3e4..6536a5870720a8 100644 --- a/nx-dev/ui-references/src/lib/icons-map.ts +++ b/nx-dev/ui-references/src/lib/icons-map.ts @@ -8,6 +8,7 @@ export const iconsMap: Record = { 'eslint-plugin': '/images/icons/eslint.svg', expo: '/images/icons/expo.svg', express: '/images/icons/express.svg', + gradle: '/images/icons/gradle.svg', jest: '/images/icons/jest.svg', js: '/images/icons/javascript.svg', eslint: '/images/icons/eslint.svg', diff --git a/packages/gradle/.eslintrc.json b/packages/gradle/.eslintrc.json new file mode 100644 index 00000000000000..52b3d2c3351891 --- /dev/null +++ b/packages/gradle/.eslintrc.json @@ -0,0 +1,37 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredDependencies": ["nx"] + } + ] + } + }, + { + "files": ["./package.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/nx-plugin-checks": "error" + } + } + ] +} diff --git a/packages/gradle/README.md b/packages/gradle/README.md new file mode 100644 index 00000000000000..7911f535eabf7a --- /dev/null +++ b/packages/gradle/README.md @@ -0,0 +1,18 @@ +

+ + + Nx - Smart Monorepos · Fast CI + +

+ +{{links}} + +
+ +# Nx: Smart Monorepos · Fast CI + +Nx is a build system with built-in tooling and advanced CI capabilities. It helps you maintain and scale monorepos, both locally and on CI. + +This package is a [Gradle plugin for Nx](https://nx.dev/gradle/overview). + +{{content}} diff --git a/packages/gradle/index.ts b/packages/gradle/index.ts new file mode 100644 index 00000000000000..1110b6451f6a13 --- /dev/null +++ b/packages/gradle/index.ts @@ -0,0 +1 @@ +export * from './plugin'; diff --git a/packages/gradle/jest.config.ts b/packages/gradle/jest.config.ts new file mode 100644 index 00000000000000..6f81e3c9f8ec72 --- /dev/null +++ b/packages/gradle/jest.config.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +export default { + transform: { + '^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'html', 'json'], + globals: {}, + displayName: 'gradle', + preset: '../../jest.preset.js', +}; diff --git a/packages/gradle/migrations.json b/packages/gradle/migrations.json new file mode 100644 index 00000000000000..f9d34cef28c2cd --- /dev/null +++ b/packages/gradle/migrations.json @@ -0,0 +1,4 @@ +{ + "generators": {}, + "packageJsonUpdates": {} +} diff --git a/packages/gradle/package.json b/packages/gradle/package.json new file mode 100644 index 00000000000000..666aba21829cf4 --- /dev/null +++ b/packages/gradle/package.json @@ -0,0 +1,34 @@ +{ + "name": "@nx/gradle", + "version": "0.0.1", + "private": true, + "description": "The Nx Plugin for gradle", + "repository": { + "type": "git", + "url": "https://github.com/nrwl/nx.git", + "directory": "packages/gradle" + }, + "keywords": [ + "Monorepo", + "Java", + "Gradle", + "CLI" + ], + "main": "./index", + "typings": "./index.d.ts", + "author": "Victor Savkin", + "license": "MIT", + "bugs": { + "url": "https://github.com/nrwl/nx/issues" + }, + "homepage": "https://nx.dev", + "nx-migrate": { + "migrations": "./migrations.json" + }, + "dependencies": { + "@nx/devkit": "file:../devkit" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/gradle/plugin.ts b/packages/gradle/plugin.ts new file mode 100644 index 00000000000000..558233345f7aec --- /dev/null +++ b/packages/gradle/plugin.ts @@ -0,0 +1,2 @@ +export { createDependencies } from './src/plugin/dependencies'; +export { createNodes } from './src/plugin/nodes'; diff --git a/packages/gradle/project.json b/packages/gradle/project.json new file mode 100644 index 00000000000000..15a6c451b653c0 --- /dev/null +++ b/packages/gradle/project.json @@ -0,0 +1,73 @@ +{ + "name": "gradle", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/gradle/src", + "projectType": "library", + "targets": { + "nx-release-publish": { + "dependsOn": ["^nx-release-publish"], + "executor": "@nx/js:release-publish", + "options": { + "packageRoot": "build/packages/gradle" + } + }, + "build-base": { + "executor": "@nx/js:tsc", + "options": { + "assets": [ + { + "input": "packages/gradle", + "glob": "**/@(files|files-angular)/**", + "output": "/" + }, + { + "input": "packages/gradle", + "glob": "**/files/**/.gitkeep", + "output": "/" + }, + { + "input": "packages/gradle", + "glob": "**/*.json", + "ignore": ["**/tsconfig*.json", "project.json", ".eslintrc.json"], + "output": "/" + }, + { + "input": "packages/gradle", + "glob": "**/*.js", + "ignore": ["**/jest.config.js"], + "output": "/" + }, + { + "input": "packages/gradle", + "glob": "**/*.d.ts", + "output": "/" + }, + { + "input": "", + "glob": "LICENSE", + "output": "/" + } + ] + } + }, + "build": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/build/packages/gradle"], + "options": { + "command": "node ./scripts/copy-readme.js gradle" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/gradle/jest.config.ts" + } + } + }, + "tags": [] +} diff --git a/packages/gradle/src/plugin/dependencies.ts b/packages/gradle/src/plugin/dependencies.ts new file mode 100644 index 00000000000000..1adfbdb8439962 --- /dev/null +++ b/packages/gradle/src/plugin/dependencies.ts @@ -0,0 +1,123 @@ +import { + CreateDependencies, + CreateDependenciesContext, + DependencyType, + FileMap, + RawProjectGraphDependency, + validateDependency, +} from '@nx/devkit'; +import { readFileSync } from 'node:fs'; +import { basename } from 'node:path'; + +import { + calculatedGradleReport, + getGradleReport, + writeGradleToCache, +} from '../utils/get-gradle-report'; +import { calculatedTargets, writeTargetsToCache } from './nodes'; + +export const createDependencies: CreateDependencies = async ( + _, + context: CreateDependenciesContext +) => { + const gradleFiles: string[] = findGradleFiles(context.filesToProcess); + if (gradleFiles.length === 0) { + return []; + } + + let dependencies: RawProjectGraphDependency[] = []; + const gradleDependenciesStart = performance.mark('gradleDependencies:start'); + const { + gradleFileToGradleProjectMap, + gradleProjectToProjectName, + buildFileToDepsMap, + } = getGradleReport(gradleFiles); + + for (const gradleFile of gradleFiles) { + const gradleProject = gradleFileToGradleProjectMap.get(gradleFile); + const projectName = gradleProjectToProjectName.get(gradleProject); + const depsFile = buildFileToDepsMap.get(gradleFile); + + if (projectName && depsFile) { + dependencies = dependencies.concat( + processGradleDependencies( + depsFile, + gradleProjectToProjectName, + projectName, + gradleFile, + context + ) + ); + } + } + const gradleDependenciesEnd = performance.mark('gradleDependencies:end'); + performance.measure( + 'gradleDependencies', + gradleDependenciesStart.name, + gradleDependenciesEnd.name + ); + + writeTargetsToCache(calculatedTargets); + writeGradleToCache(calculatedGradleReport); + return dependencies; +}; + +const gradleConfigFileNames = new Set(['build.gradle', 'build.gradle.kts']); + +function findGradleFiles(fileMap: FileMap): string[] { + const gradleFiles: string[] = []; + + for (const [_, files] of Object.entries(fileMap.projectFileMap)) { + for (const file of files) { + if (gradleConfigFileNames.has(basename(file.file))) { + gradleFiles.push(file.file); + } + } + } + + return gradleFiles; +} + +function processGradleDependencies( + depsFile: string, + gradleProjectToProjectName: Map, + sourceProjectName: string, + gradleFile: string, + context: CreateDependenciesContext +) { + const dependencies: RawProjectGraphDependency[] = []; + const lines = readFileSync(depsFile).toString().split('\n'); + let inDeps = false; + for (const line of lines) { + if (line.startsWith('implementationDependenciesMetadata')) { + inDeps = true; + continue; + } + + if (inDeps) { + if (line === '') { + inDeps = false; + continue; + } + const [indents, dep] = line.split('--- '); + if ((indents === '\\' || indents === '+') && dep.startsWith('project ')) { + const gradleProjectName = dep + .substring('project '.length) + .replace(/ \(n\)$/, '') + .trim(); + const target = gradleProjectToProjectName.get( + gradleProjectName + ) as string; + const dependency: RawProjectGraphDependency = { + source: sourceProjectName, + target, + type: DependencyType.static, + sourceFile: gradleFile, + }; + validateDependency(dependency, context); + dependencies.push(dependency); + } + } + } + return dependencies; +} diff --git a/packages/gradle/src/plugin/nodes.ts b/packages/gradle/src/plugin/nodes.ts new file mode 100644 index 00000000000000..98e4baa7be1886 --- /dev/null +++ b/packages/gradle/src/plugin/nodes.ts @@ -0,0 +1,182 @@ +import { + CreateNodes, + CreateNodesContext, + TargetConfiguration, + readJsonFile, + writeJsonFile, +} from '@nx/devkit'; +import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { projectGraphCacheDirectory } from 'nx/src/utils/cache-directory'; + +import { getGradleBinaryPath } from '../utils/exec-gradle'; +import { getGradleReport } from '../utils/get-gradle-report'; + +const nonCacheableGradleTaskTypes = new Set(['Application']); +const dependsOnMap = { + build: ['^build', 'classes'], + test: ['classes'], + classes: ['^classes'], +}; + +interface GradleTask { + type: string; + name: string; +} + +export interface GradlePluginOptions { + testTargetName?: string; + classesTargetName?: string; + buildTargetName?: string; + [taskTargetName: string]: string | undefined; +} + +const cachePath = join(projectGraphCacheDirectory, 'gradle.hash'); +const targetsCache = existsSync(cachePath) ? readTargetsCache() : {}; + +export const calculatedTargets: Record< + string, + { name: string; targets: Record } +> = {}; + +function readTargetsCache(): Record< + string, + { name: string; targets: Record } +> { + return readJsonFile(cachePath); +} + +export function writeTargetsToCache( + targets: Record< + string, + { name: string; targets: Record } + > +) { + writeJsonFile(cachePath, targets); +} + +export const createNodes: CreateNodes = [ + '**/build.{gradle.kts,gradle}', + ( + gradleFilePath, + options: GradlePluginOptions | undefined, + context: CreateNodesContext + ) => { + const projectRoot = dirname(gradleFilePath); + + const hash = calculateHashForCreateNodes( + projectRoot, + options ?? {}, + context + ); + if (targetsCache[hash]) { + calculatedTargets[hash] = targetsCache[hash]; + return { + projects: { + [projectRoot]: targetsCache[hash], + }, + }; + } + + try { + const { + tasksMap, + gradleProjectToTasksTypeMap, + gradleFileToOutputDirsMap, + gradleFileToGradleProjectMap, + gradleProjectToProjectName, + } = getGradleReport([gradleFilePath]); + + const gradleProject = gradleFileToGradleProjectMap.get( + gradleFilePath + ) as string; + const projectName = gradleProjectToProjectName.get(gradleProject); + if (!projectName) { + return; + } + + const availableTaskNames = tasksMap.get(gradleFilePath) as string[]; + const tasksTypeMap = gradleProjectToTasksTypeMap.get( + gradleProject + ) as Map; + const tasks: GradleTask[] = availableTaskNames.map((taskName) => { + return { + type: tasksTypeMap.get(taskName) ?? 'Unknown', + name: taskName, + }; + }); + + const outputDirs = gradleFileToOutputDirsMap.get(gradleFilePath) as Map< + string, + string + >; + + const targets = createGradleTargets( + tasks, + projectRoot, + options, + context, + outputDirs + ); + calculatedTargets[hash] = { + name: projectName, + targets, + }; + + return { + projects: { + [projectRoot]: { + root: projectRoot, + name: projectName, + targets, + }, + }, + }; + } catch (e) { + console.error(e); + return {}; + } + }, +]; + +function createGradleTargets( + tasks: GradleTask[], + projectRoot: string, + options: GradlePluginOptions | undefined, + context: CreateNodesContext, + outputDirs: Map +): Record { + const inputsMap = createInputsMap(context); + + const targets: Record = {}; + for (const task of tasks) { + const targetName = options?.[`${task.name}TargetName`] ?? task.name; + + const outputs = outputDirs.get(task.name); + targets[targetName] = { + command: `${getGradleBinaryPath()} ${task.name}`, + options: { + cwd: projectRoot, + }, + cache: !nonCacheableGradleTaskTypes.has(task.type), + inputs: inputsMap[task.name], + outputs: outputs ? [outputs] : undefined, + dependsOn: dependsOnMap[task.name], + }; + } + return targets; +} + +function createInputsMap( + context: CreateNodesContext +): Record { + const namedInputs = context.nxJsonConfiguration.namedInputs; + return { + build: namedInputs?.production + ? ['production', '^production'] + : ['default', '^default'], + test: ['default', namedInputs?.production ? '^production' : '^default'], + classes: ['default', '^default'], + }; +} diff --git a/packages/gradle/src/utils/exec-gradle.ts b/packages/gradle/src/utils/exec-gradle.ts new file mode 100644 index 00000000000000..7578f6dcc2a590 --- /dev/null +++ b/packages/gradle/src/utils/exec-gradle.ts @@ -0,0 +1,63 @@ +import { workspaceRoot } from '@nx/devkit'; +import { ExecFileOptions } from 'child_process'; +import { + ExecFileSyncOptionsWithBufferEncoding, + execFile, + execFileSync, +} from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +export function execGradle( + args: string[], + execOptions: ExecFileSyncOptionsWithBufferEncoding +) { + const gradleBinaryPath = getGradleBinaryPath(); + + return execFileSync(gradleBinaryPath, args, execOptions); +} + +export function getGradleBinaryPath() { + const gradleFile = process.platform.startsWith('win') + ? 'gradlew.bat' + : 'gradlew'; + const gradleBinaryPath = join(workspaceRoot, gradleFile); + if (!existsSync(gradleBinaryPath)) { + throw new Error('Gradle is not setup. Run "gradle init"'); + } + + return gradleBinaryPath; +} + +export function execGradleAsync( + args: ReadonlyArray, + execOptions: ExecFileOptions +) { + const gradleBinaryPath = getGradleBinaryPath(); + if (!existsSync(gradleBinaryPath)) { + throw new Error('Gradle is not setup. Run "gradle init"'); + } + + return new Promise((res, rej) => { + const cp = execFile(gradleBinaryPath, args, execOptions); + + let stdout = Buffer.from(''); + cp.stdout?.on('data', (data) => { + stdout += data; + }); + + cp.on('exit', (code) => { + if (code === 0) { + res(stdout); + } else { + rej( + new Error( + `Executing Gradle with ${args.join( + ' ' + )} failed with code: ${code}. \nLogs: ${stdout}` + ) + ); + } + }); + }); +} diff --git a/packages/gradle/src/utils/get-gradle-report.ts b/packages/gradle/src/utils/get-gradle-report.ts new file mode 100644 index 00000000000000..43eb0563c6cf35 --- /dev/null +++ b/packages/gradle/src/utils/get-gradle-report.ts @@ -0,0 +1,245 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; + +import { readJsonFile, workspaceRoot, writeJsonFile } from '@nx/devkit'; +import { projectGraphCacheDirectory } from 'nx/src/utils/cache-directory'; +import { hashWithWorkspaceContext } from 'nx/src/utils/workspace-context'; + +import { execGradle } from './exec-gradle'; + +interface GradleReport { + gradleFileToGradleProjectMap: Map; + buildFileToDepsMap: Map; + gradleFileToOutputDirsMap: Map>; + gradleProjectToTasksTypeMap: Map>; + tasksMap: Map; + gradleProjectToProjectName: Map; +} + +const cachePath = join(projectGraphCacheDirectory, 'gradle-report.hash'); +const gradleReportCache = existsSync(cachePath) ? readGradleReportCache() : {}; + +export const calculatedGradleReport: Record = + gradleReportCache; + +function readGradleReportCache(): GradleReport { + const gradleReportsJson = readJsonFile(cachePath); + const objToMap = (obj) => { + return new Map(Object.entries(obj)); + }; + const gradleReports = Object.keys(gradleReportsJson).reduce( + (gradleReports, hashKey) => { + const gradleReportJson = gradleReportsJson[hashKey]; + gradleReports[hashKey] = Object.keys(gradleReportJson).reduce( + (gradleReport, key) => { + gradleReport[key] = objToMap(gradleReportJson[key]); + return gradleReport; + }, + {} + ); + return gradleReports; + }, + {} + ) as GradleReport; + return gradleReports; +} + +export function writeGradleToCache( + gradleReports: Record +) { + // change Map to Object for each map in the GradleReport + const gradleReportsJson = Object.keys(gradleReports).reduce( + (gradleReportsJson, hashKey) => { + const gradleReport = gradleReports[hashKey]; + gradleReportsJson[hashKey] = Object.keys(gradleReport).reduce( + (gradleReportsJson, key) => { + gradleReportsJson[key] = Object.fromEntries(gradleReport[key]); + return gradleReportsJson; + }, + {} + ); + return gradleReportsJson; + }, + {} + ); + writeJsonFile(cachePath, gradleReportsJson); +} + +export function getGradleReport(gradleFilePaths: string[]): GradleReport { + const hash = calculateHashForGradleReport(gradleFilePaths, workspaceRoot); + if (gradleReportCache[hash]) { + calculatedGradleReport[hash] = gradleReportCache[hash]; + return gradleReportCache[hash]; + } + + let gradleReport: GradleReport; + const gradleProjectReportStart = performance.mark( + 'gradleProjectReport:start' + ); + const projectReportLines = execGradle(['projectReport'], { + cwd: workspaceRoot, + }) + .toString() + .split('\n'); + const gradleProjectReportEnd = performance.mark('gradleProjectReport:end'); + performance.measure( + 'gradleProjectReport', + gradleProjectReportStart.name, + gradleProjectReportEnd.name + ); + gradleReport = processProjectReports(projectReportLines); + calculatedGradleReport[hash] = gradleReport; + return gradleReport; +} + +function processProjectReports(projectReportLines: string[]): GradleReport { + /** + * Map of Gradle File path to Gradle Project Name + */ + const gradleFileToGradleProjectMap = new Map(); + /** + * Map of Gradle Project Name to Gradle File + */ + const gradleProjectToGradleFileMap = new Map(); + const dependenciesMap = new Map(); + /** + * Map of Gradle Build File to available tasks + */ + const tasksMap = new Map(); + /** + * Map of Gradle Build File to tasks type map + */ + const gradleProjectToTasksTypeMap = new Map>(); + const gradleProjectToProjectName = new Map(); + /** + * Map of buildFile to dependencies report path + */ + const buildFileToDepsMap = new Map(); + /** + * Map fo possible output files of each gradle file + * e.g. {build.gradle.kts: { projectReportDir: '' testReportDir: '' }} + */ + const gradleFileToOutputDirsMap = new Map>(); + + projectReportLines.forEach((line, index) => { + if (line.startsWith('> Task ')) { + const nextLine = projectReportLines[index + 1]; + if (line.endsWith(':dependencyReport')) { + const gradleProject = line.substring( + '> Task '.length, + line.length - ':dependencyReport'.length + ); + const [_, file] = nextLine.split('file://'); + dependenciesMap.set(gradleProject, file); + } + if (line.endsWith('propertyReport')) { + const gradleProject = line.substring( + '> Task '.length, + line.length - ':propertyReport'.length + ); + const [_, file] = nextLine.split('file://'); + const propertyReportLines = readFileSync(file).toString().split('\n'); + + let projectName: string, + absBuildFilePath: string, + absBuildDirPath: string; + const tasks: string[] = []; + const outputDirMap = new Map(); + for (const line of propertyReportLines) { + if (line.startsWith('name: ')) { + projectName = line.substring('name: '.length); + } + if (line.startsWith('buildFile: ')) { + absBuildFilePath = line.substring('buildFile: '.length); + } + if (line.startsWith('buildDir: ')) { + absBuildDirPath = line.substring('buildDir: '.length); + } + if (line.includes(': task ')) { + const taskSegments = line.split(': task '); + tasks.push(taskSegments[0]); + } + if (line.includes('Dir: ')) { + const [dirName, dirPath] = line.split(': '); + const taskName = dirName.replace('Dir', ''); + outputDirMap.set( + taskName, + `{workspaceRoot}/${relative(workspaceRoot, dirPath)}` + ); + } + } + + if (!projectName || !absBuildFilePath || !absBuildDirPath) { + return; + } + const buildFile = relative(workspaceRoot, absBuildFilePath); + const buildDir = relative(workspaceRoot, absBuildDirPath); + buildFileToDepsMap.set( + buildFile, + dependenciesMap.get(gradleProject) as string + ); + + outputDirMap.set('build', `{workspaceRoot}/${buildDir}`); + outputDirMap.set( + 'classes', + `{workspaceRoot}/${join(buildDir, 'classes')}` + ); + + gradleFileToOutputDirsMap.set(buildFile, outputDirMap); + gradleFileToGradleProjectMap.set(buildFile, gradleProject); + gradleProjectToGradleFileMap.set(gradleProject, buildFile); + gradleProjectToProjectName.set(gradleProject, projectName); + tasksMap.set(buildFile, tasks); + } + if (line.endsWith('taskReport')) { + const gradleProject = line.substring( + '> Task '.length, + line.length - ':taskReport'.length + ); + const [_, file] = nextLine.split('file://'); + const taskTypeMap = new Map(); + const tasksFileLines = readFileSync(file).toString().split('\n'); + + let i = 0; + while (i < tasksFileLines.length) { + const line = tasksFileLines[i]; + + if (line.endsWith('tasks')) { + const dashes = new Array(line.length + 1).join('-'); + if (tasksFileLines[i + 1] === dashes) { + const type = line.substring(0, line.length - ' tasks'.length); + i++; + while (tasksFileLines[++i] !== '') { + const [taskName] = tasksFileLines[i].split(' - '); + taskTypeMap.set(taskName, type); + } + } + } + i++; + } + gradleProjectToTasksTypeMap.set(gradleProject, taskTypeMap); + } + } + }); + + return { + gradleFileToGradleProjectMap, + buildFileToDepsMap, + gradleFileToOutputDirsMap, + gradleProjectToTasksTypeMap, + tasksMap, + gradleProjectToProjectName, + }; +} + +export function calculateHashForGradleReport( + gradleFilePaths: string[], + workspaceRoot: string +): string { + return hashWithWorkspaceContext( + workspaceRoot, + gradleFilePaths.map((gradleFilePath) => + join(dirname(gradleFilePath), '**/*') + ) + ); +} diff --git a/packages/gradle/tsconfig.json b/packages/gradle/tsconfig.json new file mode 100644 index 00000000000000..f5a01e5713e0e2 --- /dev/null +++ b/packages/gradle/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": {}, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/gradle/tsconfig.lib.json b/packages/gradle/tsconfig.lib.json new file mode 100644 index 00000000000000..7bfc80f73e6fad --- /dev/null +++ b/packages/gradle/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/gradle/tsconfig.spec.json b/packages/gradle/tsconfig.spec.json new file mode 100644 index 00000000000000..546f12877f7f05 --- /dev/null +++ b/packages/gradle/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 0672131ea978a0..430aff896eb2ee 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -35,6 +35,8 @@ "@nx/expo": ["packages/expo"], "@nx/expo/*": ["packages/expo/*"], "@nx/express": ["packages/express"], + "@nx/gradle": ["packages/gradle/src/index.ts"], + "@nx/gradle/*": ["packages/gradle/*"], "@nx/graph/project-details": ["graph/project-details/src/index.ts"], "@nx/graph/shared": ["graph/shared/src/index.ts"], "@nx/graph/ui-code-block": ["graph/ui-code-block/src/index.ts"],