add interactive mode for init command
This commit is contained in:
parent
e8ac54f231
commit
7da26c9590
|
@ -24,7 +24,7 @@ watch = ["dep:notify-debouncer-mini", "dep:ctrlc"]
|
|||
zip = ["shulkerbox/zip"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.4", features = ["derive", "env"] }
|
||||
clap = { version = "4.5.4", features = ["derive", "env", "deprecated"] }
|
||||
colored = "2.1.0"
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
thiserror = "1.0.58"
|
||||
|
@ -39,3 +39,6 @@ notify-debouncer-mini = { version = "0.4.1", default-features = false, optional
|
|||
ctrlc = { version = "3.4.4", optional = true }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = "0.3.18"
|
||||
# waiting for pull request to be merged
|
||||
inquire = { git = "https://github.com/moritz-hoelting/rust-inquire.git", branch = "main", package = "inquire" }
|
||||
camino = "1.1.7"
|
||||
|
|
|
@ -18,8 +18,10 @@ Where [PATH] is the path of the folder to initialize in [default: `.`]
|
|||
Options:
|
||||
- `--name <NAME>` The name of the project
|
||||
- `--description <DESCRIPTION>` The description of the project
|
||||
- `--pack-format <FORMAT>` The pack format version
|
||||
- `--pack-format <FORMAT>` The pack format version
|
||||
- `--icon <PATH>` The path to the icon file, leave empty for default icon
|
||||
- `--force` Force initialization even if the directory is not empty
|
||||
- `--batch` Do not prompt for input, use default values instead if possible or fail
|
||||
|
||||
### Build a project
|
||||
```bash
|
||||
|
|
|
@ -12,30 +12,28 @@ pub struct ProjectConfig {
|
|||
pub struct PackConfig {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(
|
||||
rename = "format",
|
||||
alias = "pack_format",
|
||||
default = "default_pack_format"
|
||||
)]
|
||||
#[serde(rename = "format", alias = "pack_format")]
|
||||
pub pack_format: u8,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl PackConfig {
|
||||
pub const DEFAULT_NAME: &'static str = "shulkerscript-pack";
|
||||
pub const DEFAULT_DESCRIPTION: &'static str = "A Minecraft datapack created with shulkerscript";
|
||||
pub const DEFAULT_PACK_FORMAT: u8 = 48;
|
||||
}
|
||||
|
||||
impl Default for PackConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "shulkerscript-pack".to_string(),
|
||||
description: "A Minecraft datapack created with shulkerscript".to_string(),
|
||||
pack_format: 26,
|
||||
name: Self::DEFAULT_NAME.to_string(),
|
||||
description: Self::DEFAULT_DESCRIPTION.to_string(),
|
||||
pack_format: Self::DEFAULT_PACK_FORMAT,
|
||||
version: "0.1.0".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_pack_format() -> u8 {
|
||||
26
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompilerConfig {
|
||||
/// The path of a folder which files and subfolders will be copied to the root of the datapack.
|
||||
|
|
|
@ -3,6 +3,7 @@ mod config;
|
|||
mod error;
|
||||
mod subcommands;
|
||||
mod terminal_output;
|
||||
mod util;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ pub struct CleanArgs {
|
|||
pub force: bool,
|
||||
/// Enable verbose output.
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
pub fn clean(args: &CleanArgs) -> Result<()> {
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
use std::{
|
||||
borrow::Cow,
|
||||
fmt::Display,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
@ -11,7 +13,7 @@ use git2::{
|
|||
use path_absolutize::Absolutize;
|
||||
|
||||
use crate::{
|
||||
config::ProjectConfig,
|
||||
config::{PackConfig, ProjectConfig},
|
||||
error::Error,
|
||||
terminal_output::{print_error, print_info, print_success},
|
||||
};
|
||||
|
@ -28,17 +30,26 @@ pub struct InitArgs {
|
|||
#[arg(short, long)]
|
||||
pub description: Option<String>,
|
||||
/// The pack format version.
|
||||
#[arg(short, long, value_name = "FORMAT")]
|
||||
#[arg(short, long, value_name = "FORMAT", visible_alias = "format")]
|
||||
pub pack_format: Option<u8>,
|
||||
/// The path of the icon file.
|
||||
#[arg(short, long = "icon", value_name = "PATH")]
|
||||
pub icon_path: Option<PathBuf>,
|
||||
/// Force initialization even if the directory is not empty.
|
||||
#[arg(short, long)]
|
||||
pub force: bool,
|
||||
/// The version control system to initialize.
|
||||
#[arg(long, default_value = "git")]
|
||||
pub vcs: VersionControlSystem,
|
||||
/// The version control system to initialize. [default: git]
|
||||
#[arg(long)]
|
||||
pub vcs: Option<VersionControlSystem>,
|
||||
/// Enable verbose output.
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
pub verbose: bool,
|
||||
/// Enable batch mode.
|
||||
///
|
||||
/// In batch mode, the command will not prompt the user for input and
|
||||
/// will use the default values instead if possible or fail.
|
||||
#[arg(long)]
|
||||
pub batch: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
|
||||
|
@ -48,54 +59,258 @@ pub enum VersionControlSystem {
|
|||
None,
|
||||
}
|
||||
|
||||
impl Display for VersionControlSystem {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
VersionControlSystem::Git => write!(f, "git"),
|
||||
VersionControlSystem::None => write!(f, "none"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(args: &InitArgs) -> Result<()> {
|
||||
if args.batch {
|
||||
initialize_batch(args)
|
||||
} else {
|
||||
initialize_interactive(args)
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_batch(args: &InitArgs) -> Result<()> {
|
||||
let verbose = args.verbose;
|
||||
let force = args.force;
|
||||
let path = args.path.as_path();
|
||||
let description = args.description.as_deref();
|
||||
let pack_format = args.pack_format;
|
||||
let force = args.force;
|
||||
let vcs = args.vcs.unwrap_or(VersionControlSystem::Git);
|
||||
|
||||
if !path.exists() {
|
||||
print_error("The specified path does not exist.");
|
||||
Err(Error::PathNotFoundError(path.to_path_buf()))?
|
||||
if force {
|
||||
fs::create_dir_all(path)?;
|
||||
} else {
|
||||
print_error("The specified path does not exist.");
|
||||
Err(Error::PathNotFoundError(path.to_path_buf()))?;
|
||||
}
|
||||
} else if !path.is_dir() {
|
||||
print_error("The specified path is not a directory.");
|
||||
Err(Error::NotDirectoryError(path.to_path_buf()))?;
|
||||
} else if !force && path.read_dir()?.next().is_some() {
|
||||
print_error("The specified directory is not empty.");
|
||||
Err(Error::NonEmptyDirectoryError(path.to_path_buf()))?;
|
||||
}
|
||||
|
||||
let name = args
|
||||
.name
|
||||
.as_deref()
|
||||
.or_else(|| path.file_name().and_then(|os| os.to_str()));
|
||||
|
||||
print_info("Initializing a new Shulkerscript project in batch mode...");
|
||||
|
||||
// Create the pack.toml file
|
||||
create_pack_config(verbose, path, name, description, pack_format)?;
|
||||
|
||||
// Create the pack.png file
|
||||
create_pack_png(path, args.icon_path.as_deref(), verbose)?;
|
||||
|
||||
// Create the src directory
|
||||
let src_path = path.join("src");
|
||||
create_dir(&src_path, verbose)?;
|
||||
|
||||
// Create the main.shu file
|
||||
create_main_file(
|
||||
path,
|
||||
&name_to_namespace(name.unwrap_or(PackConfig::DEFAULT_NAME)),
|
||||
verbose,
|
||||
)?;
|
||||
|
||||
// Initialize the version control system
|
||||
initalize_vcs(path, vcs, verbose)?;
|
||||
|
||||
print_success("Project initialized successfully.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initialize_interactive(args: &InitArgs) -> Result<()> {
|
||||
const ABORT_MSG: &str = "Project initialization interrupted. Aborting...";
|
||||
|
||||
let verbose = args.verbose;
|
||||
let force = args.force;
|
||||
let path = args.path.as_path();
|
||||
let description = args.description.as_deref();
|
||||
let pack_format = args.pack_format;
|
||||
|
||||
if !path.exists() {
|
||||
if force {
|
||||
fs::create_dir_all(path)?;
|
||||
} else if let Ok(true) =
|
||||
inquire::Confirm::new("The specified path does not exist. Do you want to create it?")
|
||||
.with_default(true)
|
||||
.prompt()
|
||||
{
|
||||
fs::create_dir_all(path)?;
|
||||
} else {
|
||||
print_info(ABORT_MSG);
|
||||
return Ok(());
|
||||
}
|
||||
} else if !path.is_dir() {
|
||||
print_error("The specified path is not a directory.");
|
||||
Err(Error::NotDirectoryError(path.to_path_buf()))?
|
||||
} else if !force && path.read_dir()?.next().is_some() {
|
||||
print_error("The specified directory is not empty.");
|
||||
Err(Error::NonEmptyDirectoryError(path.to_path_buf()))?
|
||||
} else {
|
||||
let name = args
|
||||
.name
|
||||
.as_deref()
|
||||
.or_else(|| path.file_name().and_then(|os| os.to_str()));
|
||||
|
||||
print_info("Initializing a new Shulkerscript project...");
|
||||
|
||||
// Create the pack.toml file
|
||||
create_pack_config(verbose, path, name, description, pack_format)?;
|
||||
|
||||
// Create the pack.png file
|
||||
create_pack_png(path, verbose)?;
|
||||
|
||||
// Create the src directory
|
||||
let src_path = path.join("src");
|
||||
create_dir(&src_path, verbose)?;
|
||||
|
||||
// Create the main.shu file
|
||||
create_main_file(
|
||||
path,
|
||||
&name_to_namespace(name.unwrap_or("shulkerscript-pack")),
|
||||
verbose,
|
||||
)?;
|
||||
|
||||
// Initialize the version control system
|
||||
initalize_vcs(path, args.vcs, verbose)?;
|
||||
|
||||
print_success("Project initialized successfully.");
|
||||
|
||||
Ok(())
|
||||
if let Ok(false) =
|
||||
inquire::Confirm::new("The specified directory is not empty. Do you want to continue?")
|
||||
.with_default(false)
|
||||
.with_help_message("This may overwrite existing files in the directory.")
|
||||
.prompt()
|
||||
{
|
||||
print_info(ABORT_MSG);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let mut interrupted = false;
|
||||
|
||||
let name = args.name.as_deref().map(Cow::Borrowed).or_else(|| {
|
||||
let default = path
|
||||
.file_name()
|
||||
.and_then(|os| os.to_str())
|
||||
.unwrap_or(PackConfig::DEFAULT_NAME);
|
||||
|
||||
match inquire::Text::new("Enter the name of the project:")
|
||||
.with_help_message("This will be the name of your datapack folder/zip file")
|
||||
.with_default(default)
|
||||
.prompt()
|
||||
{
|
||||
Ok(res) => Some(Cow::Owned(res)),
|
||||
Err(_) => {
|
||||
interrupted = true;
|
||||
None
|
||||
}
|
||||
}
|
||||
.or_else(|| {
|
||||
path.file_name()
|
||||
.and_then(|os| os.to_str().map(Cow::Borrowed))
|
||||
})
|
||||
});
|
||||
|
||||
if interrupted {
|
||||
print_info(ABORT_MSG);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let description = description.map(Cow::Borrowed).or_else(|| {
|
||||
match inquire::Text::new("Enter the description of the project:")
|
||||
.with_help_message("This will be the description of your datapack, visible in the datapack selection screen")
|
||||
.with_default(PackConfig::DEFAULT_DESCRIPTION)
|
||||
.prompt() {
|
||||
Ok(res) => Some(Cow::Owned(res)),
|
||||
Err(_) => {
|
||||
interrupted = true;
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if interrupted {
|
||||
print_info(ABORT_MSG);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pack_format = pack_format.or_else(|| {
|
||||
match inquire::Text::new("Enter the pack format:")
|
||||
.with_help_message("This will determine the Minecraft version compatible with your pack, find more on the Minecraft wiki")
|
||||
.with_default(PackConfig::DEFAULT_PACK_FORMAT.to_string().as_str())
|
||||
.prompt() {
|
||||
Ok(res) => res.parse().ok(),
|
||||
Err(_) => {
|
||||
interrupted = true;
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if interrupted {
|
||||
print_info(ABORT_MSG);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let vcs = args.vcs.unwrap_or_else(|| {
|
||||
match inquire::Select::new(
|
||||
"Select the version control system:",
|
||||
vec![VersionControlSystem::Git, VersionControlSystem::None],
|
||||
)
|
||||
.with_help_message("This will initialize a version control system")
|
||||
.prompt()
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(_) => {
|
||||
interrupted = true;
|
||||
VersionControlSystem::Git
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if interrupted {
|
||||
print_info(ABORT_MSG);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let icon_path = args.icon_path.as_deref().map(Cow::Borrowed).or_else(|| {
|
||||
let autocompleter = crate::util::PathAutocomplete::new();
|
||||
match inquire::Text::new("Enter the path of the icon file:")
|
||||
.with_help_message(
|
||||
"This will be the icon of your datapack, visible in the datapack selection screen [use \"-\" for default]",
|
||||
)
|
||||
.with_autocomplete(autocompleter)
|
||||
.with_default("-")
|
||||
// .with_autocomplete()
|
||||
.prompt()
|
||||
{
|
||||
Ok(res) if &res == "-" => None,
|
||||
Ok(res) => Some(Cow::Owned(PathBuf::from(res))),
|
||||
Err(_) => {
|
||||
interrupted = true;
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if interrupted {
|
||||
print_info(ABORT_MSG);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
print_info("Initializing a new Shulkerscript project...");
|
||||
|
||||
// Create the pack.toml file
|
||||
create_pack_config(
|
||||
verbose,
|
||||
path,
|
||||
name.as_deref(),
|
||||
description.as_deref(),
|
||||
pack_format,
|
||||
)?;
|
||||
|
||||
// Create the pack.png file
|
||||
create_pack_png(path, icon_path.as_deref(), verbose)?;
|
||||
|
||||
// Create the src directory
|
||||
let src_path = path.join("src");
|
||||
create_dir(&src_path, verbose)?;
|
||||
|
||||
// Create the main.shu file
|
||||
create_main_file(
|
||||
path,
|
||||
&name_to_namespace(&name.unwrap_or(Cow::Borrowed("shulkerscript-pack"))),
|
||||
verbose,
|
||||
)?;
|
||||
|
||||
// Initialize the version control system
|
||||
initalize_vcs(path, vcs, verbose)?;
|
||||
|
||||
print_success("Project initialized successfully.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_pack_config(
|
||||
|
@ -155,14 +370,29 @@ fn create_gitignore(path: &Path, verbose: bool) -> std::io::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn create_pack_png(path: &Path, verbose: bool) -> std::io::Result<()> {
|
||||
let pack_png = path.join("pack.png");
|
||||
fs::write(&pack_png, include_bytes!("../../assets/default-icon.png"))?;
|
||||
if verbose {
|
||||
print_info(format!(
|
||||
"Created pack.png file at {}.",
|
||||
pack_png.absolutize()?.display()
|
||||
));
|
||||
fn create_pack_png(
|
||||
project_path: &Path,
|
||||
icon_path: Option<&Path>,
|
||||
verbose: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let pack_png = project_path.join("pack.png");
|
||||
if let Some(icon_path) = icon_path {
|
||||
fs::copy(icon_path, &pack_png)?;
|
||||
if verbose {
|
||||
print_info(format!(
|
||||
"Copied pack.png file from {} to {}.",
|
||||
icon_path.absolutize()?.display(),
|
||||
pack_png.absolutize()?.display()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
fs::write(&pack_png, include_bytes!("../../assets/default-icon.png"))?;
|
||||
if verbose {
|
||||
print_info(format!(
|
||||
"Created pack.png file at {}.",
|
||||
pack_png.absolutize()?.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -0,0 +1,117 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use camino::Utf8PathBuf;
|
||||
|
||||
use inquire::{autocompletion::Replacement, Autocomplete};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PathAutocomplete {
|
||||
parent: String,
|
||||
current: String,
|
||||
outputs: Vec<String>,
|
||||
|
||||
cache: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl PathAutocomplete {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn split_input(input: &str) -> (&str, &str) {
|
||||
let (parent, current) = if input.ends_with('/') {
|
||||
(input.trim_end_matches('/'), "")
|
||||
} else if let Some((parent, current)) = input.rsplit_once('/') {
|
||||
if parent.is_empty() {
|
||||
("/", current)
|
||||
} else {
|
||||
(parent, current)
|
||||
}
|
||||
} else {
|
||||
("", input)
|
||||
};
|
||||
let parent = if parent.is_empty() { "." } else { parent };
|
||||
|
||||
(parent, current)
|
||||
}
|
||||
|
||||
fn get_cached(&mut self, parent: &str) -> Result<&[String], &'static str> {
|
||||
if !self.cache.contains_key(parent) {
|
||||
tracing::trace!("Cache miss for \"{}\", reading dir", parent);
|
||||
|
||||
let parent_path = Utf8PathBuf::from(parent);
|
||||
if !parent_path.exists() || !parent_path.is_dir() {
|
||||
return Err("Path does not exist");
|
||||
}
|
||||
|
||||
let entries = parent_path
|
||||
.read_dir_utf8()
|
||||
.map_err(|_| "Could not read dir")?
|
||||
.filter_map(|entry| {
|
||||
entry.ok().map(|entry| {
|
||||
entry.file_name().to_string() + if entry.path().is_dir() { "/" } else { "" }
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
self.cache.insert(parent.to_string(), entries);
|
||||
}
|
||||
|
||||
Ok(self
|
||||
.cache
|
||||
.get(parent)
|
||||
.expect("Previous caching did not work"))
|
||||
}
|
||||
|
||||
fn update_input(&mut self, input: &str) -> Result<(), inquire::CustomUserError> {
|
||||
let (parent, current) = Self::split_input(input);
|
||||
|
||||
if self.parent == parent && self.current == current {
|
||||
Ok(())
|
||||
} else {
|
||||
self.parent = parent.to_string();
|
||||
self.current = current.to_string();
|
||||
|
||||
self.outputs = self
|
||||
.get_cached(parent)?
|
||||
.iter()
|
||||
.filter(|entry| entry.starts_with(current))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Autocomplete for PathAutocomplete {
|
||||
fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, inquire::CustomUserError> {
|
||||
self.update_input(input)?;
|
||||
|
||||
Ok(self.outputs.clone())
|
||||
}
|
||||
|
||||
fn get_completion(
|
||||
&mut self,
|
||||
input: &str,
|
||||
highlighted_suggestion: Option<String>,
|
||||
) -> Result<inquire::autocompletion::Replacement, inquire::CustomUserError> {
|
||||
let (parent, current) = Self::split_input(input);
|
||||
|
||||
if let Some(highlighted) = highlighted_suggestion {
|
||||
let completion = format!("{parent}/{highlighted}");
|
||||
self.update_input(&completion)?;
|
||||
Ok(Replacement::Some(completion))
|
||||
} else if let Some(first) = self
|
||||
.get_cached(parent)?
|
||||
.iter()
|
||||
.find(|entry| entry.starts_with(current))
|
||||
{
|
||||
let completion = format!("{parent}/{first}");
|
||||
self.update_input(&completion)?;
|
||||
Ok(Replacement::Some(completion))
|
||||
} else {
|
||||
Ok(Replacement::None)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue