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 [ ...] [options] update_nim_package_version set [ ...] [options] update_nim_package_version get [ ...] [options] update_nim_package_version interactive [ ...] [options] Options: -d, --dir Use the given directory as the project root (defaults to the current working directory). -l, --lang 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 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. 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. Sometimes it is useful to encode the version in source files via constants. Passing arguments as 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 _VERSION* = "" export const _VERSION = "" Specifically it uses the following regexes: Nim: ^\s*const\s+\S*VERSION\S*\*?\s*=\s*"()"\s*$ Node: ^\s*export\s+const\s+\S*VERSION\S*\s*=\s*"()"\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"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?: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[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.. 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.. 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.. 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[""]: replaceVersionInFile(Path(filePath), pkg, oldVersion) echo pkg.version elif args["set"]: if not args["--part"]: pkg.version = $args[""] elif $args["--part"] == "last": pkg.version = setLastVersionPart(pkg.version, $args[""]) else: let part = parseSemverPart($args["--part"]) pkg.version = setSemverPart(pkg.version, part, $args[""]) for filePath in args[""]: 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[""]: echo "Updating version definition in " & $filePath replaceVersionInFile(Path(filePath), pkg, oldVersion)