-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(manager): add Cabal/Haskell manager using Hackage/PVP (#33142)
Co-authored-by: Sebastian Poxhofer <[email protected]>
- Loading branch information
Showing
10 changed files
with
417 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,6 +12,7 @@ export const Categories = [ | |
'dotnet', | ||
'elixir', | ||
'golang', | ||
'haskell', | ||
'helm', | ||
'iac', | ||
'java', | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' }, | ||
]); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.