From 5aa2ebfbcb333b7bc5f01fc3de768a78e2abe433 Mon Sep 17 00:00:00 2001
From: Sergei Zharinov <zharinov@users.noreply.github.com>
Date: Mon, 6 May 2024 14:17:07 -0300
Subject: [PATCH] refactor(gomod): Simplify dependency extraction (#28852)

---
 .../gomod/__snapshots__/extract.spec.ts.snap  |  12 +-
 lib/modules/manager/gomod/extract.spec.ts     |   6 +-
 lib/modules/manager/gomod/extract.ts          | 172 ++---------
 lib/modules/manager/gomod/line-parser.spec.ts | 280 ++++++++++++++++++
 lib/modules/manager/gomod/line-parser.ts      | 154 ++++++++++
 5 files changed, 465 insertions(+), 159 deletions(-)
 create mode 100644 lib/modules/manager/gomod/line-parser.spec.ts
 create mode 100644 lib/modules/manager/gomod/line-parser.ts

diff --git a/lib/modules/manager/gomod/__snapshots__/extract.spec.ts.snap b/lib/modules/manager/gomod/__snapshots__/extract.spec.ts.snap
index f089350ae9..93e19c1eaf 100644
--- a/lib/modules/manager/gomod/__snapshots__/extract.spec.ts.snap
+++ b/lib/modules/manager/gomod/__snapshots__/extract.spec.ts.snap
@@ -721,12 +721,13 @@ exports[`modules/manager/gomod/extract extractPackageFile() extracts single-line
   },
   {
     "currentValue": "abcdef1",
+    "datasource": "go",
     "depName": "github.com/rarkins/foo",
     "depType": "require",
     "managerData": {
       "lineNumber": 6,
     },
-    "skipReason": "unsupported-version",
+    "skipReason": "invalid-version",
   },
   {
     "currentValue": "v1.0.0",
@@ -746,6 +747,15 @@ exports[`modules/manager/gomod/extract extractPackageFile() extracts single-line
       "lineNumber": 8,
     },
   },
+  {
+    "datasource": "go",
+    "depName": "../errors",
+    "depType": "replace",
+    "managerData": {
+      "lineNumber": 10,
+    },
+    "skipReason": "local-dependency",
+  },
   {
     "currentValue": "v0.0.0",
     "datasource": "go",
diff --git a/lib/modules/manager/gomod/extract.spec.ts b/lib/modules/manager/gomod/extract.spec.ts
index 67b7f2c88a..97d093d62b 100644
--- a/lib/modules/manager/gomod/extract.spec.ts
+++ b/lib/modules/manager/gomod/extract.spec.ts
@@ -14,11 +14,11 @@ describe('modules/manager/gomod/extract', () => {
     it('extracts single-line requires', () => {
       const res = extractPackageFile(gomod1)?.deps;
       expect(res).toMatchSnapshot();
-      expect(res).toHaveLength(9);
+      expect(res).toHaveLength(10);
       expect(res?.filter((e) => e.depType === 'require')).toHaveLength(7);
       expect(res?.filter((e) => e.depType === 'indirect')).toHaveLength(1);
-      expect(res?.filter((e) => e.skipReason)).toHaveLength(1);
-      expect(res?.filter((e) => e.depType === 'replace')).toHaveLength(1);
+      expect(res?.filter((e) => e.skipReason)).toHaveLength(2);
+      expect(res?.filter((e) => e.depType === 'replace')).toHaveLength(2);
     });
 
     it('extracts multi-line requires', () => {
diff --git a/lib/modules/manager/gomod/extract.ts b/lib/modules/manager/gomod/extract.ts
index 524158f1d2..731bd2b7c3 100644
--- a/lib/modules/manager/gomod/extract.ts
+++ b/lib/modules/manager/gomod/extract.ts
@@ -1,165 +1,27 @@
-import semver from 'semver';
-import { logger } from '../../../logger';
-import { newlineRegex, regEx } from '../../../util/regex';
-import { GoDatasource } from '../../datasource/go';
-import { GolangVersionDatasource } from '../../datasource/golang-version';
-import { isVersion } from '../../versioning/semver';
+import { newlineRegex } from '../../../util/regex';
 import type { PackageDependency, PackageFileContent } from '../types';
-import type { MultiLineParseResult } from './types';
+import { parseLine } from './line-parser';
 
-function getDep(
-  lineNumber: number,
-  match: RegExpMatchArray,
-  type: string,
-): PackageDependency {
-  const [, , currentValue] = match;
-  let [, depName] = match;
-  depName = depName.replace(regEx(/"/g), '');
-  const dep: PackageDependency = {
-    managerData: {
-      lineNumber,
-    },
-    depName,
-    depType: type,
-    currentValue,
-  };
-  if (isVersion(currentValue)) {
-    dep.datasource = GoDatasource.id;
-  } else {
-    dep.skipReason = 'unsupported-version';
-  }
-  const digestMatch = regEx(GoDatasource.pversionRegexp).exec(currentValue);
-  if (digestMatch?.groups?.digest) {
-    dep.currentDigest = digestMatch.groups.digest;
-    dep.digestOneAndOnly = true;
-    dep.versioning = 'loose';
-  }
-  return dep;
-}
-
-function getGoDep(
-  lineNumber: number,
-  goVer: string,
-  versioning: string | undefined = undefined,
-  depType: string = 'golang',
-): PackageDependency {
-  return {
-    managerData: {
-      lineNumber,
-    },
-    depName: 'go',
-    depType,
-    currentValue: goVer,
-    datasource: GolangVersionDatasource.id,
-    ...(versioning && { versioning }),
-  };
-}
-
-export function extractPackageFile(
-  content: string,
-  packageFile?: string,
-): PackageFileContent | null {
-  logger.trace({ content }, 'gomod.extractPackageFile()');
+export function extractPackageFile(content: string): PackageFileContent | null {
   const deps: PackageDependency[] = [];
-  try {
-    const lines = content.split(newlineRegex);
-    for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
-      const line = lines[lineNumber];
-      const goVer = line.startsWith('go ') ? line.replace('go ', '') : null;
-      if (goVer && semver.validRange(goVer)) {
-        const dep = getGoDep(lineNumber, goVer, 'go-mod-directive');
-        deps.push(dep);
-        continue;
-      }
-      const goToolVer = line.startsWith('toolchain go')
-        ? line.replace('toolchain go', '')
-        : null;
-      if (goToolVer && semver.valid(goToolVer)) {
-        const dep = getGoDep(lineNumber, goToolVer, undefined, 'toolchain');
-        deps.push(dep);
-        continue;
-      }
-      const replaceMatch = regEx(
-        /^replace\s+[^\s]+[\s]+[=][>]\s+([^\s]+)\s+([^\s]+)/,
-      ).exec(line);
-      if (replaceMatch) {
-        const dep = getDep(lineNumber, replaceMatch, 'replace');
-        deps.push(dep);
-      }
-      const requireMatch = regEx(/^require\s+([^\s]+)\s+([^\s]+)/).exec(line);
-      if (requireMatch) {
-        if (line.endsWith('// indirect')) {
-          logger.trace({ lineNumber }, `indirect line: "${line}"`);
-          const dep = getDep(lineNumber, requireMatch, 'indirect');
-          dep.enabled = false;
-          deps.push(dep);
-        } else {
-          logger.trace({ lineNumber }, `require line: "${line}"`);
-          const dep = getDep(lineNumber, requireMatch, 'require');
-          deps.push(dep);
-        }
-      }
-      if (line.trim() === 'require (') {
-        logger.trace(`Matched multi-line require on line ${lineNumber}`);
-        const matcher = regEx(/^\s+([^\s]+)\s+([^\s]+)/);
-        const { reachedLine, detectedDeps } = parseMultiLine(
-          lineNumber,
-          lines,
-          matcher,
-          'require',
-        );
-        lineNumber = reachedLine;
-        deps.push(...detectedDeps);
-      } else if (line.trim() === 'replace (') {
-        logger.trace(`Matched multi-line replace on line ${lineNumber}`);
-        const matcher = regEx(/^\s+[^\s]+[\s]+[=][>]\s+([^\s]+)\s+([^\s]+)/);
-        const { reachedLine, detectedDeps } = parseMultiLine(
-          lineNumber,
-          lines,
-          matcher,
-          'replace',
-        );
-        lineNumber = reachedLine;
-        deps.push(...detectedDeps);
-      }
+
+  const lines = content.split(newlineRegex);
+  for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
+    const line = lines[lineNumber];
+    const dep = parseLine(line);
+    if (!dep) {
+      continue;
     }
-  } catch (err) /* istanbul ignore next */ {
-    logger.warn({ err, packageFile }, 'Error extracting go modules');
+
+    dep.managerData ??= {};
+    dep.managerData.lineNumber = lineNumber;
+
+    deps.push(dep);
   }
+
   if (!deps.length) {
     return null;
   }
-  return { deps };
-}
 
-function parseMultiLine(
-  startingLine: number,
-  lines: string[],
-  matchRegex: RegExp,
-  blockType: 'require' | 'replace',
-): MultiLineParseResult {
-  const deps: PackageDependency[] = [];
-  let lineNumber = startingLine;
-  let line = '';
-  do {
-    lineNumber += 1;
-    line = lines[lineNumber];
-    const multiMatch = matchRegex.exec(line);
-    logger.trace(`${blockType}: "${line}"`);
-    if (multiMatch && !line.endsWith('// indirect')) {
-      logger.trace({ lineNumber }, `${blockType} line: "${line}"`);
-      const dep = getDep(lineNumber, multiMatch, blockType);
-      dep.managerData!.multiLine = true;
-      deps.push(dep);
-    } else if (multiMatch && line.endsWith('// indirect')) {
-      logger.trace({ lineNumber }, `${blockType} indirect line: "${line}"`);
-      const dep = getDep(lineNumber, multiMatch, 'indirect');
-      dep.managerData!.multiLine = true;
-      dep.enabled = false;
-      deps.push(dep);
-    } else if (line.trim() !== ')') {
-      logger.trace(`No multi-line match: ${line}`);
-    }
-  } while (line.trim() !== ')');
-  return { reachedLine: lineNumber, detectedDeps: deps };
+  return { deps };
 }
diff --git a/lib/modules/manager/gomod/line-parser.spec.ts b/lib/modules/manager/gomod/line-parser.spec.ts
new file mode 100644
index 0000000000..deef02e43f
--- /dev/null
+++ b/lib/modules/manager/gomod/line-parser.spec.ts
@@ -0,0 +1,280 @@
+import { parseLine } from './line-parser';
+
+describe('modules/manager/gomod/line-parser', () => {
+  it('should return null for invalid input', () => {
+    expect(parseLine('invalid')).toBeNull();
+  });
+
+  it('should parse go version', () => {
+    const line = 'go 1.16';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: '1.16',
+      datasource: 'golang-version',
+      depName: 'go',
+      depType: 'golang',
+      versioning: 'go-mod-directive',
+    });
+  });
+
+  it('should skip invalid go version', () => {
+    const line = 'go invalid';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: 'invalid',
+      datasource: 'golang-version',
+      depName: 'go',
+      depType: 'golang',
+      skipReason: 'invalid-version',
+      versioning: 'go-mod-directive',
+    });
+  });
+
+  it('should parse toolchain version', () => {
+    const line = 'toolchain go1.16';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: '1.16',
+      datasource: 'golang-version',
+      depName: 'go',
+      depType: 'toolchain',
+      skipReason: 'invalid-version',
+    });
+  });
+
+  it('should skip invalid toolchain version', () => {
+    const line = 'toolchain go-invalid';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: '-invalid',
+      datasource: 'golang-version',
+      depName: 'go',
+      depType: 'toolchain',
+      skipReason: 'invalid-version',
+    });
+  });
+
+  it('should parse require definition', () => {
+    const line = 'require foo/foo v1.2';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: 'v1.2',
+      datasource: 'go',
+      depName: 'foo/foo',
+      depType: 'require',
+      skipReason: 'invalid-version',
+    });
+  });
+
+  it('should parse require definition with pseudo-version', () => {
+    const line = 'require foo/foo v0.0.0-20210101000000-000000000000';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentDigest: '000000000000',
+      currentValue: 'v0.0.0-20210101000000-000000000000',
+      datasource: 'go',
+      depName: 'foo/foo',
+      depType: 'require',
+      digestOneAndOnly: true,
+      versioning: 'loose',
+    });
+  });
+
+  it('should parse require multi-line', () => {
+    const line = '        foo/foo v1.2';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: 'v1.2',
+      datasource: 'go',
+      depName: 'foo/foo',
+      depType: 'require',
+      managerData: {
+        multiLine: true,
+      },
+      skipReason: 'invalid-version',
+    });
+  });
+
+  it('should parse require definition with quotes', () => {
+    const line = 'require "foo/foo" v1.2';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: 'v1.2',
+      datasource: 'go',
+      depName: 'foo/foo',
+      depType: 'require',
+      skipReason: 'invalid-version',
+    });
+  });
+
+  it('should parse require multi-line definition with quotes', () => {
+    const line = '        "foo/foo" v1.2';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: 'v1.2',
+      datasource: 'go',
+      depName: 'foo/foo',
+      depType: 'require',
+      managerData: {
+        multiLine: true,
+      },
+      skipReason: 'invalid-version',
+    });
+  });
+
+  it('should parse require definition with indirect dependency', () => {
+    const line = 'require foo/foo v1.2 // indirect';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: 'v1.2',
+      datasource: 'go',
+      depName: 'foo/foo',
+      depType: 'indirect',
+      enabled: false,
+      skipReason: 'invalid-version',
+    });
+  });
+
+  it('should parse require multi-line definition with indirect dependency', () => {
+    const line = '        foo/foo v1.2 // indirect';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: 'v1.2',
+      datasource: 'go',
+      depName: 'foo/foo',
+      depType: 'indirect',
+      enabled: false,
+      managerData: {
+        multiLine: true,
+      },
+      skipReason: 'invalid-version',
+    });
+  });
+
+  it('should parse replace definition', () => {
+    const line = 'replace foo/foo => bar/bar';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      datasource: 'go',
+      depName: 'bar/bar',
+      depType: 'replace',
+      skipReason: 'unspecified-version',
+    });
+  });
+
+  it('should parse replace multi-line definition', () => {
+    const line = '        foo/foo => bar/bar';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      datasource: 'go',
+      depName: 'bar/bar',
+      depType: 'replace',
+      managerData: {
+        multiLine: true,
+      },
+      skipReason: 'unspecified-version',
+    });
+  });
+
+  it('should parse replace definition with quotes', () => {
+    const line = 'replace "foo/foo" => "bar/bar"';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      datasource: 'go',
+      depName: 'bar/bar',
+      depType: 'replace',
+      skipReason: 'unspecified-version',
+    });
+  });
+
+  it('should parse replace multi-line definition with quotes', () => {
+    const line = '        "foo/foo" => "bar/bar"';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      datasource: 'go',
+      depName: 'bar/bar',
+      depType: 'replace',
+      managerData: {
+        multiLine: true,
+      },
+      skipReason: 'unspecified-version',
+    });
+  });
+
+  it('should parse replace definition with version', () => {
+    const line = 'replace foo/foo => bar/bar v1.2';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: 'v1.2',
+      datasource: 'go',
+      depName: 'bar/bar',
+      depType: 'replace',
+      skipReason: 'invalid-version',
+    });
+  });
+
+  it('should parse replace definition with pseudo-version', () => {
+    const line =
+      'replace foo/foo => bar/bar v0.0.0-20210101000000-000000000000';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentDigest: '000000000000',
+      currentValue: 'v0.0.0-20210101000000-000000000000',
+      datasource: 'go',
+      depName: 'bar/bar',
+      depType: 'replace',
+      digestOneAndOnly: true,
+      versioning: 'loose',
+    });
+  });
+
+  it('should parse replace indirect definition', () => {
+    const line = 'replace foo/foo => bar/bar v1.2 // indirect';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: 'v1.2',
+      datasource: 'go',
+      depName: 'bar/bar',
+      depType: 'indirect',
+      enabled: false,
+      skipReason: 'invalid-version',
+    });
+  });
+
+  it('should parse replace multi-line definition with version', () => {
+    const line = '        foo/foo => bar/bar v1.2';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      currentValue: 'v1.2',
+      datasource: 'go',
+      depName: 'bar/bar',
+      depType: 'replace',
+      managerData: {
+        multiLine: true,
+      },
+      skipReason: 'invalid-version',
+    });
+  });
+
+  it('should parse replace definition pointing to relative local path', () => {
+    const line = 'replace foo/foo => ../bar';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      datasource: 'go',
+      depName: '../bar',
+      depType: 'replace',
+      skipReason: 'local-dependency',
+    });
+  });
+
+  it('should parse replace definition pointing to absolute local path', () => {
+    const line = 'replace foo/foo => /bar';
+    const res = parseLine(line);
+    expect(res).toStrictEqual({
+      datasource: 'go',
+      depName: '/bar',
+      depType: 'replace',
+      skipReason: 'local-dependency',
+    });
+  });
+});
diff --git a/lib/modules/manager/gomod/line-parser.ts b/lib/modules/manager/gomod/line-parser.ts
new file mode 100644
index 0000000000..58591c7c50
--- /dev/null
+++ b/lib/modules/manager/gomod/line-parser.ts
@@ -0,0 +1,154 @@
+import semver from 'semver';
+import { regEx } from '../../../util/regex';
+import { GoDatasource } from '../../datasource/go';
+import { GolangVersionDatasource } from '../../datasource/golang-version';
+import { isVersion } from '../../versioning/semver';
+import type { PackageDependency } from '../types';
+
+function trimQuotes(str: string): string {
+  return str.replace(regEx(/^"(.*)"$/), '$1');
+}
+
+const requireRegex = regEx(
+  /^(?<keyword>require)?\s+(?<module>[^\s]+\/[^\s]+)\s+(?<version>[^\s]+)(?:\s*\/\/\s*(?<comment>[^\s]+)\s*)?$/,
+);
+
+const replaceRegex = regEx(
+  /^(?<keyword>replace)?\s+(?<module>[^\s]+\/[^\s]+)\s*=>\s*(?<replacement>[^\s]+)(?:\s+(?<version>[^\s]+))?(?:\s*\/\/\s*(?<comment>[^\s]+)\s*)?$/,
+);
+
+const goVersionRegex = regEx(/^\s*go\s+(?<version>[^\s]+)\s*$/);
+
+const toolchainVersionRegex = regEx(/^\s*toolchain\s+go(?<version>[^\s]+)\s*$/);
+
+const pseudoVersionRegex = regEx(GoDatasource.pversionRegexp);
+
+function extractDigest(input: string): string | undefined {
+  const match = pseudoVersionRegex.exec(input);
+  return match?.groups?.digest;
+}
+
+export function parseLine(input: string): PackageDependency | null {
+  const goVersionMatches = goVersionRegex.exec(input)?.groups;
+  if (goVersionMatches) {
+    const { version: currentValue } = goVersionMatches;
+
+    const dep: PackageDependency = {
+      datasource: GolangVersionDatasource.id,
+      versioning: 'go-mod-directive',
+      depType: 'golang',
+      depName: 'go',
+      currentValue,
+    };
+
+    if (!semver.validRange(currentValue)) {
+      dep.skipReason = 'invalid-version';
+    }
+
+    return dep;
+  }
+
+  const toolchainMatches = toolchainVersionRegex.exec(input)?.groups;
+  if (toolchainMatches) {
+    const { version: currentValue } = toolchainMatches;
+
+    const dep: PackageDependency = {
+      datasource: GolangVersionDatasource.id,
+      depType: 'toolchain',
+      depName: 'go',
+      currentValue,
+    };
+
+    if (!semver.valid(currentValue)) {
+      dep.skipReason = 'invalid-version';
+    }
+
+    return dep;
+  }
+
+  const requireMatches = requireRegex.exec(input)?.groups;
+  if (requireMatches) {
+    const { keyword, module, version: currentValue, comment } = requireMatches;
+
+    const depName = trimQuotes(module);
+
+    const dep: PackageDependency = {
+      datasource: GoDatasource.id,
+      depType: 'require',
+      depName,
+      currentValue,
+    };
+
+    if (isVersion(currentValue)) {
+      const digest = extractDigest(currentValue);
+      if (digest) {
+        dep.currentDigest = digest;
+        dep.digestOneAndOnly = true;
+        dep.versioning = 'loose';
+      }
+    } else {
+      dep.skipReason = 'invalid-version';
+    }
+
+    if (comment === 'indirect') {
+      dep.depType = 'indirect';
+      dep.enabled = false;
+    }
+
+    if (!keyword) {
+      dep.managerData = { multiLine: true };
+    }
+
+    return dep;
+  }
+
+  const replaceMatches = replaceRegex.exec(input)?.groups;
+  if (replaceMatches) {
+    const {
+      keyword,
+      replacement,
+      version: currentValue,
+      comment,
+    } = replaceMatches;
+
+    const depName = trimQuotes(replacement);
+
+    const dep: PackageDependency = {
+      datasource: GoDatasource.id,
+      depType: 'replace',
+      depName,
+      currentValue,
+    };
+
+    if (isVersion(currentValue)) {
+      const digest = extractDigest(currentValue);
+      if (digest) {
+        dep.currentDigest = digest;
+        dep.digestOneAndOnly = true;
+        dep.versioning = 'loose';
+      }
+    } else if (currentValue) {
+      dep.skipReason = 'invalid-version';
+    } else {
+      dep.skipReason = 'unspecified-version';
+      delete dep.currentValue;
+    }
+
+    if (comment === 'indirect') {
+      dep.depType = 'indirect';
+      dep.enabled = false;
+    }
+
+    if (!keyword) {
+      dep.managerData = { multiLine: true };
+    }
+
+    if (depName.startsWith('/') || depName.startsWith('.')) {
+      dep.skipReason = 'local-dependency';
+    }
+
+    return dep;
+  }
+
+  return null;
+}
-- 
GitLab