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',