This commit is contained in:
Janus Troelsen 2024-12-31 14:54:55 +00:00 committed by GitHub
commit dd5500ea4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 389 additions and 7 deletions

View file

@ -12,6 +12,7 @@ export const Categories = [
'dotnet', 'dotnet',
'elixir', 'elixir',
'golang', 'golang',
'haskell',
'helm', 'helm',
'iac', 'iac',
'java', 'java',

View file

@ -44,6 +44,7 @@ import * as gleam from './gleam';
import * as gomod from './gomod'; import * as gomod from './gomod';
import * as gradle from './gradle'; import * as gradle from './gradle';
import * as gradleWrapper from './gradle-wrapper'; import * as gradleWrapper from './gradle-wrapper';
import * as haskellCabal from './haskell-cabal';
import * as helmRequirements from './helm-requirements'; import * as helmRequirements from './helm-requirements';
import * as helmValues from './helm-values'; import * as helmValues from './helm-values';
import * as helmfile from './helmfile'; import * as helmfile from './helmfile';
@ -150,6 +151,7 @@ api.set('gleam', gleam);
api.set('gomod', gomod); api.set('gomod', gomod);
api.set('gradle', gradle); api.set('gradle', gradle);
api.set('gradle-wrapper', gradleWrapper); api.set('gradle-wrapper', gradleWrapper);
api.set('haskell-cabal', haskellCabal);
api.set('helm-requirements', helmRequirements); api.set('helm-requirements', helmRequirements);
api.set('helm-values', helmValues); api.set('helm-values', helmValues);
api.set('helmfile', helmfile); api.set('helmfile', helmfile);

View file

@ -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');
});
});
});

View file

@ -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;
}

View file

@ -0,0 +1,33 @@
import { extractPackageFile, getRangeStrategy } 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);
},
);
});
describe('getRangeStrategy()', () => {
it.each`
input | expected
${'auto'} | ${'widen'}
${'widen'} | ${'widen'}
${'replace'} | ${'replace'}
`('getRangeStrategy({ rangeStrategy: $input })', ({ input, expected }) => {
expect(getRangeStrategy({ rangeStrategy: input })).toBe(expected);
});
});
});

View file

@ -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,
};
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 };
}
export function getRangeStrategy({
rangeStrategy,
}: RangeConfig): RangeStrategy {
if (rangeStrategy === 'auto') {
return 'widen';
}
return rangeStrategy;
}

View file

@ -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.

View file

@ -141,12 +141,12 @@ describe('modules/versioning/pvp/index', () => {
describe('.getNewValue(newValueConfig)', () => { describe('.getNewValue(newValueConfig)', () => {
it.each` it.each`
currentValue | newVersion | rangeStrategy | expected currentValue | newVersion | rangeStrategy | expected
${'>=1.0 && <1.1'} | ${'1.1'} | ${'auto'} | ${'>=1.0 && <1.2'} ${'>=1.0 && <1.1'} | ${'1.1'} | ${'widen'} | ${'>=1.0 && <1.2'}
${'>=1.2 && <1.3'} | ${'1.2.3'} | ${'auto'} | ${null} ${'>=1.2 && <1.3'} | ${'1.2.3'} | ${'widen'} | ${null}
${'>=1.0 && <1.1'} | ${'1.2.3'} | ${'update-lockfile'} | ${null} ${'>=1.0 && <1.1'} | ${'1.2.3'} | ${'update-lockfile'} | ${null}
${'gibberish'} | ${'1.2.3'} | ${'auto'} | ${null} ${'gibberish'} | ${'1.2.3'} | ${'widen'} | ${null}
${'>=1.0 && <1.1'} | ${'0.9'} | ${'auto'} | ${null} ${'>=1.0 && <1.1'} | ${'0.9'} | ${'widen'} | ${null}
${'>=1.0 && <1.1'} | ${''} | ${'auto'} | ${null} ${'>=1.0 && <1.1'} | ${''} | ${'widen'} | ${null}
`( `(
'pvp.getNewValue({currentValue: "$currentValue", newVersion: "$newVersion", rangeStrategy: "$rangeStrategy"}) === $expected', 'pvp.getNewValue({currentValue: "$currentValue", newVersion: "$newVersion", rangeStrategy: "$rangeStrategy"}) === $expected',
({ currentValue, newVersion, rangeStrategy, expected }) => { ({ currentValue, newVersion, rangeStrategy, expected }) => {

View file

@ -9,7 +9,7 @@ export const id = 'pvp';
export const displayName = 'Package Versioning Policy (Haskell)'; export const displayName = 'Package Versioning Policy (Haskell)';
export const urls = ['https://pvp.haskell.org']; export const urls = ['https://pvp.haskell.org'];
export const supportsRanges = true; export const supportsRanges = true;
export const supportedRangeStrategies: RangeStrategy[] = ['auto']; export const supportedRangeStrategies: RangeStrategy[] = ['widen'];
const digitsAndDots = regEx(/^[\d.]+$/); const digitsAndDots = regEx(/^[\d.]+$/);
@ -112,7 +112,7 @@ function getNewValue({
newVersion, newVersion,
rangeStrategy, rangeStrategy,
}: NewValueConfig): string | null { }: NewValueConfig): string | null {
if (rangeStrategy !== 'auto') { if (rangeStrategy !== 'widen') {
logger.info( logger.info(
{ rangeStrategy, currentValue, newVersion }, { rangeStrategy, currentValue, newVersion },
`PVP can't handle this range strategy.`, `PVP can't handle this range strategy.`,

View file

@ -48,6 +48,7 @@ export const CategoryNames: Record<Category, string> = {
dotnet: '.NET', dotnet: '.NET',
elixir: 'Elixir', elixir: 'Elixir',
golang: 'Go', golang: 'Go',
haskell: 'Haskell',
helm: 'Helm', helm: 'Helm',
iac: 'Infrastructure as Code', iac: 'Infrastructure as Code',
java: 'Java', java: 'Java',