diff --git a/lib/manager/common.ts b/lib/manager/common.ts index 3bc5c0a9595ad4619bebda5ade5709fe81dd7c15..e525f8db74697dc6b892bb5901090c25c639651f 100644 --- a/lib/manager/common.ts +++ b/lib/manager/common.ts @@ -222,9 +222,9 @@ export interface UpdateArtifact { config: UpdateArtifactsConfig; } -export interface UpdateDependencyConfig { +export interface UpdateDependencyConfig<T = Record<string, any>> { fileContent: string; - upgrade: Upgrade; + upgrade: Upgrade<T>; } export interface ManagerApi { diff --git a/lib/manager/gradle-lite/__snapshots__/parser.spec.ts.snap b/lib/manager/gradle-lite/__snapshots__/parser.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..0f74ccdb37146327f04044c2cca0eb2707c8ac4f --- /dev/null +++ b/lib/manager/gradle-lite/__snapshots__/parser.spec.ts.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`manager/gradle-lite/parser parses fixture from "gradle" manager 1`] = ` +Array [ + Object { + "currentValue": "1.5.2.RELEASE", + "depName": "org.springframework.boot:spring-boot-gradle-plugin", + "groupName": "springBootVersion", + "managerData": Object { + "fileReplacePosition": 53, + "packageFile": "build.gradle", + }, + }, + Object { + "currentValue": "1.2.3", + "depName": "com.github.jengelman.gradle.plugins:shadow", + "managerData": Object { + "fileReplacePosition": 388, + "packageFile": "build.gradle", + }, + }, + Object { + "currentValue": "0.1", + "depName": "com.fkorotkov:gradle-libraries-plugin", + "managerData": Object { + "fileReplacePosition": 452, + "packageFile": "build.gradle", + }, + }, + Object { + "currentValue": "0.2.3", + "depName": "gradle.plugin.se.patrikerdes:gradle-use-latest-versions-plugin", + "managerData": Object { + "fileReplacePosition": 539, + "packageFile": "build.gradle", + }, + }, + Object { + "currentValue": "3.1.1", + "depName": "org.apache.openjpa:openjpa", + "managerData": Object { + "fileReplacePosition": 592, + "packageFile": "build.gradle", + }, + }, + Object { + "currentValue": "6.0.9.RELEASE", + "depName": "org.grails:gorm-hibernate5-spring-boot", + "managerData": Object { + "fileReplacePosition": 1785, + "packageFile": "build.gradle", + }, + }, + Object { + "currentValue": "6.0.5", + "depName": "mysql:mysql-connector-java", + "managerData": Object { + "fileReplacePosition": 1841, + "packageFile": "build.gradle", + }, + }, + Object { + "currentValue": "1.0-groovy-2.4", + "depName": "org.spockframework:spock-spring", + "managerData": Object { + "fileReplacePosition": 1899, + "packageFile": "build.gradle", + }, + }, + Object { + "currentValue": "1.3", + "depName": "org.hamcrest:hamcrest-core", + "managerData": Object { + "fileReplacePosition": 2004, + "packageFile": "build.gradle", + }, + }, + Object { + "currentValue": "3.1", + "depName": "cglib:cglib-nodep", + "managerData": Object { + "fileReplacePosition": 2092, + "packageFile": "build.gradle", + }, + }, + Object { + "currentValue": "3.1.1", + "depName": "org.apache.openjpa:openjpa", + "managerData": Object { + "fileReplacePosition": 2198, + "packageFile": "build.gradle", + }, + }, +] +`; diff --git a/lib/manager/gradle-lite/common.ts b/lib/manager/gradle-lite/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c47de178afb72f5042743ebaf7b260523edf743 --- /dev/null +++ b/lib/manager/gradle-lite/common.ts @@ -0,0 +1,100 @@ +import { PackageDependency } from '../common'; + +export interface ManagerData { + fileReplacePosition: number; + packageFile?: string; +} + +export interface VariableData extends ManagerData { + key: string; + value: string; +} + +export type PackageVariables = Record<string, VariableData>; +export type VariableRegistry = Record<string, PackageVariables>; + +export enum TokenType { + Space = 'space', + LineComment = 'lineComment', + MultiComment = 'multiComment', + Newline = 'newline', + + Semicolon = 'semicolon', + Colon = 'colon', + Dot = 'dot', + Comma = 'comma', + Operator = 'operator', + + Assignment = 'assignment', + + Word = 'word', + + LeftParen = 'leftParen', + RightParen = 'rightParen', + + LeftBracket = 'leftBracket', + RightBracket = 'rightBracket', + + LeftBrace = 'leftBrace', + RightBrace = 'rightBrace', + + SingleQuotedStart = 'singleQuotedStart', + SingleQuotedFinish = 'singleQuotedFinish', + + DoubleQuotedStart = 'doubleQuotedStart', + StringInterpolation = 'interpolation', + IgnoredInterpolationStart = 'ignoredInterpolation', + Variable = 'variable', + DoubleQuotedFinish = 'doubleQuotedFinish', + + TripleSingleQuotedStart = 'tripleQuotedStart', + TripleDoubleQuotedStart = 'tripleDoubleQuotedStart', + TripleQuotedFinish = 'tripleQuotedFinish', + + Char = 'char', + EscapedChar = 'escapedChar', + String = 'string', + + UnknownLexeme = 'unknownChar', + UnknownFragment = 'unknownFragment', +} + +export interface Token { + type: TokenType; + value: string; + offset: number; +} + +export interface StringInterpolation extends Token { + type: TokenType.StringInterpolation; + children: Token[]; // Tokens inside double-quoted string that are subject of interpolation + isComplete: boolean; // True if token has parsed completely + isValid: boolean; // False if string contains something unprocessable +} + +// Matcher on single token +export interface SyntaxMatcher { + matchType: TokenType | TokenType[]; + matchValue?: string | string[]; + lookahead?: boolean; + tokenMapKey?: string; +} + +export type TokenMap = Record<string, Token>; + +export interface SyntaxHandlerInput { + packageFile: string; + variables: PackageVariables; + tokenMap: TokenMap; +} + +export type SyntaxHandlerOutput = { + deps?: PackageDependency<ManagerData>[]; + vars?: PackageVariables; + urls?: string[]; +} | null; + +export interface SyntaxMatchConfig { + matchers: SyntaxMatcher[]; + handler: (MatcherHandlerInput) => SyntaxHandlerOutput; +} diff --git a/lib/manager/gradle-lite/extract.spec.ts b/lib/manager/gradle-lite/extract.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..87d2dd16ec2872efd2d52db686115857ffde6e59 --- /dev/null +++ b/lib/manager/gradle-lite/extract.spec.ts @@ -0,0 +1,69 @@ +import { fs } from '../../../test/util'; +import { extractAllPackageFiles } from '.'; + +jest.mock('../../util/fs'); + +function mockFs(files: Record<string, string>): void { + fs.readLocalFile.mockImplementation( + (fileName: string): Promise<string> => { + const content = files?.[fileName]; + return typeof content === 'string' + ? Promise.resolve(content) + : Promise.reject(`File not found: ${fileName}`); + } + ); +} + +describe('manager/gradle-lite/extract', () => { + beforeAll(() => {}); + afterAll(() => { + jest.resetAllMocks(); + }); + + it('returns null', async () => { + mockFs({ + 'gradle.properties': '', + 'build.gradle': '', + }); + + const res = await extractAllPackageFiles({} as never, [ + 'build.gradle', + 'gradle.properties', + ]); + + expect(res).toBeNull(); + }); + + it('works', async () => { + mockFs({ + 'gradle.properties': 'baz=1.2.3', + 'build.gradle': 'url "https://example.com"; "foo:bar:$baz"', + 'settings.gradle': null, + }); + + const res = await extractAllPackageFiles({} as never, [ + 'build.gradle', + 'gradle.properties', + 'settings.gradle', + ]); + + expect(res).toMatchObject([ + { + packageFile: 'gradle.properties', + deps: [ + { + depName: 'foo:bar', + currentValue: '1.2.3', + registryUrls: ['https://example.com'], + }, + ], + }, + { packageFile: 'build.gradle', deps: [] }, + { + datasource: 'maven', + deps: [], + packageFile: 'settings.gradle', + }, + ]); + }); +}); diff --git a/lib/manager/gradle-lite/extract.ts b/lib/manager/gradle-lite/extract.ts new file mode 100644 index 0000000000000000000000000000000000000000..cab54c04aa7d806defe9c5210b81518b7f5f5553 --- /dev/null +++ b/lib/manager/gradle-lite/extract.ts @@ -0,0 +1,72 @@ +import * as upath from 'upath'; +import * as datasourceMaven from '../../datasource/maven'; +import { logger } from '../../logger'; +import { readLocalFile } from '../../util/fs'; +import { ExtractConfig, PackageDependency, PackageFile } from '../common'; +import { ManagerData, VariableRegistry } from './common'; +import { parseGradle, parseProps } from './parser'; +import { + getVars, + isGradleFile, + isPropsFile, + reorderFiles, + toAbsolutePath, +} from './utils'; + +export async function extractAllPackageFiles( + config: ExtractConfig, + packageFiles: string[] +): Promise<PackageFile[] | null> { + const extractedDeps: PackageDependency<ManagerData>[] = []; + const registry: VariableRegistry = {}; + const packageFilesByName: Record<string, PackageFile> = {}; + const registryUrls = []; + for (const packageFile of reorderFiles(packageFiles)) { + packageFilesByName[packageFile] = { + packageFile, + datasource: datasourceMaven.id, + deps: [], + }; + + try { + const content = await readLocalFile(packageFile, 'utf8'); + const dir = upath.dirname(toAbsolutePath(packageFile)); + if (isPropsFile(packageFile)) { + const { vars, deps } = parseProps(content, packageFile); + registry[dir] = vars; + extractedDeps.push(...deps); + } else if (isGradleFile(packageFile)) { + const vars = getVars(registry, dir); + const { deps, urls } = parseGradle(content, vars, packageFile); + urls.forEach((url) => { + if (!registryUrls.includes(url)) { + registryUrls.push(url); + } + }); + extractedDeps.push(...deps); + } + } catch (e) { + logger.warn( + { config, packageFile }, + `Failed to process Gradle file: ${packageFile}` + ); + } + } + + if (!extractedDeps.length) { + return null; + } + + extractedDeps.forEach((dep) => { + const key = dep.managerData.packageFile; + const pkgFile: PackageFile = packageFilesByName[key]; + const { deps } = pkgFile; + deps.push({ + ...dep, + registryUrls: [...(dep.registryUrls || []), ...registryUrls], + }); + packageFilesByName[key] = pkgFile; + }); + + return Object.values(packageFilesByName); +} diff --git a/lib/manager/gradle-lite/index.ts b/lib/manager/gradle-lite/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2696995d6bd88c34f20da90a3db9631d9c42f86f --- /dev/null +++ b/lib/manager/gradle-lite/index.ts @@ -0,0 +1,10 @@ +import * as gradleVersioning from '../../versioning/gradle'; + +export { extractAllPackageFiles } from './extract'; +export { updateDependency } from './update'; + +export const defaultConfig = { + fileMatch: ['(^|/)gradle.properties$', '\\.gradle(\\.kts)?$'], + versioning: gradleVersioning.id, + enabled: false, +}; diff --git a/lib/manager/gradle-lite/parser.spec.ts b/lib/manager/gradle-lite/parser.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b58cc58b880a0bb983681512185e37320220ef1 --- /dev/null +++ b/lib/manager/gradle-lite/parser.spec.ts @@ -0,0 +1,187 @@ +import { readFileSync } from 'fs'; +import path from 'path'; +import { parseGradle, parseProps } from './parser'; + +function getGradleFile(fileName: string): string { + return readFileSync(path.resolve(__dirname, fileName), 'utf8'); +} + +describe('manager/gradle-lite/parser', () => { + it('handles end of input', () => { + expect(parseGradle('version = ').deps).toBeEmpty(); + expect(parseGradle('id "foo.bar" version').deps).toBeEmpty(); + }); + it('parses variables', () => { + let deps; + + ({ deps } = parseGradle( + '\nversion = "1.2.3"\n"foo:bar:$version"\nversion = "3.2.1"' + )); + expect(deps).toMatchObject([ + { + depName: 'foo:bar', + currentValue: '1.2.3', + }, + ]); + + ({ deps } = parseGradle('version = "1.2.3"\n"foo:bar:$version@@@"')); + expect(deps).toBeEmpty(); + }); + it('parses registryUrls', () => { + let urls; + + ({ urls } = parseGradle('url ""')); + expect(urls).toBeEmpty(); + + ({ urls } = parseGradle('url "#!@"')); + expect(urls).toBeEmpty(); + + ({ urls } = parseGradle('url "https://example.com"')); + expect(urls).toStrictEqual(['https://example.com']); + + ({ urls } = parseGradle('url("https://example.com")')); + expect(urls).toStrictEqual(['https://example.com']); + }); + it('parses long form deps', () => { + let deps; + ({ deps } = parseGradle( + 'group: "com.example", name: "my.dependency", version: "1.2.3"' + )); + expect(deps).toMatchObject([ + { + depName: 'com.example:my.dependency', + currentValue: '1.2.3', + }, + ]); + + ({ deps } = parseGradle( + 'group: "com.example", name: "my.dependency", version: depVersion' + )); + expect(deps).toBeEmpty(); + + ({ deps } = parseGradle( + 'depVersion = "1.2.3"\ngroup: "com.example", name: "my.dependency", version: depVersion' + )); + expect(deps).toMatchObject([ + { + depName: 'com.example:my.dependency', + currentValue: '1.2.3', + }, + ]); + + ({ deps } = parseGradle('("com.example", "my.dependency", "1.2.3")')); + expect(deps).toMatchObject([ + { + depName: 'com.example:my.dependency', + currentValue: '1.2.3', + }, + ]); + + ({ deps } = parseGradle( + '(group = "com.example", name = "my.dependency", version = "1.2.3")' + )); + expect(deps).toMatchObject([ + { + depName: 'com.example:my.dependency', + currentValue: '1.2.3', + }, + ]); + }); + it('parses plugin', () => { + let deps; + + ({ deps } = parseGradle('id "foo.bar" version "1.2.3"')); + expect(deps).toMatchObject([ + { + depName: 'foo.bar', + lookupName: 'foo.bar:foo.bar.gradle.plugin', + currentValue: '1.2.3', + }, + ]); + + ({ deps } = parseGradle('id("foo.bar") version "1.2.3"')); + expect(deps).toMatchObject([ + { + depName: 'foo.bar', + lookupName: 'foo.bar:foo.bar.gradle.plugin', + currentValue: '1.2.3', + }, + ]); + + ({ deps } = parseGradle('kotlin("jvm") version "1.3.71"')); + expect(deps).toMatchObject([ + { + depName: 'org.jetbrains.kotlin.jvm', + lookupName: + 'org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin', + currentValue: '1.3.71', + }, + ]); + }); + it('parses fixture from "gradle" manager', () => { + const content = getGradleFile( + `../gradle/__fixtures__/build.gradle.example1` + ); + const { deps } = parseGradle(content, {}, 'build.gradle'); + deps.forEach((dep) => { + expect( + content + .slice(dep.managerData.fileReplacePosition) + .indexOf(dep.currentValue) + ).toEqual(0); + }); + expect(deps).toMatchSnapshot(); + }); + it('calculates offset', () => { + const content = "'foo:bar:1.2.3'"; + const { deps } = parseGradle(content); + const res = deps[0]; + expect( + content.slice(res.managerData.fileReplacePosition).indexOf('1.2.3') + ).toEqual(0); + }); + it('gradle.properties', () => { + expect(parseProps('foo=bar')).toMatchObject({ + vars: { + foo: { + fileReplacePosition: 4, + key: 'foo', + value: 'bar', + }, + }, + deps: [], + }); + expect(parseProps(' foo = bar ')).toMatchObject({ + vars: { + foo: { key: 'foo', value: 'bar', fileReplacePosition: 7 }, + }, + deps: [], + }); + expect(parseProps('foo.bar=baz')).toMatchObject({ + vars: { + 'foo.bar': { key: 'foo.bar', value: 'baz', fileReplacePosition: 8 }, + }, + deps: [], + }); + expect(parseProps('foo=foo\nbar=bar')).toMatchObject({ + vars: { + foo: { key: 'foo', value: 'foo', fileReplacePosition: 4 }, + bar: { key: 'bar', value: 'bar', fileReplacePosition: 12 }, + }, + deps: [], + }); + expect(parseProps('x=foo:bar:baz', 'x/gradle.properties')).toMatchObject({ + vars: {}, + deps: [ + { + currentValue: 'baz', + depName: 'foo:bar', + managerData: { + fileReplacePosition: 10, + packageFile: 'x/gradle.properties', + }, + }, + ], + }); + }); +}); diff --git a/lib/manager/gradle-lite/parser.ts b/lib/manager/gradle-lite/parser.ts new file mode 100644 index 0000000000000000000000000000000000000000..18579e66f8fd014b86a4a3d22187760a29eb9b6a --- /dev/null +++ b/lib/manager/gradle-lite/parser.ts @@ -0,0 +1,475 @@ +import * as url from 'url'; +import is from '@sindresorhus/is'; +import { logger } from '../../logger'; +import { regEx } from '../../util/regex'; +import { PackageDependency } from '../common'; +import { + ManagerData, + PackageVariables, + StringInterpolation, + SyntaxHandlerInput, + SyntaxHandlerOutput, + SyntaxMatchConfig, + SyntaxMatcher, + Token, + TokenMap, + TokenType, + VariableData, +} from './common'; +import { tokenize } from './tokenizer'; +import { + interpolateString, + isDependencyString, + parseDependencyString, +} from './utils'; + +function matchTokens( + tokens: Token[], + matchers: SyntaxMatcher[] +): TokenMap | null { + let lookaheadCount = 0; + const result: TokenMap = {}; + for (let idx = 0; idx < matchers.length; idx += 1) { + const token = tokens[idx]; + const matcher = matchers[idx]; + + if (!token) { + if (matcher.lookahead) { + break; + } + return null; + } + + const typeMatches = is.string(matcher.matchType) + ? matcher.matchType === token.type + : matcher.matchType.includes(token.type); + if (!typeMatches) { + return null; + } + + if (is.string(matcher.matchValue) && token.value !== matcher.matchValue) { + return null; + } + + if ( + is.array<string>(matcher.matchValue) && + !matcher.matchValue.includes(token.value) + ) { + return null; + } + + lookaheadCount = matcher.lookahead ? lookaheadCount + 1 : 0; + + if (matcher.tokenMapKey) { + result[matcher.tokenMapKey] = token; + } + } + + tokens.splice(0, matchers.length - lookaheadCount); + return result; +} + +const endOfInstruction: SyntaxMatcher = { + // Ensure we skip assignments of complex expressions (not strings) + matchType: [ + TokenType.Semicolon, + TokenType.RightBrace, + TokenType.Word, + TokenType.String, + TokenType.StringInterpolation, + ], + lookahead: true, +}; + +const potentialStringTypes = [TokenType.String, TokenType.Word]; + +function coercePotentialString( + token: Token, + variables: PackageVariables +): string | null { + const tokenType = token?.type; + if (tokenType === TokenType.String) { + return token?.value; + } + if ( + tokenType === TokenType.Word && + typeof variables[token?.value] !== 'undefined' + ) { + return variables[token.value].value; + } + return null; +} + +function handleAssignment({ + packageFile, + tokenMap, +}: SyntaxHandlerInput): SyntaxHandlerOutput { + const { keyToken, valToken } = tokenMap; + const variableData: VariableData = { + key: keyToken.value, + value: valToken.value, + fileReplacePosition: valToken.offset, + packageFile, + }; + return { vars: { [variableData.key]: variableData } }; +} + +function processDepString({ + packageFile, + tokenMap, +}: SyntaxHandlerInput): SyntaxHandlerOutput { + const { token } = tokenMap; + const dep = parseDependencyString(token.value); + if (dep) { + dep.managerData = { + fileReplacePosition: token.offset + dep.depName.length + 1, + packageFile, + }; + return { deps: [dep] }; + } + return null; +} + +function processDepInterpolation({ + tokenMap, + variables, +}: SyntaxHandlerInput): SyntaxHandlerOutput { + const token = tokenMap.depInterpolation as StringInterpolation; + const interpolationResult = interpolateString(token.children, variables); + if (interpolationResult && isDependencyString(interpolationResult)) { + const dep = parseDependencyString(interpolationResult); + if (dep) { + const lastChild = token.children[token.children.length - 1]; + const lastChildValue = lastChild?.value; + const variable = variables[lastChildValue]; + if ( + lastChild?.type === TokenType.Variable && + variable && + variable?.value === dep.currentValue + ) { + dep.managerData = { + fileReplacePosition: variable.fileReplacePosition, + packageFile: variable.packageFile, + }; + dep.groupName = variable.key; + return { deps: [dep] }; + } + } + } + return null; +} + +function processPlugin({ + tokenMap, + packageFile, +}: SyntaxHandlerInput): SyntaxHandlerOutput { + const { pluginName, pluginVersion, methodName } = tokenMap; + const plugin = pluginName.value; + const depName = + methodName.value === 'kotlin' ? `org.jetbrains.kotlin.${plugin}` : plugin; + const lookupName = + methodName.value === 'kotlin' + ? `org.jetbrains.kotlin.${plugin}:org.jetbrains.kotlin.${plugin}.gradle.plugin` + : `${plugin}:${plugin}.gradle.plugin`; + const currentValue = pluginVersion.value; + const fileReplacePosition = pluginVersion.offset; + const dep = { + depType: 'plugin', + depName, + lookupName, + registryUrls: ['https://plugins.gradle.org/m2/'], + currentValue, + commitMessageTopic: `plugin ${depName}`, + managerData: { + fileReplacePosition, + packageFile, + }, + }; + return { deps: [dep] }; +} + +function processRegistryUrl({ + tokenMap, +}: SyntaxHandlerInput): SyntaxHandlerOutput { + const registryUrl = tokenMap.registryUrl?.value; + try { + if (registryUrl) { + const { host, protocol } = url.parse(registryUrl); + if (host && protocol) { + return { urls: [registryUrl] }; + } + } + } catch (e) { + // no-op + } + return null; +} + +function processLongFormDep({ + tokenMap, + variables, + packageFile, +}: SyntaxHandlerInput): SyntaxHandlerOutput { + const groupId = coercePotentialString(tokenMap.groupId, variables); + const artifactId = coercePotentialString(tokenMap.artifactId, variables); + const version = coercePotentialString(tokenMap.version, variables); + const dep = parseDependencyString([groupId, artifactId, version].join(':')); + if (dep) { + const versionToken: Token = tokenMap.version; + if (versionToken.type === TokenType.Word) { + const variable = variables[versionToken.value]; + dep.managerData = { + fileReplacePosition: variable.fileReplacePosition, + packageFile: variable.packageFile, + }; + } else { + dep.managerData = { + fileReplacePosition: versionToken.offset, + packageFile, + }; + } + return { deps: [dep] }; + } + return null; +} + +const matcherConfigs: SyntaxMatchConfig[] = [ + { + // foo = 'bar' + matchers: [ + { matchType: TokenType.Word, tokenMapKey: 'keyToken' }, + { matchType: TokenType.Assignment }, + { matchType: TokenType.String, tokenMapKey: 'valToken' }, + endOfInstruction, + ], + handler: handleAssignment, + }, + { + // 'foo.bar:baz:1.2.3' + matchers: [ + { + matchType: TokenType.String, + tokenMapKey: 'token', + }, + ], + handler: processDepString, + }, + { + // "foo.bar:baz:${bazVersion}" + matchers: [ + { + matchType: TokenType.StringInterpolation, + tokenMapKey: 'depInterpolation', + }, + ], + handler: processDepInterpolation, + }, + { + // id 'foo.bar' version '1.2.3' + matchers: [ + { + matchType: TokenType.Word, + matchValue: ['id', 'kotlin'], + tokenMapKey: 'methodName', + }, + { matchType: TokenType.String, tokenMapKey: 'pluginName' }, + { matchType: TokenType.Word, matchValue: 'version' }, + { matchType: TokenType.String, tokenMapKey: 'pluginVersion' }, + endOfInstruction, + ], + handler: processPlugin, + }, + { + // id('foo.bar') version '1.2.3' + matchers: [ + { + matchType: TokenType.Word, + matchValue: ['id', 'kotlin'], + tokenMapKey: 'methodName', + }, + { matchType: TokenType.LeftParen }, + { matchType: TokenType.String, tokenMapKey: 'pluginName' }, + { matchType: TokenType.RightParen }, + { matchType: TokenType.Word, matchValue: 'version' }, + { matchType: TokenType.String, tokenMapKey: 'pluginVersion' }, + endOfInstruction, + ], + handler: processPlugin, + }, + { + // url 'https://repo.spring.io/snapshot/' + matchers: [ + { matchType: TokenType.Word, matchValue: 'url' }, + { matchType: TokenType.String, tokenMapKey: 'registryUrl' }, + endOfInstruction, + ], + handler: processRegistryUrl, + }, + { + // url('https://repo.spring.io/snapshot/') + matchers: [ + { matchType: TokenType.Word, matchValue: 'url' }, + { matchType: TokenType.LeftParen }, + { matchType: TokenType.String, tokenMapKey: 'registryUrl' }, + { matchType: TokenType.RightParen }, + endOfInstruction, + ], + handler: processRegistryUrl, + }, + { + // group: "com.example", name: "my.dependency", version: "1.2.3" + matchers: [ + { matchType: TokenType.Word, matchValue: 'group' }, + { matchType: TokenType.Colon }, + { matchType: potentialStringTypes, tokenMapKey: 'groupId' }, + { matchType: TokenType.Comma }, + { matchType: TokenType.Word, matchValue: 'name' }, + { matchType: TokenType.Colon }, + { matchType: potentialStringTypes, tokenMapKey: 'artifactId' }, + { matchType: TokenType.Comma }, + { matchType: TokenType.Word, matchValue: 'version' }, + { matchType: TokenType.Colon }, + { matchType: potentialStringTypes, tokenMapKey: 'version' }, + endOfInstruction, + ], + handler: processLongFormDep, + }, + { + // ("com.example", "my.dependency", "1.2.3") + matchers: [ + { matchType: TokenType.LeftParen }, + { matchType: potentialStringTypes, tokenMapKey: 'groupId' }, + { matchType: TokenType.Comma }, + { matchType: potentialStringTypes, tokenMapKey: 'artifactId' }, + { matchType: TokenType.Comma }, + { matchType: potentialStringTypes, tokenMapKey: 'version' }, + { matchType: TokenType.RightParen }, + ], + handler: processLongFormDep, + }, + { + // (group = "com.example", name = "my.dependency", version = "1.2.3") + matchers: [ + { matchType: TokenType.LeftParen }, + { matchType: TokenType.Word, matchValue: 'group' }, + { matchType: TokenType.Assignment }, + { matchType: potentialStringTypes, tokenMapKey: 'groupId' }, + { matchType: TokenType.Comma }, + { matchType: TokenType.Word, matchValue: 'name' }, + { matchType: TokenType.Assignment }, + { matchType: potentialStringTypes, tokenMapKey: 'artifactId' }, + { matchType: TokenType.Comma }, + { matchType: TokenType.Word, matchValue: 'version' }, + { matchType: TokenType.Assignment }, + { matchType: potentialStringTypes, tokenMapKey: 'version' }, + { matchType: TokenType.RightParen }, + ], + handler: processLongFormDep, + }, +]; + +interface MatchConfig { + tokens: Token[]; + variables: PackageVariables; + packageFile: string; +} + +function tryMatch({ + tokens, + variables, + packageFile, +}: MatchConfig): SyntaxHandlerOutput { + for (const { matchers, handler } of matcherConfigs) { + const tokenMap = matchTokens(tokens, matchers); + if (tokenMap) { + const result = handler({ + packageFile, + variables, + tokenMap, + }); + if (result !== null) { + return result; + } + } + } + tokens.shift(); + return null; +} + +export function parseGradle( + input: string, + initVars: PackageVariables = {}, + packageFile?: string +): { deps: PackageDependency<ManagerData>[]; urls: string[] } { + const vars = { ...initVars }; + const deps: PackageDependency<ManagerData>[] = []; + const urls = []; + + const tokens = tokenize(input); + let prevTokensLength = tokens.length; + while (tokens.length) { + const matchResult = tryMatch({ tokens, variables: vars, packageFile }); + if (matchResult?.deps?.length) { + deps.push(...matchResult.deps); + } + if (matchResult?.vars) { + Object.assign(vars, matchResult.vars); + } + if (matchResult?.urls) { + urls.push(...matchResult.urls); + } + + // istanbul ignore if + if (tokens.length >= prevTokensLength) { + // Should not happen, but it's better to be prepared + logger.warn( + { packageFile }, + `${packageFile} parsing error, results can be incomplete` + ); + break; + } + prevTokensLength = tokens.length; + } + + return { deps, urls }; +} + +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*$` +); + +export function parseProps( + input: string, + packageFile?: string +): { vars: PackageVariables; deps: PackageDependency<ManagerData>[] } { + let offset = 0; + const vars = {}; + const deps = []; + for (const line of input.split('\n')) { + const lineMatch = propRegex.exec(line); + if (lineMatch) { + const { key, value, leftPart } = lineMatch.groups; + if (isDependencyString(value)) { + const dep = parseDependencyString(value); + deps.push({ + ...dep, + managerData: { + fileReplacePosition: + offset + leftPart.length + dep.depName.length + 1, + packageFile, + }, + }); + } else { + vars[key] = { + key, + value, + fileReplacePosition: offset + leftPart.length, + packageFile, + }; + } + } + offset += line.length + 1; + } + return { vars, deps }; +} diff --git a/lib/manager/gradle-lite/readme.md b/lib/manager/gradle-lite/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..c9778421d78420f0c8723dc67b8d9280547a926e --- /dev/null +++ b/lib/manager/gradle-lite/readme.md @@ -0,0 +1,6 @@ +`gradle-lite` is an an alternate manager for Gradle, and is written in JavaScript. +The main benefit of `gradle-lite` is that it skips the slow Gradle commands. + +You can use the default `gradle` manager and `gradle-lite` at the same time. + +If you like the commits from `gradle-lite`, you can use `gradle-lite` as a complete replacement for the default manager. diff --git a/lib/manager/gradle-lite/tokenizer.spec.ts b/lib/manager/gradle-lite/tokenizer.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d53d7dbde9c9b7335462779d021180a1531297a --- /dev/null +++ b/lib/manager/gradle-lite/tokenizer.spec.ts @@ -0,0 +1,186 @@ +import { TokenType } from './common'; +import { extractRawTokens, tokenize } from './tokenizer'; + +function tokenTypes(input): string[] { + return extractRawTokens(input).map((token) => token.type); +} + +describe('manager/gradle-lite/tokenizer', () => { + it('extractTokens', () => { + const samples = { + ' ': [TokenType.Space], + '\t': [TokenType.Space], + '\r': [TokenType.Space], + '\t\r': [TokenType.Space], + '\r\t': [TokenType.Space], + '// foobar': [TokenType.LineComment], + '/* foobar */': [TokenType.MultiComment], + '/* foo *//* bar */': [TokenType.MultiComment, TokenType.MultiComment], + '/* foo\nbar\nbaz */': [TokenType.MultiComment], + '/* foo\r\nbar\r\nbaz */': [TokenType.MultiComment], + '\n\n': [TokenType.Newline, TokenType.Newline], + ':': [TokenType.Colon], + ';': [TokenType.Semicolon], + '.': [TokenType.Dot], + '==': [TokenType.Operator], + '=': [TokenType.Assignment], + foo: [TokenType.Word], + 'foo.bar': [TokenType.Word, TokenType.Dot, TokenType.Word], + 'foo()': [TokenType.Word, TokenType.LeftParen, TokenType.RightParen], + 'foo[]': [TokenType.Word, TokenType.LeftBracket, TokenType.RightBracket], + '{{}}': [ + TokenType.LeftBrace, + TokenType.LeftBrace, + TokenType.RightBrace, + TokenType.RightBrace, + ], + '@': [TokenType.UnknownLexeme], + "'\\''": [ + TokenType.SingleQuotedStart, + TokenType.EscapedChar, + TokenType.SingleQuotedFinish, + ], + "'\\\"'": [ + TokenType.SingleQuotedStart, + TokenType.EscapedChar, + TokenType.SingleQuotedFinish, + ], + "'\\'\\\"'": [ + TokenType.SingleQuotedStart, + TokenType.EscapedChar, + TokenType.EscapedChar, + TokenType.SingleQuotedFinish, + ], + "'x'": [ + TokenType.SingleQuotedStart, + TokenType.Char, + TokenType.SingleQuotedFinish, + ], + "'\n'": [ + TokenType.SingleQuotedStart, + TokenType.Char, + TokenType.SingleQuotedFinish, + ], + "'$x'": [ + TokenType.SingleQuotedStart, + TokenType.Char, + TokenType.Char, + TokenType.SingleQuotedFinish, + ], + "''''''": ['tripleQuotedStart', 'tripleQuotedFinish'], + "'''x'''": ['tripleQuotedStart', TokenType.Char, 'tripleQuotedFinish'], + "'''\n'''": ['tripleQuotedStart', TokenType.Char, 'tripleQuotedFinish'], + "'''\\''''": [ + 'tripleQuotedStart', + TokenType.EscapedChar, + 'tripleQuotedFinish', + ], + "'''\\\"'''": [ + 'tripleQuotedStart', + TokenType.EscapedChar, + 'tripleQuotedFinish', + ], + "'''\\'\\\"'''": [ + 'tripleQuotedStart', + TokenType.EscapedChar, + TokenType.EscapedChar, + 'tripleQuotedFinish', + ], + '""': [TokenType.DoubleQuotedStart, TokenType.DoubleQuotedFinish], + '"\\""': [ + TokenType.DoubleQuotedStart, + TokenType.EscapedChar, + TokenType.DoubleQuotedFinish, + ], + '"\\\'"': [ + TokenType.DoubleQuotedStart, + TokenType.EscapedChar, + TokenType.DoubleQuotedFinish, + ], + '"\\"\\\'"': [ + TokenType.DoubleQuotedStart, + TokenType.EscapedChar, + TokenType.EscapedChar, + TokenType.DoubleQuotedFinish, + ], + '"x"': [ + TokenType.DoubleQuotedStart, + TokenType.Char, + TokenType.DoubleQuotedFinish, + ], + '"\n"': [ + TokenType.DoubleQuotedStart, + TokenType.Char, + TokenType.DoubleQuotedFinish, + ], + // eslint-disable-next-line no-template-curly-in-string + '"${x}"': [ + TokenType.DoubleQuotedStart, + TokenType.Variable, + TokenType.DoubleQuotedFinish, + ], + // eslint-disable-next-line no-template-curly-in-string + '"${foo}"': [ + TokenType.DoubleQuotedStart, + TokenType.Variable, + TokenType.DoubleQuotedFinish, + ], + // eslint-disable-next-line no-template-curly-in-string + '"${x()}"': [ + TokenType.DoubleQuotedStart, + TokenType.IgnoredInterpolationStart, + TokenType.UnknownLexeme, + TokenType.UnknownLexeme, + TokenType.UnknownLexeme, + TokenType.RightBrace, + TokenType.DoubleQuotedFinish, + ], + // eslint-disable-next-line no-template-curly-in-string + '"${x{}}"': [ + TokenType.DoubleQuotedStart, + TokenType.IgnoredInterpolationStart, + TokenType.UnknownLexeme, + TokenType.LeftBrace, + TokenType.RightBrace, + TokenType.RightBrace, + TokenType.DoubleQuotedFinish, + ], + }; + for (const [str, result] of Object.entries(samples)) { + expect(tokenTypes(str)).toStrictEqual(result); + } + }); + + it('tokenize', () => { + const samples = { + '@': [{ type: TokenType.UnknownFragment }], + '@@@': [{ type: TokenType.UnknownFragment }], + "'foobar'": [{ type: TokenType.String, value: 'foobar' }], + "'\\b'": [{ type: TokenType.String, value: '\b' }], + "'''foobar'''": [{ type: TokenType.String, value: 'foobar' }], + '"foobar"': [{ type: TokenType.String, value: 'foobar' }], + '"$foo"': [ + { + type: TokenType.StringInterpolation, + children: [{ type: TokenType.Variable }], + }, + ], + // eslint-disable-next-line no-template-curly-in-string + '" foo ${ bar } baz "': [ + { + type: TokenType.StringInterpolation, + children: [ + { type: TokenType.String, value: ' foo ' }, + { type: TokenType.Variable, value: 'bar' }, + { type: TokenType.String, value: ' baz ' }, + ], + }, + ], + // eslint-disable-next-line no-template-curly-in-string + '"${ x + y }"': [{ type: TokenType.StringInterpolation, isValid: false }], + }; + for (const [str, result] of Object.entries(samples)) { + expect(tokenize(str)).toMatchObject(result); + } + }); +}); diff --git a/lib/manager/gradle-lite/tokenizer.ts b/lib/manager/gradle-lite/tokenizer.ts new file mode 100644 index 0000000000000000000000000000000000000000..904cf29b161111c135981b8df31533d931401409 --- /dev/null +++ b/lib/manager/gradle-lite/tokenizer.ts @@ -0,0 +1,233 @@ +import moo from 'moo'; +import { StringInterpolation, Token, TokenType } from './common'; + +const escapedCharRegex = /\\['"bfnrt\\]/; +const escapedChars = { + [TokenType.EscapedChar]: { + match: escapedCharRegex, + value: (x: string): string => + ({ + "\\'": "'", + '\\"': '"', + '\\b': '\b', + '\\f': '\f', + '\\n': '\n', + '\\r': '\r', + '\\t': '\t', + '\\\\': '\\', + }[x]), + }, +}; + +export const rawLexer = { + // Top-level Groovy lexemes + main: { + [TokenType.LineComment]: { match: /\/\/.*?$/ }, + [TokenType.MultiComment]: { match: /\/\*[^]*?\*\//, lineBreaks: true }, + [TokenType.Newline]: { match: /\r?\n/, lineBreaks: true }, + [TokenType.Space]: { match: /[ \t\r]+/ }, + [TokenType.Semicolon]: ';', + [TokenType.Colon]: ':', + [TokenType.Dot]: '.', + [TokenType.Comma]: ',', + [TokenType.Operator]: /(?:==|\+=?|-=?|\/=?|\*\*?|\.+|:)/, + [TokenType.Assignment]: '=', + [TokenType.Word]: { match: /[a-zA-Z$_][a-zA-Z0-9$_]+/ }, + [TokenType.LeftParen]: { match: '(' }, + [TokenType.RightParen]: { match: ')' }, + [TokenType.LeftBracket]: { match: '[' }, + [TokenType.RightBracket]: { match: ']' }, + [TokenType.LeftBrace]: { match: '{', push: 'main' }, + [TokenType.RightBrace]: { match: '}', pop: 1 }, + [TokenType.TripleSingleQuotedStart]: { + match: "'''", + push: TokenType.TripleSingleQuotedStart, + }, + [TokenType.TripleDoubleQuotedStart]: { + match: '"""', + push: TokenType.TripleDoubleQuotedStart, + }, + [TokenType.SingleQuotedStart]: { + match: "'", + push: TokenType.SingleQuotedStart, + }, + [TokenType.DoubleQuotedStart]: { + match: '"', + push: TokenType.DoubleQuotedStart, + }, + [TokenType.UnknownLexeme]: { match: /./ }, + }, + + // Tokenize triple-quoted string literal characters + [TokenType.TripleSingleQuotedStart]: { + ...escapedChars, + [TokenType.TripleQuotedFinish]: { match: "'''", pop: 1 }, + [TokenType.Char]: { match: /[^]/, lineBreaks: true }, + }, + [TokenType.TripleDoubleQuotedStart]: { + ...escapedChars, + [TokenType.TripleQuotedFinish]: { match: '"""', pop: 1 }, + [TokenType.Char]: { match: /[^]/, lineBreaks: true }, + }, + + // Tokenize single-quoted string literal characters + [TokenType.SingleQuotedStart]: { + ...escapedChars, + [TokenType.SingleQuotedFinish]: { match: "'", pop: 1 }, + [TokenType.Char]: { match: /[^]/, lineBreaks: true }, + }, + + // Tokenize double-quoted string literal chars and interpolations + [TokenType.DoubleQuotedStart]: { + ...escapedChars, + [TokenType.DoubleQuotedFinish]: { match: '"', pop: 1 }, + variable: { + // Supported: ${foo}, $foo, ${ foo.bar.baz }, $foo.bar.baz + match: /\${\s*[a-zA-Z_][a-zA-Z0-9_]*(?:\s*\.\s*[a-zA-Z_][a-zA-Z0-9_]*)*\s*}|\$[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*/, + value: (x: string): string => + x.replace(/^\${?\s*/, '').replace(/\s*}$/, ''), + }, + [TokenType.IgnoredInterpolationStart]: { + match: /\${/, + push: TokenType.IgnoredInterpolationStart, + }, + [TokenType.Char]: { match: /[^]/, lineBreaks: true }, + }, + + // Ignore interpolation of complex expressions˙, + // but track the balance of braces to find the end of interpolation. + [TokenType.IgnoredInterpolationStart]: { + [TokenType.LeftBrace]: { + match: '{', + push: TokenType.IgnoredInterpolationStart, + }, + [TokenType.RightBrace]: { match: '}', pop: 1 }, + [TokenType.UnknownLexeme]: { match: /[^]/, lineBreaks: true }, + }, +}; + +/* + Turn UnknownLexeme chars to UnknownFragment strings + */ +function processUnknownLexeme(acc: Token[], token: Token): Token[] { + if (token.type === TokenType.UnknownLexeme) { + const prevToken: Token = acc[acc.length - 1]; + if (prevToken?.type === TokenType.UnknownFragment) { + prevToken.value += token.value; + } else { + acc.push({ ...token, type: TokenType.UnknownFragment }); + } + } else { + acc.push(token); + } + return acc; +} + +// +// Turn separated chars of string literal to single String token +// +function processChar(acc: Token[], token: Token): Token[] { + const tokenType = token.type; + const prevToken: Token = acc[acc.length - 1]; + if ([TokenType.Char, TokenType.EscapedChar].includes(tokenType)) { + if (prevToken?.type === TokenType.String) { + prevToken.value += token.value; + } else { + acc.push({ ...token, type: TokenType.String }); + } + } else { + acc.push(token); + } + return acc; +} + +export function isInterpolationToken( + token: Token +): token is StringInterpolation { + return token?.type === TokenType.StringInterpolation; +} + +// +// Turn all tokens between double quote pairs into StringInterpolation token +// +function processInterpolation(acc: Token[], token: Token): Token[] { + if (token.type === TokenType.DoubleQuotedStart) { + // This token will accumulate further strings and variables + const interpolationToken: StringInterpolation = { + type: TokenType.StringInterpolation, + children: [], + isValid: true, + isComplete: false, + offset: token.offset + 1, + value: '', + }; + acc.push(interpolationToken); + return acc; + } + + const prevToken: Token = acc[acc.length - 1]; + if (isInterpolationToken(prevToken) && !prevToken.isComplete) { + const type = token.type; + if (type === TokenType.DoubleQuotedFinish) { + if ( + prevToken.isValid && + prevToken.children.every(({ type: t }) => t === TokenType.String) + ) { + // Nothing to interpolate, replace to String + acc[acc.length - 1] = { + type: TokenType.String, + value: prevToken.children.map(({ value }) => value).join(''), + offset: prevToken.offset, + }; + return acc; + } + prevToken.isComplete = true; + } else if (type === TokenType.String || type === TokenType.Variable) { + prevToken.children.push(token); + } else { + prevToken.children.push(token); + prevToken.isValid = false; + } + } else { + acc.push(token); + } + return acc; +} + +const filteredTokens = [ + TokenType.Space, + TokenType.LineComment, + TokenType.MultiComment, + TokenType.Newline, + TokenType.Semicolon, + TokenType.SingleQuotedStart, + TokenType.SingleQuotedFinish, + TokenType.DoubleQuotedFinish, + TokenType.TripleSingleQuotedStart, + TokenType.TripleDoubleQuotedStart, + TokenType.TripleQuotedFinish, +]; + +function filterTokens({ type }: Token): boolean { + return !filteredTokens.includes(type); +} + +export function extractRawTokens(input: string): Token[] { + const lexer = moo.states(rawLexer); + lexer.reset(input); + return Array.from(lexer).map( + ({ type, offset, value }) => ({ type, offset, value } as Token) + ); +} + +export function processTokens(tokens: Token[]): Token[] { + return tokens + .reduce(processUnknownLexeme, []) + .reduce(processChar, []) + .reduce(processInterpolation, []) + .filter(filterTokens); +} + +export function tokenize(input: string): Token[] { + return processTokens(extractRawTokens(input)); +} diff --git a/lib/manager/gradle-lite/update.spec.ts b/lib/manager/gradle-lite/update.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..36ce9c2f1ff509e23d6b4b99029288314ef28aac --- /dev/null +++ b/lib/manager/gradle-lite/update.spec.ts @@ -0,0 +1,78 @@ +import { updateDependency } from './update'; + +describe('manager/gradle-lite/update', () => { + it('replaces', () => { + expect( + updateDependency({ + fileContent: '___1.2.3___', + upgrade: { + currentValue: '1.2.3', + newValue: '1.2.4', + managerData: { + fileReplacePosition: 3, + }, + }, + }) + ).toEqual('___1.2.4___'); + }); + + it('groups', () => { + expect( + updateDependency({ + fileContent: '___1.2.4___', + upgrade: { + currentValue: '1.2.3', + newValue: '1.2.5', + groupName: 'group', + managerData: { + fileReplacePosition: 3, + }, + }, + }) + ).toEqual('___1.2.5___'); + }); + + it('returns same content', () => { + const fileContent = '___1.2.4___'; + expect( + updateDependency({ + fileContent, + upgrade: { + currentValue: '1.2.3', + newValue: '1.2.4', + managerData: { + fileReplacePosition: 3, + }, + }, + }) + ).toBe(fileContent); + }); + + it('returns null', () => { + expect( + updateDependency({ + fileContent: '___1.3.0___', + upgrade: { + currentValue: '1.2.3', + newValue: '1.2.4', + managerData: { + fileReplacePosition: 3, + }, + }, + }) + ).toBeNull(); + + expect( + updateDependency({ + fileContent: '', + upgrade: { + currentValue: '1.2.3', + newValue: '1.2.4', + managerData: { + fileReplacePosition: 3, + }, + }, + }) + ).toBeNull(); + }); +}); diff --git a/lib/manager/gradle-lite/update.ts b/lib/manager/gradle-lite/update.ts new file mode 100644 index 0000000000000000000000000000000000000000..5da7d06e8d678fb05d07aa0d2f641bc3be6cb845 --- /dev/null +++ b/lib/manager/gradle-lite/update.ts @@ -0,0 +1,29 @@ +import { logger } from '../../logger'; +import { UpdateDependencyConfig } from '../common'; +import { ManagerData } from './common'; +import { versionLikeSubstring } from './utils'; + +export function updateDependency({ + fileContent, + upgrade, +}: UpdateDependencyConfig<ManagerData>): string | null { + const { depName, currentValue, newValue, managerData } = upgrade; + const offset = managerData.fileReplacePosition; + const leftPart = fileContent.slice(0, offset); + const rightPart = fileContent.slice(offset); + const version = versionLikeSubstring(rightPart); + if (version) { + const versionClosePosition = version.length; + const restPart = rightPart.slice(versionClosePosition); + if (version === newValue) { + return fileContent; + } + if (version === currentValue || upgrade.groupName) { + return leftPart + newValue + restPart; + } + logger.debug({ depName, version, currentValue, newValue }, 'Unknown value'); + } else { + logger.debug({ depName, currentValue, newValue }, 'Wrong offset'); + } + return null; +} diff --git a/lib/manager/gradle-lite/utils.spec.ts b/lib/manager/gradle-lite/utils.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f014b1e496a8e5fa063083404c61b1163e3ea61 --- /dev/null +++ b/lib/manager/gradle-lite/utils.spec.ts @@ -0,0 +1,183 @@ +import { TokenType } from './common'; +import { + getVars, + interpolateString, + isDependencyString, + parseDependencyString, + reorderFiles, + toAbsolutePath, + versionLikeSubstring, +} from './utils'; + +describe('manager/gradle-lite/utils', () => { + it('versionLikeSubstring', () => { + [ + '1.2.3', + 'foobar', + '[1.0,2.0]', + '(,2.0[', + '2.1.1.RELEASE', + '1.0.+', + 'latest', + ].forEach((input) => { + expect(versionLikeSubstring(input)).toEqual(input); + expect(versionLikeSubstring(`${input}'`)).toEqual(input); + expect(versionLikeSubstring(`${input}"`)).toEqual(input); + expect(versionLikeSubstring(`${input}\n`)).toEqual(input); + expect(versionLikeSubstring(`${input} `)).toEqual(input); + expect(versionLikeSubstring(`${input}$`)).toEqual(input); + }); + expect(versionLikeSubstring('')).toBeNull(); + expect(versionLikeSubstring(undefined)).toBeNull(); + expect(versionLikeSubstring(null)).toBeNull(); + }); + + it('isDependencyString', () => { + expect(isDependencyString('foo:bar:1.2.3')).toBe(true); + expect(isDependencyString('foo.foo:bar.bar:1.2.3')).toBe(true); + expect(isDependencyString('foo:bar:baz:qux')).toBe(false); + expect(isDependencyString('foo.bar:baz:1.2.3')).toBe(true); + expect(isDependencyString('foo.bar:baz:1.2.+')).toBe(true); + expect(isDependencyString('foo.bar:baz:qux:quux')).toBe(false); + expect(isDependencyString("foo:bar:1.2.3'")).toBe(false); + expect(isDependencyString('foo:bar:1.2.3"')).toBe(false); + expect(isDependencyString('-Xep:ParameterName:OFF')).toBe(false); + }); + + it('parseDependencyString', () => { + expect(parseDependencyString('foo:bar:1.2.3')).toMatchObject({ + depName: 'foo:bar', + currentValue: '1.2.3', + }); + expect(parseDependencyString('foo.foo:bar.bar:1.2.3')).toMatchObject({ + depName: 'foo.foo:bar.bar', + currentValue: '1.2.3', + }); + expect(parseDependencyString('foo:bar:baz:qux')).toBeNull(); + expect(parseDependencyString('foo.bar:baz:1.2.3')).toMatchObject({ + depName: 'foo.bar:baz', + currentValue: '1.2.3', + }); + expect(parseDependencyString('foo:bar:1.2.+')).toMatchObject({ + depName: 'foo:bar', + currentValue: '1.2.+', + }); + expect(parseDependencyString('foo.bar:baz:qux:quux')).toBeNull(); + expect(parseDependencyString("foo:bar:1.2.3'")).toBeNull(); + expect(parseDependencyString('foo:bar:1.2.3"')).toBeNull(); + expect(parseDependencyString('-Xep:ParameterName:OFF')).toBeNull(); + }); + + it('interpolateString', () => { + expect(interpolateString([], {})).toBe(''); + expect( + interpolateString( + [ + { type: TokenType.String, value: 'foo' }, + { type: TokenType.Variable, value: 'bar' }, + { type: TokenType.String, value: 'baz' }, + ] as never, + { + bar: { value: 'BAR' }, + } as never + ) + ).toBe('fooBARbaz'); + expect( + interpolateString( + [{ type: TokenType.Variable, value: 'foo' }] as never, + {} as never + ) + ).toBeNull(); + expect( + interpolateString( + [{ type: TokenType.UnknownFragment, value: 'foo' }] as never, + {} as never + ) + ).toBeNull(); + }); + + it('reorderFiles', () => { + expect(reorderFiles(['a.gradle', 'b.gradle', 'a.gradle'])).toStrictEqual([ + 'a.gradle', + 'a.gradle', + 'b.gradle', + ]); + + expect( + reorderFiles([ + 'a/b/c/build.gradle', + 'a/build.gradle', + 'a/b/build.gradle', + 'build.gradle', + ]) + ).toStrictEqual([ + 'build.gradle', + 'a/build.gradle', + 'a/b/build.gradle', + 'a/b/c/build.gradle', + ]); + + expect(reorderFiles(['b.gradle', 'c.gradle', 'a.gradle'])).toStrictEqual([ + 'a.gradle', + 'b.gradle', + 'c.gradle', + ]); + + expect( + reorderFiles(['b.gradle', 'c.gradle', 'a.gradle', 'gradle.properties']) + ).toStrictEqual(['gradle.properties', 'a.gradle', 'b.gradle', 'c.gradle']); + + expect( + reorderFiles([ + 'a/b/c/gradle.properties', + 'a/b/c/build.gradle', + 'a/build.gradle', + 'a/gradle.properties', + 'a/b/build.gradle', + 'a/b/gradle.properties', + 'build.gradle', + 'gradle.properties', + 'b.gradle', + 'c.gradle', + 'a.gradle', + ]) + ).toStrictEqual([ + 'gradle.properties', + 'a.gradle', + 'b.gradle', + 'build.gradle', + 'c.gradle', + 'a/gradle.properties', + 'a/build.gradle', + 'a/b/gradle.properties', + 'a/b/build.gradle', + 'a/b/c/gradle.properties', + 'a/b/c/build.gradle', + ]); + }); + + it('getVars', () => { + const registry = { + [toAbsolutePath('/foo')]: { + foo: { key: 'foo', value: 'FOO' } as never, + bar: { key: 'bar', value: 'BAR' } as never, + baz: { key: 'baz', value: 'BAZ' } as never, + qux: { key: 'qux', value: 'QUX' } as never, + }, + [toAbsolutePath('/foo/bar')]: { + foo: { key: 'foo', value: 'foo' } as never, + }, + [toAbsolutePath('/foo/bar/baz')]: { + bar: { key: 'bar', value: 'bar' } as never, + baz: { key: 'baz', value: 'baz' } as never, + }, + }; + const res = getVars(registry, '/foo/bar/baz/build.gradle'); + expect(res).toStrictEqual({ + foo: { key: 'foo', value: 'foo' }, + bar: { key: 'bar', value: 'bar' }, + baz: { key: 'baz', value: 'baz' }, + qux: { key: 'qux', value: 'QUX' }, + }); + }); +}); diff --git a/lib/manager/gradle-lite/utils.ts b/lib/manager/gradle-lite/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..4afe57e7f2c6410e7aefe5db0731a5c546fb6b47 --- /dev/null +++ b/lib/manager/gradle-lite/utils.ts @@ -0,0 +1,138 @@ +import upath from 'upath'; +import { regEx } from '../../util/regex'; +import { PackageDependency } from '../common'; +import { + ManagerData, + PackageVariables, + Token, + TokenType, + VariableRegistry, +} from './common'; + +const artifactRegex = regEx( + '^[a-zA-Z][-_a-zA-Z0-9]*(?:.[a-zA-Z][-_a-zA-Z0-9]*)*$' +); + +const versionLikeRegex = regEx('^(?<version>[-.\\[\\](),a-zA-Z0-9+]+)'); + +// Extracts version-like and range-like strings +// from the beginning of input +export function versionLikeSubstring(input: string): string | null { + const match = input ? versionLikeRegex.exec(input) : null; + return match ? match.groups.version : null; +} + +export function isDependencyString(input: string): boolean { + const split = input?.split(':'); + if (split?.length !== 3) { + return false; + } + const [groupId, artifactId, versionPart] = split; + return ( + groupId && + artifactId && + versionPart && + artifactRegex.test(groupId) && + artifactRegex.test(artifactId) && + versionPart === versionLikeSubstring(versionPart) + ); +} + +export function parseDependencyString( + input: string +): PackageDependency<ManagerData> | null { + if (!isDependencyString(input)) { + return null; + } + const [groupId, artifactId, currentValue] = input?.split(':'); + return { + depName: `${groupId}:${artifactId}`, + currentValue, + }; +} + +export function interpolateString( + childTokens: Token[], + variables: PackageVariables +): string | null { + const resolvedSubstrings = []; + for (const childToken of childTokens) { + const type = childToken.type; + if (type === TokenType.String) { + resolvedSubstrings.push(childToken.value); + } else if (type === TokenType.Variable) { + const varName = childToken.value; + const varData = variables[varName]; + if (varData) { + resolvedSubstrings.push(varData.value); + } else { + return null; + } + } else { + return null; + } + } + return resolvedSubstrings.join(''); +} + +export function isGradleFile(path: string): boolean { + const filename = upath.basename(path).toLowerCase(); + return filename.endsWith('.gradle') || filename.endsWith('.gradle.kts'); +} + +export function isPropsFile(path: string): boolean { + const filename = upath.basename(path).toLowerCase(); + return filename === 'gradle.properties'; +} + +export function toAbsolutePath(packageFile: string): string { + return upath.join(packageFile.replace(/^[/\\]*/, '/')); +} + +export function reorderFiles(packageFiles: string[]): string[] { + return packageFiles.sort((x, y) => { + const xAbs = toAbsolutePath(x); + const yAbs = toAbsolutePath(y); + + const xDir = upath.dirname(xAbs); + const yDir = upath.dirname(yAbs); + + if (xDir === yDir) { + if ( + (isGradleFile(xAbs) && isGradleFile(yAbs)) || + (isPropsFile(xAbs) && isPropsFile(yAbs)) + ) { + if (xAbs > yAbs) { + return 1; + } + if (xAbs < yAbs) { + return -1; + } + } else if (isGradleFile(xAbs)) { + return 1; + } else if (isGradleFile(yAbs)) { + return -1; + } + } else if (xDir.startsWith(yDir)) { + return 1; + } else if (yDir.startsWith(xDir)) { + return -1; + } + + return 0; + }); +} + +export function getVars( + registry: VariableRegistry, + dir: string, + vars: PackageVariables = registry[dir] || {} +): PackageVariables { + const dirAbs = toAbsolutePath(dir); + const parentDir = upath.dirname(dirAbs); + if (parentDir === dirAbs) { + return vars; + } + const parentVars = registry[parentDir] || {}; + return getVars(registry, parentDir, { ...parentVars, ...vars }); +} diff --git a/package.json b/package.json index cfc9ebd10f53803c021621574958eff462f7903b..760fc9c5c60c62389a031686054851775efce5fa 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "markdown-it": "12.0.2", "markdown-table": "2.0.0", "minimatch": "3.0.4", + "moo": "0.5.1", "node-emoji": "1.10.0", "p-all": "3.0.0", "p-map": "4.0.0", @@ -211,6 +212,7 @@ "@types/luxon": "1.25.0", "@types/markdown-it": "10.0.3", "@types/markdown-table": "2.0.0", + "@types/moo": "0.5.3", "@types/nock": "10.0.3", "@types/node": "12.19.8", "@types/node-emoji": "1.8.1", diff --git a/yarn.lock b/yarn.lock index 0e0a4b430b2738d26261857c2f154ba58a2707b5..acb795f2097d914da42728c4166f974595c2830b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1750,6 +1750,11 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.1.tgz#283f669ff76d7b8260df8ab7a4262cc83d988256" integrity sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg== +"@types/moo@0.5.3": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@types/moo/-/moo-0.5.3.tgz#d034ae641728c473d367e7b7afd991123b8bdecb" + integrity sha512-PJJ/jvb5Gor8DWvXN3e75njfQyYNRz0PaFSZ3br9GfHM9N2FxvuJ/E/ytcQePJOLzHlvgFSsIJIvfUMUxWTbnA== + "@types/nock@10.0.3": version "10.0.3" resolved "https://registry.yarnpkg.com/@types/nock/-/nock-10.0.3.tgz#dab1d18ffbccfbf2db811dab9584304eeb6e1c4c" @@ -7328,6 +7333,11 @@ moment@^2.19.3: resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== +moo@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4" + integrity sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w== + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"