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":
+202
View File
@@ -0,0 +1,202 @@
## docgen.nim — Extract `///` (and adjacent `/* */`) docs into Markdown.
## Used by `bux doc [path...]`.
import std/[os, strutils, strformat, algorithm]
type
DocItem* = object
kind*: string ## module | func | struct | enum | interface | extern
name*: string
signature*: string ## first declaration line (trimmed)
docs*: string
file*: string
line*: int
proc isDeclStart(line: string): bool =
let s = line.strip()
if s.len == 0: return false
# Leading attributes @[Checked] etc. — not a decl by themselves
if s.startsWith("@["): return false
if s.startsWith("func ") or s.startsWith("pub func ") or
s.startsWith("extern func ") or s.startsWith("const func ") or
s.startsWith("async func "):
return true
if s.startsWith("struct ") or s.startsWith("pub struct ") or
s.startsWith("enum ") or s.startsWith("pub enum ") or
s.startsWith("union ") or s.startsWith("interface ") or
s.startsWith("module ") or s.startsWith("type "):
return true
return false
proc declKindAndName(line: string): tuple[kind, name: string] =
var s = line.strip()
# Strip leading pub/extern/const/async
for prefix in ["pub ", "extern ", "const ", "async "]:
if s.startsWith(prefix):
s = s[prefix.len .. ^1].strip()
var kind = "item"
if s.startsWith("func "):
kind = "func"
s = s["func ".len .. ^1]
elif s.startsWith("struct "):
kind = "struct"
s = s["struct ".len .. ^1]
elif s.startsWith("enum "):
kind = "enum"
s = s["enum ".len .. ^1]
elif s.startsWith("union "):
kind = "union"
s = s["union ".len .. ^1]
elif s.startsWith("interface "):
kind = "interface"
s = s["interface ".len .. ^1]
elif s.startsWith("module "):
kind = "module"
s = s["module ".len .. ^1]
elif s.startsWith("type "):
kind = "type"
s = s["type ".len .. ^1]
# Name: until `<` `(` `{` `:` space
var name = ""
for ch in s:
if ch in {' ', '<', '(', '{', ':', ';'}:
break
name.add(ch)
if name.len == 0:
name = s
# extern funcs already stripped "extern "
if kind == "func" and line.strip().startsWith("extern"):
kind = "extern"
return (kind, name)
proc extractDocsFromSource*(source, path: string): seq[DocItem] =
result = @[]
var pending: seq[string] = @[]
var inBlockComment = false
var blockDoc: seq[string] = @[]
var lineNo = 0
for rawLine in source.splitLines():
inc lineNo
var line = rawLine
let stripped = line.strip()
# Block comment handling (/* ... */ used in stdlib today)
if inBlockComment:
let endIdx = stripped.find("*/")
if endIdx >= 0:
let before = stripped[0 ..< endIdx].strip()
if before.len > 0:
blockDoc.add(before)
inBlockComment = false
# Treat completed block as pending doc if non-empty
if blockDoc.len > 0:
pending = blockDoc
blockDoc = @[]
continue
else:
blockDoc.add(stripped)
continue
if stripped.startsWith("/*") and not stripped.startsWith("/***"):
let rest = stripped["/*".len .. ^1]
let endIdx = rest.find("*/")
if endIdx >= 0:
let body = rest[0 ..< endIdx].strip()
if body.len > 0:
pending = @[body]
else:
inBlockComment = true
blockDoc = @[]
let body = rest.strip()
if body.len > 0:
blockDoc.add(body)
continue
# Triple-slash doc comments
if stripped.startsWith("///"):
var body = stripped["///".len .. ^1]
if body.startsWith(" "):
body = body[1 .. ^1]
pending.add(body)
continue
# Empty line: keep pending docs (allow blank lines inside doc blocks)
if stripped.len == 0:
continue
# Attributes immediately before decl: keep pending
if stripped.startsWith("@["):
continue
if isDeclStart(line):
if pending.len > 0:
let (kind, name) = declKindAndName(line)
result.add(DocItem(
kind: kind,
name: name,
signature: stripped,
docs: pending.join("\n"),
file: path,
line: lineNo
))
pending = @[]
continue
# Other code clears pending (except plain // comments)
if stripped.startsWith("//"):
continue
pending = @[]
proc collectBuxFilesForDoc*(root: string): seq[string] =
result = @[]
if fileExists(root) and root.endsWith(".bux"):
result.add(root)
return
if not dirExists(root):
return
for path in walkDirRec(root):
if path.endsWith(".bux"):
result.add(path)
result.sort(system.cmp)
proc renderMarkdown*(items: seq[DocItem], title: string = "API Reference"): string =
var sb: string
sb.add(&"# {title}\n\n")
sb.add("Generated by `bux doc` from `///` and `/* */` documentation comments.\n\n")
if items.len == 0:
sb.add("_No documented items found._\n")
return sb
# Group by file
var byFile: seq[string] = @[]
for it in items:
if it.file notin byFile:
byFile.add(it.file)
byFile.sort(system.cmp)
for f in byFile:
let base = splitFile(f).name
sb.add(&"## `{base}`\n\n")
sb.add(&"_Source: `{f}`_\n\n")
for it in items:
if it.file != f:
continue
sb.add(&"### `{it.name}` _{it.kind}_\n\n")
sb.add("```bux\n")
sb.add(it.signature)
sb.add("\n```\n\n")
if it.docs.len > 0:
sb.add(it.docs)
sb.add("\n\n")
return sb
proc generateDocs*(paths: seq[string]): seq[DocItem] =
result = @[]
var files: seq[string] = @[]
for p in paths:
for f in collectBuxFilesForDoc(p):
if f notin files:
files.add(f)
files.sort(system.cmp)
for f in files:
let src = readFile(f)
result.add(extractDocsFromSource(src, f))
+106
View File
@@ -0,0 +1,106 @@
## fmt.nim — Indentation-based Bux source formatter (bootstrap).
## Mirrors selfhost `src/fmt.bux`: re-indent by brace depth, preserve content.
import std/[strutils, os, algorithm]
proc isInStringOrComment(line: string, pos: int): bool =
## Simplified: track `//`, `"..."`, and `'...'` up to `pos`.
var inString = false
var inChar = false
var inComment = false
var i = 0
while i < pos and i < line.len:
let c = line[i]
let n = if i + 1 < line.len: line[i + 1] else: '\0'
if inComment:
inc i
continue
if c == '/' and n == '/':
inComment = true
inc i
continue
if c == '"' and not inChar:
inString = not inString
if c == '\'' and not inString:
inChar = not inChar
inc i
return inString or inChar or inComment
proc countBraceDelta(line: string): int =
var delta = 0
for i in 0 ..< line.len:
if isInStringOrComment(line, i):
continue
let c = line[i]
if c == '{':
inc delta
elif c == '}':
dec delta
return delta
proc formatSource*(source: string): string =
## Re-indent each non-empty line to 4 spaces × brace depth.
## Idempotent: formatting a clean file is a no-op.
var sb: string
var indent = 0
# Nim's splitLines leaves a trailing "" when the source ends with '\n'.
# Drop that artifact so we don't accumulate blank lines on re-format.
var lines = source.splitLines(keepEol = false)
if source.len > 0 and source.endsWith('\n') and lines.len > 0 and lines[^1].len == 0:
lines.setLen(lines.len - 1)
for line in lines:
let trimmed = line.strip(leading = true, trailing = false)
if trimmed.len == 0:
sb.add('\n')
continue
let delta = countBraceDelta(trimmed)
let firstChar = trimmed[0]
if firstChar == '}':
dec indent
if indent < 0:
indent = 0
for _ in 0 ..< indent:
sb.add(" ")
sb.add(trimmed)
sb.add('\n')
if firstChar != '}':
indent = indent + delta
else:
# Net delta after the initial decrease for a leading `}`
indent = indent + delta + 1
if indent < 0:
indent = 0
return sb
proc formatFile*(path: string, checkOnly: bool): tuple[ok: bool, changed: bool, msg: string] =
## Format `path` in place, or only check if reformatting would change it.
if not fileExists(path):
return (false, false, "file not found: " & path)
let source = readFile(path)
let formatted = formatSource(source)
if formatted == source:
return (true, false, "")
if checkOnly:
return (true, true, "would reformat")
try:
writeFile(path, formatted)
return (true, true, "formatted")
except CatchableError as e:
return (false, false, e.msg)
proc collectBuxFiles*(root: string): seq[string] =
## Collect `.bux` files: single file, or recursive directory walk.
result = @[]
if fileExists(root) and root.endsWith(".bux"):
result.add(root)
return
if not dirExists(root):
return
for path in walkDirRec(root):
if path.endsWith(".bux"):
result.add(path)
result.sort(system.cmp)
+167
View File
@@ -0,0 +1,167 @@
## registry.nim — Bux package registry index (E.1)
##
## Index format (TOML-ish, one package per [[package]] table):
##
## [[package]]
## name = "greet"
## version = "0.1.0"
## source = "file:packages/greet" # relative to the registry file
## description = "Hello helpers"
##
## [[package]]
## name = "net"
## version = "1.2.0"
## source = "https://github.com/bux-lang/net.git"
##
## Lookup order for the index file:
## 1. $BUX_REGISTRY (file path)
## 2. ~/.bux/registry.toml
## 3. <repo>/config/registry.toml next to the compiler / cwd
import std/[os, strutils, strformat, algorithm]
type
RegistryPackage* = object
name*: string
version*: string
source*: string ## raw source as written in the index
description*: string
resolvedPath*: string ## absolute path for file: sources (filled on load)
Registry* = object
path*: string ## index file path
packages*: seq[RegistryPackage]
proc resolvePackageSource(pkg: var RegistryPackage, indexDir: string) =
if pkg.source.startsWith("file:"):
var p = pkg.source["file:".len .. ^1]
if p.startsWith("//"):
p = p[2 .. ^1]
if not p.isAbsolute:
p = indexDir / p
pkg.resolvedPath = p.absolutePath
elif pkg.source.startsWith("path:"):
var p = pkg.source["path:".len .. ^1]
if not p.isAbsolute:
p = indexDir / p
pkg.resolvedPath = p.absolutePath
pkg.source = "file:" & pkg.resolvedPath
proc parseRegistryToml(content, indexPath: string): seq[RegistryPackage] =
## Minimal parser for repeated [[package]] blocks with string keys.
result = @[]
var cur: RegistryPackage
var inPkg = false
let indexDir = indexPath.parentDir
for raw in content.splitLines():
let line = raw.strip()
if line.len == 0 or line.startsWith("#"):
continue
if line == "[[package]]" or line == "[[Package]]":
if inPkg and cur.name.len > 0:
resolvePackageSource(cur, indexDir)
result.add(cur)
cur = RegistryPackage()
inPkg = true
continue
if not inPkg:
continue
let eq = line.find('=')
if eq < 0: continue
let key = line[0 ..< eq].strip().toLowerAscii()
var val = line[eq + 1 .. ^1].strip()
if val.len >= 2 and val[0] == '"' and val[^1] == '"':
val = val[1 ..< ^1]
case key
of "name": cur.name = val
of "version": cur.version = val
of "source": cur.source = val
of "description": cur.description = val
else: discard
if inPkg and cur.name.len > 0:
resolvePackageSource(cur, indexDir)
result.add(cur)
proc findRegistryIndex*(): string =
## Locate the registry index file.
let env = getEnv("BUX_REGISTRY")
if env.len > 0 and fileExists(env):
return env.absolutePath
let homeIdx = getHomeDir() / ".bux" / "registry.toml"
if fileExists(homeIdx):
return homeIdx
let candidates = @[
getAppDir() / ".." / "config" / "registry.toml",
getAppDir() / "config" / "registry.toml",
getCurrentDir() / "config" / "registry.toml",
getCurrentDir() / ".." / "config" / "registry.toml",
]
for c in candidates:
if fileExists(c):
return c.absolutePath
return ""
proc loadRegistry*(path: string = ""): Registry =
result.path = if path.len > 0: path else: findRegistryIndex()
result.packages = @[]
if result.path.len == 0 or not fileExists(result.path):
return
try:
let content = readFile(result.path)
result.packages = parseRegistryToml(content, result.path)
except CatchableError:
result.packages = @[]
proc registryLookup*(reg: Registry, name: string, versionReq: string = "*"): RegistryPackage =
## Find a package by name. versionReq `*` picks the last matching entry
## (index order; put newest last). Exact version matches preferred.
result = RegistryPackage()
var candidates: seq[RegistryPackage] = @[]
for p in reg.packages:
if p.name.toLowerAscii() == name.toLowerAscii():
candidates.add(p)
if candidates.len == 0:
return
if versionReq.len == 0 or versionReq == "*":
return candidates[^1]
for p in candidates:
if p.version == versionReq:
return p
# Semver prefix match: "1" matches "1.0.0"
for p in candidates:
if p.version.startsWith(versionReq):
return p
return candidates[^1]
proc registrySearch*(reg: Registry, query: string): seq[RegistryPackage] =
result = @[]
let q = query.toLowerAscii()
for p in reg.packages:
if q.len == 0 or
q in p.name.toLowerAscii() or
q in p.description.toLowerAscii():
result.add(p)
result.sort(proc (a, b: RegistryPackage): int =
cmp(a.name.toLowerAscii(), b.name.toLowerAscii()))
proc isGitSource*(source: string): bool =
source.startsWith("http://") or source.startsWith("https://") or
source.startsWith("git@") or source.startsWith("git://") or
source.startsWith("ssh://")
proc isFileSource*(source: string): bool =
source.startsWith("file:") or source.startsWith("path:")
proc formatRegistryDepLine*(name: string, pkg: RegistryPackage): string =
## Produce a bux.toml Dependencies line for a resolved registry package.
if pkg.resolvedPath.len > 0 and dirExists(pkg.resolvedPath):
return &"{name} = {{ Path = \"{pkg.resolvedPath}\" }}"
if isGitSource(pkg.source):
let ver = if pkg.version.len > 0: pkg.version else: "*"
return &"{name} = {{ Version = \"{ver}\", Source = \"{pkg.source}\" }}"
if isFileSource(pkg.source) and pkg.resolvedPath.len > 0:
return &"{name} = {{ Path = \"{pkg.resolvedPath}\" }}"
# Fallback: version-only (install will re-resolve)
let ver = if pkg.version.len > 0: pkg.version else: "*"
return &"{name} = \"{ver}\""
+178 -5
View File
@@ -51,6 +51,11 @@ type
## When true, ekIdent skips use-while-borrowed (we're forming `&x` itself)
suppressUseWhileBorrow*: bool
currentRetType*: Type ## return type of the function being checked
## Lifetime elision / ref-origin tracking (@[Checked] only)
## Binding name → lifetime id ("'a", "#elided0", "#local", …)
varRefLifetime*: Table[string, string]
## Expected lifetime of the function's returned reference ("" if ret is not a ref)
returnLifetime*: string
closureDepth*: int ## nesting depth inside closures
currentClosureExpr*: Expr ## current closure being analyzed
closureScope*: Scope ## scope at which the current closure was entered
@@ -164,6 +169,138 @@ proc checkTempMutBorrow(sema: var Sema, varName: string, loc: SourceLocation) =
elif sema.activeSharedBorrows.getOrDefault(varName, 0) > 0:
sema.emitError(loc, &"cannot mutably borrow '{varName}' while it is shared-borrowed")
# ---------------------------------------------------------------------------
# Lifetime elision (C.1) — Rust-style simple rules for @[Checked]
# ---------------------------------------------------------------------------
#
# Rules (common cases, no annotations required):
# 1. Each elided input reference (&T / &mut T param) gets a distinct lifetime.
# 2. If there is exactly one input lifetime, it is assigned to all elided outputs.
# 3. If the first param is `self` / `Self`, its lifetime is preferred for outputs.
# 4. Multiple input refs + elided return → error (need explicit `'a`).
# 5. Returning a reference derived from a local (or by-value param) is rejected.
#
const
LifetimeLocal* = "#local" ## ref derived from a local / by-value place
LifetimeOutNone* = "#out" ## return ref with no input to borrow from
LifetimeAmbiguous* = "#ambiguous"
proc isRefTypeExpr(te: TypeExpr): bool =
te != nil and te.kind in {tekRef, tekMutRef}
proc applyLifetimeElision*(sema: var Sema, decl: Decl) =
## Assign elided lifetimes for ref params/return of `decl`. Populates
## `varRefLifetime` (params) and `returnLifetime`.
sema.varRefLifetime = initTable[string, string]()
sema.returnLifetime = ""
if not sema.checkedFunc:
return
var inputLts: seq[string] = @[]
var anon = 0
for p in decl.declFuncParams:
if not isRefTypeExpr(p.ptype):
continue
var lt = p.ptype.refLifetime
if lt.len == 0:
lt = "#elided" & $anon
inc anon
inputLts.add(lt)
sema.varRefLifetime[p.name] = lt
let ret = decl.declFuncReturnType
if not isRefTypeExpr(ret):
return
var rlt = ret.refLifetime
if rlt.len == 0:
if inputLts.len == 1:
rlt = inputLts[0]
elif inputLts.len == 0:
rlt = LifetimeOutNone
elif decl.declFuncParams.len > 0 and
decl.declFuncParams[0].name in ["self", "Self"]:
rlt = inputLts[0]
else:
sema.emitError(decl.loc,
"lifetime elision failed: return type needs an explicit lifetime " &
"(multiple input references); e.g. func F<'a>(a: &'a T, b: &'a U) -> &'a T")
rlt = LifetimeAmbiguous
sema.returnLifetime = rlt
proc exprRefLifetime*(sema: Sema, expr: Expr, scope: Scope): string =
## Best-effort lifetime of a reference-producing expression.
if expr == nil:
return ""
case expr.kind
of ekIdent:
if sema.varRefLifetime.hasKey(expr.exprIdent):
return sema.varRefLifetime[expr.exprIdent]
return ""
of ekUnary:
if expr.exprUnaryOp == tkAmp:
let name = extractBorrowedIdent(expr)
if name.len == 0:
return LifetimeLocal
# Reborrow of an existing ref binding keeps its lifetime
if sema.varRefLifetime.hasKey(name):
return sema.varRefLifetime[name]
# Address-of a by-value local or by-value parameter → local (dangling if returned)
return LifetimeLocal
# Dereference: *r still carries r's lifetime for field/ref purposes
if expr.exprUnaryOp == tkStar:
return sema.exprRefLifetime(expr.exprUnaryOperand, scope)
return ""
of ekBorrow:
# `borrow &x` / `borrow &mut x` — same origin rules as unary &
if expr.exprBorrowOperand != nil:
return sema.exprRefLifetime(expr.exprBorrowOperand, scope)
return LifetimeLocal
of ekField:
# Field projection through a ref keeps the base lifetime: (*p).x or p.x
if expr.exprFieldObj != nil:
let baseLt = sema.exprRefLifetime(expr.exprFieldObj, scope)
if baseLt.len > 0:
return baseLt
# Base is an ident of a struct local — field address would be local
if expr.exprFieldObj.kind == ekIdent:
if sema.varRefLifetime.hasKey(expr.exprFieldObj.exprIdent):
return sema.varRefLifetime[expr.exprFieldObj.exprIdent]
return LifetimeLocal
return ""
else:
return ""
proc checkReturnLifetime*(sema: var Sema, retExpr: Expr, scope: Scope, loc: SourceLocation) =
## Reject dangling returns and explicit lifetime mismatches in @[Checked].
if not sema.checkedFunc or sema.returnLifetime.len == 0 or retExpr == nil:
return
let got = sema.exprRefLifetime(retExpr, scope)
if sema.returnLifetime == LifetimeOutNone:
sema.emitError(loc,
"cannot return a reference: function has no input reference to borrow from")
return
if got == LifetimeLocal:
sema.emitError(loc, "cannot return reference to local variable")
return
if got.len == 0:
# Non-trivial expression (call, etc.) — leave for later analysis
return
if got == LifetimeAmbiguous or sema.returnLifetime == LifetimeAmbiguous:
return
# Explicit lifetime mismatch (both sides named with ')
if got.startsWith("'") and sema.returnLifetime.startsWith("'") and got != sema.returnLifetime:
sema.emitError(loc,
&"lifetime mismatch: returning '{got}' but function returns '{sema.returnLifetime}'")
return
# Distinct elided inputs returned into another elided input's return slot
if got.startsWith("#elided") and sema.returnLifetime.startsWith("#elided") and
got != sema.returnLifetime:
sema.emitError(loc,
"lifetime mismatch: returned reference does not outlive the return type " &
"(multiple input references; annotate with an explicit lifetime)")
# ---------------------------------------------------------------------------
# Generic type inference helpers
# ---------------------------------------------------------------------------
@@ -472,7 +609,7 @@ proc inferTypeArgs(sema: var Sema, funcDecl: Decl, argTypes: seq[Type],
# Type resolution from AST TypeExpr
# ---------------------------------------------------------------------------
proc resolveType(sema: var Sema, te: TypeExpr): Type =
proc resolveType*(sema: var Sema, te: TypeExpr): Type =
if te == nil:
return makeUnknown()
case te.kind
@@ -918,7 +1055,7 @@ proc collectGlobals*(sema: var Sema) =
# Expression type checking
# ---------------------------------------------------------------------------
proc checkExpr(sema: var Sema, expr: Expr, scope: Scope): Type
proc checkExpr*(sema: var Sema, expr: Expr, scope: Scope): Type
proc checkStmt(sema: var Sema, stmt: Stmt, scope: Scope): Type
proc typeImplements(sema: Sema, t: Type, interfaceName: string): bool =
@@ -1099,7 +1236,7 @@ proc resolveCallArgs(sema: var Sema, expr: Expr, calleeDecl: Decl, scope: Scope)
expr.exprCallArgs = newArgs
expr.exprCallArgNames = newNames
proc checkExpr(sema: var Sema, expr: Expr, scope: Scope): Type =
proc checkExpr*(sema: var Sema, expr: Expr, scope: Scope): Type =
if expr == nil:
return makeUnknown()
case expr.kind
@@ -1835,6 +1972,11 @@ proc checkStmt(sema: var Sema, stmt: Stmt, scope: Scope): Type =
# Untyped let + `&x` is typed as &mut by unary lowering
isMut = initType.isMutRef
sema.checkCreateBorrow(bname, isMut, stmt.stmtLetInit.loc)
# Propagate ref lifetime to the new binding (for return-site checks)
if declaredType.isRef or declaredType.isMutRef or initType.isRef or initType.isMutRef:
let lt = sema.exprRefLifetime(stmt.stmtLetInit, scope)
if lt.len > 0:
sema.varRefLifetime[stmt.stmtLetName] = lt
return makeVoid()
of skIf:
let condType = sema.checkExpr(stmt.stmtIfCond, scope)
@@ -1897,6 +2039,8 @@ proc checkStmt(sema: var Sema, stmt: Stmt, scope: Scope): Type =
let retSym = scope.lookup(stmt.stmtReturnValue.exprIdent)
if retSym != nil and retSym.isOwn:
sema.movedVars.add(stmt.stmtReturnValue.exprIdent)
# Lifetime: reject dangling returns / explicit mismatches
sema.checkReturnLifetime(stmt.stmtReturnValue, scope, stmt.loc)
return makeVoid()
of skBreak, skContinue:
return makeVoid()
@@ -1951,9 +2095,15 @@ proc checkStmt(sema: var Sema, stmt: Stmt, scope: Scope): Type =
proc checkFunc(sema: var Sema, decl: Decl) =
if decl.declFuncBody == nil:
return
# Skip body type-checking for generic functions — their bodies contain
# Skip body type-checking for type-generic functions — their bodies contain
# type parameters that cannot be fully resolved until monomorphization.
if decl.declFuncTypeParams.len > 0:
# Lifetime-only params (`'a`) are fine: we still check the body for elision.
var hasTypeGeneric = false
for tp in decl.declFuncTypeParams:
if not tp.isLifetime:
hasTypeGeneric = true
break
if hasTypeGeneric:
return
let wasChecked = sema.checkedFunc
let wasAsync = sema.currentFuncIsAsync
@@ -1963,10 +2113,18 @@ proc checkFunc(sema: var Sema, decl: Decl) =
sema.movedVars = @[]
sema.activeMutBorrows = initTable[string, SourceLocation]()
sema.activeSharedBorrows = initTable[string, int]()
# C.1: elide lifetimes on params / return before walking the body
sema.applyLifetimeElision(decl)
else:
sema.varRefLifetime = initTable[string, string]()
sema.returnLifetime = ""
var funcScope = newScope(sema.globalScope)
# Add type parameters to type table for resolution
var addedTypeParams: seq[string] = @[]
for tp in decl.declFuncTypeParams:
if tp.isLifetime:
# Lifetime params are not types; skip typeTable
continue
sema.typeTable[tp.name] = makeTypeParam(tp.name)
addedTypeParams.add(tp.name)
# Add parameters
@@ -1982,6 +2140,8 @@ proc checkFunc(sema: var Sema, decl: Decl) =
sema.typeTable.del(tp)
sema.checkedFunc = wasChecked
sema.currentFuncIsAsync = wasAsync
sema.varRefLifetime = initTable[string, string]()
sema.returnLifetime = ""
# ---------------------------------------------------------------------------
# Second pass: check all function bodies
@@ -2026,3 +2186,16 @@ proc analyzeFull*(modu: Module): tuple[result: SemaResult, sema: Sema] =
sema.collectGlobals()
sema.checkBodies()
result = (SemaResult(diagnostics: sema.diagnostics), sema)
proc checkExprForLsp*(sema: var Sema, expr: Expr, scope: Scope): Type =
## Type-check an expression for IDE use (no borrow/move side effects).
let wasChecked = sema.checkedFunc
let savedMoved = sema.movedVars
let savedMut = sema.activeMutBorrows
let savedShared = sema.activeSharedBorrows
sema.checkedFunc = false
result = sema.checkExpr(expr, scope)
sema.checkedFunc = wasChecked
sema.movedVars = savedMoved
sema.activeMutBorrows = savedMut
sema.activeSharedBorrows = savedShared