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

Merged
merged 19 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
122aab9
feat(manager): add Cabal/Haskell manager using Hackage/PVP
ysangkok Dec 16, 2024
ac0b8bf
chore(manager/haskell-cabal): Run prettier
ysangkok Dec 16, 2024
093e75f
chore(manager/haskell-cabal): add readme
ysangkok Dec 16, 2024
87d580d
chore(manager/haskell-cabal): fix markdown link
ysangkok Dec 16, 2024
38e5feb
chore(manager/haskell-cabal): fix pkg name extraction
ysangkok Dec 17, 2024
6158c2b
chore(manager/haskell-cabal): fix spelling of boolean
ysangkok Dec 17, 2024
a5c810a
chore(versioning): PVP only handles widen. Not auto.
ysangkok Dec 17, 2024
cc15bbe
chore(manager/haskell-cabal): on rangeStrategy=auto, set widen
ysangkok Dec 18, 2024
7575acd
chore(manager/haskell-cabal): Run prettier again
ysangkok Dec 18, 2024
70069e2
chore(manager/haskell-cabal): test getRangeStrategy
ysangkok Dec 18, 2024
f58e5de
chore(manager/haskell-cabal): Various fixes from @secustor
ysangkok Jan 4, 2025
4124c39
chore(manager/haskell-cabal): Avoid adding new Fixture
ysangkok Jan 5, 2025
e45eef3
chore(haskell-cabal): Use `codeBlock`
ysangkok Jan 7, 2025
a7f3bbb
chore(manager/haskell-cabal): Add codeBlock import, real repo ref
ysangkok Jan 7, 2025
3f09a92
chore(manager/haskell-cabal): Remove repo reference in code
ysangkok Jan 7, 2025
0938656
chore(manager/haskell-cabal): Remove repo ref in code
ysangkok Jan 7, 2025
ee47a8d
Revert "chore(manager/haskell-cabal): Remove repo ref in code"
ysangkok Jan 7, 2025
a979191
Update lib/modules/manager/haskell-cabal/index.ts
secustor Jan 13, 2025
6784b3e
Update lib/modules/manager/haskell-cabal/index.ts
secustor Jan 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}
ysangkok marked this conversation as resolved.
Show resolved Hide resolved
${'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'} | ${''}
ysangkok marked this conversation as resolved.
Show resolved Hide resolved
`(
'splitSingleDependency($depLine)',
({ depLine, expectedName, expectedRange }) => {
const res = splitSingleDependency(depLine);
expect(res?.name).toBe(expectedName);
ysangkok marked this conversation as resolved.
Show resolved Hide resolved
expect(res?.range).toBe(expectedRange);
},
);
});

describe('extractNamesAndRanges()', () => {
it('trims replaceString', () => {
const res = extractNamesAndRanges(' a , b ');
expect(res).toHaveLength(2);
secustor marked this conversation as resolved.
Show resolved Hide resolved
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;
ysangkok marked this conversation as resolved.
Show resolved Hide resolved
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;
}
33 changes: 33 additions & 0 deletions lib/modules/manager/haskell-cabal/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { extractPackageFile, getRangeStrategy } from '.';

describe('modules/manager/haskell-cabal/index', () => {
describe('extractPackageFile()', () => {
ysangkok marked this conversation as resolved.
Show resolved Hide resolved
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);
},
);
});

describe('getRangeStrategy()', () => {
it.each`
input | expected
${'auto'} | ${'widen'}
${'widen'} | ${'widen'}
${'replace'} | ${'replace'}
`('getRangeStrategy({ rangeStrategy: $input })', ({ input, expected }) => {
expect(getRangeStrategy({ rangeStrategy: input })).toBe(expected);
});
});
});
58 changes: 58 additions & 0 deletions lib/modules/manager/haskell-cabal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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,
versioning: pvpVersioning.id,
secustor marked this conversation as resolved.
Show resolved Hide resolved
};

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',
secustor marked this conversation as resolved.
Show resolved Hide resolved
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;
}
Loading
Loading