From 4484cbcb2842c128e2e648278d8ea2584a3b4c72 Mon Sep 17 00:00:00 2001
From: Joshua Tang <joshuaystang@gmail.com>
Date: Thu, 10 Aug 2023 23:19:17 +1000
Subject: [PATCH] feat(manager/pub): extract Flutter SDK (#23759)

---
 .../manager/pub/__fixtures__/extract.yaml     | 13 ---
 .../manager/pub/__fixtures__/update.yaml      | 14 ---
 lib/modules/manager/pub/artifacts.spec.ts     | 26 ++++--
 lib/modules/manager/pub/artifacts.ts          | 17 ++--
 lib/modules/manager/pub/extract.spec.ts       | 87 +++++++++++++-----
 lib/modules/manager/pub/extract.ts            | 92 ++++++++++---------
 lib/modules/manager/pub/schema.ts             | 17 +++-
 lib/modules/manager/pub/utils.spec.ts         | 46 +++++++++-
 lib/modules/manager/pub/utils.ts              | 22 ++++-
 9 files changed, 222 insertions(+), 112 deletions(-)
 delete mode 100644 lib/modules/manager/pub/__fixtures__/extract.yaml
 delete mode 100644 lib/modules/manager/pub/__fixtures__/update.yaml

diff --git a/lib/modules/manager/pub/__fixtures__/extract.yaml b/lib/modules/manager/pub/__fixtures__/extract.yaml
deleted file mode 100644
index 0ed1dd32cd..0000000000
--- a/lib/modules/manager/pub/__fixtures__/extract.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-dev_dependencies:
-  test: ^0.1
-  build:
-    version: 0.1
-
-dependencies:
-  meta: 'something'
-  foo: 1
-  bar:
-    version: 1
-  baz:
-    non-sense: true
-  qux: false
diff --git a/lib/modules/manager/pub/__fixtures__/update.yaml b/lib/modules/manager/pub/__fixtures__/update.yaml
deleted file mode 100644
index ff17976192..0000000000
--- a/lib/modules/manager/pub/__fixtures__/update.yaml
+++ /dev/null
@@ -1,14 +0,0 @@
-dev_dependencies:
-  test: 0.1
-  build:
-    version: 0.1
-
-dependencies:
-  - bar:
-    sdk: flatter
-  foo: 1
-  bar:
-    version: 1
-  baz:
-    non-sense: true
-  qux: false
diff --git a/lib/modules/manager/pub/artifacts.spec.ts b/lib/modules/manager/pub/artifacts.spec.ts
index c7b8aaf903..bf7c292f22 100644
--- a/lib/modules/manager/pub/artifacts.spec.ts
+++ b/lib/modules/manager/pub/artifacts.spec.ts
@@ -4,6 +4,7 @@ import { env, fs, mocked } from '../../../../test/util';
 import { GlobalConfig } from '../../../config/global';
 import type { RepoGlobalConfig } from '../../../config/types';
 import * as docker from '../../../util/exec/docker';
+import { range } from '../../../util/range';
 import * as _datasource from '../../datasource';
 import type { UpdateArtifact, UpdateArtifactsConfig } from '../types';
 import * as pub from '.';
@@ -19,7 +20,9 @@ process.env.CONTAINERBASE = 'true';
 const lockFile = 'pubspec.lock';
 const oldLockFileContent = 'Old pubspec.lock';
 const newLockFileContent = 'New pubspec.lock';
-const depName = 'depName';
+const depNames = [...range(0, 3)].map((i) => `depName${i}`);
+const depNamesWithSpace = depNames.join(' ');
+const depNamesWithFlutter = [...depNames, 'flutter'];
 
 const datasource = mocked(_datasource);
 
@@ -33,7 +36,9 @@ const config: UpdateArtifactsConfig = {};
 
 const updateArtifact: UpdateArtifact = {
   packageFileName: 'pubspec.yaml',
-  updatedDeps: [{ depName }],
+  updatedDeps: depNamesWithFlutter.map((depName) => {
+    return { depName };
+  }),
   newPackageFileContent: '',
   config,
 };
@@ -65,6 +70,15 @@ describe('modules/manager/pub/artifacts', () => {
     ).toBeNull();
   });
 
