atlas.nim 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. #
  2. # Atlas Package Cloner
  3. # (c) Copyright 2021 Andreas Rumpf
  4. #
  5. # See the file "copying.txt", included in this
  6. # distribution, for details about the copyright.
  7. #
  8. ## Simple tool to automate frequent workflows: Can "clone"
  9. ## a Nimble dependency and its dependencies recursively.
  10. import std/[parseopt, strutils, os, osproc, unicode, tables, sets, json, jsonutils]
  11. import parse_requires, osutils, packagesjson
  12. const
  13. Version = "0.1"
  14. Usage = "atlas - Nim Package Cloner Version " & Version & """
  15. (c) 2021 Andreas Rumpf
  16. Usage:
  17. atlas [options] [command] [arguments]
  18. Command:
  19. clone url|pkgname clone a package and all of its dependencies
  20. search keyw keywB... search for package that contains the given keywords
  21. extract file.nimble extract the requirements and custom commands from
  22. the given Nimble file
  23. Options:
  24. --keepCommits do not perform any `git checkouts`
  25. --cfgHere also create/maintain a nim.cfg in the current
  26. working directory
  27. --version show the version
  28. --help show this help
  29. """
  30. proc writeHelp() =
  31. stdout.write(Usage)
  32. stdout.flushFile()
  33. quit(0)
  34. proc writeVersion() =
  35. stdout.write(Version & "\n")
  36. stdout.flushFile()
  37. quit(0)
  38. const
  39. MockupRun = defined(atlasTests)
  40. TestsDir = "tools/atlas/tests"
  41. type
  42. PackageName = distinct string
  43. DepRelation = enum
  44. normal, strictlyLess, strictlyGreater
  45. Dependency = object
  46. name: PackageName
  47. url, commit: string
  48. rel: DepRelation # "requires x < 1.0" is silly, but Nimble allows it so we have too.
  49. AtlasContext = object
  50. projectDir, workspace: string
  51. hasPackageList: bool
  52. keepCommits: bool
  53. cfgHere: bool
  54. p: Table[string, string] # name -> url mapping
  55. processed: HashSet[string] # the key is (url / commit)
  56. errors: int
  57. when MockupRun:
  58. currentDir: string
  59. step: int
  60. mockupSuccess: bool
  61. const
  62. InvalidCommit = "<invalid commit>"
  63. ProduceTest = false
  64. type
  65. Command = enum
  66. GitDiff = "git diff",
  67. GitTags = "git show-ref --tags",
  68. GitRevParse = "git rev-parse",
  69. GitCheckout = "git checkout",
  70. GitPull = "git pull",
  71. GitCurrentCommit = "git log -n 1 --format=%H"
  72. GitMergeBase = "git merge-base"
  73. include testdata
  74. proc exec(c: var AtlasContext; cmd: Command; args: openArray[string]): (string, int) =
  75. when MockupRun:
  76. assert TestLog[c.step].cmd == cmd
  77. case cmd
  78. of GitDiff, GitTags, GitRevParse, GitPull, GitCurrentCommit:
  79. result = (TestLog[c.step].output, TestLog[c.step].exitCode)
  80. of GitCheckout:
  81. assert args[0] == TestLog[c.step].output
  82. of GitMergeBase:
  83. let tmp = TestLog[c.step].output.splitLines()
  84. assert tmp.len == 4, $tmp.len
  85. assert tmp[0] == args[0]
  86. assert tmp[1] == args[1]
  87. assert tmp[3] == ""
  88. result[0] = tmp[2]
  89. result[1] = TestLog[c.step].exitCode
  90. inc c.step
  91. else:
  92. var cmdLine = $cmd
  93. for i in 0..<args.len:
  94. cmdLine.add ' '
  95. cmdLine.add quoteShell(args[i])
  96. result = osproc.execCmdEx(cmdLine)
  97. when ProduceTest:
  98. echo "cmd ", cmd, " args ", args, " --> ", result
  99. proc cloneUrl(c: var AtlasContext; url, dest: string; cloneUsingHttps: bool): string =
  100. when MockupRun:
  101. result = ""
  102. else:
  103. result = osutils.cloneUrl(url, dest, cloneUsingHttps)
  104. when ProduceTest:
  105. echo "cloned ", url, " into ", dest
  106. template withDir*(c: var AtlasContext; dir: string; body: untyped) =
  107. when MockupRun:
  108. c.currentDir = dir
  109. body
  110. else:
  111. let oldDir = getCurrentDir()
  112. try:
  113. when ProduceTest:
  114. echo "Current directory is now ", dir
  115. setCurrentDir(dir)
  116. body
  117. finally:
  118. setCurrentDir(oldDir)
  119. proc extractRequiresInfo(c: var AtlasContext; nimbleFile: string): NimbleFileInfo =
  120. result = extractRequiresInfo(nimbleFile)
  121. when ProduceTest:
  122. echo "nimble ", nimbleFile, " info ", result
  123. proc toDepRelation(s: string): DepRelation =
  124. case s
  125. of "<": strictlyLess
  126. of ">": strictlyGreater
  127. else: normal
  128. proc isCleanGit(c: var AtlasContext; dir: string): string =
  129. result = ""
  130. let (outp, status) = exec(c, GitDiff, [])
  131. if outp.len != 0:
  132. result = "'git diff' not empty"
  133. elif status != 0:
  134. result = "'git diff' returned non-zero"
  135. proc message(c: var AtlasContext; category: string; p: PackageName; args: varargs[string]) =
  136. var msg = category & "(" & p.string & ")"
  137. for a in args:
  138. msg.add ' '
  139. msg.add a
  140. stdout.writeLine msg
  141. inc c.errors
  142. proc warn(c: var AtlasContext; p: PackageName; args: varargs[string]) =
  143. message(c, "[Warning] ", p, args)
  144. proc error(c: var AtlasContext; p: PackageName; args: varargs[string]) =
  145. message(c, "[Error] ", p, args)
  146. proc sameVersionAs(tag, ver: string): bool =
  147. const VersionChars = {'0'..'9', '.'}
  148. proc safeCharAt(s: string; i: int): char {.inline.} =
  149. if i >= 0 and i < s.len: s[i] else: '\0'
  150. let idx = find(tag, ver)
  151. if idx >= 0:
  152. # we found the version as a substring inside the `tag`. But we
  153. # need to watch out the the boundaries are not part of a
  154. # larger/different version number:
  155. result = safeCharAt(tag, idx-1) notin VersionChars and
  156. safeCharAt(tag, idx+ver.len) notin VersionChars
  157. proc versionToCommit(c: var AtlasContext; d: Dependency): string =
  158. let (outp, status) = exec(c, GitTags, [])
  159. if status == 0:
  160. var useNextOne = false
  161. for line in splitLines(outp):
  162. let commitsAndTags = strutils.splitWhitespace(line)
  163. if commitsAndTags.len == 2:
  164. case d.rel
  165. of normal:
  166. if commitsAndTags[1].sameVersionAs(d.commit):
  167. return commitsAndTags[0]
  168. of strictlyLess:
  169. if d.commit == InvalidCommit or not commitsAndTags[1].sameVersionAs(d.commit):
  170. return commitsAndTags[0]
  171. of strictlyGreater:
  172. if commitsAndTags[1].sameVersionAs(d.commit):
  173. useNextOne = true
  174. elif useNextOne:
  175. return commitsAndTags[0]
  176. return ""
  177. proc shortToCommit(c: var AtlasContext; short: string): string =
  178. let (cc, status) = exec(c, GitRevParse, [short])
  179. result = if status == 0: strutils.strip(cc) else: ""
  180. proc checkoutGitCommit(c: var AtlasContext; p: PackageName; commit: string) =
  181. let (_, status) = exec(c, GitCheckout, [commit])
  182. if status != 0:
  183. error(c, p, "could not checkout commit", commit)
  184. proc gitPull(c: var AtlasContext; p: PackageName) =
  185. let (_, status) = exec(c, GitPull, [])
  186. if status != 0:
  187. error(c, p, "could not 'git pull'")
  188. proc updatePackages(c: var AtlasContext) =
  189. if dirExists(c.workspace / PackagesDir):
  190. withDir(c, c.workspace / PackagesDir):
  191. gitPull(c, PackageName PackagesDir)
  192. else:
  193. withDir c, c.workspace:
  194. let err = cloneUrl(c, "https://github.com/nim-lang/packages", PackagesDir, false)
  195. if err != "":
  196. error c, PackageName(PackagesDir), err
  197. proc fillPackageLookupTable(c: var AtlasContext) =
  198. if not c.hasPackageList:
  199. c.hasPackageList = true
  200. when not MockupRun:
  201. updatePackages(c)
  202. let plist = getPackages(when MockupRun: TestsDir else: c.workspace)
  203. for entry in plist:
  204. c.p[unicode.toLower entry.name] = entry.url
  205. proc toUrl(c: var AtlasContext; p: string): string =
  206. if p.isUrl:
  207. result = p
  208. else:
  209. fillPackageLookupTable(c)
  210. result = c.p.getOrDefault(unicode.toLower p)
  211. if result.len == 0:
  212. inc c.errors
  213. proc toName(p: string): PackageName =
  214. if p.isUrl:
  215. result = PackageName splitFile(p).name
  216. else:
  217. result = PackageName p
  218. proc needsCommitLookup(commit: string): bool {.inline.} =
  219. '.' in commit or commit == InvalidCommit
  220. proc isShortCommitHash(commit: string): bool {.inline.} =
  221. commit.len >= 4 and commit.len < 40
  222. proc checkoutCommit(c: var AtlasContext; w: Dependency) =
  223. let dir = c.workspace / w.name.string
  224. withDir c, dir:
  225. if w.commit.len == 0 or cmpIgnoreCase(w.commit, "head") == 0:
  226. gitPull(c, w.name)
  227. else:
  228. let err = isCleanGit(c, dir)
  229. if err != "":
  230. warn c, w.name, err
  231. else:
  232. let requiredCommit =
  233. if needsCommitLookup(w.commit): versionToCommit(c, w)
  234. elif isShortCommitHash(w.commit): shortToCommit(c, w.commit)
  235. else: w.commit
  236. let (cc, status) = exec(c, GitCurrentCommit, [])
  237. let currentCommit = strutils.strip(cc)
  238. if requiredCommit == "" or status != 0:
  239. if requiredCommit == "" and w.commit == InvalidCommit:
  240. warn c, w.name, "package has no tagged releases"
  241. else:
  242. warn c, w.name, "cannot find specified version/commit", w.commit
  243. else:
  244. if currentCommit != requiredCommit:
  245. # checkout the later commit:
  246. # git merge-base --is-ancestor <commit> <commit>
  247. let (cc, status) = exec(c, GitMergeBase, [currentCommit, requiredCommit])
  248. let mergeBase = strutils.strip(cc)
  249. if status == 0 and (mergeBase == currentCommit or mergeBase == requiredCommit):
  250. # conflict resolution: pick the later commit:
  251. if mergeBase == currentCommit:
  252. checkoutGitCommit(c, w.name, requiredCommit)
  253. else:
  254. checkoutGitCommit(c, w.name, requiredCommit)
  255. when false:
  256. warn c, w.name, "do not know which commit is more recent:",
  257. currentCommit, "(current) or", w.commit, " =", requiredCommit, "(required)"
  258. proc findNimbleFile(c: AtlasContext; dep: Dependency): string =
  259. when MockupRun:
  260. result = TestsDir / dep.name.string & ".nimble"
  261. doAssert fileExists(result), "file does not exist " & result
  262. else:
  263. result = c.workspace / dep.name.string / (dep.name.string & ".nimble")
  264. if not fileExists(result):
  265. result = ""
  266. for x in walkFiles(c.workspace / dep.name.string / "*.nimble"):
  267. if result.len == 0:
  268. result = x
  269. else:
  270. # ambiguous .nimble file
  271. return ""
  272. proc addUniqueDep(c: var AtlasContext; work: var seq[Dependency];
  273. tokens: seq[string]) =
  274. let oldErrors = c.errors
  275. let url = toUrl(c, tokens[0])
  276. if oldErrors != c.errors:
  277. warn c, toName(tokens[0]), "cannot resolve package name"
  278. elif not c.processed.containsOrIncl(url / tokens[2]):
  279. work.add Dependency(name: toName(tokens[0]), url: url, commit: tokens[2],
  280. rel: toDepRelation(tokens[1]))
  281. proc collectNewDeps(c: var AtlasContext; work: var seq[Dependency];
  282. dep: Dependency; result: var seq[string];
  283. isMainProject: bool) =
  284. let nimbleFile = findNimbleFile(c, dep)
  285. if nimbleFile != "":
  286. let nimbleInfo = extractRequiresInfo(c, nimbleFile)
  287. for r in nimbleInfo.requires:
  288. var tokens: seq[string] = @[]
  289. for token in tokenizeRequires(r):
  290. tokens.add token
  291. if tokens.len == 1:
  292. # nimx uses dependencies like 'requires "sdl2"'.
  293. # Via this hack we map them to the first tagged release.
  294. # (See the `isStrictlySmallerThan` logic.)
  295. tokens.add "<"
  296. tokens.add InvalidCommit
  297. elif tokens.len == 2 and tokens[1].startsWith("#"):
  298. # Dependencies can also look like 'requires "sdl2#head"
  299. var commit = tokens[1][1 .. ^1]
  300. tokens[1] = "=="
  301. tokens.add commit
  302. if tokens.len >= 3 and cmpIgnoreCase(tokens[0], "nim") != 0:
  303. c.addUniqueDep work, tokens
  304. result.add dep.name.string / nimbleInfo.srcDir
  305. else:
  306. result.add dep.name.string
  307. proc clone(c: var AtlasContext; start: string): seq[string] =
  308. # non-recursive clone.
  309. let oldErrors = c.errors
  310. var work = @[Dependency(name: toName(start), url: toUrl(c, start), commit: "")]
  311. if oldErrors != c.errors:
  312. error c, toName(start), "cannot resolve package name"
  313. return
  314. c.projectDir = c.workspace / work[0].name.string
  315. result = @[]
  316. var i = 0
  317. while i < work.len:
  318. let w = work[i]
  319. let oldErrors = c.errors
  320. if not dirExists(c.workspace / w.name.string):
  321. withDir c, c.workspace:
  322. let err = cloneUrl(c, w.url, w.name.string, false)
  323. if err != "":
  324. error c, w.name, err
  325. if oldErrors == c.errors:
  326. if not c.keepCommits: checkoutCommit(c, w)
  327. # even if the checkout fails, we can make use of the somewhat
  328. # outdated .nimble file to clone more of the most likely still relevant
  329. # dependencies:
  330. collectNewDeps(c, work, w, result, i == 0)
  331. inc i
  332. const
  333. configPatternBegin = "############# begin Atlas config section ##########\n"
  334. configPatternEnd = "############# end Atlas config section ##########\n"
  335. proc patchNimCfg(c: var AtlasContext; deps: seq[string]; cfgHere: bool) =
  336. var paths = "--noNimblePath\n"
  337. if cfgHere:
  338. let cwd = getCurrentDir()
  339. for d in deps:
  340. let x = relativePath(c.workspace / d, cwd, '/')
  341. paths.add "--path:\"" & x & "\"\n"
  342. else:
  343. for d in deps:
  344. paths.add "--path:\"../" & d.replace("\\", "/") & "\"\n"
  345. var cfgContent = configPatternBegin & paths & configPatternEnd
  346. when MockupRun:
  347. assert readFile(TestsDir / "nim.cfg") == cfgContent
  348. c.mockupSuccess = true
  349. else:
  350. let cfg = if cfgHere: getCurrentDir() / "nim.cfg"
  351. else: c.projectDir / "nim.cfg"
  352. if not fileExists(cfg):
  353. writeFile(cfg, cfgContent)
  354. else:
  355. let content = readFile(cfg)
  356. let start = content.find(configPatternBegin)
  357. if start >= 0:
  358. cfgContent = content.substr(0, start-1) & cfgContent
  359. let theEnd = content.find(configPatternEnd, start)
  360. if theEnd >= 0:
  361. cfgContent.add content.substr(theEnd+len(configPatternEnd))
  362. else:
  363. cfgContent = content & "\n" & cfgContent
  364. if cfgContent != content:
  365. # do not touch the file if nothing changed
  366. # (preserves the file date information):
  367. writeFile(cfg, cfgContent)
  368. proc error*(msg: string) =
  369. when defined(debug):
  370. writeStackTrace()
  371. quit "[Error] " & msg
  372. proc main =
  373. var action = ""
  374. var args: seq[string] = @[]
  375. template singleArg() =
  376. if args.len != 1:
  377. error action & " command takes a single package name"
  378. template noArgs() =
  379. if args.len != 0:
  380. error action & " command takes no arguments"
  381. var c = AtlasContext(
  382. projectDir: getCurrentDir(),
  383. workspace: getCurrentDir())
  384. for kind, key, val in getopt():
  385. case kind
  386. of cmdArgument:
  387. if action.len == 0:
  388. action = key.normalize
  389. else:
  390. args.add key
  391. of cmdLongOption, cmdShortOption:
  392. case normalize(key)
  393. of "help", "h": writeHelp()
  394. of "version", "v": writeVersion()
  395. of "keepcommits": c.keepCommits = true
  396. of "cfghere": c.cfgHere = true
  397. else: writeHelp()
  398. of cmdEnd: assert false, "cannot happen"
  399. while c.workspace.len > 0 and dirExists(c.workspace / ".git"):
  400. c.workspace = c.workspace.parentDir()
  401. case action
  402. of "":
  403. error "No action."
  404. of "clone":
  405. singleArg()
  406. let deps = clone(c, args[0])
  407. patchNimCfg c, deps, false
  408. if c.cfgHere:
  409. patchNimCfg c, deps, true
  410. when MockupRun:
  411. if not c.mockupSuccess:
  412. error "There were problems."
  413. else:
  414. if c.errors > 0:
  415. error "There were problems."
  416. of "refresh":
  417. noArgs()
  418. updatePackages(c)
  419. of "search", "list":
  420. updatePackages(c)
  421. search getPackages(c.workspace), args
  422. of "extract":
  423. singleArg()
  424. if fileExists(args[0]):
  425. echo toJson(extractRequiresInfo(args[0]))
  426. else:
  427. error "File does not exist: " & args[0]
  428. else:
  429. error "Invalid action: " & action
  430. when isMainModule:
  431. main()
  432. when false:
  433. # some testing code for the `patchNimCfg` logic:
  434. var c = AtlasContext(
  435. projectDir: getCurrentDir(),
  436. workspace: getCurrentDir().parentDir)
  437. patchNimCfg(c, @[PackageName"abc", PackageName"xyz"])
  438. when false:
  439. assert sameVersionAs("v0.2.0", "0.2.0")
  440. assert sameVersionAs("v1", "1")
  441. assert sameVersionAs("1.90", "1.90")
  442. assert sameVersionAs("v1.2.3-zuzu", "1.2.3")
  443. assert sameVersionAs("foo-1.2.3.4", "1.2.3.4")
  444. assert not sameVersionAs("foo-1.2.3.4", "1.2.3")
  445. assert not sameVersionAs("foo", "1.2.3")
  446. assert not sameVersionAs("", "1.2.3")