2bc64eca22
- New screens: Main Menu, Compile, Run, REPL, AI Generator, AI Settings, Help - AI configuration persisted in ~/.config/cljnim/config.json - Navigate with arrow keys, Enter, Escape - illwill added as dependency in cljnim.nimble
359 lines
12 KiB
Nim
359 lines
12 KiB
Nim
# Clojure/Nim — AI-First Clojure Compiler
|
|
import os, osproc, strutils, times
|
|
import reader, emitter, types, repl, macros, deps, ai_assist, tui
|
|
|
|
proc getLibPath(): string =
|
|
let appDir = getAppDir()
|
|
let candidate1 = appDir / "lib"
|
|
let candidate2 = appDir.parentDir / "lib"
|
|
let candidate3 = getCurrentDir() / "lib"
|
|
if dirExists(candidate1): return candidate1
|
|
if dirExists(candidate2): return candidate2
|
|
if dirExists(candidate3): return candidate3
|
|
return "lib"
|
|
|
|
proc resolveNsToPath(nsName: string, searchPaths: seq[string]): string =
|
|
# Convert namespace name to file path: my.app -> my/app.clj
|
|
# Clojure convention: hyphens in ns names become underscores in filenames
|
|
let relPath = nsName.replace("-", "_").replace(".", "/") & ".clj"
|
|
for sp in searchPaths:
|
|
let candidate = sp / relPath
|
|
if fileExists(candidate):
|
|
return candidate
|
|
return ""
|
|
|
|
proc extractRequires(forms: seq[CljVal]): seq[(string, string)] =
|
|
# Extract require declarations: returns (namespace, alias) pairs
|
|
result = @[]
|
|
for form in forms:
|
|
if form.kind == ckList and form.items.len >= 2 and
|
|
form.items[0].kind == ckSymbol and form.items[0].symName == "ns":
|
|
for ri in 2..<form.items.len:
|
|
let clause = form.items[ri]
|
|
let isRequire = (clause.kind == ckList and clause.items.len > 0 and
|
|
((clause.items[0].kind == ckSymbol and clause.items[0].symName == ":require") or
|
|
(clause.items[0].kind == ckKeyword and clause.items[0].kwName == "require")))
|
|
if isRequire:
|
|
for ci in 1..<clause.items.len:
|
|
let req = clause.items[ci]
|
|
if req.kind == ckVector and req.items.len >= 1 and req.items[0].kind == ckSymbol:
|
|
let libName = req.items[0].symName
|
|
var alias = libName
|
|
if req.items.len >= 3 and req.items[1].kind == ckKeyword and req.items[1].kwName == "as":
|
|
if req.items[2].kind == ckSymbol:
|
|
alias = req.items[2].symName
|
|
result.add((libName, alias))
|
|
|
|
proc compileFileInternal(inputPath: string, outputPath: string, extraPaths: seq[string] = @[], libMode: bool = false) =
|
|
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
|
|
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] = @[]
|
|
var visited: seq[string] = @[]
|
|
var nsAliases: seq[(string, string)] = @[] # (alias, namespace)
|
|
|
|
proc resolveFile(nsName: string) =
|
|
if nsName in visited: return
|
|
visited.add(nsName)
|
|
let path = resolveNsToPath(nsName, searchPaths)
|
|
if path.len == 0:
|
|
stderr.writeLine("Warning: Cannot find file for namespace: " & nsName)
|
|
return
|
|
let src = readFile(path)
|
|
let fileForms = reader.readAll(src)
|
|
# Recursively resolve nested requires
|
|
let nestedRequires = extractRequires(fileForms)
|
|
for (nestedNs, _) in nestedRequires:
|
|
resolveFile(nestedNs)
|
|
# Add non-ns forms
|
|
for f in fileForms:
|
|
if not (f.kind == ckList and f.items.len >= 1 and
|
|
f.items[0].kind == ckSymbol and f.items[0].symName == "ns"):
|
|
allForms.add(f)
|
|
|
|
# Collect namespace aliases
|
|
for (nsName, alias) in requires:
|
|
nsAliases.add((alias, nsName))
|
|
resolveFile(nsName)
|
|
|
|
# Set namespace aliases in emitter
|
|
emitter.setNsAliases(nsAliases)
|
|
|
|
# Add current file's forms (skip ns declaration)
|
|
for f in forms:
|
|
if not (f.kind == ckList and f.items.len >= 1 and
|
|
f.items[0].kind == ckSymbol and f.items[0].symName == "ns"):
|
|
allForms.add(f)
|
|
|
|
let nimCode = if libMode: emitter.emitProgramLib(allForms) else: emitter.emitProgram(allForms)
|
|
writeFile(outputPath, nimCode)
|
|
echo "Generated: ", outputPath
|
|
|
|
proc compileFile*(inputPath: string, outputPath: string, extraPaths: seq[string] = @[]) =
|
|
compileFileInternal(inputPath, outputPath, extraPaths, false)
|
|
|
|
proc compileFileLib*(inputPath: string, outputPath: string, extraPaths: seq[string] = @[]) =
|
|
compileFileInternal(inputPath, outputPath, extraPaths, true)
|
|
|
|
proc nimCompile(nimPath: string, binPath: string, release: bool = false): tuple[exitCode: int, output: string] =
|
|
let libPath = getLibPath()
|
|
var cmd = "nim c"
|
|
if release:
|
|
cmd.add(" -d:release")
|
|
cmd.add(" --path:" & quoteShell(libPath))
|
|
cmd.add(" -o:" & quoteShell(binPath) & " " & quoteShell(nimPath))
|
|
echo "Compiling: ", cmd
|
|
let (output, exitCode) = execCmdEx(cmd)
|
|
return (exitCode, output)
|
|
|
|
proc makeTempDir(): string =
|
|
let pid = getCurrentProcessId()
|
|
let ts = epochTime().int
|
|
result = getTempDir() / "cljnim_build_" & $pid & "_" & $ts
|
|
createDir(result)
|
|
|
|
proc runFile*(inputPath: string) =
|
|
let baseName = inputPath.splitFile.name
|
|
let buildDir = makeTempDir()
|
|
let nimPath = buildDir / baseName & ".nim"
|
|
let binPath = buildDir / baseName
|
|
|
|
# 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"
|
|
let cacheSubDir = cacheDir / baseName
|
|
let cacheNimPath = cacheSubDir / baseName & ".nim"
|
|
let cacheBinPath = cacheSubDir / baseName
|
|
|
|
var useCache = false
|
|
if fileExists(cacheNimPath) and fileExists(cacheBinPath):
|
|
let srcTime = getLastModificationTime(inputPath).toUnix
|
|
let cacheTime = getLastModificationTime(cacheNimPath).toUnix
|
|
if srcTime <= cacheTime:
|
|
useCache = true
|
|
|
|
if useCache:
|
|
echo "Using cached: ", cacheNimPath
|
|
let (compileResult, compileOut) = nimCompile(cacheNimPath, cacheBinPath)
|
|
if compileResult != 0:
|
|
stderr.writeLine("Compilation failed (cached)")
|
|
stderr.writeLine(compileOut)
|
|
if ai_assist.hasAiConfig():
|
|
let source = readFile(inputPath)
|
|
let aiRes = ai_assist.explainError(compileOut, source, inputPath)
|
|
stderr.writeLine(ai_assist.formatSuggestion(aiRes))
|
|
quit(1)
|
|
try: removeDir(buildDir) except: discard
|
|
let runResult = execCmd(cacheBinPath)
|
|
quit(runResult)
|
|
|
|
compileFile(inputPath, nimPath, depPaths)
|
|
|
|
let (compileResult, compileOut) = nimCompile(nimPath, binPath)
|
|
if compileResult != 0:
|
|
stderr.writeLine("Compilation failed")
|
|
stderr.writeLine(compileOut)
|
|
if ai_assist.hasAiConfig():
|
|
let source = readFile(inputPath)
|
|
let aiRes = ai_assist.explainError(compileOut, source, inputPath)
|
|
stderr.writeLine(ai_assist.formatSuggestion(aiRes))
|
|
try: removeDir(buildDir) except: discard
|
|
quit(1)
|
|
|
|
# Save to cache
|
|
try:
|
|
createDir(cacheSubDir)
|
|
copyFile(nimPath, cacheNimPath)
|
|
copyFile(binPath, cacheBinPath)
|
|
echo "Cached: ", cacheNimPath
|
|
except:
|
|
discard
|
|
|
|
let runResult = execCmd(binPath)
|
|
try: removeDir(buildDir) except: discard
|
|
if runResult != 0:
|
|
stderr.writeLine("Execution failed")
|
|
quit(1)
|
|
|
|
proc main() =
|
|
initBuiltinMacros()
|
|
let args = commandLineParams()
|
|
|
|
if args.len == 0:
|
|
echo "Clojure/Nim — AI-First Clojure Compiler"
|
|
echo ""
|
|
echo "Usage:"
|
|
echo " cljnim compile <file.clj> [output.nim] Compile to Nim source"
|
|
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 " cljnim ai '<description>' Generate Clojure code with AI"
|
|
echo " cljnim tui Start interactive TUI"
|
|
echo ""
|
|
echo "REPL Commands:"
|
|
echo " :quit, :q Exit REPL"
|
|
echo " :defs List defined vars"
|
|
echo " :clear Clear definitions"
|
|
echo " :ns Show current namespace"
|
|
echo ""
|
|
echo "AI Mode (JSON):"
|
|
echo " cljnim repl --json"
|
|
echo " Input: {\"op\":\"eval\",\"form\":\"(+ 1 2)\"}"
|
|
echo " Output: {\"status\":\"ok\",\"result\":{...},\"meta\":{...}}"
|
|
quit(0)
|
|
|
|
let cmd = args[0]
|
|
|
|
# Handle -e flag
|
|
if cmd == "-e":
|
|
if args.len < 2:
|
|
stderr.writeLine("Error: Missing expression after -e")
|
|
quit(1)
|
|
let code = args[1]
|
|
let buildDir = makeTempDir()
|
|
let nimPath = buildDir / "expr.nim"
|
|
let binPath = buildDir / "expr"
|
|
let forms = reader.readAll(code)
|
|
let nimCode = emitter.emitProgram(forms)
|
|
writeFile(nimPath, nimCode)
|
|
let (compileResult, compileOut) = nimCompile(nimPath, binPath)
|
|
if compileResult != 0:
|
|
stderr.writeLine("Compilation failed")
|
|
stderr.writeLine(compileOut)
|
|
if ai_assist.hasAiConfig():
|
|
let aiRes = ai_assist.explainError(compileOut, code, "-e expression")
|
|
stderr.writeLine(ai_assist.formatSuggestion(aiRes))
|
|
try: removeDir(buildDir) except: discard
|
|
quit(1)
|
|
let runResult = execCmd(binPath)
|
|
try: removeDir(buildDir) except: discard
|
|
quit(runResult)
|
|
|
|
case cmd
|
|
of "compile":
|
|
if args.len < 2:
|
|
stderr.writeLine("Error: Missing input file")
|
|
quit(1)
|
|
let inputPath = args[1]
|
|
let outputPath = if args.len >= 3: args[2] else: inputPath.changeFileExt("nim")
|
|
compileFile(inputPath, outputPath)
|
|
|
|
of "compile-lib":
|
|
if args.len < 2:
|
|
stderr.writeLine("Error: Missing input file")
|
|
quit(1)
|
|
let inputPath = args[1]
|
|
let outputPath = if args.len >= 3: args[2] else: inputPath.changeFileExt("nim")
|
|
compileFileLib(inputPath, outputPath)
|
|
|
|
of "run":
|
|
if args.len < 2:
|
|
stderr.writeLine("Error: Missing input file")
|
|
quit(1)
|
|
runFile(args[1])
|
|
|
|
of "read":
|
|
if args.len < 2:
|
|
stderr.writeLine("Error: Missing input file")
|
|
quit(1)
|
|
let source = readFile(args[1])
|
|
let forms = reader.readAll(source)
|
|
for form in forms:
|
|
echo $form
|
|
|
|
of "repl":
|
|
var mode = rmHuman
|
|
var tcpPort = -1
|
|
for i in 1..<args.len:
|
|
if args[i] == "--json":
|
|
mode = rmJson
|
|
elif args[i] == "--edn":
|
|
mode = rmEdn
|
|
elif args[i] == "--tcp":
|
|
if i + 1 < args.len:
|
|
try:
|
|
tcpPort = parseInt(args[i+1])
|
|
except ValueError:
|
|
stderr.writeLine("Invalid TCP port: " & args[i+1])
|
|
quit(1)
|
|
elif args[i].startsWith("--tcp="):
|
|
try:
|
|
tcpPort = parseInt(args[i][6..^1])
|
|
except ValueError:
|
|
stderr.writeLine("Invalid TCP port: " & args[i][6..^1])
|
|
quit(1)
|
|
var state = initReplState(mode)
|
|
try:
|
|
if tcpPort > 0:
|
|
runTcpRepl(state, tcpPort)
|
|
else:
|
|
case mode
|
|
of rmHuman: runHumanRepl(state)
|
|
of rmJson: runJsonRepl(state)
|
|
of rmEdn: runJsonRepl(state)
|
|
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"
|
|
|
|
of "ai":
|
|
if not ai_assist.hasAiConfig():
|
|
stderr.writeLine("Error: No AI API key configured.")
|
|
stderr.writeLine("Set DEEPSEEK_API_KEY, OPENAI_API_KEY, or MIMO_API_KEY environment variable.")
|
|
quit(1)
|
|
if args.len < 2:
|
|
stderr.writeLine("Error: Missing description for AI generation")
|
|
stderr.writeLine("Usage: cljnim ai 'function that reverses a list'")
|
|
quit(1)
|
|
let description = args[1]
|
|
echo "🤖 Asking AI to generate code..."
|
|
let aiRes = ai_assist.generateCode(description)
|
|
if aiRes.ok:
|
|
echo aiRes.suggestion
|
|
else:
|
|
stderr.writeLine(aiRes.suggestion)
|
|
quit(1)
|
|
|
|
of "tui":
|
|
runTui()
|
|
|
|
else:
|
|
stderr.writeLine("Unknown command: " & cmd)
|
|
quit(1)
|
|
|
|
when isMainModule:
|
|
main()
|