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 import macroexpand type ColorMode* = enum cmAuto cmOn cmOff GlobalOptions* = object color*: ColorMode quiet*: bool verbose*: bool release*: bool ## --release: -O2, no -g / no #line (E.4 dual) proc printUsage*() = echo """Bux Programming Language (bootstrap compiler) Usage: bux [options] [command-options] Commands: new Create a new Bux package init Initialize a Bux package in the current directory add [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 Only run tests whose name contains fmt --check Exit 1 if any file would be reformatted (CI) doc --out Write docs to file (default: stdout) add --path / --git Explicit source; else resolve via registry build --release Optimized build (-O2, no debug / #line) Registry: BUX_REGISTRY Local path or http(s):// URL to registry.toml BUX_REGISTRY_REFRESH=1 Force re-download of HTTP index cache BUX_CFLAGS Extra flags appended to the C compiler line Global options: --color Control colored output (default: auto) -q, --quiet Suppress non-error output -v, --verbose Verbose output --release Optimize (-O2), omit -g and #line maps """ proc parseGlobalOptions(args: seq[string]): tuple[opts: GlobalOptions, rest: seq[string], ok: bool] = result.opts = GlobalOptions(color: cmAuto, quiet: false, verbose: false, release: false) result.rest = @[] result.ok = true var i = 0 while i < args.len: let arg = args[i] if arg == "--color": if i + 1 >= args.len: stderr.writeLine("error: --color requires an argument") result.ok = false return inc i case args[i].toLowerAscii() of "auto": result.opts.color = cmAuto of "on": result.opts.color = cmOn of "off": result.opts.color = cmOff else: stderr.writeLine(&"error: unknown --color value '{args[i]}'") result.ok = false return elif arg == "-q" or arg == "--quiet": result.opts.quiet = true elif arg == "-v" or arg == "--verbose": result.opts.verbose = true elif arg == "--release": result.opts.release = true else: result.rest.add(arg) inc i proc shouldUseColor(opts: GlobalOptions): bool = case opts.color of cmOn: true of cmOff: false of cmAuto: terminal.isatty(stdout) proc printError(msg: string, useColor: bool) = if useColor: stdout.setForegroundColor(fgRed) stdout.write("error: ") stdout.resetAttributes() stdout.writeLine(msg) else: stderr.writeLine("error: " & msg) proc printInfo(msg: string, useColor: bool) = if useColor: stdout.setForegroundColor(fgCyan) stdout.write("info: ") stdout.resetAttributes() stdout.writeLine(msg) else: echo("info: " & msg) # --------------------------------------------------------------------------- # Rust-style diagnostics (snippet + optional help hint) # --------------------------------------------------------------------------- proc getSourceLine(path: string, lineNum: uint32): string = ## Read a single 1-based line from path. Empty if unavailable. if path.len == 0 or lineNum == 0 or not fileExists(path): return "" try: let content = readFile(path) var n: uint32 = 1 for line in content.splitLines(): if n == lineNum: return line inc n except CatchableError: discard return "" proc extractQuotedName(msg: string): string = ## Pull the first 'name' from messages like: undeclared identifier 'foo' let a = msg.find('\'') if a < 0: return "" let b = msg.find('\'', a + 1) if b <= a + 1: return "" return msg[a + 1 .. b - 1] proc underlineLength(lineText: string, col: uint32, message: string): int = ## Multi-character underline under the token at `col` (1-based). ## Falls back to scanning a source token, or matching a quoted name in the message. if lineText.len == 0: return 1 let start = if col > 0: int(col) - 1 else: 0 if start < 0 or start >= lineText.len: return 1 # Prefer highlighting the quoted identifier/token from the message when it # appears on this line (e.g. undeclared identifier 'foo'). let quoted = extractQuotedName(message) if quoted.len > 0: let idx = lineText.find(quoted) if idx >= 0: # If caret is on/near that token, use its full length if abs(idx - start) <= quoted.len: return quoted.len let c0 = lineText[start] # String / char / backtick literals if c0 == '"' or c0 == '\'' or c0 == '`': let quote = c0 var i = start + 1 while i < lineText.len: if lineText[i] == '\\' and i + 1 < lineText.len: i += 2 continue if lineText[i] == quote: return i - start + 1 inc i return max(1, lineText.len - start) # Identifier or keyword if c0.isAlphaAscii or c0 == '_': var i = start while i < lineText.len and (lineText[i].isAlphaNumeric or lineText[i] == '_'): inc i return max(1, i - start) # Number literal if c0.isDigit: var i = start while i < lineText.len and (lineText[i].isDigit or lineText[i] in {'.', 'x', 'X', 'b', 'B', 'o', 'O', 'a'..'f', 'A'..'F', '_'}): inc i # optional type suffix: 42i64, 1.0f while i < lineText.len and lineText[i] in {'i', 'u', 'f', 'I', 'U', 'F', '0'..'9'}: inc i return max(1, i - start) # Multi-char operators starting at caret const multiOps = ["<<=", ">>=", "**", "++", "--", "==", "!=", "<=", ">=", "&&", "||", "<<", ">>", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "=>", "..", "->"] for op in multiOps: if start + op.len <= lineText.len and lineText[start .. start + op.len - 1] == op: return op.len return 1 proc hintForMessage(msg: string): string = ## Actionable help text for common compiler errors. let m = msg.toLowerAscii() if "while it is mutably borrowed" in m: return "only one active '&mut' borrow is allowed at a time; end the borrow before reuse" if "cannot assign" in m: return "ensure the right-hand side type matches the left-hand side" if "undeclared identifier" in m: return "check the spelling, or import the symbol from the right module" if "too few arguments" in m or "too many arguments" in m: return "compare the call with the function's parameter list" if "missing argument for parameter" in m: 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: return "only one active '&mut' borrow is allowed at a time; end the borrow before reuse" if "shared-borrow" in m or "shared-borrowed" in m: return "exclusive '&mut' and shared '&' cannot overlap on the same variable" if "expected expression" in m: return "the previous statement may be incomplete (missing value or ';')" if "expected type" in m: return "write a type name such as 'int', 'String', or 'Array'" if "expected field name" in m: return "after '.' use an identifier or a tuple index (.0, .1, ...)" if "does not implement trait" in m: return "add an 'extend Type for Trait { ... }' block, or pick another type" if "duplicate symbol" in m: return "rename one of the definitions or remove the duplicate" if "unterminated" in m: return "check for a missing closing quote, backtick, or comment delimiter" return "" proc printDiagnostic*(severity: string, message: string, loc: SourceLocation, useColor: bool, fallbackFile: string = "") = ## Print a Rust-style diagnostic: ## error: message ## --> file:line:col ## | ## 42 | source line ## | ^ ## = help: hint let isError = severity == "error" if useColor: stdout.setForegroundColor(if isError: fgRed else: fgYellow) stdout.write(severity & ": ") stdout.resetAttributes() stdout.writeLine(message) else: let stream = if isError: stderr else: stdout stream.writeLine(severity & ": " & message) let file = if loc.file.len > 0: loc.file else: fallbackFile if loc.line > 0: let locStr = if file.len > 0: &"{file}:{loc.line}:{loc.column}" else: &"{loc.line}:{loc.column}" if useColor: stdout.setForegroundColor(fgCyan) stdout.write(" --> ") stdout.resetAttributes() stdout.writeLine(locStr) else: stderr.writeLine(" --> " & locStr) let lineText = getSourceLine(file, loc.line) if lineText.len > 0: let gutter = $loc.line let pad = " ".repeat(max(gutter.len, 3)) # If message names a quoted token, prefer caret at that token's start var col = loc.column let quoted = extractQuotedName(message) if quoted.len > 0: let idx = lineText.find(quoted) if idx >= 0: col = uint32(idx + 1) let ulen = underlineLength(lineText, col, message) stdout.writeLine(pad & " |") stdout.writeLine(" " & gutter & " | " & lineText) # Multi-char underline under the token (1-based column) var caretPad = "" if col > 0: caretPad = " ".repeat(int(col) - 1) let marks = "^".repeat(max(1, ulen)) stdout.writeLine(pad & " | " & caretPad & marks) let hint = hintForMessage(message) if hint.len > 0: if useColor: stdout.setForegroundColor(fgGreen) stdout.write(" = help: ") stdout.resetAttributes() stdout.writeLine(hint) else: stdout.writeLine(" = help: " & hint) proc printLexerDiags(diags: seq[LexerDiagnostic], useColor: bool, fallbackFile = "") = for d in diags: let sev = if d.severity == ldsError: "error" else: "warning" printDiagnostic(sev, d.message, d.loc, useColor, fallbackFile) proc printParserDiags(diags: seq[ParserDiagnostic], useColor: bool, fallbackFile = "") = for d in diags: let sev = if d.severity == pdsError: "error" else: "warning" printDiagnostic(sev, d.message, d.loc, useColor, fallbackFile) proc printSemaDiags(diags: seq[SemaDiagnostic], useColor: bool, fallbackFile = "") = for d in diags: let sev = if d.severity == sdsError: "error" else: "warning" printDiagnostic(sev, d.message, d.loc, useColor, fallbackFile) # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- proc cmdNew*(args: seq[string], opts: GlobalOptions): int = let useColor = shouldUseColor(opts) if args.len < 1: printError("'new' requires a package name", useColor) return 1 let name = args[0] let root = getCurrentDir() / name if dirExists(root): printError(&"directory '{name}' already exists", useColor) return 1 createDir(root / "src") writeFile(root / "bux.toml", &"""[Package] Name = "{name}" Version = "0.1.0" Type = "bin" [Build] Output = "Bin" """) writeFile(root / "src" / "Main.bux", """import Std::Io::PrintLine; func Main() -> int { PrintLine(c8"Hello, Bux!"); return 0; } """) if not opts.quiet: printInfo(&"Created Bux package '{name}'", useColor) return 0 proc cmdInit*(args: seq[string], opts: GlobalOptions): int = let useColor = shouldUseColor(opts) let root = getCurrentDir() if fileExists(root / "bux.toml"): printError("bux.toml already exists", useColor) return 1 let name = splitPath(root).tail writeFile(root / "bux.toml", &"""[Package] Name = "{name}" Version = "0.1.0" Type = "bin" [Build] Output = "Bin" """) if not dirExists(root / "src"): createDir(root / "src") if not opts.quiet: printInfo(&"Initialized Bux package '{name}'", useColor) return 0 proc cmdAdd*(args: seq[string], opts: GlobalOptions): int = let useColor = shouldUseColor(opts) let root = getCurrentDir() let manifestPath = root / "bux.toml" if not fileExists(manifestPath): printError("no bux.toml found", useColor) return 1 if args.len == 0: printError("usage: bux add [version] [--path ] [--git ]", useColor) return 1 let depName = args[0] var version = "*" var path = "" var gitUrl = "" var i = 1 while i < args.len: case args[i] of "--path": if i + 1 < args.len: path = args[i + 1] inc i else: printError("--path requires a value", useColor) return 1 of "--git": if i + 1 < args.len: gitUrl = args[i + 1] inc i else: printError("--git requires a value", useColor) return 1 else: 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 = "" if path.len > 0: depLine = &"{depName} = {{ Path = \"{path}\" }}" elif gitUrl.len > 0: depLine = &"{depName} = {{ Version = \"{version}\", Source = \"{gitUrl}\" }}" else: # Registry resolve (E.1 + HTTP URL) let reg = loadRegistry() if reg.path.len == 0: if reg.sourceUrl.len > 0: printError(&"failed to fetch registry from {reg.sourceUrl}", useColor) printError("hint: check network, curl/wget, or set BUX_REGISTRY to a local file", useColor) else: 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 | bux add name --path ", useColor) return 1 depLine = formatRegistryDepLine(depName, pkg) if not opts.quiet: let src = if reg.sourceUrl.len > 0: reg.sourceUrl else: reg.path printInfo(&"Resolved '{depName}' {pkg.version} from registry {src}", useColor) var content = readFile(manifestPath) # Ensure [Dependencies] section exists if content.find("[Dependencies]") < 0: content.add("\n[Dependencies]\n") # Append dependency line content.add(depLine & "\n") writeFile(manifestPath, content) if not opts.quiet: 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: if reg.sourceUrl.len > 0: printError(&"failed to fetch registry from {reg.sourceUrl}", useColor) printError("hint: need curl or wget; or set BUX_REGISTRY to a local file", useColor) else: printError("no package registry found (set BUX_REGISTRY path or http(s) URL)", useColor) return 1 if not opts.quiet: if reg.sourceUrl.len > 0: echo &"Registry: {reg.sourceUrl}" echo &" (cached: {reg.path})" else: 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() let manifestPath = root / "bux.toml" if not fileExists(manifestPath): printError("no bux.toml found", useColor) return 1 let man = loadManifest(manifestPath) var lock = Lockfile(entries: @[]) 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 of dkPath: let absPath = if dep.path.isAbsolute: dep.path else: root / dep.path if not dirExists(absPath): printError(&"path dependency not found: {absPath}", useColor) return 1 # Read dependency manifest let depManifestPath = absPath / "bux.toml" if fileExists(depManifestPath): let depMan = loadManifest(depManifestPath) lock.entries.add(LockEntry(name: dep.name, version: depMan.version, source: absPath)) else: lock.entries.add(LockEntry(name: dep.name, version: "0.0.0", source: absPath)) if not opts.quiet: printInfo(&"Resolved path dependency '{dep.name}' from {absPath}", useColor) of dkGit: let depDir = cacheDir / dep.name if not dirExists(depDir): if not opts.quiet: printInfo(&"Cloning '{dep.name}' from {dep.gitUrl}...", useColor) 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: # 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) if not opts.quiet: printInfo(&"Generated {lockPath}", useColor) return 0 proc collectStdlibDecls(stdlibDir: string): seq[Decl] proc getDeclName(d: Decl): string proc mergeDecls(stdlibDecls: seq[Decl], userDecls: seq[Decl]): seq[Decl] proc collectDepDecls(lock: Lockfile, root: string, opts: GlobalOptions): seq[Decl] proc findStdlibDir(root: string): string = let searchPaths = @[ getAppDir() / ".." / "lib", getAppDir() / "lib", root / "lib", ] for path in searchPaths: if dirExists(path): return path return "" type ProjectContext = object root: string man: Manifest stdlibDir: string stdlibDecls: seq[Decl] depDecls: seq[Decl] allModuleItems: seq[Decl] hasMain: bool proc prepareProject(root: string, useColor: bool, opts: GlobalOptions): (ProjectContext, int) = var pctx: ProjectContext pctx.root = root let manifestPath = root / "bux.toml" if not fileExists(manifestPath): printError("no bux.toml found", useColor) return (pctx, 1) pctx.man = loadManifest(manifestPath) let srcDir = root / "src" if not dirExists(srcDir): printError("no src/ directory found", useColor) return (pctx, 1) pctx.stdlibDir = findStdlibDir(root) pctx.stdlibDecls = collectStdlibDecls(pctx.stdlibDir) let lock = loadLockfile(root / "bux.lock") pctx.depDecls = collectDepDecls(lock, root, opts) pctx.allModuleItems = @[] pctx.hasMain = false for kind, path in walkDir(srcDir): if kind == pcFile and path.endsWith(".bux"): let source = readFile(path) let lexRes = tokenize(source, path) if lexRes.hasErrors: printError(&"lex errors in {path}", useColor) printLexerDiags(lexRes.diagnostics, useColor, path) return (pctx, 1) let parseRes = parse(lexRes.tokens, path) if parseRes.diagnostics.len > 0: printError(&"parse errors in {path}", useColor) printParserDiags(parseRes.diagnostics, useColor, path) return (pctx, 1) for decl in parseRes.module.items: if decl.kind == dkModule: for sub in decl.declModuleItems: pctx.allModuleItems.add(sub) else: pctx.allModuleItems.add(decl) if splitFile(path).name == "Main": pctx.hasMain = true if not pctx.hasMain: printError("no Main.bux found in src/", useColor) return (pctx, 1) return (pctx, 0) proc mergeProject(pctx: ProjectContext): Module = let stdlibAndDeps = mergeDecls(pctx.stdlibDecls, pctx.depDecls) let mergedItems = mergeDecls(stdlibAndDeps, pctx.allModuleItems) var unifiedModule = newModule("main") unifiedModule.items = mergedItems return unifiedModule proc cmdCheck*(args: seq[string], opts: GlobalOptions): int = let useColor = shouldUseColor(opts) let root = if args.len > 0: absolutePath(args[0]) else: getCurrentDir() let (pctx, status) = prepareProject(root, useColor, opts) if status != 0: return status let unifiedModule = mergeProject(pctx) let macRes = expandMacros(unifiedModule) if macRes.diagnostics.len > 0: printError("macro expansion errors", useColor) for d in macRes.diagnostics: printDiagnostic("error", d.message, d.loc, useColor) return 1 let semaRes = analyze(unifiedModule) if semaRes.hasErrors: printError("type errors in project", useColor) printSemaDiags(semaRes.diagnostics, useColor) return 1 if not opts.quiet: printInfo("check passed", useColor) return 0 proc collectStdlibDecls(stdlibDir: string): seq[Decl] = result = @[] if not dirExists(stdlibDir): return for path in walkDirRec(stdlibDir): if path.endsWith(".bux"): let source = readFile(path) let lexRes = tokenize(source, path) if lexRes.hasErrors: continue let parseRes = parse(lexRes.tokens, path) if parseRes.diagnostics.len > 0: continue for item in parseRes.module.items: if item.kind == dkModule: for sub in item.declModuleItems: result.add(sub) else: result.add(item) proc getDeclName(d: Decl): string = case d.kind of dkFunc: d.declFuncName of dkExternFunc: d.declExtFuncName of dkStruct: d.declStructName of dkEnum: d.declEnumName of dkUnion: d.declUnionName of dkInterface: d.declInterfaceName of dkConst: d.declConstName of dkTypeAlias: d.declAliasName of dkMacro: d.declMacroName else: "" proc collectDepDecls(lock: Lockfile, root: string, opts: GlobalOptions): seq[Decl] = ## Collect declarations from all locked dependencies. let cacheDir = getHomeDir() / ".bux" / "packages" let useColor = shouldUseColor(opts) for entry in lock.entries: var depSrcDir = "" if dirExists(entry.source): # Path-based dependency depSrcDir = entry.source / "src" elif entry.source.startsWith("http") or entry.source.startsWith("git@"): # Git-based dependency in cache depSrcDir = cacheDir / entry.name / "src" if depSrcDir == "" or not dirExists(depSrcDir): continue for kind, path in walkDir(depSrcDir): if kind == pcFile and path.endsWith(".bux"): let source = readFile(path) let lexRes = tokenize(source, path) if lexRes.hasErrors: continue let parseRes = parse(lexRes.tokens, path) if parseRes.diagnostics.len > 0: continue for decl in parseRes.module.items: if decl.kind == dkModule: for sub in decl.declModuleItems: result.add(sub) else: result.add(decl) if not opts.quiet: printInfo(&"Loaded dependency '{entry.name}' from {depSrcDir}", useColor) proc mergeDecls(stdlibDecls: seq[Decl], userDecls: seq[Decl]): seq[Decl] = ## Merge stdlib and user declarations. ## User funcs shadow stdlib funcs with the same name (simple overload avoidance). var userNames: HashSet[string] for d in userDecls: let name = getDeclName(d) if name != "": userNames.incl(name) result = @[] for d in stdlibDecls: let name = getDeclName(d) if name == "" or name notin userNames: result.add(d) for d in userDecls: result.add(d) proc cmdBuild*(args: seq[string], opts: GlobalOptions): int = var opts = opts var pathArgs: seq[string] = @[] for a in args: if a == "--release": opts.release = true elif a.startsWith("-"): # ignore unknown flags for forward-compat; keep path-like later discard else: pathArgs.add(a) let useColor = shouldUseColor(opts) let root = if pathArgs.len > 0: absolutePath(pathArgs[0]) else: getCurrentDir() let (pctx, status) = prepareProject(root, useColor, opts) if status != 0: return status # Create build directory let buildDir = root / "build" if not dirExists(buildDir): createDir(buildDir) let unifiedModule = mergeProject(pctx) # Phase 2b: expand declarative macro! / quote! before type checking let macRes = expandMacros(unifiedModule) if macRes.diagnostics.len > 0: printError("macro expansion errors", useColor) for d in macRes.diagnostics: printDiagnostic("error", d.message, d.loc, useColor) return 1 # Phase 3: Sema + HIR + C codegen on unified module let (semaRes, semaCtx) = analyzeFull(unifiedModule) if semaRes.hasErrors: printError("type errors in project", useColor) printSemaDiags(semaRes.diagnostics, useColor) return 1 let hirMod = lowerModule(unifiedModule, semaCtx) let lirBuilder = lowerModuleToLir(hirMod) # Debug builds: #line maps into .bux for gdb. Release: skip maps + -O2. var lirCbe = initLirCBackend(emitDebugLines = not opts.release) var allCCode = lirCbe.emitModule(lirBuilder, hirMod) # Write C file let cFile = buildDir / "main.c" writeFile(cFile, allCCode) # Copy runtime files (rt/ is sibling of lib/) let stdlibDir = pctx.stdlibDir let runtimeDst = buildDir / "runtime.c" let ioDst = buildDir / "io.c" if stdlibDir == "": printError("stdlib directory not found", useColor) return 1 let baseDir = stdlibDir.parentDir() let runtimeSrc = baseDir / "rt" / "runtime.c" if fileExists(runtimeSrc): copyFile(runtimeSrc, runtimeDst) else: printError("runtime.c not found in rt/", useColor) return 1 let ioSrc = baseDir / "rt" / "io.c" if fileExists(ioSrc): copyFile(ioSrc, ioDst) else: printError("io.c not found in rt/", useColor) return 1 # Compile with cc — debug default (-O0 -g) or --release (-O2) let outputName = if pctx.man.name != "": pctx.man.name else: "bux_out" let outputFile = buildDir / outputName let optFlags = if opts.release: "-O2 -DNDEBUG" else: "-O0 -g" let extraCflags = getEnv("BUX_CFLAGS") let cflags = if extraCflags.len > 0: optFlags & " " & extraCflags else: optFlags # --build-id is GNU ld only (breaks Apple ld). Reproducible selfhost-loop uses Linux CI. let ldStable = when defined(linux): " -Wl,--build-id=none" else: "" let ccCmd = &"cc {cflags} -pthread{ldStable} -o {outputFile} {cFile} {runtimeDst} {ioDst} -lm -lcrypto 2>&1" if opts.verbose: printInfo(&"running: {ccCmd}", useColor) let (output, exitCode) = execCmdEx(ccCmd) if exitCode != 0: printError("C compilation failed:", useColor) echo output return 1 if not opts.quiet: printInfo(&"build: {outputFile}", useColor) return 0 proc cmdRun*(args: seq[string], opts: GlobalOptions): int = let useColor = shouldUseColor(opts) let root = if args.len > 0: absolutePath(args[0]) else: getCurrentDir() let buildRes = cmdBuild(args, opts) if buildRes != 0: return buildRes let man = loadManifest(root / "bux.toml") let outputName = if man.name != "": man.name else: "bux_out" let outputFile = root / "build" / outputName if not fileExists(outputFile): printError("executable not found after build", useColor) return 1 let exitCode = execCmd(outputFile) return exitCode proc cmdClean*(args: seq[string], opts: GlobalOptions): int = let useColor = shouldUseColor(opts) let root = getCurrentDir() let buildDir = root / "build" if dirExists(buildDir): removeDir(buildDir) if not opts.quiet: 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 ` / `--filter=`, 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 ] [project-dir]" echo " --filter Only run tests whose filename contains " 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 (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: 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: let testName = splitFile(testFile).name let tmpDir = getTempDir() / "bux_test_" & testName removeDir(tmpDir) createDir(tmpDir / "src") copyFile(testFile, tmpDir / "src" / "Main.bux") 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: 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 if didChange: changed += 1 if not opts.quiet: if checkOnly: printError(&" would reformat {path}", useColor) else: printInfo(&" formatted {path}", useColor) else: 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)" return 0 proc runCli*(args: seq[string]): int = let (opts, rest, ok) = parseGlobalOptions(args) if not ok: return 1 if rest.len == 0: printUsage() return 0 let cmd = rest[0] let cmdArgs = if rest.len > 1: rest[1..^1] else: @[] case cmd of "new": return cmdNew(cmdArgs, opts) 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": printUsage() return 0 else: let useColor = shouldUseColor(opts) printError(&"unknown command '{cmd}'", useColor) return 1