use crate::Result;
use indexmap::IndexMap;
pub mod pre_commit;
#[derive(Debug, clap::Args)]
pub struct Migrate {
#[clap(subcommand)]
command: MigrateCommands,
}
#[derive(Debug, clap::Subcommand)]
enum MigrateCommands {
PreCommit(pre_commit::PreCommit),
}
impl Migrate {
pub async fn run(&self) -> Result<()> {
match &self.command {
MigrateCommands::PreCommit(cmd) => cmd.run().await,
}
}
}
#[derive(Debug, Default)]
pub struct HkConfig {
pub amends: String,
pub imports: Vec<String>,
pub vendor_imports: Vec<(String, String)>,
pub header_comments: Vec<String>,
pub step_collections: IndexMap<String, IndexMap<String, HkStep>>,
pub hooks: IndexMap<String, HkHook>,
}
#[derive(Debug, Clone)]
pub struct HkStep {
pub builtin: Option<String>,
pub comments: Vec<String>,
pub glob: Option<String>,
pub exclude: Option<String>,
pub prefix: Option<String>,
pub check: Option<String>,
pub fix: Option<String>,
pub shell: Option<String>,
pub exclusive: bool,
pub properties_as_comments: Vec<String>,
}
#[derive(Debug)]
pub struct HkHook {
pub fix: Option<bool>,
pub stash: Option<String>,
pub step_spreads: Vec<String>,
pub direct_steps: IndexMap<String, HkStep>,
}
impl HkConfig {
pub fn new(amends_config_pkl: Option<String>, builtins_pkl: Option<String>) -> Self {
let version = env!("CARGO_PKG_VERSION");
let amends = amends_config_pkl.unwrap_or_else(|| {
format!(
"package://github.com/jdx/hk/releases/download/v{}/hk@{}#/Config.pkl",
version, version
)
});
let mut imports = vec![];
imports.push(builtins_pkl.unwrap_or_else(|| {
format!(
"package://github.com/jdx/hk/releases/download/v{}/hk@{}#/Builtins.pkl",
version, version
)
}));
Self {
amends,
imports,
..Default::default()
}
}
pub fn to_pkl(&self) -> String {
let mut output = String::new();
output.push_str(&format!("amends \"{}\"\n", self.amends));
for import in &self.imports {
output.push_str(&format!("import \"{}\"\n", import));
}
for (name, path) in &self.vendor_imports {
output.push_str(&format!("import \"{}\" as {}\n", path, name));
}
output.push('\n');
if !self.header_comments.is_empty() {
for comment in &self.header_comments {
let trimmed = comment.trim_end();
if trimmed.is_empty() {
output.push_str("//\n");
} else {
output.push_str(&format!("// {}\n", trimmed));
}
}
output.push('\n');
}
for (name, steps) in &self.step_collections {
if steps.is_empty() {
continue;
}
output.push_str(&format!("local {} = new Mapping<String, Step> {{\n", name));
for (id, step) in steps {
output.push_str(&self.format_step(id, step, 1));
}
output.push_str("}\n\n");
}
if !self.hooks.is_empty() {
output.push_str("hooks {\n");
for (name, hook) in &self.hooks {
output.push_str(&self.format_hook(name, hook));
}
output.push_str("}\n");
}
output
}
fn format_step(&self, id: &str, step: &HkStep, indent_level: usize) -> String {
let mut output = String::new();
let indent = " ".repeat(indent_level);
for comment in &step.comments {
let trimmed = comment.trim_end();
if trimmed.is_empty() {
output.push_str(&format!("{}//\n", indent));
} else {
output.push_str(&format!("{}// {}\n", indent, trimmed));
}
}
output.push_str(&format!("{}[\"{}\"]", indent, id));
if let Some(ref builtin) = step.builtin {
if step.glob.is_none()
&& step.exclude.is_none()
&& step.check.is_none()
&& step.fix.is_none()
&& step.shell.is_none()
&& step.properties_as_comments.is_empty()
{
output.push_str(&format!(" = {}\n", builtin));
return output;
}
output.push_str(&format!(" = ({}) {{\n", builtin));
} else {
output.push_str(" {\n");
}
let inner_indent = " ".repeat(indent_level + 1);
if let Some(ref glob) = step.glob {
output.push_str(&format!(
"{}glob = {}\n",
inner_indent,
format_pkl_value(glob)
));
}
if let Some(ref exclude) = step.exclude {
output.push_str(&format!(
"{}exclude = {}\n",
inner_indent,
format_pkl_value(exclude)
));
}
if let Some(ref prefix) = step.prefix {
output.push_str(&format!(
"{}prefix = {}\n",
inner_indent,
format_pkl_string(prefix)
));
}
if let Some(ref check) = step.check {
output.push_str(&format!(
"{}check = {}\n",
inner_indent,
format_pkl_string(check)
));
}
if let Some(ref fix) = step.fix {
output.push_str(&format!(
"{}fix = {}\n",
inner_indent,
format_pkl_string(fix)
));
}
if let Some(ref shell) = step.shell {
output.push_str(&format!(
"{}shell = {}\n",
inner_indent,
format_pkl_string(shell)
));
}
if step.exclusive {
output.push_str(&format!("{}exclusive = true\n", inner_indent));
}
for comment in &step.properties_as_comments {
let trimmed = comment.trim_end();
if trimmed.is_empty() {
output.push_str(&format!("{}//\n", inner_indent));
} else {
output.push_str(&format!("{}// {}\n", inner_indent, trimmed));
}
}
output.push_str(&format!("{}}}\n", indent));
output
}
fn format_hook(&self, name: &str, hook: &HkHook) -> String {
let mut output = String::new();
let indent = " ";
output.push_str(&format!("{}[\"{}\"] {{\n", indent, name));
if let Some(fix) = hook.fix {
output.push_str(&format!("{} fix = {}\n", indent, fix));
}
if let Some(ref stash) = hook.stash {
output.push_str(&format!("{} stash = \"{}\"\n", indent, stash));
}
if !hook.step_spreads.is_empty() || !hook.direct_steps.is_empty() {
output.push_str(&format!("{} steps {{\n", indent));
for spread in &hook.step_spreads {
output.push_str(&format!("{} ...{}\n", indent, spread));
}
for (id, step) in &hook.direct_steps {
output.push_str(&self.format_step(id, step, 3));
}
output.push_str(&format!("{} }}\n", indent));
}
output.push_str(&format!("{}}}\n", indent));
output
}
}
fn parse_as_path_list(pattern: &str) -> Option<Vec<String>> {
let trimmed = pattern.trim();
let working_pattern = if let Some(stripped) = trimmed.strip_prefix("(?x)") {
stripped.trim()
} else {
trimmed
};
let parts: Vec<&str> = working_pattern.split('|').collect();
let mut globs = Vec::new();
for part in parts {
let part = part.trim();
if let Some(glob) = convert_regex_to_glob(part) {
globs.push(glob);
} else {
return None; }
}
Some(globs)
}
fn convert_regex_to_glob(regex: &str) -> Option<String> {
let mut result = String::new();
let mut chars = regex.chars().peekable();
let has_start_anchor = regex.starts_with('^');
let _has_end_anchor = regex.ends_with('$');
if has_start_anchor {
chars.next();
}
while let Some(ch) = chars.next() {
match ch {
'$' if chars.peek().is_none() => {
}
'\\' => {
if let Some('.') = chars.next() {
result.push('.');
} else {
return None; }
}
'.' if chars.peek() == Some(&'*') => {
chars.next(); if chars.peek() == Some(&'/') {
chars.next(); result.push_str("**/");
} else {
result.push_str("**");
}
}
'a'..='z' | 'A'..='Z' | '0'..='9' | '/' | '-' | '_' => {
result.push(ch);
}
_ => return None, }
}
if !has_start_anchor && !result.starts_with("**/") {
result = format!("**/{}", result);
}
Some(result)
}
pub fn format_pkl_value(value: &str) -> String {
if is_regex_pattern(value) {
return format!("Regex({})", format_pkl_string(value));
}
if !value.contains('\n') {
if let Some(paths) = parse_as_path_list(value) {
let formatted_paths: Vec<String> = paths.iter().map(|p| format!("\"{}\"", p)).collect();
return format!("List({})", formatted_paths.join(", "));
}
}
format_pkl_string(value)
}
fn is_regex_pattern(pattern: &str) -> bool {
let trimmed = pattern.trim();
trimmed.starts_with("(?x)") || trimmed.starts_with("(?i") || trimmed.starts_with("(?") || trimmed.starts_with('^') || trimmed.contains("\\b") || trimmed.contains("\\s") || trimmed.contains("\\d") || trimmed.contains("\\w") || trimmed.contains(".*") || trimmed.contains(".+") || (trimmed.contains('|') && trimmed.contains('^')) }
pub fn format_pkl_string(value: &str) -> String {
let trimmed = value.trim();
if trimmed.contains('\\') || trimmed.contains('"') {
if trimmed.contains('\n') {
format!("#\"\"\"\n{}\n\"\"\"#", trimmed)
} else {
format!("#\"{}\"#", trimmed)
}
} else if trimmed.contains('\n') {
format!("\"\"\"\n{}\n\"\"\"", trimmed)
} else {
format!("\"{}\"", trimmed)
}
}