Files
update-version/update_version.nim
T
Jonathan Bernard 8f71630e78
PR Validation / Unit Tests (pull_request) Successful in 15s
Add the ability to directly set version parts.
- Move unittests to a dedicated file.
- Rework CLI making version part selection an option that can be passed
  into many commands.
- Add support for updates to version constants defined in soruce files.
2025-06-17 20:50:33 -05:00

358 lines
11 KiB
Nim

import std/[dirs, json, paths, sequtils, strutils, syncio, tables]
import std/nre except toSeq
import docopt, zero_functional
const USAGE = """Usage:
update_nim_package_version bump [<src-file> ...] [options]
update_nim_package_version set <new-version> [<src-file> ...] [options]
update_nim_package_version get [<src-file> ...] [options]
update_nim_package_version interactive [<src-file> ...] [options]
Options:
-d, --dir <dir> Use the given directory as the project root (defaults
to the current working directory).
-l, --lang <language> Choose the language/ecosystem to consider. Valid
values are: 'nim' or 'node'. If not provided, this
is auto-detected by the presence of either a *.nimble
or package.json file.
-p, --part <part> Choose which part of the version string to set or
bump. Valid options are: 'major', 'minor', 'patch',
'prerelease', 'buildmetadata', and 'last'. Defaults
to 'last'
Details:
bump
Assuming the project with a semver-like versioning scheme, update one of
the version parts by one. <part> must be one of 'major', 'minor', 'patch',
'prerelease', 'buildmetadata', or 'last'. Semver looks like
'major.minor.patch-prerelease+buildmetadata' Last is a special case to
support looser version strings that end in '.x' like 'alpha.1' or '1.5' and
matches '.*(\.\d+)$'
The matched portion is interpreted as an integer and incremented by one.
set
Update the version string with a new string. If --part is supplied, only
that part is updated. Otherwise the entire version string is replaced.
get
Just return the existing version.
interactive
Update the version interactively.
<src-file>
Sometimes it is useful to encode the version in source files via constants.
Passing arguments as <src-files> instructs update_version to modify these
source files directly. It does so by matching constant assignments via
regex and replacing the previous version string with the new version
string. When inspecting the provided files, update_version matches the
following patterns for constanst assignments to consider:
const <package-name>_VERSION* = "<prev-version-string>"
export const <package-name>_VERSION = "<prev-version-string>"
Specifically it uses the following regexes:
Nim: ^\s*const\s+\S*VERSION\S*\*?\s*=\s*"(<old-version>)"\s*$
Node: ^\s*export\s+const\s+\S*VERSION\S*\s*=\s*"(<old-version>)"\s*;?$
"""
const UV_VERSION* = "1.0.2"
type
LangType* = enum lNim, lNode
SemverParts* = enum major, minor, patch, prerelease, buildmetadata
type PackageVersion* = object
file*: Path
version*: string
name*: string
case lang*: LangType
of lNim:
lines: seq[string]
versionLine: int
of lNode:
nodePackage: JsonNode
# Taken from
# https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
#
# See also:
# https://regex101.com/r/Ly7O1x/3/
#
let SemverRegex =
re"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
let NimConstPattern = "^\\s*const\\s+\\S*VERSION\\S*\\*?\\s*=\\s*\"($#)\"\\s*$"
let NodeConstPattern =
"^\\s*export\\s+const\\s+\\S*VERSION\\S*\\s*=\\s*\"($#)\"\\s*;?$"
let VERSION_DEFAULTS: TableRef[SemverParts, string] = newTable([
(major, "0"),
(minor, "0"),
(patch, "0"),
(prerelease, ""),
(buildmetadata, "")])
proc parseSemverPart(str: string): SemverParts =
try: parseEnum[SemverParts](str)
except:
stderr.writeLine(
"update_version: Invalid --part value: '" & str & "'. Valid values are:" &
"\n\tmajor, minor, patch, prerelease, buildmetadata, last")
quit(QuitFailure)
proc parseNimblePackage(dir: Path): PackageVersion =
result = PackageVersion(lang: lNim)
for fe in walkDir(dir):
if fe.kind == pcFile and
fe.path.splitFile.ext == ".nimble":
result.lines = readFile($fe.path).splitLines
for idx, l in result.lines:
if l.startsWith("version"):
result.versionLine = idx
result.version = l.split("=")[^1].strip(chars = {' ', '"'})
result.name = $fe.path.splitFile.name
result.file = fe.path
return result
raise newException(IOError, "No valid .nimble file found in $#" % [$dir])
proc parseNodePackage(dir: Path): PackageVersion =
result = PackageVersion(
file: dir / Path("package.json"),
lang: lNode,
nodePackage: parseFile($result.file))
if not result.nodePackage.hasKey("name") or
not result.nodePackage.hasKey("version") or
result.nodePackage["name"].kind != JString or
result.nodePackage["version"].kind != JString:
raise newException(ValueError,
"package.json does not have valid 'name' and 'version' fields.")
result.name = result.nodePackage{"name"}.getStr
result.version = result.nodePackage{"version"}.getStr
proc detectAndParsePackage(dir: Path): PackageVersion =
try: result = parseNimblePackage(dir)
except:
let nimEx = getCurrentException()
try: result = parseNodePackage(dir)
except:
let nodeEx = getCurrentException()
stderr.writeLine(
"Unable to find a package definitions file. Errors:\n\t$#\n\t$#" %
[nimEx.msg, nodeEx.msg])
proc writePackage(pkg: PackageVersion) =
if pkg.lang == lNim:
var lines = pkg.lines
lines[pkg.versionLine] = "version = \"$#\"" % pkg.version
writeFile($pkg.file, lines.join("\p"))
elif pkg.lang == lNode:
pkg.nodePackage["version"] = %pkg.version
writeFile($pkg.file, pkg.nodePackage.pretty)
proc replaceVersionInSource*(
source: string,
pkg: PackageVersion,
vOld: string): string =
let rgx =
case pkg.lang
of lNim: re(NimConstPattern % [vOld.replace(".", "\\.")])
of lNode: re(NodeConstPattern % [vOld.replace(".", "\\.")])
var newLines = newSeq[string]()
for l in source.splitLines():
let mOpt = l.match(rgx)
if mOpt.isSome:
let m = mOpt.get
newLines.add(
l[0..<m.captureBounds[0].a] &
pkg.version &
l[m.captureBounds[0].b+1 .. ^1])
else: newLines.add(l)
return newLines.join("\p")
proc replaceVersionInFile(file: Path, pkg: PackageVersion, vOld: string) =
writeFile($file, replaceVersionInSource(readFile($file), pkg, vOld))
proc fmtSemver*(semver: TableRef[SemverParts, string]): string =
result = "$#.$#.$#" % [
semver[major], semver[minor], semver[patch] ]
if semver[prerelease].len > 0:
result &= "-" & semver[prerelease]
if semver[buildmetadata].len > 0:
result &= "+" & semver[buildmetadata]
proc setLastVersionPart*(version: string, newVersion: string): string =
let versionParts = toSeq(findIter(version, re"([^\d.]+)?\.?(\d+)"))
let lastVersionPartMatch = versionParts[^1]
return
version[0..<lastVersionPartMatch.captureBounds[1].a] &
newVersion &
version[lastVersionPartMatch.captureBounds[1].b+1 .. ^1]
proc setSemverPart*(
version: string,
part: SemverParts,
newVersionPart: string,
defaults = VERSION_DEFAULTS): string =
let matchOpt = match(version, SemverRegex)
if matchOpt.isNone:
raise newException(ValueError,
"Version [$#] is not a valid Semantic Version number" % version)
let m = matchOpt.get
let versionParts = newTable[SemverParts, string]()
for p in SemverParts.items:
if p == part: versionParts[p] = newVersionPart
elif not m.captures.contains($p) or p > part: versionParts[p] = defaults[p]
else: versionParts[p] = m.captures[$p]
result = fmtSemver(versionParts)
if match(result, SemverRegex).isNone:
raise newException(ValueError,
"Refusing to update: Version [$#] would not be a valid Semantic Version number" % result)
proc incrementLastVersionPart*(version: string): string =
let versionParts = toSeq(findIter(version, re"([^\d.]+)?\.?(\d+)"))
let lastVersionPartMatch = versionParts[^1]
let lastVersionPartInt = parseInt(lastVersionPartMatch.captures[1])
return
version[0..<lastVersionPartMatch.captureBounds[1].a] &
$(lastVersionPartInt + 1) &
version[lastVersionPartMatch.captureBounds[1].b+1 .. ^1]
proc incrementSemverPart*(
version: string,
part: SemverParts,
defaults = VERSION_DEFAULTS): string =
let matchOpt = match(version, SemverRegex)
if matchOpt.isNone:
raise newException(ValueError,
"Version [$#] is not a valid Semantic Version number" % version)
let m = matchOpt.get
let versionParts = newTable[SemverParts, string]()
for p in SemverParts.items:
if p == part:
if m.captures.contains($p):
versionParts[p] = incrementLastVersionPart(m.captures[$p])
elif p < prerelease or defaults[p].len > 0:
versionParts[p] = defaults[p]
# if this part specifically has been requested to be incremented but
# the default is empty, we are still going to give it *something*
elif p == prerelease: versionParts[p] = "prerelease.0"
elif p == buildmetadata: versionParts[p] = "build.0"
elif not m.captures.contains($p) or p > part: versionParts[p] = defaults[p]
else: versionParts[p] = m.captures[$p]
return fmtSemver(versionParts)
when isMainModule:
let args = docopt(USAGE, version = UV_VERSION)
let dir =
if args["--dir"]: Path($args["--dir"])
else: Path(".")
var pkg =
if args["--lang"] and $args["--lang"] == "nim":
parseNimblePackage(dir)
elif args["--lang"] and $args["--lang"] == "node":
parseNodePackage(dir)
else:
detectAndParsePackage(dir)
let oldVersion = pkg.version
if args["bump"]:
let partName =
if args["--part"]: $args["--part"]
else: "last"
if partName == "last":
pkg.version = incrementLastVersionPart(pkg.version)
else:
let part = parseSemverPart(partName)
pkg.version = incrementSemverPart(pkg.version, part)
writePackage(pkg)
for filePath in args["<src-file>"]:
replaceVersionInFile(Path(filePath), pkg, oldVersion)
echo pkg.version
elif args["set"]:
if not args["--part"]:
pkg.version = $args["<new-version>"]
elif $args["--part"] == "last":
pkg.version = setLastVersionPart(pkg.version, $args["<new-version>"])
else:
let part = parseSemverPart($args["--part"])
pkg.version = setSemverPart(pkg.version, part, $args["<new-version>"])
for filePath in args["<src-file>"]:
replaceVersionInFile(Path(filePath), pkg, oldVersion)
writePackage(pkg)
echo pkg.version
elif args["get"]: echo pkg.version
elif args["interactive"]:
var acceptNewVersion = false
while not acceptNewVersion:
stdout.writeLine "Current version is " & oldVersion
stdout.write "New version? "
pkg.version = stdin.readLine
stdout.write "New version will be set to '" & pkg.version & "'. Is this correct (yes/no)? "
let isCorrect = stdin.readLine
acceptNewVersion = "yes".startsWith(isCorrect.toLower)
echo "Updating version definition in " & $pkg.file
writePackage(pkg)
for filePath in args["<src-file>"]:
echo "Updating version definition in " & $filePath
replaceVersionInFile(Path(filePath), pkg, oldVersion)