From 2a809f4d98ba10b10913fb2ed6ea421c5663f44a Mon Sep 17 00:00:00 2001
From: Tobias Schlatter <schlatter.tobias@gmail.com>
Date: Wed, 12 Feb 2025 17:32:27 +0100
Subject: [PATCH] refactor(bazel-module): use bazel syntactic concepts in
 parser (#34154)

---
 .../manager/bazel-module/context.spec.ts      | 80 ++++++++++++++----
 lib/modules/manager/bazel-module/context.ts   | 79 +++++++++++++-----
 lib/modules/manager/bazel-module/extract.ts   |  8 +-
 .../manager/bazel-module/fragments.spec.ts    | 71 ++++++++++++----
 lib/modules/manager/bazel-module/fragments.ts | 70 ++++++++++++++--
 .../bazel-module/parser/extension-tags.ts     | 50 +++++++++++
 .../manager/bazel-module/parser/index.spec.ts | 74 ++++++++++-------
 .../manager/bazel-module/parser/index.ts      | 11 ++-
 .../manager/bazel-module/parser/maven.ts      | 83 +++++++------------
 .../manager/bazel-module/parser/oci.ts        | 35 +++-----
 .../parser/{module.ts => rules.ts}            | 12 ++-
 .../manager/bazel-module/rules.spec.ts        | 39 ++++-----
 lib/modules/manager/bazel-module/rules.ts     | 56 ++++++-------
 13 files changed, 432 insertions(+), 236 deletions(-)
 create mode 100644 lib/modules/manager/bazel-module/parser/extension-tags.ts
 rename lib/modules/manager/bazel-module/parser/{module.ts => rules.ts} (57%)

diff --git a/lib/modules/manager/bazel-module/context.spec.ts b/lib/modules/manager/bazel-module/context.spec.ts
index a134bb69fe..2cf9c71a9f 100644
--- a/lib/modules/manager/bazel-module/context.spec.ts
+++ b/lib/modules/manager/bazel-module/context.spec.ts
@@ -13,9 +13,9 @@ describe('modules/manager/bazel-module/context', () => {
         .endRule();
 
       expect(ctx.results).toEqual([
-        fragments.record(
+        fragments.rule(
+          'bazel_dep',
           {
-            rule: fragments.string('bazel_dep'),
             name: fragments.string('rules_foo'),
             version: fragments.string('1.2.3'),
           },
@@ -32,9 +32,9 @@ describe('modules/manager/bazel-module/context', () => {
         .endRule();
 
       expect(ctx.results).toEqual([
-        fragments.record(
+        fragments.rule(
+          'bazel_dep',
           {
-            rule: fragments.string('bazel_dep'),
             name: fragments.string('rules_foo'),
           },
           true,
@@ -55,9 +55,9 @@ describe('modules/manager/bazel-module/context', () => {
         .endRule();
 
       expect(ctx.results).toEqual([
-        fragments.record(
+        fragments.rule(
+          'foo_library',
           {
-            rule: fragments.string('foo_library'),
             name: fragments.string('my_library'),
             srcs: fragments.array(
               [fragments.string('first'), fragments.string('second')],
@@ -69,23 +69,73 @@ describe('modules/manager/bazel-module/context', () => {
       ]);
     });
 
-    describe('.currentRecord', () => {
+    it('construct an extension tag', () => {
+      const ctx = new Ctx()
+        .prepareExtensionTag('maven', 'maven_01')
+        .startExtensionTag('install')
+        .startAttribute('artifacts')
+        .startArray()
+        .addString('org.example:my-lib:1.0.0')
+        .endArray()
+        .endExtensionTag();
+
+      expect(ctx.results).toEqual([
+        fragments.extensionTag(
+          'maven',
+          'maven_01',
+          'install',
+          {
+            artifacts: fragments.array(
+              [fragments.string('org.example:my-lib:1.0.0')],
+              true,
+            ),
+          },
+          true,
+        ),
+      ]);
+    });
+
+    describe('extension tag failure cases', () => {
+      it('throws if there is no current', () => {
+        expect(() => new Ctx().startExtensionTag('install')).toThrow(
+          new Error('Requested current, but no value.'),
+        );
+      });
+
+      it('throws if the current is not a prepared extension tag', () => {
+        expect(() =>
+          new Ctx().startRule('foo').startExtensionTag('install'),
+        ).toThrow(
+          new Error(
+            'Requested current prepared extension tag, but does not exist.',
+          ),
+        );
+      });
+
+      it('throws if the current is not an extension tag', () => {
+        expect(() => new Ctx().startRule('foo').endExtensionTag()).toThrow(
+          new Error('Requested current extension tag, but does not exist.'),
+        );
+      });
+    });
+
+    describe('.currentRule', () => {
       it('returns the record fragment if it is current', () => {
-        const ctx = new Ctx().startRecord();
-        expect(ctx.currentRecord).toEqual(fragments.record());
+        const ctx = new Ctx().startRule('dummy');
+        expect(ctx.currentRule).toEqual(fragments.rule('dummy'));
       });
 
       it('throws if there is no current', () => {
         const ctx = new Ctx();
-        expect(() => ctx.currentRecord).toThrow(
+        expect(() => ctx.currentRule).toThrow(
           new Error('Requested current, but no value.'),
         );
       });
 
-      it('throws if the current is not a record fragment', () => {
+      it('throws if the current is not a rule fragment', () => {
         const ctx = new Ctx().startArray();
-        expect(() => ctx.currentRecord).toThrow(
-          new Error('Requested current record, but does not exist.'),
+        expect(() => ctx.currentRule).toThrow(
+          new Error('Requested current rule, but does not exist.'),
         );
       });
     });
@@ -96,8 +146,8 @@ describe('modules/manager/bazel-module/context', () => {
         expect(ctx.currentArray).toEqual(fragments.array());
       });
 
-      it('throws if the current is not a record fragment', () => {
-        const ctx = new Ctx().startRecord();
+      it('throws if the current is not an array fragment', () => {
+        const ctx = new Ctx().startRule('dummy');
         expect(() => ctx.currentArray).toThrow(
           new Error('Requested current array, but does not exist.'),
         );
diff --git a/lib/modules/manager/bazel-module/context.ts b/lib/modules/manager/bazel-module/context.ts
index b590a560e0..6ad1a20ea1 100644
--- a/lib/modules/manager/bazel-module/context.ts
+++ b/lib/modules/manager/bazel-module/context.ts
@@ -1,14 +1,16 @@
 import type {
   AllFragments,
   ArrayFragment,
-  ChildFragments,
-  RecordFragment,
+  ExtensionTagFragment,
+  PreparedExtensionTagFragment,
+  ResultFragment,
+  RuleFragment,
 } from './fragments';
 import * as fragments from './fragments';
 
 // Represents the fields that the context must have.
 export interface CtxCompatible {
-  results: RecordFragment[];
+  results: ResultFragment[];
   stack: AllFragments[];
 }
 
@@ -27,12 +29,12 @@ export class CtxProcessingError extends Error {
 }
 
 export class Ctx implements CtxCompatible {
-  results: RecordFragment[];
+  results: ResultFragment[];
   stack: AllFragments[];
 
-  constructor(results: RecordFragment[] = [], stack: AllFragments[] = []) {
-    this.results = results;
-    this.stack = stack;
+  constructor() {
+    this.results = [];
+    this.stack = [];
   }
 
   private get safeCurrent(): AllFragments | undefined {
@@ -46,12 +48,20 @@ export class Ctx implements CtxCompatible {
     }
     return c;
   }
-  get currentRecord(): RecordFragment {
+  get currentRule(): RuleFragment {
     const current = this.current;
-    if (current.type === 'record') {
+    if (current.type === 'rule') {
       return current;
     }
-    throw new Error('Requested current record, but does not exist.');
+    throw new Error('Requested current rule, but does not exist.');
+  }
+
+  get currentExtensionTag(): ExtensionTagFragment {
+    const current = this.current;
+    if (current.type === 'extensionTag') {
+      return current;
+    }
+    throw new Error('Requested current extension tag, but does not exist.');
   }
 
   get currentArray(): ArrayFragment {
@@ -62,6 +72,19 @@ export class Ctx implements CtxCompatible {
     throw new Error('Requested current array, but does not exist.');
   }
 
+  private popPreparedExtensionTag(): PreparedExtensionTagFragment {
+    const c = this.stack.pop();
+    if (c === undefined) {
+      throw new Error('Requested current, but no value.');
+    }
+    if (c.type === 'preparedExtensionTag') {
+      return c;
+    }
+    throw new Error(
+      'Requested current prepared extension tag, but does not exist.',
+    );
+  }
+
   private popStack(): boolean {
     const current = this.stack.pop();
     if (!current) {
@@ -84,14 +107,14 @@ export class Ctx implements CtxCompatible {
         return true;
       }
       if (
-        parent.type === 'record' &&
+        (parent.type === 'rule' || parent.type === 'extensionTag') &&
         current.type === 'attribute' &&
         current.value !== undefined
       ) {
         parent.children[current.name] = current.value;
         return true;
       }
-    } else if (current.type === 'record') {
+    } else if (current.type === 'rule' || current.type === 'extensionTag') {
       this.results.push(current);
       return true;
     }
@@ -116,24 +139,36 @@ export class Ctx implements CtxCompatible {
     return this.processStack();
   }
 
-  startRecord(children: ChildFragments = {}): Ctx {
-    const record = fragments.record(children);
-    this.stack.push(record);
+  startRule(name: string): Ctx {
+    const rule = fragments.rule(name);
+    this.stack.push(rule);
     return this;
   }
 
-  endRecord(): Ctx {
-    const record = this.currentRecord;
-    record.isComplete = true;
+  endRule(): Ctx {
+    const rule = this.currentRule;
+    rule.isComplete = true;
     return this.processStack();
   }
 
-  startRule(name: string): Ctx {
-    return this.startRecord({ rule: fragments.string(name) });
+  prepareExtensionTag(extension: string, rawExtension: string): Ctx {
+    const preppedTag = fragments.preparedExtensionTag(extension, rawExtension);
+    this.stack.push(preppedTag);
+    return this;
   }
 
-  endRule(): Ctx {
-    return this.endRecord();
+  startExtensionTag(tag: string): Ctx {
+    const { extension, rawExtension } = this.popPreparedExtensionTag();
+
+    const extensionTag = fragments.extensionTag(extension, rawExtension, tag);
+    this.stack.push(extensionTag);
+    return this;
+  }
+
+  endExtensionTag(): Ctx {
+    const tag = this.currentExtensionTag;
+    tag.isComplete = true;
+    return this.processStack();
   }
 
   startAttribute(name: string): Ctx {
diff --git a/lib/modules/manager/bazel-module/extract.ts b/lib/modules/manager/bazel-module/extract.ts
index 8774057c32..ce16ba3c17 100644
--- a/lib/modules/manager/bazel-module/extract.ts
+++ b/lib/modules/manager/bazel-module/extract.ts
@@ -4,7 +4,7 @@ import { isNotNullOrUndefined } from '../../../util/array';
 import { LooseArray } from '../../../util/schema-utils';
 import type { PackageDependency, PackageFileContent } from '../types';
 import * as bazelrc from './bazelrc';
-import type { RecordFragment } from './fragments';
+import type { ResultFragment } from './fragments';
 import { parse } from './parser';
 import { RuleToMavenPackageDep, fillRegistryUrls } from './parser/maven';
 import { RuleToDockerPackageDep } from './parser/oci';
@@ -45,7 +45,7 @@ export async function extractPackageFile(
 }
 
 async function extractBazelPfc(
-  records: RecordFragment[],
+  records: ResultFragment[],
   packageFile: string,
 ): Promise<PackageFileContent> {
   const pfc: PackageFileContent = LooseArray(RuleToBazelModulePackageDep)
@@ -66,12 +66,12 @@ async function extractBazelPfc(
 }
 
 function extractGitRepositoryDeps(
-  records: RecordFragment[],
+  records: ResultFragment[],
 ): PackageDependency[] {
   return LooseArray(GitRepositoryToPackageDep).parse(records);
 }
 
-function extractMavenDeps(records: RecordFragment[]): PackageDependency[] {
+function extractMavenDeps(records: ResultFragment[]): PackageDependency[] {
   return LooseArray(RuleToMavenPackageDep)
     .transform(fillRegistryUrls)
     .parse(records);
diff --git a/lib/modules/manager/bazel-module/fragments.spec.ts b/lib/modules/manager/bazel-module/fragments.spec.ts
index bf30857cee..e352b35898 100644
--- a/lib/modules/manager/bazel-module/fragments.spec.ts
+++ b/lib/modules/manager/bazel-module/fragments.spec.ts
@@ -2,7 +2,9 @@ import {
   ArrayFragmentSchema,
   AttributeFragmentSchema,
   BooleanFragmentSchema,
-  RecordFragmentSchema,
+  ExtensionTagFragmentSchema,
+  PreparedExtensionTagFragmentSchema,
+  RuleFragmentSchema,
   StringFragmentSchema,
 } from './fragments';
 import * as fragments from './fragments';
@@ -20,13 +22,46 @@ describe('modules/manager/bazel-module/fragments', () => {
     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') });
+  it('.rule()', () => {
+    const result = fragments.rule(
+      'foo',
+      { name: fragments.string('bar') },
+      true,
+    );
+    expect(() => RuleFragmentSchema.parse(result)).not.toThrow();
+    expect(result.rule).toBe('foo');
+    expect(result.children).toEqual({ name: fragments.string('bar') });
     expect(result.isComplete).toBe(true);
   });
 
+  it('.extensionTag()', () => {
+    const result = fragments.extensionTag(
+      'ext',
+      'ext_01',
+      'tag',
+      { name: fragments.string('bar') },
+      true,
+    );
+
+    expect(() => ExtensionTagFragmentSchema.parse(result)).not.toThrow();
+    expect(result.extension).toBe('ext');
+    expect(result.rawExtension).toBe('ext_01');
+    expect(result.tag).toBe('tag');
+    expect(result.children).toEqual({ name: fragments.string('bar') });
+    expect(result.isComplete).toBe(true);
+  });
+
+  it('.preparedExtensionTag()', () => {
+    const result = fragments.preparedExtensionTag('ext', 'ext_01');
+
+    expect(() =>
+      PreparedExtensionTagFragmentSchema.parse(result),
+    ).not.toThrow();
+    expect(result.extension).toBe('ext');
+    expect(result.rawExtension).toBe('ext_01');
+    expect(result.isComplete).toBe(false);
+  });
+
   it('.attribute()', () => {
     const result = fragments.attribute('name', fragments.string('foo'), true);
     expect(() => AttributeFragmentSchema.parse(result)).not.toThrow();
@@ -43,22 +78,26 @@ describe('modules/manager/bazel-module/fragments', () => {
   });
 
   it.each`
-    a                            | exp
-    ${fragments.string('hello')} | ${true}
-    ${fragments.boolean(true)}   | ${true}
-    ${fragments.array()}         | ${true}
-    ${fragments.record()}        | ${false}
+    a                                                  | exp
+    ${fragments.string('hello')}                       | ${true}
+    ${fragments.boolean(true)}                         | ${true}
+    ${fragments.array()}                               | ${true}
+    ${fragments.rule('dummy')}                         | ${false}
+    ${fragments.extensionTag('ext', 'ext_01', 'tag')}  | ${false}
+    ${fragments.preparedExtensionTag('ext', 'ext_01')} | ${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 }) => {
+    a                                                  | exp
+    ${fragments.string('hello')}                       | ${true}
+    ${fragments.boolean(true)}                         | ${true}
+    ${fragments.array()}                               | ${false}
+    ${fragments.rule('dummy')}                         | ${false}
+    ${fragments.extensionTag('ext', 'ext_01', 'tag')}  | ${false}
+    ${fragments.preparedExtensionTag('ext', 'ext_01')} | ${false}
+  `('.isPrimitive($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
index 806a9beb15..4d686d2288 100644
--- a/lib/modules/manager/bazel-module/fragments.ts
+++ b/lib/modules/manager/bazel-module/fragments.ts
@@ -31,8 +31,26 @@ const ValueFragmentsSchema = z.discriminatedUnion('type', [
   BooleanFragmentSchema,
   ArrayFragmentSchema,
 ]);
-export const RecordFragmentSchema = z.object({
-  type: z.literal('record'),
+export const RuleFragmentSchema = z.object({
+  type: z.literal('rule'),
+  rule: z.string(),
+  children: LooseRecord(ValueFragmentsSchema),
+  isComplete: z.boolean(),
+});
+export const PreparedExtensionTagFragmentSchema = z.object({
+  type: z.literal('preparedExtensionTag'),
+  // See ExtensionTagFragmentSchema for documentation of the fields.
+  extension: z.string(),
+  rawExtension: z.string(),
+  isComplete: z.literal(false), // never complete, parser internal type.
+});
+export const ExtensionTagFragmentSchema = z.object({
+  type: z.literal('extensionTag'),
+  // The "logical" name of the extension (e.g. `oci` or `maven`).
+  extension: z.string(),
+  // The "raw" name of the extension as it appears in the MODULE file (e.g. `maven_01` or `maven`)
+  rawExtension: z.string(),
+  tag: z.string(),
   children: LooseRecord(ValueFragmentsSchema),
   isComplete: z.boolean(),
 });
@@ -46,7 +64,9 @@ export const AllFragmentsSchema = z.discriminatedUnion('type', [
   ArrayFragmentSchema,
   AttributeFragmentSchema,
   BooleanFragmentSchema,
-  RecordFragmentSchema,
+  RuleFragmentSchema,
+  PreparedExtensionTagFragmentSchema,
+  ExtensionTagFragmentSchema,
   StringFragmentSchema,
 ]);
 
@@ -56,9 +76,14 @@ export type AttributeFragment = z.infer<typeof AttributeFragmentSchema>;
 export type BooleanFragment = z.infer<typeof BooleanFragmentSchema>;
 export type ChildFragments = Record<string, ValueFragments>;
 export type PrimitiveFragments = z.infer<typeof PrimitiveFragmentsSchema>;
-export type RecordFragment = z.infer<typeof RecordFragmentSchema>;
+export type RuleFragment = z.infer<typeof RuleFragmentSchema>;
+export type PreparedExtensionTagFragment = z.infer<
+  typeof PreparedExtensionTagFragmentSchema
+>;
+export type ExtensionTagFragment = z.infer<typeof ExtensionTagFragmentSchema>;
 export type StringFragment = z.infer<typeof StringFragmentSchema>;
 export type ValueFragments = z.infer<typeof ValueFragmentsSchema>;
+export type ResultFragment = RuleFragment | ExtensionTagFragment;
 
 export function string(value: string): StringFragment {
   return {
@@ -76,12 +101,43 @@ export function boolean(value: string | boolean): BooleanFragment {
   };
 }
 
-export function record(
+export function rule(
+  rule: string,
+  children: ChildFragments = {},
+  isComplete = false,
+): RuleFragment {
+  return {
+    type: 'rule',
+    rule,
+    isComplete,
+    children,
+  };
+}
+
+export function preparedExtensionTag(
+  extension: string,
+  rawExtension: string,
+): PreparedExtensionTagFragment {
+  return {
+    type: 'preparedExtensionTag',
+    extension,
+    rawExtension,
+    isComplete: false, // never complete
+  };
+}
+
+export function extensionTag(
+  extension: string,
+  rawExtension: string,
+  tag: string,
   children: ChildFragments = {},
   isComplete = false,
-): RecordFragment {
+): ExtensionTagFragment {
   return {
-    type: 'record',
+    type: 'extensionTag',
+    extension,
+    rawExtension,
+    tag,
     isComplete,
     children,
   };
diff --git a/lib/modules/manager/bazel-module/parser/extension-tags.ts b/lib/modules/manager/bazel-module/parser/extension-tags.ts
new file mode 100644
index 0000000000..231112c091
--- /dev/null
+++ b/lib/modules/manager/bazel-module/parser/extension-tags.ts
@@ -0,0 +1,50 @@
+import { query as q } from 'good-enough-parser';
+import { regEx } from '../../../../util/regex';
+import type { Ctx } from '../context';
+import { kvParams } from './common';
+
+import { mavenExtensionPrefix, mavenExtensionTags } from './maven';
+import { ociExtensionPrefix, ociExtensionTags } from './oci';
+
+// In bazel modules an extension tag is (roughly) a "member function application".
+// For example:
+//
+//     oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
+//     ^^^ --> the extension definition (not parsed by this module)
+//
+//     oci.pull(<parameters>)
+//         ^^^^ --> the extension tag
+//
+// The name of the extension (`oci` in the example above) technically arbitrary.
+// However, in practice, there are conventions. We use this to simplify parsing
+// by assuming the extension names start with well-known prefixes.
+
+const supportedExtensionRegex = regEx(
+  `^(${ociExtensionPrefix}|${mavenExtensionPrefix}).*$`,
+);
+
+const supportedExtensionTags = [...mavenExtensionTags, ...ociExtensionTags];
+
+const supportedExtensionTagsRegex = regEx(
+  `^(${supportedExtensionTags.join('|')})$`,
+);
+
+export const extensionTags = q
+  .sym<Ctx>(supportedExtensionRegex, (ctx, token) => {
+    const rawExtension = token.value;
+    const match = rawExtension.match(supportedExtensionRegex)!;
+    const extension = match[1];
+    return ctx.prepareExtensionTag(extension, rawExtension);
+  })
+  .op('.')
+  .sym(supportedExtensionTagsRegex, (ctx, token) => {
+    return ctx.startExtensionTag(token.value);
+  })
+  .join(
+    q.tree({
+      type: 'wrapped-tree',
+      maxDepth: 1,
+      search: kvParams,
+      postHandler: (ctx) => ctx.endExtensionTag(),
+    }),
+  );
diff --git a/lib/modules/manager/bazel-module/parser/index.spec.ts b/lib/modules/manager/bazel-module/parser/index.spec.ts
index f3783f8777..cbb629ed0a 100644
--- a/lib/modules/manager/bazel-module/parser/index.spec.ts
+++ b/lib/modules/manager/bazel-module/parser/index.spec.ts
@@ -21,17 +21,17 @@ describe('modules/manager/bazel-module/parser/index', () => {
       `;
       const res = parse(input);
       expect(res).toEqual([
-        fragments.record(
+        fragments.rule(
+          'bazel_dep',
           {
-            rule: fragments.string('bazel_dep'),
             name: fragments.string('rules_foo'),
             version: fragments.string('1.2.3'),
           },
           true,
         ),
-        fragments.record(
+        fragments.rule(
+          'bazel_dep',
           {
-            rule: fragments.string('bazel_dep'),
             name: fragments.string('rules_bar'),
             version: fragments.string('1.0.0'),
             dev_dependency: fragments.boolean(true),
@@ -54,17 +54,17 @@ describe('modules/manager/bazel-module/parser/index', () => {
       `;
       const res = parse(input);
       expect(res).toEqual([
-        fragments.record(
+        fragments.rule(
+          'bazel_dep',
           {
-            rule: fragments.string('bazel_dep'),
             name: fragments.string('rules_foo'),
             version: fragments.string('1.2.3'),
           },
           true,
         ),
-        fragments.record(
+        fragments.rule(
+          'git_override',
           {
-            rule: fragments.string('git_override'),
             module_name: fragments.string('rules_foo'),
             patches: fragments.array(
               [fragments.string('//:rules_foo.patch')],
@@ -94,17 +94,17 @@ describe('modules/manager/bazel-module/parser/index', () => {
       `;
       const res = parse(input);
       expect(res).toEqual([
-        fragments.record(
+        fragments.rule(
+          'bazel_dep',
           {
-            rule: fragments.string('bazel_dep'),
             name: fragments.string('rules_foo'),
             version: fragments.string('1.2.3'),
           },
           true,
         ),
-        fragments.record(
+        fragments.rule(
+          'archive_override',
           {
-            rule: fragments.string('archive_override'),
             module_name: fragments.string('rules_foo'),
             urls: fragments.array(
               [fragments.string('https://example.com/archive.tar.gz')],
@@ -126,17 +126,17 @@ describe('modules/manager/bazel-module/parser/index', () => {
       `;
       const res = parse(input);
       expect(res).toEqual([
-        fragments.record(
+        fragments.rule(
+          'bazel_dep',
           {
-            rule: fragments.string('bazel_dep'),
             name: fragments.string('rules_foo'),
             version: fragments.string('1.2.3'),
           },
           true,
         ),
-        fragments.record(
+        fragments.rule(
+          'local_path_override',
           {
-            rule: fragments.string('local_path_override'),
             module_name: fragments.string('rules_foo'),
             urls: fragments.string('/path/to/repo'),
           },
@@ -156,17 +156,17 @@ describe('modules/manager/bazel-module/parser/index', () => {
       `;
       const res = parse(input);
       expect(res).toEqual([
-        fragments.record(
+        fragments.rule(
+          'bazel_dep',
           {
-            rule: fragments.string('bazel_dep'),
             name: fragments.string('rules_foo'),
             version: fragments.string('1.2.3'),
           },
           true,
         ),
-        fragments.record(
+        fragments.rule(
+          'single_version_override',
           {
-            rule: fragments.string('single_version_override'),
             module_name: fragments.string('rules_foo'),
             version: fragments.string('1.2.3'),
             registry: fragments.string('https://example.com/custom_registry'),
@@ -193,9 +193,11 @@ describe('modules/manager/bazel-module/parser/index', () => {
       `;
       const res = parse(input);
       expect(res).toEqual([
-        fragments.record(
+        fragments.extensionTag(
+          'maven',
+          'maven',
+          'artifact',
           {
-            rule: fragments.string('maven_artifact'),
             group: fragments.string('org.clojure'),
             artifact: fragments.string('core.specs.alpha'),
             version: fragments.string('0.2.56'),
@@ -212,9 +214,11 @@ describe('modules/manager/bazel-module/parser/index', () => {
           },
           true,
         ),
-        fragments.record(
+        fragments.extensionTag(
+          'maven',
+          'maven_1',
+          'artifact',
           {
-            rule: fragments.string('maven_artifact'),
             group: fragments.string('org.clojure1'),
             artifact: fragments.string('core.specs.alpha1'),
             version: fragments.string('0.2.561'),
@@ -244,9 +248,11 @@ describe('modules/manager/bazel-module/parser/index', () => {
       `;
       const res = parse(input);
       expect(res).toEqual([
-        fragments.record(
+        fragments.extensionTag(
+          'maven',
+          'maven',
+          'install',
           {
-            rule: fragments.string('maven_install'),
             artifacts: fragments.array(
               [
                 {
@@ -275,9 +281,11 @@ describe('modules/manager/bazel-module/parser/index', () => {
           },
           true,
         ),
-        fragments.record(
+        fragments.extensionTag(
+          'maven',
+          'maven',
+          'artifact',
           {
-            rule: fragments.string('maven_artifact'),
             group: fragments.string('org.clojure'),
             artifact: fragments.string('core.specs.alpha'),
             version: fragments.string('0.2.56'),
@@ -300,9 +308,11 @@ describe('modules/manager/bazel-module/parser/index', () => {
 
       const res = parse(input);
       expect(res).toEqual([
-        fragments.record(
+        fragments.extensionTag(
+          'oci',
+          'oci',
+          'pull',
           {
-            rule: fragments.string('oci_pull'),
             name: fragments.string('nginx_image'),
             digest: fragments.string(
               'sha256:287ff321f9e3cde74b600cc26197424404157a72043226cbbf07ee8304a2c720',
@@ -328,9 +338,9 @@ describe('modules/manager/bazel-module/parser/index', () => {
       `;
       const res = parse(input);
       expect(res).toEqual([
-        fragments.record(
+        fragments.rule(
+          'git_repository',
           {
-            rule: fragments.string('git_repository'),
             name: fragments.string('rules_foo'),
             patches: fragments.array(
               [fragments.string('//:rules_foo.patch')],
diff --git a/lib/modules/manager/bazel-module/parser/index.ts b/lib/modules/manager/bazel-module/parser/index.ts
index b53bce2241..cd25ead145 100644
--- a/lib/modules/manager/bazel-module/parser/index.ts
+++ b/lib/modules/manager/bazel-module/parser/index.ts
@@ -1,11 +1,10 @@
 import { lang, query as q } from 'good-enough-parser';
 import { Ctx } from '../context';
-import type { RecordFragment } from '../fragments';
-import { mavenRules } from './maven';
-import { moduleRules } from './module';
-import { ociRules } from './oci';
+import type { ResultFragment } from '../fragments';
+import { extensionTags } from './extension-tags';
+import { rules } from './rules';
 
-const rule = q.alt<Ctx>(moduleRules, mavenRules, ociRules);
+const rule = q.alt<Ctx>(rules, extensionTags);
 
 const query = q.tree<Ctx>({
   type: 'root-tree',
@@ -15,7 +14,7 @@ const query = q.tree<Ctx>({
 
 const starlarkLang = lang.createLang('starlark');
 
-export function parse(input: string): RecordFragment[] {
+export function parse(input: string): ResultFragment[] {
   const parsedResult = starlarkLang.query(input, query, new Ctx());
   return parsedResult?.results ?? [];
 }
diff --git a/lib/modules/manager/bazel-module/parser/maven.ts b/lib/modules/manager/bazel-module/parser/maven.ts
index 56376b8a13..9b6bf797b7 100644
--- a/lib/modules/manager/bazel-module/parser/maven.ts
+++ b/lib/modules/manager/bazel-module/parser/maven.ts
@@ -1,26 +1,23 @@
-import { query as q } from 'good-enough-parser';
 import { z } from 'zod';
-import { regEx } from '../../../../util/regex';
 import { MavenDatasource } from '../../../datasource/maven';
 import { id as versioning } from '../../../versioning/gradle';
 import type { PackageDependency } from '../../types';
-import type { Ctx } from '../context';
 import {
-  RecordFragmentSchema,
+  ExtensionTagFragmentSchema,
   StringArrayFragmentSchema,
   StringFragmentSchema,
 } from '../fragments';
-import { kvParams } from './common';
 
-const artifactMethod = 'artifact';
-const installMethod = 'install';
+const artifactTag = 'artifact';
+const installTag = 'install';
 const commonDepType = 'maven_install';
-const mavenVariableRegex = regEx(/^maven.*/);
-const bzlmodMavenMethods = [installMethod, artifactMethod];
-const methodRegex = regEx(`^${bzlmodMavenMethods.join('|')}$`);
 
-function getParsedRuleByMethod(method: string): string {
-  return `maven_${method}`;
+export const mavenExtensionPrefix = 'maven';
+
+export const mavenExtensionTags = [artifactTag, installTag];
+
+function depTypeByTag(tag: string): string {
+  return `maven_${tag}`;
 }
 
 const ArtifactSpec = z.object({
@@ -30,32 +27,30 @@ const ArtifactSpec = z.object({
 });
 type ArtifactSpec = z.infer<typeof ArtifactSpec>;
 
-const MavenArtifactTarget = RecordFragmentSchema.extend({
+const MavenArtifactTarget = ExtensionTagFragmentSchema.extend({
+  extension: z.literal(mavenExtensionPrefix),
+  tag: z.literal(artifactTag),
   children: z.object({
-    rule: StringFragmentSchema.extend({
-      value: z.literal(getParsedRuleByMethod(artifactMethod)),
-    }),
     artifact: StringFragmentSchema,
     group: StringFragmentSchema,
     version: StringFragmentSchema,
   }),
 }).transform(
-  ({ children: { rule, artifact, group, version } }): PackageDependency[] => [
+  ({ children: { artifact, group, version } }): PackageDependency[] => [
     {
       datasource: MavenDatasource.id,
       versioning,
       depName: `${group.value}:${artifact.value}`,
       currentValue: version.value,
-      depType: rule.value,
+      depType: depTypeByTag(artifactTag),
     },
   ],
 );
 
-const MavenInstallTarget = RecordFragmentSchema.extend({
+const MavenInstallTarget = ExtensionTagFragmentSchema.extend({
+  extension: z.literal(mavenExtensionPrefix),
+  tag: z.literal(installTag),
   children: z.object({
-    rule: StringFragmentSchema.extend({
-      value: z.literal(getParsedRuleByMethod(installMethod)),
-    }),
     artifacts: StringArrayFragmentSchema.transform((artifacts) => {
       const result: ArtifactSpec[] = [];
       for (const { value } of artifacts.items) {
@@ -69,16 +64,15 @@ const MavenInstallTarget = RecordFragmentSchema.extend({
     }),
     repositories: StringArrayFragmentSchema,
   }),
-}).transform(
-  ({ children: { rule, artifacts, repositories } }): PackageDependency[] =>
-    artifacts.map(({ group, artifact, version: currentValue }) => ({
-      datasource: MavenDatasource.id,
-      versioning,
-      depName: `${group}:${artifact}`,
-      currentValue,
-      depType: rule.value,
-      registryUrls: repositories.items.map((i) => i.value),
-    })),
+}).transform(({ children: { artifacts, repositories } }): PackageDependency[] =>
+  artifacts.map(({ group, artifact, version: currentValue }) => ({
+    datasource: MavenDatasource.id,
+    versioning,
+    depName: `${group}:${artifact}`,
+    currentValue,
+    depType: depTypeByTag(installTag),
+    registryUrls: repositories.items.map((i) => i.value),
+  })),
 );
 
 export const RuleToMavenPackageDep = z.union([
@@ -95,12 +89,12 @@ export function fillRegistryUrls(
 
   // registry urls are specified only in maven.install, not in maven.artifact
   packageDeps.flat().forEach((dep) => {
-    if (dep.depType === getParsedRuleByMethod(installMethod)) {
+    if (dep.depType === depTypeByTag(installTag)) {
       if (Array.isArray(dep.registryUrls)) {
         registryUrls.push(...dep.registryUrls);
         result.push(dep);
       }
-    } else if (dep.depType === getParsedRuleByMethod(artifactMethod)) {
+    } else if (dep.depType === depTypeByTag(artifactTag)) {
       artifactRules.push(dep);
     }
   });
@@ -115,24 +109,3 @@ export function fillRegistryUrls(
 
   return result;
 }
-
-export const mavenRules = q
-  .sym<Ctx>(mavenVariableRegex, (ctx, token) => {
-    return ctx.startRule(token.value);
-  })
-  .op('.')
-  .sym(methodRegex, (ctx, token) => {
-    const rule = ctx.currentRecord.children.rule;
-    if (rule.type === 'string') {
-      rule.value = getParsedRuleByMethod(token.value);
-    }
-    return ctx;
-  })
-  .join(
-    q.tree({
-      type: 'wrapped-tree',
-      maxDepth: 1,
-      search: kvParams,
-      postHandler: (ctx) => ctx.endRule(),
-    }),
-  );
diff --git a/lib/modules/manager/bazel-module/parser/oci.ts b/lib/modules/manager/bazel-module/parser/oci.ts
index 60b4e556a2..f295a987af 100644
--- a/lib/modules/manager/bazel-module/parser/oci.ts
+++ b/lib/modules/manager/bazel-module/parser/oci.ts
@@ -1,41 +1,30 @@
-import { query as q } from 'good-enough-parser';
 import { z } from 'zod';
 import { DockerDatasource } from '../../../datasource/docker';
 import type { PackageDependency } from '../../types';
-import type { Ctx } from '../context';
-import { RecordFragmentSchema, StringFragmentSchema } from '../fragments';
-import { kvParams } from './common';
+import { ExtensionTagFragmentSchema, StringFragmentSchema } from '../fragments';
 
-export const RuleToDockerPackageDep = RecordFragmentSchema.extend({
+export const ociExtensionPrefix = 'oci';
+
+const pullTag = 'pull';
+
+export const ociExtensionTags = ['pull'];
+
+export const RuleToDockerPackageDep = ExtensionTagFragmentSchema.extend({
+  extension: z.literal(ociExtensionPrefix),
+  tag: z.literal(pullTag),
   children: z.object({
-    rule: StringFragmentSchema.extend({
-      value: z.literal('oci_pull'),
-    }),
     name: StringFragmentSchema,
     image: StringFragmentSchema,
     tag: StringFragmentSchema.optional(),
     digest: StringFragmentSchema.optional(),
   }),
 }).transform(
-  ({ children: { rule, name, image, tag, digest } }): PackageDependency => ({
+  ({ children: { name, image, tag, digest } }): PackageDependency => ({
     datasource: DockerDatasource.id,
-    depType: rule.value,
+    depType: 'oci_pull',
     depName: name.value,
     packageName: image.value,
     currentValue: tag?.value,
     currentDigest: digest?.value,
   }),
 );
-
-export const ociRules = q
-  .sym<Ctx>('oci')
-  .op('.')
-  .sym('pull', (ctx, token) => ctx.startRule('oci_pull'))
-  .join(
-    q.tree({
-      type: 'wrapped-tree',
-      maxDepth: 1,
-      search: kvParams,
-      postHandler: (ctx) => ctx.endRule(),
-    }),
-  );
diff --git a/lib/modules/manager/bazel-module/parser/module.ts b/lib/modules/manager/bazel-module/parser/rules.ts
similarity index 57%
rename from lib/modules/manager/bazel-module/parser/module.ts
rename to lib/modules/manager/bazel-module/parser/rules.ts
index 54371e9a7b..dfcadaf5db 100644
--- a/lib/modules/manager/bazel-module/parser/module.ts
+++ b/lib/modules/manager/bazel-module/parser/rules.ts
@@ -3,6 +3,16 @@ import { regEx } from '../../../../util/regex';
 import type { Ctx } from '../context';
 import { kvParams } from './common';
 
+// For the purpose of parsing bazel module files in Renovate, we consider a rule
+// to be any "direct function application". For example:
+//
+//     bazel_dep(name = "platforms", version = "0.0.11")
+//     ^^^^^^^^^ --> the "rule"
+//
+// In bazel, rules have typically a more narrow definition. However:
+// - They are syntactically indistinguishable from, say, macros.
+// - In informal speech, "rule" is often used as umbrella term.
+
 const supportedRules = [
   'archive_override',
   'bazel_dep',
@@ -13,7 +23,7 @@ const supportedRules = [
 ];
 const supportedRulesRegex = regEx(`^${supportedRules.join('|')}$`);
 
-export const moduleRules = q
+export const rules = q
   .sym<Ctx>(supportedRulesRegex, (ctx, token) => ctx.startRule(token.value))
   .join(
     q.tree({
diff --git a/lib/modules/manager/bazel-module/rules.spec.ts b/lib/modules/manager/bazel-module/rules.spec.ts
index cd035d3a86..53d67a7f5e 100644
--- a/lib/modules/manager/bazel-module/rules.spec.ts
+++ b/lib/modules/manager/bazel-module/rules.spec.ts
@@ -96,48 +96,43 @@ const gitRepositoryForUnsupportedPkgDep: BasePackageDep = {
 
 describe('modules/manager/bazel-module/rules', () => {
   describe('RuleToBazelModulePackageDep', () => {
-    const bazelDepWithoutDevDep = fragments.record({
-      rule: fragments.string('bazel_dep'),
+    const bazelDepWithoutDevDep = fragments.rule('bazel_dep', {
       name: fragments.string('rules_foo'),
       version: fragments.string('1.2.3'),
     });
-    const bazelDepWithoutDevDepNoVersion = fragments.record({
-      rule: fragments.string('bazel_dep'),
+    const bazelDepWithoutDevDepNoVersion = fragments.rule('bazel_dep', {
       name: fragments.string('rules_foo'),
     });
-    const gitOverrideWithGihubHost = fragments.record({
-      rule: fragments.string('git_override'),
+    const gitOverrideWithGihubHost = fragments.rule('git_override', {
       module_name: fragments.string('rules_foo'),
       remote: fragments.string('https://github.com/example/rules_foo.git'),
       commit: fragments.string('850cb49c8649e463b80ef7984e7c744279746170'),
     });
-    const gitOverrideWithUnsupportedHost = fragments.record({
-      rule: fragments.string('git_override'),
+    const gitOverrideWithUnsupportedHost = fragments.rule('git_override', {
       module_name: fragments.string('rules_foo'),
       remote: fragments.string('https://nobuenos.com/example/rules_foo.git'),
       commit: fragments.string('850cb49c8649e463b80ef7984e7c744279746170'),
     });
-    const archiveOverride = fragments.record({
-      rule: fragments.string('archive_override'),
+    const archiveOverride = fragments.rule('archive_override', {
       module_name: fragments.string('rules_foo'),
       urls: fragments.string('https://example.com/rules_foo.tar.gz'),
     });
-    const localPathOverride = fragments.record({
-      rule: fragments.string('local_path_override'),
+    const localPathOverride = fragments.rule('local_path_override', {
       module_name: fragments.string('rules_foo'),
       path: fragments.string('/path/to/module'),
     });
-    const singleVersionOverride = fragments.record({
-      rule: fragments.string('single_version_override'),
+    const singleVersionOverride = fragments.rule('single_version_override', {
       module_name: fragments.string('rules_foo'),
       version: fragments.string('1.2.3'),
       registry: fragments.string(customRegistryUrl),
     });
-    const singleVersionOverrideWithRegistry = fragments.record({
-      rule: fragments.string('single_version_override'),
-      module_name: fragments.string('rules_foo'),
-      registry: fragments.string(customRegistryUrl),
-    });
+    const singleVersionOverrideWithRegistry = fragments.rule(
+      'single_version_override',
+      {
+        module_name: fragments.string('rules_foo'),
+        registry: fragments.string(customRegistryUrl),
+      },
+    );
 
     it.each`
       msg                                                    | a                                    | exp
@@ -156,14 +151,12 @@ describe('modules/manager/bazel-module/rules', () => {
   });
 
   describe('GitRepositoryToPackageDep', () => {
-    const gitRepositoryWithGihubHost = fragments.record({
-      rule: fragments.string('git_repository'),
+    const gitRepositoryWithGihubHost = fragments.rule('git_repository', {
       name: fragments.string('rules_foo'),
       remote: fragments.string('https://github.com/example/rules_foo.git'),
       commit: fragments.string('850cb49c8649e463b80ef7984e7c744279746170'),
     });
-    const gitRepositoryWithUnsupportedHost = fragments.record({
-      rule: fragments.string('git_repository'),
+    const gitRepositoryWithUnsupportedHost = fragments.rule('git_repository', {
       name: fragments.string('rules_foo'),
       remote: fragments.string('https://nobuenos.com/example/rules_foo.git'),
       commit: fragments.string('850cb49c8649e463b80ef7984e7c744279746170'),
diff --git a/lib/modules/manager/bazel-module/rules.ts b/lib/modules/manager/bazel-module/rules.ts
index ef0fe52fc6..5c2b133ebb 100644
--- a/lib/modules/manager/bazel-module/rules.ts
+++ b/lib/modules/manager/bazel-module/rules.ts
@@ -8,7 +8,7 @@ import { regEx } from '../../../util/regex';
 import { BazelDatasource } from '../../datasource/bazel';
 import { GithubTagsDatasource } from '../../datasource/github-tags';
 import type { PackageDependency } from '../types';
-import { RecordFragmentSchema, StringFragmentSchema } from './fragments';
+import { RuleFragmentSchema, StringFragmentSchema } from './fragments';
 
 // Rule Schemas
 
@@ -63,39 +63,36 @@ export function bazelModulePackageDepToPackageDependency(
   return copy;
 }
 
-const BazelDepToPackageDep = RecordFragmentSchema.extend({
+const BazelDepToPackageDep = RuleFragmentSchema.extend({
+  rule: z.literal('bazel_dep'),
   children: z.object({
-    rule: StringFragmentSchema.extend({
-      value: z.literal('bazel_dep'),
-    }),
     name: StringFragmentSchema,
     version: StringFragmentSchema.optional(),
   }),
 }).transform(
-  ({ children: { rule, name, version } }): BasePackageDep => ({
+  ({ rule, children: { name, version } }): BasePackageDep => ({
     datasource: BazelDatasource.id,
-    depType: rule.value,
+    depType: rule,
     depName: name.value,
     currentValue: version?.value,
     ...(version ? {} : { skipReason: 'unspecified-version' }),
   }),
 );
 
-const GitOverrideToPackageDep = RecordFragmentSchema.extend({
+const GitOverrideToPackageDep = RuleFragmentSchema.extend({
+  rule: z.literal('git_override'),
   children: z.object({
-    rule: StringFragmentSchema.extend({
-      value: z.literal('git_override'),
-    }),
     module_name: StringFragmentSchema,
     remote: StringFragmentSchema,
     commit: StringFragmentSchema,
   }),
 }).transform(
   ({
-    children: { rule, module_name: moduleName, remote, commit },
+    rule,
+    children: { module_name: moduleName, remote, commit },
   }): OverridePackageDep => {
     const override: OverridePackageDep = {
-      depType: rule.value,
+      depType: rule,
       depName: moduleName.value,
       bazelDepSkipReason: 'git-dependency',
       currentDigest: commit.value,
@@ -111,21 +108,20 @@ const GitOverrideToPackageDep = RecordFragmentSchema.extend({
   },
 );
 
-const SingleVersionOverrideToPackageDep = RecordFragmentSchema.extend({
+const SingleVersionOverrideToPackageDep = RuleFragmentSchema.extend({
+  rule: z.literal('single_version_override'),
   children: z.object({
-    rule: StringFragmentSchema.extend({
-      value: z.literal('single_version_override'),
-    }),
     module_name: StringFragmentSchema,
     version: StringFragmentSchema.optional(),
     registry: StringFragmentSchema.optional(),
   }),
 }).transform(
   ({
-    children: { rule, module_name: moduleName, version, registry },
+    rule,
+    children: { module_name: moduleName, version, registry },
   }): BasePackageDep => {
     const base: BasePackageDep = {
-      depType: rule.value,
+      depType: rule,
       depName: moduleName.value,
       skipReason: 'ignored',
     };
@@ -145,17 +141,15 @@ const SingleVersionOverrideToPackageDep = RecordFragmentSchema.extend({
   },
 );
 
-const UnsupportedOverrideToPackageDep = RecordFragmentSchema.extend({
+const UnsupportedOverrideToPackageDep = RuleFragmentSchema.extend({
+  rule: z.enum(['archive_override', 'local_path_override']),
   children: z.object({
-    rule: StringFragmentSchema.extend({
-      value: z.enum(['archive_override', 'local_path_override']),
-    }),
     module_name: StringFragmentSchema,
   }),
 }).transform(
-  ({ children: { rule, module_name: moduleName } }): OverridePackageDep => {
+  ({ rule, children: { module_name: moduleName } }): OverridePackageDep => {
     let bazelDepSkipReason: SkipReason = 'unsupported';
-    switch (rule.value) {
+    switch (rule) {
       case 'archive_override':
         bazelDepSkipReason = 'file-dependency';
         break;
@@ -164,7 +158,7 @@ const UnsupportedOverrideToPackageDep = RecordFragmentSchema.extend({
         break;
     }
     return {
-      depType: rule.value,
+      depType: rule,
       depName: moduleName.value,
       skipReason: 'unsupported-datasource',
       bazelDepSkipReason,
@@ -244,18 +238,16 @@ export function toPackageDependencies(
   return collectByModule(packageDeps).map(processModulePkgDeps).flat();
 }
 
-export const GitRepositoryToPackageDep = RecordFragmentSchema.extend({
+export const GitRepositoryToPackageDep = RuleFragmentSchema.extend({
+  rule: z.literal('git_repository'),
   children: z.object({
-    rule: StringFragmentSchema.extend({
-      value: z.literal('git_repository'),
-    }),
     name: StringFragmentSchema,
     remote: StringFragmentSchema,
     commit: StringFragmentSchema,
   }),
-}).transform(({ children: { rule, name, remote, commit } }): BasePackageDep => {
+}).transform(({ rule, children: { name, remote, commit } }): BasePackageDep => {
   const gitRepo: BasePackageDep = {
-    depType: rule.value,
+    depType: rule,
     depName: name.value,
     currentDigest: commit.value,
   };
-- 
GitLab