|
| 1 | +export interface VersionMatch { |
| 2 | + file: string; |
| 3 | + line: number; |
| 4 | + column: number; |
| 5 | + matched: string; |
| 6 | + major: number; |
| 7 | + minor: number; |
| 8 | + patch: number; |
| 9 | +} |
| 10 | + |
| 11 | +interface PatternDefinition { |
| 12 | + id: string; |
| 13 | + description: string; |
| 14 | + isFileSupported: (filePath: string) => boolean; |
| 15 | + regexes: RegExp[]; |
| 16 | +} |
| 17 | + |
| 18 | +const VERSION_PATTERN = '\\d+\\.\\d+\\.\\d+'; |
| 19 | + |
| 20 | +export const pythonVersionPatterns: PatternDefinition[] = [ |
| 21 | + { |
| 22 | + id: 'workflow-python-version', |
| 23 | + description: 'python-version inputs inside GitHub Actions workflows', |
| 24 | + isFileSupported: (filePath) => { |
| 25 | + const normalized = normalizePath(filePath).toLowerCase(); |
| 26 | + const isWorkflowFile = normalized.includes('.github/workflows/'); |
| 27 | + const isYaml = normalized.endsWith('.yml') || normalized.endsWith('.yaml'); |
| 28 | + return isWorkflowFile && isYaml; |
| 29 | + }, |
| 30 | + regexes: [new RegExp(`python-version\\s*:\\s*["]?(?<version>${VERSION_PATTERN})["]?`, 'gi')], |
| 31 | + }, |
| 32 | + { |
| 33 | + id: 'dockerfile-from', |
| 34 | + description: 'Python base images in Dockerfiles', |
| 35 | + isFileSupported: isDockerfile, |
| 36 | + regexes: [ |
| 37 | + new RegExp(`FROM\\s+[^\\s]*python[^\\s:]*:(?<version>${VERSION_PATTERN})`, 'gi'), |
| 38 | + new RegExp( |
| 39 | + `\\b(?:ARG|ENV)\\s+PYTHON[_-]?VERSION\\s*=\\s*["]?(?<version>${VERSION_PATTERN})["]?`, |
| 40 | + 'gi', |
| 41 | + ), |
| 42 | + ], |
| 43 | + }, |
| 44 | + { |
| 45 | + id: 'python-version-file', |
| 46 | + description: '.python-version files containing a sole version', |
| 47 | + isFileSupported: (filePath) => getBasename(filePath) === '.python-version', |
| 48 | + regexes: [new RegExp(`^\\s*(?<version>${VERSION_PATTERN})\\s*$`, 'gim')], |
| 49 | + }, |
| 50 | + { |
| 51 | + id: 'tool-versions', |
| 52 | + description: 'python entries inside .tool-versions', |
| 53 | + isFileSupported: (filePath) => getBasename(filePath) === '.tool-versions', |
| 54 | + regexes: [new RegExp(`^python[^\\S\\r\\n]+(?<version>${VERSION_PATTERN})\\b`, 'gim')], |
| 55 | + }, |
| 56 | + { |
| 57 | + id: 'runtime-txt', |
| 58 | + description: 'Heroku-style runtime.txt files', |
| 59 | + isFileSupported: (filePath) => getBasename(filePath) === 'runtime.txt', |
| 60 | + regexes: [new RegExp(`^python-(?<version>${VERSION_PATTERN})\\b`, 'gim')], |
| 61 | + }, |
| 62 | + { |
| 63 | + id: 'pyproject-python', |
| 64 | + description: 'python/requirements entries inside pyproject.toml', |
| 65 | + isFileSupported: (filePath) => getBasename(filePath) === 'pyproject.toml', |
| 66 | + regexes: [ |
| 67 | + new RegExp( |
| 68 | + `\\b(?:requires-python|python(?:[_-]?version)?|pythonVersion)\\s*=\\s*["](?:==)?(?<version>${VERSION_PATTERN})["]`, |
| 69 | + 'gi', |
| 70 | + ), |
| 71 | + ], |
| 72 | + }, |
| 73 | + { |
| 74 | + id: 'tox-ini', |
| 75 | + description: 'tox.ini basepython/python_version fields', |
| 76 | + isFileSupported: (filePath) => getBasename(filePath) === 'tox.ini', |
| 77 | + regexes: [ |
| 78 | + new RegExp(`^\\s*python_version\\s*=\\s*(?<version>${VERSION_PATTERN})\\b`, 'gim'), |
| 79 | + new RegExp(`^\\s*basepython\\s*=\\s*python(?<version>${VERSION_PATTERN})\\b`, 'gim'), |
| 80 | + ], |
| 81 | + }, |
| 82 | + { |
| 83 | + id: 'pipfile', |
| 84 | + description: 'Pipfile python version declarations', |
| 85 | + isFileSupported: (filePath) => getBasename(filePath) === 'pipfile', |
| 86 | + regexes: [ |
| 87 | + new RegExp(`^\\s*python_full_version\\s*=\\s*["](?<version>${VERSION_PATTERN})["]`, 'gim'), |
| 88 | + new RegExp(`^\\s*python_version\\s*=\\s*["](?<version>${VERSION_PATTERN})["]`, 'gim'), |
| 89 | + ], |
| 90 | + }, |
| 91 | + { |
| 92 | + id: 'environment-yml', |
| 93 | + description: 'Conda environment python dependencies', |
| 94 | + isFileSupported: (filePath) => { |
| 95 | + const base = getBasename(filePath); |
| 96 | + return base === 'environment.yml' || base === 'environment.yaml'; |
| 97 | + }, |
| 98 | + regexes: [new RegExp(`(?:^|\\s|-)python(?:==|=)(?<version>${VERSION_PATTERN})\\b`, 'gi')], |
| 99 | + }, |
| 100 | +]; |
| 101 | + |
| 102 | +function normalizePath(filePath: string): string { |
| 103 | + return filePath.replace(/\\\\/g, '/'); |
| 104 | +} |
| 105 | + |
| 106 | +function getBasename(filePath: string): string { |
| 107 | + const normalized = normalizePath(filePath); |
| 108 | + const index = normalized.lastIndexOf('/'); |
| 109 | + const base = index === -1 ? normalized : normalized.slice(index + 1); |
| 110 | + return base.toLowerCase(); |
| 111 | +} |
| 112 | + |
| 113 | +function isDockerfile(filePath: string): boolean { |
| 114 | + const base = getBasename(filePath); |
| 115 | + return base === 'dockerfile' || base.endsWith('.dockerfile'); |
| 116 | +} |
| 117 | + |
| 118 | +function cloneRegex(regex: RegExp): RegExp { |
| 119 | + const flags = regex.flags.includes('g') ? regex.flags : `${regex.flags}g`; |
| 120 | + return new RegExp(regex.source, flags); |
| 121 | +} |
| 122 | + |
| 123 | +function indexToPosition(content: string, index: number): { line: number; column: number } { |
| 124 | + let line = 1; |
| 125 | + let column = 1; |
| 126 | + |
| 127 | + for (let i = 0; i < index; i += 1) { |
| 128 | + const char = content[i]; |
| 129 | + if (char === '\n') { |
| 130 | + line += 1; |
| 131 | + column = 1; |
| 132 | + continue; |
| 133 | + } |
| 134 | + |
| 135 | + if (char === '\r') { |
| 136 | + if (content[i + 1] === '\n') { |
| 137 | + i += 1; |
| 138 | + } |
| 139 | + line += 1; |
| 140 | + column = 1; |
| 141 | + continue; |
| 142 | + } |
| 143 | + |
| 144 | + column += 1; |
| 145 | + } |
| 146 | + |
| 147 | + return { line, column }; |
| 148 | +} |
| 149 | + |
| 150 | +function extractVersion(match: RegExpExecArray): string | null { |
| 151 | + if (match.groups && match.groups.version) { |
| 152 | + return match.groups.version; |
| 153 | + } |
| 154 | + |
| 155 | + return null; |
| 156 | +} |
| 157 | + |
| 158 | +export function findPythonVersionMatches(filePath: string, content: string): VersionMatch[] { |
| 159 | + const results: VersionMatch[] = []; |
| 160 | + |
| 161 | + for (const pattern of pythonVersionPatterns) { |
| 162 | + if (!pattern.isFileSupported(filePath)) { |
| 163 | + continue; |
| 164 | + } |
| 165 | + |
| 166 | + for (const regex of pattern.regexes) { |
| 167 | + const globalRegex = cloneRegex(regex); |
| 168 | + let match: RegExpExecArray | null; |
| 169 | + while ((match = globalRegex.exec(content)) !== null) { |
| 170 | + const version = extractVersion(match); |
| 171 | + if (!version) { |
| 172 | + continue; |
| 173 | + } |
| 174 | + |
| 175 | + const [major, minor, patch] = version.split('.').map(Number); |
| 176 | + if ([major, minor, patch].some((value) => Number.isNaN(value))) { |
| 177 | + continue; |
| 178 | + } |
| 179 | + |
| 180 | + const relativeIndex = match[0].indexOf(version); |
| 181 | + const versionIndex = match.index + (relativeIndex >= 0 ? relativeIndex : 0); |
| 182 | + const position = indexToPosition(content, versionIndex); |
| 183 | + |
| 184 | + results.push({ |
| 185 | + file: filePath, |
| 186 | + line: position.line, |
| 187 | + column: position.column, |
| 188 | + matched: version, |
| 189 | + major, |
| 190 | + minor, |
| 191 | + patch, |
| 192 | + }); |
| 193 | + } |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + return results.sort((a, b) => { |
| 198 | + if (a.line !== b.line) { |
| 199 | + return a.line - b.line; |
| 200 | + } |
| 201 | + |
| 202 | + if (a.column !== b.column) { |
| 203 | + return a.column - b.column; |
| 204 | + } |
| 205 | + |
| 206 | + if (a.matched !== b.matched) { |
| 207 | + return a.matched.localeCompare(b.matched); |
| 208 | + } |
| 209 | + |
| 210 | + return 0; |
| 211 | + }); |
| 212 | +} |
0 commit comments