Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(manager): add Cabal/Haskell manager using Hackage/PVP #33142

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
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
86 changes: 86 additions & 0 deletions lib/modules/manager/haskell-cabal/extract.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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}
`('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).toBe(expectedName);
expect(res?.range).toBe(expectedRange);
},
);
});

describe('extractNamesAndRanges()', () => {
it('trims replaceString', () => {
const res = extractNamesAndRanges(' a , b ');
expect(res).toHaveLength(2);
expect(res[0].replaceString).toBe('a');
});
});
});
191 changes: 191 additions & 0 deletions lib/modules/manager/haskell-cabal/extract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
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 === null) {
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;
}
22 changes: 22 additions & 0 deletions lib/modules/manager/haskell-cabal/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { extractPackageFile } from '.';

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);
},
);
});
});
44 changes: 44 additions & 0 deletions lib/modules/manager/haskell-cabal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Category } from '../../../constants';
import { HackageDatasource } from '../../datasource/hackage';
import * as pvpVersioning from '../../versioning/pvp';
import type { PackageDependency, PackageFileContent } from '../types';
import type { CabalDependency } from './extract';
import { extractNamesAndRanges, findDepends } from './extract';

export const defaultConfig = {
fileMatch: ['\\.cabal$'],
pinDigests: false,
versioning: pvpVersioning.id,
};

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: 'pvp',
replaceString: cabalDep.replaceString.trim(),
autoReplaceStringTemplate: '{{{depName}}} {{{newValue}}}',
};
deps.push(dep);
}
current = current.slice(maybeContent.lengthProcessed);
}
return { deps };
}
10 changes: 10 additions & 0 deletions lib/modules/manager/haskell-cabal/readme.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions tools/docs/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading