atlas.nim 19 KB

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