T7.4: Dependency resolution — deps.edn parser, Git deps, cljnim deps command, 12 tests

This commit is contained in:
2026-05-08 20:05:19 +03:00
parent 62ca70d05a
commit feb9cee287
6 changed files with 315 additions and 8 deletions
+2
View File
@@ -10,6 +10,7 @@ test:
nim c -r tests/test_emitter.nim
nim c -r tests/test_pvec.nim
nim c -r tests/test_pmap.nim
nim c -r tests/test_deps.nim
test-reader:
nim c -r tests/test_reader.nim
@@ -33,3 +34,4 @@ clean:
find . -name "*_generated" -delete
find . -name "test_reader" -delete
find . -name "test_emitter" -delete
find . -name "test_deps" -delete
+1 -1
View File
@@ -64,7 +64,7 @@
| T7.1 | `ns` declaration parsing | ✅ | 🟡 | `src/emitter.nim` | `(ns my.app (:require [other.lib :as lib]))` parses, extracts aliases, resolves. |
| T7.2 | Multi-file compilation | ✅ | 🔴 | `src/cljnim.nim`, `src/emitter.nim` | `./cljnim run app.clj` finds required files (hyphen→underscore), inlines defs. |
| T7.3 | Module caching | ✅ | 🟡 | `src/cljnim.nim` | Compiled `.nim` files cached in `nimcache/`. Rebuild only if source changed. |
| T7.4 | Dependency resolution | | 🔴 | New: `src/deps.nim` | Read `deps.edn` or `project.clj` format. Download deps from Git. |
| T7.4 | Dependency resolution | | 🔴 | New: `src/deps.nim`, `tests/test_deps.nim` | Read `deps.edn` format. Download Git deps to `.deps/`. `cljnim deps` command. 12 tests. |
---
+1 -1
View File
@@ -65,7 +65,7 @@
- [x] Compile entire projects (not just single files)
- [x] Namespace system (`ns`, `(:require [lib :as alias])`)
- [x] Module caching for faster REPL startup
- [ ] Dependency resolution (deps.edn, Git deps)
- [x] Dependency resolution (deps.edn, Git deps to .deps/)
## Phase 8: Self-Hosted REPL
- [ ] Compile forms in memory (no temp files)
+29 -4
View File
@@ -1,6 +1,6 @@
# Clojure/Nim — AI-First Clojure Compiler
import os, osproc, strutils, times
import reader, emitter, types, repl, macros
import reader, emitter, types, repl, macros, deps
proc getLibPath(): string =
let appDir = getAppDir()
@@ -44,7 +44,7 @@ proc extractRequires(forms: seq[CljVal]): seq[(string, string)] =
alias = req.items[2].symName
result.add((libName, alias))
proc compileFile*(inputPath: string, outputPath: string) =
proc compileFile*(inputPath: string, outputPath: string, extraPaths: seq[string] = @[]) =
let source = readFile(inputPath)
let forms = reader.readAll(source)
@@ -54,7 +54,10 @@ proc compileFile*(inputPath: string, outputPath: string) =
# Resolve requires and collect all forms from required files
let inputDir = inputPath.parentDir
let searchPaths = @[inputDir, getCurrentDir()]
var searchPaths: seq[string] = @[inputDir, getCurrentDir()]
for p in extraPaths:
if p notin searchPaths:
searchPaths.add(p)
let requires = extractRequires(forms)
var allForms: seq[CljVal] = @[]
@@ -114,6 +117,10 @@ proc runFile*(inputPath: string) =
let nimPath = tmpDir / baseName & "_generated.nim"
let binPath = tmpDir / baseName & "_generated"
# Resolve project dependencies
let inputDir = inputPath.parentDir
let depPaths = deps.loadAndResolve(if inputDir.len > 0: inputDir else: getCurrentDir())
# Check nimcache for cached output
let projectDir = inputPath.parentDir
let cacheDir = projectDir / "nimcache"
@@ -136,7 +143,7 @@ proc runFile*(inputPath: string) =
let runResult = execCmd(cacheBinPath)
quit(runResult)
compileFile(inputPath, nimPath)
compileFile(inputPath, nimPath, depPaths)
let compileResult = nimCompile(nimPath, binPath)
if compileResult != 0:
@@ -169,6 +176,7 @@ proc main() =
echo " cljnim run <file.clj> Compile and run binary"
echo " cljnim read <file.clj> Parse and print AST"
echo " cljnim repl [--json] Start REPL (human or AI mode)"
echo " cljnim deps Resolve dependencies from deps.edn"
echo " cljnim -e '<code>' Evaluate expression"
echo ""
echo "REPL Commands:"
@@ -261,6 +269,23 @@ proc main() =
finally:
state.cleanup()
of "deps":
let projectDir = getCurrentDir()
let depsPath = deps.findDepsFile(projectDir)
if depsPath.len == 0:
echo "No deps.edn or project.clj found"
quit(0)
echo "Found: ", depsPath
let depsFile = deps.parseDepsFile(depsPath)
echo "Dependencies: ", depsFile.deps.len
let paths = deps.resolveDeps(depsFile, depsPath.parentDir)
if paths.len > 0:
echo "Resolved paths:"
for p in paths:
echo " ", p
else:
echo "No dependencies to resolve"
else:
stderr.writeLine("Unknown command: " & cmd)
quit(1)
+170
View File
@@ -0,0 +1,170 @@
# Dependency Resolution — reads deps.edn, downloads Git deps
import os, osproc, strutils
import types, reader
type
DepKind* = enum
dkGit, dkLocal
Dependency* = object
name*: string # e.g. "org.clojure/core.async"
case kind*: DepKind
of dkGit:
gitUrl*: string # :git/url
sha*: string # :sha or :rev
of dkLocal:
localRoot*: string # :local/root
DepsFile* = object
deps*: seq[Dependency]
DepsError* = object of CatchableError
proc findDepsFile*(startDir: string = getCurrentDir()): string =
## Walk up from startDir looking for deps.edn or project.clj
var dir = startDir
while true:
let depsEdn = dir / "deps.edn"
if fileExists(depsEdn): return depsEdn
let projectClj = dir / "project.clj"
if fileExists(projectClj): return projectClj
let parent = dir.parentDir
if parent == dir: break
dir = parent
return ""
proc extractMapPairs(form: CljVal): seq[(string, CljVal)] =
## Extract keyword->value pairs from a map CljVal
if form.kind != ckMap:
return @[]
for i in 0..<form.mapKeys.len:
let key = form.mapKeys[i]
let val = form.mapVals[i]
if key.kind == ckKeyword:
result.add((key.kwName, val))
elif key.kind == ckSymbol:
result.add((key.symName, val))
proc parseDepsMap(depsMap: CljVal): seq[Dependency] =
## Parse {:deps {lib-name {:git/url "..." :sha "..."} ...}} map
result = @[]
if depsMap.kind != ckMap:
raise newException(DepsError, "deps value must be a map")
for i in 0..<depsMap.mapKeys.len:
let depKey = depsMap.mapKeys[i]
let depVal = depsMap.mapVals[i]
var depName = ""
if depKey.kind == ckSymbol:
depName = depKey.symName
elif depKey.kind == ckKeyword:
depName = depKey.kwName
else:
continue
if depVal.kind != ckMap:
raise newException(DepsError, "Dependency spec for " & depName & " must be a map")
let pairs = extractMapPairs(depVal)
var gitUrl = ""
var sha = ""
var localRoot = ""
for (pk, pv) in pairs:
case pk
of "git/url":
if pv.kind == ckString: gitUrl = pv.strVal
of "sha", "rev":
if pv.kind == ckString: sha = pv.strVal
of "local/root":
if pv.kind == ckString: localRoot = pv.strVal
if gitUrl.len > 0 and sha.len > 0:
result.add(Dependency(name: depName, kind: dkGit, gitUrl: gitUrl, sha: sha))
elif localRoot.len > 0:
result.add(Dependency(name: depName, kind: dkLocal, localRoot: localRoot))
else:
raise newException(DepsError, "Dependency " & depName & " needs :git/url+:sha or :local/root")
proc parseDepsFile*(filePath: string): DepsFile =
## Parse a deps.edn file and return structured deps
let source = readFile(filePath)
let forms = reader.readAll(source)
if forms.len == 0:
raise newException(DepsError, "Empty deps.edn")
let topMap = forms[0]
if topMap.kind != ckMap:
raise newException(DepsError, "deps.edn must contain a map at top level")
let pairs = extractMapPairs(topMap)
for (pk, pv) in pairs:
if pk == "deps":
result.deps = parseDepsMap(pv)
proc depsDir*(projectDir: string): string =
## Return the .deps directory path for a project
return projectDir / ".deps"
proc depLocalPath*(projectDir: string, dep: Dependency): string =
## Return the local filesystem path for a dependency
case dep.kind
of dkLocal:
let root = dep.localRoot
if root.isAbsolute:
return root
return projectDir / root
of dkGit:
# Sanitize name for directory: org.clojure/core.async -> org_clojure_core_async
let dirName = dep.name.replace("/", "_").replace(".", "_")
return depsDir(projectDir) / dirName
proc gitClone*(url, targetDir: string): bool =
## Clone a git repo to targetDir. Returns true on success.
createDir(targetDir.parentDir)
if dirExists(targetDir):
return true # already cloned
let cmd = "git clone --quiet " & quoteShell(url) & " " & quoteShell(targetDir)
let exitCode = execCmd(cmd)
return exitCode == 0
proc gitCheckout*(repoDir, sha: string): bool =
## Checkout a specific commit in a repo
let cmd = "git -C " & quoteShell(repoDir) & " checkout --quiet " & sha
let exitCode = execCmd(cmd)
return exitCode == 0
proc resolveDeps*(depsFile: DepsFile, projectDir: string): seq[string] =
## Download/resolve all dependencies and return search paths
result = @[]
for dep in depsFile.deps:
case dep.kind
of dkLocal:
let path = depLocalPath(projectDir, dep)
if dirExists(path):
result.add(path)
else:
stderr.writeLine("Warning: Local dependency not found: " & path)
of dkGit:
let path = depLocalPath(projectDir, dep)
if not dirExists(path):
echo "Cloning ", dep.name, " from ", dep.gitUrl, "..."
if not gitClone(dep.gitUrl, path):
stderr.writeLine("Warning: Failed to clone " & dep.name)
continue
if not gitCheckout(path, dep.sha):
stderr.writeLine("Warning: Failed to checkout " & dep.sha & " for " & dep.name)
else:
echo "Checked out ", dep.name, " @ ", dep.sha
if dirExists(path):
result.add(path)
proc loadAndResolve*(projectDir: string): seq[string] =
## Find deps.edn, parse it, resolve deps, return search paths
let depsPath = findDepsFile(projectDir)
if depsPath.len == 0:
return @[]
let depsFile = parseDepsFile(depsPath)
return resolveDeps(depsFile, projectDir.parentDir)
+110
View File
@@ -0,0 +1,110 @@
import unittest, os, strutils
import ../src/deps
let testDir = getCurrentDir() / "tests" / "tmp_deps"
proc setup() =
createDir(testDir)
proc teardown() =
removeDir(testDir)
suite "Deps - Parsing":
setup()
teardown()
test "parse deps.edn with git deps":
let edn = """{:deps {org.clojure/core.async {:git/url "https://github.com/clojure/core.async.git" :sha "abc123"}}}"""
let tmpFile = testDir / "deps.edn"
createDir(testDir)
writeFile(tmpFile, edn)
let depsFile = parseDepsFile(tmpFile)
check depsFile.deps.len == 1
check depsFile.deps[0].name == "org.clojure/core.async"
check depsFile.deps[0].kind == dkGit
check depsFile.deps[0].gitUrl == "https://github.com/clojure/core.async.git"
check depsFile.deps[0].sha == "abc123"
removeFile(tmpFile)
removeDir(testDir)
test "parse deps.edn with local deps":
let edn = """{:deps {mylib/local {:local/root "../mylib"}}}"""
let tmpFile = testDir / "deps.edn"
createDir(testDir)
writeFile(tmpFile, edn)
let depsFile = parseDepsFile(tmpFile)
check depsFile.deps.len == 1
check depsFile.deps[0].name == "mylib/local"
check depsFile.deps[0].kind == dkLocal
check depsFile.deps[0].localRoot == "../mylib"
removeFile(tmpFile)
removeDir(testDir)
test "parse deps.edn with multiple deps":
let edn = """{:deps {org.clojure/core.async {:git/url "https://github.com/clojure/core.async.git" :sha "abc123"}
mylib/local {:local/root "./lib"}}}"""
let tmpFile = testDir / "deps.edn"
createDir(testDir)
writeFile(tmpFile, edn)
let depsFile = parseDepsFile(tmpFile)
check depsFile.deps.len == 2
removeFile(tmpFile)
removeDir(testDir)
test "parse deps.edn with :rev instead of :sha":
let edn = """{:deps {some/lib {:git/url "https://example.com/lib.git" :rev "def456"}}}"""
let tmpFile = testDir / "deps.edn"
createDir(testDir)
writeFile(tmpFile, edn)
let depsFile = parseDepsFile(tmpFile)
check depsFile.deps.len == 1
check depsFile.deps[0].sha == "def456"
removeFile(tmpFile)
removeDir(testDir)
test "parse empty deps map":
let edn = """{:deps {}}"""
let tmpFile = testDir / "deps.edn"
createDir(testDir)
writeFile(tmpFile, edn)
let depsFile = parseDepsFile(tmpFile)
check depsFile.deps.len == 0
removeFile(tmpFile)
removeDir(testDir)
suite "Deps - Path Resolution":
test "depLocalPath for git dep":
let dep = Dependency(name: "org.clojure/core.async", kind: dkGit, gitUrl: "https://example.com", sha: "abc")
let path = depLocalPath("/project", dep)
check path == "/project/.deps/org_clojure_core_async"
test "depLocalPath for local dep relative":
let dep = Dependency(name: "mylib", kind: dkLocal, localRoot: "../mylib")
let path = depLocalPath("/project", dep)
check path == "/mylib"
test "depLocalPath for local dep absolute":
let dep = Dependency(name: "mylib", kind: dkLocal, localRoot: "/abs/path/mylib")
let path = depLocalPath("/project", dep)
check path == "/abs/path/mylib"
test "depsDir returns correct path":
check depsDir("/project") == "/project/.deps"
suite "Deps - findDepsFile":
test "findDepsFile returns empty when no deps file":
let tmpSubdir = testDir / "subdir"
createDir(tmpSubdir)
let result = findDepsFile(tmpSubdir)
check result.len == 0
removeDir(testDir)
test "findDepsFile finds deps.edn":
createDir(testDir)
writeFile(testDir / "deps.edn", "{:deps {}}")
let tmpSubdir = testDir / "subdir"
createDir(tmpSubdir)
let result = findDepsFile(tmpSubdir)
check result.len > 0
check result.endsWith("deps.edn")
removeDir(testDir)