- `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
This commit is contained in:
2026-03-19 00:02:04 +08:00
parent 0e93a1aa0f
commit 5f0dca4bb3
28 changed files with 1416 additions and 200 deletions

View File

@@ -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<String, i32> {
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<String, String> {
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<String, String> {
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<Vec<String>, 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<Vec<String>, 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<String, String> {
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<String> {
let value = config.get(key)?.as_str()?;
let trimmed = value.trim();
@@ -96,10 +215,68 @@ fn get_config_list(config: &Config, key: &str) -> Result<Vec<String>, String> {
Ok(out)
}
fn get_build_steps_from_value(value: &toml::Value, key: &str) -> Result<Vec<BuildStep>, 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<Vec<BuildStep>, 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<f64, String> {
}
}
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<std::path::PathBuf> =
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<String> = 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<PathBuf> = 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<String> = 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<String> {
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<Vec<BuildStep>, String> {
let value = match config.get("build.steps") {
fn get_build_steps(config: &Config, key: &str) -> Result<Vec<BuildStep>, 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<Vec<PathBuf>, 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<std::process::ExitStatus, std::io::Err
}
}
fn build_steps_need_run(steps: &[BuildStep]) -> Result<bool, String> {
fn build_steps_need_run(steps: &[BuildStep], vars: &StepVars) -> Result<bool, String> {
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<bool, String> {
));
}
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<bool, String> {
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<Vec<std::path::PathBuf>, 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<std::path::PathBuf>,
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 {

View File

@@ -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::<Vec<String>>(),
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,
}
}

View File

@@ -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 <subcommand> 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);

View File

@@ -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;

View File

@@ -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,
}
}

View File

@@ -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<Vec<String>, 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<String>,
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 "<none>".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<PathBuf> {
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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<PathBuf> = 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"));

View File

@@ -7,7 +7,8 @@ use std::process::Command;
use std::time::Instant;
pub fn build(ctx: &BuildContext) -> Result<f64, String> {
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<f64, String> {
pub(crate) fn collect_sources(ctx: &BuildContext) -> Result<Vec<String>, 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(

View File

@@ -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],

View File

@@ -12,8 +12,9 @@ pub fn build(ctx: &BuildContext) -> Result<f64, String> {
} 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<f64, String> {
pub(crate) fn collect_sources(ctx: &BuildContext) -> Result<Vec<String>, 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<String, String> {

View File

@@ -7,7 +7,8 @@ use std::process::Command;
use std::time::Instant;
pub fn build(ctx: &BuildContext) -> Result<f64, String> {
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<f64, String> {
pub(crate) fn collect_sources(ctx: &BuildContext) -> Result<Vec<String>, 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(

View File

@@ -14,7 +14,12 @@ pub fn build(ctx: &BuildContext) -> Result<f64, String> {
};
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<f64, String> {
pub(crate) fn collect_sources(ctx: &BuildContext) -> Result<Vec<String>, 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 {

View File

@@ -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<String, ConfigError> {
.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<String, ConfigError> {
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");

View File

@@ -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