From eec46d1880b9d972f6d5546cc83451a5b017e5e6 Mon Sep 17 00:00:00 2001
From: Johannes Feichtner <343448+Churro@users.noreply.github.com>
Date: Wed, 10 May 2023 21:36:05 +0200
Subject: [PATCH] feat(gradle/manager): add support for Kotlin objects in
 `buildSrc` files (#21892)

---
 lib/modules/manager/gradle/extract.spec.ts    | 120 ++++++++++++++++++
 lib/modules/manager/gradle/extract.ts         |  26 +++-
 lib/modules/manager/gradle/index.ts           |   1 +
 lib/modules/manager/gradle/parser.spec.ts     | 105 ++++++++++++++-
 lib/modules/manager/gradle/parser.ts          |  29 +++++
 .../manager/gradle/parser/assignments.ts      |   2 +-
 .../manager/gradle/parser/common.spec.ts      |  12 ++
 lib/modules/manager/gradle/parser/common.ts   |  12 ++
 lib/modules/manager/gradle/parser/objects.ts  |  54 ++++++++
 lib/modules/manager/gradle/utils.ts           |   5 +
 10 files changed, 358 insertions(+), 8 deletions(-)
 create mode 100644 lib/modules/manager/gradle/parser/objects.ts

diff --git a/lib/modules/manager/gradle/extract.spec.ts b/lib/modules/manager/gradle/extract.spec.ts
index e5f314711d..94c9a4f1b1 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<ExtractConfig>(),
+      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 e0a76398b7..a75d6a9098 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<string, PackageFile> = {};
   const packageRegistries: PackageRegistry[] = [];
   const extractedDeps: PackageDependency<GradleManagerData>[] = [];
-  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 ad83ff7f60..82419fa6f8 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 4cf790ce64..fe948df16d 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 1d4d3914b9..a3824ed8a2 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<GradleManagerData>[] } {
+  let vars: PackageVariables = { ...initVars };
+  const deps: PackageDependency<GradleManagerData>[] = [];
+
+  const query = q.tree<Ctx>({
+    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(
   `^(?<leftPart>\\s*(?<key>${propWord})\\s*[= :]\\s*['"]?)(?<value>[^\\s'"]+)['"]?\\s*$`
diff --git a/lib/modules/manager/gradle/parser/assignments.ts b/lib/modules/manager/gradle/parser/assignments.ts
index 91da408fb4..424be4895e 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 77a7df15e8..6915f7339d 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 6379bb5644..6f92589544 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 0000000000..b1d0f0c862
--- /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<Ctx>(':').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<Ctx, parser.Node>
+): q.QueryBuilder<Ctx, parser.Node> =>
+  q.alt(
+    q.sym<Ctx>('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 bed8720663..06b8476d8d 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');
-- 
GitLab