T7.4: Dependency resolution — deps.edn parser, Git deps, cljnim deps command, 12 tests
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+31
-6
@@ -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,17 +44,20 @@ 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)
|
||||
|
||||
|
||||
if forms.len == 0:
|
||||
stderr.writeLine("Error: No forms found in " & inputPath)
|
||||
quit(1)
|
||||
|
||||
|
||||
# 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
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user