feat: lifetime elision, tooling CI, registry, and LSP locals

Ship the QUALITY_PLAN stretch from ownership through ecosystem: C.1
lifetime elision (bootstrap + selfhost), bux fmt/test/doc CI hooks,
stdlib goldens, package registry (bux search/add), and LSP 0.4
position-sensitive locals with inferred let types. Full-tree format
pass plus Map/Set remove double-free fix.
This commit is contained in:
2026-07-19 16:35:08 +03:00
parent 3eb1ad3a82
commit 53b43b0f79
130 changed files with 20494 additions and 16738 deletions
+334 -26
View File
@@ -1,6 +1,9 @@
import std/[os, strutils, terminal, strformat, osproc, sets]
import std/[os, strutils, terminal, strformat, osproc, sets, algorithm, tables]
import lexer, parser, ast, sema, manifest, hir_lower, lir_lower, lir_c_backend
import source_location
import fmt
import docgen
import registry
type
ColorMode* = enum
@@ -21,16 +24,25 @@ Usage: bux [options] <command> [command-options]
Commands:
new <name> Create a new Bux package
init Initialize a Bux package in the current directory
add <name> [ver] Add a dependency (--path, --git)
add <name> [ver] Add a dependency (--path, --git, or registry)
install Resolve and install dependencies
search [query] Search the package registry
build Build the current package
run Build and run the current package
test Run tests in tests/ directory
check Type-check the current package
fmt [path] Format .bux sources (default: .)
doc [path] Generate Markdown API docs from /// comments
clean Remove build artifacts
help Show this help message
version Show version
Command options:
test --filter <s> Only run tests whose name contains <s>
fmt --check Exit 1 if any file would be reformatted (CI)
doc --out <file> Write docs to file (default: stdout)
add --path / --git Explicit source; else resolve via registry
Global options:
--color <auto|on|off> Control colored output (default: auto)
-q, --quiet Suppress non-error output
@@ -192,6 +204,14 @@ proc hintForMessage(msg: string): string =
return "provide the missing argument (positional or named)"
if "use of moved value" in m:
return "the value was moved; clone it or restructure ownership"
if "cannot return reference to local" in m:
return "return a value, or return a reference borrowed from a function parameter"
if "lifetime elision failed" in m:
return "add an explicit lifetime, e.g. func F<'a>(x: &'a T, y: &'a U) -> &'a T"
if "lifetime mismatch" in m:
return "returned reference must share a lifetime with the return type (annotate with 'a)"
if "no input reference to borrow from" in m:
return "add a '&T' parameter to borrow from, or return an owned value"
if "shared reference" in m or "checked function" in m:
return "use '&mut T' for mutation, or drop @[Checked] for unchecked code"
if "double mutable borrow" in m or "already mutably borrowed" in m:
@@ -378,7 +398,11 @@ proc cmdAdd*(args: seq[string], opts: GlobalOptions): int =
printError("--git requires a value", useColor)
return 1
else:
version = args[i]
if not args[i].startsWith("-"):
version = args[i]
else:
printError(&"unknown add option '{args[i]}'", useColor)
return 1
inc i
# Append to bux.toml
var depLine = ""
@@ -387,7 +411,19 @@ proc cmdAdd*(args: seq[string], opts: GlobalOptions): int =
elif gitUrl.len > 0:
depLine = &"{depName} = {{ Version = \"{version}\", Source = \"{gitUrl}\" }}"
else:
depLine = &"{depName} = \"{version}\""
# Registry resolve (E.1)
let reg = loadRegistry()
if reg.path.len == 0:
printError("no package registry found (set BUX_REGISTRY or install config/registry.toml)", useColor)
return 1
let pkg = registryLookup(reg, depName, version)
if pkg.name.len == 0:
printError(&"package '{depName}' not found in registry ({reg.path})", useColor)
printError("hint: bux search | bux add name --git <url> | bux add name --path <dir>", useColor)
return 1
depLine = formatRegistryDepLine(depName, pkg)
if not opts.quiet:
printInfo(&"Resolved '{depName}' {pkg.version} from registry {reg.path}", useColor)
var content = readFile(manifestPath)
# Ensure [Dependencies] section exists
if content.find("[Dependencies]") < 0:
@@ -399,6 +435,34 @@ proc cmdAdd*(args: seq[string], opts: GlobalOptions): int =
printInfo(&"Added dependency '{depName}' to bux.toml", useColor)
return 0
proc cmdSearch*(args: seq[string], opts: GlobalOptions): int =
let useColor = shouldUseColor(opts)
let query = if args.len > 0: args[0] else: ""
let reg = loadRegistry()
if reg.path.len == 0:
printError("no package registry found (set BUX_REGISTRY)", useColor)
return 1
if not opts.quiet:
echo &"Registry: {reg.path}"
let hits = registrySearch(reg, query)
if hits.len == 0:
if not opts.quiet:
echo "No packages matched."
return 1
# Dedupe by name showing latest version
var seen = initTable[string, RegistryPackage]()
for p in hits:
seen[p.name.toLowerAscii()] = p
var names: seq[string] = @[]
for k in seen.keys:
names.add(k)
names.sort(system.cmp)
for k in names:
let p = seen[k]
let desc = if p.description.len > 0: p.description else: p.source
echo &" {p.name} {p.version} — {desc}"
return 0
proc cmdInstall*(args: seq[string], opts: GlobalOptions): int =
let useColor = shouldUseColor(opts)
let root = getCurrentDir()
@@ -411,6 +475,7 @@ proc cmdInstall*(args: seq[string], opts: GlobalOptions): int =
let cacheDir = getHomeDir() / ".bux" / "packages"
if not dirExists(cacheDir):
createDir(cacheDir)
let reg = loadRegistry()
# Resolve each dependency
for dep in man.dependencies:
case dep.kind
@@ -433,20 +498,43 @@ proc cmdInstall*(args: seq[string], opts: GlobalOptions): int =
if not dirExists(depDir):
if not opts.quiet:
printInfo(&"Cloning '{dep.name}' from {dep.gitUrl}...", useColor)
let (outp, code) = execCmdEx(&"git clone {dep.gitUrl} {depDir} 2>&1")
let (outp, code) = execCmdEx(&"git clone --quiet {quoteShell(dep.gitUrl)} {quoteShell(depDir)} 2>&1")
if code != 0:
printError(&"failed to clone {dep.gitUrl}: {outp}", useColor)
return 1
else:
if not opts.quiet:
printInfo(&"Using cached '{dep.name}' from {depDir}", useColor)
# Lock stores git URL; build loads from cache by name
lock.entries.add(LockEntry(name: dep.name, version: dep.gitVersion, source: dep.gitUrl))
of dkVersion:
# For version-based deps without a registry, we just record them
# TODO: lookup in registry
lock.entries.add(LockEntry(name: dep.name, version: dep.versionReq, source: "registry"))
if not opts.quiet:
printInfo(&"Recorded dependency '{dep.name}' = {dep.versionReq}", useColor)
# Registry lookup (E.1)
if reg.path.len == 0:
printError(&"cannot resolve '{dep.name}': no package registry (set BUX_REGISTRY)", useColor)
return 1
let pkg = registryLookup(reg, dep.name, dep.versionReq)
if pkg.name.len == 0:
printError(&"package '{dep.name}' not found in registry", useColor)
return 1
if pkg.resolvedPath.len > 0 and dirExists(pkg.resolvedPath):
lock.entries.add(LockEntry(name: dep.name, version: pkg.version, source: pkg.resolvedPath))
if not opts.quiet:
printInfo(&"Resolved '{dep.name}' {pkg.version} → {pkg.resolvedPath}", useColor)
elif isGitSource(pkg.source):
let depDir = cacheDir / dep.name
if not dirExists(depDir):
if not opts.quiet:
printInfo(&"Cloning '{dep.name}' from {pkg.source}...", useColor)
let (outp, code) = execCmdEx(&"git clone --quiet {quoteShell(pkg.source)} {quoteShell(depDir)} 2>&1")
if code != 0:
printError(&"failed to clone {pkg.source}: {outp}", useColor)
return 1
lock.entries.add(LockEntry(name: dep.name, version: pkg.version, source: pkg.source))
if not opts.quiet:
printInfo(&"Resolved '{dep.name}' {pkg.version} → git {pkg.source}", useColor)
else:
printError(&"registry entry '{dep.name}' has unusable source '{pkg.source}'", useColor)
return 1
# Save lockfile
let lockPath = root / "bux.lock"
saveLockfile(lockPath, lock)
@@ -722,18 +810,93 @@ proc cmdClean*(args: seq[string], opts: GlobalOptions): int =
printInfo("clean: build directory removed", useColor)
return 0
proc parseTestArgs(args: seq[string]): tuple[filter: string, paths: seq[string], ok: bool] =
## Parse `test` args: optional `--filter <s>` / `--filter=<s>`, rest are ignored paths.
result.filter = ""
result.paths = @[]
result.ok = true
var i = 0
while i < args.len:
let a = args[i]
if a == "--filter":
if i + 1 >= args.len:
stderr.writeLine("error: --filter requires an argument")
result.ok = false
return
inc i
result.filter = args[i]
elif a.startsWith("--filter="):
result.filter = a["--filter=".len .. ^1]
elif a == "--help" or a == "-h":
echo "Usage: bux test [--filter <name>] [project-dir]"
echo " --filter <name> Only run tests whose filename contains <name>"
result.ok = false # treat as early exit without error in caller? use special
# Signal help via empty filter and a sentinel path
result.paths = @["__help__"]
return
elif a.startsWith("-"):
stderr.writeLine(&"error: unknown test option '{a}'")
result.ok = false
return
else:
result.paths.add(a)
inc i
proc parseFmtArgs(args: seq[string]): tuple[checkOnly: bool, paths: seq[string], ok: bool, help: bool] =
result.checkOnly = false
result.paths = @[]
result.ok = true
result.help = false
var i = 0
while i < args.len:
let a = args[i]
if a == "--check":
result.checkOnly = true
elif a == "--help" or a == "-h":
result.help = true
return
elif a.startsWith("-"):
stderr.writeLine(&"error: unknown fmt option '{a}'")
result.ok = false
return
else:
result.paths.add(a)
inc i
proc cmdTest*(args: seq[string], opts: GlobalOptions): int =
let useColor = shouldUseColor(opts)
let root = getCurrentDir()
let (filter, paths, ok) = parseTestArgs(args)
if not ok:
if paths.len == 1 and paths[0] == "__help__":
return 0
return 1
let root = if paths.len > 0: absolutePath(paths[0]) else: getCurrentDir()
let testsDir = root / "tests"
var testFiles: seq[string] = @[]
if dirExists(testsDir):
for kind, path in walkDir(testsDir):
if kind == pcFile and path.endsWith(".bux"):
let testName = splitFile(path).name
if filter.len > 0 and filter notin testName:
continue
testFiles.add(path)
testFiles.sort(system.cmp)
if testFiles.len == 0:
printError("no tests found in tests/ directory", useColor)
if filter.len > 0:
printError(&"no tests matching filter '{filter}' in tests/", useColor)
else:
printError("no tests found in tests/ directory", useColor)
return 1
if not opts.quiet:
if filter.len > 0:
echo &"Running tests (filter: {filter}) in {testsDir}"
else:
echo &"Running tests in {testsDir}"
echo "┌──────────────────────────────┬────────┐"
echo "│ Test │ Status │"
echo "├──────────────────────────────┼────────┤"
var passed = 0
var failed = 0
for testFile in testFiles:
@@ -742,25 +905,167 @@ proc cmdTest*(args: seq[string], opts: GlobalOptions): int =
removeDir(tmpDir)
createDir(tmpDir / "src")
copyFile(testFile, tmpDir / "src" / "Main.bux")
writeFile(tmpDir / "bux.toml", "[package]\nname = \"" & testName & "\"\nversion = \"0.1.0\"\n")
writeFile(tmpDir / "bux.toml",
"[Package]\nName = \"" & testName & "\"\nVersion = \"0.1.0\"\nType = \"bin\"\n\n[Build]\nOutput = \"Bin\"\n")
let buildRes = cmdBuild(@[tmpDir], opts)
var status: string
var statusOk = false
if buildRes != 0:
printError(&" FAIL {testName} (build)", useColor)
status = "FAIL"
failed += 1
else:
var execFile = tmpDir / "build" / testName
if not fileExists(execFile):
execFile = tmpDir / "build" / "bux_out"
let exitCode = execCmd(execFile)
if exitCode == 0:
status = "PASS"
statusOk = true
passed += 1
else:
status = &"FAIL:{exitCode}"
failed += 1
removeDir(tmpDir)
if not opts.quiet:
# Pad name to 28 chars for the table column
var nameCol = testName
if nameCol.len > 28:
nameCol = nameCol[0 .. 24] & "..."
else:
nameCol = nameCol & repeat(' ', 28 - nameCol.len)
var stCol = status
if stCol.len < 6:
stCol = stCol & repeat(' ', 6 - stCol.len)
if useColor:
if statusOk:
stdout.setForegroundColor(fgGreen)
else:
stdout.setForegroundColor(fgRed)
stdout.writeLine(&"│ {nameCol} │ {stCol} │")
stdout.resetAttributes()
else:
echo &"│ {nameCol} │ {stCol} │"
if not opts.quiet:
echo "└──────────────────────────────┴────────┘"
echo &"\nResults: {passed} passed, {failed} failed, {testFiles.len} total"
# CI-friendly exit codes: 0 = all pass, 1 = some failed
return if failed > 0: 1 else: 0
proc cmdFmt*(args: seq[string], opts: GlobalOptions): int =
let useColor = shouldUseColor(opts)
let (checkOnly, paths, ok, help) = parseFmtArgs(args)
if not ok:
return 1
if help:
echo "Usage: bux fmt [--check] [path...]"
echo " --check Do not write; exit 1 if any file would be reformatted"
echo " path File or directory (default: .)"
return 0
let targets = if paths.len > 0: paths else: @["."]
var files: seq[string] = @[]
for t in targets:
let collected = collectBuxFiles(t)
for f in collected:
if f notin files:
files.add(f)
files.sort(system.cmp)
if files.len == 0:
printError("no .bux files found", useColor)
return 1
var changed = 0
var failed = 0
var unchanged = 0
for path in files:
let (okf, didChange, msg) = formatFile(path, checkOnly)
if not okf:
printError(&"{path}: {msg}", useColor)
failed += 1
continue
var execFile = tmpDir / "build" / testName
if not fileExists(execFile):
execFile = tmpDir / "build" / "bux_out"
let exitCode = execCmd(execFile)
if exitCode == 0:
printInfo(&" PASS {testName}", useColor)
passed += 1
if didChange:
changed += 1
if not opts.quiet:
if checkOnly:
printError(&" would reformat {path}", useColor)
else:
printInfo(&" formatted {path}", useColor)
else:
printError(&" FAIL {testName} (exit {exitCode})", useColor)
failed += 1
removeDir(tmpDir)
echo &"\nResults: {passed} passed, {failed} failed"
return if failed > 0: 1 else: 0
unchanged += 1
if opts.verbose and not opts.quiet:
echo &" ok {path}"
if not opts.quiet:
if checkOnly:
echo &"\nfmt --check: {changed} would reformat, {unchanged} ok, {failed} errors"
else:
echo &"\nFormatted {changed}/{files.len} files ({unchanged} already clean)"
if failed > 0:
return 1
if checkOnly and changed > 0:
return 1
return 0
proc cmdDoc*(args: seq[string], opts: GlobalOptions): int =
## Generate Markdown docs from `///` / adjacent `/* */` comments.
var outPath = ""
var paths: seq[string] = @[]
var i = 0
while i < args.len:
let a = args[i]
if a == "--out" or a == "-o":
if i + 1 >= args.len:
stderr.writeLine("error: --out requires a path")
return 1
inc i
outPath = args[i]
elif a.startsWith("--out="):
outPath = a["--out=".len .. ^1]
elif a == "--help" or a == "-h":
echo "Usage: bux doc [--out file.md] [path...]"
echo " Scans .bux files for /// and /* */ docs preceding declarations."
echo " Default path: lib/ (stdlib) when omitted."
return 0
elif a.startsWith("-"):
stderr.writeLine(&"error: unknown doc option '{a}'")
return 1
else:
paths.add(a)
inc i
if paths.len == 0:
# Prefer stdlib if present
if dirExists("lib"):
paths = @["lib"]
else:
paths = @["."]
let items = generateDocs(paths)
let title =
if paths.len == 1 and paths[0] == "lib": "Bux Standard Library"
else: "API Reference"
let md = renderMarkdown(items, title)
if outPath.len > 0:
try:
let parent = parentDir(outPath)
if parent.len > 0 and not dirExists(parent):
createDir(parent)
writeFile(outPath, md)
if not opts.quiet:
echo &"Wrote {items.len} documented items → {outPath}"
except CatchableError as e:
stderr.writeLine("error: " & e.msg)
return 1
else:
stdout.write(md)
if items.len == 0 and not opts.quiet:
stderr.writeLine("warning: no /// or /* */ documented declarations found")
return 0
proc cmdVersion*(args: seq[string], opts: GlobalOptions): int =
echo "bux 0.1.0 (bootstrap)"
@@ -782,10 +1087,13 @@ proc runCli*(args: seq[string]): int =
of "init": return cmdInit(cmdArgs, opts)
of "add": return cmdAdd(cmdArgs, opts)
of "install": return cmdInstall(cmdArgs, opts)
of "search": return cmdSearch(cmdArgs, opts)
of "build": return cmdBuild(cmdArgs, opts)
of "run": return cmdRun(cmdArgs, opts)
of "check": return cmdCheck(cmdArgs, opts)
of "test": return cmdTest(cmdArgs, opts)
of "fmt": return cmdFmt(cmdArgs, opts)
of "doc": return cmdDoc(cmdArgs, opts)
of "clean": return cmdClean(cmdArgs, opts)
of "version", "--version", "-v": return cmdVersion(cmdArgs, opts)
of "help", "--help", "-h":