diff --git a/lib/modules/manager/gradle/extract.spec.ts b/lib/modules/manager/gradle/extract.spec.ts index e5f314711d3bd6..94c9a4f1b18865 100644 --- a/lib/modules/manager/gradle/extract.spec.ts +++ b/lib/modules/manager/gradle/extract.spec.ts @@ -120,6 +120,126 @@ describe('modules/manager/gradle/extract', () => { ]); }); + it('resolves cross-file Kotlin objects', async () => { + const fsMock = { + 'buildSrc/src/main/kotlin/Deps.kt': codeBlock` + object Libraries { + const val jacksonAnnotations = "com.fasterxml.jackson.core:jackson-annotations:\${Versions.jackson}" + const val rxjava: String = "io.reactivex.rxjava2:rxjava:" + Versions.rxjava + const val jCache = "javax.cache:cache-api:1.1.0" + private const val shadowVersion = "7.1.2" + + object Kotlin { + const val version = GradleDeps.Kotlin.version + const val stdlibJdk = "org.jetbrains.kotlin:kotlin-stdlib:$version" + } + + object Android { + object Tools { + private const val version = "4.1.2" + const val buildGradle = "com.android.tools.build:gradle:$version" + } + } + + val modulePlugins = mapOf( + "shadow" to shadowVersion + ) + + object Test { + private const val version = "1.3.0-rc01" + const val core = "androidx.test:core:\${Test.version}" + + object Espresso { + private const val version = "3.3.0-rc01" + const val espressoCore = "androidx.test.espresso:espresso-core:$version" + } + + object Androidx { + const val coreKtx = "androidx.test:core-ktx:$version" + } + } + } + `, + 'buildSrc/src/main/kotlin/GradleDeps.kt': codeBlock` + object GradleDeps { + object Kotlin { + const val version = "1.8.10" + } + } + `, + 'buildSrc/src/main/kotlin/Versions.kt': codeBlock` + object Versions { + const val jackson = "2.9.10" + const val rxjava: String = "1.2.3" + } + `, + }; + mockFs(fsMock); + + const res = await extractAllPackageFiles( + partial(), + Object.keys(fsMock) + ); + + expect(res).toMatchObject([ + { + packageFile: 'buildSrc/src/main/kotlin/Deps.kt', + deps: [ + { + depName: 'javax.cache:cache-api', + currentValue: '1.1.0', + groupName: 'Libraries.jCache', + }, + { + depName: 'com.android.tools.build:gradle', + currentValue: '4.1.2', + groupName: 'Libraries.Android.Tools.version', + }, + { + depName: 'androidx.test:core', + currentValue: '1.3.0-rc01', + groupName: 'Libraries.Test.version', + }, + { + depName: 'androidx.test.espresso:espresso-core', + currentValue: '3.3.0-rc01', + groupName: 'Libraries.Test.Espresso.version', + }, + { + depName: 'androidx.test:core-ktx', + currentValue: '1.3.0-rc01', + groupName: 'Libraries.Test.version', + }, + ], + }, + { + packageFile: 'buildSrc/src/main/kotlin/GradleDeps.kt', + deps: [ + { + depName: 'org.jetbrains.kotlin:kotlin-stdlib', + currentValue: '1.8.10', + groupName: 'GradleDeps.Kotlin.version', + }, + ], + }, + { + packageFile: 'buildSrc/src/main/kotlin/Versions.kt', + deps: [ + { + depName: 'com.fasterxml.jackson.core:jackson-annotations', + currentValue: '2.9.10', + groupName: 'Versions.jackson', + }, + { + depName: 'io.reactivex.rxjava2:rxjava', + currentValue: '1.2.3', + groupName: 'Versions.rxjava', + }, + ], + }, + ]); + }); + it('inherits gradle variables', async () => { const fsMock = { 'gradle.properties': 'foo=1.0.0', diff --git a/lib/modules/manager/gradle/extract.ts b/lib/modules/manager/gradle/extract.ts index e0a76398b747fa..a75d6a9098b430 100644 --- a/lib/modules/manager/gradle/extract.ts +++ b/lib/modules/manager/gradle/extract.ts @@ -9,7 +9,7 @@ import { parseGcv, usesGcv, } from './extract/consistent-versions-plugin'; -import { parseGradle, parseProps } from './parser'; +import { parseGradle, parseKotlinSource, parseProps } from './parser'; import { REGISTRY_URLS } from './parser/common'; import type { GradleManagerData, @@ -19,6 +19,7 @@ import type { import { getVars, isGradleScriptFile, + isKotlinSourceFile, isPropsFile, isTOMLFile, reorderFiles, @@ -94,6 +95,15 @@ async function parsePackageFiles( ) { const deps = parseGcv(packageFile, fileContents); extractedDeps.push(...deps); + } else if (isKotlinSourceFile(packageFile)) { + const vars = getVars(varRegistry, packageFileDir); + const { vars: gradleVars, deps } = parseKotlinSource( + content, + vars, + packageFile + ); + updateVars(varRegistry, '/', gradleVars); + extractedDeps.push(...deps); } else if (isGradleScriptFile(packageFile)) { const vars = getVars(varRegistry, packageFileDir); const { @@ -123,11 +133,14 @@ export async function extractAllPackageFiles( const packageFilesByName: Record = {}; const packageRegistries: PackageRegistry[] = []; const extractedDeps: PackageDependency[] = []; - const gradleFiles = reorderFiles(packageFiles); + const kotlinSourceFiles = packageFiles.filter(isKotlinSourceFile); + const gradleFiles = reorderFiles( + packageFiles.filter((e) => !kotlinSourceFiles.includes(e)) + ); await parsePackageFiles( config, - gradleFiles, + [...kotlinSourceFiles, ...kotlinSourceFiles, ...gradleFiles], extractedDeps, packageFilesByName, packageRegistries @@ -161,9 +174,10 @@ export async function extractAllPackageFiles( dep.registryUrls = getRegistryUrlsForDep(packageRegistries, dep); if (!dep.depType) { - dep.depType = key.startsWith('buildSrc') - ? 'devDependencies' - : 'dependencies'; + dep.depType = + key.startsWith('buildSrc') && !kotlinSourceFiles.length + ? 'devDependencies' + : 'dependencies'; } } diff --git a/lib/modules/manager/gradle/index.ts b/lib/modules/manager/gradle/index.ts index ad83ff7f60ba5d..82419fa6f81868 100644 --- a/lib/modules/manager/gradle/index.ts +++ b/lib/modules/manager/gradle/index.ts @@ -14,6 +14,7 @@ export const defaultConfig = { '\\.gradle(\\.kts)?$', '(^|/)gradle\\.properties$', '(^|/)gradle/.+\\.toml$', + '(^|/)buildSrc/.+\\.kt$', '\\.versions\\.toml$', // The two below is for gradle-consistent-versions plugin `(^|/)versions.props$`, diff --git a/lib/modules/manager/gradle/parser.spec.ts b/lib/modules/manager/gradle/parser.spec.ts index 4cf790ce64404f..fe948df16dd442 100644 --- a/lib/modules/manager/gradle/parser.spec.ts +++ b/lib/modules/manager/gradle/parser.spec.ts @@ -2,7 +2,7 @@ import is from '@sindresorhus/is'; import { codeBlock } from 'common-tags'; import { Fixtures } from '../../../../test/fixtures'; import { fs, logger } from '../../../../test/util'; -import { parseGradle, parseProps } from './parser'; +import { parseGradle, parseKotlinSource, parseProps } from './parser'; import { GRADLE_PLUGINS, REGISTRY_URLS } from './parser/common'; jest.mock('../../../util/fs'); @@ -930,4 +930,107 @@ describe('modules/manager/gradle/parser', () => { expect(deps).toMatchObject([output].filter(is.truthy)); }); }); + + describe('Kotlin object notation', () => { + it('simple objects', () => { + const input = codeBlock` + object Versions { + const val baz = "1.2.3" + } + + object Libraries { + val deps = mapOf("api" to "org.slf4j:slf4j-api:\${Versions.baz}") + val dep: String = "foo:bar:" + Versions.baz + } + `; + + const res = parseKotlinSource(input); + expect(res).toMatchObject({ + vars: { + 'Versions.baz': { + key: 'Versions.baz', + value: '1.2.3', + }, + }, + deps: [ + { + depName: 'org.slf4j:slf4j-api', + groupName: 'Versions.baz', + currentValue: '1.2.3', + }, + { + depName: 'foo:bar', + groupName: 'Versions.baz', + currentValue: '1.2.3', + }, + ], + }); + }); + + it('nested objects', () => { + const input = codeBlock` + object Deps { + const val kotlinVersion = "1.5.31" + + object Kotlin { + val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:\${Deps.kotlinVersion}" + } + + object Test { + private const val version = "1.3.0-rc01" + const val core = "androidx.test:core:\${Deps.Test.version}" + + object Espresso { + private const val version = "3.3.0-rc01" + const val espressoCore = "androidx.test.espresso:espresso-core:$version" + } + + object Androidx { + const val coreKtx = "androidx.test:core-ktx:$version" + } + } + } + `; + + const res = parseKotlinSource(input); + expect(res).toMatchObject({ + vars: { + 'Deps.kotlinVersion': { + key: 'Deps.kotlinVersion', + value: '1.5.31', + }, + 'Deps.Test.version': { + key: 'Deps.Test.version', + value: '1.3.0-rc01', + }, + 'Deps.Test.Espresso.version': { + key: 'Deps.Test.Espresso.version', + value: '3.3.0-rc01', + }, + }, + deps: [ + { + depName: 'org.jetbrains.kotlin:kotlin-stdlib-jdk7', + currentValue: '1.5.31', + groupName: 'Deps.kotlinVersion', + }, + { + depName: 'androidx.test:core', + currentValue: '1.3.0-rc01', + groupName: 'Deps.Test.version', + }, + { + depName: 'androidx.test.espresso:espresso-core', + currentValue: '3.3.0-rc01', + groupName: 'Deps.Test.Espresso.version', + }, + { + depName: 'androidx.test:core-ktx', + currentValue: '1.3.0-rc01', + groupName: 'Deps.Test.version', + }, + ], + }); + }); + }); }); diff --git a/lib/modules/manager/gradle/parser.ts b/lib/modules/manager/gradle/parser.ts index 1d4d3914b989eb..a3824ed8a2d0e7 100644 --- a/lib/modules/manager/gradle/parser.ts +++ b/lib/modules/manager/gradle/parser.ts @@ -5,6 +5,7 @@ import { qApplyFrom } from './parser/apply-from'; import { qAssignments } from './parser/assignments'; import { qDependencies, qLongFormDep } from './parser/dependencies'; import { setParseGradleFunc } from './parser/handlers'; +import { qKotlinMultiObjectVarAssignment } from './parser/objects'; import { qPlugins } from './parser/plugins'; import { qRegistryUrls } from './parser/registry-urls'; import { qVersionCatalogs } from './parser/version-catalogs'; @@ -77,6 +78,34 @@ export function parseGradle( return { deps, urls, vars }; } +export function parseKotlinSource( + input: string, + initVars: PackageVariables = {}, + packageFile = '' +): { vars: PackageVariables; deps: PackageDependency[] } { + let vars: PackageVariables = { ...initVars }; + const deps: PackageDependency[] = []; + + const query = q.tree({ + type: 'root-tree', + maxDepth: 1, + search: qKotlinMultiObjectVarAssignment, + }); + + const parsedResult = groovy.query(input, query, { + ...ctx, + packageFile, + globalVars: vars, + }); + + if (parsedResult) { + deps.push(...parsedResult.deps); + vars = { ...vars, ...parsedResult.globalVars }; + } + + return { deps, vars }; +} + const propWord = '[a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*'; const propRegex = regEx( `^(?\\s*(?${propWord})\\s*[= :]\\s*['"]?)(?[^\\s'"]+)['"]?\\s*$` diff --git a/lib/modules/manager/gradle/parser/assignments.ts b/lib/modules/manager/gradle/parser/assignments.ts index 91da408fb475c8..424be4895e8c80 100644 --- a/lib/modules/manager/gradle/parser/assignments.ts +++ b/lib/modules/manager/gradle/parser/assignments.ts @@ -138,7 +138,7 @@ const qKotlinMapOfExpr = ( ); // val versions = mapOf("foo1" to "bar1", "foo2" to "bar2", "foo3" to "bar3") -const qKotlinMultiMapOfVarAssignment = qVariableAssignmentIdentifier +export const qKotlinMultiMapOfVarAssignment = qVariableAssignmentIdentifier .op('=') .sym('mapOf') .tree({ diff --git a/lib/modules/manager/gradle/parser/common.spec.ts b/lib/modules/manager/gradle/parser/common.spec.ts index 77a7df15e851c5..6915f7339d4b3a 100644 --- a/lib/modules/manager/gradle/parser/common.spec.ts +++ b/lib/modules/manager/gradle/parser/common.spec.ts @@ -121,11 +121,23 @@ describe('modules/manager/gradle/parser/common', () => { }); it('findVariable', () => { + ctx.tmpNestingDepth = [token, token]; ctx.globalVars = { foo: { key: 'foo', value: 'bar' }, + 'test.foo': { key: 'test.foo', value: 'bar2' }, + 'test.test.foo3': { key: 'test.test.foo3', value: 'bar3' }, }; expect(findVariable('unknown-global-var', ctx)).toBeUndefined(); + expect(findVariable('foo3', ctx)).toStrictEqual( + ctx.globalVars['test.test.foo3'] + ); + expect(findVariable('test.foo', ctx)).toStrictEqual( + ctx.globalVars['test.foo'] + ); + expect(findVariable('foo', ctx)).toStrictEqual(ctx.globalVars['test.foo']); + + ctx.tmpNestingDepth = []; expect(findVariable('foo', ctx)).toStrictEqual(ctx.globalVars['foo']); }); diff --git a/lib/modules/manager/gradle/parser/common.ts b/lib/modules/manager/gradle/parser/common.ts index 6379bb5644893c..6f92589544e793 100644 --- a/lib/modules/manager/gradle/parser/common.ts +++ b/lib/modules/manager/gradle/parser/common.ts @@ -116,6 +116,18 @@ export function findVariable( ctx: Ctx, variables: PackageVariables = ctx.globalVars ): VariableData | undefined { + if (ctx.tmpNestingDepth.length) { + const prefixParts = ctx.tmpNestingDepth.map((token) => token.value); + for (let idx = ctx.tmpNestingDepth.length; idx > 0; idx -= 1) { + const prefix = prefixParts.slice(0, idx).join('.'); + const identifier = `${prefix}.${name}`; + + if (variables[identifier]) { + return variables[identifier]; + } + } + } + return variables[name]; } diff --git a/lib/modules/manager/gradle/parser/objects.ts b/lib/modules/manager/gradle/parser/objects.ts new file mode 100644 index 00000000000000..b1d0f0c862e358 --- /dev/null +++ b/lib/modules/manager/gradle/parser/objects.ts @@ -0,0 +1,54 @@ +import { parser, query as q } from 'good-enough-parser'; +import type { Ctx } from '../types'; +import { qKotlinMultiMapOfVarAssignment } from './assignments'; +import { + cleanupTempVars, + coalesceVariable, + increaseNestingDepth, + prependNestingDepth, + qValueMatcher, + qVariableAssignmentIdentifier, + reduceNestingDepth, + storeInTokenMap, + storeVarToken, +} from './common'; +import { handleAssignment } from './handlers'; + +const qKotlinSingleObjectVarAssignment = q.alt( + // val dep = mapOf("qux" to "foo:bar:\${Versions.baz}") + qKotlinMultiMapOfVarAssignment, + // val dep: String = "foo:bar:" + Versions.baz + qVariableAssignmentIdentifier + .opt(q.op(':').sym('String')) + .op('=') + .handler(prependNestingDepth) + .handler(coalesceVariable) + .handler((ctx) => storeInTokenMap(ctx, 'keyToken')) + .join(qValueMatcher) + .handler((ctx) => storeInTokenMap(ctx, 'valToken')) + .handler(handleAssignment) + .handler(cleanupTempVars) +); + +// object foo { ... } +const qKotlinMultiObjectExpr = ( + search: q.QueryBuilder +): q.QueryBuilder => + q.alt( + q.sym('object').sym(storeVarToken).tree({ + type: 'wrapped-tree', + maxDepth: 1, + startsWith: '{', + endsWith: '}', + preHandler: increaseNestingDepth, + search, + postHandler: reduceNestingDepth, + }), + qKotlinSingleObjectVarAssignment + ); + +export const qKotlinMultiObjectVarAssignment = qKotlinMultiObjectExpr( + qKotlinMultiObjectExpr( + qKotlinMultiObjectExpr(qKotlinSingleObjectVarAssignment) + ) +).handler(cleanupTempVars); diff --git a/lib/modules/manager/gradle/utils.ts b/lib/modules/manager/gradle/utils.ts index bed8720663dc5f..06b8476d8d5572 100644 --- a/lib/modules/manager/gradle/utils.ts +++ b/lib/modules/manager/gradle/utils.ts @@ -113,6 +113,11 @@ export function isPropsFile(path: string): boolean { return filename === 'gradle.properties'; } +export function isKotlinSourceFile(path: string): boolean { + const filename = upath.basename(path).toLowerCase(); + return filename.endsWith('.kt'); +} + export function isTOMLFile(path: string): boolean { const filename = upath.basename(path).toLowerCase(); return filename.endsWith('.toml');