diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a8ebf..306e579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,20 @@ # Changelog -## [0.2.10] - 2026-03-16 +## [0.3.0] - 2026-03-18 + Added: + +- `dcr gen` command for IDE integration and build tool support + - `dcr gen project-info`: Output project metadata as JSON + - `dcr gen compile-commands`: Generate `compile_commands.json` for clangd and clang-tidy + - `dcr gen vscode`: Generate VS Code `.vscode/` integration (launch.json, tasks.json, settings.json) + - `dcr gen clion`: Generate JetBrains CLion `.idea/` integration (run configurations, build targets) +- Full workspace support for all `gen` subcommands + +## [0.2.10] - 2026-03-16 + +Added: + - `build.steps` for pre-build generators (e.g., `moc`/`uic`/`rcc`) - `build.pkg_config` for `pkg-config`-driven cflags/ldflags resolution - `[toolchain]` support for `uic`, `moc`, `rcc` @@ -9,20 +22,25 @@ Added: - `{profile}` placeholder support for dependency paths ## [0.2.9] - 2026-03-14 + Added: + - Header dependency tracking for fine-grained incremental builds (rebuilds when `#include` files change) - Complete internal rewrite of the builder module: merged duplicate compilation backends Changed: + - `cflags` and `default_flags` no longer incorrectly leak to the linker stage in GCC/Clang - Reduced builder codebase size by over 20% while increasing reliability - Greatly expanded test suite with 34 new tests covering configuration, CLI errors, and path operations ## [0.2.8] - 2026-03-06 + Added: + - Workspace support (`[workspace]` with deps ordering) - `clean --all` for workspace members -Changed: + Changed: - Project root discovery for `build/run/clean` (searches parent dirs) - Workspace paths are excluded from root source scan - Build output now uses a Cargo-style `Compiling v` line @@ -31,12 +49,16 @@ Changed: - Safer build cache: fingerprints include headers and resolved library files ## [0.2.7] - 2026-03-03 + Added: + - Toolchain overrides via `[toolchain]` and env (`DCR_*`) - ASM `.S` preprocessing via GCC/Clang (`-x assembler-with-cpp`) ## [0.2.6] - 2026-03-01 + Added: + - `sharedlib` build kind (shared library output) - ASM projects (`build.language = "asm"`) - NASM and GAS backends @@ -44,24 +66,31 @@ Added: - Basic CLI integration tests ## [0.2.5] - 2026-02-26 + Added: + - Incremental builds (object caching by mtime) - `build.kind` with `staticlib` support - Custom output directory via `build.target` ## [0.2.4] - 2026-02-22 + Added: + - Custom build flags: `build.cflags` and `build.ldflags` - Recursive source discovery inside `src/` - Path dependencies with auto include/lib resolution and `dcr.lock` generation (experimental) - Add docs/index.md and docs/dcr-toml.md ## [0.2.3] - 2026-02-21 + Added: + - Modular builders for `gcc`, `clang`, `msvc` - Platform-specific binary path generation (Linux/macOS/Windows) Changed: + - Build configuration moved to `[build]` (`language`, `standard`, `compiler`) - `dcr.toml` formatting now includes the `[build]` section - Build uses built-in `debug/release` flags per compiler @@ -69,20 +98,26 @@ Changed: - Updated `dcr.toml` examples in documentation ## [0.2.2] - 2026-02-20 + Changed: + - Reworked `dcr.toml` handling: added read, validation, and edit through `core::config` - `new` and `init` use the new config creation logic - `build` and `run` now require `dcr.toml` and read `compiler` and `name` from it ## [0.2.1] - 2026-02-18 + Changed: + - Translated user-facing CLI messages to English - Unified error and warning output via `utils::log::{error, warn}` - Updated `--help` output: translated headers and examples to English, used `printc`, and applied `BOLD_*` styles - Translated installer script messages in `install.sh` and `install.ps1` to English ## [0.2.0] - 2026-02-17 + Changed: + - Project migrated from Python to Rust - CLI and commands (`new`, `init`, `build`, `run`, `clean`, `--help`, `--version`, `--update`) ported to Rust - Updated `--update` flag: added support for GNU/Linux, Windows, macOS @@ -91,31 +126,40 @@ Changed: - Updated `install.sh` for GNU/Linux and macOS Added: + - Added `install.ps1` for Windows - Support for GNU/Linux, Windows, macOS (x86_64/arm) Important: + - Code was ported with neural networks; future versions will include bug fixes and logic changes ## [0.1.2] - 2026-02-12 + Added: + - Update command - `install.sh` install script and README instructions - `--version` flag Changed: + - Improved CLI output, added colors, and updated `--help` - Updated project run/build handling in `run.py`/`build.py` ## [0.1.1] - 2026-02-11 + Changed: + - Updated `--help` - `main.py` now runs correctly when executed directly ## [0.1.0] - 2026-02-11 + First public release. Added: + - Base commands `new`, `init`, `build`, `run`, `clean` - Build profiles `debug` and `release` - `dcr.toml` and `src/main.c` templates diff --git a/Cargo.lock b/Cargo.lock index 2579ce4..a846250 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,7 +90,7 @@ dependencies = [ [[package]] name = "dcr" -version = "0.2.10" +version = "0.3.0" dependencies = [ "glob", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index e4f9512..d982357 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dcr" -version = "0.2.10" +version = "0.3.0" edition = "2024" build = "build.rs" diff --git a/README.md b/README.md index 8f28830..6a89ae2 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,11 @@ The current implementation is written in Rust. - Build a project with `debug` and `release` profiles - Run the compiled binary - Clean build artifacts +- Generate IDE integration files (VS Code, CLion) and compilation databases - Generate a minimal C project template - Build static and shared libraries - ASM projects with NASM/GAS or via GCC/Clang +- Mixed language projects (`language = ["c", "asm"]` and similar) - Update the binary via `dcr --update` (GitHub Releases, not for pacman/AUR installs) ## Supported Platforms @@ -104,9 +106,11 @@ Creates a project in the current directory. The project name is taken from the d ### `dcr build [profile]` Builds the project. If no profile is specified, `--debug` is used. +Use `--force` to rebuild without cache, `--clean` to clean before build. ### `dcr run [profile]` Builds the project and runs the binary. If no profile is specified, `--debug` is used. +Use `--force` to rebuild without cache, `--clean` to clean before build. Run manually: `./target//` @@ -121,11 +125,16 @@ Two profiles are supported: - `--release` - built-in flags per compiler Custom flags can be added in `dcr.toml` via `build.cflags` and `build.ldflags`. +Profile-specific flags can be added via `[build.debug]` and `[build.release]`. You can also set `build.target` to override the output directory (profile-independent). You can set `build.platform` to pass `-march=` (GCC/Clang) or `/arch:*` (MSVC) where supported. Use `build.kind = "staticlib"` to build a static library instead of a binary. Use `build.kind = "sharedlib"` to build a shared library (`.so`/`.dylib`/`.dll`). `dcr run` is only for `build.kind = "bin"` and will fail for libraries. +Use `build.exclude`/`build.include` to control source/header collection. +Use `build.roots` and `build.src_disable` to replace the default `src/` root. +Use `build.steps` and `build.post_steps` to run custom commands. +Use `build.clean` for extra cleanup paths and `[run].cmd` to override run command. ## Configuration The main project file is `dcr.toml`. @@ -146,6 +155,10 @@ compiler = "clang" # Optional custom flags cflags = ["-Wall", "-Wextra"] ldflags = ["-lm"] +# +# Optional include/exclude: +# exclude = ["src/vendor", "src/legacy/**"] +# include = ["src/boot/arch/**"] [toolchain] # Optional tool overrides: @@ -154,6 +167,9 @@ ldflags = ["-lm"] # as = "as" # ar = "ar" # ld = "ld" +# uic = "uic" +# moc = "moc" +# rcc = "rcc" [dependencies] ``` diff --git a/docs/README.md b/docs/README.md index b1a19c0..ec944ba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,12 +18,19 @@ Current DCR focuses on a simple, predictable workflow: - C++: `*.cpp`, `*.cxx`, `*.cc` - Compiler selection from `dcr.toml` (`clang`, `gcc`, `cl`, `clang-cl`, etc.). - ASM support via `language = "asm"` (GCC/Clang, NASM, GAS). +- Mixed language builds via `language = ["c", "asm"]` and similar combinations. - Three build kinds: - `bin` (default) - `staticlib` - `sharedlib` - Optional custom output directory via `build.target`. - Optional `build.platform` to pass architecture hints (`-march` / `/arch`). +- Optional `build.exclude`/`build.include` for source/header selection. +- Optional `build.roots`/`build.src_disable` to replace the default `src/` root. +- Optional build steps with `build.steps` and `build.post_steps`. +- Optional `build.pkg_config` integration. +- Optional `build.clean` and `build.expect`. +- Optional `[run].cmd` to override run command. - Workspace builds via `[workspace]` with per-project `dcr.toml`. - Path dependencies with include/lib resolution and `dcr.lock` generation. @@ -31,7 +38,7 @@ Current DCR focuses on a simple, predictable workflow: - `dcr.toml` is required for `build`, `run`, and `clean`. - `run` works only with `build.kind = "bin"`. -- Incremental rebuild currently tracks source file timestamps (not header dependency graphs). +- Incremental rebuild tracks source file timestamps and header dependencies (rebuilds when either source or included headers change). - Object files are always created under `target//obj/`. ## Repository diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 5e0a23a..61542f9 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -24,6 +24,7 @@ * [dcr build](commands/dcr-build.md) * [dcr run](commands/dcr-run.md) * [dcr clean](commands/dcr-clean.md) +* [dcr gen](commands/dcr-gen.md) ## Dependencies & Build diff --git a/docs/commands/dcr-build.md b/docs/commands/dcr-build.md index 40b0d6f..f0170a3 100644 --- a/docs/commands/dcr-build.md +++ b/docs/commands/dcr-build.md @@ -8,6 +8,8 @@ Builds the project in `debug` or `release` profile. dcr build dcr build --debug dcr build --release +dcr build --release --force +dcr build --debug --clean ``` ## What `build` does @@ -30,17 +32,33 @@ dcr build --release - `build.platform` - `build.cflags` - `build.ldflags` +- `build.debug` / `build.release` +- `build.exclude` +- `build.include` +- `build.pkg_config` +- `build.steps` +- `build.post_steps` +- `build.generated` +- `build.expect` +- `build.clean` ## Source selection - `language = "c"` -> `*.c` - `language = "c++" | "cpp" | "cxx"` -> `*.cpp`, `*.cxx`, `*.cc` - `language = "asm"` -> `*.s`, `*.S`, `*.asm` +- Mixed languages are supported with arrays, for example `language = ["c", "asm"]`. +- By default sources are searched in `src/`; use `build.roots` and `build.src_disable` to override. ## Notes - The profile is selected from the first argument only. - Unknown profile flags return an error. - Incremental rebuild is based on source/object mtime comparison. +- `--force` skips build cache checks and recompiles. +- `--clean` removes `target/` and `build.clean` paths before building. - For `language = "asm"` with `compiler = "as"`/`"gas"`, use `.s` files (no preprocessing). For `.S`, use `gcc` or `clang`. - In workspace root, `dcr build` builds all members in dependency order. +- `build.exclude` removes paths from source/header collection; `build.include` re-allows matching paths and has priority over `exclude`. +- `build.steps` run before compilation; `build.post_steps` run after linking. +- `build.generated` is cleaned when `build.steps` need to rerun; `build.expect` is verified after post-steps. diff --git a/docs/commands/dcr-clean.md b/docs/commands/dcr-clean.md index 1ba38f3..dce8d8f 100644 --- a/docs/commands/dcr-clean.md +++ b/docs/commands/dcr-clean.md @@ -17,6 +17,8 @@ dcr clean --release --all - Without arguments: removes entire `target/`. - With profile: removes only `target//`. - With `--all`: also cleans all workspace members. +- Additionally removes paths from `build.clean` if configured. +- `build.clean` supports `{profile}` and version placeholders. ## Validation diff --git a/docs/commands/dcr-run.md b/docs/commands/dcr-run.md index 842dccc..335488a 100644 --- a/docs/commands/dcr-run.md +++ b/docs/commands/dcr-run.md @@ -8,6 +8,8 @@ Builds the project and runs the resulting binary. dcr run dcr run --debug dcr run --release +dcr run --release --force +dcr run --debug --clean ``` ## Behavior @@ -16,11 +18,20 @@ dcr run --release 2. Reads `package.name`, `build.kind`, and optional `build.target`. 3. Runs the same build flow as `dcr build`. 4. Executes the built artifact path for the selected profile. +5. If `[run].cmd` is set, runs that command instead of the built binary. ## Restrictions - If `build.kind = "staticlib"` or `build.kind = "sharedlib"`, `run` exits with error. - The profile is selected from the first argument. +- `--force` skips build cache checks and recompiles. +- `--clean` removes `target/` and `build.clean` paths before building. + +## `run.cmd` variables + +`run.cmd` supports the same variables as build steps: `{profile}`, `{version}`, `{version_major}`, +`{version_minor}`, `{version_patch}`, `{version_suffix}`, `{version_suffix_dash}`. +The command runs via `sh -c` on Unix and `cmd /C` on Windows. ## On build failure diff --git a/docs/configuration/build-profiles.md b/docs/configuration/build-profiles.md index 1e6b63e..0e22ec2 100644 --- a/docs/configuration/build-profiles.md +++ b/docs/configuration/build-profiles.md @@ -14,6 +14,8 @@ dcr run --release dcr clean --debug ``` +You can also add `--force` or `--clean` to `build`/`run`. + ## Default profile flags ### GCC/Clang-like toolchains @@ -55,3 +57,18 @@ Shared library mode (`kind = "sharedlib"`): - Linux: `lib.so` - macOS: `lib.dylib` - Windows: `.dll` + +## Profile overrides + +Use `[build.debug]` and `[build.release]` to override build settings per profile. +All values inherit from `[build]`. If a field is present in the profile table, it replaces the base value. +For arrays (like `cflags`/`ldflags`), the profile value is appended to the base array. + +```toml +[build.debug] +cflags = ["-g3"] + +[build.release] +cflags = ["-O3"] +ldflags = ["-s"] +``` diff --git a/docs/configuration/build-section.md b/docs/configuration/build-section.md index 58490f9..43d44de 100644 --- a/docs/configuration/build-section.md +++ b/docs/configuration/build-section.md @@ -13,11 +13,20 @@ kind = "bin" # platform = "x86_64" # cflags = ["-Wall"] # ldflags = ["-lm"] +# exclude = ["src/vendor", "src/legacy/**"] +# include = ["src/legacy/boot/**"] +# roots = ["kernel", "drivers"] +# src_disable = true +# pkg_config = ["Qt6Core", "Qt6Widgets"] +# generated = ["src/ui/*.h"] +# expect = ["./target/{profile}/boot/*.bin"] +# clean = ["./target/{profile}/boot", "/tmp/image/*.img"] ``` ## Fields -- `language` (string, required): `c`, `c++`, `cpp`, `cxx`, or `asm`. +- `language` (string or array, required): `c`, `c++`, `cpp`, `cxx`, or `asm`. + Mixed languages can be specified as an array, for example `["c", "c++", "asm"]`. - `standard` (string, required): language standard passed to compiler. - `compiler` (string, required): compiler command (for example `clang`, `gcc`, `cl`). - `kind` (string, required): `bin`, `staticlib`, or `sharedlib`. @@ -25,6 +34,62 @@ kind = "bin" - `platform` (string, optional): architecture hint for compiler (used for `-march` or `/arch`). - `cflags` (string array, optional): extra compile flags. - `ldflags` (string array, optional): extra link flags. +- `exclude` (string array, optional): paths or glob patterns to skip during source/header collection. +- `include` (string array, optional): allowlist paths or globs that override `exclude`. +- `include` directory entries (non-glob) are also passed to the compiler as `-I` include paths. +- `roots` (string array, optional): additional source roots to scan instead of `src/`. +- `src_disable` (bool, optional): disables default `src/` root when true. +- `pkg_config` (string array, optional): packages used to append `pkg-config --cflags/--libs`. +- `generated` (string array, optional): generated files to delete when build steps rerun. +- `expect` (string array, optional): artifacts that must exist after build/post-steps. +- `clean` (string array, optional): additional paths removed by `dcr clean` and `--clean`. + +Notes for `exclude`/`include`: +- Supports `*` and `**` glob patterns. +- If `include` is set, it has priority over `exclude`. +- Use `include` to re-allow nested paths, for example exclude `src/boot` but allow `src/boot/arch/**`. +When using glob patterns in `exclude`, DCR converts them internally, so `exclude` globs still work as expected. + +### Build steps + +Build steps run shell commands before compilation. Post steps run after linking. + +```toml +[[build.steps]] +name = "uic" +in = "src/ui/*.ui" +out = "src/ui/{stem}.h" +cmd = "{uic} {in} -o {out}" + +[[build.post_steps]] +name = "image" +in = "target/{profile}/boot/*.bin" +out = "/tmp/image/os-{version}.img" +cmd = "dd if=/dev/zero of={out} bs=1M count=8" +``` + +Step notes: +- `in` supports glob patterns. +- If `in` expands to multiple files, `out` must include `{stem}`. +- Steps run only when inputs are newer than outputs. +- Variables: `{in}`, `{out}`, `{stem}`, `{cflags}`, `{uic}`, `{moc}`, `{rcc}`, `{profile}`, + `{version}`, `{version_major}`, `{version_minor}`, `{version_patch}`, `{version_suffix}`, `{version_suffix_dash}`. + +`build.clean` supports `{profile}` and version placeholders (`{version}`, `{version_major}`, ...). + +### Profile overrides + +Profile tables inherit all values from `[build]`. Any field present in the profile table replaces the base value. +Array fields (like `cflags`/`ldflags`) are appended to the base array. + +```toml +[build.debug] +cflags = ["-g3"] + +[build.release] +cflags = ["-O3"] +ldflags = ["-s"] +``` ## Toolchain section @@ -35,6 +100,9 @@ cxx = "clang++" as = "as" ar = "ar" ld = "ld" +uic = "uic" +moc = "moc" +rcc = "rcc" ``` ## Behavior notes diff --git a/docs/configuration/dcr.toml-overview.md b/docs/configuration/dcr.toml-overview.md index c411911..0f6b74b 100644 --- a/docs/configuration/dcr.toml-overview.md +++ b/docs/configuration/dcr.toml-overview.md @@ -45,6 +45,19 @@ Workspace-only root: - `build.target` - `build.cflags` - `build.ldflags` +- `build.exclude` +- `build.include` +- `build.roots` +- `build.src_disable` +- `build.pkg_config` +- `build.steps` +- `build.post_steps` +- `build.generated` +- `build.expect` +- `build.clean` +- `build.debug` +- `build.release` - dependency fields (`include`, `lib`, `libs`, `system`) -- `[toolchain]` overrides (`cc`, `cxx`, `as`, `ar`, `ld`) +- `[toolchain]` overrides (`cc`, `cxx`, `as`, `ar`, `ld`, `uic`, `moc`, `rcc`) - `[workspace]` members +- `[run]` overrides (`cmd`) diff --git a/docs/dependencies-and-build/compilers-and-linking.md b/docs/dependencies-and-build/compilers-and-linking.md index b18a3c5..dd46751 100644 --- a/docs/dependencies-and-build/compilers-and-linking.md +++ b/docs/dependencies-and-build/compilers-and-linking.md @@ -15,8 +15,11 @@ DCR picks a backend from `build.compiler`: ## Compilation model - Source files are found recursively in `src/` based on `build.language`. +- `build.roots` and `build.src_disable` can replace the default `src/` root. +- Mixed language builds use arrays, for example `build.language = ["c", "asm"]`. - Each source is compiled to `target//obj/.o` (`.obj` on MSVC). - Recompile happens when source mtime is newer than object mtime. +- `build.exclude` removes paths from source/header collection; `build.include` re-allows matching paths and has priority over `exclude`. ## Default build flags @@ -46,10 +49,12 @@ If `[toolchain]` is set, DCR uses its values to override compiler/linker tools: - `cc`, `cxx`, `as` override compilers for C/C++/ASM - `ar` overrides the archiver (used for `staticlib`) - `ld` overrides the linker (used by GCC/Clang/NASM/GAS backends) +- `uic`, `moc`, `rcc` override Qt tools used in `build.steps` Environment overrides (highest priority): - `DCR_COMPILER` (all languages) - `DCR_CC`, `DCR_CXX`, `DCR_AS`, `DCR_AR`, `DCR_LD` +- `DCR_DEBUG` enables verbose build logging ## Linking diff --git a/docs/getting-started/build-and-run.md b/docs/getting-started/build-and-run.md index 2e51494..87ec5b1 100644 --- a/docs/getting-started/build-and-run.md +++ b/docs/getting-started/build-and-run.md @@ -8,6 +8,7 @@ Debug (default): dcr build # or dcr build --debug +dcr build --debug --clean ``` Release: @@ -22,6 +23,7 @@ dcr build --release dcr run # or dcr run --release +dcr run --release --force ``` `dcr run` first builds, then launches the produced binary. @@ -47,3 +49,4 @@ dcr clean --release --all - Build profile flag is parsed from the first command argument. - Unknown profile flags produce an error. - In workspace root, `clean --all` cleans all member projects. +- `--clean` for `build`/`run` removes `target/` and paths from `build.clean`. diff --git a/docs/reference-and-help/config-reference.md b/docs/reference-and-help/config-reference.md index ec9ceef..9b03619 100644 --- a/docs/reference-and-help/config-reference.md +++ b/docs/reference-and-help/config-reference.md @@ -8,7 +8,7 @@ name = "string" # required version = "string" # required [build] -language = "c|c++|cpp|cxx|asm" # required +language = "c|c++|cpp|cxx|asm" # required (string or array) standard = "string" # required compiler = "string" # required kind = "bin|staticlib|sharedlib" # required @@ -16,6 +16,42 @@ target = "string" # optional platform = "string" # optional cflags = ["string", "..."] # optional ldflags = ["string", "..."] # optional +exclude = ["string", "..."] # optional +include = ["string", "..."] # optional +roots = ["string", "..."] # optional +src_disable = false # optional +pkg_config = ["string", "..."] # optional +generated = ["string", "..."] # optional +expect = ["string", "..."] # optional +clean = ["string", "..."] # optional + +[[build.steps]] +name = "string" +in = "glob" +out = "path or glob" +cmd = "string" + +[[build.post_steps]] +name = "string" +in = "glob" +out = "path or glob" +cmd = "string" + +# Inline array form (equivalent to [[build.steps]] / [[build.post_steps]]) +steps = [ + { name = "string", in = "glob", out = "path or glob", cmd = "string" }, +] +post_steps = [ + { name = "string", in = "glob", out = "path or glob", cmd = "string" }, +] + +[build.debug] +cflags = ["string", "..."] # optional +ldflags = ["string", "..."] # optional + +[build.release] +cflags = ["string", "..."] # optional +ldflags = ["string", "..."] # optional [toolchain] cc = "string" # optional @@ -23,6 +59,12 @@ cxx = "string" # optional as = "string" # optional ar = "string" # optional ld = "string" # optional +uic = "string" # optional +moc = "string" # optional +rcc = "string" # optional + +[run] +cmd = "string" # optional [workspace] name = { path = "string", deps = ["string", "..."] } @@ -42,6 +84,15 @@ name = { - Required string fields must be non-empty. - `build.kind` must be `bin`, `staticlib`, or `sharedlib`. - Dependency fields `include/lib/libs` must be string arrays when provided. +- `build.exclude`/`build.include` must be string arrays when provided. +- `build.roots` must be a string array when provided. +- `build.src_disable` must be boolean when provided. +- `build.clean`/`build.generated`/`build.pkg_config`/`build.expect` must be string arrays when provided. +- `build.debug`/`build.release` must be tables; their `cflags`/`ldflags` must be string arrays. +- `build.steps`/`build.post_steps` entries must include `name`, `in`, `out`, `cmd`. +- `build.language` may be a string or array (for example `["c", "c++", "asm"]`). +- `[build.]` tables inherit all base values and override any fields they specify. + Array fields (like `cflags`/`ldflags`) are appended to the base array. ## Generated defaults (`dcr new` / `dcr init`) diff --git a/src/cli/build.rs b/src/cli/build.rs index 611d5c2..29a8e67 100644 --- a/src/cli/build.rs +++ b/src/cli/build.rs @@ -1,10 +1,12 @@ -use crate::config::{PROFILE, flags}; +use crate::cli::clean::clean; +use crate::cli::flags::parse_build_run_flags; +use crate::core::builder::common; use crate::core::builder::{BuildContext, build as build_project, collect_sources}; use crate::core::config::Config; use crate::core::deps::resolve_deps; use crate::core::workspace::parse_workspace; use crate::utils::fs::{check_dir, find_project_root}; -use crate::utils::log::{error, warn}; +use crate::utils::log::error; use crate::utils::text::{BOLD_GREEN, colored}; use glob::glob; use sha2::{Digest, Sha256}; @@ -31,11 +33,18 @@ pub fn build(args: &[String]) -> i32 { return 1; } }; - let active_profile = match parse_profile(args) { - Ok(profile) => profile, + let flags = match parse_build_run_flags(args) { + Ok(v) => v, Err(code) => return code, }; - match with_dir(&root, || build_from_root(&root, &active_profile)) { + if flags.clean { + let mut clean_args = Vec::new(); + clean_args.push(format!("--{}", flags.profile)); + let _ = clean(&clean_args); + } + match with_dir(&root, || { + build_from_root(&root, &flags.profile, flags.force) + }) { Ok(()) => 0, Err(msg) => { error(&msg); @@ -44,22 +53,6 @@ pub fn build(args: &[String]) -> i32 { } } -fn parse_profile(args: &[String]) -> Result { - if let Some(first_arg) = args.first() { - if first_arg.starts_with("--") { - let candidate = first_arg.trim_start_matches("--"); - if flags(candidate).is_some() { - return Ok(candidate.to_string()); - } - warn("Unknown build flag"); - return Err(1); - } - warn("Unknown argument"); - return Err(1); - } - Ok(PROFILE.to_string()) -} - fn get_config_str(config: &Config, key: &str) -> String { config .get(key) @@ -68,6 +61,132 @@ fn get_config_str(config: &Config, key: &str) -> String { .to_string() } +fn get_config_language(config: &Config, key: &str) -> Result { + let value = config.get(key).ok_or_else(|| format!("{key} is missing"))?; + if let Some(s) = value.as_str() { + let trimmed = s.trim(); + if trimmed.is_empty() { + return Err(format!("{key} is empty")); + } + return Ok(trimmed.to_string()); + } + let arr = value + .as_array() + .ok_or_else(|| format!("{key} must be string or array of strings"))?; + let mut parts = Vec::new(); + for item in arr { + let s = item + .as_str() + .ok_or_else(|| format!("{key} must be string or array of strings"))?; + let trimmed = s.trim(); + if trimmed.is_empty() { + return Err(format!("{key} contains empty value")); + } + parts.push(trimmed.to_string()); + } + if parts.is_empty() { + return Err(format!("{key} is empty")); + } + Ok(parts.join("+")) +} + +fn parse_language_value(value: &toml::Value, key: &str) -> Result { + if let Some(s) = value.as_str() { + let trimmed = s.trim(); + if trimmed.is_empty() { + return Err(format!("{key} is empty")); + } + return Ok(trimmed.to_string()); + } + let arr = value + .as_array() + .ok_or_else(|| format!("{key} must be string or array of strings"))?; + let mut parts = Vec::new(); + for item in arr { + let s = item + .as_str() + .ok_or_else(|| format!("{key} must be string or array of strings"))?; + let trimmed = s.trim(); + if trimmed.is_empty() { + return Err(format!("{key} contains empty value")); + } + parts.push(trimmed.to_string()); + } + if parts.is_empty() { + return Err(format!("{key} is empty")); + } + Ok(parts.join("+")) +} + +fn profile_table<'a>(config: &'a Config, profile: &str) -> Option<&'a toml::value::Table> { + config + .get("build") + .and_then(|v| v.as_table()) + .and_then(|b| b.get(profile)) + .and_then(|v| v.as_table()) +} + +fn get_string_with_profile(config: &Config, field: &str, profile: &str) -> String { + let base = get_config_str(config, &format!("build.{field}")); + let Some(table) = profile_table(config, profile) else { + return base; + }; + let value = table.get(field).and_then(|v| v.as_str()).unwrap_or(""); + let trimmed = value.trim(); + if trimmed.is_empty() { + base + } else { + trimmed.to_string() + } +} + +fn parse_string_array(value: &toml::Value, key: &str) -> Result, String> { + let arr = value + .as_array() + .ok_or_else(|| format!("{key} must be an array of strings"))?; + let mut out = Vec::new(); + for item in arr { + let s = item + .as_str() + .ok_or_else(|| format!("{key} must be an array of strings"))?; + out.push(s.to_string()); + } + Ok(out) +} + +fn get_list_with_profile( + config: &Config, + field: &str, + profile: &str, +) -> Result, String> { + let mut out = get_config_list(config, &format!("build.{field}"))?; + if let Some(table) = profile_table(config, profile) + && let Some(value) = table.get(field) + { + let mut extra = parse_string_array(value, &format!("build.{profile}.{field}"))?; + out.append(&mut extra); + } + Ok(out) +} + +fn get_bool_with_profile(config: &Config, field: &str, profile: &str, default: bool) -> bool { + let base = config + .get(&format!("build.{field}")) + .and_then(|v| v.as_bool()); + let profile_val = profile_table(config, profile) + .and_then(|table| table.get(field).and_then(|value| value.as_bool())); + profile_val.or(base).unwrap_or(default) +} + +fn get_language_with_profile(config: &Config, profile: &str) -> Result { + if let Some(table) = profile_table(config, profile) + && let Some(value) = table.get("language") + { + return parse_language_value(value, &format!("build.{profile}.language")); + } + get_config_language(config, "build.language") +} + fn get_config_opt(config: &Config, key: &str) -> Option { let value = config.get(key)?.as_str()?; let trimmed = value.trim(); @@ -96,10 +215,68 @@ fn get_config_list(config: &Config, key: &str) -> Result, String> { Ok(out) } +fn get_build_steps_from_value(value: &toml::Value, key: &str) -> Result, String> { + let arr = value + .as_array() + .ok_or_else(|| format!("{key} must be an array"))?; + let mut out = Vec::new(); + for item in arr { + let tbl = item + .as_table() + .ok_or_else(|| format!("{key} entries must be tables"))?; + let name = tbl + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let input = tbl + .get("in") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let output = tbl + .get("out") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let cmd = tbl + .get("cmd") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if name.is_empty() || input.is_empty() || output.is_empty() || cmd.is_empty() { + return Err(format!("{key} entries must include name, in, out, cmd")); + } + out.push(BuildStep { + name, + input, + output, + cmd, + }); + } + Ok(out) +} + +fn get_build_steps_with_profile( + config: &Config, + field: &str, + profile: &str, +) -> Result, String> { + if let Some(table) = profile_table(config, profile) + && let Some(value) = table.get(field) + { + return get_build_steps_from_value(value, &format!("build.{profile}.{field}")); + } + get_build_steps(config, &format!("build.{field}")) +} + fn ensure_target_dirs(items: &[String], profile: &str, target_dir: Option<&str>) { if let Some(dir) = target_dir { let _ = fs::create_dir_all(dir); - return; } if !items.contains(&"target".to_string()) { let _ = fs::create_dir("./target"); @@ -125,7 +302,7 @@ fn run_build(ctx: &BuildContext) -> Result { } } -fn build_from_root(root: &Path, profile: &str) -> Result<(), String> { +fn build_from_root(root: &Path, profile: &str, force: bool) -> Result<(), String> { let config = Config::open("./dcr.toml").map_err(|err| err.to_string())?; let project_name = get_config_str(&config, "package.name"); let start_time = Instant::now(); @@ -135,10 +312,10 @@ fn build_from_root(root: &Path, profile: &str) -> Result<(), String> { colored(profile, BOLD_GREEN) ); if let Some(workspace) = parse_workspace(&config, root)? { - build_workspace(&workspace, profile)?; + build_workspace(&workspace, profile, force)?; let excludes: Vec = workspace.members.iter().map(|m| m.path.clone()).collect(); - build_project_at(root, profile, &excludes)?; + build_project_at(root, profile, &excludes, force)?; let elapsed = ((start_time.elapsed().as_secs_f64() * 100.0).trunc()) / 100.0; println!( " {} Build completed successfully in {} seconds", @@ -147,7 +324,7 @@ fn build_from_root(root: &Path, profile: &str) -> Result<(), String> { ); return Ok(()); } - build_project_at(root, profile, &[])?; + build_project_at(root, profile, &[], force)?; let elapsed = ((start_time.elapsed().as_secs_f64() * 100.0).trunc()) / 100.0; println!( " {} Build completed successfully in {} seconds", @@ -160,9 +337,10 @@ fn build_from_root(root: &Path, profile: &str) -> Result<(), String> { fn build_workspace( workspace: &crate::core::workspace::Workspace, profile: &str, + force: bool, ) -> Result<(), String> { for member in &workspace.members { - build_project_at(&member.path, profile, &[])?; + build_project_at(&member.path, profile, &[], force)?; } Ok(()) } @@ -171,6 +349,7 @@ fn build_project_at( project_root: &Path, profile: &str, exclude_dirs: &[std::path::PathBuf], + force: bool, ) -> Result<(), String> { with_dir(project_root, || { let items = check_dir(None).map_err(|_| "Failed to read project directory".to_string())?; @@ -180,12 +359,12 @@ fn build_project_at( let config = Config::open("./dcr.toml").map_err(|err| err.to_string())?; let project_name = get_config_str(&config, "package.name"); let project_version = get_config_str(&config, "package.version"); - let project_compiler = get_config_str(&config, "build.compiler"); - let build_language = get_config_str(&config, "build.language"); - let build_standard = get_config_str(&config, "build.standard"); - let build_target = get_config_str(&config, "build.target"); - let build_kind = get_config_str(&config, "build.kind"); - let build_platform = get_config_str(&config, "build.platform"); + let project_compiler = get_string_with_profile(&config, "compiler", profile); + let build_language = get_language_with_profile(&config, profile)?; + let build_standard = get_string_with_profile(&config, "standard", profile); + let build_target = get_string_with_profile(&config, "target", profile); + let build_kind = get_string_with_profile(&config, "kind", profile); + let build_platform = get_string_with_profile(&config, "platform", profile); let tc_cc = get_config_opt(&config, "toolchain.cc"); let tc_cxx = get_config_opt(&config, "toolchain.cxx"); let tc_as = get_config_opt(&config, "toolchain.as"); @@ -194,11 +373,17 @@ fn build_project_at( let tc_uic = get_config_opt(&config, "toolchain.uic"); let tc_moc = get_config_opt(&config, "toolchain.moc"); let tc_rcc = get_config_opt(&config, "toolchain.rcc"); - let build_cflags = get_config_list(&config, "build.cflags")?; - let build_ldflags = get_config_list(&config, "build.ldflags")?; - let pkg_configs = get_config_list(&config, "build.pkg_config")?; - let build_generated = get_config_list(&config, "build.generated")?; - let build_steps = get_build_steps(&config)?; + let build_cflags = get_list_with_profile(&config, "cflags", profile)?; + let build_ldflags = get_list_with_profile(&config, "ldflags", profile)?; + let build_excludes = get_list_with_profile(&config, "exclude", profile)?; + let build_includes = get_list_with_profile(&config, "include", profile)?; + let build_roots = get_list_with_profile(&config, "roots", profile)?; + let src_disable = get_bool_with_profile(&config, "src_disable", profile, false); + let build_expects = get_list_with_profile(&config, "expect", profile)?; + let pkg_configs = get_list_with_profile(&config, "pkg_config", profile)?; + let build_generated = get_list_with_profile(&config, "generated", profile)?; + let build_steps = get_build_steps_with_profile(&config, "steps", profile)?; + let build_post_steps = get_build_steps_with_profile(&config, "post_steps", profile)?; let resolved_compiler = resolve_compiler( &build_language, @@ -215,6 +400,72 @@ fn build_project_at( let resolved = resolve_deps(&config, profile, project_root)?; let (resolved_cflags, resolved_ldflags) = resolve_pkg_config_flags(&pkg_configs, &build_cflags, &build_ldflags)?; + let mut combined_excludes = Vec::new(); + for dir in exclude_dirs { + combined_excludes.push(dir.clone()); + } + let mut exclude_patterns = Vec::new(); + for raw in build_excludes { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let normalized = trimmed.replace('\\', "/"); + if common::has_glob_magic(&normalized) { + exclude_patterns.push(normalized); + continue; + } + let p = Path::new(trimmed); + if p.is_absolute() { + combined_excludes.push(p.to_path_buf()); + exclude_patterns.push(normalized); + } else { + combined_excludes.push(project_root.join(p)); + exclude_patterns.push(normalized); + } + } + let mut combined_includes: Vec = Vec::new(); + combined_includes.extend(exclude_patterns.iter().map(|v| format!("!{v}"))); + combined_includes.extend(build_includes.iter().map(|v| v.replace('\\', "/"))); + + let mut source_roots: Vec = Vec::new(); + for raw in &build_roots { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let p = Path::new(trimmed); + if p.is_absolute() { + source_roots.push(p.to_path_buf()); + } else { + source_roots.push(project_root.join(p)); + } + } + if !src_disable && source_roots.is_empty() { + source_roots.push(project_root.join("src")); + } + + let mut merged_include_dirs = resolved.include_dirs.clone(); + for raw in &build_includes { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let normalized = trimmed.replace('\\', "/"); + if common::has_glob_magic(&normalized) { + continue; + } + let p = Path::new(trimmed); + let dir = if p.is_absolute() { + p.to_path_buf() + } else { + project_root.join(p) + }; + if dir.is_dir() { + merged_include_dirs.push(dir.to_string_lossy().to_string()); + } + } + let ctx = BuildContext { profile, project_name: &project_name, @@ -226,8 +477,10 @@ fn build_project_at( platform: normalize_platform(&build_platform), linker: resolved_linker.as_deref(), archiver: resolved_archiver.as_deref(), - exclude_dirs, - include_dirs: &resolved.include_dirs, + source_roots: &source_roots, + exclude_dirs: &combined_excludes, + include_paths: &combined_includes, + include_dirs: &merged_include_dirs, lib_dirs: &resolved.lib_dirs, libs: &resolved.libs, cflags: &resolved_cflags, @@ -243,10 +496,20 @@ fn build_project_at( let tool_execs = resolve_toolchain_execs(&tc_uic, &tc_moc, &tc_rcc, &pkg_configs); let step_flags = build_step_flags(&resolved_cflags, &resolved.include_dirs, &resolved_compiler); - let steps_dirty = build_steps_need_run(&build_steps)?; + let version_info = parse_version_info(&project_version); + let step_vars = StepVars { + profile, + version: &version_info.full, + version_major: &version_info.major, + version_minor: &version_info.minor, + version_patch: &version_info.patch, + version_suffix: &version_info.suffix, + version_suffix_dash: &version_info.suffix_dash, + }; + let steps_dirty = build_steps_need_run(&build_steps, &step_vars)?; if steps_dirty { clean_generated_files(&build_generated)?; - run_build_steps(&build_steps, &tool_execs, &step_flags)?; + run_build_steps(&build_steps, &tool_execs, &step_flags, &step_vars)?; } let sources = collect_sources(&ctx)?; let headers = collect_header_files(&ctx, project_root)?; @@ -257,6 +520,9 @@ fn build_project_at( skip = false; } let debug_enabled = std::env::var("DCR_DEBUG").is_ok(); + if force { + skip = false; + } if skip && !debug_enabled { return Ok(()); } @@ -270,6 +536,11 @@ fn build_project_at( run_build(&ctx)?; write_build_fingerprint(&ctx, &fingerprint)?; } + let post_steps_dirty = build_steps_need_run(&build_post_steps, &step_vars)?; + if post_steps_dirty { + run_build_steps(&build_post_steps, &tool_execs, &step_flags, &step_vars)?; + } + verify_expectations(&build_expects, &step_vars)?; Ok(()) }) } @@ -315,7 +586,7 @@ fn resolve_compiler( tc_cxx: Option<&str>, tc_as: Option<&str>, ) -> String { - let lang = language.to_lowercase(); + let lang = primary_language(language); env_override_compiler(&lang) .or_else(|| toolchain_override_compiler(&lang, tc_cc, tc_cxx, tc_as)) .unwrap_or_else(|| compiler.to_string()) @@ -371,6 +642,26 @@ fn toolchain_override_compiler( tc_cc.map(|v| v.to_string()) } +fn primary_language(language: &str) -> String { + let parts: Vec = language + .split('+') + .map(|p| p.trim().to_lowercase()) + .filter(|p| !p.is_empty()) + .collect(); + for p in &parts { + if p == "c++" || p == "cpp" || p == "cxx" { + return p.clone(); + } + } + if parts.iter().any(|p| p == "c") { + return "c".to_string(); + } + if parts.iter().any(|p| p == "asm") { + return "asm".to_string(); + } + language.to_lowercase() +} + fn resolve_tool(env_key: &str, fallback: Option<&str>) -> Option { if let Ok(value) = std::env::var(env_key) { let trimmed = value.trim(); @@ -588,63 +879,22 @@ struct BuildStep { cmd: String, } -fn get_build_steps(config: &Config) -> Result, String> { - let value = match config.get("build.steps") { +fn get_build_steps(config: &Config, key: &str) -> Result, String> { + let value = match config.get(key) { Some(v) => v, None => return Ok(Vec::new()), }; - let arr = value - .as_array() - .ok_or_else(|| "build.steps must be an array".to_string())?; - let mut out = Vec::new(); - for item in arr { - let tbl = item - .as_table() - .ok_or_else(|| "build.steps entries must be tables".to_string())?; - let name = tbl - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - let input = tbl - .get("in") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - let output = tbl - .get("out") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - let cmd = tbl - .get("cmd") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if name.is_empty() || input.is_empty() || output.is_empty() || cmd.is_empty() { - return Err("build.steps entries must include name, in, out, cmd".to_string()); - } - out.push(BuildStep { - name, - input, - output, - cmd, - }); - } - Ok(out) + get_build_steps_from_value(value, key) } fn run_build_steps( steps: &[BuildStep], tools: &ToolchainExecs, step_flags: &str, + vars: &StepVars, ) -> Result<(), String> { for step in steps { - run_build_step(step, tools, step_flags)?; + run_build_step(step, tools, step_flags, vars)?; } Ok(()) } @@ -653,8 +903,10 @@ fn run_build_step( step: &BuildStep, tools: &ToolchainExecs, step_flags: &str, + vars: &StepVars, ) -> Result<(), String> { - let inputs = expand_glob(&step.input)?; + let input_pattern = expand_step_value(&step.input, "", vars); + let inputs = expand_glob(&input_pattern)?; if inputs.is_empty() { return Ok(()); } @@ -666,8 +918,11 @@ fn run_build_step( )); } for input in inputs { + if !input.is_file() { + continue; + } let stem = input.file_stem().and_then(|v| v.to_str()).unwrap_or(""); - let out_path = PathBuf::from(step.output.replace("{stem}", stem)); + let out_path = PathBuf::from(expand_step_value(&step.output, stem, vars)); if !should_run_step(&input, &out_path) { continue; } @@ -675,7 +930,7 @@ fn run_build_step( fs::create_dir_all(parent) .map_err(|err| format!("Failed to create step output dir: {err}"))?; } - let cmd = substitute_step_cmd(&step.cmd, &input, &out_path, tools, step_flags, stem); + let cmd = substitute_step_cmd(&step.cmd, &input, &out_path, tools, step_flags, stem, vars); let status = run_shell_command(&cmd) .map_err(|err| format!("Failed to run step '{}': {err}", step.name))?; if !status.success() { @@ -706,6 +961,17 @@ fn expand_glob(pattern: &str) -> Result, String> { Ok(out) } +fn verify_expectations(patterns: &[String], vars: &StepVars) -> Result<(), String> { + for pattern in patterns { + let expanded = expand_step_value(pattern, "", vars); + let matches = expand_glob(&expanded)?; + if matches.is_empty() { + return Err(format!("Expected artifact not found: {expanded}")); + } + } + Ok(()) +} + fn should_run_step(input: &Path, output: &Path) -> bool { let in_time = fs::metadata(input).and_then(|m| m.modified()); let out_time = fs::metadata(output).and_then(|m| m.modified()); @@ -723,6 +989,7 @@ fn substitute_step_cmd( tools: &ToolchainExecs, step_flags: &str, stem: &str, + vars: &StepVars, ) -> String { template .replace("{in}", &input.to_string_lossy()) @@ -732,6 +999,13 @@ fn substitute_step_cmd( .replace("{rcc}", &tools.rcc) .replace("{cflags}", step_flags) .replace("{stem}", stem) + .replace("{profile}", vars.profile) + .replace("{version}", vars.version) + .replace("{version_major}", vars.version_major) + .replace("{version_minor}", vars.version_minor) + .replace("{version_patch}", vars.version_patch) + .replace("{version_suffix}", vars.version_suffix) + .replace("{version_suffix_dash}", vars.version_suffix_dash) } fn build_step_flags(cflags: &[String], include_dirs: &[String], compiler: &str) -> String { @@ -794,9 +1068,10 @@ fn run_shell_command(cmd: &str) -> Result Result { +fn build_steps_need_run(steps: &[BuildStep], vars: &StepVars) -> Result { for step in steps { - let inputs = expand_glob(&step.input)?; + let input_pattern = expand_step_value(&step.input, "", vars); + let inputs = expand_glob(&input_pattern)?; if inputs.is_empty() { continue; } @@ -808,8 +1083,11 @@ fn build_steps_need_run(steps: &[BuildStep]) -> Result { )); } for input in inputs { + if !input.is_file() { + continue; + } let stem = input.file_stem().and_then(|v| v.to_str()).unwrap_or(""); - let out_path = PathBuf::from(step.output.replace("{stem}", stem)); + let out_path = PathBuf::from(expand_step_value(&step.output, stem, vars)); if should_run_step(&input, &out_path) { return Ok(true); } @@ -818,6 +1096,66 @@ fn build_steps_need_run(steps: &[BuildStep]) -> Result { Ok(false) } +fn expand_step_value(template: &str, stem: &str, vars: &StepVars) -> String { + template + .replace("{stem}", stem) + .replace("{profile}", vars.profile) + .replace("{version}", vars.version) + .replace("{version_major}", vars.version_major) + .replace("{version_minor}", vars.version_minor) + .replace("{version_patch}", vars.version_patch) + .replace("{version_suffix}", vars.version_suffix) + .replace("{version_suffix_dash}", vars.version_suffix_dash) +} + +struct VersionInfo { + full: String, + major: String, + minor: String, + patch: String, + suffix: String, + suffix_dash: String, +} + +fn parse_version_info(version: &str) -> VersionInfo { + let mut full = version.trim().to_string(); + if full.is_empty() { + full = "0.0.0".to_string(); + } + let full_clone = full.clone(); + let (base, suffix) = match full_clone.split_once('-') { + Some((head, tail)) => (head, tail), + None => (full_clone.as_str(), ""), + }; + let mut parts = base.split('.'); + let major = parts.next().unwrap_or("0").to_string(); + let minor = parts.next().unwrap_or("0").to_string(); + let patch = parts.next().unwrap_or("0").to_string(); + let suffix_dash = if suffix.is_empty() { + "".to_string() + } else { + format!("-{suffix}") + }; + VersionInfo { + full, + major, + minor, + patch, + suffix: suffix.to_string(), + suffix_dash, + } +} + +struct StepVars<'a> { + profile: &'a str, + version: &'a str, + version_major: &'a str, + version_minor: &'a str, + version_patch: &'a str, + version_suffix: &'a str, + version_suffix_dash: &'a str, +} + fn compute_build_fingerprint( ctx: &BuildContext, sources: &[String], @@ -897,10 +1235,8 @@ fn write_build_fingerprint(ctx: &BuildContext, fingerprint: &str) -> Result<(), } fn build_cache_path(profile: &str, target_dir: Option<&str>) -> std::path::PathBuf { - match target_dir { - Some(dir) => Path::new(dir).join(format!(".dcr-build.{profile}.hash")), - None => Path::new("./target").join(profile).join(".dcr-build.hash"), - } + let _ = target_dir; + Path::new("./target").join(profile).join(".dcr-build.hash") } fn build_output_path(ctx: &BuildContext) -> String { @@ -919,7 +1255,11 @@ fn collect_header_files( ) -> Result, String> { let mut out = Vec::new(); let mut roots = Vec::new(); - roots.push(project_root.join("src")); + if ctx.source_roots.is_empty() { + roots.push(project_root.join("src")); + } else { + roots.extend(ctx.source_roots.iter().cloned()); + } for dir in ctx.include_dirs { roots.push(Path::new(dir).to_path_buf()); } @@ -927,7 +1267,7 @@ fn collect_header_files( if !root.exists() { continue; } - collect_header_files_rec(&root, &mut out, ctx.exclude_dirs)?; + collect_header_files_rec(&root, &mut out, ctx.exclude_dirs, ctx.include_paths)?; } out.sort(); out.dedup(); @@ -938,23 +1278,27 @@ fn collect_header_files_rec( dir: &Path, out: &mut Vec, exclude_dirs: &[std::path::PathBuf], + include_paths: &[String], ) -> Result<(), String> { - if is_excluded(dir, exclude_dirs) { + if common::is_excluded(dir, exclude_dirs, include_paths) && include_paths.is_empty() { return Ok(()); } for entry in fs::read_dir(dir).map_err(|err| format!("read_dir error: {err}"))? { let entry = entry.map_err(|err| format!("read_dir error: {err}"))?; let path = entry.path(); if path.is_dir() { - if is_excluded(&path, exclude_dirs) { + if common::is_excluded(&path, exclude_dirs, include_paths) && include_paths.is_empty() { continue; } - collect_header_files_rec(&path, out, exclude_dirs)?; + collect_header_files_rec(&path, out, exclude_dirs, include_paths)?; continue; } if !path.is_file() { continue; } + if common::is_excluded(&path, exclude_dirs, include_paths) { + continue; + } if is_header_file(&path) { out.push(path); } @@ -1013,10 +1357,6 @@ fn update_hasher_with_file(hasher: &mut Sha256, path: &Path) -> Result<(), Strin Ok(()) } -fn is_excluded(path: &Path, exclude_dirs: &[std::path::PathBuf]) -> bool { - exclude_dirs.iter().any(|dir| path.starts_with(dir)) -} - fn to_hex(bytes: &[u8]) -> String { let mut out = String::with_capacity(bytes.len() * 2); for b in bytes { diff --git a/src/cli/clean.rs b/src/cli/clean.rs index 7e46f94..c270629 100644 --- a/src/cli/clean.rs +++ b/src/cli/clean.rs @@ -4,6 +4,7 @@ use crate::core::workspace::parse_workspace; use crate::utils::fs::{check_dir, find_project_root}; use crate::utils::log::{error, warn}; use crate::utils::text::{BOLD_GREEN, colored}; +use glob::glob; use std::fs; use std::path::Path; @@ -85,6 +86,7 @@ fn clean_from_root(root: &Path, flags: &CleanFlags) -> Result<(), String> { fn clean_project_at(project_root: &Path, profile: Option<&str>) -> Result<(), String> { with_dir(project_root, || { + let config = Config::open("./dcr.toml").map_err(|err| err.to_string())?; let project_name = std::env::current_dir() .ok() .and_then(|p| p.file_name().map(|v| v.to_string_lossy().to_string())) @@ -97,31 +99,33 @@ fn clean_project_at(project_root: &Path, profile: Option<&str>) -> Result<(), St " Cleaning project `{}`", colored(&project_name, BOLD_GREEN) ); - if !items.contains(&"target".to_string()) { - warn("Directory target not found"); - return Ok(()); - } - if let Some(profile) = profile { let target_items = check_dir(Some("target")).unwrap_or_default(); if !target_items.contains(&profile.to_string()) { warn(&format!("Directory target/{profile} not found")); - return Ok(()); + } else { + println!(" Profile: {}", colored(profile, BOLD_GREEN)); + let _ = fs::remove_dir_all(format!("target/{profile}")); + println!( + "{} Removed directory target/{profile}", + colored("\n ✔", BOLD_GREEN) + ); } - println!(" Profile: {}", colored(profile, BOLD_GREEN)); - let _ = fs::remove_dir_all(format!("target/{profile}")); - println!( - "{} Removed directory target/{profile}", - colored("\n ✔", BOLD_GREEN) - ); + clean_custom_paths(&config, profile)?; return Ok(()); } - let _ = fs::remove_dir_all("target"); - println!( - "{} Removed directory target", - colored("\n ✔", BOLD_GREEN) - ); + if items.contains(&"target".to_string()) { + let _ = fs::remove_dir_all("target"); + println!( + "{} Removed directory target", + colored("\n ✔", BOLD_GREEN) + ); + } else { + warn("Directory target not found"); + } + clean_custom_paths(&config, "debug")?; + clean_custom_paths(&config, "release")?; Ok(()) }) } @@ -136,3 +140,86 @@ where let _ = std::env::set_current_dir(prev); result } + +fn clean_custom_paths(config: &Config, profile: &str) -> Result<(), String> { + let patterns = match config.get("build.clean") { + Some(v) => v + .as_array() + .ok_or_else(|| "build.clean must be an array of strings".to_string())? + .iter() + .filter_map(|item| item.as_str()) + .map(|s| { + let value = s.replace("{profile}", profile); + substitute_version_vars(&value, config) + }) + .collect::>(), + None => Vec::new(), + }; + if patterns.is_empty() { + return Ok(()); + } + for pattern in patterns { + for entry in glob(&pattern).map_err(|err| format!("glob error: {err}"))? { + let path = entry.map_err(|err| format!("glob error: {err}"))?; + if path.is_dir() { + let _ = fs::remove_dir_all(&path); + } else if path.is_file() { + let _ = fs::remove_file(&path); + } + } + } + Ok(()) +} + +fn substitute_version_vars(value: &str, config: &Config) -> String { + let version = config + .get("package.version") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let info = parse_version_info(version); + value + .replace("{version}", &info.full) + .replace("{version_major}", &info.major) + .replace("{version_minor}", &info.minor) + .replace("{version_patch}", &info.patch) + .replace("{version_suffix}", &info.suffix) + .replace("{version_suffix_dash}", &info.suffix_dash) +} + +struct VersionInfo { + full: String, + major: String, + minor: String, + patch: String, + suffix: String, + suffix_dash: String, +} + +fn parse_version_info(version: &str) -> VersionInfo { + let mut full = version.trim().to_string(); + if full.is_empty() { + full = "0.0.0".to_string(); + } + let full_clone = full.clone(); + let (base, suffix) = match full_clone.split_once('-') { + Some((head, tail)) => (head, tail), + None => (full_clone.as_str(), ""), + }; + let mut parts = base.split('.'); + let major = parts.next().unwrap_or("0").to_string(); + let minor = parts.next().unwrap_or("0").to_string(); + let patch = parts.next().unwrap_or("0").to_string(); + let suffix_dash = if suffix.is_empty() { + "".to_string() + } else { + format!("-{suffix}") + }; + VersionInfo { + full, + major, + minor, + patch, + suffix: suffix.to_string(), + suffix_dash, + } +} diff --git a/src/cli/help.rs b/src/cli/help.rs index 9c9525d..c4a104e 100644 --- a/src/cli/help.rs +++ b/src/cli/help.rs @@ -13,14 +13,18 @@ pub fn help() -> i32 { println!(" build [--profile] Build the project (default: --debug)"); println!(" run [--profile] Build and run the project (default: --debug)"); println!(" clean Remove the target directory"); + println!(" gen Generate IDE integration files"); printc("FLAGS:", BOLD_GREEN); println!(" --help Show command help"); println!(" --update Update dcr to the latest version"); println!(" --version Show dcr version"); println!(); printc("OPTIONS:", BOLD_GREEN); - println!(" --debug Build with debug profile"); - println!(" --release Build with release profile"); + println!(" --debug Use debug profile"); + println!(" --release Use release profile"); + println!(" --force Force rebuild (build/run)"); + println!(" --clean Clean before build (build/run)"); + println!(" --all Clean all workspace members (clean)"); println!(); printc("EXAMPLES:", BOLD_GREEN); printc(" dcr new hello", BOLD_CYAN); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5b37090..814950c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,8 @@ pub mod build; pub mod clean; pub mod flag_update; +pub mod flags; +pub mod r#gen; pub mod help; pub mod init; pub mod new; diff --git a/src/cli/run.rs b/src/cli/run.rs index dc46d50..d6c7b06 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -1,15 +1,14 @@ use crate::cli::build::build; -use crate::config::{PROFILE, flags}; +use crate::cli::flags::parse_build_run_flags; use crate::core::config::Config; use crate::core::runner::run_binary; use crate::utils::fs::find_project_root; -use crate::utils::log::{error, warn}; +use crate::utils::log::error; use crate::utils::text::{BOLD_GREEN, colored}; use std::path::Path; +use std::process::Command; pub fn run(args: &[String]) -> i32 { - let mut active_profile = PROFILE.to_string(); - let start_dir = match std::env::current_dir() { Ok(dir) => dir, Err(_) => { @@ -51,35 +50,45 @@ pub fn run(args: &[String]) -> i32 { .map(|s| s.trim()) .filter(|s| !s.is_empty()); - if let Some(first_arg) = args.first() { - if first_arg.starts_with("--") { - let candidate = first_arg.trim_start_matches("--"); - if flags(candidate).is_some() { - active_profile = candidate.to_string(); - } else { - warn("Unknown build flag"); - return 1; - } - } else { - warn("Unknown argument"); - return 1; - } - } + let flags = match parse_build_run_flags(args) { + Ok(v) => v, + Err(_) => return 1, + }; + + let version = config + .get("package.version") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let run_cmd = config + .get("run.cmd") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .map(|cmd| substitute_run_vars(&cmd, &flags.profile, version)); let kind = build_kind.trim(); - if kind == "staticlib" || kind == "sharedlib" { + if run_cmd.is_none() && (kind == "staticlib" || kind == "sharedlib") { error("Cannot run library build"); return 1; } let build_status = build(args); - let bin_path = crate::platform::bin_path(&active_profile, project_name, target_dir); + let bin_path = crate::platform::bin_path(&flags.profile, project_name, target_dir); if build_status == 0 { + if let Some(cmd) = run_cmd { + println!("\n {} {}", colored("Running", BOLD_GREEN), cmd); + println!("--------------------------------"); + return run_shell(&cmd); + } println!("\n {} {}", colored("Running", BOLD_GREEN), bin_path); println!("--------------------------------"); - return run_binary(project_name, &active_profile, target_dir); + return run_binary(project_name, &flags.profile, target_dir); } - let fallback_code = run_binary(project_name, &active_profile, target_dir); + let fallback_code = if let Some(cmd) = run_cmd { + run_shell(&cmd) + } else { + run_binary(project_name, &flags.profile, target_dir) + }; if fallback_code != 1 { return fallback_code; } @@ -98,3 +107,65 @@ where let _ = std::env::set_current_dir(prev); result } + +fn run_shell(cmd: &str) -> i32 { + let status = if cfg!(target_os = "windows") { + Command::new("cmd").arg("/C").arg(cmd).status() + } else { + Command::new("sh").arg("-c").arg(cmd).status() + }; + match status { + Ok(s) if s.success() => 0, + Ok(s) => s.code().unwrap_or(1), + Err(_) => 1, + } +} + +fn substitute_run_vars(cmd: &str, profile: &str, version: &str) -> String { + let info = parse_version_info(version); + cmd.replace("{profile}", profile) + .replace("{version}", &info.full) + .replace("{version_major}", &info.major) + .replace("{version_minor}", &info.minor) + .replace("{version_patch}", &info.patch) + .replace("{version_suffix}", &info.suffix) + .replace("{version_suffix_dash}", &info.suffix_dash) +} + +struct VersionInfo { + full: String, + major: String, + minor: String, + patch: String, + suffix: String, + suffix_dash: String, +} + +fn parse_version_info(version: &str) -> VersionInfo { + let mut full = version.trim().to_string(); + if full.is_empty() { + full = "0.0.0".to_string(); + } + let full_clone = full.clone(); + let (base, suffix) = match full_clone.split_once('-') { + Some((head, tail)) => (head, tail), + None => (full_clone.as_str(), ""), + }; + let mut parts = base.split('.'); + let major = parts.next().unwrap_or("0").to_string(); + let minor = parts.next().unwrap_or("0").to_string(); + let patch = parts.next().unwrap_or("0").to_string(); + let suffix_dash = if suffix.is_empty() { + "".to_string() + } else { + format!("-{suffix}") + }; + VersionInfo { + full, + major, + minor, + patch, + suffix: suffix.to_string(), + suffix_dash, + } +} diff --git a/src/core/builder/common.rs b/src/core/builder/common.rs index 1efc86e..4d7ed2b 100644 --- a/src/core/builder/common.rs +++ b/src/core/builder/common.rs @@ -2,33 +2,37 @@ use std::fs; use std::path::{Path, PathBuf}; pub fn collect_sources( + roots: &[PathBuf], extensions: &[&str], exclude_dirs: &[PathBuf], + include_paths: &[String], ) -> Result, String> { let mut sources = Vec::new(); - collect_sources_rec("./src", extensions, &mut sources, exclude_dirs)?; + for root in roots { + collect_sources_rec(root, extensions, &mut sources, exclude_dirs, include_paths)?; + } sources.sort(); if sources.is_empty() { - return Err("No source files found in ./src".to_string()); + return Err(format!("No source files found in {}", format_roots(roots))); } Ok(sources) } fn collect_sources_rec( - dir: &str, + dir: &Path, extensions: &[&str], out: &mut Vec, exclude_dirs: &[PathBuf], + include_paths: &[String], ) -> Result<(), String> { - let dir_path = Path::new(dir); - let full_dir = if dir_path.is_absolute() { - dir_path.to_path_buf() + let full_dir = if dir.is_absolute() { + dir.to_path_buf() } else { std::env::current_dir() .map_err(|err| format!("src dir error: {err}"))? - .join(dir_path) + .join(dir) }; - if is_excluded(&full_dir, exclude_dirs) { + if is_excluded(&full_dir, exclude_dirs, include_paths) && include_paths.is_empty() { return Ok(()); } let entries = fs::read_dir(&full_dir).map_err(|err| format!("src dir error: {err}"))?; @@ -36,15 +40,18 @@ fn collect_sources_rec( let entry = entry.map_err(|err| format!("src dir error: {err}"))?; let path = entry.path(); if path.is_dir() { - if is_excluded(&path, exclude_dirs) { + if is_excluded(&path, exclude_dirs, include_paths) && include_paths.is_empty() { continue; } - collect_sources_rec(&path.to_string_lossy(), extensions, out, exclude_dirs)?; + collect_sources_rec(&path, extensions, out, exclude_dirs, include_paths)?; continue; } if !path.is_file() { continue; } + if is_excluded(&path, exclude_dirs, include_paths) { + continue; + } let ext_raw = path.extension().and_then(|v| v.to_str()).unwrap_or(""); let ext_lower = ext_raw.to_lowercase(); let matched = extensions @@ -57,10 +64,95 @@ fn collect_sources_rec( Ok(()) } -pub fn is_excluded(path: &Path, exclude_dirs: &[PathBuf]) -> bool { +fn format_roots(roots: &[PathBuf]) -> String { + if roots.is_empty() { + return "".to_string(); + } + let mut out = Vec::new(); + for root in roots { + out.push(root.to_string_lossy().to_string()); + } + out.join(", ") +} + +pub fn is_excluded(path: &Path, exclude_dirs: &[PathBuf], include_paths: &[String]) -> bool { + if matches_patterns(path, include_paths, true) { + return false; + } + if matches_patterns(path, include_paths, false) { + return true; + } exclude_dirs.iter().any(|dir| path.starts_with(dir)) } +fn matches_patterns(path: &Path, patterns: &[String], positive: bool) -> bool { + if patterns.is_empty() { + return false; + } + let cwd = std::env::current_dir().ok(); + let abs_path = if path.is_absolute() { + path.to_path_buf() + } else if let Some(base) = cwd.as_ref() { + base.join(path) + } else { + path.to_path_buf() + }; + let abs = abs_path.to_string_lossy().replace('\\', "/"); + let rel = cwd + .as_ref() + .and_then(|base| abs_path.strip_prefix(base).ok()) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + let rel_dot = if rel.is_empty() { + String::new() + } else { + format!("./{rel}") + }; + for raw in patterns { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let is_neg = trimmed.starts_with('!'); + if is_neg == positive { + continue; + } + let normalized = trimmed.trim_start_matches('!').replace('\\', "/"); + if normalized.is_empty() { + continue; + } + if has_glob_magic(&normalized) { + let pat = match glob::Pattern::new(&normalized) { + Ok(p) => p, + Err(_) => continue, + }; + if pat.matches(&abs) + || (!rel.is_empty() && (pat.matches(&rel) || pat.matches(&rel_dot))) + { + return true; + } + continue; + } + let norm = normalized.trim_end_matches('/'); + let candidate = Path::new(norm); + let full = if candidate.is_absolute() { + candidate.to_path_buf() + } else if let Some(base) = cwd.as_ref() { + base.join(candidate) + } else { + candidate.to_path_buf() + }; + if abs_path.starts_with(&full) { + return true; + } + } + false +} + +pub fn has_glob_magic(value: &str) -> bool { + value.chars().any(|c| matches!(c, '*' | '?' | '[')) +} + pub fn normalize_source_path(path: &Path) -> String { if !path.is_absolute() { return path.to_string_lossy().to_string(); @@ -189,6 +281,10 @@ mod tests { dir } + fn default_roots() -> Vec { + vec![PathBuf::from("src")] + } + #[test] fn object_path_basic() { let obj_dir = Path::new("target/debug/obj"); @@ -260,19 +356,34 @@ mod tests { #[test] fn is_excluded_match() { + let include: Vec = Vec::new(); let excluded = vec![PathBuf::from("/project/src/vendor")]; - assert!(is_excluded(Path::new("/project/src/vendor"), &excluded)); + assert!(is_excluded( + Path::new("/project/src/vendor"), + &excluded, + &include + )); assert!(is_excluded( Path::new("/project/src/vendor/lib.c"), - &excluded + &excluded, + &include )); } #[test] fn is_excluded_no_match() { + let include: Vec = Vec::new(); let excluded = vec![PathBuf::from("/project/src/vendor")]; - assert!(!is_excluded(Path::new("/project/src/main.c"), &excluded)); - assert!(!is_excluded(Path::new("/other/vendor"), &excluded)); + assert!(!is_excluded( + Path::new("/project/src/main.c"), + &excluded, + &include + )); + assert!(!is_excluded( + Path::new("/other/vendor"), + &excluded, + &include + )); } #[test] @@ -287,7 +398,9 @@ mod tests { let _guard = CWD_LOCK.lock().unwrap(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); - let result = collect_sources(&["c"], &[]); + let include: Vec = Vec::new(); + let roots = default_roots(); + let result = collect_sources(&roots, &["c"], &[], &include); std::env::set_current_dir(prev).unwrap(); let sources = result.expect("should find sources"); @@ -309,7 +422,9 @@ mod tests { let _guard = CWD_LOCK.lock().unwrap(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); - let result = collect_sources(&["cpp", "cxx", "cc"], &[]); + let include: Vec = Vec::new(); + let roots = default_roots(); + let result = collect_sources(&roots, &["cpp", "cxx", "cc"], &[], &include); std::env::set_current_dir(prev).unwrap(); let sources = result.expect("should find sources"); @@ -325,7 +440,9 @@ mod tests { let _guard = CWD_LOCK.lock().unwrap(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); - let result = collect_sources(&["c"], &[]); + let include: Vec = Vec::new(); + let roots = default_roots(); + let result = collect_sources(&roots, &["c"], &[], &include); std::env::set_current_dir(prev).unwrap(); assert!(result.is_err(), "empty src should return error"); @@ -344,7 +461,9 @@ mod tests { let _guard = CWD_LOCK.lock().unwrap(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); - let result = collect_sources(&["c"], &exclude); + let include: Vec = Vec::new(); + let roots = default_roots(); + let result = collect_sources(&roots, &["c"], &exclude, &include); std::env::set_current_dir(prev).unwrap(); let sources = result.expect("should find sources"); @@ -364,13 +483,89 @@ mod tests { let _guard = CWD_LOCK.lock().unwrap(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); - let result = collect_sources(&["c"], &[]); + let include: Vec = Vec::new(); + let roots = default_roots(); + let result = collect_sources(&roots, &["c"], &[], &include); std::env::set_current_dir(prev).unwrap(); let sources = result.expect("should find sources"); assert_eq!(sources.len(), 2); } + #[test] + fn include_overrides_exclude() { + let dir = temp_dir("include_override"); + let src = dir.join("src"); + let boot = src.join("boot"); + let arch = boot.join("arch"); + fs::create_dir_all(&arch).unwrap(); + fs::write(arch.join("start.s"), "").unwrap(); + fs::write(boot.join("skip.c"), "").unwrap(); + + let exclude = vec![boot.clone()]; + let include = vec!["src/boot/arch/**".to_string()]; + let _guard = CWD_LOCK.lock().unwrap(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + let roots = default_roots(); + let result = collect_sources(&roots, &["s", "c"], &exclude, &include); + std::env::set_current_dir(prev).unwrap(); + + let sources = result.expect("should find sources"); + assert_eq!(sources.len(), 1); + assert!(sources[0].ends_with("start.s")); + } + + #[test] + fn exclude_glob_with_include_override() { + let dir = temp_dir("exclude_glob"); + let src = dir.join("src"); + let legacy = src.join("legacy"); + let allow = legacy.join("allow"); + fs::create_dir_all(&allow).unwrap(); + fs::write(legacy.join("skip.c"), "").unwrap(); + fs::write(allow.join("keep.c"), "").unwrap(); + + let exclude = vec![dir.join("src"), dir.join(".")]; + let include = vec![ + "!src/legacy/**".to_string(), + "src/legacy/allow/**".to_string(), + ]; + let _guard = CWD_LOCK.lock().unwrap(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + let roots = default_roots(); + let result = collect_sources(&roots, &["c"], &exclude, &include); + std::env::set_current_dir(prev).unwrap(); + + let sources = result.expect("should find sources"); + assert_eq!(sources.len(), 1); + assert!(sources[0].ends_with("keep.c")); + } + + #[test] + fn exclude_glob_without_include_keeps_other_files() { + let dir = temp_dir("exclude_glob_only"); + let src = dir.join("src"); + let gen_dir = src.join("gen"); + fs::create_dir_all(&gen_dir).unwrap(); + fs::write(src.join("main.c"), "").unwrap(); + fs::write(gen_dir.join("skip.c"), "").unwrap(); + + let exclude: Vec = Vec::new(); + let include = vec!["!src/gen/**".to_string()]; + let _guard = CWD_LOCK.lock().unwrap(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + let roots = default_roots(); + let result = collect_sources(&roots, &["c"], &exclude, &include); + std::env::set_current_dir(prev).unwrap(); + + let sources = result.expect("should find sources"); + assert_eq!(sources.len(), 1); + assert!(sources[0].ends_with("main.c")); + } + #[test] fn normalize_relative_path() { let result = normalize_source_path(Path::new("./src/main.c")); diff --git a/src/core/builder/gas.rs b/src/core/builder/gas.rs index 020142a..e39b869 100644 --- a/src/core/builder/gas.rs +++ b/src/core/builder/gas.rs @@ -7,7 +7,8 @@ use std::process::Command; use std::time::Instant; pub fn build(ctx: &BuildContext) -> Result { - if ctx.language.to_lowercase().as_str() != "asm" { + let lang = ctx.language.to_lowercase(); + if lang.split('+').any(|p| p.trim() != "asm") { return Err("GAS backend requires build.language = \"asm\"".to_string()); } let assembler = if ctx.compiler.is_empty() { @@ -91,7 +92,12 @@ pub fn build(ctx: &BuildContext) -> Result { pub(crate) fn collect_sources(ctx: &BuildContext) -> Result, String> { // GAS handles only lowercase .s files - common::collect_sources(&["s"], ctx.exclude_dirs) + common::collect_sources( + ctx.source_roots, + &["s"], + ctx.exclude_dirs, + ctx.include_paths, + ) } fn build_objects( diff --git a/src/core/builder/mod.rs b/src/core/builder/mod.rs index 3c95c91..a513912 100644 --- a/src/core/builder/mod.rs +++ b/src/core/builder/mod.rs @@ -15,7 +15,9 @@ pub struct BuildContext<'a> { pub platform: Option<&'a str>, pub linker: Option<&'a str>, pub archiver: Option<&'a str>, + pub source_roots: &'a [std::path::PathBuf], pub exclude_dirs: &'a [std::path::PathBuf], + pub include_paths: &'a [String], pub include_dirs: &'a [String], pub lib_dirs: &'a [String], pub libs: &'a [String], diff --git a/src/core/builder/msvc.rs b/src/core/builder/msvc.rs index ea79817..6c2cd4b 100644 --- a/src/core/builder/msvc.rs +++ b/src/core/builder/msvc.rs @@ -12,8 +12,9 @@ pub fn build(ctx: &BuildContext) -> Result { } else { ctx.compiler }; - if ctx.language.to_lowercase() == "asm" { - return Err("MSVC backend does not support build.language = \"asm\"".to_string()); + let lang = ctx.language.to_lowercase(); + if lang.contains("asm") { + return Err("MSVC backend does not support build.language with asm".to_string()); } let start_time = Instant::now(); let sources = collect_sources(ctx)?; @@ -109,16 +110,29 @@ pub fn build(ctx: &BuildContext) -> Result { pub(crate) fn collect_sources(ctx: &BuildContext) -> Result, String> { let extensions = source_extensions(ctx.language); - common::collect_sources(&extensions, ctx.exclude_dirs) + common::collect_sources( + ctx.source_roots, + &extensions, + ctx.exclude_dirs, + ctx.include_paths, + ) } fn source_extensions(language: &str) -> Vec<&str> { - let lang = language.to_lowercase(); - match lang.as_str() { - "c" => vec!["c"], - "c++" | "cpp" | "cxx" => vec!["cpp", "cxx", "cc"], - _ => vec!["c"], + let mut out = Vec::new(); + for part in language.split('+') { + let lang = part.trim().to_lowercase(); + match lang.as_str() { + "c" => out.extend(["c"]), + "c++" | "cpp" | "cxx" => out.extend(["cpp", "cxx", "cc"]), + "asm" => {} + _ => {} + } } + if out.is_empty() { + out.extend(["c"]); + } + out } fn msvc_standard_flag(language: &str, standard: &str) -> Result { diff --git a/src/core/builder/nasm.rs b/src/core/builder/nasm.rs index 0adc449..c479155 100644 --- a/src/core/builder/nasm.rs +++ b/src/core/builder/nasm.rs @@ -7,7 +7,8 @@ use std::process::Command; use std::time::Instant; pub fn build(ctx: &BuildContext) -> Result { - if ctx.language.to_lowercase().as_str() != "asm" { + let lang = ctx.language.to_lowercase(); + if lang.split('+').any(|p| p.trim() != "asm") { return Err("NASM backend requires build.language = \"asm\"".to_string()); } let assembler = if ctx.compiler.is_empty() { @@ -91,7 +92,12 @@ pub fn build(ctx: &BuildContext) -> Result { pub(crate) fn collect_sources(ctx: &BuildContext) -> Result, String> { // NASM handles lowercase .s and .asm only (not .S which is GCC preprocessed) - common::collect_sources(&["asm", "s"], ctx.exclude_dirs) + common::collect_sources( + ctx.source_roots, + &["asm", "s"], + ctx.exclude_dirs, + ctx.include_paths, + ) } fn build_objects( diff --git a/src/core/builder/unix_cc.rs b/src/core/builder/unix_cc.rs index 03d3ef9..8f017c5 100644 --- a/src/core/builder/unix_cc.rs +++ b/src/core/builder/unix_cc.rs @@ -14,7 +14,12 @@ pub fn build(ctx: &BuildContext) -> Result { }; let start_time = Instant::now(); let extensions = source_extensions(ctx.language); - let sources = common::collect_sources(&extensions, ctx.exclude_dirs)?; + let sources = common::collect_sources( + ctx.source_roots, + &extensions, + ctx.exclude_dirs, + ctx.include_paths, + )?; let obj_dir = Path::new("./target").join(ctx.profile).join("obj"); let objects = build_objects(compiler, &sources, &obj_dir, ctx, "o")?; @@ -80,17 +85,29 @@ pub fn build(ctx: &BuildContext) -> Result { pub(crate) fn collect_sources(ctx: &BuildContext) -> Result, String> { let extensions = source_extensions(ctx.language); - common::collect_sources(&extensions, ctx.exclude_dirs) + common::collect_sources( + ctx.source_roots, + &extensions, + ctx.exclude_dirs, + ctx.include_paths, + ) } fn source_extensions(language: &str) -> Vec<&str> { - let lang = language.to_lowercase(); - match lang.as_str() { - "c" => vec!["c"], - "c++" | "cpp" | "cxx" => vec!["cpp", "cxx", "cc"], - "asm" => vec!["s", "S", "asm"], - _ => vec!["c"], + let mut out = Vec::new(); + for part in language.split('+') { + let lang = part.trim().to_lowercase(); + match lang.as_str() { + "c" => out.extend(["c"]), + "c++" | "cpp" | "cxx" => out.extend(["cpp", "cxx", "cc"]), + "asm" => out.extend(["s", "S", "asm"]), + _ => {} + } } + if out.is_empty() { + out.extend(["c"]); + } + out } fn default_flags(profile: &str) -> &'static [&'static str] { @@ -139,6 +156,9 @@ fn build_objects( cmd.arg(format!("-std={}", ctx.standard)); } for flag in default_flags(ctx.profile) { + if ctx.platform.is_some() && *flag == "-march=native" { + continue; + } cmd.arg(flag); } for flag in ctx.cflags { diff --git a/src/core/config.rs b/src/core/config.rs index bac2654..367d181 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -115,7 +115,12 @@ impl Config { .and_then(|v| v.as_table()) .ok_or_else(|| ConfigError::Invalid("missing [build]".into()))?; - for key in ["language", "standard", "compiler"] { + if let Some(language) = build.get("language") { + self.validate_language(language)?; + } else { + return Err(ConfigError::Invalid("build.language is empty".into())); + } + for key in ["standard", "compiler"] { let value = build.get(key).and_then(|v| v.as_str()).unwrap_or(""); if value.trim().is_empty() { return Err(ConfigError::Invalid(format!("build.{key} is empty"))); @@ -144,6 +149,73 @@ impl Config { return Err(ConfigError::Invalid("build.kind is invalid".into())); } } + if let Some(exclude) = build.get("exclude") { + let arr = exclude.as_array().ok_or_else(|| { + ConfigError::Invalid("build.exclude must be an array of strings".into()) + })?; + for item in arr { + let s = item.as_str().unwrap_or(""); + if s.trim().is_empty() { + return Err(ConfigError::Invalid( + "build.exclude contains empty value".into(), + )); + } + } + } + if let Some(include) = build.get("include") { + let arr = include.as_array().ok_or_else(|| { + ConfigError::Invalid("build.include must be an array of strings".into()) + })?; + for item in arr { + let s = item.as_str().unwrap_or(""); + if s.trim().is_empty() { + return Err(ConfigError::Invalid( + "build.include contains empty value".into(), + )); + } + } + } + if let Some(roots) = build.get("roots") { + let arr = roots.as_array().ok_or_else(|| { + ConfigError::Invalid("build.roots must be an array of strings".into()) + })?; + for item in arr { + let s = item.as_str().unwrap_or(""); + if s.trim().is_empty() { + return Err(ConfigError::Invalid( + "build.roots contains empty value".into(), + )); + } + } + } + if let Some(src_disable) = build.get("src_disable") + && !src_disable.is_bool() + { + return Err(ConfigError::Invalid( + "build.src_disable must be a boolean".into(), + )); + } + if let Some(clean) = build.get("clean") { + let arr = clean.as_array().ok_or_else(|| { + ConfigError::Invalid("build.clean must be an array of strings".into()) + })?; + for item in arr { + let s = item.as_str().unwrap_or(""); + if s.trim().is_empty() { + return Err(ConfigError::Invalid( + "build.clean contains empty value".into(), + )); + } + } + } + for profile in ["release", "debug"] { + if let Some(section) = build.get(profile) { + let table = section.as_table().ok_or_else(|| { + ConfigError::Invalid(format!("build.{profile} must be a table")) + })?; + self.validate_profile_section(profile, table)?; + } + } self.validate_workspace()?; Ok(()) } @@ -161,6 +233,115 @@ impl Config { } impl Config { + fn validate_language(&self, value: &Value) -> Result<(), ConfigError> { + if let Some(s) = value.as_str() { + if s.trim().is_empty() { + return Err(ConfigError::Invalid("build.language is empty".into())); + } + return Ok(()); + } + let arr = value + .as_array() + .ok_or_else(|| ConfigError::Invalid("build.language must be string or array".into()))?; + if arr.is_empty() { + return Err(ConfigError::Invalid("build.language is empty".into())); + } + for item in arr { + let s = item.as_str().unwrap_or(""); + if s.trim().is_empty() { + return Err(ConfigError::Invalid( + "build.language contains empty value".into(), + )); + } + } + Ok(()) + } + + fn validate_profile_section( + &self, + profile: &str, + table: &toml::value::Table, + ) -> Result<(), ConfigError> { + if let Some(lang) = table.get("language") { + self.validate_language(lang)?; + } + for key in ["standard", "compiler", "kind", "target", "platform"] { + if let Some(value) = table.get(key) { + let s = value.as_str().unwrap_or(""); + if s.trim().is_empty() { + return Err(ConfigError::Invalid(format!( + "build.{profile}.{key} is empty" + ))); + } + } + } + if let Some(kind) = table.get("kind").and_then(|v| v.as_str()) { + let kind = kind.trim(); + if !kind.is_empty() && kind != "bin" && kind != "staticlib" && kind != "sharedlib" { + return Err(ConfigError::Invalid(format!( + "build.{profile}.kind is invalid" + ))); + } + } + if let Some(src_disable) = table.get("src_disable") + && !src_disable.is_bool() + { + return Err(ConfigError::Invalid(format!( + "build.{profile}.src_disable must be boolean" + ))); + } + for key in [ + "cflags", + "ldflags", + "exclude", + "include", + "roots", + "pkg_config", + "generated", + "expect", + "clean", + ] { + if let Some(val) = table.get(key) { + let arr = val.as_array().ok_or_else(|| { + ConfigError::Invalid(format!( + "build.{profile}.{key} must be an array of strings" + )) + })?; + for item in arr { + let s = item.as_str().unwrap_or(""); + if s.trim().is_empty() { + return Err(ConfigError::Invalid(format!( + "build.{profile}.{key} contains empty value" + ))); + } + } + } + } + for key in ["steps", "post_steps"] { + if let Some(val) = table.get(key) { + let arr = val.as_array().ok_or_else(|| { + ConfigError::Invalid(format!("build.{profile}.{key} must be an array")) + })?; + for item in arr { + let tbl = item.as_table().ok_or_else(|| { + ConfigError::Invalid(format!( + "build.{profile}.{key} entries must be tables" + )) + })?; + for req in ["name", "in", "out", "cmd"] { + let s = tbl.get(req).and_then(|v| v.as_str()).unwrap_or(""); + if s.trim().is_empty() { + return Err(ConfigError::Invalid(format!( + "build.{profile}.{key} missing {req}" + ))); + } + } + } + } + } + Ok(()) + } + fn validate_workspace(&self) -> Result<(), ConfigError> { let Some(workspace) = self.get("workspace").and_then(|v| v.as_table()) else { return Ok(()); @@ -264,7 +445,12 @@ fn format_toml(value: &Value) -> Result { .and_then(|v| v.as_str()) .unwrap_or(""); - let language = build.get("language").and_then(|v| v.as_str()).unwrap_or(""); + let language_value = build.get("language"); + let language = match language_value { + Some(Value::String(s)) => s.to_string(), + Some(Value::Array(arr)) => format_string_array(&Value::Array(arr.clone())), + _ => "".to_string(), + }; let standard = build.get("standard").and_then(|v| v.as_str()).unwrap_or(""); let compiler = build.get("compiler").and_then(|v| v.as_str()).unwrap_or(""); @@ -274,7 +460,11 @@ fn format_toml(value: &Value) -> Result { out.push_str(&format!("version = \"{version}\"\n\n")); out.push_str("[build]\n"); - out.push_str(&format!("language = \"{language}\"\n")); + if language.starts_with('[') { + out.push_str(&format!("language = {language}\n")); + } else { + out.push_str(&format!("language = \"{language}\"\n")); + } out.push_str(&format!("standard = \"{standard}\"\n")); out.push_str(&format!("compiler = \"{compiler}\"\n")); let kind = build @@ -474,6 +664,28 @@ mod tests { assert!(result.is_err(), "empty language should fail validation"); } + #[test] + fn validate_language_array() { + let dir = temp_dir("lang_array"); + let path = write_toml_file( + &dir, + "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = [\"c\", \"c++\", \"asm\"]\nstandard = \"c11\"\ncompiler = \"clang\"\n\n[dependencies]\n", + ); + let result = Config::open(&path.to_string_lossy()); + assert!(result.is_ok(), "language array should be valid"); + } + + #[test] + fn validate_language_array_empty_fails() { + let dir = temp_dir("lang_array_empty"); + let path = write_toml_file( + &dir, + "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = []\nstandard = \"c11\"\ncompiler = \"clang\"\n\n[dependencies]\n", + ); + let result = Config::open(&path.to_string_lossy()); + assert!(result.is_err(), "empty language array should fail"); + } + #[test] fn validate_unknown_kind_fails() { let dir = temp_dir("bad_kind"); diff --git a/src/main.rs b/src/main.rs index f07057f..a6edff3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ fn main() { "build" => cli::build::build(rest), "run" => cli::run::run(rest), "clean" => cli::clean::clean(rest), + "gen" => cli::r#gen::r#gen(rest), "--version" => { println!("dcr {} ({})", env!("CARGO_PKG_VERSION"), env!("DCR_TARGET")); 0