#![allow(clippy::uninlined_format_args)]
use crate::{
ast::{infer_deps_from_ast, infer_deps_from_source},
code_utils::get_source_path,
config::DependencyInference,
maybe_config, Ast, BuildState, Dependencies, Style, ThagError, ThagResult,
};
use cargo_lookup::{Package, Query, Release};
use cargo_toml::{Dependency, DependencyDetail, Edition, Manifest};
use regex::Regex;
use serde_merge::omerge;
use std::{collections::BTreeMap, env, path::PathBuf, str::FromStr, time::Instant};
use syn::{parse_file, File};
use thag_common::{debug_log, get_verbosity, re, vprtln, V};
use thag_proc_macros::styled;
use thag_profiler::{end, profile, profiled};
use thag_styling::{svprtln, AnsiStyleExt, Role};
#[cfg(debug_assertions)]
use crate::debug_timings;
#[allow(clippy::missing_panics_doc)]
#[must_use]
#[profiled]
pub fn cargo_lookup(dep_crate: &str) -> Option<(String, String)> {
let crate_variants = vec![dep_crate.to_string(), dep_crate.replace('_', "-")];
for crate_name in crate_variants {
let query: Query = match crate_name.parse() {
Ok(q) => q,
Err(e) => {
debug_log!("Failed to parse query for crate {}: {}", crate_name, e);
continue;
}
};
match query.package() {
Ok(package) => {
debug_log!(
"Found package {} with {} releases",
package.name(),
package.releases().len()
);
let release = highest_release(&package);
match release {
Some(r) => {
debug_log!("Selected stable version: {}", r.vers);
let name = r.name.clone();
let version = r.vers.to_string();
if name == dep_crate || name == dep_crate.replace('_', "-") {
return Some((name, version));
}
}
None => {
debug_log!("No stable version found for {}", crate_name);
}
}
}
Err(e) => {
debug_log!("Failed to look up crate {}: {}", crate_name, e);
}
}
}
None
}
fn highest_release(pkg: &Package) -> Option<&Release> {
pkg.releases()
.iter()
.filter(|r| !r.yanked)
.filter(|r| r.vers.pre.is_empty())
.max_by_key(|r| r.vers.clone()) }
#[profiled]
pub fn capture_dep(first_line: &str) -> ThagResult<(String, String)> {
debug_log!("first_line={first_line}");
let re: &Regex = re!(r#"^(?P<name>[\w-]+) = "(?P<version>\d+\.\d+\.\d+)"#);
let (name, version) = if re.is_match(first_line) {
let captures = re.captures(first_line).unwrap();
let name = captures.get(1).unwrap().as_str();
let version = captures.get(2).unwrap().as_str();
(String::from(name), String::from(version))
} else {
vprtln!(V::QQ, "Not a valid Cargo dependency format.");
return Err("Not a valid Cargo dependency format".into());
};
Ok((name, version))
}
#[profiled]
pub fn configure_default(build_state: &BuildState) -> ThagResult<Manifest> {
let source_stem = &build_state.source_stem;
let gen_src_path = get_source_path(build_state);
debug_log!(
r"build_state.build_from_orig_source={}
gen_src_path={gen_src_path}",
build_state.build_from_orig_source
);
default(source_stem, &gen_src_path)
}
#[profiled]
pub fn default(source_stem: &str, gen_src_path: &str) -> ThagResult<Manifest> {
let cargo_manifest = format!(
r#"[package]
name = "{}"
version = "0.0.1"
edition = "2021"
[dependencies]
[features]
[patch]
[workspace]
[[bin]]
name = "{}"
path = "{}"
"#,
source_stem, source_stem, gen_src_path
);
Ok(Manifest::from_str(&cargo_manifest)?)
}
#[profiled]
pub fn merge(build_state: &mut BuildState, rs_source: &str) -> ThagResult<()> {
#[cfg(debug_assertions)]
let start_merge_manifest = Instant::now();
let default_cargo_manifest = configure_default(build_state)?;
let cargo_manifest = build_state
.cargo_manifest
.take()
.map_or(default_cargo_manifest, |manifest| manifest);
profile!(infer_deps, time);
let rs_inferred_deps = if let Some(ref use_crates) = build_state.crates_finder {
build_state.metadata_finder.as_ref().map_or_else(
|| infer_deps_from_source(rs_source),
|metadata_finder| infer_deps_from_ast(use_crates, metadata_finder),
)
} else {
infer_deps_from_source(rs_source)
};
end!(infer_deps);
profile!(merge_manifest, time);
let merged_manifest = if let Some(ref mut rs_manifest) = build_state.rs_manifest {
if !rs_inferred_deps.is_empty() {
#[cfg(debug_assertions)]
debug_log!(
"rs_dep_map (before inferred) {:#?}",
rs_manifest.dependencies
);
lookup_deps(
&build_state.infer,
&rs_inferred_deps,
&mut rs_manifest.dependencies,
);
#[cfg(debug_assertions)]
debug_log!(
"rs_dep_map (after inferred) {:#?}",
rs_manifest.dependencies
);
}
call_omerge(&cargo_manifest, rs_manifest)?
} else {
cargo_manifest
};
build_state.cargo_manifest = Some(merged_manifest);
end!(merge_manifest);
#[cfg(debug_assertions)]
debug_timings(&start_merge_manifest, "Processed features");
Ok(())
}
#[profiled]
fn call_omerge(cargo_manifest: &Manifest, rs_manifest: &mut Manifest) -> ThagResult<Manifest> {
Ok(omerge(cargo_manifest, rs_manifest)?)
}
#[must_use]
#[profiled]
pub fn find_use_renames_source(code: &str) -> (Vec<String>, Vec<String>) {
debug_log!("In code_utils::find_use_renames_source");
let use_as_regex: &Regex = re!(r"(?m)^\s*use\s+(\w+).*? as\s+(\w+)");
let mut use_renames_from: Vec<String> = vec![];
let mut use_renames_to: Vec<String> = vec![];
for cap in use_as_regex.captures_iter(code) {
let from_name = cap[1].to_string();
let to_name = cap[2].to_string();
debug_log!("use_rename: from={from_name}, to={to_name}");
use_renames_from.push(from_name);
use_renames_to.push(to_name);
}
use_renames_from.sort();
use_renames_from.dedup();
debug_log!("use_renames from source: from={use_renames_from:#?}, to={use_renames_to:#?}");
(use_renames_from, use_renames_to)
}
#[profiled]
pub fn extract(
rs_full_source: &str,
#[allow(unused_variables)] start_parsing_rs: Instant,
) -> ThagResult<Manifest> {
let maybe_rs_toml = extract_toml_block(rs_full_source);
profile!(parse, mem_summary, time);
let mut rs_manifest = if let Some(rs_toml_str) = maybe_rs_toml {
Manifest::from_str(&rs_toml_str)?
} else {
Manifest::from_str("")?
};
end!(parse);
profile!(set_edition, mem_summary, time);
if let Some(package) = rs_manifest.package.as_mut() {
package.edition = cargo_toml::Inheritable::Set(Edition::E2021);
}
end!(set_edition);
#[cfg(debug_assertions)]
debug_timings(&start_parsing_rs, "extract_manifest parsed source");
Ok(rs_manifest)
}
#[profiled]
pub fn process_thag_auto_dependencies(build_state: &mut BuildState) -> ThagResult<()> {
if let Some(ref mut rs_manifest) = build_state.rs_manifest {
let thag_crates = [
"thag_common",
"thag_rs",
"thag_proc_macros",
"thag_profiler",
"thag_styling",
];
for crate_name in &thag_crates {
if let Some(dependency) = rs_manifest.dependencies.get(*crate_name) {
if should_process_thag_auto(dependency) {
let new_dependency = resolve_thag_dependency(crate_name, dependency)?;
rs_manifest
.dependencies
.insert((*crate_name).to_string(), new_dependency);
build_state.thag_auto_processed = true;
}
}
for target in rs_manifest.target.values_mut() {
if let Some(dependency) = target.dependencies.get_mut(*crate_name) {
if should_process_thag_auto(dependency) {
*dependency = resolve_thag_dependency(crate_name, &dependency.clone())?;
build_state.thag_auto_processed = true;
}
}
}
}
}
Ok(())
}
fn should_process_thag_auto(dependency: &Dependency) -> bool {
match dependency {
Dependency::Detailed(detail) => {
detail
.version
.as_ref()
.is_some_and(|v| v.contains("thag-auto"))
}
Dependency::Simple(version) => version.contains("thag-auto"),
Dependency::Inherited(_) => false,
}
}
fn resolve_thag_dependency(
crate_name: &str,
original_dep: &Dependency,
) -> ThagResult<cargo_toml::Dependency> {
let (base_version, features, default_features) = match original_dep {
Dependency::Detailed(detail) => {
let version = detail.version.as_ref().and_then(|v| {
if v.contains("thag-auto") {
v.split(',').next().map(|s| s.trim().to_string())
} else {
Some(v.clone())
}
});
(version, detail.features.clone(), detail.default_features)
}
Dependency::Simple(version) => {
let version = if version.contains("thag-auto") {
version.split(',').next().map(|s| s.trim().to_string())
} else {
Some(version.clone())
};
(version, Vec::new(), true)
}
Dependency::Inherited(_) => return Ok(original_dep.clone()),
};
let mut new_detail = Box::new(DependencyDetail {
features,
default_features,
..Default::default()
});
let is_ci = env::var("CI").is_ok();
let git_ref_env_var = if is_ci { "GITHUB_REF" } else { "THAG_GIT_REF" };
if let Ok(dev_path) = env::var("THAG_DEV_PATH") {
let crate_path = match crate_name {
"thag_common" => format!("{}/thag_common", dev_path),
"thag_proc_macros" => format!("{}/thag_proc_macros", dev_path),
"thag_profiler" => format!("{}/thag_profiler", dev_path),
"thag_styling" => format!("{}/thag_styling", dev_path),
_ => dev_path,
};
new_detail.path = Some(crate_path);
debug_log!("Using local path for {}: {:?}", crate_name, new_detail.path);
} else if is_ci || env::var(git_ref_env_var).is_ok() {
let git_repo = env::var("THAG_GIT_REPO")
.unwrap_or_else(|_| "https://github.com/durbanlegend/thag_rs".to_string());
let git_ref = env::var(git_ref_env_var).map_or_else(
|_| "main".to_string(),
|s| {
let var_start = s.rfind('/').map_or_else(|| 0, |pos| pos + 1);
s[var_start..].to_string()
},
);
new_detail.git = Some(git_repo);
new_detail.branch = Some(git_ref);
debug_log!(
"Using git dependency for {}: {:?} @ {:?}",
crate_name,
new_detail.git,
new_detail.branch
);
} else {
if let Some(version) = base_version {
let query: Query = format!("{crate_name}@={version}")
.parse()
.map_err(|e| ThagError::FromStr(format!("Failed to parse query: {e}").into()))?;
let result = query.submit();
if let Ok(Some(release)) = result {
let vers = release.vers;
new_detail.version = Some(format!("{}.{}.{}", vers.major, vers.minor, vers.patch));
debug_log!(
"Using crates.io version for {crate_name}: {:?}",
new_detail.version
);
} else {
display_thag_auto_help();
return Err(ThagError::FromStr(
format!("{crate_name} version {} not found in crates.io", version).into(),
));
}
}
}
Ok(Dependency::Detailed(new_detail))
}
fn display_thag_auto_help() {
svprtln!(
Role::ERR,
V::N,
"Build failed - thag dependency issue detected"
);
svprtln!(
Role::EMPH,
V::N,
r"
This script uses thag dependencies (thag_common, thag_rs, thag_proc_macros, thag_profiler or thag_styling)
with the 'thag-auto' keyword, which automatically resolves to the appropriate
dependency source based on your environment.
The most likely issue is that the version specified in the script doesn't exist
on crates.io yet. To fix this, you have several options:
1. DEVELOPMENT (recommended): Set environment variable to use local path
{}, e.g.:
{}
2. GIT DEPENDENCY: Use git reference to get the latest version
export THAG_GIT_REF=main
3. ALWAYS RUN THROUGH THAG: Use 'thag script.rs' instead of 'cargo build'
(This allows thag-auto processing to work properly)
The thag-auto system is designed to work with crates.io by default, falling back
to git or local paths when environment variables are set. This allows the same
script to work in different environments without modification.
For more details, see the comments in demo scripts or the thag documentation.",
if cfg!(target_os = "windows") {
"(Assuming PowerShell:) $env:THAG_DEV_PATH = absolute\\path\\to\\thag_rs"
} else {
"export THAG_DEV_PATH=/absolute/path/to/thag_rs"
},
styled!(
if cfg!(target_os = "windows") {
"$env:THAG_DEV_PATH = $PWD"
} else {
"export THAG_DEV_PATH=$PWD"
},
bold,
reversed
)
);
}
#[profiled]
fn extract_toml_block(input: &str) -> Option<String> {
let re: &Regex = re!(r"(?s)/\*\[toml\](.*?)\*/");
re.captures(input)
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
}
#[profiled]
pub fn extract_and_wrap_uses(source: &str) -> Result<Ast, syn::Error> {
let use_simple_regex: &Regex = re!(r"(?m)(^\s*use\s+[^;{]+;\s*$)");
let use_nested_regex: &Regex = re!(r"(?ms)(^\s*use\s+\{.*\};\s*$)");
let mut use_statements: Vec<String> = vec![];
for cap in use_simple_regex.captures_iter(source) {
let use_string = cap[1].to_string();
use_statements.push(use_string);
}
for cap in use_nested_regex.captures_iter(source) {
let use_string = cap[1].to_string();
use_statements.push(use_string);
}
let ast: File = parse_file(&use_statements.join("\n"))?;
Ok(Ast::File(ast))
}
#[profiled]
fn clean_features(features: Vec<String>) -> Vec<String> {
let mut features: Vec<String> = features
.into_iter()
.filter(|f| !f.contains('/')) .collect();
features.sort();
features
}
#[profiled]
fn get_crate_features(name: &str) -> Option<Vec<String>> {
let query: Query = match name.parse() {
Ok(q) => q,
Err(e) => {
debug_log!("Failed to parse query for crate {}: {}", name, e);
return None;
}
};
match query.package() {
Ok(package) => {
let latest = package.into_latest()?;
let mut all_features: Vec<String> = latest.features.keys().cloned().collect();
if let Some(features2) = latest.features2 {
all_features.extend(features2.keys().cloned());
}
if all_features.is_empty() {
None
} else {
Some(clean_features(all_features))
}
}
Err(e) => {
debug_log!("Failed to get features for crate {}: {}", name, e);
None
}
}
}
#[allow(clippy::missing_panics_doc)]
#[profiled]
pub fn lookup_deps(
inference_level: &DependencyInference,
rs_inferred_deps: &[String],
rs_dep_map: &mut BTreeMap<String, Dependency>,
) {
if rs_inferred_deps.is_empty() {
return;
}
let existing_toml_block = !&rs_dep_map.is_empty();
let mut new_inferred_deps: Vec<String> = vec![];
let recomm_style = &Style::for_role(Role::Heading1);
let recomm_inf_level = &DependencyInference::Config;
let actual_style = if inference_level == recomm_inf_level {
recomm_style
} else {
&Style::for_role(Role::Emphasis)
};
let styled_inference_level = actual_style.paint(inference_level.to_string());
let styled_recomm_inf_level = recomm_style.paint(recomm_inf_level.to_string());
svprtln!(
Role::NORM,
V::V,
"\x1b[0mRecommended dependency inference_level={styled_recomm_inf_level}, actual={styled_inference_level}"
);
let config = maybe_config();
let binding = Dependencies::default();
let dep_config = config.as_ref().map_or(&binding, |c| &c.dependencies);
for dep_name in rs_inferred_deps {
if dep_name == "thag_demo_proc_macros" {
proc_macros_magic(rs_dep_map, dep_name, "demo");
continue;
} else if dep_name == "thag_bank_proc_macros" {
proc_macros_magic(rs_dep_map, dep_name, "bank");
continue;
} else if rs_dep_map.contains_key(dep_name) {
continue;
}
if let Some((name, version)) = cargo_lookup(dep_name) {
if rs_dep_map.contains_key(&name) || rs_dep_map.contains_key(dep_name.as_str()) {
continue;
}
new_inferred_deps.push(name.clone());
let features = get_crate_features(&name);
match inference_level {
DependencyInference::None => {
}
DependencyInference::Min => {
insert_simple(rs_dep_map, name, version);
}
DependencyInference::Config | DependencyInference::Max => {
if let Some(ref all_features) = features {
let features_for_inference_level = dep_config
.get_features_for_inference_level(&name, all_features, inference_level);
if let (Some(final_features), default_features) =
features_for_inference_level
{
rs_dep_map.entry(name.clone()).or_insert_with(|| {
Dependency::Detailed(Box::new(DependencyDetail {
version: Some(version.clone()),
features: final_features,
default_features,
..Default::default()
}))
});
} else {
insert_simple(rs_dep_map, name, version);
}
} else {
insert_simple(rs_dep_map, name, version);
}
}
}
}
}
if get_verbosity() < V::V
|| matches!(inference_level, DependencyInference::None)
|| new_inferred_deps.is_empty()
{
return;
}
display_toml_info(
existing_toml_block,
&new_inferred_deps,
rs_dep_map,
inference_level,
);
}
#[profiled]
fn insert_simple(rs_dep_map: &mut BTreeMap<String, Dependency>, name: String, version: String) {
rs_dep_map
.entry(name)
.or_insert_with(|| Dependency::Simple(version));
}
#[profiled]
fn display_toml_info(
existing_toml_block: bool,
new_inferred_deps: &[String],
rs_dep_map: &BTreeMap<String, Dependency>,
inference_level: &DependencyInference,
) {
let mut toml_block = String::new();
if !existing_toml_block {
toml_block.push_str("/*[toml]\n[dependencies]\n");
}
for dep_name in new_inferred_deps {
let value = rs_dep_map.get(dep_name);
match value {
Some(Dependency::Simple(string)) => {
let dep_line = format!("{dep_name} = \"{string}\"\n");
toml_block.push_str(&dep_line);
}
Some(Dependency::Detailed(dep)) => {
if dep.features.is_empty() {
let dep_line = format!(
"{dep_name} = \"{}\"\n",
dep.version
.as_ref()
.unwrap_or_else(|| panic!("Error unwrapping version for {dep_name}")),
);
toml_block.push_str(&dep_line);
} else {
let maybe_default_features = if dep.default_features {
""
} else {
", default-features = false"
};
let dep_line = format!(
"{} = {{ version = \"{}\"{maybe_default_features}, features = [{}] }}\n",
dep_name,
dep.version
.as_ref()
.unwrap_or_else(|| panic!("Error unwrapping version for {dep_name}")),
dep.features
.iter()
.map(|f| format!("\"{}\"", f))
.collect::<Vec<_>>()
.join(", ")
);
toml_block.push_str(&dep_line);
}
}
Some(Dependency::Inherited(_)) | None => (),
}
}
if !existing_toml_block {
toml_block.push_str("*/");
}
let styled_toml_block = Style::for_role(Role::Heading2).paint(&toml_block);
let styled_inference_level = Style::for_role(Role::EMPH).paint(inference_level.to_string());
let wording = if existing_toml_block {
format!("This is the {styled_inference_level} manifest information that was generated for this run. If you want to, you can merge it into the existing toml block at")
} else {
format!("This toml block contains the same {styled_inference_level} manifest information that was generated for this run. If you want to, you can copy it into")
};
vprtln!(
V::N,
"\n{wording} the top of your script, to lock it down or maybe compile a little faster in future:\n{styled_toml_block}\n"
);
}
#[profiled]
fn proc_macros_magic(
rs_dep_map: &mut BTreeMap<String, Dependency>,
dep_name: &str,
dir_name: &str,
) {
svprtln!(
Role::INFO,
V::V,
r#"Found magic import `{dep_name}`: attempting to generate path dependency from `proc_macros.(...)proc_macro_crate_path` in config file ".../config.toml"."#
);
let default_proc_macros_dir = format!("{dir_name}/proc_macros");
let maybe_magic_proc_macros_dir = maybe_config().map_or_else(
|| {
debug_log!(
r#"Missing config file for "use {dep_name};", defaulting to "{dir_name}/proc_macros"."#
);
Some(default_proc_macros_dir.clone())
},
|config| {
debug_log!("Found config.proc_macros={:#?}", config.proc_macros);
if dep_name == "thag_demo_proc_macros" {
config.proc_macros.demo_proc_macro_crate_path
} else if dep_name == "thag_bank_proc_macros" {
config.proc_macros.bank_proc_macro_crate_path
} else {
None
}
},
);
let magic_proc_macros_dir = maybe_magic_proc_macros_dir.as_ref().map_or_else(|| {
svprtln!(
Role::INFO,
V::V,
r#"No `config.proc_macros.proc_macro_crate_path` in config file for "use {dep_name};": defaulting to "{default_proc_macros_dir}"."#
);
default_proc_macros_dir
}, |proc_macros_dir| {
svprtln!(Role::INFO, V::V, "Found {proc_macros_dir:#?}.");
proc_macros_dir.to_string()
});
let path = PathBuf::from_str(&magic_proc_macros_dir).unwrap();
let path = if path.is_absolute() {
path
} else {
path.canonicalize()
.unwrap_or_else(|_| panic!("Could not canonicalize path {}", path.display()))
};
let dep = Dependency::Detailed(Box::new(DependencyDetail {
path: Some(path.display().to_string()),
..Default::default()
}));
rs_dep_map.insert(dep_name.to_string(), dep);
}
#[must_use]
#[profiled]
pub fn find_modules_source(code: &str) -> Vec<String> {
let module_regex: &Regex = re!(r"(?m)^[\s]*mod\s+([^;{\s]+)");
debug_log!("In code_utils::find_use_renames_source");
let mut modules: Vec<String> = vec![];
for cap in module_regex.captures_iter(code) {
let module = cap[1].to_string();
debug_log!("module={module}");
modules.push(module);
}
debug_log!("modules from source={modules:#?}");
modules
}