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_emitter.nim
|
||||||
nim c -r tests/test_pvec.nim
|
nim c -r tests/test_pvec.nim
|
||||||
nim c -r tests/test_pmap.nim
|
nim c -r tests/test_pmap.nim
|
||||||
|
nim c -r tests/test_deps.nim
|
||||||
|
|
||||||
test-reader:
|
test-reader:
|
||||||
nim c -r tests/test_reader.nim
|
nim c -r tests/test_reader.nim
|
||||||
@@ -33,3 +34,4 @@ clean:
|
|||||||
find . -name "*_generated" -delete
|
find . -name "*_generated" -delete
|
||||||
find . -name "test_reader" -delete
|
find . -name "test_reader" -delete
|
||||||
find . -name "test_emitter" -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.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.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.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] Compile entire projects (not just single files)
|
||||||
- [x] Namespace system (`ns`, `(:require [lib :as alias])`)
|
- [x] Namespace system (`ns`, `(:require [lib :as alias])`)
|
||||||
- [x] Module caching for faster REPL startup
|
- [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
|
## Phase 8: Self-Hosted REPL
|
||||||
- [ ] Compile forms in memory (no temp files)
|
- [ ] Compile forms in memory (no temp files)
|
||||||
|
|||||||
+31
-6
@@ -1,6 +1,6 @@
|
|||||||
# Clojure/Nim — AI-First Clojure Compiler
|
# Clojure/Nim — AI-First Clojure Compiler
|
||||||
import os, osproc, strutils, times
|
import os, osproc, strutils, times
|
||||||
import reader, emitter, types, repl, macros
|
import reader, emitter, types, repl, macros, deps
|
||||||
|
|
||||||
proc getLibPath(): string =
|
proc getLibPath(): string =
|
||||||
let appDir = getAppDir()
|
let appDir = getAppDir()
|
||||||
@@ -44,17 +44,20 @@ proc extractRequires(forms: seq[CljVal]): seq[(string, string)] =
|
|||||||
alias = req.items[2].symName
|
alias = req.items[2].symName
|
||||||
result.add((libName, alias))
|
result.add((libName, alias))
|
||||||
|
|
||||||
proc compileFile*(inputPath: string, outputPath: string) =
|
proc compileFile*(inputPath: string, outputPath: string, extraPaths: seq[string] = @[]) =
|
||||||
let source = readFile(inputPath)
|
let source = readFile(inputPath)
|
||||||
let forms = reader.readAll(source)
|
let forms = reader.readAll(source)
|
||||||
|
|
||||||
if forms.len == 0:
|
if forms.len == 0:
|
||||||
stderr.writeLine("Error: No forms found in " & inputPath)
|
stderr.writeLine("Error: No forms found in " & inputPath)
|
||||||
quit(1)
|
quit(1)
|
||||||
|
|
||||||
# Resolve requires and collect all forms from required files
|
# Resolve requires and collect all forms from required files
|
||||||
let inputDir = inputPath.parentDir
|
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)
|
let requires = extractRequires(forms)
|
||||||
|
|
||||||
var allForms: seq[CljVal] = @[]
|
var allForms: seq[CljVal] = @[]
|
||||||
@@ -114,6 +117,10 @@ proc runFile*(inputPath: string) =
|
|||||||
let nimPath = tmpDir / baseName & "_generated.nim"
|
let nimPath = tmpDir / baseName & "_generated.nim"
|
||||||
let binPath = tmpDir / baseName & "_generated"
|
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
|
# Check nimcache for cached output
|
||||||
let projectDir = inputPath.parentDir
|
let projectDir = inputPath.parentDir
|
||||||
let cacheDir = projectDir / "nimcache"
|
let cacheDir = projectDir / "nimcache"
|
||||||
@@ -136,7 +143,7 @@ proc runFile*(inputPath: string) =
|
|||||||
let runResult = execCmd(cacheBinPath)
|
let runResult = execCmd(cacheBinPath)
|
||||||
quit(runResult)
|
quit(runResult)
|
||||||
|
|
||||||
compileFile(inputPath, nimPath)
|
compileFile(inputPath, nimPath, depPaths)
|
||||||
|
|
||||||
let compileResult = nimCompile(nimPath, binPath)
|
let compileResult = nimCompile(nimPath, binPath)
|
||||||
if compileResult != 0:
|
if compileResult != 0:
|
||||||
@@ -169,6 +176,7 @@ proc main() =
|
|||||||
echo " cljnim run <file.clj> Compile and run binary"
|
echo " cljnim run <file.clj> Compile and run binary"
|
||||||
echo " cljnim read <file.clj> Parse and print AST"
|
echo " cljnim read <file.clj> Parse and print AST"
|
||||||
echo " cljnim repl [--json] Start REPL (human or AI mode)"
|
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 " cljnim -e '<code>' Evaluate expression"
|
||||||
echo ""
|
echo ""
|
||||||
echo "REPL Commands:"
|
echo "REPL Commands:"
|
||||||
@@ -261,6 +269,23 @@ proc main() =
|
|||||||
finally:
|
finally:
|
||||||
state.cleanup()
|
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:
|
else:
|
||||||
stderr.writeLine("Unknown command: " & cmd)
|
stderr.writeLine("Unknown command: " & cmd)
|
||||||
quit(1)
|
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