From feb9cee287c15aaedc4ba2e294bd92f028a61247 Mon Sep 17 00:00:00 2001 From: dimgigov Date: Fri, 8 May 2026 20:05:19 +0300 Subject: [PATCH] =?UTF-8?q?T7.4:=20Dependency=20resolution=20=E2=80=94=20d?= =?UTF-8?q?eps.edn=20parser,=20Git=20deps,=20cljnim=20deps=20command,=2012?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 2 + TASKS.md | 2 +- docs/ROADMAP.md | 2 +- src/cljnim.nim | 37 ++++++++-- src/deps.nim | 170 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_deps.nim | 110 ++++++++++++++++++++++++++++ 6 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 src/deps.nim create mode 100644 tests/test_deps.nim diff --git a/Makefile b/Makefile index c8af3d0..e67a286 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/TASKS.md b/TASKS.md index 6016773..e5e4698 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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. | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 53297a1..3bb826a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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) diff --git a/src/cljnim.nim b/src/cljnim.nim index d3791e1..5fcada9 100644 --- a/src/cljnim.nim +++ b/src/cljnim.nim @@ -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 Compile and run binary" echo " cljnim read 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 '' 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) diff --git a/src/deps.nim b/src/deps.nim new file mode 100644 index 0000000..6f3e55f --- /dev/null +++ b/src/deps.nim @@ -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.. 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) diff --git a/tests/test_deps.nim b/tests/test_deps.nim new file mode 100644 index 0000000..b23f7a2 --- /dev/null +++ b/tests/test_deps.nim @@ -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)