Skip to content

Commit

Permalink
feat(manager): add Cabal/Haskell manager using Hackage/PVP (#33142)
Browse files Browse the repository at this point in the history
Co-authored-by: Sebastian Poxhofer <[email protected]>
  • Loading branch information
ysangkok and secustor authored Jan 13, 2025
1 parent 738843a commit 3c52395
Show file tree
Hide file tree
Showing 10 changed files with 417 additions and 7 deletions.
1 change: 1 addition & 0 deletions lib/constants/category.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const Categories = [
'dotnet',
'elixir',
'golang',
'haskell',
'helm',
'iac',
'java',
Expand Down
2 changes: 2 additions & 0 deletions lib/modules/manager/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
94 changes: 94 additions & 0 deletions lib/modules/manager/haskell-cabal/extract.spec.ts
Original file line number Diff line number Diff line change
@@ -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' },
]);
});
});
});
190 changes: 190 additions & 0 deletions lib/modules/manager/haskell-cabal/extract.ts
Original file line number Diff line number Diff line change
@@ -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;
}
55 changes: 55 additions & 0 deletions lib/modules/manager/haskell-cabal/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading

0 comments on commit 3c52395

Please sign in to comment.