+  it('returns null if updatedDeps only contains flutter', async () => {
+    expect(
+      await pub.updateArtifacts({
+        ...updateArtifact,
+        updatedDeps: [{ depName: 'flutter' }],
+      })
+    ).toBeNull();
+  });
+
   describe.each([
     { sdk: 'dart', packageFileContent: '' },
     { sdk: 'flutter', packageFileContent: 'sdk: flutter' },
@@ -81,7 +95,7 @@ describe('modules/manager/pub/artifacts', () => {
       ).toBeNull();
       expect(execSnapshots).toMatchObject([
         {
-          cmd: `${params.sdk} pub upgrade ${depName}`,
+          cmd: `${params.sdk} pub upgrade ${depNamesWithSpace}`,
         },
       ]);
     });
@@ -107,7 +121,7 @@ describe('modules/manager/pub/artifacts', () => {
       ]);
       expect(execSnapshots).toMatchObject([
         {
-          cmd: `${params.sdk} pub upgrade ${depName}`,
+          cmd: `${params.sdk} pub upgrade ${depNamesWithSpace}`,
         },
       ]);
     });
@@ -181,7 +195,7 @@ describe('modules/manager/pub/artifacts', () => {
             'bash -l -c "' +
             `install-tool ${params.sdk} 3.3.9` +
             ' && ' +
-            `${params.sdk} pub upgrade ${depName}` +
+            `${params.sdk} pub upgrade ${depNamesWithSpace}` +
             '"',
         },
       ]);
@@ -210,7 +224,7 @@ describe('modules/manager/pub/artifacts', () => {
       ]);
       expect(execSnapshots).toMatchObject([
         { cmd: `install-tool ${params.sdk} 3.3.9` },
-        { cmd: `${params.sdk} pub upgrade ${depName}` },
+        { cmd: `${params.sdk} pub upgrade ${depNamesWithSpace}` },
       ]);
     });
 
