Refactor CLI to use subcommands, update changelog

This commit is contained in:
Filip Tibell 2024-01-14 10:35:27 +01:00
parent 3565c897c0
commit d7c603e881
No known key found for this signature in database
9 changed files with 415 additions and 372 deletions

View file

@ -10,8 +10,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Breaking Changes
- The Lune CLI now uses subcommands instead of flag options: <br/>
- `lune script_name arg1 arg2 arg3` -> `lune run script_name arg1 arg2 arg3`
- `lune --list` -> `lune list`
- `lune --setup` -> `lune setup`
This unfortunately hurts ergonomics for quickly running scripts but is a necessary change to allow us to add more commands, such as the new `build` subcommand.
### Added ### Added
- Added support for compiling single Lune scripts into standalone executables! ([#140])
Example usage:
```lua
-- my_cool_script.luau
print("Hello, standalone!")
```
```sh
> lune build my_cool_script
# Creates `my_cool_script.exe` (Windows) or `my_cool_script` (macOS / Linux)
```
```sh
> ./my_cool_script.exe # Windows
> ./my_cool_script # macOS / Linux
> "Hello, standalone!"
```
To compile scripts that use `require` and reference multiple files, a bundler such as [darklua](https://github.com/seaofvoices/darklua) will need to be used first. This limitation will be lifted in the future and Lune will automatically bundle any referenced scripts.
- Added support for multiple values for a single query, and multiple values for a single header, in `net.request`. This is a part of the HTTP specification that is not widely used but that may be useful in certain cases. To clarify: - Added support for multiple values for a single query, and multiple values for a single header, in `net.request`. This is a part of the HTTP specification that is not widely used but that may be useful in certain cases. To clarify:
- Single values remain unchanged and will work exactly the same as before. <br/> - Single values remain unchanged and will work exactly the same as before. <br/>
@ -42,6 +74,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
}) })
``` ```
[#140]: https://github.com/filiptibell/lune/pull/140
### Changed ### Changed
- Update to Luau version `0.606`. - Update to Luau version `0.606`.

View file

@ -1,26 +1,44 @@
use std::{env, path::Path, process::ExitCode}; use std::{
env::{self, consts::EXE_EXTENSION},
path::{Path, PathBuf},
process::ExitCode,
};
use anyhow::Result; use anyhow::{Context, Result};
use clap::Parser;
use console::style; use console::style;
use mlua::Compiler as LuaCompiler; use mlua::Compiler as LuaCompiler;
use tokio::{fs, io::AsyncWriteExt as _}; use tokio::{fs, io::AsyncWriteExt as _};
use crate::executor::MetaChunk; use crate::executor::MetaChunk;
/** /// Build a standalone executable
Compiles and embeds the bytecode of a given lua file to form a standalone #[derive(Debug, Clone, Parser)]
binary, then writes it to an output file, with the required permissions. pub struct BuildCommand {
*/ /// The path to the input file
#[allow(clippy::similar_names)] pub input: PathBuf,
pub async fn build_standalone(
input_path: impl AsRef<Path>,
output_path: impl AsRef<Path>,
source_code: impl AsRef<[u8]>,
) -> Result<ExitCode> {
let input_path_displayed = input_path.as_ref().display();
let output_path_displayed = output_path.as_ref().display();
// First, we read the contents of the lune interpreter as our starting point /// The path to the output file - defaults to the
/// input file path with an executable extension
#[clap(short, long)]
pub output: Option<PathBuf>,
}
impl BuildCommand {
pub async fn run(self) -> Result<ExitCode> {
let output_path = self
.output
.unwrap_or_else(|| self.input.with_extension(EXE_EXTENSION));
let input_path_displayed = self.input.display();
let output_path_displayed = output_path.display();
// Try to read the input file
let source_code = fs::read(&self.input)
.await
.context("failed to read input file")?;
// Read the contents of the lune interpreter as our starting point
println!( println!(
"Creating standalone binary using {}", "Creating standalone binary using {}",
style(input_path_displayed).green() style(input_path_displayed).green()
@ -47,6 +65,7 @@ pub async fn build_standalone(
Ok(ExitCode::SUCCESS) Ok(ExitCode::SUCCESS)
} }
}
async fn write_executable_file_to(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> Result<()> { async fn write_executable_file_to(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> Result<()> {
let mut options = fs::OpenOptions::new(); let mut options = fs::OpenOptions::new();

View file

@ -1,61 +0,0 @@
use std::collections::HashMap;
use anyhow::{Context, Result};
use directories::UserDirs;
use futures_util::future::try_join_all;
use include_dir::Dir;
use tokio::fs::{create_dir_all, write};
pub async fn generate_typedef_files_from_definitions(dir: &Dir<'_>) -> Result<String> {
let contents = read_typedefs_dir_contents(dir);
write_typedef_files(contents).await
}
fn read_typedefs_dir_contents(dir: &Dir<'_>) -> HashMap<String, Vec<u8>> {
let mut definitions = HashMap::new();
for entry in dir.find("*.luau").unwrap() {
let entry_file = entry.as_file().unwrap();
let entry_name = entry_file.path().file_name().unwrap().to_string_lossy();
let typedef_name = entry_name.trim_end_matches(".luau");
let typedef_contents = entry_file.contents().to_vec();
definitions.insert(typedef_name.to_string(), typedef_contents);
}
definitions
}
async fn write_typedef_files(typedef_files: HashMap<String, Vec<u8>>) -> Result<String> {
let version_string = env!("CARGO_PKG_VERSION");
let mut dirs_to_write = Vec::new();
let mut files_to_write = Vec::new();
// Create the typedefs dir in the users cache dir
let cache_dir = UserDirs::new()
.context("Failed to find user home directory")?
.home_dir()
.join(".lune")
.join(".typedefs")
.join(version_string);
dirs_to_write.push(cache_dir.clone());
// Make typedef files
for (builtin_name, builtin_typedef) in typedef_files {
let path = cache_dir
.join(builtin_name.to_ascii_lowercase())
.with_extension("luau");
files_to_write.push((builtin_name.to_lowercase(), path, builtin_typedef));
}
// Write all dirs and files only when we know generation was successful
let futs_dirs = dirs_to_write
.drain(..)
.map(create_dir_all)
.collect::<Vec<_>>();
let futs_files = files_to_write
.iter()
.map(|(_, path, contents)| write(path, contents))
.collect::<Vec<_>>();
try_join_all(futs_dirs).await?;
try_join_all(futs_files).await?;
Ok(version_string.to_string())
}

47
src/cli/list.rs Normal file
View file

@ -0,0 +1,47 @@
use std::{fmt::Write as _, process::ExitCode};
use anyhow::Result;
use clap::Parser;
use super::utils::listing::{find_lune_scripts, sort_lune_scripts, write_lune_scripts_list};
/// List scripts available to run
#[derive(Debug, Clone, Parser)]
pub struct ListCommand {}
impl ListCommand {
pub async fn run(self) -> Result<ExitCode> {
let sorted_relative = find_lune_scripts(false).await.map(sort_lune_scripts);
let sorted_home_dir = find_lune_scripts(true).await.map(sort_lune_scripts);
if sorted_relative.is_err() && sorted_home_dir.is_err() {
eprintln!("{}", sorted_relative.unwrap_err());
return Ok(ExitCode::FAILURE);
}
let sorted_relative = sorted_relative.unwrap_or(Vec::new());
let sorted_home_dir = sorted_home_dir.unwrap_or(Vec::new());
let mut buffer = String::new();
if !sorted_relative.is_empty() {
if sorted_home_dir.is_empty() {
write!(&mut buffer, "Available scripts:")?;
} else {
write!(&mut buffer, "Available scripts in current directory:")?;
}
write_lune_scripts_list(&mut buffer, sorted_relative)?;
}
if !sorted_home_dir.is_empty() {
write!(&mut buffer, "Available global scripts:")?;
write_lune_scripts_list(&mut buffer, sorted_home_dir)?;
}
if buffer.is_empty() {
println!("No scripts found.");
} else {
print!("{buffer}");
}
Ok(ExitCode::SUCCESS)
}
}

View file

@ -1,180 +1,54 @@
use std::{env, fmt::Write as _, path::PathBuf, process::ExitCode}; use std::process::ExitCode;
use anyhow::{Context, Result}; use anyhow::Result;
use clap::Parser; use clap::{Parser, Subcommand};
use lune::Lune;
use tokio::{
fs::read as read_to_vec,
io::{stdin, AsyncReadExt},
};
pub(crate) mod build; pub(crate) mod build;
pub(crate) mod gen; pub(crate) mod list;
pub(crate) mod repl; pub(crate) mod repl;
pub(crate) mod run;
pub(crate) mod setup; pub(crate) mod setup;
pub(crate) mod utils; pub(crate) mod utils;
use setup::run_setup; pub use self::{
use utils::{ build::BuildCommand, list::ListCommand, repl::ReplCommand, run::RunCommand, setup::SetupCommand,
files::{discover_script_path_including_lune_dirs, strip_shebang},
listing::{find_lune_scripts, sort_lune_scripts, write_lune_scripts_list},
}; };
use self::build::build_standalone; #[derive(Debug, Clone, Subcommand)]
pub enum CliSubcommand {
/// A Luau script runner Run(RunCommand),
#[derive(Parser, Debug, Default, Clone)] List(ListCommand),
#[command(version, long_about = None)] Setup(SetupCommand),
pub struct Cli { Build(BuildCommand),
/// Script name or full path to the file to run Repl(ReplCommand),
script_path: Option<String>, }
/// Arguments to pass to the script, stored in process.args
script_args: Vec<String>, impl Default for CliSubcommand {
/// List scripts found inside of a nearby `lune` directory fn default() -> Self {
#[clap(long, short = 'l')] Self::Repl(ReplCommand::default())
list: bool, }
/// Set up type definitions and settings for development }
#[clap(long)]
setup: bool, /// Lune, a standalone Luau runtime
/// Build a Luau file to an OS-Native standalone executable #[derive(Parser, Debug, Default, Clone)]
#[clap(long)] #[command(version, about, long_about = None)]
build: bool, pub struct Cli {
#[clap(subcommand)]
subcommand: Option<CliSubcommand>,
} }
#[allow(dead_code)]
impl Cli { impl Cli {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::parse()
} }
pub fn with_path<S>(mut self, path: S) -> Self
where
S: Into<String>,
{
self.script_path = Some(path.into());
self
}
pub fn with_args<A>(mut self, args: A) -> Self
where
A: Into<Vec<String>>,
{
self.script_args = args.into();
self
}
pub fn setup(mut self) -> Self {
self.setup = true;
self
}
pub fn list(mut self) -> Self {
self.list = true;
self
}
#[allow(clippy::too_many_lines)]
pub async fn run(self) -> Result<ExitCode> { pub async fn run(self) -> Result<ExitCode> {
// List files in `lune` and `.lune` directories, if wanted match self.subcommand.unwrap_or_default() {
// This will also exit early and not run anything else CliSubcommand::Run(cmd) => cmd.run().await,
if self.list { CliSubcommand::List(cmd) => cmd.run().await,
let sorted_relative = find_lune_scripts(false).await.map(sort_lune_scripts); CliSubcommand::Setup(cmd) => cmd.run().await,
CliSubcommand::Build(cmd) => cmd.run().await,
let sorted_home_dir = find_lune_scripts(true).await.map(sort_lune_scripts); CliSubcommand::Repl(cmd) => cmd.run().await,
if sorted_relative.is_err() && sorted_home_dir.is_err() { }
eprintln!("{}", sorted_relative.unwrap_err());
return Ok(ExitCode::FAILURE);
}
let sorted_relative = sorted_relative.unwrap_or(Vec::new());
let sorted_home_dir = sorted_home_dir.unwrap_or(Vec::new());
let mut buffer = String::new();
if !sorted_relative.is_empty() {
if sorted_home_dir.is_empty() {
write!(&mut buffer, "Available scripts:")?;
} else {
write!(&mut buffer, "Available scripts in current directory:")?;
}
write_lune_scripts_list(&mut buffer, sorted_relative)?;
}
if !sorted_home_dir.is_empty() {
write!(&mut buffer, "Available global scripts:")?;
write_lune_scripts_list(&mut buffer, sorted_home_dir)?;
}
if buffer.is_empty() {
println!("No scripts found.");
} else {
print!("{buffer}");
}
return Ok(ExitCode::SUCCESS);
}
// Generate (save) definition files, if wanted
if self.setup {
run_setup().await;
}
if self.script_path.is_none() {
// Only generating typedefs without running a script is completely
// fine, and we should just exit the program normally afterwards
if self.setup {
return Ok(ExitCode::SUCCESS);
}
// If not in a standalone context and we don't have any arguments
// display the interactive REPL interface
return repl::show_interface().await;
}
// Figure out if we should read from stdin or from a file,
// reading from stdin is marked by passing a single "-"
// (dash) as the script name to run to the cli
let script_path = self.script_path.unwrap();
let (script_display_name, script_contents) = if script_path == "-" {
let mut stdin_contents = Vec::new();
stdin()
.read_to_end(&mut stdin_contents)
.await
.context("Failed to read script contents from stdin")?;
("stdin".to_string(), stdin_contents)
} else {
let file_path = discover_script_path_including_lune_dirs(&script_path)?;
let file_contents = read_to_vec(&file_path).await?;
// NOTE: We skip the extension here to remove it from stack traces
let file_display_name = file_path.with_extension("").display().to_string();
(file_display_name, file_contents)
};
if self.build {
let output_path =
PathBuf::from(script_path.clone()).with_extension(env::consts::EXE_EXTENSION);
return Ok(
match build_standalone(script_path, output_path, script_contents).await {
Ok(exitcode) => exitcode,
Err(err) => {
eprintln!("{err}");
ExitCode::FAILURE
}
},
);
}
// Create a new lune object with all globals & run the script
let result = Lune::new()
.with_args(self.script_args)
.run(&script_display_name, strip_shebang(script_contents))
.await;
Ok(match result {
Err(err) => {
eprintln!("{err}");
ExitCode::FAILURE
}
Ok(code) => code,
})
} }
} }

View file

@ -1,6 +1,7 @@
use std::{path::PathBuf, process::ExitCode}; use std::{path::PathBuf, process::ExitCode};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser;
use directories::UserDirs; use directories::UserDirs;
use rustyline::{error::ReadlineError, DefaultEditor}; use rustyline::{error::ReadlineError, DefaultEditor};
@ -14,7 +15,12 @@ enum PromptState {
Continuation, Continuation,
} }
pub async fn show_interface() -> Result<ExitCode> { /// Launch an interactive REPL (default)
#[derive(Debug, Clone, Default, Parser)]
pub struct ReplCommand {}
impl ReplCommand {
pub async fn run(self) -> Result<ExitCode> {
println!("{MESSAGE_WELCOME}"); println!("{MESSAGE_WELCOME}");
let history_file_path: &PathBuf = &UserDirs::new() let history_file_path: &PathBuf = &UserDirs::new()
@ -98,3 +104,4 @@ pub async fn show_interface() -> Result<ExitCode> {
Ok(ExitCode::SUCCESS) Ok(ExitCode::SUCCESS)
} }
}

56
src/cli/run.rs Normal file
View file

@ -0,0 +1,56 @@
use std::process::ExitCode;
use anyhow::{Context, Result};
use clap::Parser;
use tokio::{
fs::read as read_to_vec,
io::{stdin, AsyncReadExt as _},
};
use lune::Lune;
use super::utils::files::{discover_script_path_including_lune_dirs, strip_shebang};
/// Run a script
#[derive(Debug, Clone, Parser)]
pub struct RunCommand {
/// Script name or full path to the file to run
script_path: String,
/// Arguments to pass to the script, stored in process.args
script_args: Vec<String>,
}
impl RunCommand {
pub async fn run(self) -> Result<ExitCode> {
// Figure out if we should read from stdin or from a file,
// reading from stdin is marked by passing a single "-"
// (dash) as the script name to run to the cli
let (script_display_name, script_contents) = if &self.script_path == "-" {
let mut stdin_contents = Vec::new();
stdin()
.read_to_end(&mut stdin_contents)
.await
.context("Failed to read script contents from stdin")?;
("stdin".to_string(), stdin_contents)
} else {
let file_path = discover_script_path_including_lune_dirs(&self.script_path)?;
let file_contents = read_to_vec(&file_path).await?;
// NOTE: We skip the extension here to remove it from stack traces
let file_display_name = file_path.with_extension("").display().to_string();
(file_display_name, file_contents)
};
// Create a new lune object with all globals & run the script
let result = Lune::new()
.with_args(self.script_args)
.run(&script_display_name, strip_shebang(script_contents))
.await;
Ok(match result {
Err(err) => {
eprintln!("{err}");
ExitCode::FAILURE
}
Ok(code) => code,
})
}
}

View file

@ -1,6 +1,12 @@
use std::{borrow::BorrowMut, env::current_dir, io::ErrorKind, path::PathBuf}; use std::{
borrow::BorrowMut, collections::HashMap, env::current_dir, io::ErrorKind, path::PathBuf,
process::ExitCode,
};
use anyhow::Result; use anyhow::{Context, Result};
use clap::Parser;
use directories::UserDirs;
use futures_util::future::try_join_all;
use include_dir::{include_dir, Dir}; use include_dir::{include_dir, Dir};
use thiserror::Error; use thiserror::Error;
use tokio::fs; use tokio::fs;
@ -8,13 +14,50 @@ use tokio::fs;
// TODO: Use a library that supports json with comments since VSCode settings may contain comments // TODO: Use a library that supports json with comments since VSCode settings may contain comments
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use super::gen::generate_typedef_files_from_definitions;
pub(crate) static TYPEDEFS_DIR: Dir<'_> = include_dir!("types"); pub(crate) static TYPEDEFS_DIR: Dir<'_> = include_dir!("types");
pub(crate) static SETTING_NAME_MODE: &str = "luau-lsp.require.mode"; pub(crate) static SETTING_NAME_MODE: &str = "luau-lsp.require.mode";
pub(crate) static SETTING_NAME_ALIASES: &str = "luau-lsp.require.directoryAliases"; pub(crate) static SETTING_NAME_ALIASES: &str = "luau-lsp.require.directoryAliases";
/// Set up type definitions for your editor
#[derive(Debug, Clone, Parser)]
pub struct SetupCommand {}
impl SetupCommand {
pub async fn run(self) -> Result<ExitCode> {
generate_typedef_files_from_definitions(&TYPEDEFS_DIR)
.await
.expect("Failed to generate typedef files");
// TODO: Let the user interactively choose what editor to set up
let res = async {
let settings = read_or_create_vscode_settings_json().await?;
let modified = add_values_to_vscode_settings_json(settings);
write_vscode_settings_json(modified).await?;
Ok::<_, SetupError>(())
}
.await;
let message = match res {
Ok(()) => "These settings have been added to your workspace for Visual Studio Code:",
Err(_) => "To finish setting up your editor, add these settings to your workspace:",
};
let version_string = lune_version();
println!(
"Lune has now been set up and editor type definitions have been generated.\
\n{message}\
\n\
\n\"{SETTING_NAME_MODE}\": \"relativeToFile\",\
\n\"{SETTING_NAME_ALIASES}\": {{\
\n \"@lune/\": \"~/.lune/.typedefs/{version_string}/\"\
\n}}",
);
Ok(ExitCode::SUCCESS)
}
}
#[derive(Debug, Clone, Copy, Error)] #[derive(Debug, Clone, Copy, Error)]
enum SetupError { enum SetupError {
#[error("Failed to read settings")] #[error("Failed to read settings")]
@ -99,30 +142,56 @@ fn add_values_to_vscode_settings_json(value: JsonValue) -> JsonValue {
settings_json settings_json
} }
pub async fn run_setup() { async fn generate_typedef_files_from_definitions(dir: &Dir<'_>) -> Result<String> {
generate_typedef_files_from_definitions(&TYPEDEFS_DIR) let contents = read_typedefs_dir_contents(dir);
.await write_typedef_files(contents).await
.expect("Failed to generate typedef files");
// TODO: Let the user interactively choose what editor to set up
let res = async {
let settings = read_or_create_vscode_settings_json().await?;
let modified = add_values_to_vscode_settings_json(settings);
write_vscode_settings_json(modified).await?;
Ok::<_, SetupError>(())
} }
.await;
let message = match res { fn read_typedefs_dir_contents(dir: &Dir<'_>) -> HashMap<String, Vec<u8>> {
Ok(()) => "These settings have been added to your workspace for Visual Studio Code:", let mut definitions = HashMap::new();
Err(_) => "To finish setting up your editor, add these settings to your workspace:",
}; for entry in dir.find("*.luau").unwrap() {
let version_string = lune_version(); let entry_file = entry.as_file().unwrap();
println!( let entry_name = entry_file.path().file_name().unwrap().to_string_lossy();
"Lune has now been set up and editor type definitions have been generated.\
\n{message}\ let typedef_name = entry_name.trim_end_matches(".luau");
\n\ let typedef_contents = entry_file.contents().to_vec();
\n\"{SETTING_NAME_MODE}\": \"relativeToFile\",\
\n\"{SETTING_NAME_ALIASES}\": {{\ definitions.insert(typedef_name.to_string(), typedef_contents);
\n \"@lune/\": \"~/.lune/.typedefs/{version_string}/\"\ }
\n}}",
); definitions
}
async fn write_typedef_files(typedef_files: HashMap<String, Vec<u8>>) -> Result<String> {
let version_string = env!("CARGO_PKG_VERSION");
let mut dirs_to_write = Vec::new();
let mut files_to_write = Vec::new();
// Create the typedefs dir in the users cache dir
let cache_dir = UserDirs::new()
.context("Failed to find user home directory")?
.home_dir()
.join(".lune")
.join(".typedefs")
.join(version_string);
dirs_to_write.push(cache_dir.clone());
// Make typedef files
for (builtin_name, builtin_typedef) in typedef_files {
let path = cache_dir
.join(builtin_name.to_ascii_lowercase())
.with_extension("luau");
files_to_write.push((builtin_name.to_lowercase(), path, builtin_typedef));
}
// Write all dirs and files only when we know generation was successful
let futs_dirs = dirs_to_write
.drain(..)
.map(fs::create_dir_all)
.collect::<Vec<_>>();
let futs_files = files_to_write
.iter()
.map(|(_, path, contents)| fs::write(path, contents))
.collect::<Vec<_>>();
try_join_all(futs_dirs).await?;
try_join_all(futs_files).await?;
Ok(version_string.to_string())
} }

View file

@ -10,8 +10,6 @@
use std::process::ExitCode; use std::process::ExitCode;
use clap::Parser;
pub(crate) mod cli; pub(crate) mod cli;
pub(crate) mod executor; pub(crate) mod executor;
@ -36,7 +34,7 @@ async fn main() -> ExitCode {
return executor::run_standalone(bin).await.unwrap(); return executor::run_standalone(bin).await.unwrap();
} }
match Cli::parse().run().await { match Cli::new().run().await {
Ok(code) => code, Ok(code) => code,
Err(err) => { Err(err) => {
eprintln!( eprintln!(