diff --git a/README.md b/README.md index 2085e04..679b5a9 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ asset_dir = "test/" write_dir = "output/" typescript = true luau = true +style = "flat" [creator] type = "user" @@ -61,6 +62,8 @@ id = 9670971 - Generate a Typescript definition file. - `luau`: boolean (optional) - Use the `luau` file extension. +- `style`: string (optional) + - The code-generation style to use. Defaults to `flat`. - `output_name`: string (optional) - The name for the generated files. Defaults to `assets`. - `existing`: map (optional) diff --git a/src/codegen.rs b/src/codegen/flat.rs similarity index 56% rename from src/codegen.rs rename to src/codegen/flat.rs index 86a2c4d..84550d7 100644 --- a/src/codegen.rs +++ b/src/codegen/flat.rs @@ -47,46 +47,3 @@ pub fn generate_ts( output_dir, interface, output_dir )) } - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use crate::{FileEntry, LockFile}; - - fn test_lockfile() -> LockFile { - let mut entries = BTreeMap::new(); - entries.insert( - "assets/foo.png".to_string(), - FileEntry { - asset_id: 1, - hash: "a".to_string(), - }, - ); - entries.insert( - "assets/bar/baz.png".to_string(), - FileEntry { - asset_id: 2, - hash: "b".to_string(), - }, - ); - - LockFile { entries } - } - - #[test] - fn generate_lua() { - let lockfile = test_lockfile(); - - let lua = super::generate_lua(&lockfile, "assets").unwrap(); - assert_eq!(lua, "return {\n\t[\"/bar/baz.png\"] = \"rbxassetid://2\",\n\t[\"/foo.png\"] = \"rbxassetid://1\"\n}"); - } - - #[test] - fn generate_ts() { - let lockfile = test_lockfile(); - - let ts = super::generate_ts(&lockfile, "assets", "assets").unwrap(); - assert_eq!(ts, "declare const assets: {\n\t\"/bar/baz.png\": string,\n\t\"/foo.png\": string\n}\nexport = assets"); - } -} diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs new file mode 100644 index 0000000..98db9b3 --- /dev/null +++ b/src/codegen/mod.rs @@ -0,0 +1,89 @@ +use crate::{config::StyleType, LockFile}; + +mod flat; +mod nested; + +pub fn generate_lua( + lockfile: &LockFile, + strip_dir: &str, + style: &StyleType, +) -> anyhow::Result { + match style { + StyleType::Flat => flat::generate_lua(lockfile, strip_dir), + StyleType::Nested => nested::generate_lua(lockfile, strip_dir), + } +} + +pub fn generate_ts( + lockfile: &LockFile, + strip_dir: &str, + output_dir: &str, + style: &StyleType, +) -> anyhow::Result { + match style { + StyleType::Flat => flat::generate_ts(lockfile, strip_dir, output_dir), + StyleType::Nested => nested::generate_ts(lockfile, strip_dir, output_dir), + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::{FileEntry, LockFile}; + + fn test_lockfile() -> LockFile { + let mut entries = BTreeMap::new(); + entries.insert( + "assets/foo.png".to_string(), + FileEntry { + asset_id: 1, + hash: "a".to_string(), + }, + ); + entries.insert( + "assets/bar/baz.png".to_string(), + FileEntry { + asset_id: 2, + hash: "b".to_string(), + }, + ); + LockFile { entries } + } + + #[test] + fn generate_lua() { + let lockfile = test_lockfile(); + + let lua = super::flat::generate_lua(&lockfile, "assets").unwrap(); + assert_eq!(lua, "return {\n\t[\"/bar/baz.png\"] = \"rbxassetid://2\",\n\t[\"/foo.png\"] = \"rbxassetid://1\"\n}"); + } + + #[test] + fn generate_ts() { + let lockfile = test_lockfile(); + + let ts = super::flat::generate_ts(&lockfile, "assets", "assets").unwrap(); + assert_eq!(ts, "declare const assets: {\n\t\"/bar/baz.png\": string,\n\t\"/foo.png\": string\n}\nexport = assets"); + } + + #[test] + fn generate_lua_nested() { + let lockfile = test_lockfile(); + + let lua = super::nested::generate_lua(&lockfile, "assets").unwrap(); + assert_eq!( + lua, + "return {\n bar = {\n [\"baz.png\"] = \"rbxassetid://2\",\n },\n [\"foo.png\"] = \"rbxassetid://1\",\n}"); + } + + #[test] + fn generate_ts_nested() { + let lockfile = test_lockfile(); + + let ts = super::nested::generate_ts(&lockfile, "assets", "assets").unwrap(); + assert_eq!( + ts, + "declare const assets: {\n bar: {\n \"baz.png\": \"rbxassetid://2\",\n },\n \"foo.png\": \"rbxassetid://1\",\n}\nexport = assets"); + } +} diff --git a/src/codegen/nested/ast.rs b/src/codegen/nested/ast.rs new file mode 100644 index 0000000..255f00f --- /dev/null +++ b/src/codegen/nested/ast.rs @@ -0,0 +1,233 @@ +use std::fmt::{self, Write}; + +macro_rules! proxy_display { + ( $target: ty ) => { + impl fmt::Display for $target { + fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result { + let mut stream = AstStream::new(output, &self.1); + AstFormat::fmt_ast(self, &mut stream) + } + } + }; +} + +trait AstFormat { + fn fmt_ast(&self, output: &mut AstStream) -> fmt::Result; + fn fmt_key(&self, output: &mut AstStream<'_, '_>) -> fmt::Result { + write!(output, "[")?; + self.fmt_ast(output)?; + write!(output, "]") + } +} + +#[derive(Debug)] +pub(crate) enum AstTarget { + Lua, + Typescript { output_dir: String }, +} + +pub(crate) struct AstStream<'a, 'b> { + number_of_spaces: usize, + indents: usize, + is_start_of_line: bool, + writer: &'a mut (dyn Write), + target: &'b AstTarget, +} + +impl<'a, 'b> AstStream<'a, 'b> { + pub fn new(writer: &'a mut (dyn fmt::Write + 'a), target: &'b AstTarget) -> Self { + Self { + number_of_spaces: 4, + indents: 0, + is_start_of_line: true, + writer, + target, + } + } + + fn indent(&mut self) { + self.indents += 1 + } + + fn unindent(&mut self) { + if self.indents > 0 { + self.indents -= 1 + } + } + + fn begin_line(&mut self) -> fmt::Result { + self.is_start_of_line = true; + self.writer.write_char('\n') + } +} + +impl Write for AstStream<'_, '_> { + fn write_str(&mut self, value: &str) -> fmt::Result { + let mut is_first_line = true; + + for line in value.split('\n') { + if is_first_line { + is_first_line = false; + } else { + self.begin_line()?; + } + + if !line.is_empty() { + if self.is_start_of_line { + self.is_start_of_line = false; + self.writer.write_str(&format!( + "{: >1$}", + "", + self.number_of_spaces * self.indents + ))?; + } + + self.writer.write_str(line)?; + } + } + + Ok(()) + } +} + +proxy_display!(ReturnStatement); + +#[derive(Debug)] +pub(crate) struct ReturnStatement(pub Expression, pub AstTarget); + +impl AstFormat for ReturnStatement { + fn fmt_ast(&self, output: &mut AstStream) -> fmt::Result { + match output.target { + AstTarget::Lua => { + write!(output, "return ") + } + AstTarget::Typescript { output_dir } => { + write!(output, "declare const {output_dir}: ") + } + }?; + let result = self.0.fmt_ast(output); + if let AstTarget::Typescript { output_dir } = output.target { + write!(output, "\nexport = {output_dir}")? + } + result + } +} + +#[derive(Debug)] +pub(crate) enum Expression { + String(String), + Table(Table), +} + +impl Expression { + pub fn table(expressions: Vec<(Expression, Expression)>) -> Self { + Self::Table(Table { expressions }) + } +} + +impl AstFormat for Expression { + fn fmt_ast(&self, output: &mut AstStream) -> fmt::Result { + match self { + Self::Table(val) => val.fmt_ast(output), + Self::String(val) => val.fmt_ast(output), + } + } + + fn fmt_key(&self, output: &mut AstStream<'_, '_>) -> fmt::Result { + match self { + Self::Table(val) => val.fmt_key(output), + Self::String(val) => val.fmt_key(output), + } + } +} + +#[derive(Debug)] +pub(crate) struct Table { + pub expressions: Vec<(Expression, Expression)>, +} + +impl AstFormat for Table { + fn fmt_ast(&self, output: &mut AstStream<'_, '_>) -> fmt::Result { + let assignment = match output.target { + AstTarget::Lua => " = ", + AstTarget::Typescript { .. } => ": ", + }; + + writeln!(output, "{{")?; + output.indent(); + + for (key, value) in &self.expressions { + key.fmt_key(output)?; + write!(output, "{assignment}")?; + value.fmt_ast(output)?; + writeln!(output, ",")?; + } + + output.unindent(); + write!(output, "}}") + } +} + +impl AstFormat for String { + fn fmt_ast(&self, output: &mut AstStream) -> fmt::Result { + write!(output, "\"{}\"", self) + } + + fn fmt_key(&self, output: &mut AstStream<'_, '_>) -> fmt::Result { + if is_valid_identifier(self) { + write!(output, "{}", self) + } else { + match output.target { + AstTarget::Lua => write!(output, "[\"{}\"]", self), + AstTarget::Typescript { .. } => write!(output, "\"{}\"", self), + } + } + } +} + +impl From for Expression { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From<&'_ String> for Expression { + fn from(value: &String) -> Self { + Self::String(value.clone()) + } +} + +impl From<&'_ str> for Expression { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +impl From for Expression { + fn from(value: Table) -> Self { + Self::Table(value) + } +} + +fn is_valid_ident_char_start(value: char) -> bool { + value.is_ascii_alphabetic() || value == '_' +} + +fn is_valid_ident_char(value: char) -> bool { + value.is_ascii_alphanumeric() || value == '_' +} + +fn is_valid_identifier(value: &str) -> bool { + let mut chars = value.chars(); + + match chars.next() { + Some(first) => { + if !is_valid_ident_char_start(first) { + return false; + } + } + None => return false, + } + + chars.all(is_valid_ident_char) +} diff --git a/src/codegen/nested/mod.rs b/src/codegen/nested/mod.rs new file mode 100644 index 0000000..6d0c957 --- /dev/null +++ b/src/codegen/nested/mod.rs @@ -0,0 +1,115 @@ +mod ast; + +use anyhow::{bail, Context}; +use std::collections::BTreeMap; +use std::{path::Component as PathComponent, path::Path}; + +use crate::LockFile; +use ast::{AstTarget, Expression, ReturnStatement}; +use std::fmt::Write; + +use self::types::NestedTable; + +pub(crate) mod types { + use std::collections::BTreeMap; + + use crate::FileEntry; + + #[derive(Debug)] + pub enum NestedTable<'a> { + Folder(BTreeMap>), + File(&'a FileEntry), + } +} + +/// Recursively builds a **[`NestedTable`]** (normally a root table) into expressions that can be evaluated in the `ast`. +fn build_table(entry: &NestedTable) -> Expression { + match entry { + NestedTable::Folder(entries) => Expression::table( + entries + .iter() + .map(|(component, entry)| (component.into(), build_table(entry))) + .collect(), + ), + NestedTable::File(file) => Expression::String(format!("rbxassetid://{}", file.asset_id)), + } +} + +/** + * Creates expressions based on the **[`LockFile`]**, and will strip the prefix + * and iterate through every file entry and build a table for code generation. +*/ +fn generate_expressions(lockfile: &LockFile, strip_dir: &str) -> anyhow::Result { + let mut root: BTreeMap> = BTreeMap::new(); + + for (file_path, file_entry) in lockfile.entries.iter() { + let mut components = vec![]; + let path = Path::new(file_path) + .strip_prefix(strip_dir) + .context("Failed to strip directory prefix")?; + + for component in path.components() { + match component { + PathComponent::RootDir | PathComponent::Prefix(..) | PathComponent::Normal(..) => { + components.push( + component + .as_os_str() + .to_str() + .context("Failed to resolve path component")?, + ) + } + PathComponent::ParentDir => { + if components.pop().is_none() { + bail!("Failed to resolve parent directory") + } + } + _ => {} + } + } + + let mut current_directory = &mut root; + for (index, &component) in components.iter().enumerate() { + // last component is assumed to be a file. + if index == components.len() - 1 { + if current_directory.get_mut(component).is_none() { + current_directory.insert(component.to_owned(), NestedTable::File(file_entry)); + }; + } else if let NestedTable::Folder(entries) = current_directory + .entry(component.to_owned()) + .or_insert_with(|| NestedTable::Folder(BTreeMap::new())) + { + current_directory = entries; + } else { + unreachable!() + } + } + } + + Ok(build_table(&NestedTable::Folder(root))) +} + +pub fn generate_lua(lockfile: &LockFile, strip_dir: &str) -> anyhow::Result { + generate_code( + generate_expressions(lockfile, strip_dir).context("Failed to create nested expressions")?, + AstTarget::Lua, + ) +} + +pub fn generate_ts( + lockfile: &LockFile, + strip_dir: &str, + output_dir: &str, +) -> anyhow::Result { + generate_code( + generate_expressions(lockfile, strip_dir).context("Failed to create nested expressions")?, + AstTarget::Typescript { + output_dir: output_dir.to_owned(), + }, + ) +} + +fn generate_code(expression: Expression, target: AstTarget) -> anyhow::Result { + let mut buffer = String::new(); + write!(buffer, "{}", ReturnStatement(expression, target))?; + Ok(buffer) +} diff --git a/src/config.rs b/src/config.rs index 414b586..e8d8abd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,13 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum StyleType { + Flat, + Nested, +} + #[derive(Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum CreatorType { @@ -29,6 +36,7 @@ pub struct Config { pub output_name: Option, pub typescript: Option, pub luau: Option, + pub style: Option, pub existing: Option>, } diff --git a/src/main.rs b/src/main.rs index 79415fa..b2c2b0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -178,7 +178,7 @@ async fn main() -> anyhow::Result<()> { })); let lua_filename = format!("{}.{}", state.output_name, state.lua_extension); - let lua_output = generate_lua(&state.new_lockfile, asset_dir_str); + let lua_output = generate_lua(&state.new_lockfile, asset_dir_str, &state.style); write(Path::new(&state.write_dir).join(lua_filename), lua_output?) .await @@ -190,6 +190,7 @@ async fn main() -> anyhow::Result<()> { &state.new_lockfile, asset_dir_str, state.output_name.as_str(), + &state.style, ); write(Path::new(&state.write_dir).join(ts_filename), ts_output?) diff --git a/src/state.rs b/src/state.rs index 254e7b6..8ec97e0 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,6 @@ use crate::{ args::Args, - config::{Config, CreatorType, ExistingAsset}, + config::{Config, CreatorType, ExistingAsset, StyleType}, LockFile, }; use anyhow::Context; @@ -35,6 +35,8 @@ pub struct State { pub output_name: String, pub lua_extension: String, + pub style: StyleType, + pub font_db: Database, pub existing_lockfile: LockFile, @@ -72,6 +74,7 @@ impl State { .to_string(); let typescript = config.typescript.unwrap_or(false); + let style = config.style.unwrap_or(StyleType::Flat); let lua_extension = String::from(if config.luau.unwrap_or(false) { "luau" @@ -101,6 +104,7 @@ impl State { typescript, output_name, lua_extension, + style, font_db, existing_lockfile, new_lockfile,