diff --git a/lib/modules/manager/pub/artifacts.ts b/lib/modules/manager/pub/artifacts.ts
index 613cd364ce..a8ebcd9ff0 100644
--- a/lib/modules/manager/pub/artifacts.ts
+++ b/lib/modules/manager/pub/artifacts.ts
@@ -24,6 +24,9 @@ export async function updateArtifacts({
   if (is.emptyArray(updatedDeps) && !isLockFileMaintenance) {
     logger.debug('No updated pub deps - returning null');
     return null;
+  } else if (updatedDeps.length === 1 && updatedDeps[0].depName === 'flutter') {
+    logger.debug('Only updated flutter sdk - returning null');
+    return null;
   }
 
   const lockFileName = getSiblingFileName(packageFileName, 'pubspec.lock');
@@ -43,13 +46,13 @@ export async function updateArtifacts({
     if (isLockFileMaintenance) {
       cmd.push(`${toolName} pub upgrade`);
     } else {
-      cmd.push(
-        `${toolName} pub upgrade ${updatedDeps
-          .map((dep) => dep.depName)
-          .filter(is.string)
-          .map((dep) => quote(dep))
-          .join(' ')}`
-      );
+      const depNames = updatedDeps
+        .map((dep) => dep.depName)
+        .filter(is.string)
+        .filter((depName) => depName !== 'flutter')
+        .map(quote)
+        .join(' ');
+      cmd.push(`${toolName} pub upgrade ${depNames}`);
     }
 
     let constraint = config.constraints?.[toolName];
diff --git a/lib/modules/manager/pub/extract.spec.ts b/lib/modules/manager/pub/extract.spec.ts
index 47e76e4d68..b4b0538615 100644
--- a/lib/modules/manager/pub/extract.spec.ts
+++ b/lib/modules/manager/pub/extract.spec.ts
@@ -1,42 +1,83 @@
-import { Fixtures } from '../../../../test/fixtures';
+import { codeBlock } from 'common-tags';
 import { extractPackageFile } from '.';
 
 describe('modules/manager/pub/extract', () => {
   describe('extractPackageFile', () => {
-    it('should return null if package does not contain any deps', () => {
-      const res = extractPackageFile('foo: bar', 'pubspec.yaml');
-      expect(res).toBeNull();
+    const packageFile = 'pubspec.yaml';
+
+    it('returns null if package does not contain any deps', () => {
+      const content = codeBlock`
+        environment:
+          sdk: ^3.0.0
+      `;
+      const actual = extractPackageFile(content, packageFile);
+      expect(actual).toBeNull();
     });
 
-    it('should return null if package is invalid', () => {
-      const res = extractPackageFile(
-        Fixtures.get('update.yaml'),
-        'pubspec.yaml'
-      );
-      expect(res).toBeNull();
+    it('returns null for invalid pubspec file', () => {
+      const content = codeBlock`
+        clarly: "invalid" "yaml"
+      `;
+      const actual = extractPackageFile(content, packageFile);
+      expect(actual).toBeNull();
     });
 
-    it('should return valid dependencies', () => {
-      const res = extractPackageFile(
-        Fixtures.get('extract.yaml'),
-        'pubspec.yaml'
-      );
-      expect(res).toEqual({
-        datasource: 'dart',
+    it('returns valid dependencies', () => {
+      const dartDatasource = 'dart';
+      const content = codeBlock`
+        environment:
+          sdk: ^3.0.0
+          flutter: 2.0.0
+        dependencies:
+          meta: 'something'
+          foo: 1.0.0
+          bar:
+            version: 1.1.0
+          baz:
+            non-sense: true
+          qux: false
+        dev_dependencies:
+          test: ^0.1.0
+          build:
+            version: 0.0.1
+      `;
+      const actual = extractPackageFile(content, packageFile);
+      expect(actual).toEqual({
         deps: [
-          { currentValue: '1', depName: 'foo', depType: 'dependencies' },
-          { currentValue: '1', depName: 'bar', depType: 'dependencies' },
-          { currentValue: null, depName: 'baz', depType: 'dependencies' },
-          { currentValue: null, depName: 'qux', depType: 'dependencies' },
           {
-            currentValue: '^0.1',
+            currentValue: '1.0.0',
+            depName: 'foo',
+            depType: 'dependencies',
+            datasource: dartDatasource,
+          },
+          {
+            currentValue: '1.1.0',
+            depName: 'bar',
+            depType: 'dependencies',
+            datasource: dartDatasource,
+          },
+          {
+            currentValue: '',
+            depName: 'baz',
+            depType: 'dependencies',
+            datasource: dartDatasource,
+          },
+          {
+            currentValue: '^0.1.0',
             depName: 'test',
             depType: 'dev_dependencies',
+            datasource: dartDatasource,
           },
           {
-            currentValue: '0.1',
+            currentValue: '0.0.1',
             depName: 'build',
             depType: 'dev_dependencies',
+            datasource: dartDatasource,
+          },
+          {
+            currentValue: '2.0.0',
+            depName: 'flutter',
+            datasource: 'flutter-version',
           },
         ],
       });
diff --git a/lib/modules/manager/pub/extract.ts b/lib/modules/manager/pub/extract.ts
index 5e66b4d8bc..0c098f2495 100644
--- a/lib/modules/manager/pub/extract.ts
+++ b/lib/modules/manager/pub/extract.ts
@@ -1,64 +1,74 @@
-import { load } from 'js-yaml';
-import { logger } from '../../../logger';
+import is from '@sindresorhus/is';
 import { DartDatasource } from '../../datasource/dart';
+import { FlutterVersionDatasource } from '../../datasource/flutter-version';
 import type { PackageDependency, PackageFileContent } from '../types';
+import type { PubspecSchema } from './schema';
+import { parsePubspec } from './utils';
 
-function getDeps(
-  depsObj: { [x: string]: any },
-  preset: { depType: string }
+function extractFromSection(
+  pubspec: PubspecSchema,
+  sectionKey: keyof Pick<PubspecSchema, 'dependencies' | 'dev_dependencies'>
 ): PackageDependency[] {
-  if (!depsObj) {
+  const sectionContent = pubspec[sectionKey];
+  if (!sectionContent) {
     return [];
   }
-  return Object.keys(depsObj).reduce((acc, depName) => {
+
+  const datasource = DartDatasource.id;
+  const deps: PackageDependency[] = [];
+  for (const depName of Object.keys(sectionContent)) {
     if (depName === 'meta') {
-      return acc;
+      continue;
     }
 
-    const section = depsObj[depName];
-
-    let currentValue: string | null = null;
-    if (section?.version) {
-      currentValue = section.version.toString();
-    } else if (section) {
-      if (typeof section === 'string') {
-        currentValue = section;
-      }
-      if (typeof section === 'number') {
-        currentValue = section.toString();
+    let currentValue = sectionContent[depName];
+    if (!is.string(currentValue)) {
+      const version = currentValue.version;
+      if (version) {
+        currentValue = version;
+      } else {
+        currentValue = '';
       }
     }
 
-    const dep: PackageDependency = { ...preset, depName, currentValue };
+    deps.push({ depName, depType: sectionKey, currentValue, datasource });
+  }
+
+  return deps;
+}
+
+function extractFlutter(pubspec: PubspecSchema): PackageDependency[] {
+  const currentValue = pubspec.environment.flutter;
+  if (!currentValue) {
+    return [];
+  }
 
-    return [...acc, dep];
-  }, [] as PackageDependency[]);
+  return [
+    {
+      depName: 'flutter',
+      currentValue,
+      datasource: FlutterVersionDatasource.id,
+    },
+  ];
 }
 
 export function extractPackageFile(
   content: string,
   packageFile: string
 ): PackageFileContent | null {
-  try {
-    // TODO: fix me (#9610)
-    const doc = load(content, { json: true }) as any;
-    const deps = [
-      ...getDeps(doc.dependencies, {
-        depType: 'dependencies',
-      }),
-      ...getDeps(doc.dev_dependencies, {
-        depType: 'dev_dependencies',
-      }),
-    ];
+  const pubspec = parsePubspec(packageFile, content);
+  if (!pubspec) {
+    return null;
+  }
 
-    if (deps.length) {
-      return {
-        datasource: DartDatasource.id,
-        deps,
-      };
-    }
-  } catch (err) {
-    logger.debug({ packageFile, err }, `Could not parse YAML`);
+  const deps = [
+    ...extractFromSection(pubspec, 'dependencies'),
+    ...extractFromSection(pubspec, 'dev_dependencies'),
+    ...extractFlutter(pubspec),
+  ];
+
+  if (deps.length) {
+    return { deps };
   }
   return null;
 }
diff --git a/lib/modules/manager/pub/schema.ts b/lib/modules/manager/pub/schema.ts
index dbf72be5c7..2d106c4add 100644
--- a/lib/modules/manager/pub/schema.ts
+++ b/lib/modules/manager/pub/schema.ts
@@ -1,5 +1,20 @@
 import { z } from 'zod';
-import { Yaml } from '../../../util/schema-utils';
+import { LooseRecord, Yaml } from '../../../util/schema-utils';
+
+const PubspecDependencySchema = LooseRecord(
+  z.string(),
+  z.union([z.string(), z.object({ version: z.string().optional() })])
+);
+
+export const PubspecSchema = z.object({
+  environment: z.object({ sdk: z.string(), flutter: z.string().optional() }),
+  dependencies: PubspecDependencySchema.optional(),
+  dev_dependencies: PubspecDependencySchema.optional(),
+});
+
+export type PubspecSchema = z.infer<typeof PubspecSchema>;
+
+export const PubspecYaml = Yaml.pipe(PubspecSchema);
 
 export const PubspecLockSchema = z.object({
   sdks: z.object({
diff --git a/lib/modules/manager/pub/utils.spec.ts b/lib/modules/manager/pub/utils.spec.ts
index c236d4c0be..44c62ae4f6 100644
--- a/lib/modules/manager/pub/utils.spec.ts
+++ b/lib/modules/manager/pub/utils.spec.ts
@@ -1,10 +1,46 @@
 import { codeBlock } from 'common-tags';
-import { parsePubspecLock } from './utils';
+import { parsePubspec, parsePubspecLock } from './utils';
 
 describe('modules/manager/pub/utils', () => {
-  describe('parsePubspeckLock', () => {
-    const fileName = 'pubspec.lock';
+  const fileName = 'fileName';
+  const invalidYaml = codeBlock`
+    clearly: "invalid" "yaml"
+  `;
+  const invalidSchema = codeBlock`
+    clearly: invalid
+  `;
+
+  describe('parsePubspec', () => {
+    it('load and parse successfully', () => {
+      const fileContent = codeBlock`
+        environment:
+          sdk: ">=3.0.0 <4.0.0"
+          flutter: ">=3.10.0"
+        dependencies:
+          dep1: 1.0.0
+        dev_dependencies:
+          dep2: 1.0.1
+      `;
+      const actual = parsePubspec(fileName, fileContent);
+      expect(actual).toMatchObject({
+        environment: { sdk: '>=3.0.0 <4.0.0', flutter: '>=3.10.0' },
+        dependencies: { dep1: '1.0.0' },
+        dev_dependencies: { dep2: '1.0.1' },
+      });
+    });
+
+    it('invalid yaml', () => {
+      const actual = parsePubspec(fileName, invalidYaml);
+      expect(actual).toBeNull();
+    });
 
+    it('invalid schema', () => {
+      const actual = parsePubspec(fileName, invalidSchema);
+      expect(actual).toBeNull();
+    });
+  });
+
+  describe('parsePubspeckLock', () => {
     it('load and parse successfully', () => {
       const pubspecLock = codeBlock`
         sdks:
@@ -18,12 +54,12 @@ describe('modules/manager/pub/utils', () => {
     });
 
     it('invalid yaml', () => {
-      const actual = parsePubspecLock(fileName, 'clearly-invalid');
+      const actual = parsePubspecLock(fileName, invalidYaml);
       expect(actual).toBeNull();
     });
 
     it('invalid schema', () => {
-      const actual = parsePubspecLock(fileName, 'clearly:\n\tinvalid: lock');
+      const actual = parsePubspecLock(fileName, invalidSchema);
       expect(actual).toBeNull();
     });
   });
diff --git a/lib/modules/manager/pub/utils.ts b/lib/modules/manager/pub/utils.ts
index 7aa7f05e0e..dda0df348e 100644
--- a/lib/modules/manager/pub/utils.ts
+++ b/lib/modules/manager/pub/utils.ts
@@ -1,5 +1,23 @@
 import { logger } from '../../../logger';
-import { PubspecLockSchema, PubspecLockYaml } from './schema';
+import {
+  PubspecLockSchema,
+  PubspecLockYaml,
+  PubspecSchema,
+  PubspecYaml,
+} from './schema';
+
+export function parsePubspec(
+  fileName: string,
+  fileContent: string
+): PubspecSchema | null {
+  const res = PubspecYaml.safeParse(fileContent);
+  if (res.success) {
+    return res.data;
+  } else {
+    logger.debug({ err: res.error, fileName }, 'Error parsing pubspec.');
+  }
+  return null;
+}
 
 export function parsePubspecLock(
   fileName: string,
@@ -11,7 +29,7 @@ export function parsePubspecLock(
   } else {
     logger.debug(
       { err: res.error, fileName },
-      `Error parsing pubspec lockfile.`
+      'Error parsing pubspec lockfile.'
     );
   }
   return null;
-- 
GitLab