diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts index 939a55fe1c58d6..7a9ca693431484 100644 --- a/lib/modules/manager/api.ts +++ b/lib/modules/manager/api.ts @@ -6,6 +6,7 @@ import * as azurePipelines from './azure-pipelines'; import * as batect from './batect'; import * as batectWrapper from './batect-wrapper'; import * as bazel from './bazel'; +import * as bazelModule from './bazel-module'; import * as bazelisk from './bazelisk'; import * as bicep from './bicep'; import * as bitbucketPipelines from './bitbucket-pipelines'; @@ -96,6 +97,7 @@ api.set('azure-pipelines', azurePipelines); api.set('batect', batect); api.set('batect-wrapper', batectWrapper); api.set('bazel', bazel); +api.set('bazel-module', bazelModule); api.set('bazelisk', bazelisk); api.set('bicep', bicep); api.set('bitbucket-pipelines', bitbucketPipelines); diff --git a/lib/modules/manager/bazel-module/bazel-dep.spec.ts b/lib/modules/manager/bazel-module/bazel-dep.spec.ts new file mode 100644 index 00000000000000..2b4c0226219102 --- /dev/null +++ b/lib/modules/manager/bazel-module/bazel-dep.spec.ts @@ -0,0 +1,23 @@ +import { BazelDatasource } from '../../datasource/bazel'; +import { ToBazelDep } from './bazel-dep'; +import * as fragments from './fragments'; + +describe('modules/manager/bazel-module/bazel-dep', () => { + describe('ToBazelDep', () => { + it('transforms a record fragment', () => { + const record = fragments.record({ + rule: fragments.string('bazel_dep'), + name: fragments.string('rules_foo'), + version: fragments.string('1.2.3'), + dev_dependency: fragments.boolean(true), + }); + const result = ToBazelDep.parse(record); + expect(result).toEqual({ + datasource: BazelDatasource.id, + depType: 'bazel_dep', + depName: 'rules_foo', + currentValue: '1.2.3', + }); + }); + }); +}); diff --git a/lib/modules/manager/bazel-module/bazel-dep.ts b/lib/modules/manager/bazel-module/bazel-dep.ts new file mode 100644 index 00000000000000..8d6f6c85e6cb14 --- /dev/null +++ b/lib/modules/manager/bazel-module/bazel-dep.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; +import { BazelDatasource } from '../../datasource/bazel'; +import type { PackageDependency } from '../types'; +import { + BooleanFragmentSchema, + RecordFragmentSchema, + StringFragmentSchema, +} from './fragments'; + +const BazelDepSchema = RecordFragmentSchema.extend({ + children: z.object({ + rule: StringFragmentSchema.extend({ + value: z.literal('bazel_dep'), + }), + name: StringFragmentSchema, + version: StringFragmentSchema, + dev_dependency: BooleanFragmentSchema.optional(), + }), +}); + +export const ToBazelDep = BazelDepSchema.transform( + ({ children: { rule, name, version } }): PackageDependency => ({ + datasource: BazelDatasource.id, + depType: rule.value, + depName: name.value, + currentValue: version.value, + }) +); diff --git a/lib/modules/manager/bazel-module/context.spec.ts b/lib/modules/manager/bazel-module/context.spec.ts new file mode 100644 index 00000000000000..06d22d0b35884e --- /dev/null +++ b/lib/modules/manager/bazel-module/context.spec.ts @@ -0,0 +1,98 @@ +import { Ctx, CtxProcessingError } from './context'; +import * as fragments from './fragments'; + +describe('modules/manager/bazel-module/context', () => { + describe('Ctx', () => { + it('construct simple bazel_dep', () => { + const ctx = new Ctx() + .startRule('bazel_dep') + .startAttribute('name') + .addString('rules_foo') + .startAttribute('version') + .addString('1.2.3') + .endRule(); + + expect(ctx.results).toEqual([ + fragments.record( + { + rule: fragments.string('bazel_dep'), + name: fragments.string('rules_foo'), + version: fragments.string('1.2.3'), + }, + true + ), + ]); + }); + + it('construct a rule with array arg', () => { + const ctx = new Ctx() + .startRule('foo_library') + .startAttribute('name') + .addString('my_library') + .startAttribute('srcs') + .startArray() + .addString('first') + .addString('second') + .endArray() + .endRule(); + + expect(ctx.results).toEqual([ + fragments.record( + { + rule: fragments.string('foo_library'), + name: fragments.string('my_library'), + srcs: fragments.array( + [fragments.string('first'), fragments.string('second')], + true + ), + }, + true + ), + ]); + }); + + describe('.currentRecord', () => { + it('returns the record fragment if it is current', () => { + const ctx = new Ctx().startRecord(); + expect(ctx.currentRecord).toEqual(fragments.record()); + }); + + it('throws if there is no current', () => { + const ctx = new Ctx(); + expect(() => ctx.currentRecord).toThrow( + new Error('Requested current, but no value.') + ); + }); + + it('throws if the current is not a record fragment', () => { + const ctx = new Ctx().startArray(); + expect(() => ctx.currentRecord).toThrow( + new Error('Requested current record, but does not exist.') + ); + }); + }); + + describe('.currentArray', () => { + it('returns the array fragment if it is current', () => { + const ctx = new Ctx().startArray(); + expect(ctx.currentArray).toEqual(fragments.array()); + }); + + it('throws if the current is not a record fragment', () => { + const ctx = new Ctx().startRecord(); + expect(() => ctx.currentArray).toThrow( + new Error('Requested current array, but does not exist.') + ); + }); + }); + + it('throws if add an attribute without a parent', () => { + const ctx = new Ctx().startAttribute('name'); + expect(() => ctx.addString('chicken')).toThrow( + new CtxProcessingError( + fragments.attribute('name', fragments.string('chicken')) + ) + ); + }); + }); +}); diff --git a/lib/modules/manager/bazel-module/context.ts b/lib/modules/manager/bazel-module/context.ts new file mode 100644 index 00000000000000..b590a560e02652 --- /dev/null +++ b/lib/modules/manager/bazel-module/context.ts @@ -0,0 +1,154 @@ +import type { + AllFragments, + ArrayFragment, + ChildFragments, + RecordFragment, +} from './fragments'; +import * as fragments from './fragments'; + +// Represents the fields that the context must have. +export interface CtxCompatible { + results: RecordFragment[]; + stack: AllFragments[]; +} + +export class CtxProcessingError extends Error { + readonly current: AllFragments; + readonly parent?: AllFragments; + constructor(current: AllFragments, parent?: AllFragments) { + const msg = `Invalid context state. current: ${current.type}, parent: ${ + parent?.type ?? 'none' + }`; + super(msg); + this.name = 'CtxProcessingError'; + this.current = current; + this.parent = parent; + } +} + +export class Ctx implements CtxCompatible { + results: RecordFragment[]; + stack: AllFragments[]; + + constructor(results: RecordFragment[] = [], stack: AllFragments[] = []) { + this.results = results; + this.stack = stack; + } + + private get safeCurrent(): AllFragments | undefined { + return this.stack.at(-1); + } + + private get current(): AllFragments { + const c = this.safeCurrent; + if (c === undefined) { + throw new Error('Requested current, but no value.'); + } + return c; + } + get currentRecord(): RecordFragment { + const current = this.current; + if (current.type === 'record') { + return current; + } + throw new Error('Requested current record, but does not exist.'); + } + + get currentArray(): ArrayFragment { + const current = this.current; + if (current.type === 'array') { + return current; + } + throw new Error('Requested current array, but does not exist.'); + } + + private popStack(): boolean { + const current = this.stack.pop(); + if (!current) { + return false; + } + if (!current.isComplete) { + this.stack.push(current); + return false; + } + const parent = this.safeCurrent; + + if (parent) { + if (parent.type === 'attribute' && fragments.isValue(current)) { + parent.value = current; + parent.isComplete = true; + return true; + } + if (parent.type === 'array' && fragments.isPrimitive(current)) { + parent.items.push(current); + return true; + } + if ( + parent.type === 'record' && + current.type === 'attribute' && + current.value !== undefined + ) { + parent.children[current.name] = current.value; + return true; + } + } else if (current.type === 'record') { + this.results.push(current); + return true; + } + + throw new CtxProcessingError(current, parent); + } + + private processStack(): Ctx { + while (this.popStack()) { + // Nothing to do + } + return this; + } + + addString(value: string): Ctx { + this.stack.push(fragments.string(value)); + return this.processStack(); + } + + addBoolean(value: string | boolean): Ctx { + this.stack.push(fragments.boolean(value)); + return this.processStack(); + } + + startRecord(children: ChildFragments = {}): Ctx { + const record = fragments.record(children); + this.stack.push(record); + return this; + } + + endRecord(): Ctx { + const record = this.currentRecord; + record.isComplete = true; + return this.processStack(); + } + + startRule(name: string): Ctx { + return this.startRecord({ rule: fragments.string(name) }); + } + + endRule(): Ctx { + return this.endRecord(); + } + + startAttribute(name: string): Ctx { + this.stack.push(fragments.attribute(name)); + return this.processStack(); + } + + startArray(): Ctx { + this.stack.push(fragments.array()); + return this.processStack(); + } + + endArray(): Ctx { + const array = this.currentArray; + array.isComplete = true; + return this.processStack(); + } +} diff --git a/lib/modules/manager/bazel-module/extract.spec.ts b/lib/modules/manager/bazel-module/extract.spec.ts new file mode 100644 index 00000000000000..8207d4e0f5dcf8 --- /dev/null +++ b/lib/modules/manager/bazel-module/extract.spec.ts @@ -0,0 +1,58 @@ +import { codeBlock } from 'common-tags'; +import { BazelDatasource } from '../../datasource/bazel'; +import * as parser from './parser'; +import { extractPackageFile } from '.'; + +describe('modules/manager/bazel-module/extract', () => { + describe('extractPackageFile()', () => { + it('returns null if fails to parse', () => { + const result = extractPackageFile('blahhhhh:foo:@what\n', 'MODULE.bazel'); + expect(result).toBeNull(); + }); + + it('returns null if something throws an error', () => { + jest.spyOn(parser, 'parse').mockImplementationOnce((input) => { + throw new Error('Test error'); + }); + const result = extractPackageFile('content', 'MODULE.bazel'); + expect(result).toBeNull(); + }); + + it('returns null if file is empty', () => { + const result = extractPackageFile('', 'MODULE.bazel'); + expect(result).toBeNull(); + }); + + it('returns null if file has not recognized declarations', () => { + const input = codeBlock` + ignore_me(name = "rules_foo", version = "1.2.3") + `; + const result = extractPackageFile(input, 'MODULE.bazel'); + expect(result).toBeNull(); + }); + + it('returns dependencies', () => { + const input = codeBlock` + bazel_dep(name = "rules_foo", version = "1.2.3") + bazel_dep(name = "rules_bar", version = "1.0.0", dev_dependency = True) + `; + const result = extractPackageFile(input, 'MODULE.bazel'); + expect(result).toEqual({ + deps: [ + { + datasource: BazelDatasource.id, + depType: 'bazel_dep', + depName: 'rules_foo', + currentValue: '1.2.3', + }, + { + datasource: BazelDatasource.id, + depType: 'bazel_dep', + depName: 'rules_bar', + currentValue: '1.0.0', + }, + ], + }); + }); + }); +}); diff --git a/lib/modules/manager/bazel-module/extract.ts b/lib/modules/manager/bazel-module/extract.ts new file mode 100644 index 00000000000000..056900579d401a --- /dev/null +++ b/lib/modules/manager/bazel-module/extract.ts @@ -0,0 +1,20 @@ +import { logger } from '../../../logger'; +import { LooseArray } from '../../../util/schema-utils'; +import type { PackageFileContent } from '../types'; +import { ToBazelDep } from './bazel-dep'; +import { parse } from './parser'; + +export function extractPackageFile( + content: string, + filename: string +): PackageFileContent | null { + try { + const records = parse(content); + return LooseArray(ToBazelDep) + .transform((deps) => (deps.length ? { deps } : null)) + .parse(records); + } catch (err) { + logger.debug({ err, filename }, 'Failed to parse bazel module file.'); + return null; + } +} diff --git a/lib/modules/manager/bazel-module/fragments.spec.ts b/lib/modules/manager/bazel-module/fragments.spec.ts new file mode 100644 index 00000000000000..bf30857ceef00f --- /dev/null +++ b/lib/modules/manager/bazel-module/fragments.spec.ts @@ -0,0 +1,64 @@ +import { + ArrayFragmentSchema, + AttributeFragmentSchema, + BooleanFragmentSchema, + RecordFragmentSchema, + StringFragmentSchema, +} from './fragments'; +import * as fragments from './fragments'; + +describe('modules/manager/bazel-module/fragments', () => { + it('.string()', () => { + const result = fragments.string('hello'); + expect(() => StringFragmentSchema.parse(result)).not.toThrow(); + expect(result.value).toBe('hello'); + }); + + it('.boolean()', () => { + const result = fragments.boolean(true); + expect(() => BooleanFragmentSchema.parse(result)).not.toThrow(); + expect(result.value).toBe(true); + }); + + it('.record()', () => { + const result = fragments.record({ name: fragments.string('foo') }, true); + expect(() => RecordFragmentSchema.parse(result)).not.toThrow(); + expect(result.children).toEqual({ name: fragments.string('foo') }); + expect(result.isComplete).toBe(true); + }); + + it('.attribute()', () => { + const result = fragments.attribute('name', fragments.string('foo'), true); + expect(() => AttributeFragmentSchema.parse(result)).not.toThrow(); + expect(result.name).toBe('name'); + expect(result.value).toEqual(fragments.string('foo')); + expect(result.isComplete).toBe(true); + }); + + it('.array()', () => { + const result = fragments.array([fragments.string('foo')], true); + expect(() => ArrayFragmentSchema.parse(result)).not.toThrow(); + expect(result.items).toEqual([fragments.string('foo')]); + expect(result.isComplete).toBe(true); + }); + + it.each` + a | exp + ${fragments.string('hello')} | ${true} + ${fragments.boolean(true)} | ${true} + ${fragments.array()} | ${true} + ${fragments.record()} | ${false} + `('.isValue($a)', ({ a, exp }) => { + expect(fragments.isValue(a)).toBe(exp); + }); + + it.each` + a | exp + ${fragments.string('hello')} | ${true} + ${fragments.boolean(true)} | ${true} + ${fragments.array()} | ${false} + ${fragments.record()} | ${false} + `('.isValue($a)', ({ a, exp }) => { + expect(fragments.isPrimitive(a)).toBe(exp); + }); +}); diff --git a/lib/modules/manager/bazel-module/fragments.ts b/lib/modules/manager/bazel-module/fragments.ts new file mode 100644 index 00000000000000..0a873a4b2eed4a --- /dev/null +++ b/lib/modules/manager/bazel-module/fragments.ts @@ -0,0 +1,117 @@ +import { z } from 'zod'; +import { LooseArray, LooseRecord } from '../../../util/schema-utils'; +import * as starlark from './starlark'; + +export const StringFragmentSchema = z.object({ + type: z.literal('string'), + value: z.string(), + isComplete: z.literal(true), +}); +export const BooleanFragmentSchema = z.object({ + type: z.literal('boolean'), + value: z.boolean(), + isComplete: z.literal(true), +}); +const PrimitiveFragmentsSchema = z.discriminatedUnion('type', [ + StringFragmentSchema, + BooleanFragmentSchema, +]); +export const ArrayFragmentSchema = z.object({ + type: z.literal('array'), + items: LooseArray(PrimitiveFragmentsSchema), + isComplete: z.boolean(), +}); +const ValueFragmentsSchema = z.discriminatedUnion('type', [ + StringFragmentSchema, + BooleanFragmentSchema, + ArrayFragmentSchema, +]); +export const RecordFragmentSchema = z.object({ + type: z.literal('record'), + children: LooseRecord(ValueFragmentsSchema), + isComplete: z.boolean(), +}); +export const AttributeFragmentSchema = z.object({ + type: z.literal('attribute'), + name: z.string(), + value: ValueFragmentsSchema.optional(), + isComplete: z.boolean(), +}); +const AllFragmentsSchema = z.discriminatedUnion('type', [ + ArrayFragmentSchema, + AttributeFragmentSchema, + BooleanFragmentSchema, + RecordFragmentSchema, + StringFragmentSchema, +]); + +export type AllFragments = z.infer; +export type ArrayFragment = z.infer; +export type AttributeFragment = z.infer; +export type BooleanFragment = z.infer; +export type ChildFragments = Record; +export type PrimitiveFragments = z.infer; +export type RecordFragment = z.infer; +export type StringFragment = z.infer; +export type ValueFragments = z.infer; + +export function string(value: string): StringFragment { + return { + type: 'string', + isComplete: true, + value, + }; +} + +export function boolean(value: string | boolean): BooleanFragment { + return { + type: 'boolean', + isComplete: true, + value: typeof value === 'string' ? starlark.asBoolean(value) : value, + }; +} + +export function record( + children: ChildFragments = {}, + isComplete = false +): RecordFragment { + return { + type: 'record', + isComplete, + children, + }; +} + +export function attribute( + name: string, + value?: ValueFragments, + isComplete = false +): AttributeFragment { + return { + type: 'attribute', + name, + value, + isComplete, + }; +} + +export function array( + items: PrimitiveFragments[] = [], + isComplete = false +): ArrayFragment { + return { + type: 'array', + items, + isComplete, + }; +} + +export function isValue(data: unknown): data is ValueFragments { + const result = ValueFragmentsSchema.safeParse(data); + return result.success; +} + +export function isPrimitive(data: unknown): data is PrimitiveFragments { + const result = PrimitiveFragmentsSchema.safeParse(data); + return result.success; +} diff --git a/lib/modules/manager/bazel-module/index.ts b/lib/modules/manager/bazel-module/index.ts new file mode 100644 index 00000000000000..3312b93fe6573d --- /dev/null +++ b/lib/modules/manager/bazel-module/index.ts @@ -0,0 +1,14 @@ +import { BazelDatasource } from '../../datasource/bazel'; +import { extractPackageFile } from './extract'; + +export { extractPackageFile }; + +export const defaultConfig = { + fileMatch: ['(^|/)MODULE\\.bazel$'], + // The bazel-module manager is still under development. The milestone + // tracking the release of this manager is at + // https://github.com/renovatebot/renovate/issues/13658. + enabled: false, +}; + +export const supportedDatasources = [BazelDatasource.id]; diff --git a/lib/modules/manager/bazel-module/parser.spec.ts b/lib/modules/manager/bazel-module/parser.spec.ts new file mode 100644 index 00000000000000..56392fef8a50ec --- /dev/null +++ b/lib/modules/manager/bazel-module/parser.spec.ts @@ -0,0 +1,44 @@ +import { codeBlock } from 'common-tags'; +import * as fragments from './fragments'; +import { parse } from './parser'; + +describe('modules/manager/bazel-module/parser', () => { + describe('parse', () => { + it('returns empty string if invalid content', () => { + const input = codeBlock` + // This is invalid + a + 1 + <<<<<<< + `; + const res = parse(input); + expect(res).toHaveLength(0); + }); + + it('finds simple bazel_dep', () => { + const input = codeBlock` + bazel_dep(name = "rules_foo", version = "1.2.3") + bazel_dep(name = "rules_bar", version = "1.0.0", dev_dependency = True) + `; + const res = parse(input); + expect(res).toEqual([ + fragments.record( + { + rule: fragments.string('bazel_dep'), + name: fragments.string('rules_foo'), + version: fragments.string('1.2.3'), + }, + true + ), + fragments.record( + { + rule: fragments.string('bazel_dep'), + name: fragments.string('rules_bar'), + version: fragments.string('1.0.0'), + dev_dependency: fragments.boolean(true), + }, + true + ), + ]); + }); + }); +}); diff --git a/lib/modules/manager/bazel-module/parser.ts b/lib/modules/manager/bazel-module/parser.ts new file mode 100644 index 00000000000000..6a33772183e942 --- /dev/null +++ b/lib/modules/manager/bazel-module/parser.ts @@ -0,0 +1,48 @@ +import { lang, query as q } from 'good-enough-parser'; +import { regEx } from '../../../util/regex'; +import { Ctx } from './context'; +import type { RecordFragment } from './fragments'; +import * as starlark from './starlark'; + +const booleanValuesRegex = regEx(`^${starlark.booleanStringValues.join('|')}$`); +const supportedRules = ['bazel_dep']; +const supportedRulesRegex = regEx(`^${supportedRules.join('|')}$`); + +/** + * Matches key-value pairs: + * - `name = "foobar"` + * - `dev_dependeny = True` + **/ +const kvParams = q + .sym((ctx, token) => ctx.startAttribute(token.value)) + .op('=') + .alt( + q.str((ctx, token) => ctx.addString(token.value)), + q.sym(booleanValuesRegex, (ctx, token) => ctx.addBoolean(token.value)) + ); + +const moduleRules = q + .sym(supportedRulesRegex, (ctx, token) => ctx.startRule(token.value)) + .join( + q.tree({ + type: 'wrapped-tree', + maxDepth: 1, + search: kvParams, + postHandler: (ctx, tree) => ctx.endRule(), + }) + ); + +const rule = q.alt(moduleRules); + +const query = q.tree({ + type: 'root-tree', + maxDepth: 16, + search: rule, +}); + +const starlarkLang = lang.createLang('starlark'); + +export function parse(input: string): RecordFragment[] { + const parsedResult = starlarkLang.query(input, query, new Ctx()); + return parsedResult?.results ?? []; +} diff --git a/lib/modules/manager/bazel-module/readme.md b/lib/modules/manager/bazel-module/readme.md new file mode 100644 index 00000000000000..06ce659dcaf89f --- /dev/null +++ b/lib/modules/manager/bazel-module/readme.md @@ -0,0 +1,8 @@ + +!!! warning + The `bazel-module` manager is a work-in-progress. + It is currently disabled. + The manager only supports updating `bazel_dep` declarations. + For more information, see [issue 13658](https://github.com/renovatebot/renovate/issues/13658). + +The `bazel-module` manager can update [Bazel module (bzlmod)](https://bazel.build/external/module) enabled workspaces. diff --git a/lib/modules/manager/bazel-module/starlark.spec.ts b/lib/modules/manager/bazel-module/starlark.spec.ts new file mode 100644 index 00000000000000..efd3e50ab19a74 --- /dev/null +++ b/lib/modules/manager/bazel-module/starlark.spec.ts @@ -0,0 +1,17 @@ +import * as starlark from './starlark'; + +describe('modules/manager/bazel-module/starlark', () => { + it.each` + a | exp + ${'True'} | ${true} + ${'False'} | ${false} + `('.asBoolean($a)', ({ a, exp }) => { + expect(starlark.asBoolean(a)).toBe(exp); + }); + + it('asBoolean', () => { + expect(() => starlark.asBoolean('bad')).toThrow( + new Error('Invalid Starlark boolean string: bad') + ); + }); +}); diff --git a/lib/modules/manager/bazel-module/starlark.ts b/lib/modules/manager/bazel-module/starlark.ts new file mode 100644 index 00000000000000..7fb06d2db338bb --- /dev/null +++ b/lib/modules/manager/bazel-module/starlark.ts @@ -0,0 +1,16 @@ +import is from '@sindresorhus/is'; + +const stringMapping: ReadonlyMap = new Map([ + ['True', true], + ['False', false], +]); + +export const booleanStringValues = Array.from(stringMapping.keys()); + +export function asBoolean(value: string): boolean { + const result = stringMapping.get(value); + if (is.boolean(result)) { + return result; + } + throw new Error(`Invalid Starlark boolean string: ${value}`); +}