nim_lk/src/nimblepkg/packageparser.nim

512 lines
19 KiB
Nim

# Copyright (C) Dominik Picheta. All rights reserved.
# BSD License. Look at license.txt for more info.
import parsecfg, sets, streams, strutils, os, tables, sugar
from sequtils import apply, map
import version, tools, common, nimscriptwrapper, options, packageinfo, cli
## Contains procedures for parsing .nimble files. Moved here from ``packageinfo``
## because it depends on ``nimscriptwrapper`` (``nimscriptwrapper`` also
## depends on other procedures in ``packageinfo``.
type
NimbleFile* = string
ValidationError* = object of NimbleError
warnInstalled*: bool # Determines whether to show a warning for installed pkgs
warnAll*: bool
const reservedNames = [
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]
proc newValidationError(msg: string, warnInstalled: bool,
hint: string, warnAll: bool): ref ValidationError =
result = newException(ValidationError, msg)
result.warnInstalled = warnInstalled
result.warnAll = warnAll
result.hint = hint
proc raiseNewValidationError(msg: string, warnInstalled: bool,
hint: string = "", warnAll = false) =
stderr.writeLine "nimble: ", msg
proc validatePackageName*(name: string) =
## Raises an error if specified package name contains invalid characters.
##
## A valid package name is one which is a valid nim module name. So only
## underscores, letters and numbers allowed.
if name.len == 0: return
if name[0] in {'0'..'9'}:
raiseNewValidationError(name &
"\"$1\" is an invalid package name: cannot begin with $2" %
[name, $name[0]], true)
var prevWasUnderscore = false
for c in name:
case c
of '_':
if prevWasUnderscore:
raiseNewValidationError(
"$1 is an invalid package name: cannot contain \"__\"" % name, true)
prevWasUnderscore = true
of AllChars - IdentChars:
raiseNewValidationError(
"$1 is an invalid package name: cannot contain '$2'" % [name, $c],
true)
else:
prevWasUnderscore = false
if name.endsWith("pkg"):
raiseNewValidationError("\"$1\" is an invalid package name: cannot end" &
" with \"pkg\"" % name, false)
if name.toUpperAscii() in reservedNames:
raiseNewValidationError(
"\"$1\" is an invalid package name: reserved name" % name, false)
proc validateVersion*(ver: string) =
for c in ver:
if c notin ({'.'} + Digits):
raiseNewValidationError(
"Version may only consist of numbers and the '.' character " &
"but found '" & c & "'.", false)
proc validatePackageStructure(pkgInfo: PackageInfo, options: Options) =
## This ensures that a package's source code does not leak into
## another package's namespace.
## https://github.com/nim-lang/nimble/issues/144
let
realDir = pkgInfo.getRealDir()
normalizedBinNames = pkgInfo.bin.map(
(x) => x.changeFileExt("").toLowerAscii()
)
correctDir =
if pkgInfo.name.toLowerAscii() in normalizedBinNames:
pkgInfo.name & "pkg"
else:
pkgInfo.name
proc onFile(path: string) =
# Remove the root to leave only the package subdirectories.
# ~/package-0.1/package/utils.nim -> package/utils.nim.
var trailPath = changeRoot(realDir, "", path)
if trailPath.startsWith(DirSep): trailPath = trailPath[1 .. ^1]
let (dir, file, ext) = trailPath.splitFile
# We're only interested in nim files, because only they can pollute our
# namespace.
if ext != (ExtSep & "nim"):
return
if dir.len == 0:
if file != pkgInfo.name:
# A source file was found in the top level of srcDir that doesn't share
# a name with the package.
let
msg = ("Package '$1' has an incorrect structure. " &
"The top level of the package source directory " &
"should contain at most one module, " &
"named '$2', but a file named '$3' was found. This " &
"will be an error in the future.") %
[pkgInfo.name, pkgInfo.name & ext, file & ext]
hint = ("If this is the primary source file in the package, " &
"rename it to '$1'. If it's a source file required by " &
"the main module, or if it is one of several " &
"modules exposed by '$4', then move it into a '$2' subdirectory. " &
"If it's a test file or otherwise not required " &
"to build the the package '$1', prevent its installation " &
"by adding `skipFiles = @[\"$3\"]` to the .nimble file. See " &
"https://github.com/nim-lang/nimble#libraries for more info.") %
[pkgInfo.name & ext, correctDir & DirSep, file & ext, pkgInfo.name]
raiseNewValidationError(msg, true, hint, true)
else:
assert(not pkgInfo.isMinimal)
# On Windows `pkgInfo.bin` has a .exe extension, so we need to normalize.
if not (dir.startsWith(correctDir & DirSep) or dir == correctDir):
let
msg = ("Package '$2' has an incorrect structure. " &
"It should contain a single directory hierarchy " &
"for source files, named '$3', but file '$1' " &
"is in a directory named '$4' instead. " &
"This will be an error in the future.") %
[file & ext, pkgInfo.name, correctDir, dir]
hint = ("If '$1' contains source files for building '$2', rename it " &
"to '$3'. Otherwise, prevent its installation " &
"by adding `skipDirs = @[\"$1\"]` to the .nimble file.") %
[dir, pkgInfo.name, correctDir]
raiseNewValidationError(msg, true, hint, true)
iterInstallFiles(realDir, pkgInfo, options, onFile)
proc validatePackageInfo(pkgInfo: PackageInfo, options: Options) =
let path = pkgInfo.myPath
if pkgInfo.name == "":
raiseNewValidationError("Incorrect .nimble file: " & path &
" does not contain a name field.", false)
if pkgInfo.name.normalize != path.splitFile.name.normalize:
raiseNewValidationError(
"The .nimble file name must match name specified inside " & path, true)
if pkgInfo.version == "":
raiseNewValidationError("Incorrect .nimble file: " & path &
" does not contain a version field.", false)
if not pkgInfo.isMinimal:
if pkgInfo.author == "":
raiseNewValidationError("Incorrect .nimble file: " & path &
" does not contain an author field.", false)
if pkgInfo.description == "":
raiseNewValidationError("Incorrect .nimble file: " & path &
" does not contain a description field.", false)
if pkgInfo.license == "":
raiseNewValidationError("Incorrect .nimble file: " & path &
" does not contain a license field.", false)
if pkgInfo.backend notin ["c", "cc", "objc", "cpp", "js"]:
raiseNewValidationError("'" & pkgInfo.backend &
"' is an invalid backend.", false)
validatePackageStructure(pkginfo, options)
proc nimScriptHint*(pkgInfo: PackageInfo) =
if not pkgInfo.isNimScript:
display("Warning:", "The .nimble file for this project could make use of " &
"additional features, if converted into the new NimScript format." &
"\nFor more details see:" &
"https://github.com/nim-lang/nimble#creating-packages",
Warning, HighPriority)
proc multiSplit(s: string): seq[string] =
## Returns ``s`` split by newline and comma characters.
##
## Before returning, all individual entries are stripped of whitespace and
## also empty entries are purged from the list. If after all the cleanups are
## done no entries are found in the list, the proc returns a sequence with
## the original string as the only entry.
result = split(s, {char(0x0A), char(0x0D), ','})
apply(result, proc(x: var string) = x = x.strip())
for i in countdown(result.len()-1, 0):
if len(result[i]) < 1:
result.del(i)
# Huh, nothing to return? Return given input.
if len(result) < 1:
if s.strip().len != 0:
return @[s]
else:
return @[]
proc readPackageInfoFromNimble(path: string; result: var PackageInfo) =
var fs = newFileStream(path, fmRead)
if fs != nil:
var p: CfgParser
open(p, fs, path)
defer: close(p)
var currentSection = ""
while true:
var ev = next(p)
case ev.kind
of cfgEof:
break
of cfgSectionStart:
currentSection = ev.section
of cfgKeyValuePair:
case currentSection.normalize
of "package":
case ev.key.normalize
of "name": result.name = ev.value
of "version": result.version = ev.value
of "author": result.author = ev.value
of "description": result.description = ev.value
of "license": result.license = ev.value
of "srcdir": result.srcDir = ev.value
of "bindir": result.binDir = ev.value
of "skipdirs":
result.skipDirs.add(ev.value.multiSplit)
of "skipfiles":
result.skipFiles.add(ev.value.multiSplit)
of "skipext":
result.skipExt.add(ev.value.multiSplit)
of "installdirs":
result.installDirs.add(ev.value.multiSplit)
of "installfiles":
result.installFiles.add(ev.value.multiSplit)
of "installext":
result.installExt.add(ev.value.multiSplit)
of "bin":
for i in ev.value.multiSplit:
if i.splitFile().ext == ".nim":
raise newException(NimbleError, "`bin` entry should not be a source file: " & i)
result.bin.add(i.addFileExt(ExeExt))
of "backend":
result.backend = ev.value.toLowerAscii()
case result.backend.normalize
of "javascript": result.backend = "js"
else: discard
of "beforehooks":
for i in ev.value.multiSplit:
result.preHooks.incl(i.normalize)
of "afterhooks":
for i in ev.value.multiSplit:
result.postHooks.incl(i.normalize)
else:
raise newException(NimbleError, "Invalid field: " & ev.key)
of "deps", "dependencies":
case ev.key.normalize
of "requires":
for v in ev.value.multiSplit:
result.requires.add(parseRequires(v.strip))
of "foreigndeps":
for v in ev.value.multiSplit:
result.foreignDeps.add(v.strip)
else:
raise newException(NimbleError, "Invalid field: " & ev.key)
else: raise newException(NimbleError,
"Invalid section: " & currentSection)
of cfgOption: raise newException(NimbleError,
"Invalid package info, should not contain --" & ev.value)
of cfgError:
raise newException(NimbleError, "Error parsing .nimble file: " & ev.msg)
else:
raise newException(ValueError, "Cannot open package info: " & path)
proc readPackageInfoFromNims(scriptName: string, options: Options,
result: var PackageInfo) =
let
iniFile = getIniFile(scriptName, options)
if iniFile.fileExists():
readPackageInfoFromNimble(iniFile, result)
proc inferInstallRules(pkgInfo: var PackageInfo, options: Options) =
# Binary packages shouldn't install .nim files by default.
# (As long as the package info doesn't explicitly specify what should be
# installed.)
let installInstructions =
pkgInfo.installDirs.len + pkgInfo.installExt.len + pkgInfo.installFiles.len
if installInstructions == 0 and pkgInfo.bin.len > 0:
pkgInfo.skipExt.add("nim")
# When a package doesn't specify a `srcDir` it's fair to assume that
# the .nim files are in the root of the package. So we can explicitly select
# them and prevent the installation of anything else. The user can always
# override this with `installFiles`.
if pkgInfo.srcDir == "":
if dirExists(pkgInfo.getRealDir() / pkgInfo.name):
pkgInfo.installDirs.add(pkgInfo.name)
if fileExists(pkgInfo.getRealDir() / pkgInfo.name.addFileExt("nim")):
pkgInfo.installFiles.add(pkgInfo.name.addFileExt("nim"))
proc readPackageInfo*(nf: NimbleFile, options: Options,
onlyMinimalInfo=false): PackageInfo =
## Reads package info from the specified Nimble file.
##
## Attempts to read it using the "old" Nimble ini format first, if that
## fails attempts to evaluate it as a nimscript file.
##
## If both fail then returns an error.
##
## When ``onlyMinimalInfo`` is true, only the `name` and `version` fields are
## populated. The ``isNimScript`` field can also be relied on.
##
## This version uses a cache stored in ``options``, so calling it multiple
## times on the same ``nf`` shouldn't require re-evaluation of the Nimble
## file.
assert fileExists(nf)
# Check the cache.
if options.pkgInfoCache.hasKey(nf):
return options.pkgInfoCache[nf]
result = initPackageInfo(nf)
let minimalInfo = getNameVersion(nf)
validatePackageName(nf.splitFile.name)
var success = false
var iniError: ref NimbleError
# Attempt ini-format first.
try:
readPackageInfoFromNimble(nf, result)
success = true
result.isNimScript = false
except NimbleError:
iniError = (ref NimbleError)(getCurrentException())
if not success:
if onlyMinimalInfo:
result.name = minimalInfo.name
result.version = minimalInfo.version
result.isNimScript = true
result.isMinimal = true
# It's possible this proc will receive a .nimble-link file eventually,
# I added this assert to hopefully make this error clear for everyone.
let msg = "No version detected. Received nimble-link?"
assert result.version.len > 0, msg
else:
try:
readPackageInfoFromNims(nf, options, result)
result.isNimScript = true
except NimbleError as exc:
if exc.hint.len > 0:
raise
let msg = "Could not read package info file in " & nf & ";\n" &
" Reading as ini file failed with: \n" &
" " & iniError.msg & ".\n" &
" Evaluating as NimScript file failed with: \n" &
" " & exc.msg & "."
raise newException(NimbleError, msg)
# By default specialVersion is the same as version.
result.specialVersion = result.version
# Only attempt to read a special version if `nf` is inside the $nimbleDir.
if nf.startsWith(options.getNimbleDir()):
# The package directory name may include a "special" version
# (example #head). If so, it is given higher priority and therefore
# overwrites the .nimble file's version.
let version = parseVersionRange(minimalInfo.version)
if version.kind == verSpecial:
result.specialVersion = minimalInfo.version
# Apply rules to infer which files should/shouldn't be installed. See #469.
inferInstallRules(result, options)
if not result.isMinimal:
options.pkgInfoCache[nf] = result
# Validate the rest of the package info last.
if not options.disableValidation:
validateVersion(result.version)
validatePackageInfo(result, options)
proc validate*(file: NimbleFile, options: Options,
error: var ValidationError, pkgInfo: var PackageInfo): bool =
try:
pkgInfo = readPackageInfo(file, options)
except ValidationError as exc:
error = exc[]
return false
return true
proc getPkgInfoFromFile*(file: NimbleFile, options: Options): PackageInfo =
## Reads the specified .nimble file and returns its data as a PackageInfo
## object. Any validation errors are handled and displayed as warnings.
try:
result = readPackageInfo(file, options)
except ValidationError:
let exc = (ref ValidationError)(getCurrentException())
if exc.warnAll:
display("Warning:", exc.msg, Warning, HighPriority)
display("Hint:", exc.hint, Warning, HighPriority)
else:
raise
proc getPkgInfo*(dir: string, options: Options): PackageInfo =
## Find the .nimble file in ``dir`` and parses it, returning a PackageInfo.
let nimbleFile = findNimbleFile(dir, true)
return getPkgInfoFromFile(nimbleFile, options)
proc getInstalledPkgs*(libsDir: string, options: Options):
seq[tuple[pkginfo: PackageInfo, meta: MetaData]] =
## Gets a list of installed packages.
##
## ``libsDir`` is in most cases: ~/.nimble/pkgs/
const
readErrorMsg = "Installed package '$1@$2' is outdated or corrupt."
validationErrorMsg = readErrorMsg & "\nPackage did not pass validation: $3"
hintMsg = "The corrupted package will need to be removed manually. To fix" &
" this error message, remove $1."
proc createErrorMsg(tmplt, path, msg: string): string =
let (name, version) = getNameVersion(path)
return tmplt % [name, version, msg]
display("Loading", "list of installed packages", priority = MediumPriority)
result = @[]
for kind, path in walkDir(libsDir):
if kind == pcDir:
let nimbleFile = findNimbleFile(path, false)
if nimbleFile != "":
let meta = readMetaData(path)
var pkg: PackageInfo
try:
pkg = readPackageInfo(nimbleFile, options, onlyMinimalInfo=false)
except ValidationError:
let exc = (ref ValidationError)(getCurrentException())
exc.msg = createErrorMsg(validationErrorMsg, path, exc.msg)
exc.hint = hintMsg % path
if exc.warnInstalled or exc.warnAll:
display("Warning:", exc.msg, Warning, HighPriority)
# Don't show hints here because they are only useful for package
# owners.
else:
raise exc
except:
let tmplt = readErrorMsg & "\nMore info: $3"
let msg = createErrorMsg(tmplt, path, getCurrentException().msg)
var exc = newException(NimbleError, msg)
exc.hint = hintMsg % path
raise exc
pkg.isInstalled = true
pkg.isLinked =
cmpPaths(nimbleFile.splitFile().dir, path) != 0
result.add((pkg, meta))
proc isNimScript*(nf: string, options: Options): bool =
result = readPackageInfo(nf, options).isNimScript
proc toFullInfo*(pkg: PackageInfo, options: Options): PackageInfo =
if pkg.isMinimal:
result = getPkgInfoFromFile(pkg.mypath, options)
result.isInstalled = pkg.isInstalled
result.isLinked = pkg.isLinked
else:
return pkg
proc getConcreteVersion*(pkgInfo: PackageInfo, options: Options): string =
## Returns a non-special version from the specified ``pkgInfo``. If the
## ``pkgInfo`` is minimal it looks it up and retrieves the concrete version.
result = pkgInfo.version
if pkgInfo.isMinimal:
let pkgInfo = pkgInfo.toFullInfo(options)
result = pkgInfo.version
assert(not newVersion(result).isSpecial)
when isMainModule:
validatePackageName("foo_bar")
validatePackageName("f_oo_b_a_r")
try:
validatePackageName("foo__bar")
assert false
except NimbleError:
assert true
echo("Everything passed!")