diff --git a/lib/constants/category.ts b/lib/constants/category.ts
index a92c54656eeb5643b46bd70d4de4dcc0e6fb1874..94c22015c9a5cd3d0cec107ba1b89d2d31b183e4 100644
--- a/lib/constants/category.ts
+++ b/lib/constants/category.ts
@@ -12,6 +12,7 @@ export const Categories = [
   'dotnet',
   'elixir',
   'golang',
+  'haskell',
   'helm',
   'iac',
   'java',
diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts
index e8522f73326a9e25eb61e94bf32dc63cdccc8661..573fcf95375e938d08f7354fb8f86e0fda741f72 100644
--- a/lib/modules/manager/api.ts
+++ b/lib/modules/manager/api.ts
@@ -44,6 +44,7 @@ import * as gleam from './gleam';
 import * as gomod from './gomod';
 import * as gradle from './gradle';
 import * as gradleWrapper from './gradle-wrapper';
+import * as haskellCabal from './haskell-cabal';
 import * as helmRequirements from './helm-requirements';
 import * as helmValues from './helm-values';
 import * as helmfile from './helmfile';
@@ -150,6 +151,7 @@ api.set('gleam', gleam);
 api.set('gomod', gomod);
 api.set('gradle', gradle);
 api.set('gradle-wrapper', gradleWrapper);
+api.set('haskell-cabal', haskellCabal);
 api.set('helm-requirements', helmRequirements);
 api.set('helm-values', helmValues);
 api.set('helmfile', helmfile);
diff --git a/lib/modules/manager/haskell-cabal/extract.spec.ts b/lib/modules/manager/haskell-cabal/extract.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7625c210396a08fffcf20780f2c20766efcacda3
--- /dev/null
+++ b/lib/modules/manager/haskell-cabal/extract.spec.ts
@@ -0,0 +1,94 @@
+import {
+  countPackageNameLength,
+  countPrecedingIndentation,
+  extractNamesAndRanges,
+  findExtents,
+  splitSingleDependency,
+} from './extract';
+
+describe('modules/manager/haskell-cabal/extract', () => {
+  describe('countPackageNameLength', () => {
+    it.each`
+      input       | expected
+      ${'-'}      | ${null}
+      ${'-j'}     | ${null}
+      ${'-H'}     | ${null}
+      ${'j-'}     | ${null}
+      ${'3-'}     | ${null}
+      ${'-3'}     | ${null}
+      ${'3'}      | ${null}
+      ${'æ'}      | ${null}
+      ${'æe'}     | ${null}
+      ${'j'}      | ${1}
+      ${'H'}      | ${1}
+      ${'0ad'}    | ${3}
+      ${'3d'}     | ${2}
+      ${'aeson'}  | ${5}
+      ${'lens'}   | ${4}
+      ${'parsec'} | ${6}
+    `('matches $input', ({ input, expected }) => {
+      const maybeIndex = countPackageNameLength(input);
+      expect(maybeIndex).toStrictEqual(expected);
+    });
+  });
+
+  describe('countPrecedingIndentation()', () => {
+    it.each`
+      content                                       | index | expected
+      ${'\tbuild-depends: base\n\tother-field: hi'} | ${1}  | ${1}
+      ${' build-depends: base'}                     | ${1}  | ${1}
+      ${'a\tb'}                                     | ${0}  | ${0}
+      ${'a\tb'}                                     | ${2}  | ${1}
+      ${'a b'}                                      | ${2}  | ${1}
+      ${'  b'}                                      | ${2}  | ${2}
+    `(
+      'countPrecedingIndentation($content, $index)',
+      ({ content, index, expected }) => {
+        expect(countPrecedingIndentation(content, index)).toBe(expected);
+      },
+    );
+  });
+
+  describe('findExtents()', () => {
+    it.each`
+      content                | indent | expected
+      ${'a: b\n\tc: d'}      | ${1}   | ${10}
+      ${'a: b'}              | ${2}   | ${4}
+      ${'a: b\n\tc: d'}      | ${2}   | ${4}
+      ${'a: b\n '}           | ${2}   | ${6}
+      ${'a: b\n c: d\ne: f'} | ${1}   | ${10}
+    `('findExtents($indent, $content)', ({ indent, content, expected }) => {
+      expect(findExtents(indent, content)).toBe(expected);
+    });
+  });
+
+  describe('splitSingleDependency()', () => {
+    it.each`
+      depLine              | expectedName | expectedRange
+      ${'base >=2 && <3'}  | ${'base'}    | ${'>=2 && <3'}
+      ${'base >=2 && <3 '} | ${'base'}    | ${'>=2 && <3'}
+      ${'base>=2&&<3'}     | ${'base'}    | ${'>=2&&<3'}
+      ${'base'}            | ${'base'}    | ${''}
+    `(
+      'splitSingleDependency($depLine)',
+      ({ depLine, expectedName, expectedRange }) => {
+        const res = splitSingleDependency(depLine);
+        expect(res?.name).toEqual(expectedName);
+        expect(res?.range).toEqual(expectedRange);
+      },
+    );
+
+    // The first hyphen makes the package name invalid
+    expect(splitSingleDependency('-invalid-package-name')).toBeNull();
+  });
+
+  describe('extractNamesAndRanges()', () => {
+    it('trims replaceString', () => {
+      const res = extractNamesAndRanges(' a , b ');
+      expect(res).toEqual([
+        { currentValue: '', packageName: 'a', replaceString: 'a' },
+        { currentValue: '', packageName: 'b', replaceString: 'b' },
+      ]);
+    });
+  });
+});
diff --git a/lib/modules/manager/haskell-cabal/extract.ts b/lib/modules/manager/haskell-cabal/extract.ts
new file mode 100644
index 0000000000000000000000000000000000000000..80231c3e5c7a932908ff2e66733dcd9c833f7c1a
--- /dev/null
+++ b/lib/modules/manager/haskell-cabal/extract.ts
@@ -0,0 +1,190 @@
+import { regEx } from '../../../util/regex';
+
+const buildDependsRegex = regEx(
+  /(?<buildDependsFieldName>build-depends[ \t]*:)/i,
+);
+function isNonASCII(str: string): boolean {
+  for (let i = 0; i < str.length; i++) {
+    if (str.charCodeAt(i) > 127) {
+      return true;
+    }
+  }
+  return false;
+}
+
+export function countPackageNameLength(input: string): number | null {
+  if (input.length < 1 || isNonASCII(input)) {
+    return null;
+  }
+  if (!regEx(/^[A-Za-z0-9]/).test(input[0])) {
+    // Must start with letter or number
+    return null;
+  }
+  let idx = 1;
+  while (idx < input.length) {
+    if (regEx(/[A-Za-z0-9-]/).test(input[idx])) {
+      idx++;
+    } else {
+      break;
+    }
+  }
+  if (!regEx(/[A-Za-z]/).test(input.slice(0, idx))) {
+    // Must contain a letter
+    return null;
+  }
+  if (idx - 1 < input.length && input[idx - 1] === '-') {
+    // Can't end in a hyphen
+    return null;
+  }
+  return idx;
+}
+
+export interface CabalDependency {
+  packageName: string;
+  currentValue: string;
+  replaceString: string;
+}
+
+/**
+ * Find extents of field contents
+ *
+ * @param {number} indent -
+ *    Indention level maintained within the block.
+ *    Any indention lower than this means it's outside the field.
+ *    Lines with this level or more are included in the field.
+ * @returns {number}
+ *    Index just after the end of the block.
+ *    Note that it may be after the end of the string.
+ */
+export function findExtents(indent: number, content: string): number {
+  let blockIdx: number = 0;
+  let mode: 'finding-newline' | 'finding-indention' = 'finding-newline';
+  for (;;) {
+    if (mode === 'finding-newline') {
+      while (content[blockIdx++] !== '\n') {
+        if (blockIdx >= content.length) {
+          break;
+        }
+      }
+      if (blockIdx >= content.length) {
+        return content.length;
+      }
+      mode = 'finding-indention';
+    } else {
+      let thisIndent = 0;
+      for (;;) {
+        if ([' ', '\t'].includes(content[blockIdx])) {
+          thisIndent += 1;
+          blockIdx++;
+          if (blockIdx >= content.length) {
+            return content.length;
+          }
+          continue;
+        }
+        mode = 'finding-newline';
+        blockIdx++;
+        break;
+      }
+      if (thisIndent < indent) {
+        // go back to before the newline
+        for (;;) {
+          if (content[blockIdx--] === '\n') {
+            break;
+          }
+        }
+        return blockIdx + 1;
+      }
+      mode = 'finding-newline';
+    }
+  }
+}
+
+/**
+ * Find indention level of build-depends
+ *
+ * @param {number} match -
+ *   Search starts at this index, and proceeds backwards.
+ * @returns {number}
+ *   Number of indention levels found before 'match'.
+ */
+export function countPrecedingIndentation(
+  content: string,
+  match: number,
+): number {
+  let whitespaceIdx = match - 1;
+  let indent = 0;
+  while (whitespaceIdx >= 0 && [' ', '\t'].includes(content[whitespaceIdx])) {
+    indent += 1;
+    whitespaceIdx--;
+  }
+  return indent;
+}
+
+/**
+ * Find one 'build-depends' field name usage and its field value
+ *
+ * @returns {{buildDependsContent: string, lengthProcessed: number}}
+ *   buildDependsContent:
+ *     the contents of the field, excluding the field name and the colon.
+ *
+ *   lengthProcessed:
+ *     points to after the end of the field. Note that the field does _not_
+ *     necessarily start at `content.length - lengthProcessed`.
+ *
+ *   Returns null if no 'build-depends' field is found.
+ */
+export function findDepends(
+  content: string,
+): { buildDependsContent: string; lengthProcessed: number } | null {
+  const matchObj = buildDependsRegex.exec(content);
+  if (!matchObj?.groups) {
+    return null;
+  }
+  const indent = countPrecedingIndentation(content, matchObj.index);
+  const ourIdx: number =
+    matchObj.index + matchObj.groups['buildDependsFieldName'].length;
+  const extent: number = findExtents(indent + 1, content.slice(ourIdx));
+  return {
+    buildDependsContent: content.slice(ourIdx, ourIdx + extent),
+    lengthProcessed: ourIdx + extent,
+  };
+}
+
+/**
+ * Split a cabal single dependency into its constituent parts.
+ * The first part is the package name, an optional second part contains
+ * the version constraint.
+ *
+ * For example 'base == 3.2' would be split into 'base' and ' == 3.2'.
+ *
+ * @returns {{name: string, range: string}}
+ *   Null if the trimmed string doesn't begin with a package name.
+ */
+export function splitSingleDependency(
+  input: string,
+): { name: string; range: string } | null {
+  const match = countPackageNameLength(input);
+  if (match === null) {
+    return null;
+  }
+  const name: string = input.slice(0, match);
+  const range = input.slice(match).trim();
+  return { name, range };
+}
+
+export function extractNamesAndRanges(content: string): CabalDependency[] {
+  const list = content.split(',');
+  const deps = [];
+  for (const untrimmedReplaceString of list) {
+    const replaceString = untrimmedReplaceString.trim();
+    const maybeNameRange = splitSingleDependency(replaceString);
+    if (maybeNameRange !== null) {
+      deps.push({
+        currentValue: maybeNameRange.range,
+        packageName: maybeNameRange.name,
+        replaceString,
+      });
+    }
+  }
+  return deps;
+}
diff --git a/lib/modules/manager/haskell-cabal/index.spec.ts b/lib/modules/manager/haskell-cabal/index.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7409938d36745b9a5b9a4607372dcaa95be42053
--- /dev/null
+++ b/lib/modules/manager/haskell-cabal/index.spec.ts
@@ -0,0 +1,55 @@
+import { codeBlock } from 'common-tags';
+import { extractPackageFile, getRangeStrategy } from '.';
+
+const minimalCabalFile = codeBlock`
+cabal-version: 3.4
+name: minimal
+version: 0.1.0.0
+
+executable my-cli-entry-point
+  main-is: Main.hs
+  build-depends: base>=4.20`;
+
+describe('modules/manager/haskell-cabal/index', () => {
+  describe('extractPackageFile()', () => {
+    it.each`
+      content                                 | expected
+      ${'build-depends: base,'}               | ${['base']}
+      ${'build-depends:,other,other2'}        | ${['other', 'other2']}
+      ${'build-depends : base'}               | ${['base']}
+      ${'Build-Depends: base'}                | ${['base']}
+      ${'build-depends: a\nbuild-depends: b'} | ${['a', 'b']}
+      ${'dependencies: base'}                 | ${[]}
+    `(
+      'extractPackageFile($content).deps.map(x => x.packageName)',
+      ({ content, expected }) => {
+        expect(
+          extractPackageFile(content).deps.map((x) => x.packageName),
+        ).toStrictEqual(expected);
+      },
+    );
+
+    expect(extractPackageFile(minimalCabalFile).deps).toStrictEqual([
+      {
+        autoReplaceStringTemplate: '{{{depName}}} {{{newValue}}}',
+        currentValue: '>=4.20',
+        datasource: 'hackage',
+        depName: 'base',
+        packageName: 'base',
+        replaceString: 'base>=4.20',
+        versioning: 'pvp',
+      },
+    ]);
+  });
+
+  describe('getRangeStrategy()', () => {
+    it.each`
+      input        | expected
+      ${'auto'}    | ${'widen'}
+      ${'widen'}   | ${'widen'}
+      ${'replace'} | ${'replace'}
+    `('getRangeStrategy({ rangeStrategy: $input })', ({ input, expected }) => {
+      expect(getRangeStrategy({ rangeStrategy: input })).toBe(expected);
+    });
+  });
+});
diff --git a/lib/modules/manager/haskell-cabal/index.ts b/lib/modules/manager/haskell-cabal/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2616dd88f17d2645a99ff7b31bc096c174f0234b
--- /dev/null
+++ b/lib/modules/manager/haskell-cabal/index.ts
@@ -0,0 +1,57 @@
+import type { Category } from '../../../constants';
+import type { RangeStrategy } from '../../../types';
+import { HackageDatasource } from '../../datasource/hackage';
+import * as pvpVersioning from '../../versioning/pvp';
+import type {
+  PackageDependency,
+  PackageFileContent,
+  RangeConfig,
+} from '../types';
+import type { CabalDependency } from './extract';
+import { extractNamesAndRanges, findDepends } from './extract';
+
+export const defaultConfig = {
+  fileMatch: ['\\.cabal$'],
+  pinDigests: false,
+};
+
+export const categories: Category[] = ['haskell'];
+
+export const supportedDatasources = [HackageDatasource.id];
+
+export function extractPackageFile(content: string): PackageFileContent {
+  const deps = [];
+  let current = content;
+  for (;;) {
+    const maybeContent = findDepends(current);
+    if (maybeContent === null) {
+      break;
+    }
+    const cabalDeps: CabalDependency[] = extractNamesAndRanges(
+      maybeContent.buildDependsContent,
+    );
+    for (const cabalDep of cabalDeps) {
+      const dep: PackageDependency = {
+        depName: cabalDep.packageName,
+        currentValue: cabalDep.currentValue,
+        datasource: HackageDatasource.id,
+        packageName: cabalDep.packageName,
+        versioning: pvpVersioning.id,
+        replaceString: cabalDep.replaceString.trim(),
+        autoReplaceStringTemplate: '{{{depName}}} {{{newValue}}}',
+      };
+      deps.push(dep);
+    }
+    current = current.slice(maybeContent.lengthProcessed);
+  }
+  return { deps };
+}
+
+export function getRangeStrategy({
+  rangeStrategy,
+}: RangeConfig): RangeStrategy {
+  if (rangeStrategy === 'auto') {
+    return 'widen';
+  }
+  return rangeStrategy;
+}
diff --git a/lib/modules/manager/haskell-cabal/readme.md b/lib/modules/manager/haskell-cabal/readme.md
new file mode 100644
index 0000000000000000000000000000000000000000..e2c90a6fac4fd3f0d41f21bac80da858a58ec028
--- /dev/null
+++ b/lib/modules/manager/haskell-cabal/readme.md
@@ -0,0 +1,10 @@
+Supports dependency extraction from `build-depends` fields in [Cabal package description files](https://cabal.readthedocs.io/en/3.12/cabal-package-description-file.html#pkg-field-build-depends).
+They use the extension `.cabal`, and are used with the [Haskell programming language](https://www.haskell.org/).
+
+Limitations:
+
+- The dependencies of all components are mushed together in one big list.
+- Fields like `pkgconfig-depends` and `build-tool-depends` are not handled.
+- The default PVP versioning is [subject to limitations](../../versioning/pvp/index.md).
+
+If you need to change the versioning format, read the [versioning](../../versioning/index.md) documentation to learn more.
diff --git a/lib/modules/versioning/pvp/index.spec.ts b/lib/modules/versioning/pvp/index.spec.ts
index 900baa3ae7ef8ebba8f2db37622a4750f94b6e78..de36293d2df97e3805dc68904cf19be97e3a06a2 100644
--- a/lib/modules/versioning/pvp/index.spec.ts
+++ b/lib/modules/versioning/pvp/index.spec.ts
@@ -141,12 +141,12 @@ describe('modules/versioning/pvp/index', () => {
   describe('.getNewValue(newValueConfig)', () => {
     it.each`
       currentValue       | newVersion | rangeStrategy        | expected
-      ${'>=1.0 && <1.1'} | ${'1.1'}   | ${'auto'}            | ${'>=1.0 && <1.2'}
-      ${'>=1.2 && <1.3'} | ${'1.2.3'} | ${'auto'}            | ${null}
+      ${'>=1.0 && <1.1'} | ${'1.1'}   | ${'widen'}           | ${'>=1.0 && <1.2'}
+      ${'>=1.2 && <1.3'} | ${'1.2.3'} | ${'widen'}           | ${null}
       ${'>=1.0 && <1.1'} | ${'1.2.3'} | ${'update-lockfile'} | ${null}
-      ${'gibberish'}     | ${'1.2.3'} | ${'auto'}            | ${null}
-      ${'>=1.0 && <1.1'} | ${'0.9'}   | ${'auto'}            | ${null}
-      ${'>=1.0 && <1.1'} | ${''}      | ${'auto'}            | ${null}
+      ${'gibberish'}     | ${'1.2.3'} | ${'widen'}           | ${null}
+      ${'>=1.0 && <1.1'} | ${'0.9'}   | ${'widen'}           | ${null}
+      ${'>=1.0 && <1.1'} | ${''}      | ${'widen'}           | ${null}
     `(
       'pvp.getNewValue({currentValue: "$currentValue", newVersion: "$newVersion", rangeStrategy: "$rangeStrategy"}) === $expected',
       ({ currentValue, newVersion, rangeStrategy, expected }) => {
diff --git a/lib/modules/versioning/pvp/index.ts b/lib/modules/versioning/pvp/index.ts
index 59e8b3026ab91c2eb84f6fcf5c4f014300ac0c4d..ca6c5953796ebca18cd547de458b6e8452490211 100644
--- a/lib/modules/versioning/pvp/index.ts
+++ b/lib/modules/versioning/pvp/index.ts
@@ -9,7 +9,7 @@ export const id = 'pvp';
 export const displayName = 'Package Versioning Policy (Haskell)';
 export const urls = ['https://pvp.haskell.org'];
 export const supportsRanges = true;
-export const supportedRangeStrategies: RangeStrategy[] = ['auto'];
+export const supportedRangeStrategies: RangeStrategy[] = ['widen'];
 
 const digitsAndDots = regEx(/^[\d.]+$/);
 
@@ -112,7 +112,7 @@ function getNewValue({
   newVersion,
   rangeStrategy,
 }: NewValueConfig): string | null {
-  if (rangeStrategy !== 'auto') {
+  if (rangeStrategy !== 'widen') {
     logger.info(
       { rangeStrategy, currentValue, newVersion },
       `PVP can't handle this range strategy.`,
diff --git a/tools/docs/manager.ts b/tools/docs/manager.ts
index 73da0d3c09ff045a162eb23d3375456c4b4d8952..70b9c0a2435b7217651f686489c2499bb0ca103f 100644
--- a/tools/docs/manager.ts
+++ b/tools/docs/manager.ts
@@ -48,6 +48,7 @@ export const CategoryNames: Record<Category, string> = {
   dotnet: '.NET',
   elixir: 'Elixir',
   golang: 'Go',
+  haskell: 'Haskell',
   helm: 'Helm',
   iac: 'Infrastructure as Code',
   java: 'Java',