381 lines
13 KiB
Nim
381 lines
13 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'
|
|
|
|
--skip-package-file Don't write changes to the package file (*.nimble or
|
|
package.json). This is useful mostly in temporary
|
|
circumstances where you want to update other source
|
|
files without updating the package file. Be careful
|
|
in these cases, however, as update_version uses the
|
|
current version (as reported in the package file) as
|
|
the canonical version. When updating versions in
|
|
other source files, update_version ignores lines that
|
|
do not match the current version. Consider the
|
|
following:
|
|
|
|
update_version set 1.0.4 file1.nim, file2.nim --skip-package-file # works
|
|
update_version set 1.0.0 file1.nim, file2.nim --skip-package-file # fails
|
|
|
|
The second command will *not* rewrite the version
|
|
strings in file1.nim and file2.nim because the
|
|
versions in those files (1.0.4) do not match what is
|
|
in the package.
|
|
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.2.1"
|
|
|
|
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)
|
|
|
|
result.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 Exception:
|
|
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)
|
|
|
|
if not args["--skip-package-file"]:
|
|
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)
|
|
|
|
if not args["--skip-package-file"]:
|
|
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)
|
|
|
|
if not args["--skip-package-file"]:
|
|
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)
|