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:
+334
-26
@@ -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":
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user