From aae045edf50eaec11d421ddb131a19bf06fea8ea Mon Sep 17 00:00:00 2001 From: Johannes Feichtner <343448+Churro@users.noreply.github.com> Date: Tue, 4 Feb 2025 18:16:51 +0100 Subject: [PATCH] feat(gradle): add support for gradle repository content descriptors (#33692) Co-authored-by: Michael Kriese <michael.kriese@visualon.de> --- lib/modules/manager/gradle/extract.spec.ts | 212 +++++++++++++++++- lib/modules/manager/gradle/extract.ts | 88 ++++++++ lib/modules/manager/gradle/parser.spec.ts | 151 +++++++++++++ lib/modules/manager/gradle/parser.ts | 1 + .../manager/gradle/parser/common.spec.ts | 1 + lib/modules/manager/gradle/parser/handlers.ts | 68 +++++- .../manager/gradle/parser/registry-urls.ts | 102 +++++++-- lib/modules/manager/gradle/types.ts | 12 + 8 files changed, 619 insertions(+), 16 deletions(-) diff --git a/lib/modules/manager/gradle/extract.spec.ts b/lib/modules/manager/gradle/extract.spec.ts index 25c24846e3..65995556f3 100644 --- a/lib/modules/manager/gradle/extract.spec.ts +++ b/lib/modules/manager/gradle/extract.spec.ts @@ -1,7 +1,8 @@ import { codeBlock } from 'common-tags'; import { Fixtures } from '../../../../test/fixtures'; import { fs, logger, partial } from '../../../../test/util'; -import type { ExtractConfig } from '../types'; +import type { ExtractConfig, PackageDependency } from '../types'; +import { matchesContentDescriptor } from './extract'; import * as parser from './parser'; import { extractAllPackageFiles } from '.'; @@ -494,6 +495,215 @@ describe('modules/manager/gradle/extract', () => { }, ]); }); + + describe('content descriptors', () => { + describe('simple descriptor matches', () => { + it.each` + input | output | descriptor + ${'foo:bar:1.2.3'} | ${true} | ${undefined} + ${'foo:bar:1.2.3'} | ${true} | ${[{ mode: 'include', matcher: 'simple', groupId: 'foo' }]} + ${'foo:bar:1.2.3'} | ${false} | ${[{ mode: 'exclude', matcher: 'simple', groupId: 'foo' }]} + ${'foo:bar:1.2.3'} | ${false} | ${[{ mode: 'include', matcher: 'simple', groupId: 'bar' }]} + ${'foo:bar:1.2.3'} | ${true} | ${[{ mode: 'include', matcher: 'simple', groupId: 'foo', artifactId: 'bar' }]} + ${'foo:bar:1.2.3'} | ${false} | ${[{ mode: 'exclude', matcher: 'simple', groupId: 'foo', artifactId: 'bar' }]} + ${'foo:bar:1.2.3'} | ${false} | ${[{ mode: 'include', matcher: 'simple', groupId: 'foo', artifactId: 'baz' }]} + ${'foo:bar:1.2.3'} | ${true} | ${[{ mode: 'include', matcher: 'simple', groupId: 'foo', artifactId: 'bar', version: '1.2.3' }]} + ${'foo:bar:1.2.3'} | ${false} | ${[{ mode: 'exclude', matcher: 'simple', groupId: 'foo', artifactId: 'bar', version: '1.2.3' }]} + ${'foo:bar:1.2.3'} | ${true} | ${[{ mode: 'include', matcher: 'simple', groupId: 'foo', artifactId: 'bar', version: '1.2.+' }]} + ${'foo:bar:1.2.3'} | ${false} | ${[{ mode: 'include', matcher: 'simple', groupId: 'foo', artifactId: 'baz', version: '4.5.6' }]} + ${'foo:bar:1.2.3'} | ${true} | ${[{ mode: 'include', matcher: 'subgroup', groupId: 'foo' }]} + ${'foo.bar.baz:qux:1.2.3'} | ${true} | ${[{ mode: 'include', matcher: 'subgroup', groupId: 'foo.bar.baz' }]} + ${'foo.bar.baz:qux:1.2.3'} | ${true} | ${[{ mode: 'include', matcher: 'subgroup', groupId: 'foo.bar' }]} + ${'foo.bar.baz:qux:1.2.3'} | ${false} | ${[{ mode: 'include', matcher: 'subgroup', groupId: 'foo.barbaz' }]} + ${'foobarbaz:qux:1.2.3'} | ${true} | ${[{ mode: 'include', matcher: 'regex', groupId: '.*bar.*' }]} + ${'foobarbaz:qux:1.2.3'} | ${true} | ${[{ mode: 'include', matcher: 'regex', groupId: '.*bar.*', artifactId: 'qux' }]} + ${'foobar:foobar:1.2.3'} | ${true} | ${[{ mode: 'include', matcher: 'regex', groupId: '.*bar.*', artifactId: 'foo.*' }]} + ${'foobar:foobar:1.2.3'} | ${false} | ${[{ mode: 'include', matcher: 'regex', groupId: 'foobar', artifactId: '^bar' }]} + ${'foobar:foobar:1.2.3'} | ${true} | ${[{ mode: 'include', matcher: 'regex', groupId: 'foobar', artifactId: '^foo.*', version: '1\\.*' }]} + ${'foobar:foobar:1.2.3'} | ${false} | ${[{ mode: 'include', matcher: 'regex', groupId: 'foobar', artifactId: '^foo', version: '3.+' }]} + ${'foobar:foobar:1.2.3'} | ${false} | ${[{ mode: 'include', matcher: 'regex', groupId: 'foobar', artifactId: 'qux', version: '1\\.*' }]} + `('$input | $output', ({ input, output, descriptor }) => { + const [groupId, artifactId, currentValue] = input.split(':'); + const dep: PackageDependency = { + depName: `${groupId}:${artifactId}`, + currentValue, + }; + + expect(matchesContentDescriptor(dep, descriptor)).toBe(output); + }); + }); + + describe('multiple descriptors', () => { + const dep: PackageDependency = { + depName: `foo:bar`, + currentValue: '1.2.3', + }; + + it('if both includes and excludes exist, dep must match include and not match exclude', () => { + expect( + matchesContentDescriptor(dep, [ + { mode: 'include', matcher: 'simple', groupId: 'foo' }, + { + mode: 'exclude', + matcher: 'simple', + groupId: 'foo', + artifactId: 'baz', + }, + ]), + ).toBe(true); + + expect( + matchesContentDescriptor(dep, [ + { mode: 'include', matcher: 'simple', groupId: 'foo' }, + { + mode: 'exclude', + matcher: 'simple', + groupId: 'foo', + artifactId: 'bar', + }, + ]), + ).toBe(false); + }); + + it('if only includes exist, dep must match at least one include', () => { + expect( + matchesContentDescriptor(dep, [ + { mode: 'include', matcher: 'simple', groupId: 'some' }, + { mode: 'include', matcher: 'simple', groupId: 'foo' }, + { mode: 'include', matcher: 'simple', groupId: 'bar' }, + ]), + ).toBe(true); + + expect( + matchesContentDescriptor(dep, [ + { mode: 'include', matcher: 'simple', groupId: 'some' }, + { mode: 'include', matcher: 'simple', groupId: 'other' }, + { mode: 'include', matcher: 'simple', groupId: 'bar' }, + ]), + ).toBe(false); + }); + + it('if only excludes exist, dep must match not match any exclude', () => { + expect( + matchesContentDescriptor(dep, [ + { mode: 'exclude', matcher: 'simple', groupId: 'some' }, + { mode: 'exclude', matcher: 'simple', groupId: 'foo' }, + { mode: 'exclude', matcher: 'simple', groupId: 'bar' }, + ]), + ).toBe(false); + + expect( + matchesContentDescriptor(dep, [ + { mode: 'exclude', matcher: 'simple', groupId: 'some' }, + { mode: 'exclude', matcher: 'simple', groupId: 'other' }, + { mode: 'exclude', matcher: 'simple', groupId: 'bar' }, + ]), + ).toBe(true); + }); + }); + + it('extracts content descriptors', async () => { + const fsMock = { + 'build.gradle': codeBlock` + pluginManagement { + repositories { + maven { + url = "https://foo.bar/baz" + content { + includeModule("com.diffplug.spotless", "com.diffplug.spotless.gradle.plugin") + } + } + } + } + repositories { + mavenCentral() + google { + content { + includeGroupAndSubgroups("foo.bar") + includeModuleByRegex("com\\\\.(google|android).*", "protobuf.*") + includeGroupByRegex("(?!(unsupported|pattern).*)") + includeGroupByRegex "org\\\\.jetbrains\\\\.kotlin.*" + excludeModule("foo.bar.group", "simple.module") + } + } + maven { + name = "some" + url = "https://foo.bar/\${name}" + content { + includeModule("foo.bar.group", "simple.module") + includeVersion("com.google.protobuf", "protobuf-java", "2.17.+") + } + } + } + + plugins { + id("com.diffplug.spotless") version "6.10.0" + } + + dependencies { + implementation "com.google.protobuf:protobuf-java:2.17.1" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21" + implementation "foo.bar:protobuf-java:2.17.0" + implementation "foo.bar.group:simple.module:2.17.0" + } + `, + }; + mockFs(fsMock); + + const res = await extractAllPackageFiles( + partial<ExtractConfig>(), + Object.keys(fsMock), + ); + + expect(res).toMatchObject([ + { + deps: [ + { + depName: 'com.diffplug.spotless', + currentValue: '6.10.0', + depType: 'plugin', + packageName: + 'com.diffplug.spotless:com.diffplug.spotless.gradle.plugin', + registryUrls: ['https://foo.bar/baz'], + }, + { + depName: 'com.google.protobuf:protobuf-java', + currentValue: '2.17.1', + registryUrls: [ + 'https://repo.maven.apache.org/maven2', + 'https://dl.google.com/android/maven2/', + 'https://foo.bar/some', + ], + }, + { + depName: 'org.jetbrains.kotlin:kotlin-stdlib-jdk8', + currentValue: '1.4.21', + registryUrls: [ + 'https://repo.maven.apache.org/maven2', + 'https://dl.google.com/android/maven2/', + ], + }, + { + depName: 'foo.bar:protobuf-java', + currentValue: '2.17.0', + registryUrls: [ + 'https://repo.maven.apache.org/maven2', + 'https://dl.google.com/android/maven2/', + ], + }, + { + depName: 'foo.bar.group:simple.module', + currentValue: '2.17.0', + registryUrls: [ + 'https://repo.maven.apache.org/maven2', + 'https://foo.bar/some', + ], + }, + ], + }, + ]); + }); + }); }); describe('version catalogs', () => { diff --git a/lib/modules/manager/gradle/extract.ts b/lib/modules/manager/gradle/extract.ts index d70a5722db..3f94060c42 100644 --- a/lib/modules/manager/gradle/extract.ts +++ b/lib/modules/manager/gradle/extract.ts @@ -1,7 +1,10 @@ import upath from 'upath'; import { logger } from '../../../logger'; +import { coerceArray } from '../../../util/array'; import { getLocalFiles } from '../../../util/fs'; +import { regEx } from '../../../util/regex'; import { MavenDatasource } from '../../datasource/maven'; +import gradleVersioning from '../../versioning/gradle'; import type { ExtractConfig, PackageDependency, PackageFile } from '../types'; import { parseCatalog } from './extract/catalog'; import { @@ -12,6 +15,7 @@ import { import { parseGradle, parseKotlinSource, parseProps } from './parser'; import { REGISTRY_URLS } from './parser/common'; import type { + ContentDescriptorSpec, GradleManagerData, PackageRegistry, VariableRegistry, @@ -44,6 +48,89 @@ function updatePackageRegistries( } } +export function matchesContentDescriptor( + dep: PackageDependency<GradleManagerData>, + contentDescriptors?: ContentDescriptorSpec[], +): boolean { + const [groupId, artifactId] = (dep.packageName ?? dep.depName!).split(':'); + let hasIncludes = false; + let hasExcludes = false; + let matchesInclude = false; + let matchesExclude = false; + + for (const content of coerceArray(contentDescriptors)) { + const { + mode, + matcher, + groupId: contentGroupId, + artifactId: contentArtifactId, + version: contentVersion, + } = content; + + // group matching + let groupMatch = false; + if (matcher === 'regex') { + groupMatch = regEx(contentGroupId).test(groupId); + } else if (matcher === 'subgroup') { + groupMatch = + groupId === contentGroupId || `${groupId}.`.startsWith(contentGroupId); + } else { + groupMatch = groupId === contentGroupId; + } + + // artifact matching (optional) + let artifactMatch = true; + if (groupMatch && contentArtifactId) { + if (matcher === 'regex') { + artifactMatch = regEx(contentArtifactId).test(artifactId); + } else { + artifactMatch = artifactId === contentArtifactId; + } + } + + // version matching (optional) + let versionMatch = true; + if (groupMatch && artifactMatch && contentVersion && dep.currentValue) { + if (matcher === 'regex') { + versionMatch = regEx(contentVersion).test(dep.currentValue); + } else { + // contentVersion can be an exact version or a gradle-supported version range + versionMatch = gradleVersioning.matches( + dep.currentValue, + contentVersion, + ); + } + } + + const isMatch = groupMatch && artifactMatch && versionMatch; + if (mode === 'include') { + hasIncludes = true; + if (isMatch) { + matchesInclude = true; + } + } else if (mode === 'exclude') { + hasExcludes = true; + if (isMatch) { + matchesExclude = true; + } + } + } + + if (hasIncludes && hasExcludes) { + // if both includes and excludes exist, dep must match include and not match exclude + return matchesInclude && !matchesExclude; + } else if (hasIncludes) { + // if only includes exist, dep must match at least one include + return matchesInclude; + } else if (hasExcludes) { + // if only excludes exist, dep must not match any exclude + return !matchesExclude; + } + + // by default, repositories include everything and exclude nothing + return true; +} + function getRegistryUrlsForDep( packageRegistries: PackageRegistry[], dep: PackageDependency<GradleManagerData>, @@ -52,6 +139,7 @@ function getRegistryUrlsForDep( const registryUrls = packageRegistries .filter((item) => item.scope === scope) + .filter((item) => matchesContentDescriptor(dep, item.content)) .map((item) => item.registryUrl); if (!registryUrls.length && scope === 'plugin') { diff --git a/lib/modules/manager/gradle/parser.spec.ts b/lib/modules/manager/gradle/parser.spec.ts index faa099f3e4..3f0281b1d5 100644 --- a/lib/modules/manager/gradle/parser.spec.ts +++ b/lib/modules/manager/gradle/parser.spec.ts @@ -728,6 +728,157 @@ describe('modules/manager/gradle/parser', () => { }, ]); }); + + describe('content descriptors', () => { + it('valid combinations', () => { + const input = codeBlock` + maven(url = "https://foo.bar/baz") { + content { + excludeGroup("baz.qux") + } + } + mavenCentral().content { + includeGroup("foo.bar") + } + maven { + url = "https://foo.bar/deps" + content { + includeGroupAndSubgroups("foo.bar") + } + } + maven { + url = "https://some.foo" + content { + includeModule("foo", "bar") + excludeModule("baz", "qux") + includeVersion("foo", "bar", "1.2.3") + excludeVersion("baz", "qux", "4.5.6") + includeGroupByRegex("org\\\\.jetbrains\\\\.kotlin.*") + excludeGroupByRegex(".*google.*") + includeModuleByRegex(".*foo.*", ".*bar.*") + excludeModuleByRegex(".*baz.*", ".*qux.*") + includeVersionByRegex(".*foo.*", ".*bar.*", "1.2.3") + excludeVersionByRegex(".*baz.*", ".*qux.*", ".*4.5.*") + } + } + `; + + const { urls } = parseGradle(input); + expect(urls).toStrictEqual([ + { + registryUrl: 'https://foo.bar/baz', + scope: 'dep', + content: [ + { mode: 'exclude', matcher: 'simple', groupId: 'baz.qux' }, + ], + }, + { + registryUrl: REGISTRY_URLS.mavenCentral, + scope: 'dep', + content: [ + { mode: 'include', matcher: 'simple', groupId: 'foo.bar' }, + ], + }, + { + registryUrl: 'https://foo.bar/deps', + scope: 'dep', + content: [ + { mode: 'include', matcher: 'subgroup', groupId: 'foo.bar' }, + ], + }, + { + registryUrl: 'https://some.foo', + scope: 'dep', + content: [ + { + mode: 'include', + matcher: 'simple', + groupId: 'foo', + artifactId: 'bar', + }, + { + mode: 'exclude', + matcher: 'simple', + groupId: 'baz', + artifactId: 'qux', + }, + { + mode: 'include', + matcher: 'simple', + groupId: 'foo', + artifactId: 'bar', + version: '1.2.3', + }, + { + mode: 'exclude', + matcher: 'simple', + groupId: 'baz', + artifactId: 'qux', + version: '4.5.6', + }, + { + mode: 'include', + matcher: 'regex', + groupId: '^org\\.jetbrains\\.kotlin.*$', + }, + { mode: 'exclude', matcher: 'regex', groupId: '^.*google.*$' }, + { + mode: 'include', + matcher: 'regex', + groupId: '^.*foo.*$', + artifactId: '^.*bar.*$', + }, + { + mode: 'exclude', + matcher: 'regex', + groupId: '^.*baz.*$', + artifactId: '^.*qux.*$', + }, + { + mode: 'include', + matcher: 'regex', + groupId: '^.*foo.*$', + artifactId: '^.*bar.*$', + version: '^1.2.3$', + }, + { + mode: 'exclude', + matcher: 'regex', + groupId: '^.*baz.*$', + artifactId: '^.*qux.*$', + version: '^.*4.5.*$', + }, + ], + }, + ]); + }); + + describe('invalid or unsupported regEx patterns', () => { + it.each` + fieldName | pattern + ${'group'} | ${'includeGroupByRegex(".*so\\me.invalid.pattern.*")'} + ${'group'} | ${'includeModuleByRegex(".*so\\me.invalid.pattern.*", ".*bar.*")'} + ${'module'} | ${'includeModuleByRegex(".*foo.*", ".*so\\me.invalid.pattern.*")'} + ${'module'} | ${'excludeModuleByRegex(".*baz.*", "(?!(foo|bar).*)")'} + ${'version'} | ${'includeVersionByRegex(".*foo.*", ".*bar.*", "(?!(foo|bar).*)")'} + ${'version'} | ${'excludeVersionByRegex(".*baz.*", ".*qux.*", "(?!(foo|bar).*)")'} + `('$pattern', ({ fieldName, pattern }) => { + const input = codeBlock` + mavenCentral { + content { + ${pattern} + } + } + `; + parseGradle(input); + expect(logger.logger.debug).toHaveBeenCalledWith( + expect.stringContaining( + `Skipping content descriptor with unsupported regExp pattern for ${fieldName}`, + ), + ); + }); + }); + }); }); describe('version catalog', () => { diff --git a/lib/modules/manager/gradle/parser.ts b/lib/modules/manager/gradle/parser.ts index b0459572b2..92d55a1b09 100644 --- a/lib/modules/manager/gradle/parser.ts +++ b/lib/modules/manager/gradle/parser.ts @@ -33,6 +33,7 @@ const ctx: Ctx = { varTokens: [], tmpKotlinImportStore: [], tmpNestingDepth: [], + tmpRegistryContent: [], tmpTokenStore: {}, tokenMap: {}, }; diff --git a/lib/modules/manager/gradle/parser/common.spec.ts b/lib/modules/manager/gradle/parser/common.spec.ts index e9625d2c2d..7b2bf2afdc 100644 --- a/lib/modules/manager/gradle/parser/common.spec.ts +++ b/lib/modules/manager/gradle/parser/common.spec.ts @@ -32,6 +32,7 @@ describe('modules/manager/gradle/parser/common', () => { varTokens: [], tmpKotlinImportStore: [], tmpNestingDepth: [], + tmpRegistryContent: [], tmpTokenStore: {}, tokenMap: {}, }; diff --git a/lib/modules/manager/gradle/parser/handlers.ts b/lib/modules/manager/gradle/parser/handlers.ts index 5d983abbd0..ca32f64a6e 100644 --- a/lib/modules/manager/gradle/parser/handlers.ts +++ b/lib/modules/manager/gradle/parser/handlers.ts @@ -5,7 +5,12 @@ import { getSiblingFileName } from '../../../../util/fs'; import { regEx } from '../../../../util/regex'; import type { PackageDependency } from '../../types'; import type { parseGradle as parseGradleCallback } from '../parser'; -import type { Ctx, GradleManagerData } from '../types'; +import type { + ContentDescriptorMatcher, + ContentDescriptorSpec, + Ctx, + GradleManagerData, +} from '../types'; import { isDependencyString, parseDependencyString } from '../utils'; import { GRADLE_PLUGINS, @@ -264,6 +269,65 @@ export function handlePlugin(ctx: Ctx): Ctx { return ctx; } +function isValidContentDescriptorRegex( + fieldName: string, + pattern: string, +): boolean { + try { + regEx(pattern); + } catch { + logger.debug( + `Skipping content descriptor with unsupported regExp pattern for ${fieldName}: ${pattern}`, + ); + return false; + } + + return true; +} + +export function handleRegistryContent(ctx: Ctx): Ctx { + const methodName = loadFromTokenMap(ctx, 'methodName')[0].value; + let groupId = loadFromTokenMap(ctx, 'groupId')[0].value; + + let matcher: ContentDescriptorMatcher = 'simple'; + if (methodName.includes('Regex')) { + matcher = 'regex'; + groupId = `^${groupId}$`.replaceAll('\\\\', '\\'); + if (!isValidContentDescriptorRegex('group', groupId)) { + return ctx; + } + } else if (methodName.includes('AndSubgroups')) { + matcher = 'subgroup'; + } + + const mode = methodName.startsWith('include') ? 'include' : 'exclude'; + const spec: ContentDescriptorSpec = { mode, matcher, groupId }; + + if (methodName.includes('Module') || methodName.includes('Version')) { + spec.artifactId = loadFromTokenMap(ctx, 'artifactId')[0].value; + if (matcher === 'regex') { + spec.artifactId = `^${spec.artifactId}$`.replaceAll('\\\\', '\\'); + if (!isValidContentDescriptorRegex('module', spec.artifactId)) { + return ctx; + } + } + } + + if (methodName.includes('Version')) { + spec.version = loadFromTokenMap(ctx, 'version')[0].value; + if (matcher === 'regex') { + spec.version = `^${spec.version}$`.replaceAll('\\\\', '\\'); + if (!isValidContentDescriptorRegex('version', spec.version)) { + return ctx; + } + } + } + + ctx.tmpRegistryContent.push(spec); + + return ctx; +} + function isPluginRegistry(ctx: Ctx): boolean { if (ctx.tokenMap.registryScope) { const registryScope = loadFromTokenMap(ctx, 'registryScope')[0].value; @@ -279,6 +343,7 @@ export function handlePredefinedRegistryUrl(ctx: Ctx): Ctx { ctx.registryUrls.push({ registryUrl: REGISTRY_URLS[registryName as keyof typeof REGISTRY_URLS], scope: isPluginRegistry(ctx) ? 'plugin' : 'dep', + content: ctx.tmpRegistryContent, }); return ctx; @@ -314,6 +379,7 @@ export function handleCustomRegistryUrl(ctx: Ctx): Ctx { ctx.registryUrls.push({ registryUrl, scope: isPluginRegistry(ctx) ? 'plugin' : 'dep', + content: ctx.tmpRegistryContent, }); } } catch { diff --git a/lib/modules/manager/gradle/parser/registry-urls.ts b/lib/modules/manager/gradle/parser/registry-urls.ts index e684135f80..1d602b5c8f 100644 --- a/lib/modules/manager/gradle/parser/registry-urls.ts +++ b/lib/modules/manager/gradle/parser/registry-urls.ts @@ -1,3 +1,4 @@ +import type { parser } from 'good-enough-parser'; import { query as q } from 'good-enough-parser'; import { regEx } from '../../../../util/regex'; import type { Ctx } from '../types'; @@ -6,16 +7,80 @@ import { qAssignments } from './assignments'; import { REGISTRY_URLS, cleanupTempVars, + qArtifactId, + qGroupId, qValueMatcher, + qVersion, storeInTokenMap, storeVarToken, } from './common'; import { handleCustomRegistryUrl, handlePredefinedRegistryUrl, + handleRegistryContent, } from './handlers'; import { qPlugins } from './plugins'; +const cleanupTmpContentSpec = (ctx: Ctx): Ctx => { + ctx.tmpRegistryContent = []; + return ctx; +}; + +const qContentDescriptorSpec = ( + methodName: RegExp, + matcher: q.QueryBuilder<Ctx, parser.Node>, +): q.QueryBuilder<Ctx, parser.Node> => { + return q + .sym<Ctx>(methodName, storeVarToken) + .handler((ctx) => storeInTokenMap(ctx, 'methodName')) + .alt( + // includeGroup "foo.bar" + matcher, + // includeGroup("foo.bar") + q.tree({ + type: 'wrapped-tree', + maxDepth: 1, + startsWith: '(', + endsWith: ')', + search: q.begin<Ctx>().join(matcher).end(), + }), + ); +}; + +// includeModule('foo') +// excludeModuleByRegex('bar') +const qContentDescriptor = ( + mode: 'include' | 'exclude', +): q.QueryBuilder<Ctx, parser.Node> => { + return q + .alt<Ctx>( + qContentDescriptorSpec( + regEx( + `^(?:${mode}Group|${mode}GroupByRegex|${mode}GroupAndSubgroups)$`, + ), + qGroupId, + ), + qContentDescriptorSpec( + regEx(`^(?:${mode}Module|${mode}ModuleByRegex)$`), + q.join(qGroupId, q.op(','), qArtifactId), + ), + qContentDescriptorSpec( + regEx(`^(?:${mode}Version|${mode}VersionByRegex)$`), + q.join(qGroupId, q.op(','), qArtifactId, q.op(','), qVersion), + ), + ) + .handler(handleRegistryContent); +}; + +// content { includeModule('foo'); excludeModule('bar') } +const qRegistryContent = q.sym<Ctx>('content').tree({ + type: 'wrapped-tree', + maxDepth: 1, + startsWith: '{', + endsWith: '}', + search: q.alt(qContentDescriptor('include'), qContentDescriptor('exclude')), +}); + // uri("https://foo.bar/baz") // "https://foo.bar/baz" const qUri = q @@ -34,22 +99,26 @@ const qPredefinedRegistries = q .sym(regEx(`^(?:${Object.keys(REGISTRY_URLS).join('|')})$`), storeVarToken) .handler((ctx) => storeInTokenMap(ctx, 'registryUrl')) .alt( - q.tree({ - type: 'wrapped-tree', - startsWith: '(', - endsWith: ')', - search: q.begin<Ctx>().end(), - }), + q + .tree({ + type: 'wrapped-tree', + startsWith: '(', + endsWith: ')', + search: q.begin<Ctx>().end(), + }) + .opt(q.op<Ctx>('.').join(qRegistryContent)), q.tree({ type: 'wrapped-tree', startsWith: '{', endsWith: '}', + search: q.opt(qRegistryContent), }), ) .handler(handlePredefinedRegistryUrl) + .handler(cleanupTmpContentSpec) .handler(cleanupTempVars); -// { url = "https://some.repo" } +// { url = "https://some.repo"; content { ... } } const qMavenArtifactRegistry = q.tree({ type: 'wrapped-tree', maxDepth: 1, @@ -68,24 +137,29 @@ const qMavenArtifactRegistry = q.tree({ endsWith: ')', search: q.begin<Ctx>().join(qUri).end(), }), + qRegistryContent, ), }); // maven(url = uri("https://foo.bar/baz")) +// maven("https://foo.bar/baz") { content { ... } } // maven { name = some; url = "https://foo.bar/${name}" } const qCustomRegistryUrl = q .sym<Ctx>('maven') .alt( - q.tree<Ctx>({ - type: 'wrapped-tree', - maxDepth: 1, - startsWith: '(', - endsWith: ')', - search: q.begin<Ctx>().opt(q.sym<Ctx>('url').op('=')).join(qUri).end(), - }), + q + .tree<Ctx>({ + type: 'wrapped-tree', + maxDepth: 1, + startsWith: '(', + endsWith: ')', + search: q.begin<Ctx>().opt(q.sym<Ctx>('url').op('=')).join(qUri).end(), + }) + .opt(qMavenArtifactRegistry), qMavenArtifactRegistry, ) .handler(handleCustomRegistryUrl) + .handler(cleanupTmpContentSpec) .handler(cleanupTempVars); const qPluginManagement = q.sym<Ctx>('pluginManagement', storeVarToken).tree({ diff --git a/lib/modules/manager/gradle/types.ts b/lib/modules/manager/gradle/types.ts index 0a6fbe93a1..dd7899e652 100644 --- a/lib/modules/manager/gradle/types.ts +++ b/lib/modules/manager/gradle/types.ts @@ -68,9 +68,20 @@ export interface RichVersion { export type GradleVersionPointerTarget = string | RichVersion; export type GradleVersionCatalogVersion = string | VersionPointer | RichVersion; +export type ContentDescriptorMatcher = 'simple' | 'regex' | 'subgroup'; + +export interface ContentDescriptorSpec { + mode: 'include' | 'exclude'; + matcher: ContentDescriptorMatcher; + groupId: string; + artifactId?: string; + version?: string; +} + export interface PackageRegistry { registryUrl: string; scope: 'dep' | 'plugin'; + content?: ContentDescriptorSpec[]; } export interface Ctx { @@ -86,6 +97,7 @@ export interface Ctx { varTokens: lexer.Token[]; tmpKotlinImportStore: lexer.Token[][]; tmpNestingDepth: lexer.Token[]; + tmpRegistryContent: ContentDescriptorSpec[]; tmpTokenStore: Record<string, lexer.Token[]>; tokenMap: Record<string, lexer.Token[]>; } -- GitLab